commit 22f02a9d32f4613a2464c10e091ce4e2346aaf52 Author: Levi Neuwirth Date: Sat Aug 29 17:25:51 2026 +0200 levineuwirth.org A Hakyll static site: essays, research preprints, music catalog and score reader, photography pipeline, commonplace book, and a semantic-similarity index over the whole corpus. History restarted at this commit. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9912f68 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Copy this file to .env and fill in the values, then run: +# chmod 600 .env +# so other local users cannot read your VPS path / token. .env is +# gitignored — never commit it. The auto-snapshot in `make build` +# uses an explicit pathspec under content/ to keep stray .env files +# out of the snapshot, but **/.env is also in .gitignore as a backstop. +# +# `make deploy` pushes to GitHub first, then rsyncs the built _site/ +# to the VPS. The Makefile aborts with a clear error if any of +# VPS_USER / VPS_HOST / VPS_PATH is unset, if VPS_PATH points at an +# obviously dangerous parent directory, or if _site/index.html does +# not exist (a sign of a broken build). + +# --- VPS deployment target ------------------------------------------------- +# SSH user on the deployment VPS. +VPS_USER= +# Hostname or IP of the deployment VPS. +VPS_HOST= +# Absolute path to the document root on the VPS (e.g. /var/www/levineuwirth.org). +VPS_PATH= + +# --- GitHub mirror push ---------------------------------------------------- +# A GitHub fine-grained personal access token with Contents: read+write +# on the levineuwirth.org repository. Currently optional — `make deploy` +# uses your local git credential helper for `git push`, so this is only +# needed if you wire token-based push into a credential helper yourself. +# Generate at: https://github.com/settings/personal-access-tokens/new +GITHUB_TOKEN= + +# The GitHub repository in owner/repo format. +GITHUB_REPO=levineuwirth/levineuwirth.org diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b17bc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,173 @@ +dist-newstyle/ +_site/ +_cache/ +.DS_Store +.env +# Defense-in-depth: the auto-snapshot in `make build` stages content/ +# wholesale. These patterns prevent any stray credential-shaped file +# (dropped accidentally during writing) from being staged + pushed. +# To intentionally commit one of these (rare), use `git add -f`. +**/.env +**/.env.* +**/*.env +# .env.example is documentation (tracked), not a credential file — the +# patterns above would otherwise shadow it for status/add purposes. +!.env.example +**/*.key +**/*.pem +**/*.p12 +**/*.pfx +**/id_rsa* +**/id_dsa* +**/id_ecdsa* +**/id_ed25519* +**/.netrc +**/.npmrc +**/.pypirc +**/credentials +**/credentials.json +**/credentials.yaml +**/credentials.yml + +# Editor backup/swap files +*~ +*.swp +*.swo + +# Python bytecode caches +**/__pycache__/ +*.pyc +*.pyo + +# LaTeX build artifacts (sitewide — covers paper/, any future TeX sources) +*.aux +*.bbl +*.blg +*.brf +*.fdb_latexmk +*.fls +*.glo +*.gls +*.idx +*.ilg +*.ind +*.lof +*.lot +*.nav +*.out +*.snm +*.synctex.gz +*.toc +*.vrb +# PGF/TikZ scratch outputs +pgftest*.pdf +pgftest*.log +pgftest*.aux +# LaTeX run logs (scoped to paper/ — bare *.log would be too broad sitewide) +paper/*.log + +# Data files that are generated at build time (not version-controlled) +data/embeddings.json +data/similar-links.json +data/backlinks.json +data/build-stats.json +data/build-start.txt +data/build-stamp.txt +data/last-build-seconds.txt +data/semantic-index.bin +data/semantic-meta.json +# Both embed caches (pages + paragraphs); the trailing glob also +# catches interrupted-write debris (.tmp / .tmp.npz) +data/embed-cache-* + +# Archive: generated text + its staleness stamp (recreated from the +# committed artifact on every build — deterministic, so committing them is +# churn). archive/**/PROVENANCE.json is deliberately NOT ignored — it is +# the committed, immutable record of each archival event. +archive/**/*.txt +archive/**/*.txt.sha256 +data/archive-index.json +data/archive-state.json + +# IGNORE.txt is for the local build and need not be synced. +IGNORE.txt + +# Working notes / planning docs at the repo root (not site content). +content/drafts/ +checklist.md +FORGEJO-MIGRATION.md + +# CV/résumé build pipeline (YAML → Jinja → xelatex). The canonical PDFs +# live under static/ and ship with the site. The pipeline *source* is +# tracked: data/ is the single source of truth for both documents (and is +# read by the site build to render the vita page), and templates/ + +# build.py are what regenerate them. Only artifacts stay local. +yaml-source/build/ +yaml-source/output/ +yaml-source/data/*~ +# Handover bundle (archived locally for reference; not part of the site). +levineuwirth_handover.zip + +# Model files for client-side semantic search (~22 MB binary artifacts). +# Download with: make download-model +static/models/ + +# Vendored PDF.js viewer (~18 MB uncompressed, pinned in tools/download-pdfjs.sh). +# 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 + +# 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 delivery images. +# +# PHOTOGRAPHY.md originally kept web-optimized JPEGs in the repo and excluded +# only the originals. That reversed in August 2026: 64 frames added 45 MB +# against a 28 MB .git, and the trajectory was clear. They are derived +# artifacts — tools/import-photo.sh regenerates them from the originals — so +# git carries the .md entries and the deployed site carries the pixels, via +# `make deploy`'s rsync of _site/. +# +# Consequence, stated plainly: a fresh clone builds with missing images, and +# the originals under ~/Photos are now load-bearing for the whole section. +# (.webp companions are already covered by content/**/*.webp above.) +content/photography/**/*.jpg +content/photography/**/*.jpeg +content/photography/**/*.png + +# Defense-in-depth — refuse to commit RAW or oversize originals, which were +# never meant to be here even when the delivery JPEGs were. 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 +# Claude Code local settings — machine-specific, and this repo mirrors +# to a public GitHub remote. +.claude/settings.local.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/ARCHIVE.md b/ARCHIVE.md new file mode 100644 index 0000000..dcde6d6 --- /dev/null +++ b/ARCHIVE.md @@ -0,0 +1,1705 @@ +# Archive + +Design and implementation plan for the link-archiving system of levineuwirth.org. +This is the source of truth for how external references are preserved, hosted, +displayed, and indexed. It sits alongside `WRITING.md`, `PHOTOGRAPHY.md`, +`HOMEPAGE.md`, and `MARKS.md` as authoritative spec. + +## Status + +**Reviewed and ratified 2026-05-21, with revisions.** The original draft was +reviewed against the live site over three rounds; the decisions below +incorporate every round of deltas and are now locked. + +**Phase 1 complete (2026-05-22).** PDF entries: `archive/manifest.yaml`, +`tools/archive.py` (`fetch` + `gc`), `build/Archive.hs`, the four templates, +and the Makefile / `head.html` / `.gitignore` wiring are built and verified — +`/archive/` and `/archive/nist-fips-203/` render. + +**Phase 2 complete (2026-05-22).** HTML snapshots: the pinned `monolith` +binary is vendored at `tools/bin/monolith`, `archive.py fetch` snapshots HTML +pages (CSP injected, text extracted, quality classified), and `archive.html` +renders them in a sandboxed iframe — `/archive/djb-aes-speed/` renders. The +cross-browser CSP check and the per-snapshot review remain author-gated by +design. + +**Archive pages styled (2026-05-22).** `static/css/archive.css` gives the +index and entry pages a framed treatment (banner callout, provenance panel, +artifact viewer); the PDF embed was changed to the raw `document.pdf` (browser- +native viewer), symmetric with HTML snapshots — see the Display — PDF decision. + +**Phase 3 complete (2026-05-22).** Link annotation + Wayback: `Filters/Archive.hs` +appends an archive affordance to body links whose target is archived; +`archive.py wayback` (+ `make archive-wayback`) backfills Wayback captures; +`visibility: private` keeps an entry's artifact in-repo but undeployed. +Bibliography annotation is documented as a `Citations.hs` follow-up +*(implemented 2026-06-10 — see the Bibliography note under Link annotation)*. + +**Phase 4 complete (2026-05-22).** Backlinks + similar-pages: `Backlinks.hs` +keeps archived external links and canonicalises them to their `/archive//` +page, so an archived work lists every essay that cites it under "Referenced by" +(grouped by the fragment each citation targets); `archive.html` also carries a +"Related" block from the `embed.py` similarity corpus, which now indexes archive +pages and excludes the `/archive/` index. + +**Phase 5 complete (2026-05-22).** Link-rot detection: `tools/archive.py check` +(+ `make archive-check`) HEAD/GET-probes every manifest URL and updates the +gitignored `data/archive-state.json` under asymmetric hysteresis (`rotted` +needs 3 fails over ≥14 days; a single success recovers immediately). +`Filters.Archive` flips a body link to the archive when its target is `rotted`; +each archive page surfaces its link status (provenance row, header note, +Pagefind `status` filter tag); `/archive/` flags rotted entries; `/build/` +gains a "Link archive" telemetry section. The search-UI `status` filter wiring +in `search-filters.js` was deliberately partial in this phase *(completed +2026-06-10 — see the Phase 5 Met note)*. + +**All five phases done.** Refinements next; see the Phase 5 Met note for the +documented deferrals (search-UI status filter, bibliography annotation from +Phase 3, and pull-from-Wayback at fetch time — all three implemented +2026-06-10). + +**Refinements (2026-05-22).** A code-review pass found and fixed several +correctness and posture issues across the system: + +- **Missing committed artifact no longer re-fetches silently.** `cmd_fetch` + used to skip its SHA guard when the artifact was absent and then download + fresh bytes whose hash differed from the recorded `sha256` — replacing the + recorded snapshot without surfacing it. The guard now also halts when + `PROVENANCE.json` is present but the artifact is missing, requiring the + author to restore the committed bytes before rebuilding. +- **`archive/removed.yaml` is now enforced in `fetch` and `check`.** It was + only read by `gc`. A removed URL re-added to the manifest now halts + `cmd_fetch` loudly; `cmd_check` skips removed URLs so the link-rot + scanner does not keep probing a deliberate takedown. +- **SHA verification closed the `.venv`-bypass hole.** The original + decision relied solely on `archive.py fetch` re-hashing, but that step is + `.venv`-gated — a contributor or deploy host without `.venv`, or a direct + `cabal run site -- build`, would publish a tampered artifact unchecked. + `build/Archive.hs` now also re-hashes via `sha256sum` from + `loadArchiveEntries` and halts the build on a mismatch, so the guarantee + holds independent of the Python step. +- **Raw artifacts are no longer publicly indexable.** Pass 1 added a + `robots.txt` `Disallow: /archive/`, which pass 2 then reverted (see + below — it was counter-productive). Pass 1's other change — injecting + `` into every new HTML + snapshot alongside the archive CSP — remains in place; the + deploy-side header for raw PDFs landed in pass 2 as `nginx/archive.conf`. +- **The documented `archive.py refresh {slug}` subcommand is implemented.** + It clears the slug's directory, re-fetches via `cmd_fetch`, and records + the prior `sha256` as `previous-sha256` in the new `PROVENANCE.json`. The + URL-changed error message in `cmd_fetch` now points at it instead of + asking the author to delete the directory by hand. +- **`url_aliases` widened** to the design's full equivalent-URL set: + tracking-parameter stripping (`utm_*`, `fbclid`, `gclid`, `mc_*`, `ref`, + `igshid`, `_hsenc`, `_hsmi`, `mkt_tok`) and arXiv abs / pdf / versioned / + `.pdf` form expansion. Phase 1 had deliberately kept these as a Phase 4 + deferral, but Phase 4 missed the follow-through. +- **`X-Robots-Tag: noarchive` is now honoured on both HEAD and GET.** Some + servers omit the header on HEAD but emit it on GET; HTML capture now + aborts if either response carries the directive. + +Three smaller items remain documented and deferred: + +- **Archive tags joining the site-wide tag indexes.** `manifest.yaml`'s + `tags:` is authored but `Tags.hs`/`Patterns.tagIndexable` does not yet + ingest archive entries — it needs a Tags.hs-side integration with its + own design pass (archive pages aren't `match`ed Hakyll items in the + normal way). +- **`archive.py suggest`** (bibliography discovery — diff `.bib` URLs + against the manifest) is documented but not implemented. *(Implemented + 2026-06-10 — see the status note below.)* +- **The controlled-host end-to-end link-rot test** (reserve + `archive-test.levineuwirth.org`, run it through a 14-day-spanning fail + streak, watch the flip happen) is inherently a multi-week real-world + verification the author runs; the hysteresis logic is unit-tested + deterministically and the rendering side is verified by a hand-crafted + `rotted` state file. + +**Refinements pass 2 (2026-05-23).** A second code-review pass surfaced +correctness gaps the first pass missed: + +- **`refresh` is now atomic.** It used to delete the slug directory and + then call `cmd_fetch`; a failed re-fetch left the entry with no + snapshot at all, while `refresh` returned 0 (because `cmd_fetch` + reports per-entry skips, not a process failure). The slug directory is + now *renamed* to a `.refresh-backup` sibling; success removes the + backup, any failure restores it. Verified by hiding the `monolith` + binary and confirming the prior snapshot survives intact. +- **Invalid `visibility` values fail closed.** The `ManifestEntry` parser + used to accept any string and only treat the exact `"private"` as + private — a typo like `privte` would publish a work the author intended + to keep offline. The parser now rejects any value other than `public` + or `private`, and `readManifest` halts the build on any parse error of + a present file (instead of warning + returning an empty list — that + silent-skip was for `file absent`, not `file present but corrupt`). +- **Lookup-side URL normalisation.** Alias generation alone cannot cover + unbounded forms (arXiv versions, arbitrary tracking-parameter + combinations). `ArchiveIndex` now normalises both index keys and + lookup inputs through the same `normalizeUrl` (drop fragment, strip + tracking, fold http→https, arXiv-canonicalise, trim trailing slash). + Verified: `https://cr.yp.to/aes-speed.html`, + `https://cr.yp.to/aes-speed.html?utm_source=mail`, and + `http://cr.yp.to/aes-speed.html/` all match the same archived entry. +- **Raw-artifact indexing posture corrected.** The Phase-5 `robots.txt` + `Disallow: /archive/` was counter-productive: a URL blocked by + robots.txt can still appear in results when externally linked, and the + Disallow also prevents compliant crawlers from reading the wrapper + pages' ``. The Disallow is reverted; a new + `nginx/archive.conf` snippet emits `X-Robots-Tag: noindex, noarchive` + for the whole `/archive/` tree, which crawlers honour for any resource + (HTML and PDF alike). The deploy vhost should `include + snippets/archive.conf`. +- **`cmd_wayback` skips `removed.yaml`.** The eviction procedure says + record in `removed.yaml` *before* dropping the manifest line; `fetch` + and `check` now honour that ordering, but `wayback` did not. A removed + entry whose manifest line was still in place could be submitted to a + third-party archive after a takedown was recorded. +- **The shipped HTML snapshot was refreshed in the working tree** so it + carries the noarchive meta the Phase-5 inject promises. `archive.py + refresh djb-aes-speed` re-fetched cr.yp.to, applied + `inject_archive_metas`, and recorded the prior SHA as `previous-sha256`. + `archive/djb-aes-speed/{snapshot.html, PROVENANCE.json}` now reflect the + new bytes; matching SHA is verified by `Archive.hs`. *Caveat surfaced + in pass 3 (below): the prior snapshot was not committed at the moment + of this refresh, so its bytes are no longer recoverable via `git log + -S`. A pass-3 fix to `refresh` now refuses to replace an uncommitted + prior, but the historical artifact survives — `previous-sha256` + records a hash whose bytes this working tree cannot reproduce.* +- **The URL-changed error in `cmd_fetch`** now points at + `archive.py refresh {slug}` instead of asking the author to delete the + directory by hand. + +Tag integration remains the one deferred refinement (it needs a Tags.hs +design pass). + +**Refinements pass 3 (2026-05-23).** A third audit surfaced gaps the pass-2 +fixes didn't fully close: + +- **`refresh` refuses to replace an uncommitted prior snapshot.** Pass 2 + preserved a prior snapshot through *failed* re-fetches, but a *successful* + one happily discarded uncommitted bytes — `previous-sha256` then pointed + at a hash no `git log -S` could recover. Pass 3 shells out to `git + ls-files` + `git diff --quiet HEAD` and refuses the refresh unless both + the prior PROVENANCE.json and its artifact are tracked and clean. +- **`refresh` is atomic across *every* exit path.** Pass 2 handled the + ordinary `cmd_fetch returns 0 but the artifact wasn't produced` case but + not fatal `sys.exit`s (e.g. a `removed.yaml` conflict halting `cmd_fetch` + mid-refresh) nor mid-refresh exceptions, and it never rolled back the + `data/archive-index.json` rewrite. The work is now wrapped in + `try/finally` that restores both the slug directory and the index on any + exit path — normal failure, `SystemExit`, `KeyboardInterrupt`, or + exception. +- **Removal enforcement now uses the same equivalence as link matching.** + Pass 2 introduced `normalizeUrl` for incoming citations but compared + removals as literal URL strings, so a tracking-laden manifest URL could + bypass a takedown. Python gains `normalize_url` mirroring the Haskell + helper, and `fetch` / `check` / `wayback` compare normalised forms. + `cmd_fetch` additionally rejects two manifest entries whose canonical + forms collide — that would otherwise route both under one slug. +- **`fetch_html` honours `X-Robots-Tag: noarchive` on the captured GET too.** + Pass 1 added HEAD + ranged-GET probes, but a server can emit the header + only on the full document response. The Python tool now downloads that + response itself, checks its header and body directives, then passes those + exact bytes to `monolith --base-url ... -` so the saved snapshot is not + obtained through a second unobservable document request. +- **`nginx/archive.conf` is wired into the deploy template** and + re-`include`s `security-headers.conf` inside its `location` block. + `nginx/vhost.conf.example` now includes `archive.conf`; the snippet + itself re-emits the baseline headers because nginx's `add_header` chain + is inherited from a parent only when the current context declares *no* + `add_header` directives — without the re-include, /archive/ would lose + HSTS, CSP, etc. +- **Contract doc cleanups.** The Phase-5 paragraph claiming `robots.txt` + disallows `/archive/` is reworded to acknowledge the pass-2 reversal; + the Phase-1 checkbox claiming `Archive.hs` does not re-hash is updated + to point at `verifyArtifactSha`; the pass-2 note about the refreshed + djb snapshot now carries the caveat that its prior bytes were + uncommitted and are therefore unrecoverable. + +The historical `previous-sha256` value in `archive/djb-aes-speed/ +PROVENANCE.json` is left in place: it is a truthful record that *a* prior +snapshot existed and what its hash was. It just is not recoverable from +git in this working tree — the pass-3 `refresh` precondition exists so +that property is never broken again. + +**Refinements pass 4 (2026-05-23).** A fourth audit completed the +failure-closed paths: + +- **Direct Hakyll builds now enforce removals and missing-artifact failures.** + `Archive.hs` reads `removed.yaml`, rejects normalized manifest conflicts + and duplicate archive targets, and aborts if provenance exists without its + artifact. `ArchiveIndex.hs` filters the generated index through the live + manifest minus normalized removals, so a stale ignored index cannot retain + archive affordances after a takedown when `archive.py` was skipped. +- **`refresh` verifies the prior bytes before replacing them.** A prior + snapshot must now be present, tracked, clean, and match its recorded + SHA-256 before its hash can be written into `previous-sha256`. +- **Failed refresh restores an originally-absent index state.** If + `data/archive-index.json` did not exist before a failed refresh, any index + created by the attempted fetch is deleted during rollback. + +The genuinely-open questions that remain are collected at the end — the list is +short. + +**`archive.py suggest` implemented (2026-06-10).** The bibliography-discovery +subcommand from the design is built: `make archive-suggest` scans `data/*.bib` +for `url` / `doi` fields (per entry the `url` field wins; a DOI-only entry +resolves to `https://doi.org/{doi}`), diffs them against `manifest.yaml` and +`removed.yaml` under the same `normalize_url` equivalence the link-annotation +filter uses, and prints manifest-ready `- url:` lines, each preceded by +`# cited by {bibfile}:{key}` comments (a work cited from several papers prints +once, with every citer). Read-only and offline: it never edits the manifest +and performs no network I/O. Known equivalence gap, surfaced by the first real +run: a work archived under its landing URL but cited via its DOI (e.g. +FIPS 203 — archived as the `nvlpubs.nist.gov` PDF, cited as +`doi.org/10.6028/NIST.FIPS.203`) is re-suggested, because nothing offline can +equate the two forms. That is the same gap that denies those bibliography +links an archive affordance, so the suggestion is honest — the fix, if wanted, +is alias-level (manifest or `url_aliases`), not suppression in `suggest`. + +**Manifest `aliases:` field implemented (2026-06-10).** The alias-level fix +above: an optional authored `aliases:` list per manifest entry for equivalent +URLs that normalisation cannot derive (the DOI ↔ landing-URL case). Aliases +are matching metadata, not identity — editing them rewrites the index on the +next fetch, no `refresh` involved. Enforcement mirrors canonical URLs on both +sides of the build: the `archive.py fetch` pre-scan and `Archive.hs`'s +`validateManifestEntries` each reject an alias that collides with another +entry (directly or via aliases) or that matches a `removed.yaml` takedown, +and `ArchiveIndex.flatIndex` additionally drops alias keys matching recorded +takedowns so a stale index cannot keep annotating a removed work. `suggest` +counts aliases as covered. Applied: `nist-fips-203` now carries +`https://doi.org/10.6028/NIST.FIPS.203`, so the simd paper's DOI citation +resolves to the archived PDF and `suggest` no longer re-suggests it. + +**Link-rot scan scheduled (2026-06-10).** The scan now runs unattended: a +systemd user timer (`systemd/archive-check.{service,timer}`, symlink-installed +on the build machine) fires `make archive-check` daily. Going unattended +exposed a hysteresis hazard hand-run scans masked — a machine left offline +would record a `fail` against every entry, and three such scans spanning two +weeks would flip the whole archive to `rotted` — so `cmd_check` gained a +canary guard: `https://levineuwirth.org/` unreachable → scan inconclusive, +state untouched, exit 0. Details in the Phase 5 section. + +**Fetch-time Wayback fallback implemented (2026-06-10).** The design's +"original already dead at first fetch → pull the most recent existing Wayback +capture" promise is now real. `fetch_pdf`/`fetch_html` classify their +failures (`ok` / `dead` / `skip`), and only a *dead* original — not a +`noarchive` refusal, cap skip, or tooling failure — falls through to +`fetch_from_wayback`, which pulls the raw `id_` capture bytes through the +normal pipeline, honours a preserved `X-Archive-Orig-X-Robots-Tag: +noarchive`, re-detects the artifact type against the capture (the dead +original's Content-Type probe degrades to the html default), and records +`fetched-from` + `wayback` in `PROVENANCE.json`. `refresh` deliberately +excludes the fallback: a dead original fails the refresh and restores the +prior first-hand snapshot. Mechanics in Wayback Machine — non-blocking. + +**Bibliography annotation implemented (2026-06-10).** The Phase 3 follow-up +is done: bibliography entries now carry the same archive affordance (and +rotted-link flip) as body links. The gating CSL-URL check passed — +citeproc renders entry URLs as `Link` inlines — so `Filters.Archive` exports +`annotateBlock` and `Citations.hs` applies it after `enhanceEntry` in both +`renderBibDiv` (essay bibliographies) and `renderBibliographyHtml` +(`/bibliography/` pages). Verified on the rendered SIMD essay, including the +DOI-cited FIPS 203 entry resolving through its manifest alias. Details under +Link annotation — Bibliography. + +**Search-UI archive filter implemented (2026-06-10).** The last deferred +refinement with a code-shaped fix: the search page's filter panel gains an +"archive" mode (exclude / only) and a "link status" multi-select (live / +moved / rotted / error), backed by a new `data/archive-meta.json` emitted by +`Archive.hs` — the archive analogue of `epistemic-meta.json`. The collision +with the epistemic `status` filter is resolved by scoping (own state fields, +button classes, and labels), not renaming. Verified in headless Chrome. +Details in the Phase 5 Met note. + +--- + +## Motivation + +The site cites external work — papers, articles, blog posts, documentation. +Three things go wrong with a plain hyperlink over time: + +1. **Link rot.** The target moves, paywalls, or vanishes. A 2019 essay's + citations decay silently; nobody notices until a reader clicks. +2. **Content drift.** The target stays up but changes. The sentence you quoted + is no longer the sentence at that URL. +3. **Opacity to the site's own machinery.** An external link is invisible to + `Backlinks.hs` (`isPageLink` drops every `http(s)://` URL) and to + `embed.py` (it indexes only `_site/**/*.html`). The site knows nothing about + the things it most often points at. A paper cited by six essays has no page, + no backlinks list, no place in any "Related" set. + +The archive fixes all three by keeping a **local, hosted, immutable snapshot** +of each referenced work, giving it a stable URL on this domain, and making that +URL a first-class citizen of the existing backlinks and similar-pages systems. + +This is deliberately *not* a general web crawler. It archives a curated set: +the things this site references. The author adds a URL to a manifest; the build +does the rest. + +### Relationship to existing pieces + +| Existing piece | What it does | Why the archive is different | +|----------------|--------------|------------------------------| +| `static/papers/` | Hosts Levi's **own** typeset PDFs (`preprint:`, `{{pdf:}}`) | The archive holds **third-party** works. Distinct directory, distinct purpose. Never conflate the two. | +| nginx `popup-proxy.conf` | Caches **metadata** (title/abstract) from arXiv / archive.org / PubMed for hover previews | Caches structured metadata, not documents. A preview accelerator, not preservation. | +| `Backlinks.hs` | Inverts **internal** links into a "who links here" map | Indexes site content only; external URLs are dropped. The archive makes referenced works internal enough to index. | +| `embed.py` / `SimilarLinks.hs` | Semantic "Related" block from `_site/**/*.html` embeddings | Only sees site pages. Archived works become site pages, so they enter the embedding corpus for free. | + +--- + +## Goals + +- **Preservation.** Every referenced work the author chooses to archive has a + byte-for-byte local snapshot that survives the original going dark. +- **Stable hosting.** Each snapshot is reachable at a permanent + `/archive/{slug}/` URL on levineuwirth.org, rendered in site chrome. +- **Hyperlink-able.** Archive URLs are ordinary internal links: usable in + prose, wikilinks, citations, and `further-reading`. +- **Indexed.** Archived works appear in the **backlinks** ("Referenced by") and + **similar-pages** ("Related") systems exactly as native content does — and, + where the source structure allows, granularly by section. +- **Curated, low-friction.** Adding an archive is one line in one manifest. + Everything else — fetch, text extraction, page generation, indexing — is + automatic and build-time. +- **Static-friendly.** Every archive page renders at build time; JS is layered + on, never required. Matches the rest of the site's contract. +- **Honest.** Archive pages never impersonate the original. They are framed as + archived copies, link prominently to the source, are kept out of search + engines, and carry a real, advertised removal channel on every page. +- **Safe by default.** No build step ever deletes or overwrites a committed + artifact; destruction and replacement are always explicit, opt-in acts. + +--- + +## Decisions (locked) + +| Topic | Decision | Rationale | +|-------|----------|-----------| +| Trigger | Curated manifest, not auto-crawl | Archives what the site *references*, not the web. Legally and operationally sane. | +| Authored input | One hand-edited file: `archive/manifest.yaml` | One line per archived link. Mirrors `data/commonplace.yaml`'s authoring model. | +| Bibliography seeding | **Rejected** as auto-seeding. `make archive-suggest` prints a "cited but not archived" diff; the author copies lines by hand. | Keeps the manifest the *identity* of the archive, not a cache of the `.bib` files. | +| Per-entry provenance | `archive/{slug}/PROVENANCE.json`, committed — immutable for the current snapshot | An immutability claim that isn't in version control isn't immutable. | +| Mutable state | `data/archive-state.json`, gitignored — link-rot status only | Strict split: immutable facts committed, volatile status disposable. | +| Hakyll input | `data/archive-index.json` — `url` + aliases → slug, written by the tool | Minimal stable shape for the Haskell side; treated like `data/annotations.json`. | +| Missing-index behaviour | `Backlinks.hs` and `Filters/Archive.hs` silently no-op when `archive-index.json` is absent | Preserves the established `.venv`-gated silent-skip convention. The archive degrades to invisible, never to an error. | +| `fetch` idempotence | `fetch` is keyed on `(slug, url)` together; a slug whose recorded URL has changed is refused, not overwritten. `fetch` always rewrites `archive-index.json` to mirror the manifest. | A committed artifact is replaced only by an explicit `refresh`, never as a `fetch` side effect. | +| Artifact storage | `archive/{slug}/` at repo root, **committed to git** | A preservation guarantee that depends on an un-versioned store is weaker. Repo stays reproducible. | +| Per-artifact size cap | 25 MB; `archive.py fetch` warns and skips above it; `git add -f` to override deliberately | A 200 MB scan must never land in an auto-commit silently. | +| Storage migration | If `archive/` exceeds ~5 GB or doubles year-over-year, evaluate a separate archive repo / object store. **Never git LFS.** | LFS breaks `git clone → make build` reproducibility — a regression for a preservation system. | +| HTML snapshots | `monolith -j` → one self-contained HTML file; the pinned `monolith` binary is committed at `tools/bin/monolith` | Single static binary, no headless browser. Strips JS. Committing it (vs downloading) removes a network dependency and keeps the build reproducible from a bare clone. | +| PDF snapshots | Direct download via `requests` | Papers are usually clean PDF URLs (arXiv etc.). | +| Display — PDF | The raw `document.pdf` in an `" + ++ "" + +-- | Percent-encode characters that would break a query-string value. +-- Slashes are left unencoded so root-relative paths remain readable and +-- work correctly with PDF.js's internal fetch. @#@ is encoded for +-- defense-in-depth even though the directive parser already splits on it +-- before this function is called. +encodeQueryValue :: String -> String +encodeQueryValue = concatMap enc + where + enc ' ' = "%20" + enc '&' = "%26" + enc '?' = "%3F" + enc '+' = "%2B" + enc '"' = "%22" + enc '#' = "%23" + enc c = [c] + diff --git a/build/Filters/Images.hs b/build/Filters/Images.hs new file mode 100644 index 0000000..5efac59 --- /dev/null +++ b/build/Filters/Images.hs @@ -0,0 +1,394 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | 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 +-- @@ and the original format as the @@ fallback. When the +-- webp companion is absent (cwebp not installed, @convert-images.sh@ not +-- yet run, or a single file missed), the filter emits a plain @@ so +-- the image still renders. This matters because browsers do NOT fall back +-- from a 404'd @@ inside @@ to the nested @@ — the +-- source is selected up front and a broken one leaves the area blank. +-- +-- @tools/convert-images.sh@ produces the companion .webp files at build +-- time. When cwebp is not installed the script is a no-op, and this +-- filter degrades gracefully to plain @@. +-- +-- 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 qualified Utils as U + +-- | Apply image attribute injection and WebP wrapping to the entire document. +-- +-- @srcDir@ is the directory of the source Markdown file, used to resolve +-- 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 doc = do + doc' <- walkM (transformBlock srcDir) doc + walkM (transformInline srcDir) doc' + +-- --------------------------------------------------------------------------- +-- 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. + ils' <- mapM (wrapLinkedImg srcDir) ils + pure (Link lAttr ils' lTarget) +transformInline srcDir (Image attr alt target) = + renderImg srcDir attr alt target True +transformInline _ x = pure x + +wrapLinkedImg :: FilePath -> Inline -> IO Inline +wrapLinkedImg srcDir (Image iAttr alt iTarget) = + renderImg srcDir iAttr alt iTarget False +wrapLinkedImg _ x = pure x + +-- | Dispatch on image type: +-- * 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 = 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 +-- is where Hakyll's static-asset rule writes them from. Relative paths +-- resolve against the source file's directory, where Pandoc already +-- expects co-located assets to live. +webpPhysicalPath :: FilePath -> Text -> FilePath +webpPhysicalPath srcDir src = + let s = T.unpack src + physical = if "/" `isPrefixOf` s + then "static" ++ s + 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 -> Maybe (Int, Int) -> Text +renderPicture (ident, classes, kvs) alt (src, title) lightbox dims = + T.concat + [ "" + , "" + , "" + , "" + ] + 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, decoding, data-lightbox, + -- width, height), so they don't appear twice on the . + passedKvs = filter + (\(k, _) -> k `notElem` + [ "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 <> "\"" + +attrClasses :: [Text] -> Text +attrClasses [] = "" +attrClasses cs = " class=\"" <> T.intercalate " " (map esc cs) <> "\"" + +attrAlt :: [Inline] -> Text +attrAlt ils = let t = stringify ils + in if T.null t then "" else " alt=\"" <> esc t <> "\"" + +attrTitle :: Text -> Text +attrTitle t = if T.null t then "" else " title=\"" <> esc t <> "\"" + +renderKvs :: [(Text, Text)] -> Text +renderKvs = T.concat . map (\(k, v) -> " " <> k <> "=\"" <> esc v <> "\"") + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- | True for local (non-URL) images with a raster format we can convert. +isLocalRaster :: FilePath -> Bool +isLocalRaster src = not (isUrl src) && lowerExt src `elem` [".jpg", ".jpeg", ".png", ".gif"] + +isUrl :: String -> Bool +isUrl s = any (`isPrefixOf` s) ["http://", "https://", "//", "data:"] + +-- | Extension of a path, lowercased (e.g. ".JPG" → ".jpg"). +-- Returns the empty string for paths with no extension. +lowerExt :: FilePath -> String +lowerExt = map toLower . takeExtension + +-- | Prepend a key=value pair if not already present. +addAttr :: Text -> Text -> Attr -> Attr +addAttr k v (i, cs, kvs) + | any ((== k) . fst) kvs = (i, cs, kvs) + | otherwise = (i, cs, (k, v) : kvs) + +-- | Plain-text content of a list of inlines (for alt text). +stringify :: [Inline] -> Text +stringify = T.concat . map go + where + go (Str t) = t + go Space = " " + go SoftBreak = " " + go LineBreak = " " + go (Emph ils) = stringify ils + go (Strong ils) = stringify ils + go (Strikeout ils) = stringify ils + go (Superscript ils) = stringify ils + go (Subscript ils) = stringify ils + go (SmallCaps ils) = stringify ils + go (Underline ils) = stringify ils + go (Quoted _ ils) = stringify ils + go (Cite _ ils) = stringify ils + go (Code _ t) = t + go (Math _ t) = t + go (RawInline _ _) = "" + go (Link _ ils _) = stringify ils + go (Image _ ils _) = stringify ils + go (Span _ ils) = stringify ils + go (Note _) = "" + +-- | HTML-escape a text value for use in attribute values. +-- Defers to the canonical 'Utils.escapeHtmlText'. +esc :: Text -> Text +esc = U.escapeHtmlText diff --git a/build/Filters/Links.hs b/build/Filters/Links.hs new file mode 100644 index 0000000..7922e12 --- /dev/null +++ b/build/Filters/Links.hs @@ -0,0 +1,187 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | External link classification. +-- +-- Walks all @Link@ inlines and: +-- * Adds @class="link-external"@ to any link whose URL starts with +-- @http://@ or @https://@ and is not on the site's own domain. +-- * Adds @data-link-icon@ / @data-link-icon-type@ attributes for +-- per-domain brand icons (see 'domainIcon' for the full list). +-- * Adds @target="_blank" rel="noopener noreferrer"@ to external links. +module Filters.Links (apply) where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.Pandoc.Definition +import Text.Pandoc.Walk (walk) + +-- | Apply link classification to the entire document. +-- Two passes: PDF links first (rewrites href to the viewer URL and tags +-- the anchor @pdf-link@), then general classification. The second pass +-- explicitly skips anchors the PDF pass already claimed — the viewer URL +-- is root-relative, so without that guard it would also be classified as +-- an internal page link and get double chrome. +apply :: Pandoc -> Pandoc +apply = walk classifyLink . walk classifyPdfLink + +-- | Rewrite root-relative PDF links to open via the vendored PDF.js viewer. +-- Preserves the original path in @data-pdf-src@ so the popup thumbnail +-- provider can locate the corresponding @.thumb.png@ file. +-- Skips links that are already pointing at the viewer (idempotent). +-- +-- Handles fragment identifiers (e.g. @/papers/foo.pdf#page=5@): the +-- fragment is stripped before the @.pdf@ suffix check and re-attached +-- after the viewer URL so PDF.js's anchor handling works. +classifyPdfLink :: Inline -> Inline +classifyPdfLink (Link (ident, classes, kvs) ils (url, title)) + | "/" `T.isPrefixOf` url + , let (path, fragment) = T.break (== '#') url + , ".pdf" `T.isSuffixOf` T.toLower path + , "pdf-link" `notElem` classes = + let viewerUrl = "/pdfjs/web/viewer.html?file=" + <> encodeQueryValue path <> fragment + classes' = classes ++ ["pdf-link"] + kvs' = kvs ++ [("data-pdf-src", path)] + in Link (ident, classes', kvs') ils (viewerUrl, title) +classifyPdfLink x = x + +classifyLink :: Inline -> Inline +classifyLink l@(Link (_, classes, _) _ _) + -- Source-ref links are owned by Filters.SourceRefs: they keep the + -- inline-code chrome of their body, must not receive an external + -- brand icon stamp, and have their own popup provider. Leave them + -- entirely alone. + | "source-ref" `elem` classes = l + -- PDF links were already rewritten to the (root-relative) viewer URL + -- and given their own chrome by 'classifyPdfLink' in the preceding + -- pass; without this guard they would be double-classified as + -- internal page links. + | "pdf-link" `elem` classes = l +classifyLink (Link (ident, classes, kvs) ils (url, title)) + | isExternal url = + let icon = domainIcon url + classes' = classes ++ ["link-external"] + kvs' = kvs + ++ [("target", "_blank")] + ++ [("rel", "noopener noreferrer")] + ++ [("data-link-icon", icon)] + ++ [("data-link-icon-type", "svg")] + in Link (ident, classes', kvs') ils (url, title) + | isInternalPage url = + let classes' = classes ++ ["link-internal"] + kvs' = kvs + ++ [("data-link-icon", "internal")] + ++ [("data-link-icon-type", "svg")] + in Link (ident, classes', kvs') ils (url, title) +classifyLink x = x + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- | True if the URL is a root-relative or relative path to another page +-- (not an anchor-only link like @#section@ or @#ref-foo@). +isInternalPage :: Text -> Bool +isInternalPage url + | T.null url = False + | "#" `T.isPrefixOf` url = False -- anchor-only + | "mailto:" `T.isPrefixOf` url = False + | "http://" `T.isPrefixOf` url = False -- handled by isExternal + | "https://" `T.isPrefixOf` url = False + | otherwise = True + +-- | True if the URL points outside the site's content host. +-- +-- Only @levineuwirth.org@ and @www.levineuwirth.org@ count as the content +-- site itself. Sibling subdomains like @git.levineuwirth.org@ (Forgejo) are +-- distinct services and are classified as external so they receive their +-- brand icon, @target=_blank@, and @rel=noopener noreferrer@. +-- +-- Uses strict hostname comparison rather than substring matching, so a +-- hostile lookalike like @evil-levineuwirth.org.attacker.com@ is also +-- correctly classified as external. +isExternal :: Text -> Bool +isExternal url = + case extractHost url of + Nothing -> False + Just host -> host /= siteHost && host /= "www." <> siteHost + where + siteHost = "levineuwirth.org" + +-- | Extract the lowercased hostname from an absolute http(s) URL, +-- stripping any userinfo (@user:pass\@@) and port. Returns 'Nothing' +-- for non-http(s) URLs (relative paths, mailto:, etc.). +extractHost :: Text -> Maybe Text +extractHost url + | Just rest <- T.stripPrefix "https://" url = Just (hostOf rest) + | Just rest <- T.stripPrefix "http://" url = Just (hostOf rest) + | otherwise = Nothing + where + hostOf rest = + let authority = T.takeWhile (\c -> c /= '/' && c /= '?' && c /= '#') rest + -- 'T.breakOnEnd' yields the segment after the last @\@@, or + -- the whole authority when there is no userinfo. + (_, hostPort) = T.breakOnEnd "@" authority + host = T.takeWhile (/= ':') hostPort + in T.toLower host + +-- | Icon name for the link, matching a file in /images/link-icons/.svg. +-- +-- Matches on the URL's host only, never on the full URL — a path like +-- @https://example.org/why-x.com-failed@ must not get the Twitter +-- icon. URLs with no extractable host get the generic icon. +domainIcon :: Text -> Text +domainIcon url = maybe "external" iconForHost (extractHost url) + +iconForHost :: Text -> Text +iconForHost host + -- Scholarly / reference + | m "wikipedia.org" = "wikipedia" + | m "arxiv.org" = "arxiv" + | m "doi.org" = "doi" + | m "worldcat.org" = "worldcat" + | m "orcid.org" = "orcid" + | m "archive.org" = "internet-archive" + -- Code / software + | m "github.com" = "github" + | m "git.levineuwirth.org" = "forgejo" + | m "tensorflow.org" = "tensorflow" + -- AI companies (consumer products share a brand icon with the lab) + | m "anthropic.com" = "anthropic" + | m "claude.ai" = "anthropic" + | m "openai.com" = "openai" + | m "chatgpt.com" = "openai" + -- Social / media + | m "twitter.com" = "twitter" + | m "x.com" = "twitter" + | m "reddit.com" = "reddit" + | m "youtube.com" = "youtube" + | m "youtu.be" = "youtube" + | m "tiktok.com" = "tiktok" + | m "substack.com" = "substack" + | m "news.ycombinator.com" = "hacker-news" + | m "lesswrong.com" = "lesswrong" + -- News + | m "nytimes.com" = "new-york-times" + -- Institutions + | m "nasa.gov" = "nasa" + | m "apple.com" = "apple" + | otherwise = "external" + where + -- Label-suffix match: the host is the domain itself or a subdomain + -- of it. Never fires on a lookalike label (@notx.com@) or on text + -- in the path or query. + m d = host == d || ("." <> d) `T.isSuffixOf` host + +-- | Percent-encode characters that would break a @?file=@ query-string value. +-- Slashes are intentionally left unencoded so root-relative paths remain +-- readable and work correctly with PDF.js's internal fetch. +encodeQueryValue :: Text -> Text +encodeQueryValue = T.concatMap enc + where + enc ' ' = "%20" + enc '&' = "%26" + enc '?' = "%3F" + enc '+' = "%2B" + enc '"' = "%22" + enc c = T.singleton c diff --git a/build/Filters/Math.hs b/build/Filters/Math.hs new file mode 100644 index 0000000..b44a4e7 --- /dev/null +++ b/build/Filters/Math.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE GHC2021 #-} +-- | Math filter placeholder. +-- +-- The spec calls for converting simple LaTeX to Unicode at build time. +-- For now, all math (inline and display) is handled client-side by KaTeX, +-- which is loaded conditionally on pages that contain math. Server-side +-- KaTeX rendering is a Phase 3 task. +module Filters.Math (apply) where + +import Text.Pandoc.Definition (Pandoc) + +-- | Identity — math rendering is handled by KaTeX. +apply :: Pandoc -> Pandoc +apply = id diff --git a/build/Filters/Score.hs b/build/Filters/Score.hs new file mode 100644 index 0000000..4baf322 --- /dev/null +++ b/build/Filters/Score.hs @@ -0,0 +1,146 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Inline SVG score fragments into the Pandoc AST. +-- +-- Fenced-div syntax in Markdown: +-- +-- > :::score-fragment{score-name="Main Theme, mm. 1–8" score-caption="The opening gesture."} +-- > ![](scores/main-theme.svg) +-- > ::: +-- +-- The filter reads the referenced SVG from disk (path resolved relative to +-- the source file's directory), replaces hardcoded black fills/strokes with +-- @currentColor@ for dark-mode compatibility, and emits a @\@ with +-- the appropriate exhibit attributes for gallery.js TOC integration. +module Filters.Score (inlineScores) where + +import Control.Exception (IOException, try) +import Data.Char (isHexDigit) +import Data.Maybe (listToMaybe) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import System.Directory (doesFileExist) +import System.FilePath (()) +import System.IO (hPutStrLn, stderr) +import Text.Pandoc.Definition +import Text.Pandoc.Walk (walkM) +import qualified Utils as U + +-- | Walk the Pandoc AST and inline all score-fragment divs. +-- @baseDir@ is the directory of the source file; image paths in the +-- fenced-div are resolved relative to it. +inlineScores :: FilePath -> Pandoc -> IO Pandoc +inlineScores baseDir = walkM (inlineScore baseDir) + +inlineScore :: FilePath -> Block -> IO Block +inlineScore baseDir (Div (_, cls, attrs) blocks) + | "score-fragment" `elem` cls = do + let mName = lookup "score-name" attrs + mCaption = lookup "score-caption" attrs + mPath = findImagePath blocks + case mPath of + Nothing -> return $ Div ("", cls, attrs) blocks + Just path -> do + let fullPath = baseDir T.unpack path + exists <- doesFileExist fullPath + if not exists + then do + hPutStrLn stderr $ + "[Score] missing SVG: " ++ fullPath + ++ " (referenced from a score-fragment in " ++ baseDir ++ ")" + return (errorBlock mName ("Missing score: " <> path)) + else do + result <- try (TIO.readFile fullPath) :: IO (Either IOException T.Text) + case result of + Left e -> do + hPutStrLn stderr $ + "[Score] read error on " ++ fullPath ++ ": " ++ show e + return (errorBlock mName ("Could not read score: " <> path)) + Right svgRaw -> do + let html = buildHtml mName mCaption (processColors svgRaw) + return $ RawBlock (Format "html") html +inlineScore _ block = return block + +-- | Render an inline error block in place of a missing or unreadable score. +-- Mirrors the convention in 'Filters.Viz.errorBlock' so build failures are +-- visible to the author without aborting the entire site build. +errorBlock :: Maybe T.Text -> T.Text -> Block +errorBlock mName message = + RawBlock (Format "html") $ T.concat + [ "

" data-exhibit-name=\"" <> escHtml n <> "\"") mName + , ">" + , "
" + , escHtml message + , "
" + , "
" + ] + +-- | Extract the image src from the first Para that contains an Image inline. +findImagePath :: [Block] -> Maybe T.Text +findImagePath blocks = listToMaybe + [ src + | Para inlines <- blocks + , Image _ _ (src, _) <- inlines + ] + +-- | Replace hardcoded black fill/stroke values with @currentColor@ so the +-- SVG inherits the CSS @color@ property in both light and dark modes. +-- +-- Quoted attribute forms (@fill="#000"@) are self-delimiting — the +-- closing quote bounds the match — so plain 'T.replace' is safe for +-- them. Unquoted style-property forms (@fill:#000@) are not: naive +-- replacement would also fire on the prefix of a longer hex colour +-- (@fill:#000080@ → @fill:currentColor80@, invalid CSS). Those go +-- through 'replaceHexColor', which rewrites a match only when it is +-- not followed by another hex digit; the boundary check also makes +-- the 3-digit/6-digit application order irrelevant. +processColors :: T.Text -> T.Text +processColors + -- 3-digit hex and keyword patterns + = T.replace "fill=\"#000\"" "fill=\"currentColor\"" + . T.replace "fill=\"black\"" "fill=\"currentColor\"" + . T.replace "stroke=\"#000\"" "stroke=\"currentColor\"" + . T.replace "stroke=\"black\"" "stroke=\"currentColor\"" + . replaceHexColor "fill:#000" "fill:currentColor" + . T.replace "fill:black" "fill:currentColor" + . replaceHexColor "stroke:#000" "stroke:currentColor" + . T.replace "stroke:black" "stroke:currentColor" + -- 6-digit hex patterns (applied first — bottom of the chain) + . T.replace "fill=\"#000000\"" "fill=\"currentColor\"" + . T.replace "stroke=\"#000000\"" "stroke=\"currentColor\"" + . replaceHexColor "fill:#000000" "fill:currentColor" + . replaceHexColor "stroke:#000000" "stroke:currentColor" + +-- | 'T.replace' restricted to hex-boundary-terminated matches: an +-- occurrence of @needle@ is rewritten only when the character after +-- it is not another hex digit, so @fill:#000@ never fires inside the +-- longer colours @fill:#0008@, @fill:#000080@, or @fill:#00000080@. +replaceHexColor :: T.Text -> T.Text -> T.Text -> T.Text +replaceHexColor needle replacement = go + where + go t = + let (pre, rest) = T.breakOn needle t + in if T.null rest + then pre + else + let after = T.drop (T.length needle) rest + in case T.uncons after of + Just (c, _) | isHexDigit c -> + pre <> needle <> go after + _ -> pre <> replacement <> go after + +buildHtml :: Maybe T.Text -> Maybe T.Text -> T.Text -> T.Text +buildHtml mName mCaption svgContent = T.concat + [ "
" data-exhibit-name=\"" <> escHtml n <> "\"") mName + , " data-exhibit-type=\"score\">" + , "
" + , svgContent + , "
" + , maybe "" (\c -> "
" <> escHtml c <> "
") mCaption + , "
" + ] + +escHtml :: T.Text -> T.Text +escHtml = U.escapeHtmlText diff --git a/build/Filters/Sidenotes.hs b/build/Filters/Sidenotes.hs new file mode 100644 index 0000000..dd974b8 --- /dev/null +++ b/build/Filters/Sidenotes.hs @@ -0,0 +1,162 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Convert Pandoc @Note@ inlines to inline sidenote HTML. +-- +-- Each footnote becomes: +-- * A @@ anchor in the body text. +-- * A @@ immediately following it, containing +-- the rendered note content. +-- +-- Additionally, every consumed note is re-emitted in a +-- @
@ appended at the document end. The +-- filter swallows Pandoc's own @Note@ inlines, so Pandoc's writer +-- never produces that section itself — without this re-emission, +-- narrow viewports with JavaScript disabled (where sidenotes.css +-- hides @.sidenote@ and sidenotes.js's bottom sheet never runs) +-- would lose footnote content entirely. +-- +-- On wide viewports, sidenotes.css floats the spans into the right +-- margin and hides @section.footnotes@; on narrow viewports the +-- spans are hidden and the section is shown. The in-text anchor +-- targets the footnotes item (the only target visible on narrow +-- no-JS viewports); sidenotes.js intercepts clicks and pairs +-- ref\/note by element id, so the href is purely the no-JS path. +module Filters.Sidenotes (apply) where + +import Control.Monad.State.Strict +import Data.Default (def) +import Data.Text (Text) +import qualified Data.Text as T +import Text.Pandoc.Class (runPure) +import Text.Pandoc.Definition +import Text.Pandoc.Options (WriterOptions (..), + HTMLMathMethod (KaTeX)) +import Text.Pandoc.Walk (walkM) +import Text.Pandoc.Writers.HTML (writeHtml5String) + +-- | Accumulator: next label counter plus collected notes +-- (newest-first; reversed before rendering the fallback section). +type NoteState = (Int, [(Text, [Block])]) + +-- | Transform all @Note@ inlines in the document to inline sidenote +-- HTML, and append the collected notes as a @section.footnotes@ +-- fallback block. +apply :: Pandoc -> Pandoc +apply doc = + let (Pandoc m blocks, (_, collected)) = + runState (walkM convertNote doc) (1, []) + notes = reverse collected + in Pandoc m $ + if null notes + then blocks + else blocks ++ [footnotesSection notes] + +convertNote :: Inline -> State NoteState Inline +convertNote (Note blocks) = do + (n, acc) <- get + put (n + 1, (toLabel n, blocks) : acc) + return $ RawInline "html" (renderNote n blocks) +convertNote x = return x + +-- | The end-of-document fallback list. Letter labels are rendered +-- explicitly (an @
    @'s automatic numbering would disagree with +-- the in-text letters), so the list itself is unstyled. +footnotesSection :: [(Text, [Block])] -> Block +footnotesSection notes = RawBlock "html" $ T.concat $ + [ "
    " + , "
      " + ] + ++ map item notes ++ + [ "
    " + , "
    " + ] + where + item (lbl, blocks) = T.concat + [ "
  1. " + , "", lbl, "" + , blocksToHtml blocks + , "\x21a9\xfe0e" + , "
  2. " + ] + +-- | Convert a 1-based counter to a letter label using base-26 expansion +-- (Excel-column style): 1→a, 2→b, … 26→z, 27→aa, 28→ab, … 52→az, +-- 53→ba, … 702→zz, 703→aaa. Guarantees a unique label per counter so +-- no two sidenotes in a single document collide on @id="sn-…"@. +toLabel :: Int -> Text +toLabel n + | n <= 0 = "?" + | otherwise = T.pack (go n) + where + go k + | k <= 0 = "" + | otherwise = + let (q, r) = (k - 1) `divMod` 26 + in go q ++ [toEnum (fromEnum 'a' + r)] + +renderNote :: Int -> [Block] -> Text +renderNote n blocks = + let inner = blocksToInlineHtml blocks + lbl = toLabel n + in T.concat + -- href targets the footnotes-section item: on narrow no-JS + -- viewports that is the only visible rendering of the note + -- (the adjacent .sidenote span is display:none there, and on + -- wide viewports the note is already visible in the margin). + -- sidenotes.js pairs ref/note by id and preventDefaults the + -- click, so the href only ever navigates without JS. + [ "" + , "", lbl, "" + , "" + , "" + , "", lbl, "\x00a0" + , inner + , "" + ] + +-- | Render a list of Pandoc blocks for inclusion inside an inline @@. Each top-level @Para@ is wrapped in a +-- @@ instead of a @

    @ (which would be +-- invalid inside a @@); other block types are rendered with the +-- regular Pandoc HTML writer. +-- +-- Operating on the AST is preferred over post-rendered string +-- substitution because the latter mangles content that legitimately +-- contains the literal text @

    @ (e.g. code samples discussing HTML). +blocksToInlineHtml :: [Block] -> Text +blocksToInlineHtml = T.concat . map renderOne + where + renderOne :: Block -> Text + renderOne (Para inlines) = + "" + <> inlinesToHtml inlines + <> "" + renderOne (Plain inlines) = + inlinesToHtml inlines + renderOne b = + blocksToHtml [b] + +-- | Writer options for note bodies. Must agree with the math method in +-- 'Compilers.writerOpts' (KaTeX), or math inside a footnote silently +-- degrades to the writer default (PlainMath -> italics) and the +-- client-side KaTeX pass never sees it. Defined locally because +-- importing Compilers from here would create a module cycle +-- (Compilers -> Filters -> Filters.Sidenotes). +noteWriterOpts :: WriterOptions +noteWriterOpts = def { writerHTMLMathMethod = KaTeX "" } + +-- | Render a list of inlines to HTML (no surrounding @

    @). +inlinesToHtml :: [Inline] -> Text +inlinesToHtml inlines = + case runPure (writeHtml5String noteWriterOpts (Pandoc mempty [Plain inlines])) of + Left _ -> T.empty + Right t -> t + +-- | Render a list of Pandoc blocks to an HTML fragment via a pure writer run. +blocksToHtml :: [Block] -> Text +blocksToHtml blocks = + case runPure (writeHtml5String noteWriterOpts (Pandoc mempty blocks)) of + Left _ -> T.empty + Right t -> t diff --git a/build/Filters/Smallcaps.hs b/build/Filters/Smallcaps.hs new file mode 100644 index 0000000..d927af9 --- /dev/null +++ b/build/Filters/Smallcaps.hs @@ -0,0 +1,85 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Automatic small-caps wrapping for abbreviations in body text. +-- +-- Any @Str@ token that consists entirely of uppercase letters (and +-- hyphens) and is at least three characters long is wrapped in +-- @@. This catches CSS, HTML, API, NASA, etc. +-- while avoiding single-character tokens (\"I\", \"A\") and mixed-case +-- words. +-- +-- Authors can also use Pandoc span syntax for explicit control: +-- @[TEXT]{.smallcaps}@ — Pandoc already emits the @smallcaps@ class on +-- those spans, and typography.css styles @.smallcaps@ directly, so no +-- extra filter logic is needed for that case. +-- +-- The filter is /not/ applied inside headings (where Fira Sans uppercase +-- text looks intentional, at any nesting depth — including headings +-- inside divs and block quotes) or inside @Code@/@RawInline@ inlines. +module Filters.Smallcaps (apply) where + +import Data.Char (isUpper, isAlpha) +import Data.Text (Text) +import qualified Data.Text as T +import Text.Pandoc.Definition +import Text.Pandoc.Walk (walk) +import qualified Utils as U + +-- | Apply smallcaps detection to paragraph-level content. +-- Heading blocks are skipped at /every/ nesting level (a top-level +-- pattern match would miss a @Header@ inside a @Div@ or +-- @BlockQuote@): each header's @Str@ content is swapped for a +-- sentinel 'RawInline' before the wrapping walk and restored +-- afterwards, so 'wrapCaps' can never see it, wherever the header +-- sits in the block tree. +apply :: Pandoc -> Pandoc +apply = walk restoreStr . walk wrapCaps . walk protectHeader + +-- | Sentinel format marking a @Str@ that must not be wrapped. It only +-- exists between the protect and restore walks inside 'apply' and +-- can never leak into the writer. +skipFmt :: Format +skipFmt = Format "smallcaps-skip" + +protectHeader :: Block -> Block +protectHeader (Header lvl attr ils) = Header lvl attr (walk protectStr ils) + where + protectStr (Str t) = RawInline skipFmt t + protectStr x = x +protectHeader b = b + +restoreStr :: Inline -> Inline +restoreStr (RawInline fmt t) | fmt == skipFmt = Str t +restoreStr x = x + +-- | Wrap an all-caps Str token in an abbr element, preserving any trailing +-- punctuation (comma, period, colon, semicolon, closing paren/bracket) +-- outside the abbr element. +wrapCaps :: Inline -> Inline +wrapCaps (Str t) = + let (core, trail) = stripTrailingPunct t + in if isAbbreviation core + then RawInline "html" $ + "" <> escHtml core <> "" + <> trail + else Str t +wrapCaps x = x + +-- | Split trailing punctuation from the token body. +stripTrailingPunct :: Text -> (Text, Text) +stripTrailingPunct t = + let isPunct c = c `elem` (",.:;!?)]\'" :: String) + trail = T.takeWhileEnd isPunct t + core = T.dropEnd (T.length trail) t + in (core, trail) + +-- | True if the token looks like an abbreviation: all uppercase (plus +-- hyphens), at least 3 characters, contains at least one alpha character. +isAbbreviation :: Text -> Bool +isAbbreviation t = + T.length t >= 3 + && T.all (\c -> isUpper c || c == '-') t + && T.any isAlpha t + +escHtml :: Text -> Text +escHtml = U.escapeHtmlText diff --git a/build/Filters/SourceRefs.hs b/build/Filters/SourceRefs.hs new file mode 100644 index 0000000..2f3d783 --- /dev/null +++ b/build/Filters/SourceRefs.hs @@ -0,0 +1,218 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Detect repo-relative source-file references in prose and wrap them +-- in a link that triggers a hover-preview popup of the file's contents. +-- +-- Two trigger forms: +-- +-- * Inline @\`build\/Filters\/Links.hs\`@ — Markdown inline code whose +-- text passes a conservative source-path heuristic. +-- * A Markdown link to +-- @https:\/\/git.levineuwirth.org\/neuwirth\/levineuwirth.org\/(src|raw)\/branch\/\/@. +-- +-- Both produce +-- @\@. The href +-- points to the Forgejo source viewer so a click without JS — or a +-- popup that fails to fetch — still resolves to a useful target. +-- The popup provider in @static\/js\/popups.js@ fetches +-- @\/source\/\@ (a same-origin copy emitted by the Hakyll +-- source-preview rule in 'Site.rules') and renders a +-- syntax-highlighted snippet via Prism. +-- +-- Conservative-by-design: the trigger only fires on paths the +-- @/source/@ serving rule actually publishes ('isServedPath', a +-- mirror of @sourcePreviewable@ in 'Site.rules'), or a small set of +-- named root files. This keeps the parser cheap, avoids false +-- positives on words that happen to contain a slash and a dot, and +-- guarantees every wrapped path has a fetchable @/source/…@ copy. +module Filters.SourceRefs (apply, isSourcePath, forgejoSourceUrl) where + +import Control.Monad (when) +import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) +import qualified Data.Map.Strict as Map +import Data.Text (Text) +import qualified Data.Text as T +import System.Directory (doesFileExist) +import System.IO.Unsafe (unsafePerformIO) +import Text.Pandoc.Definition +import Text.Pandoc.Walk (walkM) + +-- | Two passes: lift Forgejo source URLs in existing Markdown links +-- first, then wrap inline-code source paths. Both passes only add +-- the @source-ref@ class when it is not already present, so re-runs +-- are idempotent. +-- +-- Runs in 'IO' because the heuristic confirms each candidate is a +-- real on-disk file before wrapping. This rules out paths like +-- @data/backlinks.json@ that look like source but are Hakyll build +-- artifacts produced into @_site/@ — wrapping those would emit a +-- link whose popup is guaranteed to 404. +apply :: Pandoc -> IO Pandoc +apply doc = do + afterLinks <- walkM classifyExistingLink doc + walkM wrapInlineCode afterLinks + +-- | Inline @`path`@ → @\\path\<\/code\>\<\/a\>@. +-- The original 'Code' node is preserved as the link's body so the +-- inline-code chrome (mono font, background) survives unchanged. +wrapInlineCode :: Inline -> IO Inline +wrapInlineCode orig@(Code (cIdent, cClasses, cKvs) txt) + | "source-ref" `notElem` cClasses + , isSourcePath txt = do + exists <- existsCached txt + if exists + then pure $ Link + ( "" + , ["source-ref"] + , [ ("data-source-path", txt) + , ("target", "_blank") + , ("rel", "noopener noreferrer") + ] + ) + [Code (cIdent, cClasses, cKvs) txt] + (forgejoSourceUrl txt, "") + else pure orig +wrapInlineCode x = pure x + +-- | Existing Markdown link to a Forgejo source URL on this site's git +-- host → tagged @source-ref@ and given a @data-source-path@ pointing +-- at the same path the popup provider expects. +classifyExistingLink :: Inline -> IO Inline +classifyExistingLink orig@(Link (ident, classes, kvs) ils (url, title)) + | "source-ref" `notElem` classes + , Just path <- forgejoSourcePath url + , isSourcePath path = do + exists <- existsCached path + if exists + then pure $ Link + ( ident + , classes ++ ["source-ref"] + , kvs ++ [("data-source-path", path)] + ) + ils (url, title) + else pure orig +classifyExistingLink x = pure x + +-- --------------------------------------------------------------------------- +-- Heuristic +-- --------------------------------------------------------------------------- + +-- | True when the text looks like a repo-relative path that the +-- @/source/@ serving rule actually publishes (or is a whitelisted +-- root file), ends in a known source extension, and contains only +-- safe path characters. Conservative by design — the goal is no +-- false positives on prose that incidentally contains a slash and a +-- dot, and no wrapped path whose popup fetch would 404. +isSourcePath :: Text -> Bool +isSourcePath t = and + [ not (T.null t) + , T.all safeChar t + , (isServedPath t && hasKnownExt t) || isKnownRootFile t + ] + where + safeChar c = + ('a' <= c && c <= 'z') + || ('A' <= c && c <= 'Z') + || ('0' <= c && c <= '9') + || c == '/' || c == '.' || c == '_' || c == '-' || c == '+' + +-- | Mirror of the @sourcePreviewable@ whitelist in 'Site.rules' (the +-- rule that copies files to @/source/@) — the two must stay +-- aligned so every link this filter emits has a corresponding +-- @/source/…@ target for the popup to fetch. Directories Site.hs +-- does not serve (e.g. @content/@) are deliberately absent here: +-- wrapping them would emit popups that are guaranteed to 404. +isServedPath :: Text -> Bool +isServedPath t = or + [ "build/" `T.isPrefixOf` t && hasExt ".hs" + , "static/js/" `T.isPrefixOf` t + , "static/css/" `T.isPrefixOf` t + , "templates/" `T.isPrefixOf` t + , "tools/" `T.isPrefixOf` t && (hasExt ".sh" || hasExt ".py") + , "nginx/" `T.isPrefixOf` t && hasExt ".conf" + , "data/" `T.isPrefixOf` t + && not ("/" `T.isInfixOf` T.drop 5 t) -- top-level data files only + && (hasExt ".json" || hasExt ".yaml" || hasExt ".md" || hasExt ".bib") + ] + where + hasExt e = e `T.isSuffixOf` T.toLower t + +hasKnownExt :: Text -> Bool +hasKnownExt t = + let lower = T.toLower t + in any (`T.isSuffixOf` lower) + [ ".hs", ".js", ".mjs", ".css", ".html" + , ".py", ".cabal", ".md", ".yaml", ".yml" + , ".toml", ".sh", ".bash", ".svg", ".conf" + , ".json", ".ini", ".tex", ".bib" + ] + +isKnownRootFile :: Text -> Bool +isKnownRootFile t = t `elem` + [ "Makefile" + , "levineuwirth.cabal" + , "cabal.project", "cabal.project.freeze" + , "pyproject.toml", "uv.lock" + , "WRITING.md", "HOMEPAGE.md", "PHOTOGRAPHY.md", "README.md" + , "LICENSE", "checklist.md" + ] + +-- --------------------------------------------------------------------------- +-- File existence cache +-- --------------------------------------------------------------------------- + +-- | Process-wide memo of /positive/ @doesFileExist@ results, keyed by +-- the same path the popup will fetch. Hakyll runs this filter once +-- per compiled page and the same source-file references recur across +-- many pages (e.g. @build\/Filters\/Links.hs@ in the Links page, +-- the Colophon, several essays); the cache turns N stats into one +-- per distinct path. Only existence is memoized: a missing file is +-- re-stat'ed on every miss, so a source file created during a +-- long-lived @make watch@ session is picked up on the next rebuild +-- instead of staying "absent" for the process lifetime. (A file +-- /deleted/ mid-watch stays cached as present until restart — the +-- benign direction: the popup fetch 404s and simply never appears.) +-- The build process's working directory is the project root, so the +-- path can be passed straight to 'doesFileExist' without prefixing. +{-# NOINLINE existsCacheRef #-} +existsCacheRef :: IORef (Map.Map Text Bool) +existsCacheRef = unsafePerformIO (newIORef Map.empty) + +existsCached :: Text -> IO Bool +existsCached path = do + cache <- readIORef existsCacheRef + case Map.lookup path cache of + Just b -> pure b + Nothing -> do + b <- doesFileExist (T.unpack path) + when b $ + atomicModifyIORef' existsCacheRef (\m -> (Map.insert path b m, ())) + pure b + +-- --------------------------------------------------------------------------- +-- Forgejo URL helpers +-- --------------------------------------------------------------------------- + +-- | Forgejo source-viewer URL for a repo-relative path. Pinned to the +-- @main@ branch so previews always reflect the deployed tip. +forgejoSourceUrl :: Text -> Text +forgejoSourceUrl path = + "https://git.levineuwirth.org/neuwirth/levineuwirth.org/src/branch/main/" + <> path + +-- | Inverse of 'forgejoSourceUrl': extract the repo-relative path from +-- a Forgejo URL on this site's git host. Recognises both the +-- @\/src\/branch\/\/@ web view and the @\/raw\/branch\/\/@ +-- variants. Returns 'Nothing' for any other URL. +forgejoSourcePath :: Text -> Maybe Text +forgejoSourcePath url = do + rest <- T.stripPrefix repoBase url + afterBranch <- + case T.stripPrefix "src/branch/" rest of + Just r -> Just r + Nothing -> T.stripPrefix "raw/branch/" rest + let (_branch, slashAndPath) = T.breakOn "/" afterBranch + path = T.drop 1 slashAndPath + if T.null path then Nothing else Just path + where + repoBase = "https://git.levineuwirth.org/neuwirth/levineuwirth.org/" diff --git a/build/Filters/Transclusion.hs b/build/Filters/Transclusion.hs new file mode 100644 index 0000000..cdbaf2c --- /dev/null +++ b/build/Filters/Transclusion.hs @@ -0,0 +1,83 @@ +{-# LANGUAGE GHC2021 #-} +-- | Source-level transclusion preprocessor. +-- +-- Rewrites block-level {{slug}} and {{slug#section}} directives to raw +-- HTML placeholders that transclude.js resolves at runtime. +-- +-- A directive must be the sole content of a line (after trimming) to be +-- replaced — this prevents accidental substitution inside prose. +-- +-- Code protection (honest scope): lines inside /fenced/ code blocks +-- are passed through untouched ('Filters.Wikilinks.mapOutsideFences'), +-- so fenced examples can show @{{slug}}@ literally. Indented code +-- blocks and inline code spans are NOT recognised — a full-line +-- directive inside either is still rewritten. +-- +-- Examples: +-- {{my-essay}} → full-page transclusion of /my-essay.html +-- {{essays/deep-dive}} → /essays/deep-dive.html (full body) +-- {{my-essay#introduction}} → section "introduction" of /my-essay.html +module Filters.Transclusion (preprocess) where + +import Data.List (isSuffixOf, isPrefixOf, stripPrefix) +import Filters.Wikilinks (mapOutsideFences) +import qualified Utils as U + +-- | Apply transclusion substitution to the raw Markdown source string, +-- skipping lines inside fenced code blocks. +preprocess :: String -> String +preprocess = mapOutsideFences processLine + +processLine :: String -> String +processLine line = + case parseDirective (U.trim line) of + Nothing -> line + Just (url, secAttr) -> + "

    " + +-- | Parse a {{slug}} or {{slug#section}} directive. +-- Returns (absolute-url, section-attribute-string) or Nothing. +-- +-- The section name is HTML-escaped before being interpolated into the +-- @data-section@ attribute, so a stray @\"@, @&@, @<@, or @>@ in a +-- section name cannot break the surrounding markup. +parseDirective :: String -> Maybe (String, String) +parseDirective s = do + inner <- stripPrefix "{{" s >>= stripSuffix "}}" + case break (== '#') inner of + ("", _) -> Nothing + (slug, "") -> Just (slugToUrl slug, "") + (slug, '#' : sec) + | null sec -> Just (slugToUrl slug, "") + | otherwise -> Just (slugToUrl slug, + " data-section=\"" ++ escAttr sec ++ "\"") + _ -> Nothing + +-- | Convert a slug (possibly with leading slash, possibly with path segments) +-- to a root-relative .html URL. Idempotent for slugs that already end in +-- @.html@ so callers can safely pass either form. +slugToUrl :: String -> String +slugToUrl slug + | ".html" `isSuffixOf` slug, "/" `isPrefixOf` slug = slug + | ".html" `isSuffixOf` slug = "/" ++ slug + | "/" `isPrefixOf` slug = slug ++ ".html" + | otherwise = "/" ++ slug ++ ".html" + +-- | Minimal HTML attribute-value escape. +escAttr :: String -> String +escAttr = concatMap esc + where + esc '&' = "&" + esc '<' = "<" + esc '>' = ">" + esc '"' = """ + esc '\'' = "'" + esc c = [c] + +-- | Strip a suffix from a string, returning Nothing if not present. +stripSuffix :: String -> String -> Maybe String +stripSuffix suf str + | suf `isSuffixOf` str = Just (take (length str - length suf) str) + | otherwise = Nothing + diff --git a/build/Filters/Typography.hs b/build/Filters/Typography.hs new file mode 100644 index 0000000..41fdf86 --- /dev/null +++ b/build/Filters/Typography.hs @@ -0,0 +1,54 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Typographic refinements applied to the Pandoc AST. +-- +-- Currently: expands common Latin abbreviations to @@ elements +-- (e.g. → exempli gratia, i.e. → id est, etc.). Pandoc's @smart@ +-- reader extension already handles em-dashes, en-dashes, ellipses, +-- and curly quotes, so those are not repeated here. +module Filters.Typography (apply) where + +import Data.Text (Text) +import Text.Pandoc.Definition +import Text.Pandoc.Walk (walk) +import Utils (escapeHtmlText) + +-- | Apply all typographic transformations to the document. +apply :: Pandoc -> Pandoc +apply = walk expandAbbrev + +-- --------------------------------------------------------------------------- +-- Abbreviation expansion +-- --------------------------------------------------------------------------- + +-- | Abbreviations that should be wrapped in @@. +-- Each entry is (verbatim text as it appears in the Pandoc Str token, +-- long-form title for the tooltip). +abbrevMap :: [(Text, Text)] +abbrevMap = + [ ("e.g.", "exempli gratia") + , ("i.e.", "id est") + , ("cf.", "confer") + , ("viz.", "videlicet") + , ("ibid.", "ibidem") + , ("op.", "opere") -- usually followed by "cit." in a separate token + , ("NB", "nota bene") + , ("NB:", "nota bene") + ] + +-- | If the Str token exactly matches a known abbreviation, replace it with +-- a @RawInline "html"@ @@ element; otherwise leave it unchanged. +-- +-- Both the @title@ attribute and the visible body pass through +-- 'escapeHtmlText' for consistency with every other raw-HTML emitter +-- in the filter pipeline. The abbreviations themselves are ASCII-safe +-- so this is defense-in-depth rather than a live hazard. +expandAbbrev :: Inline -> Inline +expandAbbrev (Str t) = + case lookup t abbrevMap of + Just title -> + RawInline "html" $ + " escapeHtmlText title <> "\">" + <> escapeHtmlText t <> "" + Nothing -> Str t +expandAbbrev x = x diff --git a/build/Filters/Viz.hs b/build/Filters/Viz.hs new file mode 100644 index 0000000..5241082 --- /dev/null +++ b/build/Filters/Viz.hs @@ -0,0 +1,238 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Inline data visualizations into the Pandoc AST. +-- +-- Two fenced-div classes are recognized in Markdown: +-- +-- __Static figure__ (Matplotlib → SVG, no client-side JS required): +-- +-- > ::: {.figure script="figures/myplot.py" caption="Caption text"} +-- > ::: +-- +-- Runs the Python script; stdout must be an SVG document with a +-- transparent background. Black fills and strokes are replaced with +-- @currentColor@ so figures adapt to dark mode automatically. +-- See @tools/viz_theme.py@ for the recommended matplotlib setup. +-- +-- __Interactive figure__ (Altair/Vega-Lite → JSON spec): +-- +-- > ::: {.visualization script="figures/myplot.py" caption="Caption text"} +-- > ::: +-- +-- Runs the Python script; stdout must be a Vega-Lite JSON spec. The spec +-- is embedded verbatim inside a @\" + , "" + , if T.null caption then "" + else "
    " <> escHtml caption <> "
    " + , "
" + ] + +errorBlock :: String -> Block +errorBlock msg = RawBlock (Format "html") $ T.concat + [ "
Visualization error: " + , escHtml (T.pack msg) + , "
" + ] + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +attr :: T.Text -> [(T.Text, T.Text)] -> T.Text +attr key kvs = fromMaybe "" (lookup key kvs) + +warn :: String -> String -> IO () +warn kind msg = hPutStrLn stderr $ "[Viz] " ++ kind ++ " error: " ++ msg + +escHtml :: T.Text -> T.Text +escHtml = U.escapeHtmlText diff --git a/build/Filters/Wikilinks.hs b/build/Filters/Wikilinks.hs new file mode 100644 index 0000000..bd42595 --- /dev/null +++ b/build/Filters/Wikilinks.hs @@ -0,0 +1,193 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Wikilink syntax preprocessor. +-- +-- Applied to the raw Markdown source string /before/ Pandoc parsing. +-- Transforms: +-- +-- * @[[Page Title]]@ → @[Page Title](/page-title)@ +-- * @[[Page Title|Display]]@ → @[Display](/page-title)@ +-- +-- The URL slug is derived from the page title: lowercased, spaces +-- replaced with hyphens, non-alphanumeric characters stripped, and +-- a @.html@ suffix appended so the link resolves identically under +-- the dev server, file:// previews, and nginx in production. +-- +-- Code protection (honest scope): lines inside /fenced/ code blocks +-- are passed through untouched (see 'mapOutsideFences'), and within a +-- line, inline code spans (backtick runs, CommonMark equal-length +-- matching) are skipped — so both fenced and @`inline`@ examples can +-- show @[[…]]@ literally. Indented code blocks and code spans that +-- cross a line break are NOT recognised; a wikilink inside those is +-- still rewritten. +module Filters.Wikilinks (preprocess, mapOutsideFences) where + +import Data.Char (isAlphaNum, toLower, isSpace) +import Data.List (intercalate) +import qualified Utils as U + +-- | Scan the raw Markdown source for @[[…]]@ wikilinks and replace them +-- with standard Markdown link syntax. Processing is line-by-line and +-- skips fenced code blocks; a wikilink therefore cannot span a line +-- break (which was never a sensible authoring form). +preprocess :: String -> String +preprocess = mapOutsideFences replaceWikilinks + +replaceWikilinks :: String -> String +replaceWikilinks = go + where + go [] = [] + -- Inline code span: a backtick run opens a span closed by a run of + -- exactly the same length (CommonMark). Its body passes through + -- verbatim so documentation can quote @`[[…]]`@ literally. An + -- unclosed run is literal text — and then a following @[[…]]@ is + -- genuinely a wikilink, matching how Pandoc will read the line. + go s@('`':_) = + let (run, afterRun) = span (== '`') s + in case codeSpan (length run) afterRun of + Just (body, after) -> run ++ body ++ run ++ go after + Nothing -> run ++ go afterRun + go ('[':'[':rest) = + case break (== ']') rest of + (inner, ']':']':after) + | not (null inner) -> + toMarkdownLink inner ++ go after + _ -> '[' : '[' : go rest + go (c:rest) = c : go rest + + -- @codeSpan n s@: the span body and the remainder after a closing + -- run of exactly @n@ backticks; 'Nothing' when no closer exists on + -- this line. + codeSpan :: Int -> String -> Maybe (String, String) + codeSpan n = loop + where + loop [] = Nothing + loop s@('`':_) = + let (run, rest) = span (== '`') s + in if length run == n + then Just ("", rest) + else prepend run <$> loop rest + loop (c:cs) = prepend [c] <$> loop cs + prepend pre (body, after) = (pre ++ body, after) + +-- --------------------------------------------------------------------------- +-- Fence-aware line mapping (shared by all source-level preprocessors) +-- --------------------------------------------------------------------------- + +-- | Apply a line transformation to every line that is not part of a +-- fenced code block. Shared by the three source-level preprocessors +-- (wikilinks here, 'Filters.Transclusion', 'Filters.EmbedPdf') so +-- their directive syntax can be quoted literally inside fenced code. +-- +-- Fence tracking follows CommonMark: an opener is at most three +-- spaces of indentation followed by a run of at least three backticks +-- or tildes (longer runs allowed); for backtick fences the info +-- string may not contain a backtick. The closer uses the same fence +-- character, a run at least as long as the opener, and nothing but +-- whitespace after it. An unclosed fence extends to the end of the +-- document. Fence delimiter lines themselves pass through untouched. +-- +-- Honest scope: only /fenced/ code blocks are protected. Indented +-- code blocks and inline code spans are not recognised here — a +-- directive inside either is still rewritten. +mapOutsideFences :: (String -> String) -> String -> String +mapOutsideFences f = unlines . go Nothing . lines + where + go _ [] = [] + go Nothing (l:ls) = + case openingFence l of + Just fence -> l : go (Just fence) ls + Nothing -> f l : go Nothing ls + go st@(Just fence) (l:ls) + | closesFence fence l = l : go Nothing ls + | otherwise = l : go st ls + +-- | The fence character and run length of a CommonMark fence opener, +-- or 'Nothing' when the line does not open a fence. +openingFence :: String -> Maybe (Char, Int) +openingFence l = do + rest <- stripFenceIndent l + case rest of + (c:_) | c == '`' || c == '~' -> + let run = takeWhile (== c) rest + n = length run + info = drop n rest + in if n >= 3 && (c == '~' || '`' `notElem` info) + then Just (c, n) + else Nothing + _ -> Nothing + +-- | True when the line closes the fence opened by @(c, n)@: the same +-- fence character, a run at least as long as the opener, and only +-- whitespace after it. +closesFence :: (Char, Int) -> String -> Bool +closesFence (c, n) l = + case stripFenceIndent l of + Nothing -> False + Just rest -> + let run = takeWhile (== c) rest + in length run >= n && all isSpace (drop (length run) rest) + +-- | Strip up to three leading spaces (the indentation CommonMark allows +-- on a fence line); 'Nothing' for four or more, which would be an +-- indented code block rather than a fence. +stripFenceIndent :: String -> Maybe String +stripFenceIndent l = + let (indent, rest) = span (== ' ') l + in if length indent <= 3 then Just rest else Nothing + +-- | Convert the inner content of @[[…]]@ to a Markdown link. +-- +-- Display text is escaped via 'escMdLinkText' so that a literal @]@, @[@, +-- or backslash in the display does not break the surrounding Markdown +-- link syntax. The URL itself is produced by 'slugify' and therefore only +-- ever contains @[a-z0-9-]@, so no URL-side encoding is needed — adding +-- one would be defense against a character set we can't produce. +toMarkdownLink :: String -> String +toMarkdownLink inner = + let (title, display) = splitOnPipe inner + url = "/" ++ slugify title ++ ".html" + in "[" ++ escMdLinkText display ++ "](" ++ url ++ ")" + +-- | Escape the minimum set of characters that would prematurely terminate +-- a Markdown link's display-text segment: backslash (escape char), @[@, +-- and @]@. Backslash MUST be escaped first so the escapes we introduce +-- for @[@ and @]@ are not themselves re-escaped. +-- +-- Deliberately NOT escaped: @_@, @*@, @\`@, @<@. Those are inline +-- formatting markers in Markdown and escaping them would strip the +-- author's ability to put emphasis, code, or inline HTML in a wikilink's +-- display text. +escMdLinkText :: String -> String +escMdLinkText = concatMap esc + where + esc '\\' = "\\\\" + esc '[' = "\\[" + esc ']' = "\\]" + esc c = [c] + +-- | Split on the first @|@; if none, display = title. +splitOnPipe :: String -> (String, String) +splitOnPipe s = + case break (== '|') s of + (title, '|':display) -> (U.trim title, U.trim display) + _ -> (U.trim s, U.trim s) + +-- | Produce a URL slug: lowercase, words joined by hyphens, +-- non-alphanumeric characters removed. +-- +-- Trailing punctuation is dropped rather than preserved as a dangling +-- hyphen — @slugify "end." == "end"@, not @"end-"@. This is intentional: +-- author-authored wikilinks tend to end sentences with a period and the +-- desired URL is almost always the terminal-punctuation-free form. +slugify :: String -> String +slugify = intercalate "-" . words . map toLowerAlnum + where + toLowerAlnum c + | isAlphaNum c = toLower c + | isSpace c = ' ' + | c == '-' = '-' + | otherwise = ' ' -- replace punctuation with a space so words + -- split correctly and double-hyphens are + -- collapsed by 'words' + diff --git a/build/Main.hs b/build/Main.hs new file mode 100644 index 0000000..555f1ab --- /dev/null +++ b/build/Main.hs @@ -0,0 +1,23 @@ +module Main where + +import Data.Time.Clock.POSIX (getPOSIXTime) +import System.Directory (createDirectoryIfMissing) +import Hakyll (hakyll) +import Site (rules) + +-- | Stamp the start of this build into @data/build-stamp.txt@ before +-- Hakyll scans the provider directory. The file therefore always exists +-- and always differs from the previous run. The telemetry pages +-- (@/build/@, @/stats/@) @load@ it as a dependency so Hakyll recompiles +-- them on every build instead of serving a stale cached copy when no +-- tracked content changed. See build/Stats.hs and build/Site.hs. +writeBuildStamp :: IO () +writeBuildStamp = do + createDirectoryIfMissing True "data" + t <- getPOSIXTime + writeFile "data/build-stamp.txt" (show t ++ "\n") + +main :: IO () +main = do + writeBuildStamp + hakyll rules diff --git a/build/Marks.hs b/build/Marks.hs new file mode 100644 index 0000000..f9bf6e4 --- /dev/null +++ b/build/Marks.hs @@ -0,0 +1,638 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Frontmatter marks: the monogram (a hand-authored SVG per piece) and +-- the epistemic figure (a build-time SVG generated from frontmatter). +-- See MARKS.md for the full specification. +-- +-- Two Hakyll context fields are exported: +-- +-- * @$monogramSvg$@ — the inlined monogram for the current item, or +-- 'noResult' when no co-located @mark.svg@ exists. +-- * @$epistemicSvg$@ — the generated epistemic figure, or 'noResult' +-- when the item has no @status:@ frontmatter +-- (MARKS.md §3.1). +-- +-- Both fields are deterministic: byte-identical inputs produce +-- byte-identical SVGs, so the GPG signing pipeline is undisturbed. +module Marks + ( monogramSvgField + , hasMonogramField + , monogramSvgFieldFor + , hasMonogramFieldFor + , epistemicSvgField + , hasMonogram + ) where + +import Control.Exception (IOException, try) +import Data.Char (toLower) +import Data.Maybe (catMaybes, isJust) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import Numeric (showFFloat) +import System.Directory (doesFileExist) +import System.FilePath (takeBaseName, takeDirectory, + takeFileName, ()) +import System.IO (hPutStrLn, stderr) +import Text.Read (readMaybe) + +import Hakyll +import Stability (resolveStability) + +-- --------------------------------------------------------------------------- +-- Monogram path resolution +-- --------------------------------------------------------------------------- + +-- | Candidate monogram paths for a given source path. The build picks the +-- first one that exists on disk. This dual-form resolver matches the +-- site's mixed flat / directory essay convention: +-- +-- > content/essays/foo.md → content/essays/foo.mark.svg +-- > content/essays/foo/index.md → content/essays/foo/mark.svg +monogramCandidates :: FilePath -> [FilePath] +monogramCandidates fp = + let dir = takeDirectory fp + fname = takeFileName fp + in if fname == "index.md" + then [dir "mark.svg"] + else [dir takeBaseName fp ++ ".mark.svg"] + +-- | Predicate form of 'resolveMonogramPath' — used by Stats.hs to +-- compute monogram coverage on @/build/@. Returns 'True' when at +-- least one of the dual-form candidate paths exists on disk. +hasMonogram :: Item a -> Compiler Bool +hasMonogram item = isJust <$> resolveMonogramPath item + +-- | @$has-monogram$@ — present (renders as @"true"@) only when the +-- item has an actual @mark.svg@ on disk; 'noResult' for the +-- placeholder-roundel case. Templates that don't want to display +-- placeholder roundels (e.g. item-card listings, popup previews) +-- gate on this flag instead of @$monogramSvg$@, which the +-- frontmatter header relies on always rendering for symmetric +-- column layout. +hasMonogramField :: Context String +hasMonogramField = field "has-monogram" $ \item -> do + has <- hasMonogram item + if has then return "true" else noResult "no real monogram" + +-- | Return the first candidate path that exists on disk, or 'Nothing'. +resolveMonogramPath :: Item a -> Compiler (Maybe FilePath) +resolveMonogramPath item = + unsafeCompiler $ firstExisting (monogramCandidates fp) + where + fp = toFilePath (itemIdentifier item) + firstExisting [] = return Nothing + firstExisting (p:ps) = do + e <- doesFileExist p + if e then return (Just p) else firstExisting ps + +-- --------------------------------------------------------------------------- +-- Monogram inlining +-- --------------------------------------------------------------------------- + +-- | @$monogramSvg$@. Reads the resolved @mark.svg@, normalizes black +-- fills/strokes to @currentColor@ (defensive — authors using AI-assist +-- tools may produce hardcoded blacks; the contract still holds), strips +-- the @width@/@height@ presentation attributes from the root @@, +-- and wraps the result in @
@. +-- +-- When no @mark.svg@ exists, returns the placeholder roundel — an +-- empty outer ring at lower opacity that visually balances the +-- epistemic-figure column and signals "monogram not yet authored". +-- Read failures fall back to the same placeholder. +monogramSvgField :: Context String +monogramSvgField = field "monogramSvg" $ \item -> do + mPath <- resolveMonogramPath item + case mPath of + Nothing -> return $ T.unpack monogramPlaceholder + Just path -> do + result <- unsafeCompiler $ try (TIO.readFile path) + :: Compiler (Either IOException T.Text) + case result of + Left e -> do + unsafeCompiler $ hPutStrLn stderr $ + "[Marks] " ++ toFilePath (itemIdentifier item) ++ + ": failed to read " ++ path ++ ": " ++ show e + return $ T.unpack monogramPlaceholder + Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg) + +-- | Empty-roundel placeholder used while a piece's monogram is still +-- to be authored (Phase 2 of MARKS.md). The @--placeholder@ modifier +-- class lets CSS render it at reduced opacity so it reads as a +-- neutral frame rather than a finished glyph. +monogramPlaceholder :: T.Text +monogramPlaceholder = T.concat + [ "
" + , "" + , "" + , "" + , "
" + ] + +-- | Wrap inlined monogram SVG in its outer figure element. +wrapMonogram :: T.Text -> T.Text +wrapMonogram svg = T.concat + [ "
" + , svg + , "
" + ] + +-- | @$monogramSvg$@ override for synthesized pages whose item identifier +-- doesn't live under @content/@ (e.g. @/build/@, @/stats/@), so the +-- auto-resolver in 'monogramSvgField' can't find a co-located mark. +-- Reads from the supplied path; falls back to the placeholder roundel +-- when the file is absent or unreadable. +monogramSvgFieldFor :: FilePath -> Context a +monogramSvgFieldFor path = field "monogramSvg" $ \_ -> do + exists <- unsafeCompiler $ doesFileExist path + if not exists + then return $ T.unpack monogramPlaceholder + else do + result <- unsafeCompiler $ try (TIO.readFile path) + :: Compiler (Either IOException T.Text) + case result of + Left e -> do + unsafeCompiler $ hPutStrLn stderr $ + "[Marks] failed to read " ++ path ++ ": " ++ show e + return $ T.unpack monogramPlaceholder + Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg) + +-- | @$has-monogram$@ override paired with 'monogramSvgFieldFor'. Present +-- (as @"true"@) only when the path exists; 'noResult' otherwise. +hasMonogramFieldFor :: FilePath -> Context a +hasMonogramFieldFor path = field "has-monogram" $ \_ -> do + exists <- unsafeCompiler $ doesFileExist path + if exists then return "true" else noResult "no real monogram" + +-- | Replace hardcoded black fills/strokes with @currentColor@ and strip +-- the root @@'s @width@/@height@ attributes (presentation lives +-- in CSS via the @.frontmatter-mark svg@ selector). Mirrors the color +-- substitution in 'Filters.Score.processColors' so the two SVG +-- inliners agree on the contract. +processSvg :: T.Text -> T.Text +processSvg = stripRootDims . normalizeColors + +-- | The same chain 'Filters.Score' applies, kept in sync deliberately. +-- 6-digit patterns first so the 3-digit replacement doesn't match +-- the prefix of a 6-digit value. +normalizeColors :: T.Text -> T.Text +normalizeColors + = T.replace "fill=\"#000\"" "fill=\"currentColor\"" + . T.replace "fill=\"black\"" "fill=\"currentColor\"" + . T.replace "stroke=\"#000\"" "stroke=\"currentColor\"" + . T.replace "stroke=\"black\"" "stroke=\"currentColor\"" + . T.replace "fill:#000" "fill:currentColor" + . T.replace "fill:black" "fill:currentColor" + . T.replace "stroke:#000" "stroke:currentColor" + . T.replace "stroke:black" "stroke:currentColor" + . T.replace "fill=\"#000000\"" "fill=\"currentColor\"" + . T.replace "stroke=\"#000000\"" "stroke=\"currentColor\"" + . T.replace "fill:#000000" "fill:currentColor" + . T.replace "stroke:#000000" "stroke:currentColor" + +-- | Remove @width="..."@ and @height="..."@ from the root @@. +-- The substitution is conservative: it walks once and only touches +-- the first occurrence of each attribute (the root tag in a +-- well-formed monogram). +stripRootDims :: T.Text -> T.Text +stripRootDims = stripFirst "width" . stripFirst "height" + where + stripFirst attr txt = + case T.breakOn (T.pack (" " ++ attr ++ "=\"")) txt of + (before, after) + | T.null after -> txt + | otherwise -> + -- Drop ` attr="..."` including its closing quote. + let restAfterEq = T.drop (T.length (T.pack (" " ++ attr ++ "=\""))) after + in case T.breakOn "\"" restAfterEq of + (_, rest) | T.null rest -> txt + | otherwise -> before <> T.drop 1 rest + +-- --------------------------------------------------------------------------- +-- Epistemic figure: data extraction +-- --------------------------------------------------------------------------- + +-- | Captures the frontmatter inputs the figure consumes. Constructed +-- once per item by 'readEpistemicData', then handed to the pure +-- geometry below. Keeps the I/O step (metadata + git) separate from +-- the SVG-string formatter, so the formatter is testable in isolation +-- without mocking Hakyll. +data EpistemicData = EpistemicData + { epConfidence :: Maybe Int -- ^ Numeric confidence; 'Nothing' if proved/absent/unparseable. + , epConfidenceProved :: Bool -- ^ True when @confidence: proved@ / @proven@. + , epImportance :: Maybe Int -- ^ 1–5 ordinal. + , epEvidence :: Maybe Int -- ^ 1–5 ordinal. + , epScope :: Maybe String -- ^ Validated scope value. + , epNovelty :: Maybe String -- ^ Validated novelty value. + , epPracticality :: Maybe String -- ^ Validated practicality value. + , epPeerStatus :: Maybe String -- ^ Validated peer-status slug ('Nothing' when absent / unreviewed / invalid). + , epResultShape :: Maybe String -- ^ Validated result-shape value. + , epStability :: String -- ^ Always one of the five stability labels. + , epTrust :: Maybe Int -- ^ Trust score 0–100 (60/40 weighted; @proved@ substitutes 100 for confidence). 'Nothing' when confidence or evidence is missing — no label is rendered. + } + +-- | Read the figure inputs from a Hakyll item's metadata + git history. +readEpistemicData :: Item a -> Compiler EpistemicData +readEpistemicData item = do + meta <- getMetadata (itemIdentifier item) + stab <- resolveStability item + let confRaw = lookupString "confidence" meta + proved = isProvedConfidenceM confRaw + confInt = if proved then Just 100 else readMaybe . trimS =<< confRaw + confNumeric = if proved then Nothing else confInt + importance = readMaybe . trimS =<< lookupString "importance" meta + evidence = readMaybe . trimS =<< lookupString "evidence" meta + scope = validate scopeValues =<< lookupString "scope" meta + novelty = validate noveltyValues =<< lookupString "novelty" meta + practical = validate practicalityValues =<< lookupString "practicality" meta + peer = validatePeerStatus =<< lookupString "peer-status" meta + resultShape = validate resultShapeValues =<< lookupString "result-shape" meta + trust = computeTrust confInt evidence + return EpistemicData + { epConfidence = confNumeric + , epConfidenceProved = proved + , epImportance = importance + , epEvidence = evidence + , epScope = scope + , epNovelty = novelty + , epPracticality = practical + , epPeerStatus = peer + , epResultShape = resultShape + , epStability = stab + , epTrust = trust + } + where + trimS = trim' + +-- | Trust score: the same 60/40 weighted composite of confidence and +-- evidence used by 'Contexts.overallScoreField'. Returns 'Nothing' +-- when either input is missing — the figure then renders no trust +-- label at all (it collapses to the bare frame), rather than a +-- literal "0" indistinguishable from an authored zero score. +computeTrust :: Maybe Int -> Maybe Int -> Maybe Int +computeTrust (Just c) (Just e) = + let raw :: Double + raw = fromIntegral c / 100.0 * 0.6 + fromIntegral (e - 1) / 4.0 * 0.4 + in Just (max 0 (min 100 (round (raw * 100.0)))) +computeTrust _ _ = Nothing + +-- | Same predicate as 'Contexts.isProvedConfidence' — local copy to keep +-- the module's dependency graph light (Marks → Stability only). The +-- two are tested against the same vocabulary; if either drifts the +-- build still warns via the schema validators in Contexts.hs. +isProvedConfidenceM :: Maybe String -> Bool +isProvedConfidenceM (Just s) = map toLower (trim' s) `elem` ["proved", "proven"] +isProvedConfidenceM _ = False + +trim' :: String -> String +trim' = f . f + where f = reverse . dropWhile (`elem` (" \t\n\r" :: String)) + +-- | Validate a value against an enum list. Returns the lowercase form +-- on hit, 'Nothing' otherwise (no warning here — Contexts.hs's parsers +-- already warn on invalid frontmatter; the figure simply degrades). +validate :: [String] -> String -> Maybe String +validate vs raw = + let s = map toLower (trim' raw) + in if s `elem` vs then Just s else Nothing + +-- | Peer-status validator: matches @peerStatusField@ in Contexts.hs but +-- maps @unreviewed@ to 'Nothing' so the figure's outer ring stays +-- neutral by default. +validatePeerStatus :: String -> Maybe String +validatePeerStatus raw = + let s = map toLower (trim' raw) + in if s `elem` ["under-review", "peer-reviewed", "published", "retracted"] + then Just s + else Nothing -- includes "unreviewed" and any unknown value + +scopeValues, noveltyValues, practicalityValues, resultShapeValues :: [String] +scopeValues = ["personal", "local", "average", "broad", "civilizational"] +noveltyValues = ["conventional", "moderate", "idiosyncratic", "innovative"] +practicalityValues = ["abstract", "low", "moderate", "high", "exceptional"] +resultShapeValues = ["positive", "negative", "mixed", "comparative", "descriptive"] + +-- | Map an ordinal value to its numeric rank (1-based). +ordinalRank :: [String] -> String -> Maybe Int +ordinalRank vs s = lookup s (zip vs [1..]) + +-- --------------------------------------------------------------------------- +-- Epistemic figure: geometry +-- --------------------------------------------------------------------------- + +-- | Centre of the figure (viewBox coordinates). +fxCenter, fyCenter :: Double +fxCenter = 100 +fyCenter = 100 + +-- | Inner / outer radii of the roundel and the polygon's full extent. +fxOuter, fxOuterPlus, fxAxisFull :: Double +fxOuter = 88 -- inner roundel circle +fxOuterPlus = 90 -- outer roundel circle +fxAxisFull = 80 -- full axis length (polygon vertex when value = 1.0) + +-- | Six axis angles, clockwise from 12 o'clock, in degrees. +-- Index → field: 0 confidence, 1 novelty, 2 practicality, +-- 3 scope, 4 evidence, 5 importance. +axisAngles :: [Double] +axisAngles = [0, 60, 120, 180, 240, 300] + +-- | Convert a (clockwise-angle-from-12-o'clock, distance-from-centre) pair +-- to absolute viewBox coordinates. +polar :: Double -> Double -> (Double, Double) +polar angleDeg dist = + let theta = (angleDeg - 90) * pi / 180 + in (fxCenter + dist * cos theta, fyCenter + dist * sin theta) + +-- | Axis index → normalized [0,1] value, or 'Nothing' when the +-- underlying frontmatter field is absent / unparseable. +axisValue :: EpistemicData -> Int -> Maybe Double +axisValue d i = case i of + 0 -> if epConfidenceProved d + then Just 1.0 + else fmap (\c -> fromIntegral c / 100.0) (epConfidence d) + 1 -> normalizeOrdinal noveltyValues 4 (epNovelty d) + 2 -> normalizeOrdinal practicalityValues 5 (epPracticality d) + 3 -> normalizeOrdinal scopeValues 5 (epScope d) + 4 -> normalizeIntScale 5 (epEvidence d) + 5 -> normalizeIntScale 5 (epImportance d) + _ -> Nothing + +-- | Map a 1..n ordinal-name value to a [0,1] value via @(rank-1)/(n-1)@. +normalizeOrdinal :: [String] -> Int -> Maybe String -> Maybe Double +normalizeOrdinal vs n (Just s) = do + r <- ordinalRank vs s + return $ fromIntegral (r - 1) / fromIntegral (n - 1) +normalizeOrdinal _ _ Nothing = Nothing + +-- | Map a 1..n integer to [0,1] via @(v-1)/(n-1)@. +normalizeIntScale :: Int -> Maybe Int -> Maybe Double +normalizeIntScale n (Just v) = Just $ fromIntegral (v - 1) / fromIntegral (n - 1) +normalizeIntScale _ Nothing = Nothing + +-- --------------------------------------------------------------------------- +-- Epistemic figure: SVG rendering +-- --------------------------------------------------------------------------- + +-- | Format a Double with two decimal places. Determinism (§8.1) requires +-- no platform-dependent floating-point formatting. +ff :: Double -> T.Text +ff x = T.pack (showFFloat (Just 2) x "") + +-- | Format a "x,y" coordinate pair. +xy :: Double -> Double -> T.Text +xy x y = ff x <> T.singleton ',' <> ff y + +-- | Render the full epistemic figure SVG. +renderEpistemicFigure :: EpistemicData -> T.Text +renderEpistemicFigure d = T.concat + [ " "trust " <> T.pack (show t) <> ", ") (epTrust d) + , "stability ", T.pack (epStability d), "\">" + , renderRoundel + , renderGuides + , renderAxes + , renderPolygon d + , renderVertexMarks d + , renderTicks (epStability d) (epPeerStatus d) + , maybe "" renderTrustLabel (epTrust d) + , renderResultShape (epResultShape d) (epTrust d) + , "" + ] + +-- | Two thin concentric circles forming the outer roundel. +renderRoundel :: T.Text +renderRoundel = T.concat + [ "" + , "" + ] + +-- | Four concentric guide circles at 0.2 R, 0.4 R, 0.6 R, 0.8 R. +renderGuides :: T.Text +renderGuides = T.concat $ map oneGuide [0.2, 0.4, 0.6, 0.8 :: Double] + where + oneGuide t = T.concat + [ "" + ] + +-- | Six radial axes from centre to the inner roundel. +renderAxes :: T.Text +renderAxes = T.concat $ map oneAxis axisAngles + where + oneAxis a = + let (x, y) = polar a fxAxisFull + in T.concat + [ "" + ] + +-- | Polygon connecting the present field values along their axes. +-- When all six axes have a value the polygon is closed; otherwise +-- it's an open polyline through the present vertices in axis order. +renderPolygon :: EpistemicData -> T.Text +renderPolygon d = + let pairs = [ (i, axisValue d i) | i <- [0..5] ] + verts = [ polar a (fxAxisFull * v) + | (i, Just v) <- pairs + , let a = axisAngles !! i ] + in case verts of + [] -> "" + _ -> + let pointsTxt = T.intercalate " " [ xy x y | (x, y) <- verts ] + allPresent = all (isJust . snd) pairs + tag = if allPresent then "polygon" else "polyline" + fillAttr = if allPresent + then " fill=\"currentColor\" fill-opacity=\"0.08\"" + else " fill=\"none\"" + in T.concat + [ "<", tag + , " points=\"", pointsTxt + , "\" stroke=\"currentColor\" stroke-width=\"1.1\"" + , fillAttr + , " stroke-linejoin=\"round\" stroke-linecap=\"round\"/>" + ] + +-- | One vertex point per present axis. Confidence axis gets a 3×3 square +-- instead of a 2-px circle when @confidence: proved@ is in effect — the +-- "proof cap" marker (MARKS.md §4.3). +renderVertexMarks :: EpistemicData -> T.Text +renderVertexMarks d = T.concat $ catMaybes + [ vertexMark d i | i <- [0..5] ] + +vertexMark :: EpistemicData -> Int -> Maybe T.Text +vertexMark d i = do + v <- axisValue d i + let (x, y) = polar (axisAngles !! i) (fxAxisFull * v) + squareCap = i == 0 && epConfidenceProved d + return $ if squareCap + then T.concat + [ "" + ] + else T.concat + [ "" + ] + +-- | Outer-ring stability ticks at the top of the figure. Always five +-- positions; inactive ticks render at opacity 0.4 so the full scale +-- stays visible. Peer-status modulates tick *style*; see +-- 'renderPeerStatusOverlay'. +renderTicks :: String -> Maybe String -> T.Text +renderTicks stability peerStatus = + let activeCount = case stability of + "volatile" -> 1 + "revising" -> 2 + "fairly stable" -> 3 + "stable" -> 4 + "established" -> 5 + _ -> 1 + tickAngles :: [Double] + tickAngles = [0, -15, 15, -30, 30] + tickOne :: Int -> Double -> T.Text + tickOne idx a = + let (x1, y1) = polar a fxOuterPlus + (x2, y2) = polar a (fxOuterPlus + 1.5) + op = if idx < activeCount then "1.0" else "0.4" + in T.concat + [ "" + ] + in T.concat (zipWith tickOne [0..] tickAngles) + <> renderPeerStatusOverlay peerStatus + +-- | Per-peer-status decorations layered on top of the tick group. +-- Geometry per MARKS.md §4.1. +renderPeerStatusOverlay :: Maybe String -> T.Text +renderPeerStatusOverlay Nothing = "" +renderPeerStatusOverlay (Just "under-review") = + -- Small unfilled circle just outside the outermost tick, at the top. + let (x, y) = polar 0 (fxOuterPlus + 3.5) + in T.concat + [ "" + ] +renderPeerStatusOverlay (Just "peer-reviewed") = + -- Single horizontal bar above the outer roundel arc, centred on top. + T.concat + [ "" + ] +renderPeerStatusOverlay (Just "published") = + -- Printer's-bracket: short vertical marks at ±15° on the outer roundel. + let (lx1, ly1) = polar (-15) (fxOuterPlus + 1) + (lx2, ly2) = polar (-15) (fxOuterPlus + 4) + (rx1, ry1) = polar 15 (fxOuterPlus + 1) + (rx2, ry2) = polar 15 (fxOuterPlus + 4) + in T.concat + [ "" + , "" + ] +renderPeerStatusOverlay (Just "retracted") = + -- Horizontal strikethrough across the tick group. + T.concat + [ "" + ] +renderPeerStatusOverlay (Just _) = "" + +-- | Trust score (Spectral, 16 px) and the small "TRUST" label below it. +renderTrustLabel :: Int -> T.Text +renderTrustLabel score = T.concat + [ "" + , T.pack (show score) + , "" + , "TRUST" + ] + +-- | Result-shape glyph immediately to the right of the trust score — +-- or centred in its place when no trust score is rendered. +renderResultShape :: Maybe String -> Maybe Int -> T.Text +renderResultShape Nothing _ = "" +renderResultShape (Just shape) mScore = + let glyph = case shape of + "positive" -> "+" + "negative" -> "\x2212" -- minus sign (not hyphen-minus) + "mixed" -> "\x00B1" -- ± + "comparative" -> "\x223C" -- ∼ + "descriptive" -> "\x25A1" -- □ + _ -> "" + -- Offset proportional to the trust number's width (digits ≈ 8 px + -- each); with no trust label the glyph takes the centre itself. + (x, anchor) = case mScore of + Just score -> + let digitCount = length (show score) + offset = fromIntegral digitCount * 4.5 + 3 :: Double + in (fxCenter + offset, "start") + Nothing -> (fxCenter, "middle") + in if T.null (T.pack glyph) + then "" + else T.concat + [ "" + , T.pack glyph + , "" + ] + +-- --------------------------------------------------------------------------- +-- Field exports +-- --------------------------------------------------------------------------- + +-- | @$epistemicSvg$@. Returns 'noResult' when @status:@ is absent +-- (matches the existing visibility rule for the epistemic block — +-- MARKS.md §3.1). Otherwise returns the inline SVG string ready for +-- template interpolation. +epistemicSvgField :: Context String +epistemicSvgField = field "epistemicSvg" $ \item -> do + meta <- getMetadata (itemIdentifier item) + case lookupString "status" meta of + Nothing -> noResult "no status; epistemic figure suppressed" + Just _ -> do + d <- readEpistemicData item + return $ T.unpack (wrapEpistemic (renderEpistemicFigure d)) + +wrapEpistemic :: T.Text -> T.Text +wrapEpistemic svg = T.concat + [ "
" + , svg + , "
" + ] + diff --git a/build/Now.hs b/build/Now.hs new file mode 100644 index 0000000..d5a72e0 --- /dev/null +++ b/build/Now.hs @@ -0,0 +1,282 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Now page: loads data/now.yaml and renders the active-projects view +-- and the recently-shipped archive for /current.html. Page-level +-- "Last updated" stamp is exposed as a context field; relative time +-- ("4 days ago") is computed at build time from getCurrentTime. +module Now + ( nowCtx + ) where + +import Data.Aeson (FromJSON (..), withObject, (.:), (.:?), (.!=)) +import Data.Char (toUpper) +import Data.List (nub, sortBy) +import Data.Maybe (fromMaybe) +import Data.Ord (Down (..), comparing) +import Data.Time.Calendar (Day, diffDays) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Format (defaultTimeLocale, formatTime, parseTimeM) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import qualified Data.Yaml as Y +import Hakyll hiding (escapeHtml) +import Contexts (siteCtx) +import Utils (escapeHtml) + +-- --------------------------------------------------------------------------- +-- Entry types +-- --------------------------------------------------------------------------- + +data NowEntry = NowEntry + { neTitle :: String + , neSection :: String + , neStatus :: String + , neUpdated :: String + , neLink :: Maybe String + , neNote :: Maybe String + , nePriority :: Int + } + +instance FromJSON NowEntry where + parseJSON = withObject "NowEntry" $ \o -> NowEntry + <$> o .: "title" + <*> o .: "section" + <*> o .: "status" + <*> o .: "updated" + <*> o .:? "link" + <*> o .:? "note" + <*> o .:? "priority" .!= 0 + +data NowShipped = NowShipped + { nsTitle :: String + , nsCompleted :: String + , nsLink :: Maybe String + , nsNote :: Maybe String + } + +instance FromJSON NowShipped where + parseJSON = withObject "NowShipped" $ \o -> NowShipped + <$> o .: "title" + <*> o .: "completed" + <*> o .:? "link" + <*> o .:? "note" + +data NowDoc = NowDoc + { nLastUpdated :: String + , nEntries :: [NowEntry] + , nShipped :: [NowShipped] + } + +instance FromJSON NowDoc where + parseJSON = withObject "NowDoc" $ \o -> NowDoc + <$> o .: "last-updated" + <*> o .:? "entries" .!= [] + <*> o .:? "shipped" .!= [] + +-- --------------------------------------------------------------------------- +-- Helpers +-- --------------------------------------------------------------------------- + +-- | Section ordering follows first-appearance in entries. Reorder the +-- YAML to reorder the page; no separate ordering key required. +sectionOrder :: [NowEntry] -> [String] +sectionOrder = nub . map neSection + +-- | Status ordering — "how close to shipping." Lower rank sorts first. +-- Statuses not listed sort below all known ones (rank 99) so a typo +-- surfaces visibly at the bottom of its section instead of silently +-- ranking next-to-the-top. +statusRanks :: [(String, Int)] +statusRanks = + [ ("accepted", 0) + , ("in-review", 1) + , ("revising", 2) + , ("drafting", 3) + , ("building", 4) + , ("early-stage", 5) + , ("paused", 6) + ] + +statusRank :: String -> Int +statusRank s = fromMaybe 99 (lookup s statusRanks) + +-- | Three-tier sort key for active entries: +-- 1. priority — manual override; higher floats up (default 0) +-- 2. statusRank — how close to shipping (lower is closer) +-- 3. updated — recency tiebreaker within the same rank +-- Sectioning is applied to the *unsorted* list so section ordering +-- continues to follow YAML source order; sorting happens within each +-- section's filtered slice. +entrySortKey :: NowEntry -> (Down Int, Int, Down String) +entrySortKey e = + ( Down (nePriority e) + , statusRank (neStatus e) + , Down (neUpdated e) + ) + +-- | "early-stage" → "Early Stage", "research" → "Research". +titleCaseWords :: String -> String +titleCaseWords = unwords . map cap . wordsOnDash + where + cap [] = [] + cap (x:xs) = toUpper x : xs + wordsOnDash s = case break (== '-') s of + (a, []) -> [a] + (a, _:rest) -> a : wordsOnDash rest + +-- --------------------------------------------------------------------------- +-- HTML rendering +-- --------------------------------------------------------------------------- + +renderStatusChip :: String -> String +renderStatusChip s = concat + [ "
" + , escapeHtml (titleCaseWords s) + , "" + ] + +-- | Active-entry card. Reuses the .item-card / .item-card-* classes from +-- item-card.css so the Now page picks up the existing typographic +-- register; the .now-* classes layer status-chip + spacing on top. +renderEntry :: NowEntry -> String +renderEntry e = concat + [ "
  • " + , "" + , renderStatusChip (neStatus e) + , "" + , "
    " + , "
    " + , renderTitle (neLink e) (neTitle e) + , "" + , "
    " + , maybe "" (\n -> "

    " ++ escapeHtml n ++ "

    ") (neNote e) + , "
    " + , "
  • " + ] + +renderShippedEntry :: NowShipped -> String +renderShippedEntry s = concat + [ "
  • " + , "" + , renderStatusChip "shipped" + , "" + , "
    " + , "
    " + , renderTitle (nsLink s) (nsTitle s) + , "" + , "
    " + , maybe "" (\n -> "

    " ++ escapeHtml n ++ "

    ") (nsNote s) + , "
    " + , "
  • " + ] + +renderTitle :: Maybe String -> String -> String +renderTitle mu title = case mu of + Just url -> "
    " ++ escapeHtml title ++ "" + Nothing -> "" ++ escapeHtml title ++ "" + +renderSection :: String -> [NowEntry] -> String +renderSection sec es = concat + [ "
    " + , "

    " + , escapeHtml (titleCaseWords sec) + , "

    " + , "
      " + , concatMap renderEntry es + , "
    " + , "
    " + ] + +renderEntries :: [NowEntry] -> String +renderEntries [] = "" +renderEntries entries = concatMap renderOne (sectionOrder entries) + where + renderOne sec = + let inSec = filter ((== sec) . neSection) entries + sorted = sortBy (comparing entrySortKey) inSec + in renderSection sec sorted + +renderShippedAll :: [NowShipped] -> String +renderShippedAll [] = "" +renderShippedAll items = concat + [ "
    " + , "

    Recently Shipped

    " + , "
      " + , concatMap renderShippedEntry sorted + , "
    " + , "
    " + ] + where + sorted = sortBy (comparing (Down . nsCompleted)) items + +-- --------------------------------------------------------------------------- +-- Date formatters — runs at build time +-- --------------------------------------------------------------------------- + +-- | "2026-04-26" → "26 April 2026". Falls back to the raw ISO string +-- if the date is unparseable, so a typo in @last-updated@ surfaces +-- in the rendered page rather than blowing up the build. +formatWriterly :: String -> String +formatWriterly iso = + case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso :: Maybe Day of + Nothing -> iso + Just d -> formatTime defaultTimeLocale "%-d %B %Y" d + +relativeTime :: Day -> String -> String +relativeTime today iso = + case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso :: Maybe Day of + Nothing -> "" + Just d -> bucket (diffDays today d) + where + bucket n + | n < 0 = "" + | n == 0 = "today" + | n == 1 = "yesterday" + | n < 7 = show n ++ " days ago" + | n < 28 = pluralize (n `div` 7) "week" + | n < 365 = pluralize (n `div` 30) "month" + | otherwise = pluralize (n `div` 365) "year" + pluralize 1 unit = "1 " ++ unit ++ " ago" + pluralize k unit = show k ++ " " ++ unit ++ "s ago" + +-- --------------------------------------------------------------------------- +-- Load +-- --------------------------------------------------------------------------- + +-- | UTF-8 round-trip String → ByteString. Hakyll's @getResourceBody@ +-- hands us a 'String' (Unicode codepoints); the yaml library wants +-- a UTF-8 'ByteString'. 'Data.ByteString.Char8.pack' would truncate +-- each 'Char' to 8 bits — fine for ASCII, silent corruption for any +-- codepoint above 0x7F (e.g. em-dash 0x2014 → control char 0x14). +loadNow :: Compiler NowDoc +loadNow = do + rawItem <- load (fromFilePath "data/now.yaml") :: Compiler (Item String) + case Y.decodeEither' (TE.encodeUtf8 (T.pack (itemBody rawItem))) of + Left err -> fail ("now.yaml: " ++ show err) + Right doc -> return doc + +-- --------------------------------------------------------------------------- +-- Context +-- --------------------------------------------------------------------------- + +nowCtx :: Context String +nowCtx = + constField "now" "true" + <> field "now-last-updated" (\_ -> nLastUpdated <$> loadNow) + <> field "now-last-updated-display" (\_ -> formatWriterly . nLastUpdated <$> loadNow) + <> field "now-last-updated-relative" (\_ -> do + doc <- loadNow + nowT <- unsafeCompiler getCurrentTime + let today = utctDay nowT + rel = relativeTime today (nLastUpdated doc) + if null rel + then noResult "no relative time" + else return rel + ) + <> field "now-entries-html" (\_ -> renderEntries . nEntries <$> loadNow) + <> field "now-shipped-html" (\_ -> renderShippedAll . nShipped <$> loadNow) + <> siteCtx diff --git a/build/Pagination.hs b/build/Pagination.hs new file mode 100644 index 0000000..8febe20 --- /dev/null +++ b/build/Pagination.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Pagination helpers. +-- +-- NOTE: This module must not import Contexts or Tags to avoid cycles. +-- Callers (Site.hs) pass contexts in as parameters. +module Pagination + ( pageSize + , sortAndGroup + , sortAndGroupAt + , blogPaginateRules + ) where + +import Hakyll +import Patterns (blogPattern) + + +-- | Items per page across most paginated lists (e.g. the blog). +pageSize :: Int +pageSize = 20 + +-- | Sort identifiers by date (most recent first) and split into pages. +sortAndGroup :: (MonadMetadata m, MonadFail m) => [Identifier] -> m [[Identifier]] +sortAndGroup = sortAndGroupAt pageSize + +-- | Like 'sortAndGroup' but with a caller-supplied page size. Used by +-- listings that want a different density than the blog default. +sortAndGroupAt :: (MonadMetadata m, MonadFail m) => Int -> [Identifier] -> m [[Identifier]] +sortAndGroupAt n ids = paginateEvery n <$> sortRecentFirst ids + +-- | Page identifier for the blog index. +-- Page 1 → blog/index.html +-- Page N → blog/page/N/index.html +blogPageId :: PageNumber -> Identifier +blogPageId 1 = fromFilePath "blog/index.html" +blogPageId n = fromFilePath $ "blog/page/" ++ show n ++ "/index.html" + +-- | Build and rule-ify a paginated blog index. +-- @itemCtx@: context for individual posts (postCtx). +-- @baseCtx@: site-level context (siteCtx). +blogPaginateRules :: Context String -> Context String -> Rules () +blogPaginateRules itemCtx baseCtx = do + paginate <- buildPaginateWith sortAndGroup (blogPattern .&&. hasNoVersion) blogPageId + paginateRules paginate $ \pageNum pat -> do + route idRoute + compile $ do + posts <- recentFirst =<< loadAll (pat .&&. hasNoVersion) + let ctx = listField "posts" itemCtx (return posts) + <> paginateContext paginate pageNum + <> constField "title" "Blog" + <> baseCtx + makeItem "" + >>= loadAndApplyTemplate "templates/blog-index.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls diff --git a/build/Patterns.hs b/build/Patterns.hs new file mode 100644 index 0000000..b757630 --- /dev/null +++ b/build/Patterns.hs @@ -0,0 +1,200 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Canonical content-pattern definitions, shared across modules. +-- +-- Several modules need to enumerate "all author-written content" or +-- "all essays". Historically each module hard-coded its own slightly +-- different list, which produced silent omissions (e.g. directory-form +-- essays not appearing on author pages). This module is the single source +-- of truth — every place that needs a content pattern should import from +-- here, not write its own. +module Patterns + ( -- * Per-section patterns + essayPattern + , draftEssayPattern + , blogPattern + , poetryPattern + , fictionPattern + , musicPattern + , photographyPattern + , allPhotoEntries + , standalonePagesPattern + , pageCollectionPattern + -- * Aggregated patterns + , allWritings -- essays + blog + poetry + fiction + , allContent -- everything that backlinks should index + , authorIndexable -- everything that should appear on /authors/{slug}/ + , tagIndexable -- everything that should appear on // + ) where + +import Hakyll + +-- --------------------------------------------------------------------------- +-- Per-section +-- --------------------------------------------------------------------------- + +-- | All published essays — flat files, directory-based essays, and entries +-- inside one-level collection directories. +essayPattern :: Pattern +essayPattern = + "content/essays/*.md" + .||. "content/essays/*/*.md" + +-- | In-progress essay drafts. Matches the flat and directory forms under +-- @content/drafts/essays/@. Only 'Site.rules' consumes this, gated on +-- @SITE_ENV=dev@ — every other module that enumerates content (Authors, +-- Tags, Backlinks, Stats, feeds) sees only 'essayPattern', so drafts are +-- automatically invisible to listings, tags, authors, backlinks, and stats. +draftEssayPattern :: Pattern +draftEssayPattern = + "content/drafts/essays/*.md" + .||. "content/drafts/essays/*/index.md" + +-- | All blog posts: flat posts plus entries inside collection directories. +-- Collection index pages are landing pages and compile separately. +blogPattern :: Pattern +blogPattern = + "content/blog/*.md" + .||. ("content/blog/*/*.md" .&&. complement "content/blog/*/index.md") + +-- | All poetry: flat poems plus collection poems, excluding collection +-- index pages (which are landing pages, not poems). +poetryPattern :: Pattern +poetryPattern = + "content/poetry/*.md" + .||. ("content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md") + +-- | All fiction: flat stories plus entries inside collection directories. +-- Collection index pages are landing pages and compile separately. +fictionPattern :: Pattern +fictionPattern = + "content/fiction/*.md" + .||. ("content/fiction/*/*.md" .&&. complement "content/fiction/*/index.md") + +-- | Music compositions (landing pages live at @content/music//index.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") + +-- | Page collection entries live one directory below @content/@. Known +-- section directories are excluded so their files retain specialized +-- compilers and routes. +pageCollectionPattern :: Pattern +pageCollectionPattern = + "content/*/*.md" .&&. complement reservedSectionPages + where + reservedSectionPages = + "content/blog/*.md" + .||. "content/cv/*.md" + .||. "content/drafts/*.md" + .||. "content/essays/*.md" + .||. "content/fiction/*.md" + .||. "content/me/*.md" + .||. "content/memento-mori/*.md" + .||. "content/music/*.md" + .||. "content/photography/*.md" + .||. "content/poetry/*.md" + .||. "content/scripts/*.md" + .||. "content/tag-meta/*.md" + +-- | Top-level standalone pages, curated CV routing pages, and generic page +-- collections. +standalonePagesPattern :: Pattern +standalonePagesPattern = + "content/*.md" + .||. "content/cv/*.md" + .||. pageCollectionPattern + +-- --------------------------------------------------------------------------- +-- Aggregations +-- --------------------------------------------------------------------------- + +-- | All long-form authored writings. +allWritings :: Pattern +allWritings = essayPattern .||. blogPattern .||. poetryPattern .||. fictionPattern + +-- | Every content file the backlinks pass should index. Includes music +-- landing pages and top-level standalone pages, in addition to writings, +-- plus the two directory-form standalone essays (@content/me/index.md@ +-- and @content/memento-mori/index.md@) — full essays rendered with +-- backlinks, whose outgoing links must be visible to the link graph. +-- +-- Photography is deliberately excluded, but note what this pattern +-- governs: link *sources*. Photo pages DO render the backlinks block +-- now (see 'Contexts.photographyCtx'), and they receive backlinks +-- normally — a poem that references a frame surfaces on that frame, +-- because inversion keys on the target URL and does not require the +-- target to appear here. What they do not do is contribute outbound +-- links: a caption-scale entry with an empty body has no prose to +-- give the graph, so indexing 240-odd of them would cost a compile +-- pass each and return nothing. Revisit if photo bodies start +-- carrying real prose. +allContent :: Pattern +allContent = + essayPattern + .||. blogPattern + .||. poetryPattern + .||. fictionPattern + .||. musicPattern + .||. standalonePagesPattern + .||. "content/me/index.md" + .||. "content/memento-mori/index.md" + +-- | Content shown on author index pages — essays + blog posts. +-- (Poetry and fiction have their own dedicated indexes and are not +-- aggregated by author.) +authorIndexable :: Pattern +authorIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion + +-- | Content shown on tag index pages — essays + every photographic entry +-- (including sibling photos in series). Blog posts are deliberately +-- excluded: tags are a topical index for substantial writing, and no +-- blog post is ever "about" a topic the way an essay is — including +-- them would dilute tag pages. (Blog posts still get related-content +-- links via the separate keyword/embedding-based similarity pass.) +-- 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 .||. allPhotoEntries) + .&&. hasNoVersion diff --git a/build/Photography.hs b/build/Photography.hs new file mode 100644 index 0000000..5249de2 --- /dev/null +++ b/build/Photography.hs @@ -0,0 +1,775 @@ +{-# 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 (intercalate, nub, sort, 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 (takeBaseName, takeDirectory, takeFileName, replaceExtension, ()) +import System.Directory (doesFileExist, getFileSize) +import Text.Printf (printf) +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 + <> seriesStatsCtx + <> 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 + + +-- | Statistics for a series landing, in the register of @/build/@ and +-- @/stats/@ — the site already treats counting things as worth showing, and +-- a body of photographs has plenty to count. +-- +-- Everything is derived at build time from the children's frontmatter and +-- the delivery files on disk, so nothing has to be maintained by hand and +-- nothing can drift from the set it describes. Fields return 'noResult' +-- when a series cannot answer them (no lens recorded, one capture date, an +-- image that is gitignored and absent on this machine), so the template's +-- @$if(...)$@ guards drop the row rather than printing a blank. +seriesStatsCtx :: Context String +seriesStatsCtx = + field "series-frame-count" (fmap (show . length) . children) + <> field "series-bytes" (\i -> do + ms <- childMeta i + dir <- return (takeDirectory (toFilePath (itemIdentifier i))) + sizes <- unsafeCompiler $ mapM (fileSize dir) ms + let total = sum sizes + if total <= 0 then noResult "no delivery files on disk" + else return (humanBytes total)) + <> field "series-captured-span" capturedSpan + <> distinctField "series-cameras" "camera" + <> distinctField "series-lenses" "lens" + <> field "series-focal-span" (spanOf "focal-length") + <> field "series-iso-span" (spanOf "iso") + <> field "series-locations" locationList + where + children i = loadSeriesChildrenFor i + + childMeta i = children i >>= mapM (getMetadata . itemIdentifier) + + fileSize dir meta = case lookupString "photo" meta of + Just p | not (null p) -> do + let fp = dir p + ok <- doesFileExist fp + if ok then fromIntegral <$> getFileSize fp else return (0 :: Integer) + _ -> return 0 + + values key i = mapMaybe (lookupString key) <$> childMeta i + + -- Distinct values, in order, comma-joined. One camera reads as a fact + -- about the series; five read as a list, which is also a fact about it. + distinctField name key = field name $ \i -> do + vs <- nub <$> values key i + if null vs then noResult (name ++ ": nothing recorded") + else return (intercalate ", " vs) + + -- Capture span in prose rather than ISO. Two dates in one month collapse + -- to "13–14 August 2026" instead of repeating the month and year; a + -- single date prints alone rather than as "13–13 August". + capturedSpan i = do + vs <- sort . nub <$> values "captured" i + case vs of + [] -> noResult "no capture dates recorded" + [x] -> return (prettyDate x) + xs -> return (spanDates (head xs) (last xs)) + + spanDates a b = + case (splitDate a, splitDate b) of + (Just (ya, ma, da), Just (yb, mb, _)) + | ya == yb && ma == mb -> + da ++ "–" ++ dayOf b ++ " " ++ monthName ma ++ " " ++ ya + | ya == yb -> + da ++ " " ++ monthName ma ++ " – " ++ dayOf b ++ " " + ++ monthName mb ++ " " ++ ya + _ -> prettyDate a ++ " – " ++ prettyDate b + + dayOf d = maybe d (\(_, _, dd) -> dd) (splitDate d) + + prettyDate d = case splitDate d of + Just (y, m, dd) -> dd ++ " " ++ monthName m ++ " " ++ y + Nothing -> d + + splitDate d = case splitOnDash d of + [y, m, dd] -> Just (y, m, dropWhile (== '0') dd) + _ -> Nothing + + splitOnDash str = case break (== '-') str of + (a, []) -> [a] + (a, _ : rest) -> a : splitOnDash rest + + monthName m = case m of + "01" -> "January"; "02" -> "February"; "03" -> "March" + "04" -> "April"; "05" -> "May"; "06" -> "June" + "07" -> "July"; "08" -> "August"; "09" -> "September" + "10" -> "October"; "11" -> "November"; "12" -> "December" + _ -> m + + -- Locations are stored as "Munich, Germany"; joining the full strings + -- with commas produced "Munich, Germany, Nuremberg, Germany", which reads + -- as four places. Keep the leading component and separate with a middot. + locationList i = do + vs <- nub . map (takeWhile (/= ',')) <$> values "location" i + if null vs then noResult "no locations recorded" + else return (intercalate " · " vs) + + -- Numeric span: strips units, compares as numbers, reprints with the + -- unit recovered from the first value. "18mm" and "55mm" must not sort + -- as strings, where "18" > "155". + spanOf key i = do + vs <- nub <$> values key i + let parsed = mapMaybe numericPrefix vs + unit = case vs of (v:_) -> dropWhile (\c -> c `elem` ("0123456789." :: String)) v + [] -> "" + case parsed of + [] -> noResult "no numeric values" + [x] -> return (trimNum x ++ unit) + xs -> let lo = minimum xs + hi = maximum xs + in if lo == hi then return (trimNum lo ++ unit) + else return (trimNum lo ++ "–" ++ trimNum hi ++ unit) + + numericPrefix v = + let digits = takeWhile (\c -> c `elem` ("0123456789." :: String)) v + in if null digits then Nothing else Just (read digits :: Double) + + trimNum x = let r = round x :: Integer + in if fromIntegral r == x then show r else printf "%.1f" x + +-- | The same children 'seriesCtx' lists, loadable from a bare item so the +-- statistics fields can reach them without duplicating the glob. +loadSeriesChildrenFor :: Item a -> Compiler [Item String] +loadSeriesChildrenFor 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 + loadAll pat + +-- | Bytes as the telemetry pages would print them. +humanBytes :: Integer -> String +humanBytes n + | n >= gb = printf "%.1f GB" (fromIntegral n / fromIntegral gb :: Double) + | n >= mb = printf "%.1f MB" (fromIntegral n / fromIntegral mb :: Double) + | n >= kb = printf "%.0f KB" (fromIntegral n / fromIntegral kb :: Double) + | otherwise = show n ++ " B" + where + kb = 1024 :: Integer + mb = kb * 1024 + gb = mb * 1024 + +-- | 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@ (defensive only — 'buildPin' validates +-- the precision and fails closed before consulting this function) +-- +-- @hidden@ and unrecognised values are handled at the call site by +-- skipping the pin entirely; this function is not consulted then. +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 +-- * @geo-precision:@ is anything other than @exact@/@km@/@city@ — +-- @hidden@ and unrecognised values (typos, wrong case) alike. +-- Failing closed means a typo'd \"hidden\" can never publish +-- coordinates the author meant to suppress. +-- * 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 (lat, lon), prec, Just r) + | maybe True (`elem` ["exact", "km", "city"]) prec -> + let prec' = fromMaybe "city" prec + rLat = roundCoord prec' lat + rLon = roundCoord prec' lon + fp = toFilePath ident + -- Directory entries (/index.md) and series children + -- (/.md) both key assets off the parent + -- directory; a flat single (content/photography/foo.md) + -- has no entry directory, so its slug is its basename and + -- its co-located assets route to /photography/ directly. + isFlat = takeDirectory fp == "content/photography" + && takeFileName fp /= "index.md" + slug = if isFlat then takeBaseName fp + else 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) -> + if isFlat then "/photography/" ++ p + else "/photography/" ++ slug ++ "/" ++ p + _ -> "" + captured = lookupString "captured" meta + -- Location and series let the map aggregate: photographs + -- rounded to the same city-level coordinate are one place, + -- and if they also share a series there is somewhere better + -- to send a click than an arbitrary one of them. + place = lookupString "location" meta + series = lookupString "series" 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 + ++ maybe [] (\l -> ["location" .= l]) place + ++ maybe [] (\x -> ["series" .= x]) series + _ -> 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" + <> constField "portal" "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 + -- Same asset-path derivation as 'buildPin': directory entries + -- (/index.md) and series children (/.md) + -- both key assets off the parent directory; a flat single + -- (content/photography/foo.md) has no entry directory, so its + -- co-located assets route to /photography/ directly. + isFlat = takeDirectory fp == "content/photography" + && takeFileName fp /= "index.md" + slug = takeFileName (takeDirectory fp) + imgTag = case lookupString "photo" meta of + Just p | not (null p) -> + let src = if isFlat then "/photography/" ++ p + else "/photography/" ++ slug ++ "/" ++ p + in "

    \"\"

    \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" + <> constField "portal" "true" + <> siteCtx + makeItem "" + >>= loadAndApplyTemplate + "templates/photography-contact-sheet.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + diff --git a/build/SimilarLinks.hs b/build/SimilarLinks.hs new file mode 100644 index 0000000..d12376f --- /dev/null +++ b/build/SimilarLinks.hs @@ -0,0 +1,195 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Similar-links field: injects a "Related" list into essay/page contexts. +-- +-- @data/similar-links.json@ is produced by @tools/embed.py@ at build time +-- (called from the Makefile after pagefind, before sign). It is a plain +-- JSON object mapping root-relative URL paths to lists of similar pages: +-- +-- { "/essays/my-essay/": [{"url": "...", "title": "...", "score": 0.87}] } +-- +-- This module loads that file with dependency tracking (so pages recompile +-- when embeddings change) and provides @similarLinksField@, which resolves +-- to an HTML list for the current page's URL. +-- +-- If the file is absent (e.g. @.venv@ not set up, or first build) the field +-- returns @noResult@ — the @$if(similar-links)$@ guard in the template is +-- false and no "Related" section is rendered. +module SimilarLinks (similarLinksField) where + +import Data.Maybe (fromMaybe) +import qualified Data.ByteString as BS +import qualified Data.Map.Strict as Map +import Data.Map.Strict (Map) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import qualified Data.Text.Encoding.Error as TE +import qualified Data.Aeson as Aeson +import Hakyll + +-- --------------------------------------------------------------------------- +-- JSON schema +-- --------------------------------------------------------------------------- + +data SimilarEntry = SimilarEntry + { seUrl :: String + , seTitle :: String + , seScore :: Double + } deriving (Show) + +instance Aeson.FromJSON SimilarEntry where + parseJSON = Aeson.withObject "SimilarEntry" $ \o -> + SimilarEntry + <$> o Aeson..: "url" + <*> o Aeson..: "title" + <*> o Aeson..: "score" + +-- --------------------------------------------------------------------------- +-- Context field +-- --------------------------------------------------------------------------- + +-- | Maximum entries rendered in the "Related" block. The on-disk JSON may +-- contain more (embed.py's TOP_N); 'similarLinksField' caps the list +-- (@take maxSimilar@) before rendering. +maxSimilar :: Int +maxSimilar = 3 + +-- | Provides @$similar-links$@ (HTML list) and @$has-similar-links$@ +-- (boolean flag for template guards). +-- Returns @noResult@ when the JSON file is absent, unparseable, or the +-- current page has no similar entries. +-- +-- Note on normalisation: 'tools/embed.py' emits map keys using the live +-- site URL (e.g. @/essays/foo.html@ for a flat page, @/essays/foo/@ for a +-- directory-index page), while Hakyll's route gives @essays/foo.html@. +-- 'normaliseUrl' collapses both forms to a canonical stem, and we apply +-- it to every JSON key on load so the lookup cannot miss. +similarLinksField :: Context String +similarLinksField = field "similar-links" $ \item -> do + -- Load with dependency tracking — pages recompile when the JSON changes. + slItem <- load (fromFilePath "data/similar-links.json") :: Compiler (Item String) + case Aeson.decodeStrict (TE.encodeUtf8 (T.pack (itemBody slItem))) + :: Maybe (Map T.Text [SimilarEntry]) of + Nothing -> fail "similar-links: could not parse data/similar-links.json" + Just rawMap -> do + mRoute <- getRoute (itemIdentifier item) + case mRoute of + Nothing -> fail "similar-links: item has no route" + Just r -> + let normMap = Map.mapKeys (T.pack . normaliseUrl . T.unpack) rawMap + key = T.pack (normaliseUrl ("/" ++ r)) + entries = take maxSimilar (fromMaybe [] (Map.lookup key normMap)) + in if null entries + then fail "no similar links" + else return (renderSimilarLinks entries) + +-- --------------------------------------------------------------------------- +-- URL normalisation (mirrors embed.py's URL derivation) +-- --------------------------------------------------------------------------- + +normaliseUrl :: String -> String +normaliseUrl url = + let t = T.pack url + -- strip query + fragment + t1 = fst (T.breakOn "?" (fst (T.breakOn "#" t))) + -- ensure leading slash + t2 = if T.isPrefixOf "/" t1 then t1 else "/" `T.append` t1 + -- strip trailing index.html → keep the directory slash + t3 = fromMaybe t2 (T.stripSuffix "index.html" t2) + -- strip bare .html extension only for non-index pages + t4 = fromMaybe t3 (T.stripSuffix ".html" t3) + in percentDecode (T.unpack t4) + +-- | Percent-decode @%XX@ escapes (UTF-8) so percent-encoded paths +-- collide with their decoded form on map lookup. Mirrors +-- 'Backlinks.percentDecode' (and 'Backlinks.normaliseUrl' now applies +-- the same strip-@index.html@-then-@.html@ normalisation as this +-- module); the duplication keeps the two modules dependency-free of +-- each other. +percentDecode :: String -> String +percentDecode = T.unpack . TE.decodeUtf8With TE.lenientDecode . BS.pack . go + where + go [] = [] + go ('%':a:b:rest) + | Just hi <- hexDigit a + , Just lo <- hexDigit b + = fromIntegral (hi * 16 + lo) : go rest + go (c:rest) = fromIntegral (fromEnum c) : go rest + + hexDigit c + | c >= '0' && c <= '9' = Just (fromEnum c - fromEnum '0') + | c >= 'a' && c <= 'f' = Just (fromEnum c - fromEnum 'a' + 10) + | c >= 'A' && c <= 'F' = Just (fromEnum c - fromEnum 'A' + 10) + | otherwise = Nothing + +-- | Percent-encode a string for use as a URI query value: RFC 3986 +-- unreserved characters pass through; everything else — including @&@, +-- @?@, @#@, spaces, and non-ASCII text via its UTF-8 bytes — becomes +-- @%XX@. Hand-rolled (the moral equivalent of network-uri's +-- @escapeURIString isUnreserved@) because network-uri is not otherwise +-- a dependency. The output is also HTML-attribute-safe: it contains +-- only unreserved characters and @%XX@ escapes. +percentEncode :: String -> String +percentEncode = concatMap enc . BS.unpack . TE.encodeUtf8 . T.pack + where + enc b + | unreserved b = [toEnum (fromIntegral b)] + | otherwise = ['%', hexDigit (b `div` 16), hexDigit (b `mod` 16)] + unreserved b = + let c = toEnum (fromIntegral b) :: Char + in (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c `elem` ("-._~" :: String) + hexDigit n = "0123456789ABCDEF" !! fromIntegral n + +-- --------------------------------------------------------------------------- +-- HTML rendering +-- --------------------------------------------------------------------------- + +-- | Render the Related block. Each anchor gets: +-- * @class="similar-link"@ — whitelist for popups.js so the default +-- footer-exclusion does not fire (content preview on hover). +-- * @data-link-icon@ / @data-link-icon-type@ — page or document icon, +-- rendered via the existing a[data-link-icon] mask-image system in +-- typography.css. +-- * For PDFs: @class="pdf-link"@ + @data-pdf-src@ + href rewritten to +-- the PDF.js viewer, matching the rest of the site. The pdfContent +-- provider in popups.js binds on @.pdf-link[data-pdf-src]@. +renderSimilarLinks :: [SimilarEntry] -> String +renderSimilarLinks entries = + "" + where + renderOne se + | isPdfUrl (seUrl se) = renderPdf se + | otherwise = renderPage se + + renderPage se = + "\n" + + renderPdf se = + -- The PDF path becomes the @file=@ query value, so it must be + -- percent-encoded (HTML escaping alone leaves @&@/@?@/@#@/spaces + -- free to break the query). A @#page=N@ fragment stays a fragment + -- of the viewer URL itself — PDF.js reads it from location.hash. + let raw = seUrl se + (path, frag) = break (== '#') raw + viewerUrl = "/pdfjs/web/viewer.html?file=" + ++ percentEncode path ++ escapeHtml frag + in "\n" + + isPdfUrl u = + let lower = T.toLower (T.pack u) + (path, _) = T.break (== '#') lower + in ".pdf" `T.isSuffixOf` path diff --git a/build/Site.hs b/build/Site.hs new file mode 100644 index 0000000..b2b13bb --- /dev/null +++ b/build/Site.hs @@ -0,0 +1,1277 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +module Site (rules) where + +import Control.Monad (forM, forM_, when) +import Data.Char (isSpace, toUpper) +import Data.List (groupBy, isPrefixOf, sort, sortBy, stripPrefix) +import Data.Map.Strict (Map) +import Data.Maybe (catMaybes, fromMaybe, listToMaybe) +import Data.Ord (Down (..), comparing) +import Data.Set (Set) +import qualified Data.Set as Set +import qualified Data.Text as T +import System.Directory (listDirectory) +import System.Environment (lookupEnv) +import System.FilePath (takeDirectory, takeFileName, takeExtension, replaceExtension, ()) +import Text.Read (readMaybe) +import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy.Char8 as LBS +import qualified Data.Map.Strict as Map +import Hakyll +import Archive (archiveRules) +import Authors (buildAllAuthors, applyAuthorRules) +import Backlinks (backlinkRules) +import BibExtras (BibExtra (..), emptyBibExtra, firstAuthorSurname, parseBibExtras) +import Citations (renderBibliographyHtml) +import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fictionCompiler, + compositionCompiler, sidecarCompiler) +import Catalog (musicCatalogCtx) +import Commonplace (commonplaceCtx) +import Now (nowCtx) +import Vita (vitaCtx, projectsCtx) +import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx, + contentKindField, declaresScore, recentFirstByDisplay, + tagLinksFieldExcludingTopSegment, isProvedConfidence) +import qualified Patterns as P +import Photography (photographyRules) +import Tags (buildAllTags, applyTagRules, sidecarIdentifier, + portalIntroField, portalTooltipField) +import Pagination (blogPaginateRules) +import Stats (statsRules) + +-- | Home-page portal grid order. Canonical ordering authority for every +-- rendering of the portals (currently: the home page; future +-- consumers follow this list). Each entry is (display name, tag name); +-- the tag name is the key to everything else — URL (@/\/@), +-- sidecar path (@content\/tag-meta\/\.md@), and the Tags.hs +-- machinery that already keys off it. Edit this list to change order; +-- do not introduce an @order:@ frontmatter field on sidecars. +homePortals :: [(String, String)] +homePortals = + [ ("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 +-- 'featured:' list may push this up to 'libraryShelfMax'. +libraryShelfCap :: Int +libraryShelfCap = 4 + +-- | Hard ceiling on cards per shelf, regardless of sidecar length. +libraryShelfMax :: Int +libraryShelfMax = 5 + +-- | Optional prose intro lifted into @$library-intro$@ on the library +-- page. Matched but not routed; consumed via the @"body"@ snapshot. +libraryIntroId :: Identifier +libraryIntroId = fromFilePath "content/library.md" + +-- | Route that strips a literal prefix from the identifier's path. +-- Hakyll's 'gsubRoute' replaces /every/ occurrence of its pattern, so +-- @gsubRoute "content/"@ would also mangle a co-located directory that +-- happened to be named @content@ deeper in the path +-- (@content/essays/slug/content/data.csv@ → @essays/slug/data.csv@). +-- This touches only the leading occurrence; identifiers that don't +-- start with the prefix pass through unchanged. +stripPrefixRoute :: String -> Routes +stripPrefixRoute prefix = customRoute $ \ident -> + let fp = toFilePath ident + in fromMaybe fp (stripPrefix prefix fp) + +feedConfig :: FeedConfiguration +feedConfig = FeedConfiguration + { feedTitle = "Levi Neuwirth" + , feedDescription = "Essays, notes, and creative work by Levi Neuwirth" + , feedAuthorName = "Levi Neuwirth" + , feedAuthorEmail = "levi@levineuwirth.org" + , feedRoot = "https://levineuwirth.org" + } + +musicFeedConfig :: FeedConfiguration +musicFeedConfig = FeedConfiguration + { feedTitle = "Levi Neuwirth — Music" + , feedDescription = "New compositions by Levi Neuwirth" + , feedAuthorName = "Levi Neuwirth" + , feedAuthorEmail = "levi@levineuwirth.org" + , feedRoot = "https://levineuwirth.org" + } + +-- | Context for the home page. Extends 'pageCtx' with a @portals@ +-- listField iterating 'homePortals' in order. Each item exposes +-- @$portal-name$@, @$portal-url$@, and (if the sidecar's tooltip is +-- populated) @$portal-tooltip$@, consumed by @templates/home.html@. +-- Tooltip lookup uses 'portalTooltipField' — the same function +-- 'Tags.applyTagRules' uses on per-tag pages — so the two surfaces +-- stay in lockstep on suppression and missing-file semantics. +homeCtx :: Context String +homeCtx = listField "portals" portalItemCtx portalItems <> pageCtx + where + portalItems :: Compiler [Item (String, String)] + portalItems = return (map (Item (fromFilePath "")) homePortals) + + portalItemCtx :: Context (String, String) + portalItemCtx = + field "portal-name" (return . fst . itemBody) + <> field "portal-url" (\i -> return $ "/" ++ snd (itemBody i) ++ "/") + <> portalTooltipField (sidecarIdentifier . snd . itemBody) + +rules :: Rules () +rules = do + -- --------------------------------------------------------------------------- + -- Build mode. SITE_ENV=dev (set by `make dev` / `make watch`) includes + -- drafts under content/drafts/**; anything else (unset, "deploy", "build") + -- excludes them entirely from every match, listing, and asset rule below. + -- --------------------------------------------------------------------------- + isDev <- preprocess $ (== Just "dev") <$> lookupEnv "SITE_ENV" + let allEssays = if isDev + then P.essayPattern .||. P.draftEssayPattern + else P.essayPattern + + -- --------------------------------------------------------------------------- + -- Backlinks (pass 1: link extraction; pass 2: JSON generation) + -- Must run before content rules so dependencies resolve correctly. + -- --------------------------------------------------------------------------- + backlinkRules + + -- --------------------------------------------------------------------------- + -- Author index pages + -- --------------------------------------------------------------------------- + authors <- buildAllAuthors + applyAuthorRules authors siteCtx + + -- --------------------------------------------------------------------------- + -- Tag-meta sidecars — optional prose intros + tooltips for tag index + -- pages and the home-page portal grid. Matched but not routed: the + -- rendered body is exposed only via the @"body"@ snapshot and the + -- @tooltip:@ frontmatter key is read through 'getMetadata' by the + -- consumers (Tags.hs, home-page rule). Registered before tag rules so + -- snapshot loads during tag-page compilation find a compiled target. + -- + -- Two-pattern union: Hakyll's @**/*@ glob requires at least one + -- subdirectory level, so flat sidecars (@content/tag-meta/nonfiction.md@) + -- and nested sidecars (@content/tag-meta/nonfiction/philosophy.md@) must + -- each be named by their own level-specific pattern. + -- --------------------------------------------------------------------------- + match ("content/tag-meta/*.md" .||. "content/tag-meta/**/*.md") $ + compile sidecarCompiler + + -- --------------------------------------------------------------------------- + -- Tag index pages + -- --------------------------------------------------------------------------- + tags <- buildAllTags + applyTagRules tags homePortals siteCtx + statsRules tags + + -- Per-page JS files — authored alongside content in content/**/*.js. + -- Draft JS is handled by a separate dev-only rule below. + match ("content/**/*.js" .&&. complement "content/drafts/**") $ do + route $ stripPrefixRoute "content/" + compile copyFileCompiler + + -- Per-page JS co-located with draft essays (dev-only). + when isDev $ match "content/drafts/**/*.js" $ do + route $ stripPrefixRoute "content/" + compile copyFileCompiler + + -- CSS — must be matched before the broad static/** rule to avoid + -- double-matching (compressCssCompiler vs. copyFileCompiler). + match "static/css/*" $ do + route $ stripPrefixRoute "static/" + compile compressCssCompiler + + -- 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 $ stripPrefixRoute "static/" + compile copyFileCompiler + + -- Templates + match "templates/**" $ compile templateBodyCompiler + + -- --------------------------------------------------------------------------- + -- Source-preview corpus — raw copies of source files, served at + -- @/source/@, fetched on hover by the popup provider in + -- @static/js/popups.js@ (sourceContent → Prism highlighting). + -- + -- Conservative whitelist: must stay aligned with 'isSourcePath' in + -- @build/Filters/SourceRefs.hs@ so that every link the filter + -- emits has a corresponding @/source/…@ target. Files in @static/@ + -- are also served under their normal /js/, /css/ paths via a + -- separate rule above; the @"source-preview"@ version lets Hakyll + -- compile the same identifier twice without conflict. + -- + -- Anything not matched here will silently 404 on hover and the + -- popup will simply not appear, which is the right failure mode + -- if the heuristic ever wraps a path we did not mean to expose. + -- --------------------------------------------------------------------------- + let sourcePreviewable = + "build/**.hs" + .||. "static/js/**" + .||. "static/css/**" + .||. "templates/**" + .||. "tools/**.sh" + .||. "tools/**.py" + .||. "nginx/**.conf" + .||. "data/*.json" + .||. "data/*.yaml" + .||. "data/*.md" + .||. "data/*.bib" + .||. "*.cabal" + .||. "cabal.project" + .||. "cabal.project.freeze" + .||. "Makefile" + .||. "pyproject.toml" + .||. "uv.lock" + .||. "LICENSE" + .||. "checklist.md" + .||. "WRITING.md" + .||. "HOMEPAGE.md" + .||. "PHOTOGRAPHY.md" + .||. "README.md" + match sourcePreviewable $ version "source-preview" $ do + route $ customRoute (\ident -> "source/" ++ toFilePath ident) + compile copyFileCompiler + + -- Link annotations — author-defined previews for any URL + match "data/annotations.json" $ do + route idRoute + compile copyFileCompiler + + -- Semantic search index — produced by tools/embed.py; fetched at runtime + -- by static/js/semantic-search.js from /data/semantic-index.bin and + -- /data/semantic-meta.json. + match ("data/semantic-index.bin" .||. "data/semantic-meta.json") $ do + route idRoute + compile copyFileCompiler + + -- Similar links — produced by tools/embed.py; absent on first build or + -- when .venv is not set up. Compiled as a raw string for similarLinksField. + match "data/similar-links.json" $ compile getResourceBody + + -- Commonplace YAML — compiled as a raw string so it can be loaded + -- with dependency tracking by the commonplace page compiler. + match "data/commonplace.yaml" $ compile getResourceBody + + -- Now YAML — same pattern as commonplace. Loaded by Now.nowCtx for + -- /current.html. Re-compiles current.html when the YAML changes. + match "data/now.yaml" $ compile getResourceBody + + -- CV/résumé YAML — the same files the PDF pipeline builds from + -- (yaml-source/data/). Loaded by Vita.vitaCtx so /about.html renders + -- education, publications, presentations, and experience from one + -- source instead of a hand-typed copy. Matching them here is what + -- makes about.html recompile when the CV data changes. + match "yaml-source/data/*.yml" $ compile getResourceBody + + -- Per-build stamp — written by Main.main before Hakyll starts, so it + -- always exists and always differs from the previous run. Matched + -- (not routed) purely so the telemetry pages can `load` it as a + -- dependency and thus recompile every build instead of serving a + -- stale cached copy. See build/Stats.hs. + match "data/build-stamp.txt" $ compile getResourceBody + + -- --------------------------------------------------------------------------- + -- Homepage + -- --------------------------------------------------------------------------- + match "content/index.md" $ do + route $ constRoute "index.html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/home.html" homeCtx + >>= loadAndApplyTemplate "templates/default.html" homeCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Standalone pages (me/, colophon.md, …) + -- --------------------------------------------------------------------------- + -- me/index.md — compiled as a full essay (TOC, metadata block, sidenotes). + -- Lives in its own directory so co-located SVG score fragments resolve + -- correctly: the Score filter reads paths relative to the source file's + -- directory (content/me/), not the content root. + match "content/me/index.md" $ do + route $ constRoute "me.html" + compile $ essayCompiler + >>= loadAndApplyTemplate "templates/essay.html" essayCtx + >>= loadAndApplyTemplate "templates/default.html" essayCtx + >>= relativizeUrls + + -- SVG score fragments co-located with me/index.md. + match "content/me/scores/*.svg" $ do + route $ stripPrefixRoute "content/me/" + compile copyFileCompiler + + -- memento-mori/index.md — lives in its own directory so co-located SVG + -- score fragments resolve correctly (same pattern as me/index.md). + match "content/memento-mori/index.md" $ do + route $ constRoute "memento-mori.html" + compile $ essayCompiler + >>= loadAndApplyTemplate "templates/essay.html" + (constField "memento-mori" "true" <> essayCtx) + >>= loadAndApplyTemplate "templates/default.html" + (constField "memento-mori" "true" <> essayCtx) + >>= relativizeUrls + + -- SVG score fragments co-located with memento-mori/index.md. + match "content/memento-mori/scores/*.svg" $ do + route $ stripPrefixRoute "content/memento-mori/" + compile copyFileCompiler + + -- --------------------------------------------------------------------------- + -- Commonplace book + -- --------------------------------------------------------------------------- + match "content/commonplace.md" $ do + route $ constRoute "commonplace.html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/commonplace.html" commonplaceCtx + >>= loadAndApplyTemplate "templates/default.html" commonplaceCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Now — research-first status page driven by data/now.yaml. Same + -- structural pattern as the commonplace page: markdown body + -- (optional intro prose) + structured YAML rendered into HTML by + -- Now.nowCtx, then assembled by templates/current.html. + -- --------------------------------------------------------------------------- + match "content/current.md" $ do + route $ constRoute "current.html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/current.html" nowCtx + >>= loadAndApplyTemplate "templates/default.html" nowCtx + >>= relativizeUrls + + match "content/colophon.md" $ do + route $ constRoute "colophon.html" + compile $ essayCompiler + >>= loadAndApplyTemplate "templates/essay.html" essayCtx + >>= loadAndApplyTemplate "templates/default.html" essayCtx + >>= relativizeUrls + + -- Vita — the academic-homepage view, driven by yaml-source/data/*.yml. + -- Prose (research interests, pointers, contact) stays in the markdown + -- body; education, publications, presentations, and experience are + -- generated by Vita.vitaCtx and assembled by templates/vita.html. + match "content/about.md" $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/vita.html" vitaCtx + >>= loadAndApplyTemplate "templates/default.html" vitaCtx + >>= relativizeUrls + + match ("content/*.md" + .&&. complement "content/index.md" + .&&. complement "content/about.md" + .&&. complement "content/commonplace.md" + .&&. complement "content/colophon.md" + .&&. complement "content/current.md" + .&&. complement "content/library.md") $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/page.html" pageCtx + >>= loadAndApplyTemplate "templates/default.html" pageCtx + >>= relativizeUrls + + -- Generic page collections + -- (content//.md → /.html). + match P.pageCollectionPattern $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/page.html" pageCtx + >>= loadAndApplyTemplate "templates/default.html" pageCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- CV routing pages (content/cv/*.md → /cv//). + -- These are narrative overlays pointing into the library; they render + -- with the same page.html pipeline as top-level standalone pages, but + -- route to directory-style URLs (/cv/projects/ rather than /cv/projects.html) + -- so nginx serves them via index-file resolution and the URLs stay stable + -- if the underlying files are later reorganized into co-located directories. + -- --------------------------------------------------------------------------- + -- The project index is generated from yaml-source/data/projects.yml — + -- the same file that feeds the CV's and résumé's project sections — so + -- it gets projectsCtx and its own template. The markdown keeps only the + -- page's framing prose. Individual project writeups stay as essays. + match "content/cv/projects.md" $ do + route $ constRoute "cv/projects/index.html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/projects.html" projectsCtx + >>= loadAndApplyTemplate "templates/default.html" projectsCtx + >>= relativizeUrls + + match ("content/cv/*.md" .&&. complement "content/cv/projects.md") $ do + route $ customRoute $ \ident -> + let fname = takeFileName (toFilePath ident) + slug = takeWhile (/= '.') fname + in "cv/" ++ slug ++ "/index.html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/page.html" pageCtx + >>= loadAndApplyTemplate "templates/default.html" pageCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Essays — flat (content/essays/foo.md → essays/foo.html) and + -- directory-based (content/essays/slug/index.md → essays/slug/index.html). + -- In dev mode, drafts under content/drafts/essays/ route to + -- drafts/essays/foo.html (flat) or drafts/essays/slug/index.html (dir). + -- --------------------------------------------------------------------------- + match allEssays $ do + route $ customRoute $ \ident -> + let fp = toFilePath ident + fname = takeFileName fp + isIndex = fname == "index.md" + isDraft = "content/drafts/essays/" `isPrefixOf` fp + stripContent = fromMaybe fp (stripPrefix "content/" fp) + isNested = takeDirectory fp /= "content/essays" + in case (isDraft, isIndex) of + -- content/drafts/essays/slug/index.md → drafts/essays/slug/index.html + (True, True) -> replaceExtension stripContent "html" + -- content/drafts/essays/foo.md → drafts/essays/foo.html + (True, False) -> "drafts/essays/" ++ replaceExtension fname "html" + -- Published directory essays and collection entries retain + -- their directory path below content/. + (False, _) | isNested -> replaceExtension stripContent "html" + -- content/essays/foo.md → essays/foo.html + (False, False) -> "essays/" ++ replaceExtension fname "html" + (False, True) -> replaceExtension stripContent "html" + compile $ essayCompiler + >>= saveSnapshot "content" + >>= loadAndApplyTemplate "templates/essay.html" essayCtx + >>= loadAndApplyTemplate "templates/default.html" essayCtx + >>= relativizeUrls + + -- 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 P.essayPattern + .&&. complement "content/essays/**/*.dims.yaml") $ do + route $ stripPrefixRoute "content/" + 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" + .&&. complement "content/drafts/essays/**/*.dims.yaml") $ do + route $ stripPrefixRoute "content/" + compile copyFileCompiler + + -- --------------------------------------------------------------------------- + -- Blog posts + -- --------------------------------------------------------------------------- + match P.blogPattern $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ postCompiler + >>= saveSnapshot "content" + >>= loadAndApplyTemplate "templates/blog-post.html" postCtx + >>= loadAndApplyTemplate "templates/default.html" postCtx + >>= relativizeUrls + + -- Blog collection index pages + -- (content/blog//index.md → blog//index.html). + match "content/blog/*/index.md" $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/default.html" pageCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Poetry + -- --------------------------------------------------------------------------- + -- All poems — flat (content/poetry/sonnet-60.md) and collection + -- (content/poetry/shakespeare-sonnets/sonnet-1.md) forms share one + -- rule; collection index pages are excluded by 'P.poetryPattern' + -- itself and matched separately below. + match P.poetryPattern $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ poetryCompiler + >>= saveSnapshot "content" + >>= loadAndApplyTemplate "templates/reading.html" poetryCtx + >>= loadAndApplyTemplate "templates/default.html" poetryCtx + >>= relativizeUrls + + -- Collection index pages (e.g. content/poetry/shakespeare-sonnets/index.md) + match "content/poetry/*/index.md" $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/default.html" pageCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Fiction + -- --------------------------------------------------------------------------- + match P.fictionPattern $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ fictionCompiler + >>= saveSnapshot "content" + >>= loadAndApplyTemplate "templates/reading.html" fictionCtx + >>= loadAndApplyTemplate "templates/default.html" fictionCtx + >>= relativizeUrls + + -- Fiction collection index pages + -- (content/fiction//index.md → fiction//index.html). + match "content/fiction/*/index.md" $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/default.html" pageCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Music — catalog index + -- --------------------------------------------------------------------------- + match "content/music/index.md" $ do + route $ constRoute "music/index.html" + compile $ pageCompiler + >>= loadAndApplyTemplate "templates/music-catalog.html" musicCatalogCtx + >>= loadAndApplyTemplate "templates/default.html" musicCatalogCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Music — composition landing pages + score reader + -- --------------------------------------------------------------------------- + + -- Static assets (SVG score pages, audio, PDF) served unchanged. + match "content/music/**/*.svg" $ do + route $ stripPrefixRoute "content/" + compile copyFileCompiler + + match "content/music/**/*.mp3" $ do + route $ stripPrefixRoute "content/" + compile copyFileCompiler + + match "content/music/**/*.pdf" $ do + route $ stripPrefixRoute "content/" + compile copyFileCompiler + + -- Landing page — full essay pipeline. + match "content/music/*/index.md" $ do + route $ stripPrefixRoute "content/" + `composeRoutes` setExtension "html" + compile $ compositionCompiler + >>= saveSnapshot "content" + >>= loadAndApplyTemplate "templates/composition.html" compositionCtx + >>= loadAndApplyTemplate "templates/default.html" compositionCtx + >>= relativizeUrls + + -- Score reader — separate URL, minimal chrome. + -- Compiled from the same source with version "score-reader". + -- + -- Gated on the composition declaring a score. Ungated, every + -- composition got a live /music//score/ whose reader had nothing + -- to show: an empty , a "p. 1 / 0" counter, and an enabled Next + -- button. 'declaresScore' is metadata-only because 'matchMetadata' + -- runs before the Compiler monad exists — a 'score-dir' naming an + -- empty directory still routes a page, which 'scorePageList' then + -- reports honestly as zero pages. + matchMetadata "content/music/*/index.md" declaresScore $ + version "score-reader" $ do + route $ customRoute $ \ident -> + let slug = takeFileName . takeDirectory . toFilePath $ ident + in "music/" ++ slug ++ "/score/index.html" + compile $ do + makeItem "" + >>= loadAndApplyTemplate "templates/score-reader.html" + compositionCtx + >>= loadAndApplyTemplate "templates/score-reader-default.html" + compositionCtx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Photography — single-photo entries, asset copy, and section landing. + -- See build/Photography.hs and PHOTOGRAPHY.md for the design. + -- --------------------------------------------------------------------------- + photographyRules + + -- --------------------------------------------------------------------------- + -- Archive — link-archiving system: per-entry /archive// pages and + -- the /archive/ index, driven by archive/manifest.yaml + PROVENANCE.json. + -- See build/Archive.hs and ARCHIVE.md for the design. + -- --------------------------------------------------------------------------- + archiveRules + + -- --------------------------------------------------------------------------- + -- Blog index (paginated) + -- --------------------------------------------------------------------------- + blogPaginateRules postCtx siteCtx + + -- --------------------------------------------------------------------------- + -- Essay index + -- --------------------------------------------------------------------------- + create ["essays/index.html"] $ do + route idRoute + compile $ do + essays <- recentFirst =<< loadAll (allEssays .&&. hasNoVersion) + let ctx = + listField "essays" essayCtx (return essays) + <> constField "title" "Essays" + <> constField "portal" "true" + <> siteCtx + makeItem "" + >>= loadAndApplyTemplate "templates/essay-index.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Poetry index + -- --------------------------------------------------------------------------- + -- Nav, the home portal grid, and the library all link /poetry/; this + -- rule is what keeps those links from 404ing. Lists flat poems and + -- collection poems alike; collection index pages are excluded by + -- 'P.poetryPattern' itself. + create ["poetry/index.html"] $ do + route idRoute + compile $ do + poems <- recentFirst =<< loadAll (P.poetryPattern .&&. hasNoVersion) + let ctx = + listField "essays" poetryCtx (return poems) + <> constField "title" "Poetry" + <> constField "portal" "true" + <> siteCtx + makeItem "" + >>= loadAndApplyTemplate "templates/essay-index.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Fiction index + -- --------------------------------------------------------------------------- + -- Same rationale as the poetry index. content/fiction/ has no entries + -- yet; an empty match list renders an empty index rather than a 404. + create ["fiction/index.html"] $ do + route idRoute + compile $ do + stories <- recentFirst =<< loadAll (P.fictionPattern .&&. hasNoVersion) + let ctx = + listField "essays" fictionCtx (return stories) + <> constField "title" "Fiction" + <> constField "portal" "true" + <> siteCtx + makeItem "" + >>= loadAndApplyTemplate "templates/essay-index.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- New page — all content sorted by creation date, newest first + -- --------------------------------------------------------------------------- + create ["new.html"] $ do + route idRoute + compile $ do + let allContent = ( allEssays + .||. P.blogPattern + .||. P.fictionPattern + .||. P.poetryPattern + .||. P.musicPattern + ) .&&. hasNoVersion + items <- recentFirstByDisplay =<< loadAll allContent + let itemCtx = contentKindField + <> essayCtx + ctx = listField "recent-items" itemCtx (return items) + <> constField "title" "New" + <> constField "list-page" "true" + <> siteCtx + makeItem "" + >>= loadAndApplyTemplate "templates/new.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Library intro — optional prose block (typically a blockquote) lifted + -- into @$library-intro$@ at the top of /library.html. Matched but not + -- routed; the body snapshot is consumed by the library rule below. + -- --------------------------------------------------------------------------- + match "content/library.md" $ compile sidecarCompiler + + -- --------------------------------------------------------------------------- + -- Library — portal-grouped view over the /new.html dataset, deduplicated + -- by primary portal. An item's primary portal is the top segment of the + -- first tag in its frontmatter 'tags:' list whose top segment matches a + -- known portal (those in 'homePortals'). Items with no such tag are + -- silently dropped from the library (they remain on /new.html and on any + -- tag pages their frontmatter produces). + -- + -- Each shelf is capped at 'libraryShelfCap' items by default. A portal's + -- tag-meta sidecar may carry a 'featured:' list of content-rooted paths + -- (e.g. @content/essays/foo.md@); featured items are placed first, in + -- listed order, and the remainder is filled by recency up to a hard + -- ceiling of 'libraryShelfMax'. Featured paths that don't resolve to an + -- item in the portal (wrong primary portal, or typo) are silently + -- dropped. When the unfiltered portal has more items than the shelf + -- shows, @$-has-more$@ is exposed so the template can render a + -- "More on this shelf →" affordance linking to the portal's tag page. + -- + -- Each card uses the shared item-card partial, with cross-portal filings + -- rendered in the card's tag footer via 'tagLinksFieldExcludingTopSegment', + -- scoped to the section's portal so the portal's own tag is suppressed. + -- --------------------------------------------------------------------------- + create ["library.html"] $ do + route idRoute + compile $ do + sidecarIds <- getMatches ("content/tag-meta/*.md" + .||. "content/tag-meta/**/*.md") + let sidecarSet = Set.fromList sidecarIds + knownPortals = map snd homePortals + + -- Top segment of the first tag that names a known portal. + -- Nothing when no tag matches — item is excluded from library. + -- Reads tags via 'getTags' (not lookupStringList) so the + -- scalar comma form ("tags: research, ai") is accepted with + -- the same semantics the tag pages use. + primaryPortalOf item = do + ts <- getTags (itemIdentifier item) + return $ listToMaybe + [ p | t <- ts + , let p = takeWhile (/= '/') t + , p `elem` knownPortals ] + + -- Per-section item context: kind badge, ISO date for datetime + -- attr, human-readable display date via essayCtx's dateDisplayField, + -- abstract via siteCtx's abstractField, and cross-portal filings + -- in the footer. Suppression is top-segment-based (hide every + -- tag under the section's portal, not just the exact match) so + -- a Research-section card doesn't re-list its research/* filings + -- alongside the section heading. @full-abstract@ unclamps the + -- card's 2-line abstract truncation — Library is the canonical + -- browsing surface and shows full abstracts. + portalItemCtx p = + contentKindField + <> tagLinksFieldExcludingTopSegment "item-tags" p + <> constField "full-abstract" "true" + <> essayCtx + + -- 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 once per portal. + essays <- loadAll (allEssays .&&. hasNoVersion) + posts <- loadAll (P.blogPattern .&&. hasNoVersion) + fiction <- loadAll (P.fictionPattern .&&. hasNoVersion) + poetry <- loadAll (P.poetryPattern .&&. hasNoVersion) + music <- loadAll (P.musicPattern .&&. hasNoVersion) + 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] + itemsByPortal = + Map.fromListWith (++) [(p, [i]) | (Just p, i) <- tagged] + + -- Existence-guarded, like the sidecar contexts in Tags.hs: + -- deleting content/library.md degrades to a library page with + -- no intro block rather than failing the whole compile. When + -- the file exists, the eager snapshot load registers the + -- library-intro dependency unconditionally, so a first-populate + -- of content/library.md re-renders the library page even when + -- the gate was previously false (see 'sidecarContext' in + -- Tags.hs for the same pattern). + introIds <- getMatches "content/library.md" + libraryIntroFld <- + if libraryIntroId `elem` introIds + then do + _ <- loadSnapshot libraryIntroId "body" :: Compiler (Item String) + return $ field "library-intro" $ \_ -> do + html <- itemBody <$> loadSnapshot libraryIntroId "body" + if all isSpace html + then noResult "empty library intro" + else return html + else return mempty + + -- One shelf's context contribution: the @-entries@ + -- listField (or absent via noResult when the shelf is + -- empty) plus an optional @-has-more@ gate. + let portalSection p = do + let portalItems = fromMaybe [] (Map.lookup p itemsByPortal) + sorted <- recentFirstByDisplay portalItems + + featuredPaths <- + if sidecarIdentifier p `Set.member` sidecarSet + then do + meta <- getMetadata (sidecarIdentifier p) + return (fromMaybe [] (lookupStringList "featured" meta)) + else return [] + + let portalIdSet = + Set.fromList (map itemIdentifier portalItems) + featuredItems = + [ i + | path <- featuredPaths + , let ident = fromFilePath path + , ident `Set.member` portalIdSet + , Just i <- [listToMaybe + (filter ((== ident) . itemIdentifier) portalItems)] + ] + cap = min libraryShelfMax + (max libraryShelfCap (length featuredItems)) + featuredIds = + Set.fromList (map itemIdentifier featuredItems) + rest = filter (\i -> not (itemIdentifier i `Set.member` featuredIds)) sorted + merged = take cap (featuredItems ++ rest) + + let entriesFld = + listField (p ++ "-entries") (portalItemCtx p) + (if null merged + then noResult ("no items in portal " ++ p) + else return merged) + hasMoreFld + | length portalItems > length merged = + constField (p ++ "-has-more") "true" + | otherwise = mempty + + return (entriesFld <> hasMoreFld) + + -- Section order follows homePortals — single ordering authority. + sections <- mapM portalSection knownPortals + + let ctx = mconcat sections + <> libraryIntroFld + <> constField "title" "Library" + <> constField "library" "true" + <> constField "portal" "true" + <> siteCtx + + makeItem "" + >>= loadAndApplyTemplate "templates/library.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Bibliography — synthetic index + per-keyword pages (Phase 6b). + -- --------------------------------------------------------------------------- + -- Bibliography-meta sidecars: same shape as tag-meta, used by the + -- per-keyword pages for the prose intro and (future) tooltips. No + -- route; body snapshot consumed by the keyword rule. + match ("content/bibliography-meta/*.md" .||. "content/bibliography-meta/**/*.md") $ + compile sidecarCompiler + + -- Collect the universe of keywords at rule-gen time. Two sources: + -- * @keywords:@ fields across all @data/*.bib@ entries + -- * @keywords:@ frontmatter across all essays + blog + poetry + + -- fiction + music-composition pages + -- The union drives which @/bibliography/\/@ pages get generated; + -- keywords with no referents anywhere are not synthesized into pages. + bibFilePaths <- preprocess $ do + files <- listDirectory "data" + return $ sort [ "data" f | f <- files, takeExtension f == ".bib" ] + + bibExtrasAll <- preprocess $ + Map.unions <$> mapM parseBibExtras bibFilePaths + + let bibKwMap :: Map String [String] + bibKwMap = invertKeywordsBib bibExtrasAll + + writingIds <- getMatches $ (P.essayPattern + .||. P.blogPattern + .||. P.fictionPattern + .||. P.poetryPattern + .||. P.musicPattern) + .&&. hasNoVersion + + writingKwPairs <- forM writingIds $ \ident -> do + meta <- getMetadata ident + let kws = readKeywords meta + return (ident, kws) + + let writingKwMap :: Map String [Identifier] + writingKwMap = invertKeywordsWritings writingKwPairs + + -- Keywords with at least one referent (writing OR bib entry). + allKeywords :: Set String + allKeywords = Set.union (Map.keysSet bibKwMap) (Map.keysSet writingKwMap) + + -- Identifiers of bibliography-meta sidecars that exist on disk, + -- used to optionally inject $portal-intro$ + $portal-tooltip$ on + -- keyword pages when the author populates a sidecar. + bibMetaIds <- getMatches ("content/bibliography-meta/*.md" + .||. "content/bibliography-meta/**/*.md") + let bibMetaSet = Set.fromList bibMetaIds + + -- /bibliography/index.html — every entry across every .bib file. + -- Sort: ascending by first-author surname, year-descending within + -- author (scholarly convention). + create ["bibliography/index.html"] $ do + route idRoute + compile $ do + let sortedKeys = bibliographyIndexOrder bibExtrasAll + grouped = groupByLetter bibExtrasAll sortedKeys + present = map fst grouped + html <- unsafeCompiler $ do + parts <- forM grouped $ \(letter, keys) -> do + body <- renderBibliographyHtml bibFilePaths bibExtrasAll keys + return (renderLetterHeader letter <> body) + return (renderBibliographyAlphabet present <> T.concat parts) + let ctx = constField "title" "Bibliography" + <> constField "bibliography-index" "true" + <> constField "bibliography-entries" (T.unpack html) + <> constField "library" "true" -- reuse flag to load library.css + item-card.css + <> constField "portal" "true" + <> siteCtx + makeItem "" + >>= loadAndApplyTemplate "templates/bibliography-index.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + + -- /bibliography//index.html for each keyword in the union. + forM_ (Set.toList allKeywords) $ \kw -> + create [fromFilePath ("bibliography/" ++ kw ++ "/index.html")] $ do + route idRoute + compile $ do + -- Writings section + let wIds = fromMaybe [] (Map.lookup kw writingKwMap) + writingItems <- case wIds of + [] -> return [] + _ -> recentFirstByDisplay + =<< mapM (\i -> load i :: Compiler (Item String)) wIds + let writingsCtx + | null writingItems = mempty + | otherwise = listField "writings" (portalWritingCtx kw) + (return writingItems) + <> constField "has-writings" "true" + + -- References section + let refKeys = keywordReferencesOrder bibExtrasAll kw + refsHtml <- unsafeCompiler $ + renderBibliographyHtml bibFilePaths bibExtrasAll refKeys + let referencesCtx + | null refKeys = mempty + | otherwise = + constField "references" (T.unpack refsHtml) + + -- Sidecar (tooltip + optional prose intro) + let sidecarId = bibliographyMetaIdentifier kw + hasSidecar = sidecarId `Set.member` bibMetaSet + scCtx <- if hasSidecar + then do + _ <- loadSnapshot sidecarId "body" :: Compiler (Item String) + return (portalIntroField (const sidecarId) + <> portalTooltipField (const sidecarId)) + else return mempty + + let ctx = constField "title" kw + <> constField "keyword" kw + <> constField "bibliography-keyword" "true" + <> constField "library" "true" -- reuse flag to load library.css + item-card.css + <> writingsCtx + <> referencesCtx + <> scCtx + <> siteCtx + makeItem "" + >>= loadAndApplyTemplate "templates/bibliography-keyword.html" ctx + >>= loadAndApplyTemplate "templates/default.html" ctx + >>= relativizeUrls + + -- --------------------------------------------------------------------------- + -- Random page manifest — essays, blog posts, fiction, and poetry (flat + -- and collection poems alike). No pagination/index pages; music and + -- photography landings are also excluded. + -- --------------------------------------------------------------------------- + create ["random-pages.json"] $ do + route idRoute + compile $ do + essays <- loadAll (allEssays .&&. hasNoVersion) :: Compiler [Item String] + posts <- loadAll (P.blogPattern .&&. hasNoVersion) :: Compiler [Item String] + fiction <- loadAll (P.fictionPattern .&&. hasNoVersion) :: Compiler [Item String] + poetry <- loadAll (P.poetryPattern .&&. hasNoVersion) :: Compiler [Item String] + routes <- mapM (getRoute . itemIdentifier) (essays ++ posts ++ fiction ++ poetry) + let urls = [ "/" ++ r | Just r <- routes ] + makeItem $ LBS.unpack (Aeson.encode urls) + + -- --------------------------------------------------------------------------- + -- Epistemic metadata manifest — maps page URLs to epistemic fields + -- (status, confidence, importance, evidence, scope, novelty, practicality, + -- stability, score) for client-side search filtering. + -- --------------------------------------------------------------------------- + create ["data/epistemic-meta.json"] $ do + route idRoute + compile $ do + essays <- loadAll (allEssays .&&. hasNoVersion) :: Compiler [Item String] + posts <- loadAll (P.blogPattern .&&. hasNoVersion) :: Compiler [Item String] + fiction <- loadAll (P.fictionPattern .&&. hasNoVersion) :: Compiler [Item String] + poetry <- loadAll (P.poetryPattern .&&. hasNoVersion) :: Compiler [Item String] + music <- loadAll (P.musicPattern .&&. hasNoVersion) :: Compiler [Item String] + let items = essays ++ posts ++ fiction ++ poetry ++ music + pairs <- mapM epistemicEntry items + let metaMap = Map.fromList (catMaybes pairs) + makeItem $ LBS.unpack (Aeson.encode metaMap) + + -- --------------------------------------------------------------------------- + -- Atom feed — all content sorted by date + -- --------------------------------------------------------------------------- + create ["feed.xml"] $ do + route idRoute + compile $ do + posts <- fmap (take 30) . recentFirst + =<< loadAllSnapshots + ( ( allEssays + .||. P.blogPattern + .||. P.fictionPattern + .||. P.poetryPattern + .||. P.musicPattern + ) + .&&. hasNoVersion + ) + "content" + let feedCtx = + dateField "updated" "%Y-%m-%dT%H:%M:%SZ" + <> dateField "published" "%Y-%m-%dT%H:%M:%SZ" + <> bodyField "description" + <> defaultContext + renderAtom feedConfig feedCtx posts + + -- --------------------------------------------------------------------------- + -- Music feed — compositions only + -- --------------------------------------------------------------------------- + create ["music/feed.xml"] $ do + route idRoute + compile $ do + compositions <- recentFirst + =<< loadAllSnapshots + (P.musicPattern .&&. hasNoVersion) + "content" + let feedCtx = + dateField "updated" "%Y-%m-%dT%H:%M:%SZ" + <> dateField "published" "%Y-%m-%dT%H:%M:%SZ" + <> bodyField "description" + <> defaultContext + renderAtom musicFeedConfig feedCtx compositions + + -- --------------------------------------------------------------------------- + -- robots.txt — minimal, just points crawlers at the sitemap + -- --------------------------------------------------------------------------- + create ["robots.txt"] $ do + route idRoute + compile $ makeItem $ unlines + -- /archive/ is *deliberately not* disallowed. Crawlers must be + -- able to reach the wrapper pages (and snapshot.html) to see + -- their ; a + -- robots.txt Disallow would block that and a URL blocked only + -- by robots.txt can still appear in results when linked. The + -- raw PDFs cannot carry meta — they need an `X-Robots-Tag` + -- HTTP header from the deploy webserver (see nginx/archive.conf). + [ "User-agent: *" + , "Allow: /" + , "" + , "Sitemap: https://levineuwirth.org/sitemap.xml" + ] + + -- --------------------------------------------------------------------------- + -- sitemap.xml — every dated content page (essays, blog, poetry, fiction, + -- music). Standalone pages (about, colophon, etc.) are intentionally + -- omitted: they're reachable via the main nav, lack `date:` frontmatter, + -- and would force a fallback lastmod that misrepresents staleness. + -- --------------------------------------------------------------------------- + create ["sitemap.xml"] $ do + route idRoute + compile $ do + entries <- recentFirst + =<< loadAllSnapshots + ( ( allEssays + .||. P.blogPattern + .||. P.fictionPattern + .||. P.poetryPattern + .||. P.musicPattern + ) + .&&. hasNoVersion + ) + "content" + let siteRoot = "https://levineuwirth.org" + sitemapItemCtx = + constField "root" siteRoot + <> dateField "lastmod" "%Y-%m-%d" + <> defaultContext + sitemapCtx = + constField "root" siteRoot + <> listField "entries" sitemapItemCtx (return entries) + makeItem ("" :: String) + >>= loadAndApplyTemplate "templates/sitemap.xml" sitemapCtx + +-- --------------------------------------------------------------------------- +-- Epistemic metadata extraction +-- --------------------------------------------------------------------------- + +-- | Extract epistemic metadata from a content item's frontmatter. +-- Returns Nothing if the item has no route or no epistemic fields. +epistemicEntry :: Item String -> Compiler (Maybe (String, Map.Map String String)) +epistemicEntry item = do + let ident = itemIdentifier item + mRoute <- getRoute ident + case mRoute of + Nothing -> return Nothing + Just r -> do + meta <- getMetadata ident + let url = "/" ++ r + fields = catMaybes + [ grab "status" meta + , grab "confidence" meta + , grab "importance" meta + , grab "evidence" meta + , grab "scope" meta + , grab "novelty" meta + , grab "practicality" meta + , grab "stability" meta + ] + obj = Map.fromList fields + -- Compute overall-score the same way Contexts.overallScoreField + -- does, including the "proved"/"proven" sentinel -> 100. + confRaw = lookupString "confidence" meta + confInt | isProvedConfidence confRaw = Just 100 + | otherwise = readMaybe =<< confRaw :: Maybe Int + obj' = case ( confInt + , readMaybe =<< lookupString "evidence" meta :: Maybe Int + ) of + (Just conf, Just ev) -> + let raw :: Double + raw = fromIntegral conf / 100.0 * 0.6 + + fromIntegral (ev - 1) / 4.0 * 0.4 + score = max 0 (min 100 (round (raw * 100.0) :: Int)) + in Map.insert "score" (show score) obj + _ -> obj + if Map.null obj' + then return Nothing + else return (Just (url, obj')) + where + grab name meta = case lookupString name meta of + Just v -> Just (name, v) + Nothing -> Nothing + + +-- --------------------------------------------------------------------------- +-- Bibliography helpers (Phase 6b) +-- --------------------------------------------------------------------------- + +-- | Invert a @citekey -> BibExtra@ map into @keyword -> [citekey]@ +-- using each entry's 'bibKeywords' list as the inversion source. +invertKeywordsBib :: Map String BibExtra -> Map String [String] +invertKeywordsBib = + Map.fromListWith (++) . concatMap flatten . Map.toList + where + flatten (k, e) = [ (kw, [k]) | kw <- bibKeywords e ] + +-- | Read a @keywords:@ frontmatter field, accepting YAML list and +-- comma-separated scalar forms. Matches 'Contexts.keywordLinksField'. +readKeywords :: Metadata -> [String] +readKeywords meta = filter (not . null) . map trimSpaces $ + case lookupStringList "keywords" meta of + Just xs -> xs + Nothing -> case lookupString "keywords" meta of + Just s -> splitComma s + Nothing -> [] + where + trimSpaces = dropWhile (== ' ') . reverse . dropWhile (== ' ') . reverse + splitComma s = case break (== ',') s of + (before, []) -> [before] + (before, _ : rest) -> before : splitComma rest + +-- | Invert a @[(Identifier, [keyword])]@ association into +-- @keyword -> [Identifier]@. Identifiers can appear under multiple +-- keywords (multi-keyword items). +invertKeywordsWritings :: [(Identifier, [String])] -> Map String [Identifier] +invertKeywordsWritings pairs = + Map.fromListWith (++) + [ (kw, [ident]) | (ident, kws) <- pairs, kw <- kws ] + +-- | Sort citekeys for the /bibliography/ index: ascending first-author +-- surname, year-descending within author. +bibliographyIndexOrder :: Map String BibExtra -> [String] +bibliographyIndexOrder extras = + map fst $ sortBy (comparing sortKey) (Map.toList extras) + where + sortKey (_, e) = (firstAuthorSurname e, Down (bibYear e)) + +-- | Sort citekeys for a /bibliography// References section: year +-- descending, then alphabetical by first-author surname within the +-- year. Filtered to only entries whose 'bibKeywords' includes @kw@. +keywordReferencesOrder :: Map String BibExtra -> String -> [String] +keywordReferencesOrder extras kw = + map fst $ sortBy (comparing sortKey) + [ (k, e) | (k, e) <- Map.toList extras, kw `elem` bibKeywords e ] + where + sortKey (_, e) = (Down (bibYear e), firstAuthorSurname e) + +-- | Identifier of a bibliography-meta sidecar for a given keyword. +-- Parallels 'Tags.sidecarIdentifier' but under +-- @content/bibliography-meta/@ rather than @content/tag-meta/@. +bibliographyMetaIdentifier :: String -> Identifier +bibliographyMetaIdentifier kw = + fromFilePath ("content/bibliography-meta/" ++ kw ++ ".md") + +-- | Group an alphabetically-sorted list of citekeys into letter buckets +-- keyed by the uppercase first letter of each entry's first-author +-- surname (falling back to the citekey's first letter when no author +-- was parsed — edge case, shouldn't occur in current content). +-- +-- Because @sortedKeys@ is already alphabetical, 'Data.List.groupBy' +-- produces contiguous same-letter runs in one pass. +groupByLetter :: Map String BibExtra -> [String] -> [(Char, [String])] +groupByLetter extras sortedKeys = + let withLetters = [ (k, letterOf k) | k <- sortedKeys ] + grouped = groupBy (\(_, a) (_, b) -> a == b) withLetters + in [ (letter, map fst grp) | grp <- grouped + , (_, letter) : _ <- [grp] ] + where + letterOf k = + let e = fromMaybe emptyBibExtra (Map.lookup k extras) + in case firstAuthorSurname e of + (c:_) -> toUpper c + _ -> case k of + (c:_) -> toUpper c + _ -> '?' + +-- | The A–Z jump strip above the entry list. Present letters render as +-- anchor links to their section heading; absent letters render as +-- muted, non-linked spans so the alphabet reads as a complete strip +-- regardless of content gaps. +renderBibliographyAlphabet :: [Char] -> T.Text +renderBibliographyAlphabet presentList = + let present = Set.fromList presentList + cell c + | c `Set.member` present = + " T.singleton c + <> "\" class=\"alpha\">" <> T.singleton c <> "" + | otherwise = + "" + <> T.singleton c <> "" + in "\n" + +-- | Letter-group heading inserted between entry groups on the +-- bibliography index. The @id@ is the anchor target for +-- 'renderBibliographyAlphabet' jump-links. +renderLetterHeader :: Char -> T.Text +renderLetterHeader c = + "

    T.singleton c + <> "\" class=\"bibliography-letter\">" + <> T.singleton c <> "

    \n" + +-- | Item-level context for Writings-section cards on a keyword page. +-- Same fields as the library's 'portalItemCtx' but with tag-footer +-- suppression tuned to the keyword context rather than a portal +-- (nothing to suppress here — writings keep their full tag list so +-- readers can see the item's own portal filings). +portalWritingCtx :: String -> Context String +portalWritingCtx _kw = + contentKindField + <> constField "full-abstract" "true" + <> essayCtx diff --git a/build/Stability.hs b/build/Stability.hs new file mode 100644 index 0000000..17a38ba --- /dev/null +++ b/build/Stability.hs @@ -0,0 +1,361 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Stability auto-calculation, last-reviewed derivation, and version history. +-- +-- For each content page: +-- * If the page's source path appears in @IGNORE.txt@, the stability and +-- last-reviewed fields fall back to the frontmatter values. +-- * Otherwise, @git log --follow@ is used. Stability is derived from +-- commit count + age; last-reviewed is the most-recent commit date. +-- +-- Version history (@$version-history$@): +-- * Prioritises frontmatter @history:@ list (date + note pairs). +-- * Falls back to the raw git log dates (date-only, no message). +-- * Falls back to nothing (template shows created/modified dates instead). +-- +-- @IGNORE.txt@ is cleared by the build target in the Makefile after +-- every successful build, so pins are one-shot. +module Stability + ( stabilityField + , resolveStability + , lastReviewedField + , lastReviewedIsoField + , versionHistoryField + , versionHistoryPrimaryField + , versionHistoryRestField + , versionHistoryRangeField + , versionHistoryRangeStartField + , versionHistoryRangeEndField + , versionHistoryCommitsField + ) where + +import Control.Exception (catch, IOException) +import Data.Aeson (Value (..)) +import qualified Data.Aeson.KeyMap as KM +import qualified Data.Vector as V +import Data.List (sortBy) +import Data.Maybe (catMaybes, fromMaybe, listToMaybe) +import Data.Ord (comparing, Down (..)) +import Data.Time.Calendar (Day, diffDays) +import Data.Time.Clock (getCurrentTime, utctDay) +import Data.Time.Format (parseTimeM, formatTime, defaultTimeLocale) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import System.Exit (ExitCode (..)) +import System.IO (hPutStrLn, stderr) +import System.Process (readProcessWithExitCode) +import Hakyll + +-- --------------------------------------------------------------------------- +-- IGNORE.txt +-- --------------------------------------------------------------------------- + +-- | Read @IGNORE.txt@ (paths relative to project root, one per line). +-- Returns an empty list when the file is absent or empty. +-- +-- Uses strict text IO so the file handle is released immediately rather +-- than left dangling on the lazy spine of 'readFile'. +readIgnore :: IO [FilePath] +readIgnore = + (filter (not . null) . map T.unpack . T.lines <$> TIO.readFile "IGNORE.txt") + `catch` \(_ :: IOException) -> return [] + +-- --------------------------------------------------------------------------- +-- Git helpers +-- --------------------------------------------------------------------------- + +-- | Return commit dates (ISO "YYYY-MM-DD", newest-first) for @fp@. +-- +-- Logs git's stderr to the build's stderr when present so the author +-- isn't left in the dark when a file isn't tracked yet (the warning +-- otherwise vanishes silently). +gitDates :: FilePath -> IO [String] +gitDates fp = do + (ec, out, err) <- readProcessWithExitCode + "git" ["log", "--follow", "--format=%ad", "--date=short", "--", fp] "" + case ec of + ExitFailure _ -> do + let msg = if null err then "git log failed" else err + hPutStrLn stderr $ "[Stability] " ++ fp ++ ": " ++ msg + return [] + ExitSuccess -> do + case err of + "" -> return () + _ -> hPutStrLn stderr $ "[Stability] " ++ fp ++ ": " ++ err + return $ filter (not . null) (lines out) + +-- | Parse an ISO "YYYY-MM-DD" string to a 'Day'. +parseIso :: String -> Maybe Day +parseIso = parseTimeM True defaultTimeLocale "%Y-%m-%d" + +-- | Derive stability label from commit dates (newest-first), judged as +-- of @today@. +-- +-- Thresholds (commit count + age in days since first commit): +-- +-- * @volatile@ — solo commit OR less than two weeks old. +-- * @revising@ — under six commits AND under three months old. +-- * @fairly stable@ — under sixteen commits OR under one year old. +-- * @stable@ — under thirty-one commits OR under two years old. +-- * @established@ — anything beyond. +-- +-- These cliffs are deliberately conservative: a fast burst of commits +-- early in a piece's life looks volatile until enough time has passed +-- to demonstrate it has settled. Age is measured from the first commit +-- to /today/, not to the most recent commit — a piece written in a +-- one-week burst must be able to stabilise as quiet time accumulates. +stabilityFromDates :: Day -> [String] -> String +stabilityFromDates _ [] = "volatile" +stabilityFromDates today dates = + classify (length dates) ageDays + where + -- 'last' is safe: the [] case is handled above. + ageDays = case parseIso (last dates) of + Just firstDay -> fromIntegral (diffDays today firstDay) + Nothing -> 0 + classify n age + | n <= 1 || age < volatileAge = "volatile" + | n <= 5 && age < revisingAge = "revising" + | n <= 15 || age < fairlyStableAge = "fairly stable" + | n <= 30 || age < stableAge = "stable" + | otherwise = "established" + + volatileAge, revisingAge, fairlyStableAge, stableAge :: Int + volatileAge = 14 + revisingAge = 90 + fairlyStableAge = 365 + stableAge = 730 + +-- | Format an ISO date as "%-d %B %Y" (e.g. "16 March 2026"). +fmtIso :: String -> String +fmtIso s = case parseIso s of + Nothing -> s + Just day -> formatTime defaultTimeLocale "%-d %B %Y" (day :: Day) + +-- --------------------------------------------------------------------------- +-- Stability and last-reviewed context fields +-- --------------------------------------------------------------------------- + +-- | Resolve the stability label for an item — frontmatter override +-- when the source path is pinned via @IGNORE.txt@, else the heuristic +-- run over @git log --follow@ on the source path. +-- +-- Used by 'stabilityField' (which exposes the label as a context field) +-- and by Marks.hs (which feeds the label into the epistemic figure's +-- outer-ring tick count). +resolveStability :: Item a -> Compiler String +resolveStability item = do + let srcPath = toFilePath (itemIdentifier item) + meta <- getMetadata (itemIdentifier item) + unsafeCompiler $ do + ignored <- readIgnore + if srcPath `elem` ignored + then return $ fromMaybe "volatile" (lookupString "stability" meta) + else do + today <- utctDay <$> getCurrentTime + stabilityFromDates today <$> gitDates srcPath + +-- | Context field @$stability$@. +-- Always resolves to a label; prefers frontmatter when the file is pinned. +stabilityField :: Context String +stabilityField = field "stability" resolveStability + +-- | Context field @$last-reviewed$@. +-- Returns the formatted date of the most-recent commit, or @noResult@ when +-- unavailable (making @$if(last-reviewed)$@ false in templates). +lastReviewedField :: Context String +lastReviewedField = field "last-reviewed" $ \item -> do + let srcPath = toFilePath (itemIdentifier item) + meta <- getMetadata (itemIdentifier item) + mDate <- unsafeCompiler $ do + ignored <- readIgnore + if srcPath `elem` ignored + -- Frontmatter convention is ISO; format it like the git + -- branch so pinned pages don't render a raw "2026-05-01". + then return $ fmtIso <$> lookupString "last-reviewed" meta + else fmap fmtIso . listToMaybe <$> gitDates srcPath + case mDate of + Nothing -> fail "no last-reviewed" + Just d -> return d + +-- | Raw-ISO companion to @$last-reviewed$@ — for hover-popup +-- @data-date-start@ attribute. Falls back to the frontmatter value for +-- pinned files (which is expected to already be ISO, the same convention +-- used by 'lastReviewedField' before it applied 'fmtIso'). +lastReviewedIsoField :: Context String +lastReviewedIsoField = field "last-reviewed-iso" $ \item -> do + let srcPath = toFilePath (itemIdentifier item) + meta <- getMetadata (itemIdentifier item) + mIso <- unsafeCompiler $ do + ignored <- readIgnore + if srcPath `elem` ignored + then return $ lookupString "last-reviewed" meta + else listToMaybe <$> gitDates srcPath + case mIso of + Nothing -> fail "no last-reviewed ISO" + Just d -> return d + +-- --------------------------------------------------------------------------- +-- Version history +-- --------------------------------------------------------------------------- + +data VHEntry = VHEntry + { vhDate :: String -- human-readable, e.g. "12 April 2026" + , vhDateIso :: String -- raw ISO, e.g. "2026-04-12" + , vhMessage :: Maybe String -- Nothing for git-log-only entries + } + +-- | Parse the optional frontmatter @history:@ list. +-- Each item must have @date:@ and @note:@ keys. +parseFmHistory :: Metadata -> [VHEntry] +parseFmHistory meta = + case KM.lookup "history" meta of + Just (Array v) -> catMaybes (map parseOne (V.toList v)) + _ -> [] + where + parseOne (Object o) = + case getString =<< KM.lookup "date" o of + Nothing -> Nothing + Just d -> Just $ VHEntry (fmtIso d) d (getString =<< KM.lookup "note" o) + parseOne _ = Nothing + + getString (String t) = Just (T.unpack t) + getString _ = Nothing + +-- | Get git log for a file as version history entries (date-only, no message). +gitLogHistory :: FilePath -> IO [VHEntry] +gitLogHistory fp = map (\d -> VHEntry (fmtIso d) d Nothing) <$> gitDates fp + +-- | Maximum entries shown by default in the version-history footer block. +-- The remainder is revealed via a
    / expand affordance, +-- matching the cap on the RELATED column. +versionHistoryHeadCount :: Int +versionHistoryHeadCount = 3 + +-- | Load version-history entries for an item. +-- Priority: frontmatter @history:@ list → git log dates → empty. +-- +-- Entries are sorted newest-first by ISO date regardless of authored +-- order: every consumer (primary/rest split, range fields) assumes the +-- head is the newest entry, and the @history:@ list may be authored in +-- either direction. Git dates already arrive newest-first; the sort is +-- idempotent there. +loadVersionHistory :: Item a -> Compiler [VHEntry] +loadVersionHistory item = do + let srcPath = toFilePath (itemIdentifier item) + meta <- getMetadata (itemIdentifier item) + let newestFirst = sortBy (comparing (Down . vhDateIso)) + fmEntries = newestFirst (parseFmHistory meta) + if not (null fmEntries) + then return fmEntries + else unsafeCompiler (newestFirst <$> gitLogHistory srcPath) + +-- | Wrap a list of 'VHEntry' as Hakyll Items with unique paths so the +-- list field works correctly inside @$for$@. +vhItems :: String -> [VHEntry] -> [Item VHEntry] +vhItems tag = + zipWith (\i e -> Item (fromFilePath (tag ++ "-" ++ show (i :: Int))) e) + [1..] + +-- | Shared sub-context for version-history entries: @$vh-date$@, +-- @$vh-date-iso$@ (raw ISO for hover popups), and (optionally) @$vh-message$@. +vhEntryCtx :: Context VHEntry +vhEntryCtx = + field "vh-date" (return . vhDate . itemBody) + <> field "vh-date-iso" (return . vhDateIso . itemBody) + <> field "vh-message" (\i -> case vhMessage (itemBody i) of + Nothing -> fail "no message" + Just m -> return m) + +-- | Context list field @$version-history$@ — full list, kept for callers +-- (e.g. feeds, stats) that want every entry in one pass. +versionHistoryField :: Context String +versionHistoryField = + listFieldWith "version-history" vhEntryCtx $ \item -> do + entries <- loadVersionHistory item + if null entries + then fail "no version history" + else return (vhItems "vh" entries) + +-- | @$version-history-primary$@ — first 'versionHistoryHeadCount' entries, +-- rendered outside the expand affordance. +versionHistoryPrimaryField :: Context String +versionHistoryPrimaryField = + listFieldWith "version-history-primary" vhEntryCtx $ \item -> do + entries <- loadVersionHistory item + let primary = take versionHistoryHeadCount entries + if null primary + then fail "no version history" + else return (vhItems "vh-p" primary) + +-- | @$version-history-rest$@ — overflow entries (count > head cap), which +-- the template wraps in a
    block. Fails (noResult) when the total +-- fits inside the head cap, so @$if(version-history-rest)$@ collapses +-- cleanly. +versionHistoryRestField :: Context String +versionHistoryRestField = + listFieldWith "version-history-rest" vhEntryCtx $ \item -> do + entries <- loadVersionHistory item + let rest = drop versionHistoryHeadCount entries + if null rest + then fail "no overflow" + else return (vhItems "vh-r" rest) + +-- | @$version-history-range$@ — formatted span between the oldest and +-- newest entry. A single-date history renders as that date alone; a +-- multi-date history renders as "OLDEST \x2013 NEWEST" (en-dash). +-- Fails when no history is available so @$if(version-history-range)$@ +-- in the template falls back to a literal label. +-- +-- Dates in the underlying VHEntry list are already pre-formatted +-- ("12 April 2026") by 'parseFmHistory' / 'gitLogHistory'. +versionHistoryRangeField :: Context String +versionHistoryRangeField = field "version-history-range" $ \item -> do + entries <- loadVersionHistory item + case entries of + [] -> fail "no version-history range" + [one] -> return (vhDate one) + es@(newest:_) -> + let oldest = last es -- safe: es is non-empty by pattern + newD = vhDate newest + oldD = vhDate oldest + in if newD == oldD + then return newD + else return (oldD ++ " \x2013 " ++ newD) + +-- | Raw-ISO start date (oldest entry) for hover-popup machine use. +versionHistoryRangeStartField :: Context String +versionHistoryRangeStartField = + field "version-history-range-start" $ \item -> do + entries <- loadVersionHistory item + case entries of + [] -> fail "no version-history start" + _ -> return (vhDateIso (last entries)) + +-- | Raw-ISO end date (newest entry) for hover-popup machine use. +-- Only resolves when the range spans more than one calendar day — single-day +-- histories don't need an end attribute on the popup trigger. +versionHistoryRangeEndField :: Context String +versionHistoryRangeEndField = + field "version-history-range-end" $ \item -> do + entries <- loadVersionHistory item + case entries of + [] -> fail "no version-history end" + [_] -> fail "single-day history — no end" + es@(newest:_) -> + let oldest = last es -- safe: es is non-empty by pattern + in if vhDateIso newest == vhDateIso oldest + then fail "single-day history — no end" + else return (vhDateIso newest) + +-- | Commit count — used by the frontmatter popup to surface the *density* +-- of attention the piece has received. Deliberately only wired into the +-- metadata-strip date link, not the aftermatter list (where it would be +-- redundant next to the enumeration of entries). +versionHistoryCommitsField :: Context String +versionHistoryCommitsField = + field "version-history-commits" $ \item -> do + entries <- loadVersionHistory item + case entries of + [] -> fail "no commits" + _ -> return (show (length entries)) diff --git a/build/Stats.hs b/build/Stats.hs new file mode 100644 index 0000000..89859c1 --- /dev/null +++ b/build/Stats.hs @@ -0,0 +1,1087 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Build telemetry page (/build/): corpus statistics, word-length +-- distribution, tag frequencies, link analysis, epistemic coverage, +-- output metrics, repository overview, and build timing. +-- Rendered as a full essay (3-column layout, TOC, metadata block). +module Stats (statsRules) where + +import Control.Exception (IOException, catch) +import Control.Monad (forM) +import Data.Char (isSpace, toLower) +import Data.List (find, isPrefixOf, isSuffixOf, sort, sortBy) +import qualified Data.Map.Strict as Map +import Data.Maybe (catMaybes, fromMaybe, isJust, listToMaybe) +import Data.Ord (comparing, Down (..)) +import qualified Data.Set as Set +import Data.String (fromString) +import Data.Time (getCurrentTime, formatTime, defaultTimeLocale, + Day, parseTimeM, utctDay, addDays, diffDays) +import Data.Time.Calendar (toGregorian, dayOfWeek) +import System.Directory (doesDirectoryExist, getFileSize, listDirectory, + pathIsSymbolicLink) +import System.Exit (ExitCode (..)) +import System.FilePath (takeExtension, ()) +import System.IO (readFile') +import System.Process (readProcessWithExitCode) +import Text.Read (readMaybe) +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 qualified Data.Text as T +import qualified Data.Text.IO as TIO +import qualified Data.Text.Encoding as TE +import qualified Text.Blaze.Html5 as H +import qualified Text.Blaze.Html5.Attributes as A +import Text.Blaze.Html.Renderer.String (renderHtml) +import qualified Text.Blaze.Internal as BI +import Hakyll +import Archive (archiveBuildStats) +import Contexts (siteCtx, authorLinksField) +import Marks (hasMonogram, monogramSvgFieldFor, + hasMonogramFieldFor) +import qualified Patterns as P +import Utils (readingTime) + +-- --------------------------------------------------------------------------- +-- Types +-- --------------------------------------------------------------------------- + +data TypeRow = TypeRow + { trLabel :: String + , trCount :: Int + , trWords :: Int + } + +data PageInfo = PageInfo + { piTitle :: String + , piUrl :: String + , piWC :: Int + } + +-- --------------------------------------------------------------------------- +-- Hakyll helpers +-- --------------------------------------------------------------------------- + +loadWC :: Item String -> Compiler Int +loadWC item = do + snap <- loadSnapshot (itemIdentifier item) "word-count" + return $ fromMaybe 0 (readMaybe (itemBody snap)) + +loadPI :: Item String -> Compiler (Maybe PageInfo) +loadPI item = do + meta <- getMetadata (itemIdentifier item) + mRoute <- getRoute (itemIdentifier item) + wc <- loadWC item + return $ fmap (\r -> PageInfo + { piTitle = fromMaybe "(untitled)" (lookupString "title" meta) + , piUrl = "/" ++ r + , piWC = wc + }) mRoute + +-- --------------------------------------------------------------------------- +-- Formatting helpers +-- --------------------------------------------------------------------------- + +commaInt :: Int -> String +commaInt n + | n < 1000 = show n + | otherwise = commaInt (n `div` 1000) ++ "," ++ pad3 (n `mod` 1000) + where + pad3 x + | x < 10 = "00" ++ show x + | x < 100 = "0" ++ show x + | otherwise = show x + +formatBytes :: Integer -> String +formatBytes b + | b < 1024 = show b ++ " B" + | b < 1024*1024 = showD (b * 10 `div` 1024) ++ " KB" + | otherwise = showD (b * 10 `div` (1024*1024)) ++ " MB" + where showD n = show (n `div` 10) ++ "." ++ show (n `mod` 10) + +rtStr :: Int -> String +rtStr totalWords + | mins < 60 = show mins ++ " min" + | otherwise = show (mins `div` 60) ++ "h " ++ show (mins `mod` 60) ++ "m" + where mins = totalWords `div` 200 + +pctStr :: Int -> Int -> String +pctStr _ 0 = "—" +pctStr n total = show (n * 100 `div` total) ++ "%" + +-- | Strip HTML tags for plain-text word counting. +-- +-- Handles: +-- * Tag bodies, including @>@ inside double-quoted attribute values +-- (so @\\"a b\"\>@ doesn't slice the surrounding text). +-- * HTML comments @\". + dropComment ('-':'-':'>':rs) = rs + dropComment (_:rs) = dropComment rs + dropComment [] = [] + + -- Drop everything up to and including "]]>". + dropCdata (']':']':'>':rs) = rs + dropCdata (_:rs) = dropCdata rs + dropCdata [] = [] + + -- Drop a tag body, respecting double-quoted attribute values. + dropTag ('"':rs) = dropTag (skipQuoted rs) + dropTag ('\'':rs) = dropTag (skipApos rs) + dropTag ('>':rs) = rs + dropTag (_:rs) = dropTag rs + dropTag [] = [] + + skipQuoted ('"':rs) = rs + skipQuoted (_:rs) = skipQuoted rs + skipQuoted [] = [] + + skipApos ('\'':rs) = rs + skipApos (_:rs) = skipApos rs + skipApos [] = [] + +-- | Normalise a page URL for backlink map lookup. Must mirror +-- 'Backlinks.normaliseUrl': strip a trailing @index.html@ (keeping the +-- directory slash) before the bare @.html@ extension, so the keys this +-- produces match the keys written into @data/backlinks.json@. +normUrl :: String -> String +normUrl u + | "index.html" `isSuffixOf` u = take (length u - 10) u + | ".html" `isSuffixOf` u = take (length u - 5) u + | otherwise = u + +pad2 :: (Show a, Integral a) => a -> String +pad2 n = if n < 10 then "0" ++ show n else show n + +-- | Median of a non-empty list; returns 0 for empty. An even-length +-- list takes the mean of the two middle elements, rounded to the +-- nearest unit. +median :: [Int] -> Int +median [] = 0 +median xs + | odd n = upper + | otherwise = (lower + upper + 1) `div` 2 + where + -- Indexes are in range for non-empty xs (lower is consulted only + -- when n >= 2), so '(!!)' is safe here by construction. The empty + -- case is caught by the first equation. + sorted = sort xs + n = length sorted + upper = sorted !! (n `div` 2) + lower = sorted !! (n `div` 2 - 1) + + +-- --------------------------------------------------------------------------- +-- Date helpers (for /stats/ page) +-- --------------------------------------------------------------------------- + +parseDay :: String -> Maybe Day +parseDay = parseTimeM True defaultTimeLocale "%Y-%m-%d" + +-- | First Monday on or before 'day' (start of its ISO week). +-- 'fromEnum' on 'DayOfWeek' is ISO-numbered (Monday=1 .. Sunday=7), +-- so Monday must subtract 0 days, Sunday 6. +weekStart :: Day -> Day +weekStart day = + addDays (fromIntegral (negate (fromEnum (dayOfWeek day) - 1))) day + +-- | Intensity class for the heatmap (hm0 … hm4). +heatClass :: Int -> String +heatClass 0 = "hm0" +heatClass n | n < 500 = "hm1" +heatClass n | n < 2000 = "hm2" +heatClass n | n < 5000 = "hm3" +heatClass _ = "hm4" + +shortMonth :: Int -> String +shortMonth m = case m of + 1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr" + 5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug" + 9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec" + _ -> "" + +-- --------------------------------------------------------------------------- +-- URL sanitization and core HTML combinators +-- --------------------------------------------------------------------------- + +-- | Defense-in-depth URL allowlist: reject anything that isn't an internal +-- path, a fragment, or an explicit safe scheme. Case-insensitive and +-- whitespace-tolerant to block @JavaScript:@, @\tjavascript:@, @data:@, etc. +-- @http://@ is intentionally excluded to avoid mixed-content warnings. +-- +-- Protocol-relative URLs (@//evil.com@) are rejected because the leading +-- slash would otherwise admit them through the @\"\/\"@ prefix check. +isSafeUrl :: String -> Bool +isSafeUrl u = + let norm = map toLower (dropWhile isSpace u) + in not ("//" `isPrefixOf` norm) + && any (`isPrefixOf` norm) ["/", "https://", "mailto:", "#"] + +safeHref :: String -> H.AttributeValue +safeHref u + | isSafeUrl u = H.stringValue u + | otherwise = H.stringValue "#" + +-- | Shorthand for 'H.toHtml' over a 'String'. +txt :: String -> H.Html +txt = H.toHtml + +-- | Anchor element with escaped title text and URL sanitized via 'safeHref'. +-- Use for trusted plain-text labels such as tag slugs. +link :: String -> String -> H.Html +link url title = H.a H.! A.href (safeHref url) $ H.toHtml title + +-- | Anchor for a content page, where the title comes from frontmatter and +-- may contain author-authored inline HTML (e.g. @Book Title@). +-- The URL is still sanitized via 'safeHref'; the title is emitted +-- pre-escaped, matching site convention that metadata titles are +-- author-controlled trusted HTML. +pageLink :: String -> String -> H.Html +pageLink url title = H.a H.! A.href (safeHref url) $ H.preEscapedToHtml title + +-- | Typed section header followed by its body content. +section :: String -> String -> H.Html -> H.Html +section id_ title body = do + H.h2 H.! A.id (H.stringValue id_) $ H.toHtml title + body + +-- | Build-telemetry table with header row, body rows, and an optional total +-- row. Cell contents are pre-rendered 'H.Html' so callers may embed links or +-- emphasis inside cells without risking double-escaping. +table :: [String] -> [[H.Html]] -> Maybe [H.Html] -> H.Html +table headers rows mFoot = + H.table H.! A.class_ "build-table" $ do + H.thead $ H.tr $ mapM_ (H.th . H.toHtml) headers + H.tbody $ mapM_ renderRow rows + maybe (return ()) renderFoot mFoot + where + renderRow cells = H.tr $ mapM_ H.td cells + renderFoot cells = H.tfoot $ + H.tr H.! A.class_ "build-total" $ mapM_ H.td cells + +-- | Two-column metadata block: each pair becomes a @
    /
    @ entry. Values +-- are 'H.Html' to allow mixing links and plain text. +dl :: [(String, H.Html)] -> H.Html +dl pairs = H.dl H.! A.class_ "build-meta" $ + mapM_ (\(k, v) -> do H.dt (H.toHtml k); H.dd v) pairs + +-- --------------------------------------------------------------------------- +-- SVG / custom element helpers (no blaze-svg dependency) +-- --------------------------------------------------------------------------- + +svgTag, rectTag, textTag, titleTag :: H.Html -> H.Html +svgTag = BI.customParent "svg" +rectTag = BI.customParent "rect" +textTag = BI.customParent "text" +titleTag = BI.customParent "title" + +-- | Attach an attribute that isn't in 'Text.Blaze.Html5.Attributes' (e.g. +-- SVG @viewBox@, @x@, @y@, or @data-target@). +customAttr :: String -> String -> H.Attribute +customAttr name val = BI.customAttribute (fromString name) (fromString val) + +-- --------------------------------------------------------------------------- +-- Heatmap SVG +-- --------------------------------------------------------------------------- + +-- | 52-week writing activity heatmap. Styled via @.heatmap-svg@ rules in +-- static/css/build.css (no inline @ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = 56 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Piano + + + + +Violin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/memento-mori/index.md b/content/memento-mori/index.md new file mode 100644 index 0000000..3e19cf9 --- /dev/null +++ b/content/memento-mori/index.md @@ -0,0 +1,47 @@ +--- +title: Memento Mori +date: 2026-03-17 +abstract: The power of vanitas through vanity, herein serving to emanate motivation and tranquility. +tags: [miscellany] +js: scripts/memento-mori.js +--- + +
    +
    +

    Like as the waves make towards the pebbled shore,
    +So do our minutes hasten to their end;
    +Each changing place with that which goes before,
    +In sequent toil all forwards do contend.

    +
    +
    Sonnet 60 — William Shakespeare
    +
    + +
    + + +
    + +--- +## Who is a website for? + +::: dropcap +It is a trick question. A website (at least, of the personal website type) is created and, in particular, shared with the world in the hopes that someone else will derive utility from it, whether that utility is diversion, knowledge, or something else. If I had something to say to myself, creating a website for it would be overkill, wouldn't it? +::: + +Yes and no, if you ask me. I believe in the power of transparency and honesty. If I am going to endeavor to accomplish something, making my attempt in the public eye is an excellent motivator and pushes me to be consistent. Similarly, if I have something that I feel is important to me, such as the content of this page, then I have yet to see a reason why I should not incorporate it into the website. Yes, the website is primarily a place for me to share works that I expressly create with the intent of sharing, but I have yet to see *any* detriment to additionally sharing works that I expressly create for my own consumption. I lose nothing, and have the possibility of gaining *something*, so this seems to me to be an instantiation of [Pascal's Wager](https://en.wikipedia.org/wiki/Pascal%27s_wager). + +## Applied Vanitas + +![Dante and Beatrice gaze upon the highest heaven.](/images/canto31.jpg "Gustav Doré - Paradiso, Canto 31") + +::: dropcap +Time passes regardless of our wishes, our intentions, our apprehensions. That is perhaps the most beautifully profound fact of our existence, and one that therefore I view with gratitude. +::: + +MANY ARTISTS AND THINKERS ALIKE HAVE, OVER CENTURIES, TRIED [TO]{.smallcaps} ENCAPSULATE THIS BEAUTIFUL PROFUNDITY THROUGH [*Vanitas*](https://en.wikipedia.org/wiki/Vanitas)^[Spanish Wikipedia has a far more detailed and preferable entry concerning *Vanitas*. For those who speak Spanish, [see here](https://es.wikipedia.org/wiki/Vanitas)] I do not think that my words can do this subject terrible justice, so rather than trying to encapsulate the potent nature of the subject myself, I will only speak here about what it means to me, interspersing my remarks with art of various forms for us to appreciate. + +### My Creative Works + +::: {.score-fragment score-name="Bassoon Concerto, II (2025-2026)" score-caption="A solo bassoon excerpt from my 2025-2026 Bassoon Concerto."} +![](scores/bsn.svg) +::: diff --git a/content/memento-mori/mark.svg b/content/memento-mori/mark.svg new file mode 100644 index 0000000..6375a96 --- /dev/null +++ b/content/memento-mori/mark.svg @@ -0,0 +1,48 @@ + + A succession of waves making toward a pebbled shore beneath a low sun + A frontispiece mark for "Memento Mori" — drawn from Sonnet 60: "Like as the waves make towards the pebbled shore, so do our minutes hasten to their end; each changing place with that which goes before, in sequent toil all forwards do contend." + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/memento-mori/scores/bsn.svg b/content/memento-mori/scores/bsn.svg new file mode 100644 index 0000000..849ed0d --- /dev/null +++ b/content/memento-mori/scores/bsn.svg @@ -0,0 +1,891 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + + + + + + + +6 + + + + + + + + + + +6 + + + + + + + + + + +6 + + + + + + + + + + +6 + + + + + + + + + + +6 + + + + + + + + + + +3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Bassoon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +6 + + + + + + + + + + + + + + + + +6 + + + + + + + + + + + + + + + + + + + +6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Bsn. + + + + + + + + + + +3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/music/index.md b/content/music/index.md new file mode 100644 index 0000000..6ef7883 --- /dev/null +++ b/content/music/index.md @@ -0,0 +1,7 @@ +--- +title: Music +abstract: Compositions spanning orchestral, chamber, solo, and vocal writing. +tags: [music] +--- + +Coming soon! diff --git a/content/music/mark.svg b/content/music/mark.svg new file mode 100644 index 0000000..19f6ebe --- /dev/null +++ b/content/music/mark.svg @@ -0,0 +1,13 @@ + + Music: staff and treble curve + Placeholder monogram for the Music index — a five-line staff crossed by a treble-clef-derived curve, abstracted to a single sweep through the staff lines. + + + + + + + + + + diff --git a/content/photography/denmark/bagsvaerd-085.md b/content/photography/denmark/bagsvaerd-085.md new file mode 100644 index 0000000..2b9a742 --- /dev/null +++ b/content/photography/denmark/bagsvaerd-085.md @@ -0,0 +1,22 @@ +--- +title: "Bagsværd" +date: 2026-08-20 +tags: [photography, photography/bagsvaerd] +photo: bagsvaerd-085.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Bagsværd, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.5] +geo-precision: city +--- + diff --git a/content/photography/denmark/bagsvaerd-087.md b/content/photography/denmark/bagsvaerd-087.md new file mode 100644 index 0000000..e195c24 --- /dev/null +++ b/content/photography/denmark/bagsvaerd-087.md @@ -0,0 +1,22 @@ +--- +title: "Double rainbow" +date: 2026-08-20 +tags: [photography, photography/bagsvaerd] +photo: bagsvaerd-087.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Bagsværd, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.5] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-005.md b/content/photography/denmark/copenhagen-005.md new file mode 100644 index 0000000..9d4d1c4 --- /dev/null +++ b/content/photography/denmark/copenhagen-005.md @@ -0,0 +1,22 @@ +--- +title: "Københavns Rådhus" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-005.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/320 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-011.md b/content/photography/denmark/copenhagen-011.md new file mode 100644 index 0000000..bc27057 --- /dev/null +++ b/content/photography/denmark/copenhagen-011.md @@ -0,0 +1,22 @@ +--- +title: "Frue Plads" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-011.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/4 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-017.md b/content/photography/denmark/copenhagen-017.md new file mode 100644 index 0000000..2e47267 --- /dev/null +++ b/content/photography/denmark/copenhagen-017.md @@ -0,0 +1,22 @@ +--- +title: "Gammel Strand" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-017.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "29mm" +exposure: "1/125 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-018.md b/content/photography/denmark/copenhagen-018.md new file mode 100644 index 0000000..b0fcb0f --- /dev/null +++ b/content/photography/denmark/copenhagen-018.md @@ -0,0 +1,22 @@ +--- +title: "Højbro Plads" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-018.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "24mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-021.md b/content/photography/denmark/copenhagen-021.md new file mode 100644 index 0000000..bdbad74 --- /dev/null +++ b/content/photography/denmark/copenhagen-021.md @@ -0,0 +1,22 @@ +--- +title: "Gammel Strand" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-021.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "26mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-023.md b/content/photography/denmark/copenhagen-023.md new file mode 100644 index 0000000..37549fe --- /dev/null +++ b/content/photography/denmark/copenhagen-023.md @@ -0,0 +1,22 @@ +--- +title: "Christiansborg" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-023.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-026.md b/content/photography/denmark/copenhagen-026.md new file mode 100644 index 0000000..8b94dde --- /dev/null +++ b/content/photography/denmark/copenhagen-026.md @@ -0,0 +1,22 @@ +--- +title: "Mozarts Plads" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-026.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/100 f/4 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-030.md b/content/photography/denmark/copenhagen-030.md new file mode 100644 index 0000000..3f8c658 --- /dev/null +++ b/content/photography/denmark/copenhagen-030.md @@ -0,0 +1,22 @@ +--- +title: "Sunset through trees" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-030.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "32mm" +exposure: "1/200 f/22 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-032.md b/content/photography/denmark/copenhagen-032.md new file mode 100644 index 0000000..71b7d8f --- /dev/null +++ b/content/photography/denmark/copenhagen-032.md @@ -0,0 +1,22 @@ +--- +title: "Rowan" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-032.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 140" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-034.md b/content/photography/denmark/copenhagen-034.md new file mode 100644 index 0000000..cbd5386 --- /dev/null +++ b/content/photography/denmark/copenhagen-034.md @@ -0,0 +1,22 @@ +--- +title: "Path" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-034.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/60 f/3.5 ISO 3200" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-035.md b/content/photography/denmark/copenhagen-035.md new file mode 100644 index 0000000..9b72158 --- /dev/null +++ b/content/photography/denmark/copenhagen-035.md @@ -0,0 +1,22 @@ +--- +title: "Dusk" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-035.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 160" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-036.md b/content/photography/denmark/copenhagen-036.md new file mode 100644 index 0000000..55a0047 --- /dev/null +++ b/content/photography/denmark/copenhagen-036.md @@ -0,0 +1,22 @@ +--- +title: "The S-tog line, Sydhavn" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-036.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/10 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-038.md b/content/photography/denmark/copenhagen-038.md new file mode 100644 index 0000000..efd856b --- /dev/null +++ b/content/photography/denmark/copenhagen-038.md @@ -0,0 +1,22 @@ +--- +title: "Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-038.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 140" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-040.md b/content/photography/denmark/copenhagen-040.md new file mode 100644 index 0000000..a1f00a2 --- /dev/null +++ b/content/photography/denmark/copenhagen-040.md @@ -0,0 +1,22 @@ +--- +title: "Sheep, Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-040.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "48mm" +exposure: "1/125 f/5.6 ISO 220" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-042.md b/content/photography/denmark/copenhagen-042.md new file mode 100644 index 0000000..809a81a --- /dev/null +++ b/content/photography/denmark/copenhagen-042.md @@ -0,0 +1,22 @@ +--- +title: "Sheep, Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-042.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "50mm" +exposure: "1/125 f/5.6 ISO 180" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-044.md b/content/photography/denmark/copenhagen-044.md new file mode 100644 index 0000000..6798bbb --- /dev/null +++ b/content/photography/denmark/copenhagen-044.md @@ -0,0 +1,22 @@ +--- +title: "Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-044.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "22mm" +exposure: "1/125 f/4 ISO 200" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-045.md b/content/photography/denmark/copenhagen-045.md new file mode 100644 index 0000000..49a6883 --- /dev/null +++ b/content/photography/denmark/copenhagen-045.md @@ -0,0 +1,22 @@ +--- +title: "Sunset, Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-045.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "22mm" +exposure: "1/200 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-046.md b/content/photography/denmark/copenhagen-046.md new file mode 100644 index 0000000..a19d2ca --- /dev/null +++ b/content/photography/denmark/copenhagen-046.md @@ -0,0 +1,22 @@ +--- +title: "Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-046.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "22mm" +exposure: "1/60 f/4 ISO 560" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-047.md b/content/photography/denmark/copenhagen-047.md new file mode 100644 index 0000000..6facbf1 --- /dev/null +++ b/content/photography/denmark/copenhagen-047.md @@ -0,0 +1,22 @@ +--- +title: "Sunset, Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-047.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "22mm" +exposure: "1/200 f/11 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-049.md b/content/photography/denmark/copenhagen-049.md new file mode 100644 index 0000000..125f13b --- /dev/null +++ b/content/photography/denmark/copenhagen-049.md @@ -0,0 +1,22 @@ +--- +title: "Boardwalk, Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-049.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-050.md b/content/photography/denmark/copenhagen-050.md new file mode 100644 index 0000000..f4d0671 --- /dev/null +++ b/content/photography/denmark/copenhagen-050.md @@ -0,0 +1,22 @@ +--- +title: "Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-050.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 220" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-051.md b/content/photography/denmark/copenhagen-051.md new file mode 100644 index 0000000..c1ac00d --- /dev/null +++ b/content/photography/denmark/copenhagen-051.md @@ -0,0 +1,22 @@ +--- +title: "Dusk, Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-051.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-055.md b/content/photography/denmark/copenhagen-055.md new file mode 100644 index 0000000..8bbf357 --- /dev/null +++ b/content/photography/denmark/copenhagen-055.md @@ -0,0 +1,22 @@ +--- +title: "Valbyparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-055.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 200" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-059.md b/content/photography/denmark/copenhagen-059.md new file mode 100644 index 0000000..0ab6447 --- /dev/null +++ b/content/photography/denmark/copenhagen-059.md @@ -0,0 +1,22 @@ +--- +title: "Fugleø" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-059.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 220" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-062.md b/content/photography/denmark/copenhagen-062.md new file mode 100644 index 0000000..071e393 --- /dev/null +++ b/content/photography/denmark/copenhagen-062.md @@ -0,0 +1,22 @@ +--- +title: "Bridge to Kystagerparken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-062.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/5.6 ISO 320" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/copenhagen-065.md b/content/photography/denmark/copenhagen-065.md new file mode 100644 index 0000000..5a82f58 --- /dev/null +++ b/content/photography/denmark/copenhagen-065.md @@ -0,0 +1,22 @@ +--- +title: "Amarken" +date: 2026-08-20 +tags: [photography, photography/copenhagen] +photo: copenhagen-065.jpg +series: denmark +orientation: landscape +captured: 2026-08-05 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/100 f/4 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Copenhagen, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-068.md b/content/photography/denmark/furesoe-068.md new file mode 100644 index 0000000..28a2d1a --- /dev/null +++ b/content/photography/denmark/furesoe-068.md @@ -0,0 +1,22 @@ +--- +title: "Horses" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-068.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/5 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-069.md b/content/photography/denmark/furesoe-069.md new file mode 100644 index 0000000..8504373 --- /dev/null +++ b/content/photography/denmark/furesoe-069.md @@ -0,0 +1,22 @@ +--- +title: "Fence line" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-069.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-070.md b/content/photography/denmark/furesoe-070.md new file mode 100644 index 0000000..fb59759 --- /dev/null +++ b/content/photography/denmark/furesoe-070.md @@ -0,0 +1,22 @@ +--- +title: "Oak in the field" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-070.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "32mm" +exposure: "1/200 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-071.md b/content/photography/denmark/furesoe-071.md new file mode 100644 index 0000000..1e49a4c --- /dev/null +++ b/content/photography/denmark/furesoe-071.md @@ -0,0 +1,22 @@ +--- +title: "Grain field" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-071.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-072.md b/content/photography/denmark/furesoe-072.md new file mode 100644 index 0000000..f76fcc4 --- /dev/null +++ b/content/photography/denmark/furesoe-072.md @@ -0,0 +1,22 @@ +--- +title: "Furesø" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-072.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-073.md b/content/photography/denmark/furesoe-073.md new file mode 100644 index 0000000..093bf8f --- /dev/null +++ b/content/photography/denmark/furesoe-073.md @@ -0,0 +1,22 @@ +--- +title: "Field and wood" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-073.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/200 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-074.md b/content/photography/denmark/furesoe-074.md new file mode 100644 index 0000000..4bfbe14 --- /dev/null +++ b/content/photography/denmark/furesoe-074.md @@ -0,0 +1,22 @@ +--- +title: "Grain field" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-074.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-075.md b/content/photography/denmark/furesoe-075.md new file mode 100644 index 0000000..99aa480 --- /dev/null +++ b/content/photography/denmark/furesoe-075.md @@ -0,0 +1,22 @@ +--- +title: "Path to the lake" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-075.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/4 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-079.md b/content/photography/denmark/furesoe-079.md new file mode 100644 index 0000000..5f9cbf6 --- /dev/null +++ b/content/photography/denmark/furesoe-079.md @@ -0,0 +1,22 @@ +--- +title: "Sunset over Furesø" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-079.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/640 f/13 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/furesoe-082.md b/content/photography/denmark/furesoe-082.md new file mode 100644 index 0000000..41e5b1c --- /dev/null +++ b/content/photography/denmark/furesoe-082.md @@ -0,0 +1,22 @@ +--- +title: "Furesø" +date: 2026-08-20 +tags: [photography, photography/furesoe] +photo: furesoe-082.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Furesø, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.4] +geo-precision: city +--- + diff --git a/content/photography/denmark/index.md b/content/photography/denmark/index.md new file mode 100644 index 0000000..4040f42 --- /dev/null +++ b/content/photography/denmark/index.md @@ -0,0 +1,13 @@ +--- +title: "Denmark" +date: 2026-08-20 +abstract: > + A second, longer stint — after a semester here in 2024. Copenhagen and the + places a train reaches from it, added to as they accumulate rather than + closed at a date. +tags: [photography] +photo: klampenborg-124.jpg +location: "Denmark" +captured: 2026-08-05 +--- + diff --git a/content/photography/denmark/klampenborg-096.md b/content/photography/denmark/klampenborg-096.md new file mode 100644 index 0000000..f1b853a --- /dev/null +++ b/content/photography/denmark/klampenborg-096.md @@ -0,0 +1,22 @@ +--- +title: "Dyrehaven" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-096.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-102.md b/content/photography/denmark/klampenborg-102.md new file mode 100644 index 0000000..a07a7cf --- /dev/null +++ b/content/photography/denmark/klampenborg-102.md @@ -0,0 +1,22 @@ +--- +title: "Geese on the pond" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-102.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-105.md b/content/photography/denmark/klampenborg-105.md new file mode 100644 index 0000000..701c9af --- /dev/null +++ b/content/photography/denmark/klampenborg-105.md @@ -0,0 +1,22 @@ +--- +title: "Deer, Dyrehaven" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-105.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/125 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-109.md b/content/photography/denmark/klampenborg-109.md new file mode 100644 index 0000000..fdbbf58 --- /dev/null +++ b/content/photography/denmark/klampenborg-109.md @@ -0,0 +1,22 @@ +--- +title: "Eremitageslottet" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-109.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-111.md b/content/photography/denmark/klampenborg-111.md new file mode 100644 index 0000000..283fa8e --- /dev/null +++ b/content/photography/denmark/klampenborg-111.md @@ -0,0 +1,22 @@ +--- +title: "Eremitagesletten" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-111.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-112.md b/content/photography/denmark/klampenborg-112.md new file mode 100644 index 0000000..b5bb291 --- /dev/null +++ b/content/photography/denmark/klampenborg-112.md @@ -0,0 +1,22 @@ +--- +title: "Clouds" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-112.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/640 f/13 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-114.md b/content/photography/denmark/klampenborg-114.md new file mode 100644 index 0000000..2c59760 --- /dev/null +++ b/content/photography/denmark/klampenborg-114.md @@ -0,0 +1,22 @@ +--- +title: "Crossing paths" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-114.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "30mm" +exposure: "1/200 f/5 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-117.md b/content/photography/denmark/klampenborg-117.md new file mode 100644 index 0000000..b052544 --- /dev/null +++ b/content/photography/denmark/klampenborg-117.md @@ -0,0 +1,22 @@ +--- +title: "Sphinx" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-117.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "38mm" +exposure: "1/200 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-121.md b/content/photography/denmark/klampenborg-121.md new file mode 100644 index 0000000..cbff3f7 --- /dev/null +++ b/content/photography/denmark/klampenborg-121.md @@ -0,0 +1,22 @@ +--- +title: "Eremitagesletten" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-121.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "24mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-122.md b/content/photography/denmark/klampenborg-122.md new file mode 100644 index 0000000..f6db800 --- /dev/null +++ b/content/photography/denmark/klampenborg-122.md @@ -0,0 +1,22 @@ +--- +title: "Crows on the path" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-122.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/125 f/5.6 ISO 250" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-124.md b/content/photography/denmark/klampenborg-124.md new file mode 100644 index 0000000..d479a83 --- /dev/null +++ b/content/photography/denmark/klampenborg-124.md @@ -0,0 +1,22 @@ +--- +title: "Beech road, Dyrehaven" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-124.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/60 f/3.5 ISO 1000" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-125.md b/content/photography/denmark/klampenborg-125.md new file mode 100644 index 0000000..c7b4d5f --- /dev/null +++ b/content/photography/denmark/klampenborg-125.md @@ -0,0 +1,22 @@ +--- +title: "Undergrowth" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-125.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-128.md b/content/photography/denmark/klampenborg-128.md new file mode 100644 index 0000000..5ac7a4f --- /dev/null +++ b/content/photography/denmark/klampenborg-128.md @@ -0,0 +1,22 @@ +--- +title: "Path" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-128.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "36mm" +exposure: "1/60 f/5 ISO 640" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-130.md b/content/photography/denmark/klampenborg-130.md new file mode 100644 index 0000000..088b83e --- /dev/null +++ b/content/photography/denmark/klampenborg-130.md @@ -0,0 +1,22 @@ +--- +title: "Øresund from the plain" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-130.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/4 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-132.md b/content/photography/denmark/klampenborg-132.md new file mode 100644 index 0000000..8d77398 --- /dev/null +++ b/content/photography/denmark/klampenborg-132.md @@ -0,0 +1,22 @@ +--- +title: "Sunset, Dyrehaven" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-132.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/640 f/13 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-134.md b/content/photography/denmark/klampenborg-134.md new file mode 100644 index 0000000..4e1ae46 --- /dev/null +++ b/content/photography/denmark/klampenborg-134.md @@ -0,0 +1,22 @@ +--- +title: "Øresund" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-134.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-138.md b/content/photography/denmark/klampenborg-138.md new file mode 100644 index 0000000..d383af5 --- /dev/null +++ b/content/photography/denmark/klampenborg-138.md @@ -0,0 +1,22 @@ +--- +title: "Øresund" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-138.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-140.md b/content/photography/denmark/klampenborg-140.md new file mode 100644 index 0000000..a76918f --- /dev/null +++ b/content/photography/denmark/klampenborg-140.md @@ -0,0 +1,22 @@ +--- +title: "Kystbanen, Skodsborg" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-140.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/4 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/klampenborg-144.md b/content/photography/denmark/klampenborg-144.md new file mode 100644 index 0000000..66670ab --- /dev/null +++ b/content/photography/denmark/klampenborg-144.md @@ -0,0 +1,22 @@ +--- +title: "Dusk" +date: 2026-08-20 +tags: [photography, photography/klampenborg] +photo: klampenborg-144.jpg +series: denmark +orientation: landscape +captured: 2026-08-08 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "30mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Klampenborg, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.6] +geo-precision: city +--- + diff --git a/content/photography/denmark/lyngby-088.md b/content/photography/denmark/lyngby-088.md new file mode 100644 index 0000000..055cae5 --- /dev/null +++ b/content/photography/denmark/lyngby-088.md @@ -0,0 +1,22 @@ +--- +title: "DTU" +date: 2026-08-20 +tags: [photography, photography/lyngby] +photo: lyngby-088.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Kongens Lyngby, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.5] +geo-precision: city +--- + diff --git a/content/photography/denmark/lyngby-090.md b/content/photography/denmark/lyngby-090.md new file mode 100644 index 0000000..7805bd7 --- /dev/null +++ b/content/photography/denmark/lyngby-090.md @@ -0,0 +1,22 @@ +--- +title: "Rainbow over the road" +date: 2026-08-20 +tags: [photography, photography/lyngby] +photo: lyngby-090.jpg +series: denmark +orientation: landscape +captured: 2026-08-06 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/125 f/5.6 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Kongens Lyngby, Denmark" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [55.8, 12.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/aarhus-20240912-01.md b/content/photography/europa-2024/aarhus-20240912-01.md new file mode 100644 index 0000000..a8c6e8f --- /dev/null +++ b/content/photography/europa-2024/aarhus-20240912-01.md @@ -0,0 +1,20 @@ +--- +title: "Aarhus" +date: 2026-08-21 +tags: [photography, photography/aarhus] +photo: aarhus-20240912-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Aarhus, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [56.2, 10.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/aarhus-20240912-02.md b/content/photography/europa-2024/aarhus-20240912-02.md new file mode 100644 index 0000000..f68284e --- /dev/null +++ b/content/photography/europa-2024/aarhus-20240912-02.md @@ -0,0 +1,20 @@ +--- +title: "Aarhus" +date: 2026-08-21 +tags: [photography, photography/aarhus] +photo: aarhus-20240912-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2604 f/1.8 ISO 80" +location: "Aarhus, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [56.2, 10.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/aarhus-20240912-03.md b/content/photography/europa-2024/aarhus-20240912-03.md new file mode 100644 index 0000000..727acbf --- /dev/null +++ b/content/photography/europa-2024/aarhus-20240912-03.md @@ -0,0 +1,20 @@ +--- +title: "Aarhus" +date: 2026-08-21 +tags: [photography, photography/aarhus] +photo: aarhus-20240912-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2506 f/1.8 ISO 80" +location: "Aarhus, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [56.2, 10.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/aarhus-20240912-04.md b/content/photography/europa-2024/aarhus-20240912-04.md new file mode 100644 index 0000000..501616a --- /dev/null +++ b/content/photography/europa-2024/aarhus-20240912-04.md @@ -0,0 +1,20 @@ +--- +title: "Aarhus" +date: 2026-08-21 +tags: [photography, photography/aarhus] +photo: aarhus-20240912-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2415 f/1.8 ISO 80" +location: "Aarhus, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [56.2, 10.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/aarhus-20240912-05.md b/content/photography/europa-2024/aarhus-20240912-05.md new file mode 100644 index 0000000..9ef4863 --- /dev/null +++ b/content/photography/europa-2024/aarhus-20240912-05.md @@ -0,0 +1,20 @@ +--- +title: "Aarhus" +date: 2026-08-21 +tags: [photography, photography/aarhus] +photo: aarhus-20240912-05.jpg +series: europa-2024 +orientation: portrait +captured: 2024-09-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Aarhus, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [56.2, 10.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/arromanches-20241130-01.md b/content/photography/europa-2024/arromanches-20241130-01.md new file mode 100644 index 0000000..af869a5 --- /dev/null +++ b/content/photography/europa-2024/arromanches-20241130-01.md @@ -0,0 +1,20 @@ +--- +title: "Arromanches-les-Bains" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/arromanches] +photo: arromanches-20241130-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-30 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/1600 f/2.2 ISO 40" +location: "Arromanches-les-Bains, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/atlantic-20240816-01.md b/content/photography/europa-2024/atlantic-20240816-01.md new file mode 100644 index 0000000..11e08a3 --- /dev/null +++ b/content/photography/europa-2024/atlantic-20240816-01.md @@ -0,0 +1,19 @@ +--- +title: "Moon over the Atlantic" +date: 2026-08-21 +tags: [photography, photography/travel] +photo: atlantic-20240816-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-16 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1425 f/1.8 ISO 80" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +# geo: [00.000, 00.000] # add deliberately; pair with geo-precision +# geo-precision: city # exact | km | city | hidden (default: city) +--- + diff --git a/content/photography/europa-2024/bayeux-20241127-01.md b/content/photography/europa-2024/bayeux-20241127-01.md new file mode 100644 index 0000000..cfad02f --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241127-01.md @@ -0,0 +1,20 @@ +--- +title: "Bayeux at night" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241127-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 640" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241127-02.md b/content/photography/europa-2024/bayeux-20241127-02.md new file mode 100644 index 0000000..0879d58 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241127-02.md @@ -0,0 +1,20 @@ +--- +title: "La ruelle" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241127-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 800" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241127-03.md b/content/photography/europa-2024/bayeux-20241127-03.md new file mode 100644 index 0000000..3b703ab --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241127-03.md @@ -0,0 +1,20 @@ +--- +title: "Cathédrale de Bayeux" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241127-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/33 f/1.8 ISO 800" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-01.md b/content/photography/europa-2024/bayeux-20241129-01.md new file mode 100644 index 0000000..2a0d818 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-01.md @@ -0,0 +1,20 @@ +--- +title: "La place" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2959 f/1.8 ISO 80" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-02.md b/content/photography/europa-2024/bayeux-20241129-02.md new file mode 100644 index 0000000..23ced12 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-02.md @@ -0,0 +1,20 @@ +--- +title: "Cathédrale de Bayeux" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-02.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/832 f/1.8 ISO 80" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-03.md b/content/photography/europa-2024/bayeux-20241129-03.md new file mode 100644 index 0000000..c56758b --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-03.md @@ -0,0 +1,20 @@ +--- +title: "La nef" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-03.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 100" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-04.md b/content/photography/europa-2024/bayeux-20241129-04.md new file mode 100644 index 0000000..cea17d6 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-04.md @@ -0,0 +1,20 @@ +--- +title: "Les voûtes" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-04.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/123 f/1.8 ISO 64" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-05.md b/content/photography/europa-2024/bayeux-20241129-05.md new file mode 100644 index 0000000..e344c4a --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-05.md @@ -0,0 +1,20 @@ +--- +title: "Le chœur" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-05.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/72 f/1.8 ISO 200" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-06.md b/content/photography/europa-2024/bayeux-20241129-06.md new file mode 100644 index 0000000..1750861 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-06.md @@ -0,0 +1,20 @@ +--- +title: "Cathédrale de Bayeux" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-06.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-07.md b/content/photography/europa-2024/bayeux-20241129-07.md new file mode 100644 index 0000000..8207cf4 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-07.md @@ -0,0 +1,20 @@ +--- +title: "Le chevet" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4950 f/1.8 ISO 80" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-08.md b/content/photography/europa-2024/bayeux-20241129-08.md new file mode 100644 index 0000000..edee571 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-08.md @@ -0,0 +1,20 @@ +--- +title: "Le moulin" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2041 f/1.8 ISO 80" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-09.md b/content/photography/europa-2024/bayeux-20241129-09.md new file mode 100644 index 0000000..646c8aa --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-09.md @@ -0,0 +1,20 @@ +--- +title: "Hôtel de Ville" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-09.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "74mm" +exposure: "1/544 f/1.8 ISO 64" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-10.md b/content/photography/europa-2024/bayeux-20241129-10.md new file mode 100644 index 0000000..cea29e4 --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-10.md @@ -0,0 +1,20 @@ +--- +title: "Bayeux at dusk" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2415 f/1.8 ISO 80" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/bayeux-20241129-11.md b/content/photography/europa-2024/bayeux-20241129-11.md new file mode 100644 index 0000000..f7b7b7c --- /dev/null +++ b/content/photography/europa-2024/bayeux-20241129-11.md @@ -0,0 +1,20 @@ +--- +title: "Bayeux at night" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/bayeux] +photo: bayeux-20241129-11.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/33 f/1.8 ISO 640" +location: "Bayeux, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.3, -0.7] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241018-01.md b/content/photography/europa-2024/berlin-20241018-01.md new file mode 100644 index 0000000..3a17cae --- /dev/null +++ b/content/photography/europa-2024/berlin-20241018-01.md @@ -0,0 +1,20 @@ +--- +title: "Mitte bei Nacht" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241018-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/25 f/1.8 ISO 800" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241018-02.md b/content/photography/europa-2024/berlin-20241018-02.md new file mode 100644 index 0000000..c271200 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241018-02.md @@ -0,0 +1,20 @@ +--- +title: "Bode-Museum" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241018-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/9 f/1.8 ISO 2000" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241018-03.md b/content/photography/europa-2024/berlin-20241018-03.md new file mode 100644 index 0000000..076a5cf --- /dev/null +++ b/content/photography/europa-2024/berlin-20241018-03.md @@ -0,0 +1,20 @@ +--- +title: "Humboldt-Universität" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241018-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/17 f/1.8 ISO 1250" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241018-04.md b/content/photography/europa-2024/berlin-20241018-04.md new file mode 100644 index 0000000..373076e --- /dev/null +++ b/content/photography/europa-2024/berlin-20241018-04.md @@ -0,0 +1,20 @@ +--- +title: "Berliner Dom" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241018-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/10 f/1.8 ISO 1000" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241018-05.md b/content/photography/europa-2024/berlin-20241018-05.md new file mode 100644 index 0000000..7ea1c71 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241018-05.md @@ -0,0 +1,20 @@ +--- +title: "Mond und Fernsehturm" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241018-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/17 f/1.8 ISO 2500" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241018-06.md b/content/photography/europa-2024/berlin-20241018-06.md new file mode 100644 index 0000000..31bbef5 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241018-06.md @@ -0,0 +1,19 @@ +--- +title: "Outdated Trains (S-Bahn-Viadukt)" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241018-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-18 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/25 f/2.2 ISO 1600" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- diff --git a/content/photography/europa-2024/berlin-20241019-01.md b/content/photography/europa-2024/berlin-20241019-01.md new file mode 100644 index 0000000..d40ba13 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-01.md @@ -0,0 +1,20 @@ +--- +title: "Neue Synagoge" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/587 f/2.2 ISO 40" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-02.md b/content/photography/europa-2024/berlin-20241019-02.md new file mode 100644 index 0000000..e1a5a2d --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-02.md @@ -0,0 +1,20 @@ +--- +title: "Kiezmarkt" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/464 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-03.md b/content/photography/europa-2024/berlin-20241019-03.md new file mode 100644 index 0000000..213e8f3 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-03.md @@ -0,0 +1,20 @@ +--- +title: "Kreuzung am Morgen" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/6410 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-04.md b/content/photography/europa-2024/berlin-20241019-04.md new file mode 100644 index 0000000..6d243e2 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-04.md @@ -0,0 +1,20 @@ +--- +title: "An der Spree" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-05.md b/content/photography/europa-2024/berlin-20241019-05.md new file mode 100644 index 0000000..c5c289c --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-05.md @@ -0,0 +1,20 @@ +--- +title: "Denkmal für die ermordeten Juden Europas" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/1506 f/2.2 ISO 50" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-06.md b/content/photography/europa-2024/berlin-20241019-06.md new file mode 100644 index 0000000..317ccb1 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-06.md @@ -0,0 +1,20 @@ +--- +title: "Komponistendenkmal" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-07.md b/content/photography/europa-2024/berlin-20241019-07.md new file mode 100644 index 0000000..48706ba --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-07.md @@ -0,0 +1,20 @@ +--- +title: "Teich im Tiergarten" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3096 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-08.md b/content/photography/europa-2024/berlin-20241019-08.md new file mode 100644 index 0000000..af86b15 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-08.md @@ -0,0 +1,20 @@ +--- +title: "Denkmal von unten" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/10417 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-09.md b/content/photography/europa-2024/berlin-20241019-09.md new file mode 100644 index 0000000..8140c90 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-09.md @@ -0,0 +1,20 @@ +--- +title: "Allee im Tiergarten" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-09.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-10.md b/content/photography/europa-2024/berlin-20241019-10.md new file mode 100644 index 0000000..8a68cf9 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-10.md @@ -0,0 +1,20 @@ +--- +title: "Rosengarten" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/723 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-11.md b/content/photography/europa-2024/berlin-20241019-11.md new file mode 100644 index 0000000..33a8945 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-11.md @@ -0,0 +1,20 @@ +--- +title: "Neuer See" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-11.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1488 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-12.md b/content/photography/europa-2024/berlin-20241019-12.md new file mode 100644 index 0000000..f09c8a4 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-12.md @@ -0,0 +1,20 @@ +--- +title: "Siegessäule" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-12.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "75mm" +exposure: "1/4310 f/1.8 ISO 64" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-13.md b/content/photography/europa-2024/berlin-20241019-13.md new file mode 100644 index 0000000..e021b05 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-13.md @@ -0,0 +1,20 @@ +--- +title: "Goldelse" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-13.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "78mm" +exposure: "1/1754 f/2.8 ISO 32" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-14.md b/content/photography/europa-2024/berlin-20241019-14.md new file mode 100644 index 0000000..5de46af --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-14.md @@ -0,0 +1,20 @@ +--- +title: "Brückengeländer" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-14.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2506 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-15.md b/content/photography/europa-2024/berlin-20241019-15.md new file mode 100644 index 0000000..491c08e --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-15.md @@ -0,0 +1,20 @@ +--- +title: "Landwehrkanal" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-15.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2174 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-16.md b/content/photography/europa-2024/berlin-20241019-16.md new file mode 100644 index 0000000..a46b29e --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-16.md @@ -0,0 +1,20 @@ +--- +title: "Richard-Wagner-Denkmal" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-16.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/510 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-17.md b/content/photography/europa-2024/berlin-20241019-17.md new file mode 100644 index 0000000..92dc4aa --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-17.md @@ -0,0 +1,20 @@ +--- +title: "Weg am Tiergarten" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-17.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/428 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-18.md b/content/photography/europa-2024/berlin-20241019-18.md new file mode 100644 index 0000000..1ce43ad --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-18.md @@ -0,0 +1,20 @@ +--- +title: "Abendrot" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-18.jpg +series: europa-2024 +orientation: portrait +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 320" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-19.md b/content/photography/europa-2024/berlin-20241019-19.md new file mode 100644 index 0000000..e02a031 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-19.md @@ -0,0 +1,20 @@ +--- +title: "Philharmonie" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-19.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/33 f/2.2 ISO 1000" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241019-20.md b/content/photography/europa-2024/berlin-20241019-20.md new file mode 100644 index 0000000..067c645 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241019-20.md @@ -0,0 +1,20 @@ +--- +title: "Lego-Giraffe" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241019-20.jpg +series: europa-2024 +orientation: portrait +captured: 2024-10-19 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/13 f/2.2 ISO 1250" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-01.md b/content/photography/europa-2024/berlin-20241020-01.md new file mode 100644 index 0000000..2bf6cfd --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-01.md @@ -0,0 +1,20 @@ +--- +title: "Unter dem Fernsehturm" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-01.jpg +series: europa-2024 +orientation: portrait +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-02.md b/content/photography/europa-2024/berlin-20241020-02.md new file mode 100644 index 0000000..d639459 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-02.md @@ -0,0 +1,20 @@ +--- +title: "East Side Gallery" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4310 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-03.md b/content/photography/europa-2024/berlin-20241020-03.md new file mode 100644 index 0000000..67c2a29 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-03.md @@ -0,0 +1,20 @@ +--- +title: "Wilder Garten" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1366 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-04.md b/content/photography/europa-2024/berlin-20241020-04.md new file mode 100644 index 0000000..79c9364 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-04.md @@ -0,0 +1,20 @@ +--- +title: "Hauptbahnhof" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 320" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-05.md b/content/photography/europa-2024/berlin-20241020-05.md new file mode 100644 index 0000000..d14a0f8 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-05.md @@ -0,0 +1,20 @@ +--- +title: "Reichstag" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/7937 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-06.md b/content/photography/europa-2024/berlin-20241020-06.md new file mode 100644 index 0000000..3c6666a --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-06.md @@ -0,0 +1,20 @@ +--- +title: "Herbstbäume" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1637 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-07.md b/content/photography/europa-2024/berlin-20241020-07.md new file mode 100644 index 0000000..9aff4e8 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-07.md @@ -0,0 +1,20 @@ +--- +title: "Olympiastadion" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/10204 f/2.2 ISO 40" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-08.md b/content/photography/europa-2024/berlin-20241020-08.md new file mode 100644 index 0000000..2bf74ad --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-08.md @@ -0,0 +1,20 @@ +--- +title: "Olympiapark" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/285 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-09.md b/content/photography/europa-2024/berlin-20241020-09.md new file mode 100644 index 0000000..3715aa2 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-09.md @@ -0,0 +1,20 @@ +--- +title: "Allee im Herbst" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-09.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4049 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-10.md b/content/photography/europa-2024/berlin-20241020-10.md new file mode 100644 index 0000000..9f2e332 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-10.md @@ -0,0 +1,20 @@ +--- +title: "Gendarmenmarkt" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/340 f/1.8 ISO 80" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-11.md b/content/photography/europa-2024/berlin-20241020-11.md new file mode 100644 index 0000000..454e541 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-11.md @@ -0,0 +1,20 @@ +--- +title: "Unter den Linden" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-11.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/230 f/1.8 ISO 64" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-12.md b/content/photography/europa-2024/berlin-20241020-12.md new file mode 100644 index 0000000..33b5d11 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-12.md @@ -0,0 +1,20 @@ +--- +title: "Brandenburger Tor" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-12.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 200" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-13.md b/content/photography/europa-2024/berlin-20241020-13.md new file mode 100644 index 0000000..e742c63 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-13.md @@ -0,0 +1,20 @@ +--- +title: "Am Reichstag" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-13.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 125" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-14.md b/content/photography/europa-2024/berlin-20241020-14.md new file mode 100644 index 0000000..777359a --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-14.md @@ -0,0 +1,20 @@ +--- +title: "Blaue Stunde" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-14.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 250" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/berlin-20241020-15.md b/content/photography/europa-2024/berlin-20241020-15.md new file mode 100644 index 0000000..b304f86 --- /dev/null +++ b/content/photography/europa-2024/berlin-20241020-15.md @@ -0,0 +1,20 @@ +--- +title: "Spreebogen" +date: 2026-08-21 +tags: [photography, photography/berlin] +photo: berlin-20241020-15.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-20 +camera: "Apple iPhone 15 Pro" +focal-length: "67mm" +exposure: "1/50 f/1.8 ISO 200" +location: "Berlin, Germany" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [52.5, 13.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/colleville-20241129-01.md b/content/photography/europa-2024/colleville-20241129-01.md new file mode 100644 index 0000000..303508b --- /dev/null +++ b/content/photography/europa-2024/colleville-20241129-01.md @@ -0,0 +1,20 @@ +--- +title: "Omaha Beach" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/colleville-sur-mer] +photo: colleville-20241129-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4608 f/1.8 ISO 80" +location: "Colleville-sur-Mer, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/colleville-20241129-02.md b/content/photography/europa-2024/colleville-20241129-02.md new file mode 100644 index 0000000..1df701b --- /dev/null +++ b/content/photography/europa-2024/colleville-20241129-02.md @@ -0,0 +1,20 @@ +--- +title: "Cimetière américain" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/colleville-sur-mer] +photo: colleville-20241129-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2710 f/1.8 ISO 80" +location: "Colleville-sur-Mer, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/colleville-20241129-03.md b/content/photography/europa-2024/colleville-20241129-03.md new file mode 100644 index 0000000..2369e90 --- /dev/null +++ b/content/photography/europa-2024/colleville-20241129-03.md @@ -0,0 +1,20 @@ +--- +title: "Cimetière américain" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/colleville-sur-mer] +photo: colleville-20241129-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3802 f/1.8 ISO 80" +location: "Colleville-sur-Mer, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/colleville-20241129-04.md b/content/photography/europa-2024/colleville-20241129-04.md new file mode 100644 index 0000000..e37da88 --- /dev/null +++ b/content/photography/europa-2024/colleville-20241129-04.md @@ -0,0 +1,20 @@ +--- +title: "Le mémorial" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/colleville-sur-mer] +photo: colleville-20241129-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4950 f/1.8 ISO 80" +location: "Colleville-sur-Mer, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/colleville-20241129-05.md b/content/photography/europa-2024/colleville-20241129-05.md new file mode 100644 index 0000000..a26c336 --- /dev/null +++ b/content/photography/europa-2024/colleville-20241129-05.md @@ -0,0 +1,20 @@ +--- +title: "Le monument" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/colleville-sur-mer] +photo: colleville-20241129-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Colleville-sur-Mer, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240818-01.md b/content/photography/europa-2024/copenhagen-20240818-01.md new file mode 100644 index 0000000..eb24261 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240818-01.md @@ -0,0 +1,20 @@ +--- +title: "Sunset at Kystagerparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240818-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1488 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240819-01.md b/content/photography/europa-2024/copenhagen-20240819-01.md new file mode 100644 index 0000000..d49459b --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240819-01.md @@ -0,0 +1,20 @@ +--- +title: "Kystagerparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240819-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-19 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1172 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240820-01.md b/content/photography/europa-2024/copenhagen-20240820-01.md new file mode 100644 index 0000000..551ed31 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240820-01.md @@ -0,0 +1,19 @@ +--- +title: "Slotsholmen Canal" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240820-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240823-01.md b/content/photography/europa-2024/copenhagen-20240823-01.md new file mode 100644 index 0000000..1f7a5cf --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240823-01.md @@ -0,0 +1,20 @@ +--- +title: "Kastellet" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240823-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-23 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1869 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240823-02.md b/content/photography/europa-2024/copenhagen-20240823-02.md new file mode 100644 index 0000000..8bf46fa --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240823-02.md @@ -0,0 +1,20 @@ +--- +title: "Kastellet" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240823-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-23 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1488 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240823-03.md b/content/photography/europa-2024/copenhagen-20240823-03.md new file mode 100644 index 0000000..3963a59 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240823-03.md @@ -0,0 +1,20 @@ +--- +title: "Kastellet" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240823-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-23 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4310 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240823-04.md b/content/photography/europa-2024/copenhagen-20240823-04.md new file mode 100644 index 0000000..e7136f8 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240823-04.md @@ -0,0 +1,20 @@ +--- +title: "Kastellet" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240823-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-23 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4049 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240824-01.md b/content/photography/europa-2024/copenhagen-20240824-01.md new file mode 100644 index 0000000..73cc0d6 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240824-01.md @@ -0,0 +1,20 @@ +--- +title: "CopenHill" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240824-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-24 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/6410 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240829-01.md b/content/photography/europa-2024/copenhagen-20240829-01.md new file mode 100644 index 0000000..412eea4 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240829-01.md @@ -0,0 +1,20 @@ +--- +title: "DR Koncerthuset" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240829-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/40 f/1.8 ISO 800" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240829-02.md b/content/photography/europa-2024/copenhagen-20240829-02.md new file mode 100644 index 0000000..a8f7588 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240829-02.md @@ -0,0 +1,20 @@ +--- +title: "DR Byen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240829-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/14 f/1.8 ISO 2500" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240831-01.md b/content/photography/europa-2024/copenhagen-20240831-01.md new file mode 100644 index 0000000..a5ab8a4 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240831-01.md @@ -0,0 +1,20 @@ +--- +title: "Frederiksberg Have" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240831-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-08-31 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/544 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240901-01.md b/content/photography/europa-2024/copenhagen-20240901-01.md new file mode 100644 index 0000000..777632a --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240901-01.md @@ -0,0 +1,19 @@ +--- +title: "Christiania Troll" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240901-01.jpg +series: europa-2024 +orientation: portrait +captured: 2024-09-01 +camera: "Apple iPhone 15 Pro" +focal-length: "30mm" +exposure: "1/1311 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240901-02.md b/content/photography/europa-2024/copenhagen-20240901-02.md new file mode 100644 index 0000000..c701eea --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240901-02.md @@ -0,0 +1,19 @@ +--- +title: "Christianshavn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240901-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/15152 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240902-01.md b/content/photography/europa-2024/copenhagen-20240902-01.md new file mode 100644 index 0000000..32692cc --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240902-01.md @@ -0,0 +1,20 @@ +--- +title: "Amalienborg" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240902-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-02 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3247 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240906-01.md b/content/photography/europa-2024/copenhagen-20240906-01.md new file mode 100644 index 0000000..95b80cf --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240906-01.md @@ -0,0 +1,20 @@ +--- +title: "Nyhavn at night" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240906-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-06 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/25 f/1.8 ISO 1000" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240906-02.md b/content/photography/europa-2024/copenhagen-20240906-02.md new file mode 100644 index 0000000..6b25724 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240906-02.md @@ -0,0 +1,20 @@ +--- +title: "Nyhavn at night" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240906-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-06 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/25 f/1.8 ISO 1000" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240907-01.md b/content/photography/europa-2024/copenhagen-20240907-01.md new file mode 100644 index 0000000..4bb9d99 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240907-01.md @@ -0,0 +1,20 @@ +--- +title: "Østerbro at dawn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240907-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-07 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/433 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240912-01.md b/content/photography/europa-2024/copenhagen-20240912-01.md new file mode 100644 index 0000000..3426cb9 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240912-01.md @@ -0,0 +1,20 @@ +--- +title: "Østerbro at dawn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240912-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3802 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240914-01.md b/content/photography/europa-2024/copenhagen-20240914-01.md new file mode 100644 index 0000000..1f08792 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240914-01.md @@ -0,0 +1,20 @@ +--- +title: "Back on Sjælland" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240914-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-14 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4310 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240915-01.md b/content/photography/europa-2024/copenhagen-20240915-01.md new file mode 100644 index 0000000..08beaf8 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240915-01.md @@ -0,0 +1,19 @@ +--- +title: "Indre By" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240915-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-15 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/912 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240915-02.md b/content/photography/europa-2024/copenhagen-20240915-02.md new file mode 100644 index 0000000..08b31c6 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240915-02.md @@ -0,0 +1,19 @@ +--- +title: "Marmorkirken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240915-02.jpg +series: europa-2024 +orientation: portrait +captured: 2024-09-15 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2604 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240915-03.md b/content/photography/europa-2024/copenhagen-20240915-03.md new file mode 100644 index 0000000..335ed4f --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240915-03.md @@ -0,0 +1,19 @@ +--- +title: "Wartime artifact, Amalienborg" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240915-03.jpg +series: europa-2024 +orientation: portrait +captured: 2024-09-15 +camera: "Apple iPhone 15 Pro" +focal-length: "80mm" +exposure: "1/1488 f/2.8 ISO 25" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240915-04.md b/content/photography/europa-2024/copenhagen-20240915-04.md new file mode 100644 index 0000000..216a5a9 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240915-04.md @@ -0,0 +1,19 @@ +--- +title: "Carl Nielsen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240915-04.jpg +series: europa-2024 +orientation: portrait +captured: 2024-09-15 +camera: "Apple iPhone 15 Pro" +focal-length: "91mm" +exposure: "1/232 f/2.8 ISO 25" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240918-01.md b/content/photography/europa-2024/copenhagen-20240918-01.md new file mode 100644 index 0000000..480bce5 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240918-01.md @@ -0,0 +1,20 @@ +--- +title: "Mocha in Christiania" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240918-01.jpg +series: europa-2024 +orientation: portrait +captured: 2024-09-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/2.2 ISO 640" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240918-02.md b/content/photography/europa-2024/copenhagen-20240918-02.md new file mode 100644 index 0000000..8d7d219 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240918-02.md @@ -0,0 +1,19 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240918-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-18 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/20000 f/1.8 ISO 64" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240921-01.md b/content/photography/europa-2024/copenhagen-20240921-01.md new file mode 100644 index 0000000..8a86edd --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240921-01.md @@ -0,0 +1,20 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240921-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-21 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/372 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240921-02.md b/content/photography/europa-2024/copenhagen-20240921-02.md new file mode 100644 index 0000000..54d2f49 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240921-02.md @@ -0,0 +1,20 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240921-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-21 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/715 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240921-03.md b/content/photography/europa-2024/copenhagen-20240921-03.md new file mode 100644 index 0000000..3bb2979 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240921-03.md @@ -0,0 +1,20 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240921-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-21 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/387 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240921-04.md b/content/photography/europa-2024/copenhagen-20240921-04.md new file mode 100644 index 0000000..7c28068 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240921-04.md @@ -0,0 +1,20 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240921-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-21 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 100" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240921-05.md b/content/photography/europa-2024/copenhagen-20240921-05.md new file mode 100644 index 0000000..7e5bc59 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240921-05.md @@ -0,0 +1,20 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240921-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-21 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 125" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240921-06.md b/content/photography/europa-2024/copenhagen-20240921-06.md new file mode 100644 index 0000000..a8dea3c --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240921-06.md @@ -0,0 +1,20 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240921-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-21 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 125" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240921-07.md b/content/photography/europa-2024/copenhagen-20240921-07.md new file mode 100644 index 0000000..1709f30 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240921-07.md @@ -0,0 +1,20 @@ +--- +title: "Valbyparken" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240921-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-21 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 100" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240922-01.md b/content/photography/europa-2024/copenhagen-20240922-01.md new file mode 100644 index 0000000..09d4c03 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240922-01.md @@ -0,0 +1,20 @@ +--- +title: "Copenhagen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240922-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-22 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/912 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240925-01.md b/content/photography/europa-2024/copenhagen-20240925-01.md new file mode 100644 index 0000000..e7743a6 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240925-01.md @@ -0,0 +1,19 @@ +--- +title: "Orientkaj" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240925-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3413 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240925-02.md b/content/photography/europa-2024/copenhagen-20240925-02.md new file mode 100644 index 0000000..30a2426 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240925-02.md @@ -0,0 +1,19 @@ +--- +title: "Nordhavn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240925-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3802 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240925-03.md b/content/photography/europa-2024/copenhagen-20240925-03.md new file mode 100644 index 0000000..a4bac40 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240925-03.md @@ -0,0 +1,19 @@ +--- +title: "Gammel Strand" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240925-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/25 f/1.8 ISO 1250" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20240925-04.md b/content/photography/europa-2024/copenhagen-20240925-04.md new file mode 100644 index 0000000..ab6b16f --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240925-04.md @@ -0,0 +1,20 @@ +--- +title: "Copenhagen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240925-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-09-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/15 f/1.8 ISO 2500" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20240926-01.md b/content/photography/europa-2024/copenhagen-20240926-01.md new file mode 100644 index 0000000..13ac664 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20240926-01.md @@ -0,0 +1,19 @@ +--- +title: "Gamle Carlsberg" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20240926-01.jpg +series: europa-2024 +orientation: portrait +captured: 2024-09-26 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1261 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20241001-01.md b/content/photography/europa-2024/copenhagen-20241001-01.md new file mode 100644 index 0000000..73c4aac --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-01.md @@ -0,0 +1,20 @@ +--- +title: "Christiansborg" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 125" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241001-02.md b/content/photography/europa-2024/copenhagen-20241001-02.md new file mode 100644 index 0000000..3b20384 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-02.md @@ -0,0 +1,20 @@ +--- +title: "Christiansborg" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/100 f/1.8 ISO 160" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241001-03.md b/content/photography/europa-2024/copenhagen-20241001-03.md new file mode 100644 index 0000000..978e300 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-03.md @@ -0,0 +1,19 @@ +--- +title: "Nyhavn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 125" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20241001-04.md b/content/photography/europa-2024/copenhagen-20241001-04.md new file mode 100644 index 0000000..3bbfae6 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-04.md @@ -0,0 +1,19 @@ +--- +title: "Idyllic Skies O'er the Canal" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/100 f/1.8 ISO 160" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20241001-05.md b/content/photography/europa-2024/copenhagen-20241001-05.md new file mode 100644 index 0000000..93c4e2c --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-05.md @@ -0,0 +1,19 @@ +--- +title: "Christianshavn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 250" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20241001-06.md b/content/photography/europa-2024/copenhagen-20241001-06.md new file mode 100644 index 0000000..c3fd0fd --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-06.md @@ -0,0 +1,19 @@ +--- +title: "Operaen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 320" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20241001-07.md b/content/photography/europa-2024/copenhagen-20241001-07.md new file mode 100644 index 0000000..e979ec9 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-07.md @@ -0,0 +1,20 @@ +--- +title: "Copenhagen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/85 f/1.8 ISO 200" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241001-08.md b/content/photography/europa-2024/copenhagen-20241001-08.md new file mode 100644 index 0000000..402cfd1 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-08.md @@ -0,0 +1,20 @@ +--- +title: "Operaen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 200" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241001-09.md b/content/photography/europa-2024/copenhagen-20241001-09.md new file mode 100644 index 0000000..801710b --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-09.md @@ -0,0 +1,20 @@ +--- +title: "Operaen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-09.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/17 f/1.8 ISO 2000" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241001-10.md b/content/photography/europa-2024/copenhagen-20241001-10.md new file mode 100644 index 0000000..4ec7796 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-10.md @@ -0,0 +1,20 @@ +--- +title: "Operaen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/30 f/1.8 ISO 1600" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241001-11.md b/content/photography/europa-2024/copenhagen-20241001-11.md new file mode 100644 index 0000000..06314fb --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241001-11.md @@ -0,0 +1,20 @@ +--- +title: "Nyhavn after dark" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241001-11.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/33 f/1.8 ISO 800" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241007-01.md b/content/photography/europa-2024/copenhagen-20241007-01.md new file mode 100644 index 0000000..220e20f --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241007-01.md @@ -0,0 +1,19 @@ +--- +title: "Copenhagen Gardens" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241007-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-07 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/693 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- diff --git a/content/photography/europa-2024/copenhagen-20241103-01.md b/content/photography/europa-2024/copenhagen-20241103-01.md new file mode 100644 index 0000000..6e51649 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241103-01.md @@ -0,0 +1,20 @@ +--- +title: "København H" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241103-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 250" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241109-01.md b/content/photography/europa-2024/copenhagen-20241109-01.md new file mode 100644 index 0000000..da60cc5 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241109-01.md @@ -0,0 +1,20 @@ +--- +title: "Vesterbro at night" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241109-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-09 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/35 f/2.2 ISO 1250" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241109-02.md b/content/photography/europa-2024/copenhagen-20241109-02.md new file mode 100644 index 0000000..f61699e --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241109-02.md @@ -0,0 +1,20 @@ +--- +title: "Rådhuspladsen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241109-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-09 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/34 f/1.8 ISO 1000" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241114-01.md b/content/photography/europa-2024/copenhagen-20241114-01.md new file mode 100644 index 0000000..60b95bd --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241114-01.md @@ -0,0 +1,20 @@ +--- +title: "BLOX" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241114-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-14 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/13 f/2.2 ISO 2500" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241114-02.md b/content/photography/europa-2024/copenhagen-20241114-02.md new file mode 100644 index 0000000..fc73db0 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241114-02.md @@ -0,0 +1,20 @@ +--- +title: "DR Byen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241114-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-14 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/11 f/1.8 ISO 1000" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241114-03.md b/content/photography/europa-2024/copenhagen-20241114-03.md new file mode 100644 index 0000000..e20ea04 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241114-03.md @@ -0,0 +1,20 @@ +--- +title: "DR Koncerthuset" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241114-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-14 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 500" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241115-01.md b/content/photography/europa-2024/copenhagen-20241115-01.md new file mode 100644 index 0000000..8376ae0 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241115-01.md @@ -0,0 +1,20 @@ +--- +title: "Strøget" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241115-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-15 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 500" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241117-01.md b/content/photography/europa-2024/copenhagen-20241117-01.md new file mode 100644 index 0000000..ceb0b98 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241117-01.md @@ -0,0 +1,20 @@ +--- +title: "Islands Brygge" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241117-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-17 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2604 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241117-02.md b/content/photography/europa-2024/copenhagen-20241117-02.md new file mode 100644 index 0000000..436859f --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241117-02.md @@ -0,0 +1,20 @@ +--- +title: "Christiansborg Slotsplads" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241117-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-17 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3597 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241117-03.md b/content/photography/europa-2024/copenhagen-20241117-03.md new file mode 100644 index 0000000..f36677a --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241117-03.md @@ -0,0 +1,20 @@ +--- +title: "Industriens Hus" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241117-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-17 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 640" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241120-01.md b/content/photography/europa-2024/copenhagen-20241120-01.md new file mode 100644 index 0000000..51eaee2 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241120-01.md @@ -0,0 +1,20 @@ +--- +title: "Kongens Nytorv" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241120-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3413 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241120-02.md b/content/photography/europa-2024/copenhagen-20241120-02.md new file mode 100644 index 0000000..5cb3e1d --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241120-02.md @@ -0,0 +1,20 @@ +--- +title: "Nyhavn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241120-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/15152 f/1.8 ISO 64" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241120-03.md b/content/photography/europa-2024/copenhagen-20241120-03.md new file mode 100644 index 0000000..64407bf --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241120-03.md @@ -0,0 +1,20 @@ +--- +title: "Nyhavn" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241120-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-20 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3096 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241126-01.md b/content/photography/europa-2024/copenhagen-20241126-01.md new file mode 100644 index 0000000..c4f6a9c --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241126-01.md @@ -0,0 +1,20 @@ +--- +title: "Søerne" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241126-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-26 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241201-01.md b/content/photography/europa-2024/copenhagen-20241201-01.md new file mode 100644 index 0000000..dba0d3e --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241201-01.md @@ -0,0 +1,20 @@ +--- +title: "Frederiksberg Slot" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241201-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-01 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/899 f/2.2 ISO 50" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241201-02.md b/content/photography/europa-2024/copenhagen-20241201-02.md new file mode 100644 index 0000000..24235f4 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241201-02.md @@ -0,0 +1,20 @@ +--- +title: "Frederiksberg Have" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241201-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-01 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1238 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241202-01.md b/content/photography/europa-2024/copenhagen-20241202-01.md new file mode 100644 index 0000000..fd4fed6 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241202-01.md @@ -0,0 +1,20 @@ +--- +title: "Slotsholmen" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241202-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-02 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/25 f/1.8 ISO 1000" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241204-01.md b/content/photography/europa-2024/copenhagen-20241204-01.md new file mode 100644 index 0000000..209f44c --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241204-01.md @@ -0,0 +1,20 @@ +--- +title: "Kongens Nytorv" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241204-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/230 f/1.8 ISO 64" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241212-01.md b/content/photography/europa-2024/copenhagen-20241212-01.md new file mode 100644 index 0000000..bae4705 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241212-01.md @@ -0,0 +1,20 @@ +--- +title: "Østre Anlæg" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241212-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/397 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241212-02.md b/content/photography/europa-2024/copenhagen-20241212-02.md new file mode 100644 index 0000000..1c32450 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241212-02.md @@ -0,0 +1,20 @@ +--- +title: "Strøget" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241212-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 500" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/copenhagen-20241213-01.md b/content/photography/europa-2024/copenhagen-20241213-01.md new file mode 100644 index 0000000..42f8f93 --- /dev/null +++ b/content/photography/europa-2024/copenhagen-20241213-01.md @@ -0,0 +1,20 @@ +--- +title: "Last View of Østerbro" +date: 2026-08-21 +tags: [photography, photography/copenhagen] +photo: copenhagen-20241213-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/439 f/1.8 ISO 80" +location: "Copenhagen, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.7, 12.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/flight-20241127-01.md b/content/photography/europa-2024/flight-20241127-01.md new file mode 100644 index 0000000..65d11e0 --- /dev/null +++ b/content/photography/europa-2024/flight-20241127-01.md @@ -0,0 +1,19 @@ +--- +title: "Sun over wing" +date: 2026-08-21 +tags: [photography, photography/travel, photography/normandy] +photo: flight-20241127-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1637 f/2.2 ISO 50" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +# geo: [00.000, 00.000] # add deliberately; pair with geo-precision +# geo-precision: city # exact | km | city | hidden (default: city) +--- + diff --git a/content/photography/europa-2024/flight-20241127-02.md b/content/photography/europa-2024/flight-20241127-02.md new file mode 100644 index 0000000..c58490f --- /dev/null +++ b/content/photography/europa-2024/flight-20241127-02.md @@ -0,0 +1,19 @@ +--- +title: "Landing" +date: 2026-08-21 +tags: [photography, photography/travel, photography/normandy] +photo: flight-20241127-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3546 f/2.2 ISO 50" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +# geo: [00.000, 00.000] # add deliberately; pair with geo-precision +# geo-precision: city # exact | km | city | hidden (default: city) +--- + diff --git a/content/photography/europa-2024/flight-20241213-01.md b/content/photography/europa-2024/flight-20241213-01.md new file mode 100644 index 0000000..73e928e --- /dev/null +++ b/content/photography/europa-2024/flight-20241213-01.md @@ -0,0 +1,19 @@ +--- +title: "Going Home" +date: 2026-08-21 +tags: [photography, photography/travel] +photo: flight-20241213-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-12-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/7092 f/1.8 ISO 80" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +# geo: [00.000, 00.000] # add deliberately; pair with geo-precision +# geo-precision: city # exact | km | city | hidden (default: city) +--- + diff --git a/content/photography/europa-2024/index.md b/content/photography/europa-2024/index.md new file mode 100644 index 0000000..f137f6b --- /dev/null +++ b/content/photography/europa-2024/index.md @@ -0,0 +1,10 @@ +--- +title: "Europa 2024" +date: 2026-08-21 +abstract: > + My study abroad: a time where dreams previous came true, symphonies were written, and new dreams that would define the years to come of my life were born. +tags: [photography] +photo: ireland-20240713-04.jpg +location: "Europe" +captured: 2024-07-11 +--- diff --git a/content/photography/europa-2024/ireland-20240711-01.md b/content/photography/europa-2024/ireland-20240711-01.md new file mode 100644 index 0000000..bdf238e --- /dev/null +++ b/content/photography/europa-2024/ireland-20240711-01.md @@ -0,0 +1,20 @@ +--- +title: "The crossing" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240711-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-11 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/20000 f/1.8 ISO 64" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240711-02.md b/content/photography/europa-2024/ireland-20240711-02.md new file mode 100644 index 0000000..fe8557e --- /dev/null +++ b/content/photography/europa-2024/ireland-20240711-02.md @@ -0,0 +1,20 @@ +--- +title: "Irish Sea" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240711-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-11 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/15152 f/1.8 ISO 100" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240712-01.md b/content/photography/europa-2024/ireland-20240712-01.md new file mode 100644 index 0000000..3bab342 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240712-01.md @@ -0,0 +1,20 @@ +--- +title: "Hedgerow" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240712-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4049 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240712-02.md b/content/photography/europa-2024/ireland-20240712-02.md new file mode 100644 index 0000000..656a001 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240712-02.md @@ -0,0 +1,20 @@ +--- +title: "Windmill" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240712-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/20000 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240712-03.md b/content/photography/europa-2024/ireland-20240712-03.md new file mode 100644 index 0000000..f79f484 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240712-03.md @@ -0,0 +1,20 @@ +--- +title: "Glasshouse" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240712-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2041 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240713-01.md b/content/photography/europa-2024/ireland-20240713-01.md new file mode 100644 index 0000000..ce9a949 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240713-01.md @@ -0,0 +1,20 @@ +--- +title: "Town square" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240713-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240713-02.md b/content/photography/europa-2024/ireland-20240713-02.md new file mode 100644 index 0000000..e30cf13 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240713-02.md @@ -0,0 +1,20 @@ +--- +title: "Marina at low tide" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240713-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/12346 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240713-03.md b/content/photography/europa-2024/ireland-20240713-03.md new file mode 100644 index 0000000..d0d8fe5 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240713-03.md @@ -0,0 +1,20 @@ +--- +title: "Headland" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240713-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240713-04.md b/content/photography/europa-2024/ireland-20240713-04.md new file mode 100644 index 0000000..fe3becb --- /dev/null +++ b/content/photography/europa-2024/ireland-20240713-04.md @@ -0,0 +1,20 @@ +--- +title: "Coast path" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240713-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/20000 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240713-05.md b/content/photography/europa-2024/ireland-20240713-05.md new file mode 100644 index 0000000..5e3ef27 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240713-05.md @@ -0,0 +1,20 @@ +--- +title: "Rocky shore" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240713-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/10417 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240713-06.md b/content/photography/europa-2024/ireland-20240713-06.md new file mode 100644 index 0000000..e76cfb9 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240713-06.md @@ -0,0 +1,20 @@ +--- +title: "Up the headland" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240713-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-07-13 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/ireland-20240714-01.md b/content/photography/europa-2024/ireland-20240714-01.md new file mode 100644 index 0000000..556c355 --- /dev/null +++ b/content/photography/europa-2024/ireland-20240714-01.md @@ -0,0 +1,20 @@ +--- +title: "Cathedral" +date: 2026-08-21 +tags: [photography, photography/ireland] +photo: ireland-20240714-01.jpg +series: europa-2024 +orientation: portrait +captured: 2024-07-14 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4310 f/1.8 ISO 80" +location: "Dublin, Ireland" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [53.3, -6.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-01.md b/content/photography/europa-2024/mont-saint-michel-20241128-01.md new file mode 100644 index 0000000..44811cb --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-01.md @@ -0,0 +1,20 @@ +--- +title: "Mont-Saint-Michel" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "71mm" +exposure: "1/3817 f/1.8 ISO 64" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-02.md b/content/photography/europa-2024/mont-saint-michel-20241128-02.md new file mode 100644 index 0000000..43bcf52 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-02.md @@ -0,0 +1,20 @@ +--- +title: "La baie" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-02.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4950 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-03.md b/content/photography/europa-2024/mont-saint-michel-20241128-03.md new file mode 100644 index 0000000..d570c73 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-03.md @@ -0,0 +1,20 @@ +--- +title: "Les remparts" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1261 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-04.md b/content/photography/europa-2024/mont-saint-michel-20241128-04.md new file mode 100644 index 0000000..6711074 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-04.md @@ -0,0 +1,20 @@ +--- +title: "L'abbaye" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/2208 f/2.2 ISO 50" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-05.md b/content/photography/europa-2024/mont-saint-michel-20241128-05.md new file mode 100644 index 0000000..820d6a1 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-05.md @@ -0,0 +1,20 @@ +--- +title: "Vue sur la baie" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4310 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-06.md b/content/photography/europa-2024/mont-saint-michel-20241128-06.md new file mode 100644 index 0000000..68614f9 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-06.md @@ -0,0 +1,20 @@ +--- +title: "La baie" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4608 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-07.md b/content/photography/europa-2024/mont-saint-michel-20241128-07.md new file mode 100644 index 0000000..7cd883a --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-07.md @@ -0,0 +1,20 @@ +--- +title: "Grande Rue" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1076 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-08.md b/content/photography/europa-2024/mont-saint-michel-20241128-08.md new file mode 100644 index 0000000..19f68c3 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-08.md @@ -0,0 +1,20 @@ +--- +title: "Les toits" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4310 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-09.md b/content/photography/europa-2024/mont-saint-michel-20241128-09.md new file mode 100644 index 0000000..e4daccc --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-09.md @@ -0,0 +1,20 @@ +--- +title: "La Vierge" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-09.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/345 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-10.md b/content/photography/europa-2024/mont-saint-michel-20241128-10.md new file mode 100644 index 0000000..922d5ed --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-10.md @@ -0,0 +1,20 @@ +--- +title: "L'abbaye" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1923 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-11.md b/content/photography/europa-2024/mont-saint-michel-20241128-11.md new file mode 100644 index 0000000..7e80b13 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-11.md @@ -0,0 +1,20 @@ +--- +title: "La flèche" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-11.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1637 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-12.md b/content/photography/europa-2024/mont-saint-michel-20241128-12.md new file mode 100644 index 0000000..c1e8989 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-12.md @@ -0,0 +1,20 @@ +--- +title: "Les grèves" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-12.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1721 f/1.8 ISO 80" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-13.md b/content/photography/europa-2024/mont-saint-michel-20241128-13.md new file mode 100644 index 0000000..f34cf92 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-13.md @@ -0,0 +1,20 @@ +--- +title: "Salle voûtée" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-13.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 500" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-14.md b/content/photography/europa-2024/mont-saint-michel-20241128-14.md new file mode 100644 index 0000000..f3d95e5 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-14.md @@ -0,0 +1,20 @@ +--- +title: "Salle voûtée" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-14.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/50 f/2.2 ISO 640" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-15.md b/content/photography/europa-2024/mont-saint-michel-20241128-15.md new file mode 100644 index 0000000..63ef968 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-15.md @@ -0,0 +1,20 @@ +--- +title: "Le parvis" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-15.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "18mm" +exposure: "1/681 f/2.2 ISO 40" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-16.md b/content/photography/europa-2024/mont-saint-michel-20241128-16.md new file mode 100644 index 0000000..548e8c8 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-16.md @@ -0,0 +1,20 @@ +--- +title: "La nef" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-16.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 500" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-17.md b/content/photography/europa-2024/mont-saint-michel-20241128-17.md new file mode 100644 index 0000000..8a24ae8 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-17.md @@ -0,0 +1,20 @@ +--- +title: "Le chœur" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-17.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 320" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-18.md b/content/photography/europa-2024/mont-saint-michel-20241128-18.md new file mode 100644 index 0000000..c9916cd --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-18.md @@ -0,0 +1,20 @@ +--- +title: "Saint Michel et le dragon" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-18.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 200" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-19.md b/content/photography/europa-2024/mont-saint-michel-20241128-19.md new file mode 100644 index 0000000..d2615f7 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-19.md @@ -0,0 +1,20 @@ +--- +title: "La chapelle" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-19.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/149 f/1.8 ISO 64" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-20.md b/content/photography/europa-2024/mont-saint-michel-20241128-20.md new file mode 100644 index 0000000..f75e8e8 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-20.md @@ -0,0 +1,20 @@ +--- +title: "Crypte des Gros Piliers" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-20.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/11 f/2.2 ISO 1250" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-21.md b/content/photography/europa-2024/mont-saint-michel-20241128-21.md new file mode 100644 index 0000000..565dccc --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-21.md @@ -0,0 +1,20 @@ +--- +title: "Le promenoir" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-21.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/10 f/1.8 ISO 800" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/mont-saint-michel-20241128-22.md b/content/photography/europa-2024/mont-saint-michel-20241128-22.md new file mode 100644 index 0000000..07b2dc5 --- /dev/null +++ b/content/photography/europa-2024/mont-saint-michel-20241128-22.md @@ -0,0 +1,20 @@ +--- +title: "Les contreforts" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/mont-saint-michel] +photo: mont-saint-michel-20241128-22.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/208 f/2.2 ISO 40" +location: "Mont-Saint-Michel, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.6, -1.5] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-01.md b/content/photography/europa-2024/odense-20241125-01.md new file mode 100644 index 0000000..6c698ad --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-01.md @@ -0,0 +1,20 @@ +--- +title: "Odense centrum" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/170 f/1.8 ISO 64" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-02.md b/content/photography/europa-2024/odense-20241125-02.md new file mode 100644 index 0000000..e15a5e0 --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-02.md @@ -0,0 +1,20 @@ +--- +title: "Letbanen" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/130 f/1.8 ISO 50" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-03.md b/content/photography/europa-2024/odense-20241125-03.md new file mode 100644 index 0000000..407623d --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-03.md @@ -0,0 +1,20 @@ +--- +title: "Odense centrum" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/577 f/1.8 ISO 80" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-04.md b/content/photography/europa-2024/odense-20241125-04.md new file mode 100644 index 0000000..dc120f0 --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-04.md @@ -0,0 +1,20 @@ +--- +title: "Sankt Albani Kirke" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-04.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/428 f/1.8 ISO 80" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-05.md b/content/photography/europa-2024/odense-20241125-05.md new file mode 100644 index 0000000..476b58c --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-05.md @@ -0,0 +1,20 @@ +--- +title: "Sankt Knuds Kirke" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 64" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-06.md b/content/photography/europa-2024/odense-20241125-06.md new file mode 100644 index 0000000..d12c78c --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-06.md @@ -0,0 +1,20 @@ +--- +title: "H.C. Andersen" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-06.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/392 f/1.8 ISO 80" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-07.md b/content/photography/europa-2024/odense-20241125-07.md new file mode 100644 index 0000000..dbe205b --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-07.md @@ -0,0 +1,20 @@ +--- +title: "Odense Å" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 80" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-08.md b/content/photography/europa-2024/odense-20241125-08.md new file mode 100644 index 0000000..6aead10 --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-08.md @@ -0,0 +1,20 @@ +--- +title: "Ved Odense Å" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/165 f/1.8 ISO 64" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-09.md b/content/photography/europa-2024/odense-20241125-09.md new file mode 100644 index 0000000..351b4f4 --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-09.md @@ -0,0 +1,20 @@ +--- +title: "Munke Mose" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-09.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 125" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/odense-20241125-10.md b/content/photography/europa-2024/odense-20241125-10.md new file mode 100644 index 0000000..56b61d0 --- /dev/null +++ b/content/photography/europa-2024/odense-20241125-10.md @@ -0,0 +1,20 @@ +--- +title: "Flakhaven" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: odense-20241125-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/407 f/1.8 ISO 80" +location: "Odense, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.4, 10.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/omaha-beach-20241129-01.md b/content/photography/europa-2024/omaha-beach-20241129-01.md new file mode 100644 index 0000000..5eea6df --- /dev/null +++ b/content/photography/europa-2024/omaha-beach-20241129-01.md @@ -0,0 +1,20 @@ +--- +title: "La falaise" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/omaha-beach] +photo: omaha-beach-20241129-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/2342 f/2.2 ISO 40" +location: "Omaha Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/omaha-beach-20241129-02.md b/content/photography/europa-2024/omaha-beach-20241129-02.md new file mode 100644 index 0000000..1e2969c --- /dev/null +++ b/content/photography/europa-2024/omaha-beach-20241129-02.md @@ -0,0 +1,20 @@ +--- +title: "Les dunes" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/omaha-beach] +photo: omaha-beach-20241129-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/20000 f/1.8 ISO 64" +location: "Omaha Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/omaha-beach-20241129-03.md b/content/photography/europa-2024/omaha-beach-20241129-03.md new file mode 100644 index 0000000..8330c13 --- /dev/null +++ b/content/photography/europa-2024/omaha-beach-20241129-03.md @@ -0,0 +1,20 @@ +--- +title: "Omaha Beach" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/omaha-beach] +photo: omaha-beach-20241129-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/2342 f/2.2 ISO 50" +location: "Omaha Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/omaha-beach-20241129-04.md b/content/photography/europa-2024/omaha-beach-20241129-04.md new file mode 100644 index 0000000..2de5b6d --- /dev/null +++ b/content/photography/europa-2024/omaha-beach-20241129-04.md @@ -0,0 +1,20 @@ +--- +title: "Omaha Beach" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/omaha-beach] +photo: omaha-beach-20241129-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/3906 f/2.2 ISO 40" +location: "Omaha Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/omaha-beach-20241129-05.md b/content/photography/europa-2024/omaha-beach-20241129-05.md new file mode 100644 index 0000000..f87a70b --- /dev/null +++ b/content/photography/europa-2024/omaha-beach-20241129-05.md @@ -0,0 +1,20 @@ +--- +title: "La marée" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/omaha-beach] +photo: omaha-beach-20241129-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/9009 f/1.8 ISO 80" +location: "Omaha Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/omaha-beach-20241129-06.md b/content/photography/europa-2024/omaha-beach-20241129-06.md new file mode 100644 index 0000000..4af8c15 --- /dev/null +++ b/content/photography/europa-2024/omaha-beach-20241129-06.md @@ -0,0 +1,20 @@ +--- +title: "Omaha Beach" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/omaha-beach] +photo: omaha-beach-20241129-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/10417 f/1.8 ISO 80" +location: "Omaha Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -0.9] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/paris-20241130-01.md b/content/photography/europa-2024/paris-20241130-01.md new file mode 100644 index 0000000..f2b9377 --- /dev/null +++ b/content/photography/europa-2024/paris-20241130-01.md @@ -0,0 +1,20 @@ +--- +title: "La Seine" +date: 2026-08-21 +tags: [photography, photography/paris] +photo: paris-20241130-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-30 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/17 f/1.8 ISO 3200" +location: "Paris, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.9, 2.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/paris-20241130-02.md b/content/photography/europa-2024/paris-20241130-02.md new file mode 100644 index 0000000..a27b2d9 --- /dev/null +++ b/content/photography/europa-2024/paris-20241130-02.md @@ -0,0 +1,20 @@ +--- +title: "Le Congrès" +date: 2026-08-21 +tags: [photography, photography/paris] +photo: paris-20241130-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-30 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/33 f/1.8 ISO 640" +location: "Paris, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.9, 2.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/paris-20241130-03.md b/content/photography/europa-2024/paris-20241130-03.md new file mode 100644 index 0000000..52368c2 --- /dev/null +++ b/content/photography/europa-2024/paris-20241130-03.md @@ -0,0 +1,20 @@ +--- +title: "Tour Eiffel" +date: 2026-08-21 +tags: [photography, photography/paris] +photo: paris-20241130-03.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-30 +camera: "Apple iPhone 15 Pro" +focal-length: "18mm" +exposure: "1/36 f/2.2 ISO 1600" +location: "Paris, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [48.9, 2.4] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/paris-20241130-04.md b/content/photography/europa-2024/paris-20241130-04.md new file mode 100644 index 0000000..27be547 --- /dev/null +++ b/content/photography/europa-2024/paris-20241130-04.md @@ -0,0 +1,20 @@ +--- +title: "Charles de Gaulle" +date: 2026-08-21 +tags: [photography, photography/paris] +photo: paris-20241130-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-30 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 800" +location: "Roissy-en-France, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.0, 2.6] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/pegasus-bridge-20241130-01.md b/content/photography/europa-2024/pegasus-bridge-20241130-01.md new file mode 100644 index 0000000..af0c942 --- /dev/null +++ b/content/photography/europa-2024/pegasus-bridge-20241130-01.md @@ -0,0 +1,20 @@ +--- +title: "Pegasus Bridge" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/pegasus-bridge] +photo: pegasus-bridge-20241130-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-30 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/7937 f/1.8 ISO 80" +location: "Bénouville, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.2, -0.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/pegasus-bridge-20241130-02.md b/content/photography/europa-2024/pegasus-bridge-20241130-02.md new file mode 100644 index 0000000..86f3c51 --- /dev/null +++ b/content/photography/europa-2024/pegasus-bridge-20241130-02.md @@ -0,0 +1,20 @@ +--- +title: "Pegasus Bridge" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/pegasus-bridge] +photo: pegasus-bridge-20241130-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-30 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/6410 f/1.8 ISO 80" +location: "Bénouville, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.2, -0.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/pointe-du-hoc-20241129-01.md b/content/photography/europa-2024/pointe-du-hoc-20241129-01.md new file mode 100644 index 0000000..9f7ef14 --- /dev/null +++ b/content/photography/europa-2024/pointe-du-hoc-20241129-01.md @@ -0,0 +1,20 @@ +--- +title: "Pointe du Hoc" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/pointe-du-hoc] +photo: pointe-du-hoc-20241129-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Pointe du Hoc, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.0] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/pointe-du-hoc-20241129-02.md b/content/photography/europa-2024/pointe-du-hoc-20241129-02.md new file mode 100644 index 0000000..8973b34 --- /dev/null +++ b/content/photography/europa-2024/pointe-du-hoc-20241129-02.md @@ -0,0 +1,20 @@ +--- +title: "Les cratères" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/pointe-du-hoc] +photo: pointe-du-hoc-20241129-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4950 f/1.8 ISO 80" +location: "Pointe du Hoc, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.0] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/pointe-du-hoc-20241129-03.md b/content/photography/europa-2024/pointe-du-hoc-20241129-03.md new file mode 100644 index 0000000..e4bf7ea --- /dev/null +++ b/content/photography/europa-2024/pointe-du-hoc-20241129-03.md @@ -0,0 +1,20 @@ +--- +title: "Dans le bunker" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/pointe-du-hoc] +photo: pointe-du-hoc-20241129-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/325 f/2.2 ISO 40" +location: "Pointe du Hoc, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.0] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/pointe-du-hoc-20241129-04.md b/content/photography/europa-2024/pointe-du-hoc-20241129-04.md new file mode 100644 index 0000000..82d393d --- /dev/null +++ b/content/photography/europa-2024/pointe-du-hoc-20241129-04.md @@ -0,0 +1,20 @@ +--- +title: "Le monument des Rangers" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/pointe-du-hoc] +photo: pointe-du-hoc-20241129-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-29 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/1346 f/2.2 ISO 40" +location: "Pointe du Hoc, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.0] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-01.md b/content/photography/europa-2024/roskilde-20241012-01.md new file mode 100644 index 0000000..fec36d5 --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-01.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-01.jpg +series: europa-2024 +orientation: portrait +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/763 f/2.2 ISO 40" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-02.md b/content/photography/europa-2024/roskilde-20241012-02.md new file mode 100644 index 0000000..7e043ec --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-02.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/4950 f/1.8 ISO 80" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-03.md b/content/photography/europa-2024/roskilde-20241012-03.md new file mode 100644 index 0000000..03f4f4d --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-03.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5814 f/1.8 ISO 80" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-04.md b/content/photography/europa-2024/roskilde-20241012-04.md new file mode 100644 index 0000000..34dc517 --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-04.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/6410 f/1.8 ISO 80" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-05.md b/content/photography/europa-2024/roskilde-20241012-05.md new file mode 100644 index 0000000..277c108 --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-05.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3802 f/1.8 ISO 80" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-06.md b/content/photography/europa-2024/roskilde-20241012-06.md new file mode 100644 index 0000000..0e7dbae --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-06.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "113mm" +exposure: "1/122 f/2.8 ISO 25" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-07.md b/content/photography/europa-2024/roskilde-20241012-07.md new file mode 100644 index 0000000..63ff62c --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-07.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-08.md b/content/photography/europa-2024/roskilde-20241012-08.md new file mode 100644 index 0000000..c10d952 --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-08.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5348 f/1.8 ISO 80" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/roskilde-20241012-09.md b/content/photography/europa-2024/roskilde-20241012-09.md new file mode 100644 index 0000000..8231b5d --- /dev/null +++ b/content/photography/europa-2024/roskilde-20241012-09.md @@ -0,0 +1,20 @@ +--- +title: "Roskilde" +date: 2026-08-21 +tags: [photography, photography/roskilde] +photo: roskilde-20241012-09.jpg +series: europa-2024 +orientation: portrait +captured: 2024-10-12 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/12346 f/1.8 ISO 80" +location: "Roskilde, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.6, 12.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/sainte-mere-eglise-20241128-01.md b/content/photography/europa-2024/sainte-mere-eglise-20241128-01.md new file mode 100644 index 0000000..43949c0 --- /dev/null +++ b/content/photography/europa-2024/sainte-mere-eglise-20241128-01.md @@ -0,0 +1,20 @@ +--- +title: "Sainte-Mère-Église" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/sainte-mere-eglise] +photo: sainte-mere-eglise-20241128-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/2041 f/1.8 ISO 80" +location: "Sainte-Mère-Église, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/sainte-mere-eglise-20241128-02.md b/content/photography/europa-2024/sainte-mere-eglise-20241128-02.md new file mode 100644 index 0000000..4b54b55 --- /dev/null +++ b/content/photography/europa-2024/sainte-mere-eglise-20241128-02.md @@ -0,0 +1,20 @@ +--- +title: "Vitrail des parachutistes" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/sainte-mere-eglise] +photo: sainte-mere-eglise-20241128-02.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 800" +location: "Sainte-Mère-Église, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/sainte-mere-eglise-20241128-03.md b/content/photography/europa-2024/sainte-mere-eglise-20241128-03.md new file mode 100644 index 0000000..010b425 --- /dev/null +++ b/content/photography/europa-2024/sainte-mere-eglise-20241128-03.md @@ -0,0 +1,20 @@ +--- +title: "La nef" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/sainte-mere-eglise] +photo: sainte-mere-eglise-20241128-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 400" +location: "Sainte-Mère-Église, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/sainte-mere-eglise-20241128-04.md b/content/photography/europa-2024/sainte-mere-eglise-20241128-04.md new file mode 100644 index 0000000..290de55 --- /dev/null +++ b/content/photography/europa-2024/sainte-mere-eglise-20241128-04.md @@ -0,0 +1,20 @@ +--- +title: "Le chœur" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/sainte-mere-eglise] +photo: sainte-mere-eglise-20241128-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-28 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/60 f/1.8 ISO 320" +location: "Sainte-Mère-Église, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.3] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-01.md b/content/photography/europa-2024/stockholm-20241103-01.md new file mode 100644 index 0000000..f980f4b --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-01.md @@ -0,0 +1,20 @@ +--- +title: "Gågatan" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/242 f/1.8 ISO 64" +location: "Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-02.md b/content/photography/europa-2024/stockholm-20241103-02.md new file mode 100644 index 0000000..bd6f4b2 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-02.md @@ -0,0 +1,20 @@ +--- +title: "Klara kyrka" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-02.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/756 f/1.8 ISO 80" +location: "Norrmalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-03.md b/content/photography/europa-2024/stockholm-20241103-03.md new file mode 100644 index 0000000..6a1f4b8 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-03.md @@ -0,0 +1,20 @@ +--- +title: "Tegelfasad" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 64" +location: "Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-04.md b/content/photography/europa-2024/stockholm-20241103-04.md new file mode 100644 index 0000000..0ee5df7 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-04.md @@ -0,0 +1,20 @@ +--- +title: "Riddarfjärden" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1172 f/1.8 ISO 80" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-05.md b/content/photography/europa-2024/stockholm-20241103-05.md new file mode 100644 index 0000000..25bd6df --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-05.md @@ -0,0 +1,20 @@ +--- +title: "Riddarholmen" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 200" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-06.md b/content/photography/europa-2024/stockholm-20241103-06.md new file mode 100644 index 0000000..ed050ba --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-06.md @@ -0,0 +1,20 @@ +--- +title: "Gamla stan" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/100 f/1.8 ISO 100" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-07.md b/content/photography/europa-2024/stockholm-20241103-07.md new file mode 100644 index 0000000..3f9b3c8 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-07.md @@ -0,0 +1,20 @@ +--- +title: "Uteservering" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/33 f/1.8 ISO 800" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-08.md b/content/photography/europa-2024/stockholm-20241103-08.md new file mode 100644 index 0000000..df4b758 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-08.md @@ -0,0 +1,20 @@ +--- +title: "Stortorget" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-08.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 320" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-09.md b/content/photography/europa-2024/stockholm-20241103-09.md new file mode 100644 index 0000000..0cc643b --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-09.md @@ -0,0 +1,20 @@ +--- +title: "Tyska kyrkan" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-09.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/25 f/2.2 ISO 2000" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-10.md b/content/photography/europa-2024/stockholm-20241103-10.md new file mode 100644 index 0000000..289511c --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-10.md @@ -0,0 +1,20 @@ +--- +title: "Kyrkogrind" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/15 f/1.8 ISO 1000" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-11.md b/content/photography/europa-2024/stockholm-20241103-11.md new file mode 100644 index 0000000..ab1ced5 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-11.md @@ -0,0 +1,20 @@ +--- +title: "Gröna Lund" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-11.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "145mm" +exposure: "1/4 f/2.8 ISO 2000" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-12.md b/content/photography/europa-2024/stockholm-20241103-12.md new file mode 100644 index 0000000..585a305 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-12.md @@ -0,0 +1,20 @@ +--- +title: "Sankt Göran och draken" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-12.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/10 f/1.8 ISO 1250" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-13.md b/content/photography/europa-2024/stockholm-20241103-13.md new file mode 100644 index 0000000..9c53d61 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-13.md @@ -0,0 +1,20 @@ +--- +title: "Kungliga slottet" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-13.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/5 f/1.8 ISO 1600" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241103-14.md b/content/photography/europa-2024/stockholm-20241103-14.md new file mode 100644 index 0000000..fe0ab48 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241103-14.md @@ -0,0 +1,20 @@ +--- +title: "Riksdagshuset" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241103-14.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-03 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/10 f/1.8 ISO 2500" +location: "Gamla stan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-01.md b/content/photography/europa-2024/stockholm-20241104-01.md new file mode 100644 index 0000000..8d7abd7 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-01.md @@ -0,0 +1,20 @@ +--- +title: "Sergels torg" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/1395 f/1.8 ISO 80" +location: "Norrmalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-02.md b/content/photography/europa-2024/stockholm-20241104-02.md new file mode 100644 index 0000000..6bc84ec --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-02.md @@ -0,0 +1,20 @@ +--- +title: "Dramaten" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/9009 f/1.8 ISO 80" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-03.md b/content/photography/europa-2024/stockholm-20241104-03.md new file mode 100644 index 0000000..9a87ea6 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-03.md @@ -0,0 +1,20 @@ +--- +title: "Stockholms universitet" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/30 f/1.8 ISO 1600" +location: "Frescati, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-04.md b/content/photography/europa-2024/stockholm-20241104-04.md new file mode 100644 index 0000000..622bdf4 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-04.md @@ -0,0 +1,20 @@ +--- +title: "Stockholms universitet" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/22 f/1.8 ISO 1600" +location: "Frescati, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-05.md b/content/photography/europa-2024/stockholm-20241104-05.md new file mode 100644 index 0000000..7fbcedd --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-05.md @@ -0,0 +1,20 @@ +--- +title: "Stockholms universitet" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/16 f/1.8 ISO 1250" +location: "Frescati, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-06.md b/content/photography/europa-2024/stockholm-20241104-06.md new file mode 100644 index 0000000..c0df27d --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-06.md @@ -0,0 +1,20 @@ +--- +title: "Stockholms Olympiastadion" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/15 f/1.8 ISO 1000" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-07.md b/content/photography/europa-2024/stockholm-20241104-07.md new file mode 100644 index 0000000..5a06c01 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-07.md @@ -0,0 +1,20 @@ +--- +title: "Löparbanan" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "14mm" +exposure: "1/40 f/2.2 ISO 1250" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-08.md b/content/photography/europa-2024/stockholm-20241104-08.md new file mode 100644 index 0000000..b7324c6 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-08.md @@ -0,0 +1,20 @@ +--- +title: "Olympiska spelen 1912" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-08.jpg +series: europa-2024 +orientation: portrait +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "80mm" +exposure: "1/50 f/2.8 ISO 400" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-09.md b/content/photography/europa-2024/stockholm-20241104-09.md new file mode 100644 index 0000000..ff86751 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-09.md @@ -0,0 +1,20 @@ +--- +title: "Hedvig Eleonora kyrka" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-09.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/20 f/1.8 ISO 1600" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-10.md b/content/photography/europa-2024/stockholm-20241104-10.md new file mode 100644 index 0000000..949692f --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-10.md @@ -0,0 +1,20 @@ +--- +title: "Blå timmen" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-10.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/25 f/1.8 ISO 800" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-11.md b/content/photography/europa-2024/stockholm-20241104-11.md new file mode 100644 index 0000000..238c39e --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-11.md @@ -0,0 +1,20 @@ +--- +title: "Dramaten at night" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-11.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/50 f/1.8 ISO 640" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-12.md b/content/photography/europa-2024/stockholm-20241104-12.md new file mode 100644 index 0000000..88e56e8 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-12.md @@ -0,0 +1,20 @@ +--- +title: "Kvällsgata" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-12.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/33 f/1.8 ISO 1000" +location: "Östermalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241104-13.md b/content/photography/europa-2024/stockholm-20241104-13.md new file mode 100644 index 0000000..ba66453 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241104-13.md @@ -0,0 +1,20 @@ +--- +title: "Norrmalmstorg" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241104-13.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-04 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/33 f/1.8 ISO 640" +location: "Norrmalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241105-01.md b/content/photography/europa-2024/stockholm-20241105-01.md new file mode 100644 index 0000000..54cc320 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241105-01.md @@ -0,0 +1,20 @@ +--- +title: "Kungsträdgården" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241105-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-05 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/3802 f/1.8 ISO 80" +location: "Norrmalm, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/stockholm-20241107-01.md b/content/photography/europa-2024/stockholm-20241107-01.md new file mode 100644 index 0000000..91d9c48 --- /dev/null +++ b/content/photography/europa-2024/stockholm-20241107-01.md @@ -0,0 +1,20 @@ +--- +title: "Gustaf Vasa kyrka" +date: 2026-08-21 +tags: [photography, photography/stockholm] +photo: stockholm-20241107-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-07 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/25 f/1.8 ISO 1250" +location: "Vasastan, Stockholm, Sweden" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [59.3, 18.1] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/storebaelt-20241125-01.md b/content/photography/europa-2024/storebaelt-20241125-01.md new file mode 100644 index 0000000..aba72ed --- /dev/null +++ b/content/photography/europa-2024/storebaelt-20241125-01.md @@ -0,0 +1,20 @@ +--- +title: "Storebælt" +date: 2026-08-21 +tags: [photography, photography/odense] +photo: storebaelt-20241125-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-25 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 64" +location: "Storebælt, Denmark" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [55.3, 11.0] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/utah-beach-20241127-01.md b/content/photography/europa-2024/utah-beach-20241127-01.md new file mode 100644 index 0000000..81ecce8 --- /dev/null +++ b/content/photography/europa-2024/utah-beach-20241127-01.md @@ -0,0 +1,20 @@ +--- +title: "Bunker" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/utah-beach] +photo: utah-beach-20241127-01.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/137 f/1.8 ISO 50" +location: "Utah Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/utah-beach-20241127-02.md b/content/photography/europa-2024/utah-beach-20241127-02.md new file mode 100644 index 0000000..e25603a --- /dev/null +++ b/content/photography/europa-2024/utah-beach-20241127-02.md @@ -0,0 +1,20 @@ +--- +title: "Cannon Ball II" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/utah-beach] +photo: utah-beach-20241127-02.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/160 f/1.8 ISO 64" +location: "Utah Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/utah-beach-20241127-03.md b/content/photography/europa-2024/utah-beach-20241127-03.md new file mode 100644 index 0000000..914c6cb --- /dev/null +++ b/content/photography/europa-2024/utah-beach-20241127-03.md @@ -0,0 +1,20 @@ +--- +title: "Utah Beach" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/utah-beach] +photo: utah-beach-20241127-03.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/262 f/1.8 ISO 64" +location: "Utah Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/utah-beach-20241127-04.md b/content/photography/europa-2024/utah-beach-20241127-04.md new file mode 100644 index 0000000..292d108 --- /dev/null +++ b/content/photography/europa-2024/utah-beach-20241127-04.md @@ -0,0 +1,20 @@ +--- +title: "Les dunes" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/utah-beach] +photo: utah-beach-20241127-04.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/220 f/1.8 ISO 64" +location: "Utah Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/utah-beach-20241127-05.md b/content/photography/europa-2024/utah-beach-20241127-05.md new file mode 100644 index 0000000..b0f75d3 --- /dev/null +++ b/content/photography/europa-2024/utah-beach-20241127-05.md @@ -0,0 +1,20 @@ +--- +title: "Marée basse" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/utah-beach] +photo: utah-beach-20241127-05.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/217 f/1.8 ISO 64" +location: "Utah Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/utah-beach-20241127-06.md b/content/photography/europa-2024/utah-beach-20241127-06.md new file mode 100644 index 0000000..26d2d55 --- /dev/null +++ b/content/photography/europa-2024/utah-beach-20241127-06.md @@ -0,0 +1,20 @@ +--- +title: "Utah Beach" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/utah-beach] +photo: utah-beach-20241127-06.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/147 f/1.8 ISO 64" +location: "Utah Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.2] +geo-precision: city +--- + diff --git a/content/photography/europa-2024/utah-beach-20241127-07.md b/content/photography/europa-2024/utah-beach-20241127-07.md new file mode 100644 index 0000000..74bc9cc --- /dev/null +++ b/content/photography/europa-2024/utah-beach-20241127-07.md @@ -0,0 +1,20 @@ +--- +title: "Les dunes" +date: 2026-08-21 +tags: [photography, photography/normandy, photography/utah-beach] +photo: utah-beach-20241127-07.jpg +series: europa-2024 +orientation: landscape +captured: 2024-11-27 +camera: "Apple iPhone 15 Pro" +focal-length: "24mm" +exposure: "1/121 f/1.8 ISO 200" +location: "Utah Beach, France" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +# The camera fields above were read from EXIF at import and written +# here because frontmatter is tracked and the sidecar is not. +# +geo: [49.4, -1.2] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/index.md b/content/photography/germany-082026/index.md new file mode 100644 index 0000000..6668dbc --- /dev/null +++ b/content/photography/germany-082026/index.md @@ -0,0 +1,10 @@ +--- +title: "Germany, August 2026" +date: 2026-08-20 +abstract: > + A triumphant return to a country I dearly love: a week in Munich, Nuremberg, and Leipzig. +tags: [photography, photography/germany] +photo: munich-283.jpg +location: "Germany" +captured: 2026-08-13 +--- diff --git a/content/photography/germany-082026/munich-171.md b/content/photography/germany-082026/munich-171.md new file mode 100644 index 0000000..838e6a3 --- /dev/null +++ b/content/photography/germany-082026/munich-171.md @@ -0,0 +1,22 @@ +--- +title: "Marienplatz" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-171.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-172.md b/content/photography/germany-082026/munich-172.md new file mode 100644 index 0000000..340e27d --- /dev/null +++ b/content/photography/germany-082026/munich-172.md @@ -0,0 +1,22 @@ +--- +title: "Alter Peter" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-172.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-175.md b/content/photography/germany-082026/munich-175.md new file mode 100644 index 0000000..9cfdec7 --- /dev/null +++ b/content/photography/germany-082026/munich-175.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-175.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-176.md b/content/photography/germany-082026/munich-176.md new file mode 100644 index 0000000..79527bf --- /dev/null +++ b/content/photography/germany-082026/munich-176.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-176.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "26mm" +exposure: "1/125 f/5.6 ISO 180" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-179.md b/content/photography/germany-082026/munich-179.md new file mode 100644 index 0000000..1c289cd --- /dev/null +++ b/content/photography/germany-082026/munich-179.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-179.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "45mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-187.md b/content/photography/germany-082026/munich-187.md new file mode 100644 index 0000000..2146936 --- /dev/null +++ b/content/photography/germany-082026/munich-187.md @@ -0,0 +1,22 @@ +--- +title: "Klassizistische Fassade" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-187.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "38mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-190.md b/content/photography/germany-082026/munich-190.md new file mode 100644 index 0000000..5fdb993 --- /dev/null +++ b/content/photography/germany-082026/munich-190.md @@ -0,0 +1,22 @@ +--- +title: "Schwabing" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-190.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "32mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-191.md b/content/photography/germany-082026/munich-191.md new file mode 100644 index 0000000..fc407bf --- /dev/null +++ b/content/photography/germany-082026/munich-191.md @@ -0,0 +1,22 @@ +--- +title: "Schwabing" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-191.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "24mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-195.md b/content/photography/germany-082026/munich-195.md new file mode 100644 index 0000000..2165ac3 --- /dev/null +++ b/content/photography/germany-082026/munich-195.md @@ -0,0 +1,22 @@ +--- +title: "Eisbach" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-195.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/100 f/5 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-200.md b/content/photography/germany-082026/munich-200.md new file mode 100644 index 0000000..ae6f0a2 --- /dev/null +++ b/content/photography/germany-082026/munich-200.md @@ -0,0 +1,22 @@ +--- +title: "Englischer Garten" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-200.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-203.md b/content/photography/germany-082026/munich-203.md new file mode 100644 index 0000000..80bf23b --- /dev/null +++ b/content/photography/germany-082026/munich-203.md @@ -0,0 +1,22 @@ +--- +title: "Geschwister-Scholl-Platz" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-203.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "28mm" +exposure: "1/320 f/9 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-205.md b/content/photography/germany-082026/munich-205.md new file mode 100644 index 0000000..6bd8062 --- /dev/null +++ b/content/photography/germany-082026/munich-205.md @@ -0,0 +1,22 @@ +--- +title: "Ludwigstraße" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-205.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "30mm" +exposure: "1/320 f/9 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-209.md b/content/photography/germany-082026/munich-209.md new file mode 100644 index 0000000..a7c00a6 --- /dev/null +++ b/content/photography/germany-082026/munich-209.md @@ -0,0 +1,22 @@ +--- +title: "Siegestor" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-209.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "26mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-211.md b/content/photography/germany-082026/munich-211.md new file mode 100644 index 0000000..d51f951 --- /dev/null +++ b/content/photography/germany-082026/munich-211.md @@ -0,0 +1,22 @@ +--- +title: "Schwabing" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-211.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "26mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-212.md b/content/photography/germany-082026/munich-212.md new file mode 100644 index 0000000..a83ff4d --- /dev/null +++ b/content/photography/germany-082026/munich-212.md @@ -0,0 +1,22 @@ +--- +title: "Schwabing" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-212.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "30mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-216.md b/content/photography/germany-082026/munich-216.md new file mode 100644 index 0000000..77ecabf --- /dev/null +++ b/content/photography/germany-082026/munich-216.md @@ -0,0 +1,22 @@ +--- +title: "Schwabing" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-216.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "22mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-217.md b/content/photography/germany-082026/munich-217.md new file mode 100644 index 0000000..9146e52 --- /dev/null +++ b/content/photography/germany-082026/munich-217.md @@ -0,0 +1,22 @@ +--- +title: "Ludwigstraße" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-217.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "36mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-223.md b/content/photography/germany-082026/munich-223.md new file mode 100644 index 0000000..4a84646 --- /dev/null +++ b/content/photography/germany-082026/munich-223.md @@ -0,0 +1,22 @@ +--- +title: "Alte Pinakothek" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-223.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "45mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-229.md b/content/photography/germany-082026/munich-229.md new file mode 100644 index 0000000..2f957e4 --- /dev/null +++ b/content/photography/germany-082026/munich-229.md @@ -0,0 +1,22 @@ +--- +title: "Schloss Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-229.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-235.md b/content/photography/germany-082026/munich-235.md new file mode 100644 index 0000000..47511ec --- /dev/null +++ b/content/photography/germany-082026/munich-235.md @@ -0,0 +1,22 @@ +--- +title: "Schloss Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-235.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/320 f/9 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-236.md b/content/photography/germany-082026/munich-236.md new file mode 100644 index 0000000..6b4235c --- /dev/null +++ b/content/photography/germany-082026/munich-236.md @@ -0,0 +1,22 @@ +--- +title: "Schloss Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-236.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/400 f/10 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-243.md b/content/photography/germany-082026/munich-243.md new file mode 100644 index 0000000..843befc --- /dev/null +++ b/content/photography/germany-082026/munich-243.md @@ -0,0 +1,22 @@ +--- +title: "Schlosspark Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-243.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-247.md b/content/photography/germany-082026/munich-247.md new file mode 100644 index 0000000..cf1b282 --- /dev/null +++ b/content/photography/germany-082026/munich-247.md @@ -0,0 +1,22 @@ +--- +title: "Schloss Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-247.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-248.md b/content/photography/germany-082026/munich-248.md new file mode 100644 index 0000000..8f0dd53 --- /dev/null +++ b/content/photography/germany-082026/munich-248.md @@ -0,0 +1,22 @@ +--- +title: "Schloss Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-248.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "35mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-250.md b/content/photography/germany-082026/munich-250.md new file mode 100644 index 0000000..ea654f1 --- /dev/null +++ b/content/photography/germany-082026/munich-250.md @@ -0,0 +1,22 @@ +--- +title: "Schlosspark Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-250.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "38mm" +exposure: "1/320 f/9 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-254.md b/content/photography/germany-082026/munich-254.md new file mode 100644 index 0000000..3f508ea --- /dev/null +++ b/content/photography/germany-082026/munich-254.md @@ -0,0 +1,22 @@ +--- +title: "Großes Parterre" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-254.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "36mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-263.md b/content/photography/germany-082026/munich-263.md new file mode 100644 index 0000000..fdedc5e --- /dev/null +++ b/content/photography/germany-082026/munich-263.md @@ -0,0 +1,22 @@ +--- +title: "Nymphenburger Kanal" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-263.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/125 f/5.6 ISO 180" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-265.md b/content/photography/germany-082026/munich-265.md new file mode 100644 index 0000000..cf0c303 --- /dev/null +++ b/content/photography/germany-082026/munich-265.md @@ -0,0 +1,22 @@ +--- +title: "Schlosspark Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-265.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-267.md b/content/photography/germany-082026/munich-267.md new file mode 100644 index 0000000..40d92a9 --- /dev/null +++ b/content/photography/germany-082026/munich-267.md @@ -0,0 +1,22 @@ +--- +title: "Schlosspark Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-267.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "38mm" +exposure: "1/125 f/5.3 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-268.md b/content/photography/germany-082026/munich-268.md new file mode 100644 index 0000000..6273caf --- /dev/null +++ b/content/photography/germany-082026/munich-268.md @@ -0,0 +1,22 @@ +--- +title: "Schlosspark Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-268.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "40mm" +exposure: "1/125 f/5.6 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-275.md b/content/photography/germany-082026/munich-275.md new file mode 100644 index 0000000..053b190 --- /dev/null +++ b/content/photography/germany-082026/munich-275.md @@ -0,0 +1,22 @@ +--- +title: "Schlosspark Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-275.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "44mm" +exposure: "1/125 f/5.6 ISO 220" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-280.md b/content/photography/germany-082026/munich-280.md new file mode 100644 index 0000000..90848ba --- /dev/null +++ b/content/photography/germany-082026/munich-280.md @@ -0,0 +1,22 @@ +--- +title: "Schlosspark Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-280.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "48mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-283.md b/content/photography/germany-082026/munich-283.md new file mode 100644 index 0000000..086da03 --- /dev/null +++ b/content/photography/germany-082026/munich-283.md @@ -0,0 +1,22 @@ +--- +title: "Schloss Nymphenburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-283.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-285.md b/content/photography/germany-082026/munich-285.md new file mode 100644 index 0000000..dcafbf3 --- /dev/null +++ b/content/photography/germany-082026/munich-285.md @@ -0,0 +1,22 @@ +--- +title: "Nymphenburger Kanal" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-285.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-288.md b/content/photography/germany-082026/munich-288.md new file mode 100644 index 0000000..15f8ffe --- /dev/null +++ b/content/photography/germany-082026/munich-288.md @@ -0,0 +1,22 @@ +--- +title: "München" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-288.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/320 f/9 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/munich-293.md b/content/photography/germany-082026/munich-293.md new file mode 100644 index 0000000..6a3df8d --- /dev/null +++ b/content/photography/germany-082026/munich-293.md @@ -0,0 +1,22 @@ +--- +title: "U-Bahn" +date: 2026-08-20 +tags: [photography, photography/germany, photography/munich] +photo: munich-293.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-13 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "34mm" +exposure: "1/60 f/5 ISO 800" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Munich, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [48.14, 11.58] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-295.md b/content/photography/germany-082026/nuremberg-295.md new file mode 100644 index 0000000..ef09d10 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-295.md @@ -0,0 +1,22 @@ +--- +title: "Hauptbahnhof" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-295.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/125 f/5.6 ISO 160" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-297.md b/content/photography/germany-082026/nuremberg-297.md new file mode 100644 index 0000000..40a75c9 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-297.md @@ -0,0 +1,22 @@ +--- +title: "Nürnberg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-297.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-301.md b/content/photography/germany-082026/nuremberg-301.md new file mode 100644 index 0000000..0b452b8 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-301.md @@ -0,0 +1,22 @@ +--- +title: "Wöhrder See" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-301.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-302.md b/content/photography/germany-082026/nuremberg-302.md new file mode 100644 index 0000000..b9d5145 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-302.md @@ -0,0 +1,22 @@ +--- +title: "Wöhrder See" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-302.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/320 f/9 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-304.md b/content/photography/germany-082026/nuremberg-304.md new file mode 100644 index 0000000..26abc27 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-304.md @@ -0,0 +1,22 @@ +--- +title: "Wöhrder See" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-304.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-307.md b/content/photography/germany-082026/nuremberg-307.md new file mode 100644 index 0000000..5b4c630 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-307.md @@ -0,0 +1,22 @@ +--- +title: "Wöhrder See" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-307.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/60 f/5.6 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-308.md b/content/photography/germany-082026/nuremberg-308.md new file mode 100644 index 0000000..2548235 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-308.md @@ -0,0 +1,22 @@ +--- +title: "Wöhrder See" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-308.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/60 f/5.6 ISO 450" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-312.md b/content/photography/germany-082026/nuremberg-312.md new file mode 100644 index 0000000..5e6bed2 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-312.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-312.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "20mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-314.md b/content/photography/germany-082026/nuremberg-314.md new file mode 100644 index 0000000..370f092 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-314.md @@ -0,0 +1,22 @@ +--- +title: "Fassadendetail" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-314.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "45mm" +exposure: "1/125 f/5.6 ISO 200" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-315.md b/content/photography/germany-082026/nuremberg-315.md new file mode 100644 index 0000000..6095462 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-315.md @@ -0,0 +1,22 @@ +--- +title: "An der Pegnitz" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-315.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "32mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-316.md b/content/photography/germany-082026/nuremberg-316.md new file mode 100644 index 0000000..a6cf7cf --- /dev/null +++ b/content/photography/germany-082026/nuremberg-316.md @@ -0,0 +1,22 @@ +--- +title: "Katharinenruine" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-316.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/320 f/9 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-317.md b/content/photography/germany-082026/nuremberg-317.md new file mode 100644 index 0000000..a7d87ee --- /dev/null +++ b/content/photography/germany-082026/nuremberg-317.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-317.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "24mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-318.md b/content/photography/germany-082026/nuremberg-318.md new file mode 100644 index 0000000..b5db22b --- /dev/null +++ b/content/photography/germany-082026/nuremberg-318.md @@ -0,0 +1,22 @@ +--- +title: "Schuldturm" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-318.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-320.md b/content/photography/germany-082026/nuremberg-320.md new file mode 100644 index 0000000..02d0a81 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-320.md @@ -0,0 +1,22 @@ +--- +title: "Heilig-Geist-Spital" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-320.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "30mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-321.md b/content/photography/germany-082026/nuremberg-321.md new file mode 100644 index 0000000..4bdbda0 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-321.md @@ -0,0 +1,22 @@ +--- +title: "Pegnitz" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-321.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "42mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-322.md b/content/photography/germany-082026/nuremberg-322.md new file mode 100644 index 0000000..f1e9b05 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-322.md @@ -0,0 +1,22 @@ +--- +title: "Heilig-Geist-Spital" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-322.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-323.md b/content/photography/germany-082026/nuremberg-323.md new file mode 100644 index 0000000..b4f506f --- /dev/null +++ b/content/photography/germany-082026/nuremberg-323.md @@ -0,0 +1,22 @@ +--- +title: "Heilig-Geist-Spital" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-323.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "32mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-324.md b/content/photography/germany-082026/nuremberg-324.md new file mode 100644 index 0000000..db8ab8a --- /dev/null +++ b/content/photography/germany-082026/nuremberg-324.md @@ -0,0 +1,22 @@ +--- +title: "An der Pegnitz" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-324.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-325.md b/content/photography/germany-082026/nuremberg-325.md new file mode 100644 index 0000000..d277b82 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-325.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-325.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "34mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-328.md b/content/photography/germany-082026/nuremberg-328.md new file mode 100644 index 0000000..c934e45 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-328.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-328.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "30mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-330.md b/content/photography/germany-082026/nuremberg-330.md new file mode 100644 index 0000000..f227ac8 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-330.md @@ -0,0 +1,22 @@ +--- +title: "Schöner Brunnen" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-330.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-338.md b/content/photography/germany-082026/nuremberg-338.md new file mode 100644 index 0000000..5a02e26 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-338.md @@ -0,0 +1,22 @@ +--- +title: "St. Lorenz" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-338.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "46mm" +exposure: "1/250 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-339.md b/content/photography/germany-082026/nuremberg-339.md new file mode 100644 index 0000000..8cc4694 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-339.md @@ -0,0 +1,22 @@ +--- +title: "St. Sebald" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-339.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "34mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-346.md b/content/photography/germany-082026/nuremberg-346.md new file mode 100644 index 0000000..d20d040 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-346.md @@ -0,0 +1,22 @@ +--- +title: "Frauentorturm" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-346.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/200 f/7.1 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-347.md b/content/photography/germany-082026/nuremberg-347.md new file mode 100644 index 0000000..f9465f1 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-347.md @@ -0,0 +1,22 @@ +--- +title: "Kaiserburg" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-347.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "38mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-351.md b/content/photography/germany-082026/nuremberg-351.md new file mode 100644 index 0000000..16de9d5 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-351.md @@ -0,0 +1,22 @@ +--- +title: "Stadtmauer" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-351.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "18mm" +exposure: "1/60 f/3.5 ISO 400" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-354.md b/content/photography/germany-082026/nuremberg-354.md new file mode 100644 index 0000000..2270788 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-354.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-354.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "24mm" +exposure: "1/250 f/8 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/germany-082026/nuremberg-358.md b/content/photography/germany-082026/nuremberg-358.md new file mode 100644 index 0000000..d023d20 --- /dev/null +++ b/content/photography/germany-082026/nuremberg-358.md @@ -0,0 +1,22 @@ +--- +title: "Altstadt" +date: 2026-08-20 +tags: [photography, photography/germany, photography/nuremberg] +photo: nuremberg-358.jpg +series: germany-082026 +orientation: landscape +captured: 2026-08-14 +camera: "NIKON D3100" +lens: "18-55mm f/3.5-5.6" +focal-length: "55mm" +exposure: "1/160 f/6.3 ISO 100" +# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves +location: "Nuremberg, Germany" +# The camera fields above were read from the file's EXIF at import and +# written here because frontmatter is tracked and the sidecar is not. Edit +# freely — these values are authoritative from now on. +# +geo: [49.45, 11.08] +geo-precision: city +--- + diff --git a/content/photography/index.md b/content/photography/index.md new file mode 100644 index 0000000..96713a9 --- /dev/null +++ b/content/photography/index.md @@ -0,0 +1,7 @@ +--- +title: Photography +abstract: A working portfolio of photographs, by an enthusiastic hobbyist. +tags: [photography] +--- + +Photographs from travel, study, and the everyday. Browse by layout above, or by [series](/photography/germany-082026/), [year](/photography/by-year/), or [place](/photography/map/). Alternatively, the [contact sheet](/photography/contact-sheet/) is every frame in one place. diff --git a/content/poetry/ozymandias.md b/content/poetry/ozymandias.md new file mode 100644 index 0000000..7eb5474 --- /dev/null +++ b/content/poetry/ozymandias.md @@ -0,0 +1,22 @@ +--- +title: Ozymandias +date: 1818-01-11 +poet: Percy Bysshe Shelley +abstract: I met a traveller from an antique land, / Who said — "Two vast and trunkless legs of stone / Stand in the desert." +tags: [poetry] +--- + +I met a traveller from an antique land, +Who said — "Two vast and trunkless legs of stone +Stand in the desert. . . . Near them, on the sand, +Half sunk a shattered visage lies, whose frown, +And wrinkled lip, and sneer of cold command, +Tell that its sculptor well those passions read +Which yet survive, stamped on these lifeless things, +The hand that mocked them, and the heart that fed; +And on the pedestal, these words appear: +My name is Ozymandias, King of Kings; +Look on my Works, ye Mighty, and despair! +Nothing beside remains. Round the decay +Of that colossal Wreck, boundless and bare +The lone and level sands stretch far away." diff --git a/content/poetry/selected-verse/index.md b/content/poetry/selected-verse/index.md new file mode 100644 index 0000000..3be277b --- /dev/null +++ b/content/poetry/selected-verse/index.md @@ -0,0 +1,18 @@ +--- +title: "Selected Verse" +date: 2026-05-08 +abstract: > + A placeholder poetry collection — replace this landing page with the + collection's actual title, abstract, and any prefatory note. Add + poems as `content/poetry/selected-verse/{slug}.md`; each compiles + with the standard poetry pipeline (hard line breaks, codex reading + mode). Rename the directory to retitle the collection. +--- + +This collection is a scaffold. Replace this body with a short +introduction describing the collection's organising principle — a +single author, a thematic arc, a translation project, a sequence +written in a particular season — or whatever frame matters to you. + +Individual poems live in this directory and link from here +automatically through the site's backlink and tag systems. diff --git a/content/poetry/selected-verse/mark.svg b/content/poetry/selected-verse/mark.svg new file mode 100644 index 0000000..bf08d15 --- /dev/null +++ b/content/poetry/selected-verse/mark.svg @@ -0,0 +1,18 @@ + + Selected Verse: open book + Placeholder monogram for the Selected Verse poetry collection — an open book with a central spine and three lines of text on each page, abstracted from a printed octavo of poetry. + + + + + + + + + + + + + + + diff --git a/content/poetry/sonnet-60.md b/content/poetry/sonnet-60.md new file mode 100644 index 0000000..043d7d2 --- /dev/null +++ b/content/poetry/sonnet-60.md @@ -0,0 +1,25 @@ +--- +title: Sonnet 60 +date: 1609-05-20 +poet: William Shakespeare +abstract: Like as the waves make towards the pebbled shore, / So do our minutes hasten to their end. +tags: [poetry] +--- + +Like as the waves make towards the pebbled shore, +So do our minutes hasten to their end; +Each changing place with that which goes before, +In sequent toil all forwards do contend. + +Nativity, once in the main of light, +Crawls to maturity, wherewith being crown'd, +Crooked eclipses 'gainst his glory fight, +And Time that gave doth now his gift confound. + +Time doth transfix the flourish set on youth, +And delves the parallels in beauty's brow, +Feeds on the rarities of nature's truth, +And nothing stands but for his scythe to mow. + +And yet to times in hope my verse shall stand, +Praising thy worth, despite his cruel hand. diff --git a/content/scripts/memento-mori.js b/content/scripts/memento-mori.js new file mode 100644 index 0000000..45d9d05 --- /dev/null +++ b/content/scripts/memento-mori.js @@ -0,0 +1,382 @@ +(function () { + 'use strict'; + + // ------------------------------------------------------------------------- + // Constants + // ------------------------------------------------------------------------- + + // Birthday: Monday, December 29, 2003 + const BIRTHDAY = new Date(2003, 11, 29); // month is 0-indexed + // End of the 80-year span: December 29, 2083 + const LIFE_END = new Date(2083, 11, 29); + + const YEARS = 80; + const WEEKS_PER_YEAR = 52; + const TOTAL_WEEKS = YEARS * WEEKS_PER_YEAR; // 4160 + + // A tropical season: 365.25 / 4 days + const SEASON_MS = 91.25 * 24 * 60 * 60 * 1000; + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + function fmtDate(d) { + return d.toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric' + }); + } + + function fmtDayShort(d) { + return d.toLocaleDateString('en-US', { + weekday: 'short', month: 'short', day: 'numeric' + }); + } + + function currentSeasonName() { + const now = new Date(); + const m = now.getMonth(); // 0-indexed + const d = now.getDate(); + // Approximate meteorological seasons (northern hemisphere) + if (m < 2 || (m === 11)) return 'winter'; + if (m < 5) return 'spring'; + if (m < 8) return 'summer'; + return 'autumn'; + } + + // ------------------------------------------------------------------------- + // Grid + // ------------------------------------------------------------------------- + + function buildGrid() { + const container = document.getElementById('weeks-grid-wrapper'); + if (!container) return; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const grid = document.createElement('div'); + grid.className = 'weeks-grid'; + grid.setAttribute('role', 'img'); + grid.setAttribute('aria-label', 'A grid of 4,160 squares representing 80 years of life, one square per week.'); + + let globalWeekIndex = 1; + + for (let year = 0; year < YEARS; year++) { + + // Anchor the start of every row exactly to the calendar anniversary. + const yearStart = new Date(BIRTHDAY.getTime()); + yearStart.setFullYear(yearStart.getFullYear() + year); + + for (let weekInYear = 1; weekInYear <= WEEKS_PER_YEAR; weekInYear++) { + + const start = new Date(yearStart.getTime()); + start.setDate(start.getDate() + (weekInYear - 1) * 7); + + const end = new Date(start.getTime()); + + // Stretch week 52 to the eve of the next birthday, absorbing + // the 365th/366th day so no day falls outside the grid. + if (weekInYear === WEEKS_PER_YEAR) { + const nextBday = new Date(BIRTHDAY.getTime()); + nextBday.setFullYear(nextBday.getFullYear() + year + 1); + end.setTime(nextBday.getTime() - 24 * 60 * 60 * 1000); + } else { + end.setDate(end.getDate() + 6); + } + + const cell = document.createElement('span'); + cell.className = 'week'; + + if (today > end) { + cell.classList.add('week--past'); + } else if (today >= start) { + cell.classList.add('week--current'); + } else { + cell.classList.add('week--future'); + } + + if (weekInYear === 1 && year % 10 === 0) { + cell.classList.add('week--decade'); + } + + cell.dataset.start = fmtDate(start); + cell.dataset.end = fmtDate(end); + cell.dataset.startRaw = start.getTime(); + cell.dataset.endRaw = end.getTime(); + cell.dataset.year = year; + cell.dataset.weekInYear = weekInYear; + cell.dataset.index = globalWeekIndex; + + grid.appendChild(cell); + globalWeekIndex++; + } + } + + container.appendChild(grid); + + const legend = document.createElement('p'); + legend.className = 'weeks-legend'; + legend.textContent = + 'Each row is one year (age 0–79). Each cell is one week. ' + + 'Decade boundaries are marked.'; + container.appendChild(legend); + + initTooltip(grid); + initWeekPopup(grid); + } + + // ------------------------------------------------------------------------- + // Tooltip + // ------------------------------------------------------------------------- + + function initTooltip(grid) { + const tip = document.createElement('div'); + tip.className = 'weeks-tooltip'; + tip.setAttribute('aria-hidden', 'true'); + tip.hidden = true; + document.body.appendChild(tip); + + grid.addEventListener('mouseover', e => { + const cell = e.target.closest('.week'); + if (!cell) { tip.hidden = true; return; } + + const year = parseInt(cell.dataset.year, 10); + const weekInYear = parseInt(cell.dataset.weekInYear, 10); + const index = parseInt(cell.dataset.index, 10); + + const isPast = cell.classList.contains('week--past'); + const isCurrent = cell.classList.contains('week--current'); + const status = isPast ? 'elapsed' : isCurrent ? 'this week' : 'remaining'; + + tip.innerHTML = + `age ${year}` + + `year ${year + 1}, week ${weekInYear}` + + `${cell.dataset.start} – ${cell.dataset.end}` + + `` + + `week ${index.toLocaleString()} of ${TOTAL_WEEKS.toLocaleString()}` + + ` · ${status}` + + ``; + + tip.hidden = false; + }); + + grid.addEventListener('mouseleave', () => { tip.hidden = true; }); + + document.addEventListener('mousemove', e => { + if (tip.hidden) return; + const tw = tip.offsetWidth; + const th = tip.offsetHeight; + let x = e.clientX + 14; + let y = e.clientY - th - 10; + if (y < 8) y = e.clientY + 16; + if (x + tw > window.innerWidth - 8) x = e.clientX - tw - 14; + tip.style.left = x + 'px'; + tip.style.top = y + 'px'; + }); + } + + // ------------------------------------------------------------------------- + // Week popup — dynamic day count + // ------------------------------------------------------------------------- + + function initWeekPopup(grid) { + const backdrop = document.createElement('div'); + backdrop.className = 'week-popup-backdrop'; + backdrop.hidden = true; + + const card = document.createElement('div'); + card.className = 'week-popup-card'; + card.setAttribute('role', 'dialog'); + card.setAttribute('aria-modal', 'true'); + + backdrop.appendChild(card); + document.body.appendChild(backdrop); + + function openPopup(cell) { + const startMs = parseInt(cell.dataset.startRaw, 10); + const endMs = parseInt(cell.dataset.endRaw, 10); + const year = parseInt(cell.dataset.year, 10); + const weekInYear = parseInt(cell.dataset.weekInYear, 10); + const index = parseInt(cell.dataset.index, 10); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Dynamically calculate how many days are in this week (7, 8, or 9). + const daysInWeek = Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)) + 1; + + const header = document.createElement('div'); + header.className = 'week-popup-header'; + + const title = document.createElement('div'); + title.className = 'week-popup-title'; + title.textContent = `Age ${year} — year ${year + 1}, week ${weekInYear}`; + + const sub = document.createElement('div'); + sub.className = 'week-popup-sub'; + sub.textContent = `week ${index.toLocaleString()} of ${TOTAL_WEEKS.toLocaleString()}`; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'week-popup-close'; + closeBtn.setAttribute('aria-label', 'Close'); + closeBtn.innerHTML = '×'; + closeBtn.addEventListener('click', closePopup); + + header.appendChild(title); + header.appendChild(sub); + header.appendChild(closeBtn); + + const days = document.createElement('div'); + days.className = 'week-popup-days'; + + for (let d = 0; d < daysInWeek; d++) { + const dayDate = new Date(startMs); + dayDate.setDate(dayDate.getDate() + d); + dayDate.setHours(0, 0, 0, 0); + + const wday = document.createElement('div'); + wday.className = 'wday'; + + if (dayDate < today) { + wday.classList.add('wday--past'); + } else if (dayDate.getTime() === today.getTime()) { + wday.classList.add('wday--today'); + } else { + wday.classList.add('wday--future'); + } + + const dot = document.createElement('div'); + dot.className = 'wday-dot'; + + const name = document.createElement('div'); + name.className = 'wday-name'; + // Derive day name from the date itself — never misaligned. + name.textContent = dayDate.toLocaleDateString('en-US', { weekday: 'short' }); + + const date = document.createElement('div'); + date.className = 'wday-date'; + date.textContent = dayDate.toLocaleDateString('en-US', { + month: 'short', day: 'numeric' + }); + + const dayNum = Math.floor( + (dayDate.getTime() - BIRTHDAY.getTime()) / (24 * 60 * 60 * 1000) + ) + 1; + const numEl = document.createElement('div'); + numEl.className = 'wday-daynum'; + numEl.textContent = 'day ' + dayNum.toLocaleString(); + + wday.appendChild(dot); + wday.appendChild(name); + wday.appendChild(date); + wday.appendChild(numEl); + days.appendChild(wday); + } + + card.innerHTML = ''; + card.appendChild(header); + card.appendChild(days); + + backdrop.hidden = false; + closeBtn.focus(); + } + + function closePopup() { + backdrop.hidden = true; + } + + grid.addEventListener('click', e => { + const cell = e.target.closest('.week'); + if (!cell) return; + openPopup(cell); + }); + + backdrop.addEventListener('click', e => { + if (e.target === backdrop) closePopup(); + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !backdrop.hidden) closePopup(); + }); + } + + // ------------------------------------------------------------------------- + // Countdown + // ------------------------------------------------------------------------- + + function initCountdown() { + const wrapper = document.getElementById('countdown-wrapper'); + if (!wrapper) return; + + let unit = localStorage.getItem('mm-unit') || 'seconds'; + + const labelEl = document.createElement('div'); + labelEl.className = 'countdown-label'; + + const valueEl = document.createElement('div'); + valueEl.className = 'countdown-value'; + + const switcherEl = document.createElement('div'); + switcherEl.className = 'countdown-switcher'; + + ['seconds', 'hours', 'seasons'].forEach(u => { + const btn = document.createElement('button'); + btn.className = 'countdown-btn'; + btn.dataset.unit = u; + btn.textContent = u; + if (u === unit) btn.classList.add('is-active'); + btn.addEventListener('click', () => { + unit = u; + localStorage.setItem('mm-unit', u); + switcherEl.querySelectorAll('.countdown-btn').forEach(b => + b.classList.toggle('is-active', b.dataset.unit === u)); + tick(); + }); + switcherEl.appendChild(btn); + }); + + wrapper.appendChild(labelEl); + wrapper.appendChild(valueEl); + wrapper.appendChild(switcherEl); + + function format(ms) { + if (ms <= 0) return '0'; + if (unit === 'seconds') return Math.floor(ms / 1000).toLocaleString(); + if (unit === 'hours') return Math.floor(ms / (1000 * 60 * 60)).toLocaleString(); + return Math.floor(ms / SEASON_MS).toLocaleString(); // seasons — whole integer + } + + function label() { + if (unit === 'seconds') return 'seconds remaining'; + if (unit === 'hours') return 'hours remaining'; + // seasons: poetic label with current season name + return 'seasons remaining — it is ' + currentSeasonName(); + } + + function tick() { + const ms = Math.max(0, LIFE_END.getTime() - Date.now()); + labelEl.textContent = label(); + valueEl.textContent = format(ms); + } + + tick(); + setInterval(tick, 1000); + } + + // ------------------------------------------------------------------------- + // Init + // ------------------------------------------------------------------------- + + function init() { + buildGrid(); + initCountdown(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +}()); diff --git a/content/search.md b/content/search.md new file mode 100644 index 0000000..d9e6e93 --- /dev/null +++ b/content/search.md @@ -0,0 +1,134 @@ +--- +title: Search +search: true +--- + +
    + + +
    + +
    + +
    + + + +

    + +
    + +

    +
    +
    diff --git a/content/stats.mark.svg b/content/stats.mark.svg new file mode 100644 index 0000000..7b41e3d --- /dev/null +++ b/content/stats.mark.svg @@ -0,0 +1,64 @@ + + A small histogram of word-length buckets above a row of tag-frequency strokes, separated by a thin rule + A frontispiece mark for the corpus statistics view of the build telemetry page. The upper register is a five-bucket histogram in the Tufte sparkbar tradition, encoding the word-length distribution. The lower register is a row of vertical strokes of varied length representing tag frequency across the corpus. A thin rule between the two separates them — the page reports many distributions, and the mark holds two of them side by side. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/tag-meta/ai.md b/content/tag-meta/ai.md new file mode 100644 index 0000000..81b2e15 --- /dev/null +++ b/content/tag-meta/ai.md @@ -0,0 +1,3 @@ +--- +tooltip: "on intelligence, artificial and otherwise" +--- diff --git a/content/tag-meta/fiction.md b/content/tag-meta/fiction.md new file mode 100644 index 0000000..d57ba51 --- /dev/null +++ b/content/tag-meta/fiction.md @@ -0,0 +1,3 @@ +--- +tooltip: "stories and a novel in progress" +--- diff --git a/content/tag-meta/miscellany.md b/content/tag-meta/miscellany.md new file mode 100644 index 0000000..9b411e7 --- /dev/null +++ b/content/tag-meta/miscellany.md @@ -0,0 +1,3 @@ +--- +tooltip: "everything that defies category" +--- diff --git a/content/tag-meta/music.md b/content/tag-meta/music.md new file mode 100644 index 0000000..984568e --- /dev/null +++ b/content/tag-meta/music.md @@ -0,0 +1,3 @@ +--- +tooltip: "compositions, scores, and recordings" +--- diff --git a/content/tag-meta/nonfiction.md b/content/tag-meta/nonfiction.md new file mode 100644 index 0000000..beda46f --- /dev/null +++ b/content/tag-meta/nonfiction.md @@ -0,0 +1,3 @@ +--- +tooltip: "living documents and essays" +--- diff --git a/content/tag-meta/photography.md b/content/tag-meta/photography.md new file mode 100644 index 0000000..a078a8d --- /dev/null +++ b/content/tag-meta/photography.md @@ -0,0 +1,3 @@ +--- +tooltip: "photographs from travel and the everyday" +--- diff --git a/content/tag-meta/poetry.md b/content/tag-meta/poetry.md new file mode 100644 index 0000000..1403a58 --- /dev/null +++ b/content/tag-meta/poetry.md @@ -0,0 +1,3 @@ +--- +tooltip: "verse, free and rigid" +--- diff --git a/content/tag-meta/research.md b/content/tag-meta/research.md new file mode 100644 index 0000000..699c101 --- /dev/null +++ b/content/tag-meta/research.md @@ -0,0 +1,3 @@ +--- +tooltip: "formal and less formal inquiry" +--- diff --git a/content/tag-meta/tech.md b/content/tag-meta/tech.md new file mode 100644 index 0000000..f9ec205 --- /dev/null +++ b/content/tag-meta/tech.md @@ -0,0 +1,3 @@ +--- +tooltip: "systems, tools, and craft" +--- diff --git a/content/work.md b/content/work.md new file mode 100644 index 0000000..47807fa --- /dev/null +++ b/content/work.md @@ -0,0 +1,182 @@ +--- +title: Work +work: true +description: Research engineer working on technical AI assurance — verifiable inference, model evaluations, systems and cryptography. +--- + +::: {.work-name} +Levi Neuwirth +::: + +::: {.work-lede} +I work on technical AI assurance: establishing verifiable claims about what a +model actually did, when the operator, the evaluator, or the model itself may +not be trusted. That question spans cryptography, systems, evaluations, and +mathematics, which is roughly the shape of my background. + +I am a MARS V fellow with the [Cambridge AI Safety Hub](https://caish.org/mars), +mentored by James Petrie (Future of Life Institute), and a graduate student in +computer science and engineering at DTU; previously computer science and +mathematics at Brown. + +**Open to full-time research and research-engineering positions worldwide.** +::: + +::: {.work-links} +[CV](/cv.pdf) · [GitHub](https://github.com/levineuwirth) · +[Projects](/cv/projects/) · [Email](mailto:ln@levineuwirth.org) +::: + +## Selected work + +::: {.work-entry} +### Verifiable LLM inference + +::: {.work-meta} +MARS V fellowship · Cambridge AI Safety Hub · ongoing +::: + +An operator who claims to have run a model may not have, and the logs that would +settle it are written by the party under suspicion. + +[VerInf](https://github.com/JamesPetrie/VerInf), a Future of Life Institute project led by James Petrie, proves +LLM inference in zero knowledge with no trusted setup by bounding the +*unexplained information* in an output stream rather than re-running the +computation. I have contributed the dry-run profiler (manifest contract, cost model, +execution DAG, partition scorecard) and an RMSNorm cost-model correction. My current +work is on proving multi-GPU, which is the ceiling on model scale, context +length, and mixture-of-experts breadth. + +::: {.work-limit} +**Ongoing.** The multi-GPU work is unmerged and its results are preliminary. +Technical write-up expected Q4 2026, for review and publication. +::: + +::: {.work-entry-links} +[Merged pull requests](https://github.com/JamesPetrie/VerInf/pulls?q=is%3Apr+author%3Alevineuwirth) · +[Upstream repository](https://github.com/JamesPetrie/VerInf) · +[MARS](https://caish.org/mars) +::: +::: + +::: {.work-entry} +### Frontier-model evaluation and red-teaming + +::: {.work-meta} +Independent research contracts · ongoing +::: + +I design evaluation environments and scoring rubrics for frontier language +models, execute models against them, debug agent scaffolds when they fail, and +analyze the failures that result. I have led environment design and evaluator +calibration; most of it is built in [Harbor](https://github.com/harbor-framework/harbor). + +For a similar methodology on public data, see [The Specification +Dilemma](/essays/specification-dilemma/) — pre-registered, matched-pairs, with +its instrumentation failures reported in full. + +::: {.work-limit} +**Disclosure.** Client identities and specific results are confidential. I can +discuss the technical categories of work and my own engineering +responsibilities. +::: + +::: {.work-entry-links} +[Code and results](https://github.com/levineuwirth/specification-dilemma) +::: +::: + +::: {.work-entry} +### Proof Broker + +::: {.work-meta} +Independent · OCaml · ongoing research program +::: + +Lean 4 and Rocq can outsource proof search to external provers — SMT solvers, +automated theorem provers, even LLMs — without extending what they trust. Proof +Broker routes a goal out to those provers, checks whatever comes back, and lifts +the result into a proof term the home system verifies for itself. The provers +stay untrusted; the kernel remains the trusted base. + +Concretely: a function-composition goal closes under `by proof_broker` through a +real Vampire invocation, axiom-free. + +::: {.work-limit} +**Boundary.** Individual inference steps are not re-derived — the kernel check +is what closes the proof. Breadth is in progress: the Vampire path is complete +end-to-end, other backends are not. +::: + +::: {.work-entry-links} +[Repository](https://github.com/levineuwirth/proof-broker) +::: +::: + +::: {.work-entry} +### Order-invariant ICD-10-CM embeddings + +::: {.work-meta} +Research engineering · manuscript under review +::: + +Comorbidity indices compress a patient's diagnosis history into a single +weighted score, discarding both order and interaction. This work learns a +permutation-invariant representation over ICD-10-CM diagnosis-code sets and +predicts 30-day unplanned readmission and 30-day post-discharge mortality, +trained on 113M+ adult hospitalizations from the Nationwide Readmissions +Database. On the temporal test split it reaches 0.750 AUROC for readmission +against 0.655 for the Charlson index. The calculator is deployed. + +::: {.work-limit} +**Status.** Under review at *JAMIA*; results are unrefereed. +::: + +::: {.work-entry-links} +[Repository](https://github.com/levineuwirth/icd_embeddings) · +[Write-up](/essays/beyond-comorbidity-indices/) · +[Calculator](https://levineuwirth.github.io/icd_embeddings) +::: +::: + +## The question + +::: {.work-thesis} +How do you establish trustworthy claims about an AI system when the system, the +operator, and the evaluator may each be untrusted? + +Cryptography attacks this from below, certifying properties of a computation +without trusting the party who ran it. Evaluations attack it from above, +measuring what a model actually does under conditions you control. Formal +methods supply the machinery for checking a claim without trusting its author. +The four projects above are attempts on different faces of the same problem. +::: + +## More work + +::: {.work-more} +### Systems and performance + +- [pmacs](https://github.com/levineuwirth/pmacs) — Rust-cored, Lua-scripted editor +- [kyber-simd-profiling](https://github.com/levineuwirth/kyber-simd-profiling) — SIMD post-quantum cryptography across AVX2, ARM NEON-SVE, and RISC-V V, with hardware counters and RAPL energy +- [LeVCS](https://github.com/levineuwirth/LeVCS) — federated version control with signed authority chains +- [arcana](https://github.com/levineuwirth/arcana) — Magic: The Gathering rules engine, built as a substrate for reinforcement-learning research +- Weenix — Unix kernel +- TCP/IP stack — written from scratch in Go + +### Mathematics + +- [Branch-tube persistence and static coverage in tree-ball geometry](/essays/branch-based-local-capture-in-tree-balls/) — preprint, revising +- [The annealed critical window for growing-radius domination in random regular graphs](/essays/near-critical-growing-radius-domination.html) — preprint, revising + +### Research engineering + +- [NeuroPose](https://github.com/levineuwirth/neuropose) — 3D pose estimation and kinematic analysis, built in Liqi Shu's laboratory at Brown Neurology +- [NeuroAI](https://neuroai.health) — ongoing research engineering + +### Writing + +- [Essays](/nonfiction/) — on the above, and on much else +- [Current](/current.html) — what is actually moving this month +- [Vita](/about.html) — the formal record, and [résumé](/resume.pdf) +::: diff --git a/data/annotations.json b/data/annotations.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/data/annotations.json @@ -0,0 +1 @@ +{} diff --git a/data/ball-occupation-paper.bib b/data/ball-occupation-paper.bib new file mode 100644 index 0000000..85e82a4 --- /dev/null +++ b/data/ball-occupation-paper.bib @@ -0,0 +1,126 @@ +% ── Foundational papers (cops-and-robbers) ────────────────────────────────── + +@article{AignerFromme, + author = {Aigner, M. and Fromme, M.}, + title = {A game of cops and robbers}, + journal = {Discrete Applied Mathematics}, + volume = {8}, + year = {1984}, + pages = {1--12}, +} + +% ── Meyniel's conjecture: bounds and partial progress ─────────────────────── + +@article{LuPeng, + author = {Lu, L. and Peng, X.}, + title = {On {Meyniel}'s conjecture of the cop number}, + journal = {Journal of Graph Theory}, + volume = {71}, + number = {2}, + year = {2012}, + pages = {192--205}, +} + +@article{ScottSudakov, + author = {Scott, A. and Sudakov, B.}, + title = {A bound for the cops and robbers problem}, + journal = {SIAM Journal on Discrete Mathematics}, + volume = {25}, + number = {3}, + year = {2011}, + pages = {1438--1442}, +} + +@article{BoseEsperetHodorJoretMicekRambaud, + author = {Bose, P. and Esperet, L. and Hodor, J. and Joret, G. and Micek, P. and Rambaud, C.}, + title = {Cops and robber in graphs with bounded vertex cover number}, + journal = {arXiv:2602.07435}, + year = {2026}, +} + +@article{BradshawHosseiniMoharStacho, + author = {Bradshaw, P. and Hosseini, S. A. and Mohar, B. and Stacho, L.}, + title = {On the cop number of graphs of high girth}, + journal = {Journal of Graph Theory}, + volume = {102}, + year = {2023}, + pages = {15--34}, + note = {arXiv:2005.10849}, +} + +@misc{Clow, + author = {Clow, A.}, + title = {Expanders satisfy the weak {Meyniel} conjecture}, + year = {2023}, + note = {Withdrawn preprint, arXiv:2311.13792}, +} + +@article{HMG, + author = {Hosseini, S. A. and Mohar, B. and {Gonzalez Hermosillo de la Maza}, S.}, + title = {{Meyniel}'s conjecture on graphs of bounded degree}, + journal = {Journal of Graph Theory}, + volume = {97}, + year = {2021}, + pages = {401--407}, + note = {arXiv:1912.06957}, +} + +@article{PralatWormald, + author = {Pra{\l}at, P. and Wormald, N.}, + title = {{Meyniel}'s conjecture holds for random graphs}, + journal = {Random Structures \& Algorithms}, + volume = {48}, + number = {2}, + year = {2016}, + pages = {396--421}, + note = {arXiv:1301.2841}, +} + +% ── Expansion, isoperimetry, and product/replacement structures ──────────── + +@article{BollobasKunLeader, + author = {Bollob\'as, B. and Kun, G. and Leader, I.}, + title = {Cops and robbers in a random graph}, + journal = {Journal of Combinatorial Theory, Series B}, + volume = {103}, + year = {2013}, + pages = {226--236}, +} + +@article{BollobasLeader, + author = {Bollob\'as, B. and Leader, I.}, + title = {An isoperimetric inequality on the discrete torus}, + journal = {SIAM Journal on Discrete Mathematics}, + volume = {3}, + year = {1990}, + pages = {32--37}, +} + +@article{ReingoldVadhanWigderson, + author = {Reingold, O. and Vadhan, S. and Wigderson, A.}, + title = {Entropy waves, the zig-zag graph product, and new constant-degree expanders}, + journal = {Annals of Mathematics}, + volume = {155}, + year = {2002}, + pages = {157--187}, +} + +% ── Cop number on tori and products ───────────────────────────────────────── + +@article{Lehner, + author = {Lehner, F.}, + title = {On the cop number of toroidal graphs}, + journal = {Journal of Combinatorial Theory, Series B}, + volume = {151}, + year = {2021}, + pages = {250--262}, +} + +@article{NeufeldNowakowski, + author = {Neufeld, S. and Nowakowski, R.}, + title = {A game of cops and robbers played on products of graphs}, + journal = {Discrete Mathematics}, + volume = {186}, + year = {1998}, + pages = {253--268}, +} diff --git a/data/bci-paper.bib b/data/bci-paper.bib new file mode 100644 index 0000000..963d17d --- /dev/null +++ b/data/bci-paper.bib @@ -0,0 +1,359 @@ +% bci-paper.bib — Beyond Comorbidity Indices +% BibLaTeX format. References for the "Deep Learning of Diagnosis Codes +% for Readmission and Postdischarge Mortality Prediction" manuscript. +% Numbered order matches the docx reference list. + +@misc{cms_hrrp, + author = {{Centers for Medicare \& Medicaid Services}}, + title = {Hospital Readmissions Reduction Program ({HRRP})}, + url = {https://www.cms.gov/medicare/payment/prospective-payment-systems/acute-inpatient-pps/hospital-readmissions-reduction-program-hrrp} +} + +@article{charlson1987, + author = {Charlson, M. E. and Pompei, P. and Ales, K. L. and MacKenzie, C. R.}, + title = {A new method of classifying prognostic comorbidity in longitudinal studies: development and validation}, + journal = {Journal of Chronic Diseases}, + year = {1987}, + volume = {40}, + number = {5}, + pages = {373--383} +} + +@article{elixhauser1998, + author = {Elixhauser, A. and Steiner, C. and Harris, D. R. and Coffey, R. M.}, + title = {Comorbidity measures for use with administrative data}, + journal = {Medical Care}, + year = {1998}, + volume = {36}, + number = {1}, + pages = {8--27} +} + +@article{deschepper2020, + author = {Deschepper, Mathieu and Eeckloo, Kristof and Vogelaers, Dominique and Waegeman, Willem}, + title = {Using structured pathology data to predict hospital-wide mortality at admission}, + journal = {PLoS One}, + year = {2020}, + volume = {15}, + number = {6}, + pages = {e0235117} +} + +@article{lelay2022, + author = {Le Lay, Jonathan and Martin, Sylvain and Mosnier, Arnaud and others}, + title = {Prediction of hospital readmission of multimorbid patients using machine learning models}, + journal = {PLoS One}, + year = {2022}, + volume = {17}, + number = {12}, + pages = {e0279433} +} + +@article{qiao2022, + author = {Qiao, Edmund M. and Deng, Jie and Kluger, Harvey M. and Yu, James B. and Gross, Cary P.}, + title = {Evaluating High-Dimensional Machine Learning Models to Predict Hospital Mortality Among Older Patients With Cancer}, + journal = {JCO Clinical Cancer Informatics}, + year = {2022}, + volume = {6}, + pages = {e2100186} +} + +@article{davis2022, + author = {Davis, Steve and Borah, Michael F. and Hansen, Justin and Malin, Bradley and Robinson, Jonathan and Turchin, Alexander}, + title = {Effective hospital readmission prediction models using machine-learned features}, + journal = {BMC Health Services Research}, + year = {2022}, + volume = {22}, + number = {1}, + pages = {1415} +} + +@article{harerimana2021, + author = {Harerimana, Gaspard and Kim, Jong Wook and Jang, Beakcheol}, + title = {A deep attention model to forecast the Length Of Stay and the in-hospital mortality right on admission from {ICD} codes and demographic data}, + journal = {Journal of Biomedical Informatics}, + year = {2021}, + volume = {118}, + pages = {103778} +} + +@article{matsui2022, + author = {Matsui, Hiroki and Yasunaga, Hideo and Fushimi, Kiyohide and Homma, Yuichi}, + title = {Development of Deep Learning Models for Predicting In-Hospital Mortality Using an Administrative Claims Database: Retrospective Cohort Study}, + journal = {JMIR Medical Informatics}, + year = {2022}, + volume = {10}, + number = {2}, + pages = {e27936} +} + +@article{nguyen2017, + author = {Nguyen, Phuoc and Tran, Truyen and Wickramasinghe, Nilmini and Venkatesh, Svetha}, + title = {{Deepr}: A Convolutional Net for Medical Records}, + journal = {IEEE Journal of Biomedical and Health Informatics}, + year = {2017}, + volume = {21}, + number = {1}, + pages = {22--30} +} + +@misc{ahrq_elixhauser, + author = {{Agency for Healthcare Research and Quality}}, + title = {Elixhauser Comorbidity Software Refined for {ICD-10-CM}}, + url = {https://www.hcup-us.ahrq.gov/toolssoftware/comorbidityicd10/comorbidity_icd10.jsp} +} + +@article{fernando2019, + author = {Fernando, Dulith T. and Berecki-Gisolf, Janneke and Newstead, Stuart and Ansari, Zul}, + title = {Effect of comorbidity on injury outcomes: a review of existing indices}, + journal = {Annals of Epidemiology}, + year = {2019}, + volume = {36}, + pages = {5--14} +} + +@article{quan2005, + author = {Quan, Hude and Sundararajan, Vijaya and Halfon, Patricia and others}, + title = {Coding algorithms for defining comorbidities in {ICD-9-CM} and {ICD-10} administrative data}, + journal = {Medical Care}, + year = {2005}, + volume = {43}, + number = {11}, + pages = {1130--1139} +} + +@article{deyo1992, + author = {Deyo, Richard A. and Cherkin, Daniel C. and Ciol, Marcia A.}, + title = {Adapting a clinical comorbidity index for use with {ICD-9-CM} administrative databases}, + journal = {Journal of Clinical Epidemiology}, + year = {1992}, + volume = {45}, + number = {6}, + pages = {613--619} +} + +@article{quan2011, + author = {Quan, Hude and Li, Bing and Couris, Chantal M. and others}, + title = {Updating and validating the {Charlson} comorbidity index and score for risk adjustment in hospital discharge abstracts using data from 6 countries}, + journal = {American Journal of Epidemiology}, + year = {2011}, + volume = {173}, + number = {6}, + pages = {676--682} +} + +@misc{stagg2006, + author = {Stagg, Vince}, + title = {{CHARLSON}: {Stata} module to calculate {Charlson} index of comorbidity}, + year = {2006}, + publisher = {Boston College Department of Economics} +} + +@inproceedings{zaheer2017, + author = {Zaheer, Manzil and Kottur, Satwik and Ravanbakhsh, Siamak and Poczos, Barnabas and Salakhutdinov, Ruslan and Smola, Alexander}, + title = {Deep Sets}, + booktitle = {Advances in Neural Information Processing Systems}, + year = {2017}, + volume = {30} +} + +@inproceedings{pozzolo2015, + author = {Dal Pozzolo, Andrea and Caelen, Olivier and Johnson, Reid A. and Bontempi, Gianluca}, + title = {Calibrating Probability with Undersampling for Unbalanced Classification}, + booktitle = {2015 {IEEE} Symposium Series on Computational Intelligence}, + year = {2015}, + pages = {159--166} +} + +@inproceedings{abadi2016, + author = {Abadi, Mart{\'i}n and Barham, Paul and Chen, Jianmin and Chen, Zhifeng and Davis, Andy and Dean, Jeffrey and Devin, Matthieu and Ghemawat, Sanjay and Irving, Geoffrey and Isard, Michael and Kudlur, Manjunath and Levenberg, Josh and Monga, Rajat and Moore, Sherry and Murray, Derek G. and Steiner, Benoit and Tucker, Paul and Vasudevan, Vijay and Warden, Pete and Wicke, Martin and Yu, Yuan and Zheng, Xiaoqiang}, + title = {{TensorFlow}: A System for Large-Scale Machine Learning}, + booktitle = {Proceedings of the 12th {USENIX} Symposium on Operating Systems Design and Implementation}, + year = {2016}, + pages = {265--283} +} + +@article{youden1950, + author = {Youden, W. J.}, + title = {Index for rating diagnostic tests}, + journal = {Cancer}, + year = {1950}, + volume = {3}, + number = {1}, + pages = {32--35} +} + +@article{delong1988, + author = {DeLong, Elizabeth R. and DeLong, David M. and Clarke-Pearson, Daniel L.}, + title = {Comparing the areas under two or more correlated receiver operating characteristic curves: a nonparametric approach}, + journal = {Biometrics}, + year = {1988}, + volume = {44}, + number = {3}, + pages = {837--845} +} + +@inproceedings{sundararajan2017, + author = {Sundararajan, Mukund and Taly, Ankur and Yan, Qiqi}, + title = {Axiomatic Attribution for Deep Networks}, + booktitle = {Proceedings of the 34th International Conference on Machine Learning}, + year = {2017}, + pages = {3319--3328} +} + +@article{placido2023, + author = {Placido, Davide and Yuan, Bo and Hjaltelin, Jessica X. and Zheng, Chunlei and Haue, Amalie D. and Chmura, Piotr J. and Yuan, Chen and Kim, Jihye and Umeton, Renato and Antell, Gregory and Chowdhury, Alexander and Franz, Alexandra and Brais, Lauren and Andrews, Elizabeth and Marks, Debora S. and Regev, Aviv and Ayandeh, Siamack and Brophy, Mary T. and Do, Nhan V. and Kraft, Peter and Wolpin, Brian M. and Rosenthal, Michael H. and Fillmore, Nathanael R. and Brunak, S{\o}ren and Sander, Chris}, + title = {A deep learning algorithm to predict risk of pancreatic cancer from disease trajectories}, + journal = {Nature Medicine}, + year = {2023}, + volume = {29}, + number = {5}, + pages = {1113--1122} +} + +@article{davis2020, + author = {Davis, Sharon E. and Greevy, Robert A. and Fonnesbeck, Christopher and Lasko, Thomas A. and Walsh, Colin G. and Matheny, Michael E.}, + title = {Detection of calibration drift in clinical prediction models to inform model updating}, + journal = {Journal of Biomedical Informatics}, + year = {2020}, + volume = {112}, + pages = {103611} +} + +@article{collins2024, + author = {Collins, Gary S. and Dhiman, Paula and Ma, Jie and Schlussel, Michael M. and Archer, Lucinda and Van Calster, Ben and Harrell, Frank E. and Martin, Glen P. and Moons, Karel G. M. and van Smeden, Maarten and Sperrin, Matthew and Bullock, Garrett S. and Riley, Richard D.}, + title = {Evaluation of clinical prediction models (part 1): from development to external validation}, + journal = {BMJ}, + year = {2024}, + volume = {384}, + pages = {e074819} +} + +@article{morgan2019, + author = {Morgan, Daniel J. and Bame, Bill and Zimand, Paul and Dooley, Patricia and Thom, Kerri A. and Harris, Anthony D. and Bentzen, S{\o}ren and Ettinger, Walter and Garrett-Ray, Stacy D. and Tracy, J. Kathleen and Liang, Yuanyuan}, + title = {Assessment of Machine Learning vs Standard Prediction Rules for Predicting Hospital Readmissions}, + journal = {JAMA Network Open}, + year = {2019}, + volume = {2}, + number = {3}, + pages = {e190348} +} + +@article{beam2018, + author = {Beam, Andrew L. and Kohane, Isaac S.}, + title = {Big Data and Machine Learning in Health Care}, + journal = {JAMA}, + year = {2018}, + volume = {319}, + number = {13}, + pages = {1317--1318} +} + +@article{rajkomar2018, + author = {Rajkomar, Alvin and Oren, Eyal and Chen, Kai and Dai, Andrew M. and Hajaj, Nissan and Hardt, Michaela and Liu, Peter J. and Liu, Xiaobing and Marcus, Jake and Sun, Mimi and Sundberg, Patrik and Yee, Hector and Zhang, Kun and Zhang, Yi and Flores, Gerardo and Duggan, Gavin E. and Irvine, Jamie and Le, Quoc and Litsch, Kurt and Mossin, Alexander and Tansuwan, Justin and Wang, De and Wexler, James and Wilson, Jimbo and Ludwig, Dana and Volchenboum, Samuel L. and Chou, Katherine and Pearson, Michael and Madabushi, Srinivasan and Shah, Nigam H. and Butte, Atul J. and Howell, Michael D. and Cui, Claire and Corrado, Greg S. and Dean, Jeffrey}, + title = {Scalable and accurate deep learning with electronic health records}, + journal = {npj Digital Medicine}, + year = {2018}, + volume = {1}, + pages = {18} +} + +@article{rudin2019, + author = {Rudin, Cynthia}, + title = {Stop Explaining Black Box Machine Learning Models for High Stakes Decisions and Use Interpretable Models Instead}, + journal = {Nature Machine Intelligence}, + year = {2019}, + volume = {1}, + number = {5}, + pages = {206--215} +} + +@article{hansen2011, + author = {Hansen, Luke O. and Young, Robert S. and Hinami, Keiki and Leung, Albert and Williams, Mark V.}, + title = {Interventions to reduce 30-day rehospitalization: a systematic review}, + journal = {Annals of Internal Medicine}, + year = {2011}, + volume = {155}, + number = {8}, + pages = {520--528} +} + +@article{coleman2006, + author = {Coleman, Eric A. and Parry, Carla and Chalmers, Sandra and Min, Sung-joon}, + title = {The care transitions intervention: results of a randomized controlled trial}, + journal = {Archives of Internal Medicine}, + year = {2006}, + volume = {166}, + number = {17}, + pages = {1822--1828} +} + +@article{joynt2013, + author = {Joynt, Karen E. and Jha, Ashish K.}, + title = {Characteristics of hospitals receiving penalties under the {Hospital Readmissions Reduction Program}}, + journal = {JAMA}, + year = {2013}, + volume = {309}, + number = {4}, + pages = {342--343} +} + +@article{desai2016, + author = {Desai, Nihar R. and Ross, Joseph S. and Kwon, Jin Yul and others}, + title = {Association Between Hospital Penalty Status Under the {Hospital Readmission Reduction Program} and Readmission Rates for Target and Nontarget Conditions}, + journal = {JAMA}, + year = {2016}, + volume = {316}, + number = {24}, + pages = {2647--2656} +} + +@article{zuckerman2017, + author = {Zuckerman, Rachael B. and Sheingold, Steven H. and Orav, E. John and Ruhter, Jordan and Epstein, Arnold M.}, + title = {Effect of a Hospital-wide Measure on the Readmissions Reduction Program}, + journal = {New England Journal of Medicine}, + year = {2017}, + volume = {377}, + number = {16}, + pages = {1551--1558} +} + +@article{obermeyer2019, + author = {Obermeyer, Ziad and Powers, Brian and Vogeli, Christine and Mullainathan, Sendhil}, + title = {Dissecting racial bias in an algorithm used to manage the health of populations}, + journal = {Science}, + year = {2019}, + volume = {366}, + number = {6464}, + pages = {447--453} +} + +@article{kind2014, + author = {Kind, Amy J. H. and Jencks, Stephen and Brock, Jim and others}, + title = {Neighborhood socioeconomic disadvantage and 30-day rehospitalization: a retrospective cohort study}, + journal = {Annals of Internal Medicine}, + year = {2014}, + volume = {161}, + number = {11}, + pages = {765--774} +} + +@article{joynt2011, + author = {Joynt, Karen E. and Orav, E. John and Jha, Ashish K.}, + title = {Thirty-day readmission rates for {Medicare} beneficiaries by race and site of care}, + journal = {JAMA}, + year = {2011}, + volume = {305}, + number = {7}, + pages = {675--681} +} + +@article{collins2015, + author = {Collins, Gary S. and Reitsma, Johannes B. and Altman, Douglas G. and Moons, Karel G. M.}, + title = {Transparent Reporting of a multivariable prediction model for Individual Prognosis or Diagnosis ({TRIPOD}): the {TRIPOD} statement}, + journal = {Annals of Internal Medicine}, + year = {2015}, + volume = {162}, + number = {1}, + pages = {55--63} +} diff --git a/data/bibliography.bib b/data/bibliography.bib new file mode 100644 index 0000000..4cfa752 --- /dev/null +++ b/data/bibliography.bib @@ -0,0 +1,481 @@ +% bibliography.bib — levineuwirth.org +% BibLaTeX format. Add entries here; reference with [@key] in essay Markdown. + +@book{tufte1983, + author = {Tufte, Edward R.}, + title = {The Visual Display of Quantitative Information}, + publisher = {Graphics Press}, + address = {Cheshire, CT}, + year = {1983} +} + +@book{butterick2019, + author = {Butterick, Matthew}, + title = {Practical Typography}, + publisher = {Matthew Butterick}, + year = {2019}, + url = {https://practicaltypography.com} +} + +@misc{pandoc, + author = {MacFarlane, John}, + title = {Pandoc: A Universal Document Converter}, + year = {2006}, + url = {https://pandoc.org} +} + +@book{knuth1984, + author = {Knuth, Donald E.}, + title = {The \TeX{}book}, + publisher = {Addison-Wesley}, + address = {Reading, MA}, + year = {1984} +} + +@article{lamport1994, + author = {Lamport, Leslie}, + title = {{\LaTeX}: A Document Preparation System}, + journal = {Communications of the ACM}, + volume = {37}, + number = {7}, + year = {1994}, + pages = {47--52} +} + +% --------------------------------------------------------------------------- +% Philosophy / Literature +% --------------------------------------------------------------------------- + +@article{Frank, + ISSN = {00373052, 1934421X}, + URL = {http://www.jstor.org/stable/27540632}, + author = {Frank, Joseph}, + journal = {The Sewanee Review}, + number = {1}, + pages = {1--33}, + publisher = {Johns Hopkins University Press}, + title = {Nihilism and ``Notes from Underground''}, + volume = {69}, + year = {1961} +} + +@article{Reynolds, + URL = {https://repository.lsu.edu/cgi/viewcontent.cgi?article=2235&context=honors_etd}, + author = {Reynolds, Andrew S.}, + number = {1}, + publisher = {LSU Scholarly Repository}, + year = {1996} +} + +@article{Somer, + URL = {https://journals.lww.com/jonmd/FullText/2017/07000/The_Comorbidity_of_Daydreaming_Disorder.4.aspx}, + author = {Somer, Eli}, + journal = {The Journal of Nervous and Mental Disease}, + number = {7}, + pages = {525--530}, + year = {2017} +} + +@book{Heidegger_Letter, + title = {Basic Writings}, + author = {Heidegger, Martin}, + isbn = {9780061627019}, + pages = {219}, + series = {Harper Perennial Modern Thought}, + year = {2008}, + publisher = {HarperCollins} +} + +@book{Heidegger_Building, + title = {Basic Writings}, + author = {Heidegger, Martin}, + isbn = {9780061627019}, + pages = {361}, + series = {Harper Perennial Modern Thought}, + year = {2008}, + publisher = {HarperCollins} +} + +@book{Notes, + title = {Notes from Underground}, + author = {Dostoevsky, Fyodor and Pevear, Richard and Volokhonsky, Larissa}, + isbn = {9780679734529}, + series = {Vintage Classics}, + year = {1994}, + publisher = {Knopf Doubleday Publishing Group} +} + +@book{Nietzsche_Gene, + title = {On the Genealogy of Morals}, + author = {Nietzsche, Friedrich and Holub, Robert C. and Scarpitti, Michael A.}, + isbn = {9780141195384}, + year = {2013}, + publisher = {Penguin Books Limited} +} + +@book{Nietzsche_Tragedy, + title = {The Birth of Tragedy: Out of the Spirit of Music}, + author = {Nietzsche, Friedrich and Tanner, Michael and Whiteside, Shaun}, + isbn = {9780141935072}, + series = {Penguin Classics}, + year = {2003}, + publisher = {Penguin Books Limited} +} + +@book{Shestov, + title = {All Things are Possible and Penultimate Words and Other Essays}, + author = {Shestov, Lev}, + isbn = {9780821402375}, + year = {1977}, + publisher = {Ohio University Press} +} + +@book{Ellul, + title = {The Technological Society}, + author = {Ellul, Jacques}, + isbn = {9780394703909}, + series = {A Vintage Book}, + year = {1964}, + publisher = {Knopf Doubleday Publishing Group} +} + +@book{Kacz, + title = {Industrial Society and Its Future}, + author = {Kaczynski, Theodore J.}, + isbn = {9781365394294}, + year = {1995}, + publisher = {The Washington Post} +} + +@book{Sartre, + title = {Being and Nothingness: An Essay in Phenomenological Ontology}, + author = {Sartre, Jean-Paul and Barnes, Hazel E.}, + isbn = {9780806522760}, + year = {2001}, + publisher = {Citadel Press} +} + +@book{Camus_Myth, + title = {The Myth of Sisyphus and Other Essays}, + author = {Camus, Albert}, + isbn = {9780307827821}, + series = {Vintage International}, + year = {2012}, + publisher = {Knopf Doubleday Publishing Group} +} + +@book{Cooper, + title = {The Totalizing Act: Key to Husserl's Early Philosophy}, + author = {Cooper-Wiele, Jonathan K.}, + isbn = {9789400922594}, + series = {Phaenomenologica}, + year = {2012}, + publisher = {Springer Netherlands} +} + +@book{Husserl_Crisis, + title = {The Crisis of European Sciences and Transcendental Phenomenology}, + author = {Husserl, Edmund and Carr, David}, + isbn = {9780810104587}, + series = {Northwestern University Studies in Phenomenology \& Existential Philosophy}, + year = {1970}, + publisher = {Northwestern University Press} +} + +@book{Husserl_Meditations, + title = {Cartesian Meditations: An Introduction to Phenomenology}, + author = {Husserl, Edmund and Cairns, Dorothea}, + isbn = {9789400999978}, + year = {2012}, + publisher = {Springer Netherlands} +} + +@book{Arendt, + title = {The Human Condition}, + author = {Arendt, Hannah}, + isbn = {9780226586748}, + year = {2022}, + publisher = {University of Chicago Press} +} + +% ── ICD Embeddings Preprint ───────────────────────────────────────────────── + +@misc{cms_hrrp, + author = {{Centers for Medicare \& Medicaid Services}}, + title = {Hospital Readmissions Reduction Program ({HRRP})}, + url = {https://www.cms.gov/medicare/payment/prospective-payment-systems/acute-inpatient-pps/hospital-readmissions-reduction-program-hrrp} +} + +@article{charlson1987, + author = {Charlson, M. E. and Pompei, P. and Ales, K. L. and MacKenzie, C. R.}, + title = {A new method of classifying prognostic comorbidity in longitudinal studies: development and validation}, + journal = {Journal of Chronic Diseases}, + year = {1987}, + volume = {40}, + number = {5}, + pages = {373--383} +} + +@article{elixhauser1998, + author = {Elixhauser, A. and Steiner, C. and Harris, D. R. and Coffey, R. M.}, + title = {Comorbidity measures for use with administrative data}, + journal = {Medical Care}, + year = {1998}, + volume = {36}, + number = {1}, + pages = {8--27} +} + +@article{deschepper2020, + author = {Deschepper, Mathieu and Eeckloo, Kristof and Vogelaers, Dominique and Waegeman, Willem}, + title = {Using structured pathology data to predict hospital-wide mortality at admission}, + journal = {PLoS One}, + year = {2020}, + volume = {15}, + number = {6}, + pages = {e0235117} +} + +@article{lelay2022, + author = {Le Lay, Jonathan and Martin, Sylvain and Mosnier, Arnaud and others}, + title = {Prediction of hospital readmission of multimorbid patients using machine learning models}, + journal = {PLoS One}, + year = {2022}, + volume = {17}, + number = {12}, + pages = {e0279433} +} + +@article{qiao2022, + author = {Qiao, Edmund M. and Deng, Jie and Kluger, Harvey M. and Yu, James B. and Gross, Cary P.}, + title = {Evaluating High-Dimensional Machine Learning Models to Predict Hospital Mortality Among Older Patients With Cancer}, + journal = {JCO Clinical Cancer Informatics}, + year = {2022}, + volume = {6}, + pages = {e2100186} +} + +@article{davis2022, + author = {Davis, Steve and Borah, Michael F. and Hansen, Justin and Malin, Bradley and Robinson, Jonathan and Turchin, Alexander}, + title = {Effective hospital readmission prediction models using machine-learned features}, + journal = {BMC Health Services Research}, + year = {2022}, + volume = {22}, + number = {1}, + pages = {1415} +} + +@article{harerimana2021, + author = {Harerimana, Gaspard and Kim, Jong Wook and Jang, Beakcheol}, + title = {A deep attention model to forecast the Length Of Stay and the in-hospital mortality right on admission from {ICD} codes and demographic data}, + journal = {Journal of Biomedical Informatics}, + year = {2021}, + volume = {118}, + pages = {103778} +} + +@article{matsui2022, + author = {Matsui, Hiroki and Yasunaga, Hideo and Fushimi, Kiyohide and Homma, Yuichi}, + title = {Development of Deep Learning Models for Predicting In-Hospital Mortality Using an Administrative Claims Database: Retrospective Cohort Study}, + journal = {JMIR Medical Informatics}, + year = {2022}, + volume = {10}, + number = {2}, + pages = {e27936} +} + +@article{nguyen2017, + author = {Nguyen, Phuoc and Tran, Truyen and Wickramasinghe, Nilmini and Venkatesh, Svetha}, + title = {{Deepr}: A Convolutional Net for Medical Records}, + journal = {IEEE Journal of Biomedical and Health Informatics}, + year = {2017}, + volume = {21}, + number = {1}, + pages = {22--30} +} + +@misc{ahrq_elixhauser, + author = {{Agency for Healthcare Research and Quality}}, + title = {Elixhauser Comorbidity Software Refined for {ICD-10-CM}}, + url = {https://www.hcup-us.ahrq.gov/toolssoftware/comorbidityicd10/comorbidity_icd10.jsp} +} + +@article{fernando2019, + author = {Fernando, Dulith T. and Berecki-Gisolf, Janneke and Newstead, Stuart and Ansari, Zul}, + title = {Effect of comorbidity on injury outcomes: a review of existing indices}, + journal = {Annals of Epidemiology}, + year = {2019}, + volume = {36}, + pages = {5--14} +} + +@article{quan2005, + author = {Quan, Hude and Sundararajan, Vijaya and Halfon, Patricia and others}, + title = {Coding algorithms for defining comorbidities in {ICD-9-CM} and {ICD-10} administrative data}, + journal = {Medical Care}, + year = {2005}, + volume = {43}, + number = {11}, + pages = {1130--1139} +} + +@article{deyo1992, + author = {Deyo, Richard A. and Cherkin, Daniel C. and Ciol, Marcia A.}, + title = {Adapting a clinical comorbidity index for use with {ICD-9-CM} administrative databases}, + journal = {Journal of Clinical Epidemiology}, + year = {1992}, + volume = {45}, + number = {6}, + pages = {613--619} +} + +@article{quan2011, + author = {Quan, Hude and Li, Bing and Couris, Chantal M. and others}, + title = {Updating and validating the {Charlson} comorbidity index and score for risk adjustment in hospital discharge abstracts using data from 6 countries}, + journal = {American Journal of Epidemiology}, + year = {2011}, + volume = {173}, + number = {6}, + pages = {676--682} +} + +@misc{stagg2006, + author = {Stagg, Vince}, + title = {{CHARLSON}: {Stata} module to calculate {Charlson} index of comorbidity}, + year = {2006}, + publisher = {Boston College Department of Economics} +} + +@inproceedings{zaheer2017, + author = {Zaheer, Manzil and Kottur, Satwik and Ravanbakhsh, Siamak and Poczos, Barnabas and Salakhutdinov, Ruslan and Smola, Alexander}, + title = {Deep Sets}, + booktitle = {Advances in Neural Information Processing Systems}, + year = {2017}, + volume = {30} +} + +@inproceedings{abadi2016, + author = {Abadi, Mart{\'i}n and Barham, Paul and Chen, Jianmin and others}, + title = {{TensorFlow}: A System for Large-Scale Machine Learning}, + booktitle = {Proceedings of the 12th {USENIX} Symposium on Operating Systems Design and Implementation}, + year = {2016}, + pages = {265--283} +} + +@article{youden1950, + author = {Youden, W. J.}, + title = {Index for rating diagnostic tests}, + journal = {Cancer}, + year = {1950}, + volume = {3}, + number = {1}, + pages = {32--35} +} + +@article{delong1988, + author = {DeLong, Elizabeth R. and DeLong, David M. and Clarke-Pearson, Daniel L.}, + title = {Comparing the areas under two or more correlated receiver operating characteristic curves: a nonparametric approach}, + journal = {Biometrics}, + year = {1988}, + volume = {44}, + number = {3}, + pages = {837--845} +} + +@inproceedings{sundararajan2017, + author = {Sundararajan, Mukund and Taly, Ankur and Yan, Qiqi}, + title = {Axiomatic Attribution for Deep Networks}, + booktitle = {Proceedings of the 34th International Conference on Machine Learning}, + year = {2017}, + pages = {3319--3328} +} + +@article{joynt2013, + author = {Joynt, Karen E. and Jha, Ashish K.}, + title = {Characteristics of hospitals receiving penalties under the {Hospital Readmissions Reduction Program}}, + journal = {JAMA}, + year = {2013}, + volume = {309}, + number = {4}, + pages = {342--343} +} + +@article{desai2016, + author = {Desai, Nihar R. and Ross, Joseph S. and Kwon, Jin Yul and others}, + title = {Association Between Hospital Penalty Status Under the {Hospital Readmission Reduction Program} and Readmission Rates for Target and Nontarget Conditions}, + journal = {JAMA}, + year = {2016}, + volume = {316}, + number = {24}, + pages = {2647--2656} +} + +@article{zuckerman2017, + author = {Zuckerman, Rachael B. and Sheingold, Steven H. and Orav, E. John and Ruhter, Jordan and Epstein, Arnold M.}, + title = {Effect of a Hospital-wide Measure on the Readmissions Reduction Program}, + journal = {New England Journal of Medicine}, + year = {2017}, + volume = {377}, + number = {16}, + pages = {1551--1558} +} + +@article{obermeyer2019, + author = {Obermeyer, Ziad and Powers, Brian and Vogeli, Christine and Mullainathan, Sendhil}, + title = {Dissecting racial bias in an algorithm used to manage the health of populations}, + journal = {Science}, + year = {2019}, + volume = {366}, + number = {6464}, + pages = {447--453} +} + +@article{kind2014, + author = {Kind, Amy J. H. and Jencks, Stephen and Brock, Jim and others}, + title = {Neighborhood socioeconomic disadvantage and 30-day rehospitalization: a retrospective cohort study}, + journal = {Annals of Internal Medicine}, + year = {2014}, + volume = {161}, + number = {11}, + pages = {765--774} +} + +@article{joynt2011, + author = {Joynt, Karen E. and Orav, E. John and Jha, Ashish K.}, + title = {Thirty-day readmission rates for {Medicare} beneficiaries by race and site of care}, + journal = {JAMA}, + year = {2011}, + volume = {305}, + number = {7}, + pages = {675--681} +} + +@article{hansen2011, + author = {Hansen, Luke O. and Young, Robert S. and Hinami, Keiki and Leung, Albert and Williams, Mark V.}, + title = {Interventions to reduce 30-day rehospitalization: a systematic review}, + journal = {Annals of Internal Medicine}, + year = {2011}, + volume = {155}, + number = {8}, + pages = {520--528} +} + +@article{coleman2006, + author = {Coleman, Eric A. and Parry, Carla and Chalmers, Sandra and Min, Sung-joon}, + title = {The care transitions intervention: results of a randomized controlled trial}, + journal = {Archives of Internal Medicine}, + year = {2006}, + volume = {166}, + number = {17}, + pages = {1822--1828} +} + +@article{rudin2019, + author = {Rudin, Cynthia}, + title = {Stop Explaining Black Box Machine Learning Models for High Stakes Decisions and Use Interpretable Models Instead}, + journal = {Nature Machine Intelligence}, + year = {2019}, + volume = {1}, + number = {5}, + pages = {206--215} +} diff --git a/data/branch-capture-paper.bib b/data/branch-capture-paper.bib new file mode 100644 index 0000000..efa38b6 --- /dev/null +++ b/data/branch-capture-paper.bib @@ -0,0 +1,95 @@ +% ── Foundational papers (cops-and-robbers) ────────────────────────────────── + +@article{NowakowskiWinkler, + author = {Nowakowski, R. and Winkler, P.}, + title = {Vertex-to-vertex pursuit in a graph}, + journal = {Discrete Mathematics}, + volume = {43}, + year = {1983}, + pages = {235--239}, +} + +@phdthesis{Quilliot, + author = {Quilliot, A.}, + title = {Jeux et points fixes sur les graphes}, + school = {Universit\'e de Paris VI}, + type = {Th\`ese de 3\`eme cycle}, + year = {1978}, +} + +@article{AignerFromme, + author = {Aigner, M. and Fromme, M.}, + title = {A game of cops and robbers}, + journal = {Discrete Applied Mathematics}, + volume = {8}, + number = {1}, + year = {1984}, + pages = {1--12}, +} + +% ── Meyniel's conjecture: bounds and partial progress ─────────────────────── + +@article{Frankl, + author = {Frankl, P.}, + title = {Cops and robbers in graphs with large girth and {Cayley} graphs}, + journal = {Discrete Applied Mathematics}, + volume = {17}, + year = {1987}, + pages = {301--305}, +} + +@article{LuPeng, + author = {Lu, L. and Peng, X.}, + title = {On {Meyniel}'s conjecture of the cop number}, + journal = {Journal of Graph Theory}, + volume = {71}, + number = {2}, + year = {2012}, + pages = {192--205}, +} + +@article{ScottSudakov, + author = {Scott, A. and Sudakov, B.}, + title = {A bound for the cops and robbers problem}, + journal = {SIAM Journal on Discrete Mathematics}, + volume = {25}, + number = {3}, + year = {2011}, + pages = {1438--1442}, +} + +@article{FriezeKrivelevichLoh, + author = {Frieze, A. and Krivelevich, M. and Loh, P.-S.}, + title = {Variations on cops and robbers}, + journal = {Journal of Graph Theory}, + volume = {69}, + number = {4}, + year = {2012}, + pages = {383--402}, +} + +@article{PralatWormald, + author = {Pra{\l}at, P. and Wormald, N.}, + title = {{Meyniel}'s conjecture holds for random graphs}, + journal = {Random Structures \& Algorithms}, + volume = {48}, + number = {2}, + year = {2016}, + pages = {396--421}, +} + +@article{BradshawHosseiniMoharStacho, + author = {Bradshaw, P. and Hosseini, S. A. and Mohar, B. and Stacho, L.}, + title = {On the cop number of graphs of high girth}, + journal = {Journal of Graph Theory}, + year = {2023}, +} + +@article{HMG, + author = {Hosseini, S. A. and Mohar, B. and {Gonzalez Hermosillo de la Maza}, S.}, + title = {{Meyniel}'s conjecture on graphs of bounded degree}, + journal = {Journal of Graph Theory}, + volume = {97}, + year = {2021}, + pages = {401--407}, +} diff --git a/data/chicago-notes.csl b/data/chicago-notes.csl new file mode 100644 index 0000000..9b16d12 --- /dev/null +++ b/data/chicago-notes.csl @@ -0,0 +1,248 @@ + + diff --git a/data/commonplace.yaml b/data/commonplace.yaml new file mode 100644 index 0000000..7e33127 --- /dev/null +++ b/data/commonplace.yaml @@ -0,0 +1,10 @@ +- text: |- + Like as the waves make towards the pebbled shore, + So do our minutes hasten to their end; + Each changing place with that which goes before, + In sequent toil all forwards do contend. + attribution: William Shakespeare + source: Sonnet 60 + source-url: /poetry/sonnet-60.html + tags: [time, mortality] + date-added: 2026-03-17 diff --git a/data/growing-radius-domination-preprint.bib b/data/growing-radius-domination-preprint.bib new file mode 100644 index 0000000..1c82a34 --- /dev/null +++ b/data/growing-radius-domination-preprint.bib @@ -0,0 +1,157 @@ +% ── Cops-and-robbers background ───────────────────────────────────────────── + +@article{AignerFromme, + author = {Aigner, M. and Fromme, M.}, + title = {A game of cops and robbers}, + journal = {Discrete Applied Mathematics}, + volume = {8}, + number = {1}, + year = {1984}, + pages = {1--12}, +} + +@article{LuPeng, + author = {Lu, L. and Peng, X.}, + title = {On {Meyniel}'s conjecture of the cop number}, + journal = {Journal of Graph Theory}, + volume = {71}, + number = {2}, + year = {2012}, + pages = {192--205}, +} + +@article{ScottSudakov, + author = {Scott, A. and Sudakov, B.}, + title = {A bound for the cops and robbers problem}, + journal = {SIAM Journal on Discrete Mathematics}, + volume = {25}, + number = {3}, + year = {2011}, + pages = {1438--1442}, +} + +@article{PralatWormald, + author = {Pra{\l}at, P. and Wormald, N.}, + title = {{Meyniel}'s conjecture holds for random graphs}, + journal = {Random Structures \& Algorithms}, + volume = {48}, + number = {2}, + year = {2016}, + pages = {396--421}, +} + +@article{PralatWormaldRegular, + author = {Pra{\l}at, P. and Wormald, N.}, + title = {{Meyniel}'s conjecture holds for random $d$-regular graphs}, + journal = {Random Structures \& Algorithms}, + volume = {55}, + number = {3}, + year = {2019}, + pages = {719--741}, +} + +@misc{NeuwirthBranchCapture, + author = {Neuwirth, Levi}, + title = {Branch-tube persistence and static coverage in tree-ball geometry}, + note = {Preprint}, + year = {2026}, + url = {https://levineuwirth.org/essays/branch-based-local-capture-in-tree-balls/index.html}, +} + +% ── Random-regular / configuration-model background ──────────────────────── + +@incollection{Wormald, + author = {Wormald, N. C.}, + title = {Models of random regular graphs}, + booktitle = {Surveys in Combinatorics, 1999}, + series = {London Mathematical Society Lecture Note Series}, + volume = {267}, + publisher = {Cambridge University Press}, + year = {1999}, + pages = {239--298}, +} + +@article{Janson, + author = {Janson, S.}, + title = {The probability that a random multigraph is simple}, + journal = {Combinatorics, Probability and Computing}, + volume = {18}, + year = {2009}, + pages = {205--225}, +} + +% ── Domination in regular / random graphs ─────────────────────────────────── + +@book{CohenHonkalaLitsynLobstein, + author = {Cohen, G. and Honkala, I. and Litsyn, S. and Lobstein, A.}, + title = {Covering Codes}, + series = {North-Holland Mathematical Library}, + volume = {54}, + publisher = {Elsevier}, + year = {1997}, +} + +@article{Duckworth, + author = {Duckworth, W.}, + title = {Randomized greedy algorithms for finding small $k$-dominating sets of regular graphs}, + journal = {Random Structures \& Algorithms}, + volume = {27}, + year = {2005}, + pages = {401--412}, +} + +@article{DuckworthWormald, + author = {Duckworth, W. and Wormald, N. C.}, + title = {On the independent domination number of random regular graphs}, + journal = {Combinatorics, Probability and Computing}, + volume = {15}, + year = {2006}, + pages = {513--522}, +} + +@article{CutlerRadcliffe, + author = {Cutler, J. and Radcliffe, A. J.}, + title = {Counting dominating sets and related structures in graphs}, + journal = {Discrete Mathematics}, + volume = {339}, + year = {2016}, + pages = {1593--1599}, +} + +@article{GlebovLiebenauSzabo, + author = {Glebov, R. and Liebenau, A. and Szab{\'o}, T.}, + title = {On the concentration of the domination number of the random graph}, + journal = {SIAM Journal on Discrete Mathematics}, + volume = {29}, + year = {2015}, + pages = {1186--1206}, +} + +% ── Statistical-mechanics / message-passing antecedents ───────────────────── + +@article{ZhaoHabibullaZhou, + author = {Zhao, J.-H. and Habibulla, Y. and Zhou, H.-J.}, + title = {Statistical mechanics of the minimum dominating set problem}, + journal = {Journal of Statistical Physics}, + volume = {159}, + year = {2015}, + pages = {1154--1174}, +} + +@misc{HabibullaQin, + author = {Habibulla, Y. and Qin, S.-m.}, + title = {Two-distance minimal dominating set problem studied by statistical mechanics and simulated annealing}, + howpublished = {arXiv:1910.07933}, + year = {2019}, +} + +% ── Variational-analysis background ───────────────────────────────────────── + +@book{RockafellarWets, + author = {Rockafellar, R. T. and Wets, R. J.-B.}, + title = {Variational Analysis}, + series = {Grundlehren der mathematischen Wissenschaften}, + volume = {317}, + publisher = {Springer}, + year = {1998}, +} diff --git a/data/now.yaml b/data/now.yaml new file mode 100644 index 0000000..ba358d3 --- /dev/null +++ b/data/now.yaml @@ -0,0 +1,121 @@ +# Now — research-first status feed for /current.html. +# +# `last-updated` is the page-level temporal stamp. Bump it whenever +# you push any change here, even a one-line note edit; the value is +# rendered prominently and a relative-time tail ("4 days ago") is +# computed at build time. +# +# `entries:` are active items. Each carries: +# - title : display name +# - section : free-form section key (research, engineering, …); +# sections render in first-appearance order +# - status : accepted | in-review | revising | drafting | building | +# early-stage | paused. Free-form, but the listed values are +# the canonical ladder (closest-to-shipping → furthest) and +# have CSS accents. "accepted" is for a paper/talk past review +# and locked in for publication or presentation. +# - updated : YYYY-MM-DD when this entry last moved (NOT page-stamp) +# - link : optional URL (artifact, preprint, repo, etc.) +# - note : optional one-sentence what's-happening-now line +# - priority : optional integer (default 0). Manual override on the sort +# order: positive floats up, negative sinks down. Use sparingly +# — the status ladder already captures most of the signal; +# priority is for "this is the headline regardless of where +# its status puts it" (or vice versa). +# +# Sort within each section: priority desc → status rank asc → updated desc. +# Section order: first-appearance in this file. Rearrange to taste. +# +# `shipped:` are recently-completed items. Each carries: +# - title, completed (date), optional link, optional note +# Sorted newest-first at render time. Curate by hand — items don't +# auto-prune. Move things off the list when they stop earning the slot. + +last-updated: 2026-08-20 + +entries: + - title: "MARS V: Zero-Knowledge Proofs for LLM Verification" + section: research + status: building + updated: 2026-08-20 + priority: 1 + link: https://caish.org/mars + note: "Zero-knowledge proofs for cryptographic verification of claims about LLM training, inference, and deployment, as a MARS V fellow mentored by James Petrie (FLI). Now porting from single-chip to multi-GPU, across both the mathematics and the engineering. Write-up expected October 2026." + + - title: "Branch-Tube Persistence and Static Coverage in Tree-Ball Geometry" + section: research + status: revising + updated: 2026-08-10 + link: /essays/branch-based-local-capture-in-tree-balls/ + note: "Mathematics preprint on path-tube persistence, exhaustive static coverage, conditional sampling thresholds, and information loss in d-regular tree-balls." + + - title: "The Annealed Critical Window for Growing-Radius Domination in Random Regular Graphs" + section: research + status: revising + updated: 2026-08-10 + link: /essays/near-critical-growing-radius-domination.html + note: "Mathematics preprint determining the bounded annealed critical window for growing-radius domination in random regular graphs — its universal scaling function and a scalar coupon-root asymptotic expansion — resolving what an earlier near-critical version left open." + + - title: "Order-Invariant ICD-10-CM Embedding (JAMIA submission)" + section: research + status: in-review + updated: 2026-07-10 + link: /essays/beyond-comorbidity-indices/ + note: "Under review at JAMIA (Journal of the American Medical Informatics Association), after moving from JAMA Network Open. Calculator deployed at levineuwirth.github.io/icd_embeddings." + + - title: "SIMD / PQC, Phase 2 & 3" + section: research + status: paused + updated: 2026-08-10 + link: /essays/where-does-simd-help-post-quantum-cryptography/ + note: "Paused pending a compute move to DTU." + + - title: "NeuroPose clinical-implications manuscript" + section: research + status: drafting + updated: 2026-04-08 + link: /essays/neuropose/ + note: "In preparation; target submission 2026–2027." + + - title: "Magic: The Gathering reinforcement learning" + section: research + status: building + updated: 2026-08-10 + note: "Reinforcement-learning agent under active development; expected late 2026." + + - title: "Levshell" + section: engineering + status: building + updated: 2026-08-10 + note: "Wayland-targeted productivity and research shell, in daily use as my main environment. Now growing from a single box to a fabric of machines that work as one, with project resume as the hero verb." + + - title: "Pmacs" + section: engineering + status: building + updated: 2026-08-10 + link: https://github.com/levineuwirth/pmacs + note: "v1.1.0 shipped, with prebuilt binaries. Rust core and embedded Lua VM, split into a long-lived instance and thin TUI and GPU frontends that attach over a typed protocol and edit CRDT-backed buffers concurrently." + + - title: "Epiphany" + section: engineering + status: building + updated: 2026-08-10 + link: https://github.com/levineuwirth/epiphany + note: "FOSS music-notation platform: a deterministic, CRDT-based score model whose LaTeX specification suite is the source of truth. Spec and editor tracks advancing in parallel." + + - title: "Levo" + section: engineering + status: building + updated: 2026-08-20 + note: "A Python-successor language designed adversarially: statically inferred without annotations, no GIL, no MRO. Spec and reference machine exist and .levo files run — but it is a conformance machine, not yet a runtime." + +shipped: + - title: "CHASE 2026 conference presentation" + completed: 2026-08-03 + link: /essays/beyond-comorbidity-indices/ + note: "Order-invariant ICD-10-CM embedding work presented at the IEEE/ACM Conference on Connected Health (CHASE)." + + - title: "Where Does SIMD Help Post-Quantum Cryptography?" + completed: 2026-04-15 + link: /essays/where-does-simd-help-post-quantum-cryptography/ + note: "Phase 1 technical report and reproducible artifact. Brown CS Department." diff --git a/data/simd-paper.bib b/data/simd-paper.bib new file mode 100644 index 0000000..df732d8 --- /dev/null +++ b/data/simd-paper.bib @@ -0,0 +1,141 @@ +% ── Post-Quantum Cryptography Standards ────────────────────────────────────── + +@techreport{fips203, + author = {{National Institute of Standards and Technology}}, + title = {{Module-Lattice-Based Key-Encapsulation Mechanism Standard}}, + institution = {NIST}, + year = {2024}, + number = {FIPS 203}, + url = {https://doi.org/10.6028/NIST.FIPS.203}, +} + +@techreport{fips204, + author = {{National Institute of Standards and Technology}}, + title = {{Module-Lattice-Based Digital Signature Standard}}, + institution = {NIST}, + year = {2024}, + number = {FIPS 204}, + url = {https://doi.org/10.6028/NIST.FIPS.204}, +} + +@techreport{fips205, + author = {{National Institute of Standards and Technology}}, + title = {{Stateless Hash-Based Digital Signature Standard}}, + institution = {NIST}, + year = {2024}, + number = {FIPS 205}, + url = {https://doi.org/10.6028/NIST.FIPS.205}, +} + +% ── Kyber / ML-KEM ─────────────────────────────────────────────────────────── + +@inproceedings{kyber2018, + author = {Bos, Joppe W. and Ducas, Léo and Kiltz, Eike and Lepoint, Tancrède + and Lyubashevsky, Vadim and Schanck, John M. and Schwabe, Peter + and Seiler, Gregor and Stehlé, Damien}, + title = {{CRYSTALS -- Kyber: A CCA-Secure Module-Lattice-Based KEM}}, + booktitle = {IEEE European Symposium on Security and Privacy (EuroS\&P)}, + year = {2018}, + pages = {353--367}, + doi = {10.1109/EuroSP.2018.00032}, +} + +@misc{kyber-avx2, + author = {Schwabe, Peter and Seiler, Gregor}, + title = {{High-Speed {AVX2} Implementation of the {Kyber} Key Encapsulation Mechanism}}, + note = {AVX2 implementation in the pqclean project}, + url = {https://github.com/pq-crystals/kyber}, +} + +% ── SIMD and Microarchitecture ──────────────────────────────────────────────── + +@inproceedings{intel-avx2, + author = {{Intel Corporation}}, + title = {{Intel 64 and IA-32 Architectures Software Developer's Manual}}, + year = {2024}, + note = {Volume 2: Instruction Set Reference}, +} + +@inproceedings{ntt-survey, + author = {Longa, Patrick and Naehrig, Michael}, + title = {{Speeding Up the Number Theoretic Transform for Faster Ideal + Lattice-Based Cryptography}}, + booktitle = {CANS}, + year = {2016}, + doi = {10.1007/978-3-319-48965-0_8}, +} + +% ── Energy Measurement ─────────────────────────────────────────────────────── + +@inproceedings{rapl, + author = {David, Howard and Gorbatov, Eugene and Hanebutte, Ulf R. and + Khanna, Rahul and Le, Christian}, + title = {{RAPL: Memory Power Estimation and Capping}}, + booktitle = {ISLPED}, + year = {2010}, + doi = {10.1145/1840845.1840883}, +} + +% ── Related Benchmarking Work ──────────────────────────────────────────────── + +@misc{pqclean, + author = {{PQClean Contributors}}, + title = {{PQClean: Clean, portable, tested implementations of post-quantum + cryptography}}, + url = {https://github.com/PQClean/PQClean}, +} + +@misc{liboqs, + author = {{Open Quantum Safe Project}}, + title = {{liboqs: C library for quantum-safe cryptographic algorithms}}, + url = {https://github.com/open-quantum-safe/liboqs}, +} + +@misc{pqm4, + author = {Kannwischer, Matthias J. and Rijneveld, Joost and Schwabe, Peter + and Stoffelen, Ko}, + title = {{pqm4: Post-quantum crypto library for the ARM Cortex-M4}}, + url = {https://github.com/mupq/pqm4}, +} + +@misc{supercop, + author = {Bernstein, Daniel J. and Lange, Tanja}, + title = {{SUPERCOP: System for Unified Performance Evaluation Related to + Cryptographic Operations and Primitives}}, + url = {https://bench.cr.yp.to/supercop.html}, +} + +@misc{papi, + author = {{Innovative Computing Laboratory, University of Tennessee}}, + title = {{PAPI: Performance Application Programming Interface}}, + url = {https://icl.utk.edu/papi/}, +} + +@inproceedings{gueron2014, + author = {Gueron, Shay and Krasnov, Vlad}, + title = {{Fast Garbling of Circuits Under Standard Assumptions}}, + booktitle = {ACM CCS}, + year = {2013}, + note = {See also: Intel white paper on AES-GCM with AVX2}, +} + +@misc{bernstein2006, + author = {Bernstein, Daniel J.}, + title = {{Curve25519: new Diffie-Hellman speed records}}, + year = {2006}, + url = {https://cr.yp.to/ecdh.html}, +} + +@misc{cachetime, + author = {Bernstein, Daniel J. and Schwabe, Peter}, + title = {{New AES Software Speed Records}}, + year = {2008}, + url = {https://cr.yp.to/aes-speed.html}, +} + +@misc{bettini2024, + author = {{Google Security Blog}}, + title = {{Protecting Chrome Traffic with Hybrid Kyber KEM}}, + year = {2023}, + url = {https://security.googleblog.com/2023/08/protecting-chrome-traffic-with-hybrid.html}, +} diff --git a/forgejo/docker-compose.yml b/forgejo/docker-compose.yml new file mode 100644 index 0000000..6af6c08 --- /dev/null +++ b/forgejo/docker-compose.yml @@ -0,0 +1,65 @@ +# Forgejo deployment for git.levineuwirth.org. +# +# Lives at /root/forgejo-server/docker-compose.yml on the VPS; this copy is +# the tracked source, in the same spirit as nginx/ and systemd/. Until +# August 2026 it existed only on that box, which meant the sole description +# of how the forge was deployed was inside its own backup tarball. +# +# scp forgejo/docker-compose.yml root@:/root/forgejo-server/ +# ssh root@ "cd /root/forgejo-server && docker compose up -d" +# +# Environment variables of the form FORGEJO__section__KEY are applied to +# /data/gitea/conf/app.ini by the entrypoint at startup. They take effect on +# container *recreation*, not on restart — `docker compose up -d` after an +# edit here, not `docker restart forgejo`. +# +# Notes on specific settings: +# +# DOMAIN / SSH_DOMAIN / ROOT_URL — these read git.yourdomain.com until +# 2026-08-11. nginx proxied around the mistake so the site worked, but +# Forgejo generates clone URLs, redirects, and mail links from ROOT_URL, +# so all of those were wrong. +# +# SSH_PORT 2222 — published on 0.0.0.0 and open on the host, but filtered +# by many public networks (it is unreachable from at least one Copenhagen +# library). Clone over HTTPS; treat SSH as the convenience path, not the +# dependable one. +# +# 3000 is bound to 127.0.0.1 deliberately: the web UI is reachable only +# through the host's nginx, never directly. +# +# The database is SQLite at forgejo-data/gitea/gitea.db. Backups are +# systemd/forgejo-backup.{service,timer} driving tools/forgejo-backup.sh. + +services: + forgejo: + image: codeberg.org/forgejo/forgejo:1.21.11-0 + container_name: forgejo + restart: unless-stopped + environment: + USER_UID: "1000" + USER_GID: "1000" + FORGEJO__server__DOMAIN: git.levineuwirth.org + FORGEJO__server__SSH_DOMAIN: git.levineuwirth.org + FORGEJO__server__ROOT_URL: https://git.levineuwirth.org/ + FORGEJO__server__SSH_PORT: "2222" + FORGEJO__database__DB_TYPE: sqlite3 + FORGEJO__service__DISABLE_REGISTRATION: "true" + FORGEJO__service__NO_REPLY_ADDRESS: noreply.git.levineuwirth.org + FORGEJO__actions__ENABLED: "false" + volumes: + - ./forgejo-data:/data + - /etc/localtime:/etc/localtime:ro + ports: + - "2222:22" + - "127.0.0.1:3000:3000" # Exposes web UI only to the local Nginx server + networks: + - proxy-net + security_opt: + - no-new-privileges:true + mem_limit: 512m + cpus: 1.0 + +networks: + proxy-net: + external: true diff --git a/levineuwirth.cabal b/levineuwirth.cabal new file mode 100644 index 0000000..1a0e9b7 --- /dev/null +++ b/levineuwirth.cabal @@ -0,0 +1,78 @@ +cabal-version: 3.0 +name: levineuwirth +version: 0.1.0.0 +synopsis: Build system for levineuwirth.org +license: MIT +license-file: LICENSE +author: Levi Neuwirth +maintainer: levi@levineuwirth.org +build-type: Simple + +executable site + main-is: Main.hs + hs-source-dirs: build + other-modules: + Site + Archive + ArchiveIndex + Authors + Catalog + Commonplace + Now + Vita + Backlinks + Dingbat + SimilarLinks + Compilers + Contexts + Marks + Patterns + Photography + Stats + Stability + Tags + Pagination + Citations + BibExtras + Filters + Filters.Typography + Filters.Sidenotes + Filters.Dropcaps + Filters.Smallcaps + Filters.Archive + Filters.Wikilinks + Filters.Transclusion + Filters.EmbedPdf + Filters.Links + Filters.SourceRefs + Filters.Math + Filters.Code + Filters.Images + Filters.Score + Filters.Aftermatter + Filters.Viz + Utils + build-depends: + base >= 4.18 && < 5, + hakyll >= 4.16 && < 4.17, + pandoc >= 3.1 && < 3.7, + pandoc-types >= 1.23 && < 1.24, + text >= 2.0 && < 2.2, + containers >= 0.6 && < 0.8, + filepath >= 1.4 && < 1.6, + 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, + process >= 1.6 && < 1.7, + data-default >= 0.7 && < 0.8, + mtl >= 2.3 && < 2.4, + blaze-html >= 0.9 && < 0.10, + blaze-markup >= 0.8 && < 0.9 + default-language: GHC2021 + ghc-options: + -threaded + -Wall diff --git a/nginx/archive.conf b/nginx/archive.conf new file mode 100644 index 0000000..6b59bcc --- /dev/null +++ b/nginx/archive.conf @@ -0,0 +1,45 @@ +# archive.conf — `X-Robots-Tag: noindex, noarchive` for the link archive. +# +# Place at /etc/nginx/snippets/archive.conf and `include` it inside the +# levineuwirth.org server { } block, *after* security-headers.conf: +# +# server { +# server_name levineuwirth.org; +# root /var/www/levineuwirth.org; +# ... +# include snippets/security-headers.conf; +# include snippets/static-assets.conf; +# include snippets/popup-proxy.conf; +# include snippets/archive.conf; +# } +# +# Why a location header rather than robots.txt: a URL blocked by +# robots.txt can still appear in results when externally linked, and the +# noindex directive must be reachable. Wrapper pages carry the meta in +# HTML, and the HTML snapshots have the same meta injected at fetch +# time. But raw PDFs cannot carry meta directives — and a robots.txt +# Disallow on /archive/ would prevent crawlers from reading the wrapper +# meta in the first place. The header form is the right control for the +# whole tree: crawlers honour it for any resource, HTML or PDF. +# +# `^~` makes this prefix-match take priority over any regex location +# that might match the same path. + +location ^~ /archive/ { + # nginx's add_header chain is inherited from a parent context ONLY + # when the current context declares no add_header directives — see + # nginx.org/en/docs/http/ngx_http_headers_module.html. Adding any + # header inside this location would silently drop the baseline + # security headers within the /archive/ subtree, so we re-include + # security-headers.conf to keep HSTS, CSP, X-Frame-Options, etc. + # intact for archive pages and raw artifacts. + include snippets/security-headers.conf; + + # `always` so the header is emitted even on 4xx/5xx responses (the + # default add_header only sets on 2xx/3xx; without `always` a 404 + # under /archive/ could be indexed). + add_header X-Robots-Tag "noindex, noarchive" always; + + # Hand off to the same static-file fallback as the rest of the site. + try_files $uri $uri/index.html $uri.html =404; +} diff --git a/nginx/popup-proxy.conf b/nginx/popup-proxy.conf new file mode 100644 index 0000000..0d8fec3 --- /dev/null +++ b/nginx/popup-proxy.conf @@ -0,0 +1,192 @@ +# popup-proxy.conf — same-origin reverse proxy for popups.js providers +# whose upstream APIs do not send CORS headers (arXiv, NCBI/PubMed, +# Internet Archive). All three return immutable metadata, so the cache +# TTL is generous; a manual `proxy_cache_purge` is unnecessary. +# +# Place this file at /etc/nginx/snippets/popup-proxy.conf and `include` +# it inside the server { } block of the levineuwirth.org vhost. The +# `proxy_cache_path` directive must live in the http { } context — put +# it in nginx.conf or the relevant conf.d/ file. +# +# http { +# proxy_cache_path /var/cache/nginx/popup-proxy +# levels=1:2 keys_zone=popup_proxy:16m +# max_size=512m inactive=60d use_temp_path=off; +# ... +# } +# +# server { +# server_name levineuwirth.org; +# ... +# include snippets/popup-proxy.conf; +# } + +# All locations use `^~` prefix matching: without it, the regex +# location in static-assets.conf (per-extension cache headers) outranks +# a plain prefix match and captures any proxied URL ending in an image +# extension — e.g. an arXiv figure .png — serving a local 404 instead +# of proxying. `^~` short-circuits regex evaluation for this subtree. + +# Shared resolver — needed because proxy_pass uses a variable upstream +# (literal upstreams are resolved once at startup; variables defer DNS +# to request time, which lets nginx start without the upstream being +# reachable and survives upstream IP changes). +resolver 1.1.1.1 8.8.8.8 ipv6=off valid=300s; +resolver_timeout 5s; + +# ── arXiv ──────────────────────────────────────────────────────────── +# Atom feed of paper metadata. Abstracts never change after publication +# (revisions get distinct IDs like 2604.06217v2), so 30d is safe. +location ^~ /proxy/arxiv/ { + set $upstream_arxiv export.arxiv.org; + # With a VARIABLE upstream, a URI part on proxy_pass is passed to + # the upstream literally — "proxy_pass https://$up/;" sends every + # request to the upstream's homepage instead of prefix-stripping. + # Strip the prefix explicitly; `break` keeps args intact. + rewrite ^/proxy/arxiv/(.*)$ /$1 break; + proxy_pass https://$upstream_arxiv; + proxy_set_header Host $upstream_arxiv; + proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)"; + proxy_ssl_server_name on; + + # Keep the security baseline: the add_header directives below + # would otherwise drop it for /proxy/ responses (same pattern + # as archive.conf). The upstream's own security headers are hidden + # first — browsers honor only the FIRST Strict-Transport-Security + # header (RFC 6797 §8.1), so an upstream's short max-age passing + # through ahead of ours would downgrade the domain's cached HSTS + # policy on every popup fetch. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + include snippets/security-headers.conf; + + proxy_cache popup_proxy; + proxy_cache_valid 200 30d; + proxy_cache_valid any 5m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status always; + + # Belt-and-suspenders: even though same-origin doesn't need CORS, a + # future migration of popups.js to a worker or different origin would. + add_header Access-Control-Allow-Origin "$scheme://$host" always; +} + +# ── arXiv HTML renditions (lead figures) ───────────────────────────── +# popups.js's arXiv enrich step fetches the LaTeXML HTML page to find +# the paper's lead figure, then loads the figure image itself — both +# through this location. Upstream is arxiv.org proper, NOT +# export.arxiv.org: the export host serves the Atom API fine but +# rate-limits the /html/ asset tree (429s on figures). Pages can be +# large (hundreds of KB), which is exactly why they're cached here. +# A 404 is the common no-HTML-rendition case (pre-2024 papers, +# unconvertible sources) — cached briefly so hovers don't hammer it. +location ^~ /proxy/arxiv-html/ { + set $upstream_arxiv_site arxiv.org; + rewrite ^/proxy/arxiv-html/(.*)$ /html/$1 break; + proxy_pass https://$upstream_arxiv_site; + proxy_set_header Host $upstream_arxiv_site; + proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)"; + proxy_ssl_server_name on; + + # Keep the security baseline: the add_header directives below + # would otherwise drop it for /proxy/ responses (same pattern + # as archive.conf). + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + include snippets/security-headers.conf; + + proxy_cache popup_proxy; + proxy_cache_valid 200 30d; + proxy_cache_valid 404 1d; + proxy_cache_valid any 5m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status always; + + add_header Access-Control-Allow-Origin "$scheme://$host" always; +} + +# ── Internet Archive ───────────────────────────────────────────────── +# Item metadata JSON. Item descriptions are author-edited and could +# change, but rarely; 7d strikes a reasonable balance. +location ^~ /proxy/archive/ { + set $upstream_archive archive.org; + # Prefix-strip explicitly — see the arXiv block for why a URI part + # on a variable proxy_pass would break this. + rewrite ^/proxy/archive/(.*)$ /$1 break; + proxy_pass https://$upstream_archive; + proxy_set_header Host $upstream_archive; + proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)"; + proxy_ssl_server_name on; + + # Keep the security baseline: the add_header directives below + # would otherwise drop it for /proxy/ responses (same pattern + # as archive.conf). The upstream's own security headers are hidden + # first — browsers honor only the FIRST Strict-Transport-Security + # header (RFC 6797 §8.1), so an upstream's short max-age passing + # through ahead of ours would downgrade the domain's cached HSTS + # policy on every popup fetch. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + include snippets/security-headers.conf; + + proxy_cache popup_proxy; + proxy_cache_valid 200 7d; + proxy_cache_valid any 5m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status always; + + add_header Access-Control-Allow-Origin "$scheme://$host" always; +} + +# ── PubMed (NCBI E-utilities) ──────────────────────────────────────── +# Article summaries. NCBI requests a tool=/email= identifier on every +# request (https://www.ncbi.nlm.nih.gov/books/NBK25497/); we inject +# them server-side so popups.js stays focused on rendering. +location ^~ /proxy/pubmed/ { + set $upstream_pubmed eutils.ncbi.nlm.nih.gov; + # Prefix-strip explicitly — see the arXiv block for why a URI part + # on a variable proxy_pass would break this. + rewrite ^/proxy/pubmed/(.*)$ /$1 break; + proxy_pass https://$upstream_pubmed; + proxy_set_header Host $upstream_pubmed; + proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)"; + proxy_ssl_server_name on; + + # NCBI etiquette: rate-limit to <3 req/s without an API key. With + # caching this is rarely exercised, but the burst guards a hot page. + limit_req zone=pubmed burst=3 nodelay; + + # Keep the security baseline: the add_header directives below + # would otherwise drop it for /proxy/ responses (same pattern + # as archive.conf). The upstream's own security headers are hidden + # first — browsers honor only the FIRST Strict-Transport-Security + # header (RFC 6797 §8.1), so an upstream's short max-age passing + # through ahead of ours would downgrade the domain's cached HSTS + # policy on every popup fetch. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + include snippets/security-headers.conf; + + proxy_cache popup_proxy; + proxy_cache_valid 200 30d; + proxy_cache_valid any 5m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status always; + + add_header Access-Control-Allow-Origin "$scheme://$host" always; +} + +# Companion directive for the limit_req above. Place in http { } context: +# +# http { +# limit_req_zone $binary_remote_addr zone=pubmed:1m rate=3r/s; +# ... +# } diff --git a/nginx/security-headers.conf b/nginx/security-headers.conf new file mode 100644 index 0000000..afe2b1c --- /dev/null +++ b/nginx/security-headers.conf @@ -0,0 +1,78 @@ +# security-headers.conf — security baseline for the levineuwirth.org vhost. +# +# Place at /etc/nginx/snippets/security-headers.conf and `include` it +# inside the server { } block of the vhost, alongside the other +# snippets shipped from this repo: +# +# server { +# server_name levineuwirth.org; +# root /var/www/levineuwirth.org; +# ... +# include snippets/security-headers.conf; +# include snippets/static-assets.conf; +# include snippets/popup-proxy.conf; +# } + +# Hide the nginx version from error pages and the Server header. +server_tokens off; + +# HSTS — one year, with subdomains, preload-eligible. Only safe to +# enable once HTTPS is the only listener (the :80 vhost should already +# 301 → https). Once preload is requested via hstspreload.org, the +# max-age cannot be lowered without removing from the list manually. +add_header Strict-Transport-Security + "max-age=31536000; includeSubDomains; preload" always; + +# Block MIME-sniffing — required for safe text/plain and svg responses. +add_header X-Content-Type-Options "nosniff" always; + +# Defense against clickjacking. Modern browsers honor CSP frame-ancestors +# (set below); X-Frame-Options is kept for legacy clients. +add_header X-Frame-Options "DENY" always; + +# Strip the Referer header to "scheme + host" on cross-origin requests. +add_header Referrer-Policy "strict-origin-when-cross-origin" always; + +# Default-deny powerful APIs. interest-cohort opts out of FLoC/Topics. +add_header Permissions-Policy + "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + +# Content Security Policy. Shipped in Report-Only mode initially — +# promote to enforcing (strip the "-Report-Only" suffix) once the +# report stream has been clean for a week. +# +# External origins justified inline: +# cdn.jsdelivr.net KaTeX CSS + JS + webfonts (the KaTeX CSS +# references its fonts relatively, so they +# resolve to the CDN -> font-src), Vega / +# Vega-Lite / Vega-Embed, transformers.js +# (whose onnxruntime fetches its .wasm from +# the CDN via fetch() -> connect-src) +# *.basemaps.cartocdn.com Leaflet basemap tiles (photography map only) +# connect-src API hosts link-popup providers fetched directly via +# CORS (the list popups.js documents in its +# header, plus git.levineuwirth.org for the +# Forgejo provider). The CORS-broken trio +# (arxiv, archive.org, pubmed) goes through +# the same-origin /proxy/ instead — see +# nginx/popup-proxy.conf. +# +# Why 'unsafe-inline' on style: +# - photography.html emits for +# palette swatches (data-driven, can't be hashed). +# - KaTeX adds inline styles when rendering math at runtime. +# +# Why 'unsafe-eval' on script: +# - vega-embed compiles Vega-Lite specs at runtime via new Function(). +# Removing this would require pre-compiling specs at build time. +# - it also covers WebAssembly.instantiate for onnxruntime-web +# (semantic search). +# +# The value MUST stay on one physical line: nginx has no line +# continuation inside quoted strings — a trailing backslash would embed +# literal backslash + LF bytes in the header value, which is illegal in +# HTTP/2 and gets whole responses rejected by strict clients. +# +# To collect violation reports, set up a `report-uri` endpoint and add +# `report-uri /csp-report;` (and/or `report-to ;`) below. +add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https://*.basemaps.cartocdn.com https://upload.wikimedia.org;font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://api.crossref.org https://api.github.com https://openlibrary.org https://api.biorxiv.org https://www.youtube.com https://git.levineuwirth.org; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always; diff --git a/nginx/static-assets.conf b/nginx/static-assets.conf new file mode 100644 index 0000000..1ed4d34 --- /dev/null +++ b/nginx/static-assets.conf @@ -0,0 +1,91 @@ +# static-assets.conf — Compression + long-lived cache headers for static assets. +# +# Place at /etc/nginx/snippets/static-assets.conf and `include` it inside +# the server { } block of the levineuwirth.org vhost: +# +# server { +# server_name levineuwirth.org; +# root /var/www/levineuwirth.org; +# ... +# include snippets/static-assets.conf; +# include snippets/popup-proxy.conf; +# } +# +# Pairs with tools/compress-assets.sh, which pre-generates .gz and .br +# sidecars at build time so nginx never pays the compression cost at +# request time. + +# ── On-the-fly gzip (fallback) ─────────────────────────────────────── +# Covers dynamically generated responses and any file for which a .gz +# sidecar was not produced (e.g. files smaller than compress-assets.sh's +# MIN_SIZE threshold, or extensions not on its allow-list). +gzip on; +gzip_vary on; +gzip_comp_level 6; +gzip_min_length 256; +gzip_proxied any; +gzip_types text/plain + text/css + text/xml + application/javascript + text/javascript + application/json + application/xml + application/xml+rss + application/wasm + image/svg+xml; + +# ── Pre-compressed sidecars ────────────────────────────────────────── +# Serve .gz / .br when the client advertises a matching +# Accept-Encoding. Zero request-time CPU; maximum compression ratio +# because the sidecars were produced with gzip -9 / brotli -Z. +gzip_static on; + +# brotli_static requires the ngx_brotli module: +# Arch: pacman -S nginx-mod-brotli (or build nginx-mainline with the module) +# Debian/Ubuntu: apt install libnginx-mod-brotli +# If the module is absent, comment out the two brotli lines below; gzip_static +# will still cover every modern browser. Chromium/Firefox/Safari all accept gzip. +brotli_static on; +brotli off; # we ship pre-compressed sidecars only, no on-the-fly brotli + +# ── Cache headers ──────────────────────────────────────────────────── +# PDF.js viewer is version-pinned in tools/download-pdfjs.sh — bumping +# the pin is a deploy, so `immutable` is safe and makes repeat visits +# instantaneous. Same reasoning applies to fingerprinted fonts and the +# locally vendored ML model files. +location ^~ /pdfjs/ { + # Re-include the security baseline: this location declares its + # own add_header, which (per nginx inheritance rules) would + # otherwise drop HSTS/nosniff/CSP for everything it serves — + # and nosniff matters most on exactly these JS/CSS responses. + # Same pattern as archive.conf. + include snippets/security-headers.conf; + add_header Cache-Control "public, max-age=31536000, immutable" always; + access_log off; +} + +location ^~ /fonts/ { + # Keep the security baseline (see first location above). + include snippets/security-headers.conf; + add_header Cache-Control "public, max-age=31536000, immutable" always; + access_log off; +} + +location ^~ /models/ { + # Keep the security baseline (see first location above). + include snippets/security-headers.conf; + add_header Cache-Control "public, max-age=31536000, immutable" always; + access_log off; +} + +# Per-extension caching for assets that live alongside HTML. CSS and JS +# in this repo are not fingerprinted, so a 1-hour cache with must-revalidate +# keeps them responsive to deploys (~1h staleness window for warm clients) +# without forcing a fetch on every page navigation. +location ~* \.(?:css|js|mjs|woff2?|svg|webp|png|jpg|jpeg|ico)$ { + # Keep the security baseline (see first location above). + include snippets/security-headers.conf; + add_header Cache-Control "public, max-age=3600, must-revalidate" always; + access_log off; +} diff --git a/nginx/vhost.conf.example b/nginx/vhost.conf.example new file mode 100644 index 0000000..475709c --- /dev/null +++ b/nginx/vhost.conf.example @@ -0,0 +1,68 @@ +# vhost.conf.example — reference vhost for levineuwirth.org. +# +# The live vhost on the VPS is the source of truth and lives at +# /etc/nginx/sites-available/levineuwirth.conf. `make deploy` does not +# touch the vhost — it only rsyncs _site/ to the document root. This +# file exists so the canonical structure (which snippets to include, +# in what order) is documented in the repo. +# +# To adopt: copy this file to /etc/nginx/sites-available/levineuwirth.conf +# on the VPS, fill in the certificate paths, and `nginx -t && systemctl +# reload nginx`. The three snippets it includes ship from this repo's +# nginx/ directory and should be installed under /etc/nginx/snippets/. + +# ── http { } scope ────────────────────────────────────────────────── +# popup-proxy.conf consumes a `proxy_cache_path` defined in http { }. +# Place this directive in nginx.conf or a conf.d/ file: +# +# proxy_cache_path /var/cache/nginx/popup-proxy +# levels=1:2 keys_zone=popup_proxy:16m +# max_size=512m inactive=60d use_temp_path=off; +# +# popup-proxy.conf also defines a `limit_req_zone` for PubMed; place +# its companion zone definition in http { } as well: +# +# limit_req_zone $binary_remote_addr zone=pubmed:1m rate=3r/s; + +# ── HTTPS server ──────────────────────────────────────────────────── +server { + listen 443 ssl; + http2 on; + listen [::]:443 ssl; + + server_name levineuwirth.org; + root /var/www/levineuwirth.org; + index index.html; + + ssl_certificate /etc/letsencrypt/live/levineuwirth.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/levineuwirth.org/privkey.pem; + + # Order matters: security-headers first so add_header directives + # propagate into the locations defined by the other snippets. + include snippets/security-headers.conf; + include snippets/static-assets.conf; + include snippets/popup-proxy.conf; + # archive.conf must come *after* security-headers.conf — it declares + # its own add_header inside `location ^~ /archive/`, which (per the + # nginx add_header inheritance rules) would otherwise drop the + # baseline headers within that subtree. The snippet re-includes + # security-headers.conf inside its location to compensate. + include snippets/archive.conf; + + # Static-site fallback. Pretty URLs first (foo/index.html, foo.html), + # then 404. + location / { + try_files $uri $uri/index.html $uri.html =404; + } + + # Custom 404. The build emits _site/404.html. + error_page 404 /404.html; +} + +# ── HTTP → HTTPS redirect ─────────────────────────────────────────── +server { + listen 80; + listen [::]:80; + server_name levineuwirth.org; + return 301 https://$host$request_uri; +} diff --git a/paper/ball-occupation-under-coarse-projections.tex b/paper/ball-occupation-under-coarse-projections.tex new file mode 100644 index 0000000..2dd3b8c --- /dev/null +++ b/paper/ball-occupation-under-coarse-projections.tex @@ -0,0 +1,1099 @@ +\documentclass[11pt]{amsart} + +\usepackage[margin=1in]{geometry} +\usepackage{amsmath,amssymb,amsthm,mathtools} +\usepackage{microtype} +\usepackage{booktabs} +\usepackage{enumitem} +\usepackage{xcolor} +\usepackage{hyperref} +\usepackage[nameinlink,capitalize,noabbrev]{cleveref} + +\newtheorem{theorem}{Theorem}[section] +\newtheorem{proposition}[theorem]{Proposition} +\newtheorem{corollary}[theorem]{Corollary} +\newtheorem{lemma}[theorem]{Lemma} +\theoremstyle{definition} +\newtheorem{definition}[theorem]{Definition} +\newtheorem{remark}[theorem]{Remark} + +\newcommand{\cnum}{c} +\newcommand{\dist}{\operatorname{dist}} +\newcommand{\diam}{\operatorname{diam}} +\newcommand{\eps}{\varepsilon} +\newcommand{\whp}{\text{with high probability}} +\newcommand{\Occ}{\operatorname{Occ}} +\newcommand{\iotae}{\iota} + +\hypersetup{ + colorlinks=true, + linkcolor=blue!50!black, + citecolor=blue!50!black, + urlcolor=blue!50!black, + pdftitle={Ball-Occupation Certificates under Coarse Graph Projections}, + pdfauthor={Levi Neuwirth}, + pdfkeywords={Cops and Robber, Meyniel's conjecture, graph products, degree reduction, expansion} +} + +\title[Ball-Occupation Certificates under Coarse Graph Projections]{Ball-Occupation Certificates under Coarse Graph Projections\\ +Degree Reduction, Square-Root Hard Families, and Toroidal Barriers} +\author{Levi Neuwirth} +\address{Brown University} +\date{July 2026} +\subjclass[2020]{05C57, 05C40, 05C12} +\keywords{Cops and Robber; Meyniel's conjecture; graph products; degree reduction; coarse graph projections; expansion; occupation certificates} + +\begin{document} + +\begin{abstract} +We isolate an abstract strategy-transfer principle for Cops and Robber. Let +$\pi:V(H)\to V(G)$ have fibers of order at most $P$, and suppose that for a +scale factor $\lambda\ge1$ the distances between distinct fibers satisfy +\[ + \lambda(\dist_G(u,v)-1)+1 + \le \dist_H(x,y) + \le \lambda(\dist_G(u,v)+2)-2, +\] +while every fiber has diameter at most $2(\lambda-1)$. If the $N$-vertex +base graph has uniform multi-source growth at the square-root scale, then a +Hall assignment occupies a lifted macro-ball before the robber can leave it, +giving +\[ + \cnum(H)\le C P\bigl(d^3+\log(ePN)\bigr)\sqrt N +\] +and capture time at most $\lambda R$. The theorem uses only the displayed +projection properties, not the internal form of the fibers. + +Iterated degree reduction of Hosseini--Mohar--Gonzalez Hermosillo de la Maza +has exactly this geometry, with $\lambda=3^k$. Applied to +$G(N,p)$ of expected degree $(\log N)^4$, it yields connected subcubic graphs +of order $M$ with +\[ + h(H)\ge (\log M)^{-O(1)}, + \qquad + M^{\frac12-\frac{9+o(1)}{2\log\log\log M}} + \le \cnum(H) + \le \sqrt M\,(\log M)^{O(1)}. +\] +Thus the known stressing family has the square-root exponent, with a +polylogarithmically tight upper bound; the lower convergence to $1/2$ is only +triple-logarithmic. + +Finally, we prove a sharp limitation of the strategy class. If a common +prepositioned bank must, after learning a robber start $v$, match distinct cops +to every vertex of $B(v,R)$ using travel distance at most $R$, then +\[ + \Occ_R(G) + \ge + \frac{\sum_v |B(v,R)|}{\max_x |B(x,2R)|}. +\] +For every fixed $k\ge2$, the Cartesian torus $C_L^{\square k}$ has +$|V|=L^k$, vertex expansion $\Theta_k(L^{-1})$, exact cop number $k+1$, and +$\Occ_R=\Omega_k(L^k)$ for every radius $R$. Hence, for every $\delta>0$, +there are bounded-degree graphs with $h(G)\ge |G|^{-\delta}$ and constant cop +number on which one-shot occupation still costs a linear number of cops. A +cubic four-cycle replacement gives a degree-three instance at exponent +$\delta=1/2$. Any universal robustness theorem must therefore use adaptive +multi-round pursuit, cop reuse, or another strategy not reducible to one-shot +occupation. +\end{abstract} + +\maketitle + +\section{Introduction and main conclusions} + +The multi-cop version of Cops and Robber was developed by Aigner and Fromme, +who proved that three cops suffice on every planar graph +\cite{AignerFromme}. Meyniel's conjecture asks whether every connected +$n$-vertex graph has cop number $O(\sqrt n)$. The current best universal +upper bound remains +\[ + \frac{n}{2^{(1-o(1))\sqrt{\log_2 n}}}, +\] +proved independently by Lu--Peng and Scott--Sudakov +\cite{LuPeng,ScottSudakov}. Bose--Esperet--Hodor--Joret--Micek--Rambaud +recently extended the same scale of bound from graph order to vertex-cover +number \cite{CurrentFrontier}. Expansion is one of the principal settings in +which polynomial savings are known: Bradshaw--Hosseini--Mohar--Stacho obtain +weak Meyniel bounds from bounded-degree expansion restricted to sublinear set +scales \cite{BHMS}, while Clow's withdrawn preprint developed a closely +related structural program connecting failure of weak Meyniel to high-cop +expanding examples \cite{Clow}. + +The motivation here is the effect of bounded-degree replacement gadgets on +pursuit. The degree-reduction construction of Hosseini--Mohar--Gonzalez +Hermosillo de la Maza (HMGHM) preserves lower bounds on cop number and +produces subcubic graphs with cop number $M^{1/2-o(1)}$ \cite{HMGHM}. A +natural converse question is whether a useful upper strategy on the base +graph survives the replacement tower. + +An arbitrary winning strategy does not lift transparently. Moving one step +in the quotient may require a squad dispersed through a cloud to reorganize +while the robber continues moving. The successful object is narrower and +more stable: an \emph{occupation certificate} that assigns distinct cops to +all vertices of a region before the robber can leave it. Distance stretching +slows both deployment and escape, and a bounded normalized additive error +leaves a strict timing margin. + +The first result is therefore stated for an abstract projection, rather than +for the HMGHM gadget. The gadget enters only later, through an exact metric +calculation. The resulting upper bound is stronger quantitatively than the +notation $M^{1/2+o(1)}$ suggests: it is $\sqrt M$ times a polylogarithmic +factor. By contrast, the available lower bound approaches the square-root +exponent at the triple-logarithmic rate displayed in the abstract. These two +facts should not be conflated merely because both can be written +$M^{1/2+o(1)}$. + +The final result marks the boundary of the mechanism. A one-shot occupation +certificate needs polynomial ball amplification between radii $R$ and $2R$. +Polynomially weak expansion alone does not supply this. For every fixed +$k$, the Cartesian tori $C_L^{\square k}$ have bounded metric doubling, exact +cop number $k+1$, and linear one-shot occupation cost at every radius. Taking +$k>1/\delta$ puts these examples inside every window +$h(G)\ge |G|^{-\delta}$. A separate cubic replacement retains the barrier at +$\delta=1/2$. The obstacle in the universal problem is therefore not degree +reduction itself; it is the need for adaptive reuse over many weak-growth +layers. + +\section{Scale-adaptive cores and the robustness window} + +For a connected graph $J$, write +\[ + h(J)=\min_{\varnothing\ne A\subseteq V(J),\ |A|\le |J|/2} + \frac{|\partial_J A|}{|A|}. +\] +The following elementary reduction explains why polynomially weak expansion +is the relevant robustness window for the universal problem. + +\begin{proposition}[Scale-adaptive induced core]\label{prop:adaptive-core} +Let $J$ be a connected graph of order $n$, and let +$\eta(1)\ge\cdots\ge\eta(n)\ge0$. Then $J$ contains a connected induced +subgraph $K$, of order $m$, such that +\[ + \boxed{h(K)\ge\eta(m)} + \qquad\text{and}\qquad + \boxed{\cnum(J)\le \cnum(K)+\sum_{j=m+1}^{n}\eta(j).} +\] +\end{proposition} + +\begin{proof} +Maintain the connected induced region $J_i$ containing the robber. Whenever +$|J_i|=m_i$ and $h(J_i)<\eta(m_i)$, choose +$A_i\subseteq V(J_i)$ with $0<|A_i|\le m_i/2$ and +$|\partial_{J_i}A_i|<\eta(m_i)|A_i|$, and occupy its boundary. The robber is +then confined to one component $J_{i+1}$ of +$J_i-\partial_{J_i}A_i$. Whether that component lies inside $A_i$ or outside +it, one has +\[ + |A_i|\le m_i-m_{i+1}. +\] +Consequently the separator costs at most +\[ + \eta(m_i)(m_i-m_{i+1}) + \le + \sum_{j=m_{i+1}+1}^{m_i}\eta(j). +\] +These integer intervals are disjoint along the robber's nested component +chain. The process terminates at a connected induced $K$ with +$h(K)\ge\eta(|K|)$, and the separator costs telescope to the displayed sum. +\end{proof} + +Taking $\eta(j)=j^{-a}$ shows that a polynomial cop-number saving on +subcubic graphs with $h(K)\ge |K|^{-a}$ would imply a weak form of Meyniel for +arbitrary graphs after the bounded-degree transfer of +Hosseini--Mohar--Gonzalez Hermosillo de la Maza +\cite[Corollary~8]{HMGHM}. Bradshaw--Hosseini--Mohar--Stacho already treat +constant expansion restricted to sublinear set scales \cite{BHMS}; the +unresolved axis in this reduction is expansion that itself shrinks +polynomially. + +\section{Coarse occupation projections} + +\begin{definition}[Coarse occupation projection]\label{def:projection} +Let $G$ and $H$ be connected graphs. A surjection +$\pi:V(H)\to V(G)$ is a $(\lambda,P)$-occupation projection if, writing +$F_v=\pi^{-1}(v)$, +\begin{enumerate}[label=\textup{(\roman*)}] +\item $|F_v|\le P$ for every $v\in V(G)$; +\item for distinct $u,v\in V(G)$ and arbitrary $x\in F_u$, $y\in F_v$, +\[ + \lambda(\dist_G(u,v)-1)+1 + \le + \dist_H(x,y) + \le + \lambda(\dist_G(u,v)+2)-2; +\] +\item $\diam_H(F_v)\le2(\lambda-1)$ for every $v\in V(G)$. +\end{enumerate} +\end{definition} + +The particular constants in Definition~\ref{def:projection} are chosen because they are +exact for the HMGHM tower. The proof below only needs a bounded additive +slack after division by $\lambda$ and a strict gap between deployment and +escape deadlines. + +For $U\subseteq V(G)$, let $B_G(U,r)$ be its closed radius-$r$ +neighborhood. + +\begin{theorem}[Abstract macro-ball occupation transfer]\label{thm:abstract-transfer} +Let $G$ have $N$ vertices. Fix $d\ge2$, $R\ge2$, and constants $a,A_0>0$. +Assume +\begin{equation}\label{eq:scale} + \sqrt N\le d^{R-2}\frac{\sqrt N}{d}\ge N^{1/3}, +\] +so the polynomial prefactors are negligible. + +Hall's condition therefore holds simultaneously for all macro-balls with +probability $1-o(1)$. The total number of sampled tokens is at most +$2AP\Theta\sqrt N$ with probability $1-o(1)$, so a deterministic placement of +the claimed size exists. + +It remains to compare deadlines. A target $y\in X_v$ lies over some +$u\in B_G(v,R)$. Its assigned cop begins over $z$ with +$\dist_G(z,u)\le R-2$. If $z\ne u$, the upper distortion bound gives travel +time at most +\[ + \lambda((R-2)+2)-2=\lambda R-2. +\] +If $z=u$, the fiber-diameter bound gives at most +$2(\lambda-1)\le\lambda R-2$, since $R\ge2$. + +To leave $X_v$, the robber must enter a fiber over a base vertex at distance +at least $R+1$ from $v$. The lower distortion bound makes this require at +least +\[ + \lambda((R+1)-1)+1=\lambda R+1 +\] +steps. Every vertex of $X_v$ is occupied first, and the cop assigned to the +robber's current vertex captures her. +\end{proof} + +\begin{remark}[The quantifier needed from the random base]\label{rem:PW-uniform} +The use of \eqref{eq:lowergrowth} is graph-uniform, not a per-source-set +probability statement. In the dense theorem of Pra{\l}at and Wormald, +condition~(i) of their deterministic Theorem~3.1 is explicitly quantified over +\emph{every} source set and radius. Their Theorem~3.4 proves that a single +$G(N,p)$ satisfies those hypotheses asymptotically almost surely; its proof +unions over the bad source sets and concludes that the growth estimate holds +simultaneously for all sets and radii \cite[Theorems~3.1 and~3.4]{PW}. +Thus the external input has the quantifier order required by +\cref{thm:abstract-transfer}. +\end{remark} + +\section{The HMGHM replacement tower} + +For a vertex of degree $r$, the HMGHM replacement has one external port for +every incident edge. The ports are partitioned into nearly equal classes, +and for each pair of classes there is an internal vertex adjacent to every +port in the two classes \cite[Section~2]{HMGHM}. + +\begin{lemma}[One-round port geometry]\label{lem:portgeometry} +For every HMGHM replacement cloud of degree at least two: +\begin{enumerate}[label=\textup{(\roman*)}] +\item distinct ports are nonadjacent and have distance exactly two; +\item every cloud vertex is within distance at most three of every specified +port; +\item the cloud diameter is at most four. +\end{enumerate} +\end{lemma} + +\begin{proof} +Two ports in different classes share the internal vertex associated with +their class pair. Two ports in the same class share any internal vertex +associated with that class and another nonempty class. Since ports are +mutually nonadjacent, their distance is exactly two. + +An internal vertex is adjacent to every port in either of two classes. If a +specified port lies in neither class, travel to a port in one of the two +classes, then through the internal vertex corresponding to that class and the +specified port's class, and finally to the specified port. This takes three +steps. The diameter bound follows by routing arbitrary endpoints through a +specified port. +\end{proof} + +Let +\[ + G=G_0,G_1,\ldots,G_k=H +\] +be an iterated HMGHM tower, and let $\pi:V(H)\to V(G)$ map every final vertex +to its original ancestor. Put +\[ + F_v=\pi^{-1}(v), + \qquad + P=\max_v|F_v|, + \qquad + \lambda=3^k. +\] + +\begin{theorem}[Exact normalized distortion]\label{thm:metric} +The ancestry projection is a $(3^k,P)$-occupation projection. Explicitly, +for distinct base vertices $u,v$, $r=\dist_G(u,v)$, and arbitrary +$x\in F_u$, $y\in F_v$, +\[ + \boxed{ + 3^k(r-1)+1 + \le + \dist_H(x,y) + \le + 3^k(r+2)-2, + } +\] +and +\[ + \boxed{\diam_H(F_v)\le2(3^k-1).} +\] +\end{theorem} + +\begin{proof} +For one round, a shortest path between distinct clouds uses $e\ge r$ +external edges. Between consecutive external edges it enters and leaves an +intermediate cloud through distinct ports: otherwise it immediately traverses +one external edge back. By Lemma~\ref{lem:portgeometry}, each intermediate port +change costs at least two internal edges, and hence +\[ + \dist_{G_1}(x,y)\ge e+2(e-1)\ge3r-2. +\] +For the upper bound, follow a base geodesic. Reaching the first prescribed +port costs at most three, each intermediate port change costs two, the +external edges cost $r$, and reaching the final endpoint costs at most three. +Thus +\[ + \dist_{G_1}(x,y)\le3+r+2(r-1)+3=3r+4. +\] +The one-round fiber diameter is at most four. + +The lower and upper affine recurrences are +\[ + L_j(r)=3L_{j-1}(r)-2, + \qquad + U_j(r)=3U_{j-1}(r)+4, +\] +with $L_0(r)=U_0(r)=r$. Solving gives +\[ + L_k(r)=3^k(r-1)+1, + \qquad + U_k(r)=3^k(r+2)-2. +\] +The diameter recurrence $D_j\le3D_{j-1}+4$, $D_0=0$, gives +$D_k\le2(3^k-1)$. +\end{proof} + +The feature that matters is not the number of rounds but the normalized +additive error: after division by $3^k$, it remains two quotient layers. By +\cref{thm:abstract-transfer}, any other graph projection with the same three +properties inherits the same occupation-certificate transfer. + +\section{Expansion retention under port-cloud replacement} + +\begin{proposition}[Expansion under connected port replacement]\label{prop:port-exp} +Let $H$ be obtained from a base graph $G$ by replacing every vertex by a +connected cloud of order at most $L_0$, with distinct external ports for the +incident base edges. If $\iotae(G)$ is the edge-isoperimetric constant of +$G$, then +\[ + \boxed{ + \iotae(H) + \ge + \frac{1}{2L_0} + \min\left\{1,\frac{\iotae(G)}{L_0}\right\}. + } +\] +\end{proposition} + +\begin{proof} +Let $S\subseteq V(H)$ with $0<|S|\le|H|/2$. In each cloud, classify the +majority side and let $\mathcal M$ be the total number of minority vertices. Since +every partially cut cloud is connected and has at most $L_0$ vertices, its +internal cut contributes at least one edge, so the total internal contribution +is at least $\mathcal M/L_0$. + +Let $U$ be the set of base vertices whose clouds have majority in $S$, and +put $E=e_G(U,V(G)\setminus U)$. A base cut edge can fail to cross the lifted +cut only if one of its two ports is a minority vertex. Distinct base edges +use distinct ports, so at most $\mathcal M$ of the $E$ external cut edges fail. Hence +\[ + e_H(S,V(H)\setminus S) + \ge + \frac{\mathcal M}{L_0}+\max\{0,E-\mathcal M\} + \ge + \frac{E+\mathcal M}{2L_0}. +\] +Apply the same majority accounting to $S$ or its complement, according as +$|U|\le|G|/2$ or not, to obtain +\[ + \min\{|U|,|V(G)\setminus U|\} + \ge + \frac{|S|-\mathcal M}{L_0}. +\] +Thus +\[ + E\ge\frac{\iotae(G)}{L_0}(|S|-\mathcal M), +\] +and consequently +\[ + E+\mathcal M + \ge + \min\left\{1,\frac{\iotae(G)}{L_0}\right\}|S|. +\] +Combining the inequalities proves the proposition. +\end{proof} + +The HMGHM cloud-size recurrence turns the one-round estimate into a +subpolynomial-loss statement in the polylogarithmic-degree regime. If $D$ +is the initial maximum degree, $L_i$ is the largest cloud order in round $i$, +and $k$ rounds are used, HMGHM prove +\[ + \prod_{i=0}^{k-1}L_i + \le C D^2(\log D)^{\log_2(11/5)}, + \qquad + 2^k=O(\log D). +\] +Iterating Proposition~\ref{prop:port-exp} therefore gives the following. + +\begin{corollary}[Expansion retained by HMGHM reduction]\label{cor:exp-retention} +Let $H$ be the final subcubic graph obtained from a connected graph $G$ of +maximum degree $D\ge4$. Then +\[ + \boxed{ + h(H) + \ge + \frac{\iotae(G)}{C D^4(\log D)^{\kappa}}, + \qquad + \kappa=1+2\log_2(11/5)<3.28. + } +\] +In particular, if $D=|G|^{o(1)}$ and $\iotae(G)=|G|^{-o(1)}$, then +$|H|=|G|^{1+o(1)}$ and $h(H)=|H|^{-o(1)}$. +\end{corollary} + +\begin{proof} +At every round $\iotae(G_i)\le D_i\le L_i$, so +Proposition~\ref{prop:port-exp} gives +\[ + \iotae(G_{i+1})\ge\frac{\iotae(G_i)}{2L_i^2}. +\] +Thus +\[ + \iotae(H) + \ge + \frac{\iotae(G)}{2^k(\prod_iL_i)^2} + \ge + \frac{\iotae(G)}{C D^4(\log D)^\kappa}. +\] +Since $H$ has maximum degree at most three, its vertex expansion is at least +one third of its edge expansion. The order statement follows from the same +cloud-product bound. +\end{proof} + +\begin{remark}[Relation to replacement products] +The regular replacement-product literature proves stronger spectral +conclusions under much stronger hypotheses on the clouds; see, for example, +Reingold--Vadhan--Wigderson \cite{RVW}. Proposition~\ref{prop:port-exp} allows arbitrary +connected, nonuniform clouds and consequently gives only a crude +isoperimetric estimate. No novelty claim is made here beyond this precise +form without a fuller graph-substitution review. +\end{remark} + +\section{A polylogarithmically tight square-root family} + +Take +\[ + d=(\log N)^4, + \qquad + p=\frac{d}{N-1}, + \qquad + G\sim G(N,p). +\] +Iterate the HMGHM replacement until the graph $H$ is subcubic, and write +$M=|H|$. + +With high probability, $\Delta(G)\le2d$. By +Remark~\ref{rem:PW-uniform}, the dense Pra{\l}at--Wormald theorem supplies the +uniform lower growth in \eqref{eq:lowergrowth}; in the volume range used here +it also supplies the upper growth in \eqref{eq:uppergrowth} \cite{PW}. Choose +$R$ minimally so that $d^{R-2}\ge\sqrt N$. Since $d$ is polylogarithmic, +\eqref{eq:scale} holds. + +HMGHM give, both globally and along one ancestry fiber, +\begin{equation}\label{eq:Pbound} + P\le C d^2(\log d)^{1.14}, + \qquad + N\le M\le PN. +\end{equation} +Their shadow strategy gives $\cnum(H)\ge\cnum(G)$, and the random-graph lower +bound of Bollob\'as--Kun--Leader used in their argument yields +\[ + \cnum(G) + \ge + d^{-2}N^{\frac12-\frac{9}{2\log\log d}}. +\] +The abstract transfer theorem gives +\[ + \cnum(H) + \le + CP\bigl(d^3+\log(ePN)\bigr)\sqrt N + \le + \sqrt M\,(\log M)^{20+o(1)}. +\] +Using $d=(\log N)^4$ and $M=N(\log N)^{O(1)}$ in the lower bound gives the +following more informative formulation. + +\begin{theorem}[Quantitative HMGHM hard family]\label{thm:hardfamily} +There is a sequence of connected subcubic graphs $H$, of order $M\to\infty$, +for which +\[ + \boxed{ + M^{\frac12-\frac{9+o(1)}{2\log\log\log M}} + \le + \cnum(H) + \le + \sqrt M\,(\log M)^{20+o(1)}. + } +\] +The upper bound is $\sqrt M$ times a polylogarithmic factor. The lower +exponent tends to $1/2$ only at a triple-logarithmic rate. +\end{theorem} + +The base edge expansion is $\Omega(d)$ with high probability. Since +$d=\operatorname{polylog}N$, Corollary~\ref{cor:exp-retention} gives +\[ + h(H)\ge (\log M)^{-O(1)}=M^{-o(1)}. +\] +This is exactly the degree regime in which the retention factor is +informative; for polynomial initial degree the crude $D^4$ loss can be +vacuous. + +\begin{corollary}[Square-root weak-expander family]\label{cor:weakexpander} +There are connected subcubic graphs satisfying +\[ + h(H)\ge M^{-o(1)} + \qquad\text{and}\qquad + \cnum(H)=M^{1/2+o(1)}. +\] +More precisely, they obey the two-sided bounds of +\cref{thm:hardfamily}. +\end{corollary} + +\begin{remark}[What is forced, and what is achieved]\label{rem:robustness-endpoint} +By \cref{prop:adaptive-core}, polynomially weak expansion is a natural +robustness window for weak Meyniel. The HMGHM lower bound alone already +forces every proposed estimate +\[ + \cnum(J)\le C\phi^{-p}|J|^{1-\eps+o(1)} + \qquad(h(J)\ge\phi=|J|^{-o(1)}) +\] +to have $\eps\le1/2$. That restriction predates the upper transfer proved +here. The new conclusion is that the known stressing family itself achieves +the endpoint order $\sqrt M$ up to polylogarithmic factors, so this family is +not an obstruction to a Meyniel-strength theorem on polynomially weak +subcubic expanders. +\end{remark} + +\section{Why chase strategies need not transfer} + +The abstract theorem deliberately transfers a strategy class, not arbitrary +cop number. The smallest example explains the distinction. For a degree-two +vertex, one HMGHM cloud is a three-vertex path. Replacing every vertex of +$C_3$ therefore produces $C_9$. But +\[ + \cnum(C_3)=1, + \qquad + \cnum(C_9)=2. +\] +The one-cop win on $C_3$ is a direct chase/dismantling phenomenon. The +subdivision-like stretching destroys it. By contrast, an occupation +certificate is synchronized to a deadline: the replacement tower stretches +the cops' travel and the robber's escape by the same factor, and the bounded +normalized additive slack preserves a strict margin. The examples +$K_4$ and the diamond graph exhibit the same one-round increase, so the issue +is structural rather than peculiar to one cycle. + +\section{A one-shot occupation barrier} + +\begin{definition}[Universal one-shot occupation number]\label{def:occ} +For a connected graph $G$ and integer $R\ge0$, let $\Occ_R(G)$ be the minimum +size of a finite set $X$ of distinct cop tokens, equipped with a position map +$p:X\to V(G)$, such that for every $v\in V(G)$ there is an injection +\[ + f_v:B_G(v,R)\longrightarrow X +\] +with +\[ + \dist_G(u,p(f_v(u)))\le R + \qquad\text{for every }u\in B_G(v,R). +\] +Different tokens may have the same initial position. After learning the +robber's starting vertex, the common prepositioned bank can occupy her entire +radius-$R$ ball within $R$ moves. +\end{definition} + +\begin{remark}[Why the target and deadline are natural] +If the robber starts at $v$, she needs at least $R+1$ robber moves to leave +$B_G(v,R)$. Occupying that whole ball within $R$ cop moves is therefore the +canonical one-shot certificate: every vertex she could still occupy is filled +before her first possible escape. The parameter $\Occ_R$ measures this +specific strategy class, not ordinary cop number. +\end{remark} + +\begin{theorem}[Counting barrier]\label{thm:occ-lower} +Every connected graph satisfies +\[ + \boxed{ + \Occ_R(G) + \ge + \frac{\sum_{v\in V(G)}|B_G(v,R)|} + {\max_{x\in V(G)}|B_G(x,2R)|}. + } +\] +In particular, if $G$ is vertex-transitive, then +\[ + \boxed{ + \Occ_R(G) + \ge + |V(G)|\frac{|B_G(o,R)|}{|B_G(o,2R)|}. + } +\] +\end{theorem} + +\begin{proof} +Fix a feasible multiset $X$. For each possible robber start $v$, every cop +token used by the injection $f_v$ lies in $B_G(v,2R)$, by the triangle +inequality. Hence at least $|B_G(v,R)|$ tokens of $X$ lie in $B_G(v,2R)$. +Summing over $v$, the number of incident pairs $(v,x)$ with +$x\in X\cap B_G(v,2R)$ is at least +$\sum_v|B_G(v,R)|$. + +A fixed token based at $x$ is counted only for starts +$v\in B_G(x,2R)$, at most $\max_y|B_G(y,2R)|$ times. Therefore +\[ + |X|\max_y|B_G(y,2R)| + \ge + \sum_v|B_G(v,R)|, +\] +which proves the claim. +\end{proof} + +The theorem identifies the exact growth ratio demanded by one-shot +occupation. A polynomial saving from the trivial $|V(G)|$ bound requires +polynomial amplification from radius $R$ to radius $2R$. + +We now give subcubic witnesses showing that polynomially weak expansion does +not imply such amplification. + +\begin{definition}[The cubic truncated torus]\label{def:Qt} +For $L\ge5$, let $Q_L$ have vertex set +\[ + (\mathbb Z/L\mathbb Z)^2\times\mathbb Z/4\mathbb Z. +\] +Inside each fiber $(x,y)\times\mathbb Z/4\mathbb Z$, join the four vertices in +a cycle. Add the external edges +\[ + (x,y,0)(x,y+1,2) + \qquad\text{and}\qquad + (x,y,1)(x+1,y,3) +\] +for every $(x,y)$. Equivalently, $(x,y,2)$ receives its external edge from +$(x,y-1,0)$, and $(x,y,3)$ receives its external edge from $(x-1,y,1)$. +Thus every vertex has two internal cycle neighbors and one external neighbor, +and $Q_L$ is the four-cycle port replacement of the square torus +$C_L\square C_L$. +\end{definition} + +Every vertex of $Q_L$ has degree three, and the construction embeds on the +torus by replacing each base vertex inside a small disk. Its order is +$4L^2$. + + + +\begin{lemma}[Vertex transitivity and explicit doubling of $Q_L$]\label{lem:doubling} +The graph $Q_L$ is vertex-transitive and, for every vertex $x$ and radius +$R\ge0$, +\[ + |B_{Q_L}(x,2R)|\le 5500\,|B_{Q_L}(x,R)|. +\] +\end{lemma} + +\begin{proof} +Translations in the first two coordinates are automorphisms. The map +\[ + \rho(x,y,i)=(y,-x,i+1) +\] +(with coordinates interpreted cyclically) preserves internal cycle edges and +interchanges the two external edge directions. Translations together with +$\rho$ act transitively. + +Let $T_L=C_L\square C_L$ and project $(x,y,i)$ to $(x,y)$. Projection does +not increase distance, so +\[ + |B_{Q_L}(x,2R)|\le4|B_{T_L}(\pi x,2R)| + \le4\min\{L,4R+1\}^2. +\] +Conversely, from an arbitrary cloud vertex one can enter the required port in +at most two internal moves and then lift each base step using at most three +moves. Hence, with $r=\lfloor(R-2)/3\rfloor$ for $R\ge2$, +\[ + |B_{Q_L}(x,R)|\ge |B_{T_L}(\pi x,r)|. +\] +The coordinate box of cyclic radius $\lfloor r/2\rfloor$ lies inside the +$\ell_1$ ball, so +\[ + |B_{T_L}(\pi x,r)| + \ge \min\{L,2\lfloor r/2\rfloor+1\}^2. +\] +For $R<10$, the upper bound is at most $4\cdot37^2<5500$ and the denominator +is at least one. For $R\ge10$, one has $r\ge R/6$ and +$2\lfloor r/2\rfloor+1\ge r$, whence the ratio is at most +$4\cdot30^2<5500$. This proves the displayed constant. +\end{proof} + +\begin{theorem}[Cubic one-shot barrier]\label{thm:cubic-barrier} +For $L\ge5$, the connected cubic graphs $Q_L$, with $M=|Q_L|=4L^2$, satisfy +\[ + \boxed{ + h(Q_L)=\Theta(M^{-1/2}), + \qquad + \cnum(Q_L)\le3, + \qquad + \Occ_R(Q_L)\ge \frac{M}{5500} + \quad\text{for every }R\ge0. + } +\] +\end{theorem} + +\begin{proof} +The square torus has edge and vertex expansion $\Theta(1/L)$ by the discrete +torus isoperimetric inequality \cite{BL}. Applying +Proposition~\ref{prop:port-exp} with cloud size four gives the matching lower +bound for $Q_L$; lifting a coordinate slab of width $\lfloor L/2\rfloor$ +gives the upper bound for every $L$. Since $Q_L$ is cubic, edge and vertex +expansion differ by at most a constant factor. +Thus $h(Q_L)=\Theta(1/L)=\Theta(M^{-1/2})$. + +The graph $Q_L$ is toroidal, and every toroidal graph has cop number at most +three \cite{Lehner}. Vertex transitivity, Theorem~\ref{thm:occ-lower}, and +Lemma~\ref{lem:doubling} give +\[ + \Occ_R(Q_L) + \ge + M\frac{|B_{Q_L}(x,R)|}{|B_{Q_L}(x,2R)|} + \ge \frac{M}{5500}. +\] +The finite audit suggests that the optimal asymptotic constant is $1/4$, but +that sharpening is not needed here. +\end{proof} + +\subsection{The barrier throughout every polynomial expansion window} + +For integers $k\ge2$ and $L\ge4$, write +\[ + T_{L,k}=\underbrace{C_L\square\cdots\square C_L}_{k\text{ factors}}. +\] +Its order is $m=L^k$, its degree is $2k$, and its metric is the cyclic +$\ell_1$ metric. + +\begin{lemma}[Uniform doubling of Cartesian tori]\label{lem:ktorus-doubling} +For every fixed $k\ge2$, every $L\ge4$, every vertex $x$, and every radius +$R\ge0$, +\[ + |B_{T_{L,k}}(x,2R)|\le (5k)^k|B_{T_{L,k}}(x,R)|. +\] +\end{lemma} + +\begin{proof} +Every coordinate of a point in $B(x,2R)$ has cyclic distance at most $2R$, so +\[ + |B(x,2R)|\le \min\{L,4R+1\}^k. +\] +The coordinate box in which every coordinate has cyclic distance at most +$\lfloor R/k\rfloor$ lies in $B(x,R)$, and therefore +\[ + |B(x,R)|\ge\min\{L,2\lfloor R/k\rfloor+1\}^k. +\] +If $R0$ there is a constant-degree graph family +with +\[ + h(G)\ge |G|^{-\delta}, + \qquad + c(G)=O_\delta(1), + \qquad + \Occ_R(G)=\Omega_\delta(|G|) + \quad\text{for every radius }R. +\] +\end{theorem} + +\begin{proof} +The discrete-torus edge-isoperimetric inequality gives order $1/L$ +\cite{BL}. Since $T_{L,k}$ has degree $2k$, edge boundary and external +vertex boundary differ by at most the fixed factor $2k$; a coordinate slab of +width $\lfloor L/2\rfloor$ supplies the matching upper bound. Hence +$h(T_{L,k})=\Theta_k(1/L)=\Theta_k(m^{-1/k})$. Neufeld and Nowakowski proved +that a Cartesian product of $k$ cycles, each of length at least four, has cop +number exactly $k+1$ \cite{NeufeldNowakowski}. Since the torus is +vertex-transitive, Theorem~\ref{thm:occ-lower} and +Lemma~\ref{lem:ktorus-doubling} give the occupation lower bound. + +Given $\delta>0$, choose $k=\max\{2,\lfloor1/\delta\rfloor+1\}$. Then $1/k<\delta$, so for sufficiently large $m$ the expansion +lower bound $h(T_{L,k})\ge m^{-\delta}$ holds after absorbing the fixed +$k$-dependent constant. +\end{proof} + +\begin{remark}[The sharp metric constant] +For fixed $k$, choose radii $1\ll R\ll L$. Lattice-point asymptotics for the +$\ell_1$ ball give +\[ + \frac{|B_{T_{L,k}}(x,2R)|}{|B_{T_{L,k}}(x,R)|}=2^k+o(1). +\] +Thus no uniform doubling constant below $2^k$ is possible, and the counting +bound of \cref{thm:occ-lower} approaches the natural fraction $2^{-k}m$ on +these local radii. The explicit constant $(5k)^{-k}$ is chosen only for a +short all-radii proof. +\end{remark} + +\begin{corollary}[No expansion-only one-shot theorem]\label{cor:no-exp-occ} +For every $\delta>0$, there is no implication of the form +\[ + h(G)\ge |G|^{-\delta} + \quad\Longrightarrow\quad + \Occ_R(G)\le |G|^{1-\eps} + \text{ for some radius $R$} +\] +with any fixed $\eps>0$, even when the maximum degree is bounded by a constant +depending only on $\delta$. +\end{corollary} + +\begin{proof} +Take the family from \cref{thm:full-window}. It lies in the prescribed +expansion window, while $\Occ_R(G)=\Omega_\delta(|G|)$ for every $R$. +\end{proof} + +\begin{remark}[Architectural meaning] +The logical obstruction is not that tori are difficult pursuit instances; +they are not. Rather, \cref{thm:occ-lower} makes every vertex-transitive +bounded-doubling graph expensive for one-shot occupation, and bounded +doubling is compatible with every polynomial weak-expansion window by +\cref{thm:full-window}. The tori +certify that the hypothesis class contains such graphs while coordinate-wise +shadowing still uses exactly $k+1$ cops. Therefore ball amplification is the +wrong invariant for adaptive pursuit, not merely a poor description of one +particular easy family. The cubic family $Q_L$ records that the same +separation already occurs in maximum degree three at exponent $1/2$. +\end{remark} + +\section{Outlook} + +For vertex expansion $h(G)\ge\phi$, iterating the elementary growth factor +$1+\phi$ reaches global scale after $O(\phi^{-1}\log |G|)$ layers. The same +iteration gives the standard diameter bound of that order; these are two +forms of the same calculation, not independent evidence. The strategic +consequence is that, when $\phi=|G|^{-a}$, an amplification-based pursuit +scheme must operate over a full-traversal timescale $\Theta(|G|^a\log|G|)$. + +The present paper separates three phenomena: +\begin{enumerate}[label=\textup{(\arabic*)}] +\item bounded normalized metric distortion preserves a strong occupation +certificate through degree reduction; +\item the HMGHM stressing family itself meets the square-root endpoint up to +polylogarithmic factors; +\item one-shot occupation is nevertheless incapable of proving a universal +robustness theorem throughout any polynomial weak-expansion window, even on +bounded-degree graphs with constant cop number; a cubic instance already +appears at exponent $1/2$. +\end{enumerate} +The remaining universal question is therefore an adaptive one. On the tori, +ball growth carries essentially no information about pursuit cost; product +structure instead supports coordinate-wise shadowing. What geometric or +combinatorial quantity replaces product coordinates on a general +polynomially weak expander? Equivalently, can a capacitated, correlated, or +deferred witness system reuse the same cop resources over polynomially many +weak-growth layers, or must every such one-traversal certificate incur +polynomial congestion? + +\section*{Acknowledgments} +The author is grateful to Anthony Clow, Peter Bradshaw, Bojan Mohar, and +Florian Lehner for work and perspectives that helped shape the questions +addressed here. Additional acknowledgments will be added in a later version. +The author welcomes corrections concerning priority, related +graph-substitution inequalities, and the scope of the occupation framework. + +\section*{Audit and reproducibility} + +The metric inequalities were independently tested on HMGHM towers rebuilt +from the published gadget description, including structured base graphs not +used in the original audit. The Hall inequalities and timing margins were +checked numerically, and exact small replacement games were solved by +retrograde analysis. The toroidal barrier audit computes exact ball profiles of +$C_L^{\square k}$ by convolving cyclic distance distributions, verifies the +$(5k)^k$ doubling bound for $k=2,3,4,5$, constructs $Q_L$, checks cubicity, +connectivity, and the displayed rotation automorphism, and evaluates the +counting lower bound at every radius. No theorem depends on the +computations. + +\begin{thebibliography}{99} + +\bibitem{AignerFromme} +M.~Aigner and M.~Fromme, +\emph{A game of cops and robbers}, +Discrete Applied Mathematics 8 (1984), 1--12. + +\bibitem{CurrentFrontier} +P.~Bose, L.~Esperet, J.~Hodor, G.~Joret, P.~Micek, and C.~Rambaud, +\emph{Cops and robber in graphs with bounded vertex cover number}, +arXiv:2602.07435, 2026. + +\bibitem{BHMS} +P.~Bradshaw, S.~A. Hosseini, B.~Mohar, and L.~Stacho, +\emph{On the cop number of graphs of high girth}, +Journal of Graph Theory 102 (2023), 15--34; arXiv:2005.10849. + +\bibitem{BKL} +B.~Bollob\'as, G.~Kun, and I.~Leader, +\emph{Cops and robbers in a random graph}, +Journal of Combinatorial Theory, Series B 103 (2013), 226--236. + +\bibitem{BL} +B.~Bollob\'as and I.~Leader, +\emph{An isoperimetric inequality on the discrete torus}, +SIAM Journal on Discrete Mathematics 3 (1990), 32--37. + +\bibitem{Clow} +A.~Clow, +\emph{Expanders satisfy the weak Meyniel conjecture}, +withdrawn preprint, arXiv:2311.13792, 2023. + +\bibitem{HMGHM} +S.~A. Hosseini, B.~Mohar, and S.~Gonzalez Hermosillo de la Maza, +\emph{Meyniel's conjecture on graphs of bounded degree}, +Journal of Graph Theory 97 (2021), 401--407; arXiv:1912.06957. + +\bibitem{Lehner} +F.~Lehner, +\emph{On the cop number of toroidal graphs}, +Journal of Combinatorial Theory, Series B 151 (2021), 250--262. + +\bibitem{LuPeng} +L.~Lu and X.~Peng, +\emph{On Meyniel's conjecture of the cop number}, +Journal of Graph Theory 71 (2012), 192--205. + +\bibitem{NeufeldNowakowski} +S.~Neufeld and R.~Nowakowski, +\emph{A game of cops and robbers played on products of graphs}, +Discrete Mathematics 186 (1998), 253--268. + +\bibitem{PW} +P.~Pra{\l}at and N.~Wormald, +\emph{Meyniel's conjecture holds for random graphs}, +Random Structures \& Algorithms 48 (2016), 396--421; arXiv:1301.2841. + +\bibitem{RVW} +O.~Reingold, S.~Vadhan, and A.~Wigderson, +\emph{Entropy waves, the zig-zag graph product, and new constant-degree +expanders}, +Annals of Mathematics 155 (2002), 157--187. + +\bibitem{ScottSudakov} +A.~Scott and B.~Sudakov, +\emph{A bound for the cops and robbers problem}, +SIAM Journal on Discrete Mathematics 25 (2011), 1438--1442. + +\end{thebibliography} + +\end{document} diff --git a/paper/branch-capture-paper.tex b/paper/branch-capture-paper.tex new file mode 100644 index 0000000..46d3efc --- /dev/null +++ b/paper/branch-capture-paper.tex @@ -0,0 +1,658 @@ +\documentclass[11pt]{amsart} + +\usepackage[T1]{fontenc} +\usepackage{microtype} +\usepackage[margin=1in]{geometry} +\usepackage{amsmath,amssymb,amsthm} +\usepackage{enumitem} +\usepackage{xcolor} +\usepackage{hyperref} + +\hypersetup{ + colorlinks=true, + linkcolor=blue!50!black, + citecolor=blue!50!black, + urlcolor=blue!50!black +} + +\newtheorem{theorem}{Theorem}[section] +\newtheorem{lemma}[theorem]{Lemma} +\newtheorem{proposition}[theorem]{Proposition} +\newtheorem{corollary}[theorem]{Corollary} +\newtheorem{question}[theorem]{Question} + +\theoremstyle{definition} +\newtheorem{definition}[theorem]{Definition} +\newtheorem{remark}[theorem]{Remark} + +\DeclareMathOperator{\dist}{dist} +\DeclareMathOperator{\supp}{supp} + +\title[Branch-Tube Persistence and Static Coverage]{Branch-Tube Persistence and Static Coverage\\in Tree-Ball Geometry} +\author{Levi Neuwirth} +\address{Brown University} +\date{July 21, 2026} + +\begin{document} + +\begin{abstract} +We analyze exhaustive static coverage by path tubes indexed by length-$t$ nonbacktracking robber paths from $v_0$ in a finite-horizon local chase on a $d$-regular graph. An endpoint-sensitive geodesic lemma shows that, among possibly infinite $d$-regular graphs, radius $R+t$ is sharp for arbitrary pairs in $B_R(v_0)\times B_t(v_0)$, while synchronized witnesses along a prescribed path require only a radius-$R$ tree-ball. If every tube is occupied, each surviving round along every path in this class ends in capture, blockage, or branch-load support on at least two branches. The tubes partition the outer ball, so deterministic coverage has minimum cost $N_t=d(d-1)^{t-1}$; conditional on a specified root, i.i.d.\ uniform coverage has threshold $\Theta(N_t\log N_t)$ for fixed $d$. An augmented prefix-depth profile that retains the complete shallow configuration still need not determine later support. The result is local and root-dependent: it treats neither arbitrary robber walks nor a robber-independent cop strategy, and it gives no cop-number bound. +\end{abstract} + +\maketitle + + +\section{Introduction} + +\subsection{Motivation} + +The cops-and-robbers game, introduced independently in its one-cop form by Quilliot and by Nowakowski--Winkler and developed in its multiple-cop form by Aigner--Fromme~\cite{Quilliot,NowakowskiWinkler,AignerFromme}, is a pursuit-evasion game on a graph in which $k$ cops attempt to capture one robber. The minimum such $k$ is the \emph{cop number} $c(G)$. \emph{Meyniel's conjecture}, attributed to Henri Meyniel and appearing in the early literature in Frankl's work~\cite{Frankl}, asserts that $c(G)=O(\sqrt n)$ for every connected graph on $n$ vertices and remains a central open problem in the area. The best general upper bound, +\[ +c(G) \leq \frac{n}{2^{(1-o(1))\sqrt{\log_2 n}}}, +\] +was proved independently by Lu--Peng, Scott--Sudakov, and Frieze--Krivelevich--Loh~\cite{LuPeng,ScottSudakov,FriezeKrivelevichLoh}. + +Pra\l at--Wormald's random-graph argument combines an initial random cop placement with assignments made after the robber's start is revealed: in different regimes, cop teams fully occupy a neighborhood or densely cover a sphere~\cite{PralatWormald}. Tree-like geometry enters the literature in several other ways. Aigner--Fromme show that one moving cop can guard a fixed isometric path~\cite{AignerFromme}. Frankl established a high-girth lower bound~\cite{Frankl}, and Bradshaw--Hosseini--Mohar--Stacho refine that line through a branch-weight argument in which unique local geodesics let the robber choose a sufficiently lightly controlled forward branch~\cite{BradshawHosseiniMoharStacho}. Related bounded-degree reductions provide broader extremal context~\cite{HMG}. These mechanisms motivate a path-conditioned local question: whether cops near the robber can coordinate their distance-decreasing moves along every prescribed length-$t$ nonbacktracking path starting at the robber's initial vertex. + +The present paper instead isolates one rigid, root-dependent certificate: every length-$t$ nonbacktracking path from $v_0$ indexes an occupied descendant tube. Paths here index a static partition of the outer ball rather than a route guarded by one moving cop or an adaptively selected escape branch. We determine this certificate's guarantee and cost. Arbitrary robber walks, including stationary moves and reversals, are not analyzed, and exact tree geometry is not asserted to be necessary. + +The uniform sampling below is conditional on the fixed root $v_0$: the root is specified before cop positions are sampled from $B_R(v_0)$. This measures the local cost of the certificate, not a legal initial placement in the standard game, where cops choose positions before the robber chooses its start. A global strategy would need comparable coverage simultaneously or adaptively for an unknown robber position. Accordingly, the results neither construct a robber-independent global strategy nor improve the cop-number bound. + +\subsection{Setup and endpoint-sensitive tree-ball geodesics} + +Graphs are simple and undirected. The local results allow finite or infinite graphs unless finiteness is stated explicitly; discussion of the cop number and all $n$-vertex asymptotics concerns finite connected graphs. Throughout, $G$ denotes a $d$-regular graph with $d\geq3$, and $v_0\in V(G)$ is the robber's initial position in the rooted local experiment. All radii and time horizons are nonnegative integers, and $t,r\geq1$ whenever length-$t$ or order-$r$ objects are used. All unadorned logarithms are natural. We write +\[ +B_R(v)=\{x\in V(G):\dist(x,v)\leq R\}, +\qquad +S_j(v)=\{x\in V(G):\dist(x,v)=j\}. +\] + +\begin{definition}[Tree-ball] +\label{def:tree-ball} +The ball $B_R(v_0)$ is a \emph{tree-ball} if the induced subgraph $G[B_R(v_0)]$ is a tree. +\end{definition} + +This is a local condition at the specified center. The global condition $\operatorname{girth}(G)>2R+1$ implies that every radius-$R$ ball is a tree-ball. Conversely, if every radius-$R$ ball is a tree-ball, then $\operatorname{girth}(G)>2R+1$. + +An induced tree-ball does not control shortest paths between arbitrary pairs of its vertices: a competing path may leave the ball and re-enter. The required radius depends on the endpoint depths and on the length of their path inside the tree-ball. + +\begin{lemma}[Endpoint-sensitive tree-ball geodesics] +\label{lem:buffer} +Suppose $B_q(v_0)$ is a tree-ball, let $x,y\in B_q(v_0)$, and let $L$ be the length of the $x$--$y$ path in $G[B_q(v_0)]$. If +\[ +\dist(x,v_0)+\dist(y,v_0)+L\leq 2q, +\] +then every ambient $x$--$y$ geodesic lies in $B_q(v_0)$. Consequently, that geodesic is unique and equals the $x$--$y$ path in $G[B_q(v_0)]$. +\end{lemma} + +\begin{proof} +Let $P$ be an ambient $x$--$y$ geodesic of length $\ell$. The path in $G[B_q(v_0)]$ gives $\ell\leq L$. For $z\in P$, write $h=\dist_P(x,z)$. Then +\[ +\dist(z,v_0) +\leq \min\{\dist(x,v_0)+h,\ \dist(y,v_0)+\ell-h\} +\leq \frac{\dist(x,v_0)+\dist(y,v_0)+\ell}{2} +\leq q. +\] +Thus $P\subseteq B_q(v_0)$. Since the induced graph on this ball is a tree, $P$ is its unique $x$--$y$ path. +\end{proof} + +\begin{remark}[Uniform buffers versus chase endpoints] +For arbitrary $x\in B_R(v_0)$ and $y\in B_t(v_0)$, the walk from $x$ to $y$ through $v_0$ has length at most $R+t$. If $B_{R+t}(v_0)$ is a tree-ball, the path between $x$ and $y$ in the rooted tree $G[B_{R+t}(v_0)]$ is no longer than this walk, so Lemma~\ref{lem:buffer} applies. For $R,t\geq1$, this uniform radius cannot in general be reduced. Put $L=R+t$, begin with the rooted $d$-regular tree truncated at depth $L-1$, and choose vertices $x$ and $y$ at depths $R$ and $t$ in distinct root branches. Choose descendants $a$ of $x$ and $b$ of $y$ in $S_{L-1}(v_0)$, allowing equality, add a new vertex $z$, and join $z$ to $a$ and $b$. + +Complete the graph explicitly: for each remaining degree deficit at a vertex of $S_{L-1}(v_0)\cup\{z\}$, attach by one edge a separate infinite rooted $(d-1)$-ary tree. Every vertex then has degree $d$, and the resulting graph is infinite, simple, and connected. The induced ball $B_{L-1}(v_0)$ is still the original tree. The path from $x$ to $y$ through $v_0$ has length $L$, while the path through $z$ has length +\[ +\dist(x,a)+2+\dist(b,y)=(t-1)+2+(R-1)=L. +\] +Each attached infinite tree meets the displayed core at only one vertex, so it creates no additional route between $x$ and $y$. Thus the two displayed paths are distinct geodesics. This proves sharpness at radius $R+t-1$ among possibly infinite $d$-regular graphs. + +The endpoint pairs used along a prescribed length-$t$ nonbacktracking path are more restricted. Their rooted-tree path and endpoint depths satisfy Lemma~\ref{lem:buffer} with $q=R$, so the persistence result requires no tree-ball beyond the cop-placement radius. This does not assert that the radius-$R$ tree-ball hypothesis is necessary. Controlled cyclic geometry is not addressed here, and no cyclic extension is proved. +\end{remark} + +Whenever $B_q(v_0)$ is a tree-ball, we root $G[B_q(v_0)]$ at $v_0$. Every vertex other than $v_0$ has a unique parent, and every vertex of depth less than $q$ has $d-1$ children. + +\begin{definition}[Geodesic cone] +\label{def:cone} +Suppose $B_r(v)$ is a tree-ball and $u\in N(v)$. The \emph{geodesic cone} through $u$ at radius $r$ is +\[ +C_u(v,r):=\{x\in B_r(v)\setminus\{v\}:\text{the path from $x$ to $v$ in $B_r(v)$ has penultimate vertex $u$}\}. +\] +\end{definition} + +\begin{definition}[Path tube] +\label{def:tube} +Let $R\geq t$, suppose $B_R(v_0)$ is a tree-ball, and let +\[ +\sigma=(v_0,v_1,\ldots,v_t) +\] +be a length-$t$ nonbacktracking path starting at $v_0$: consecutive vertices are adjacent and $v_{i+1}\neq v_{i-1}$ whenever the latter condition is defined. The \emph{length-$t$ tube} associated with $\sigma$ is +\[ +T_\sigma(R):=\{x\in B_R(v_0):\text{the rooted path from $x$ to $v_0$ contains $v_t,v_{t-1},\ldots,v_1$ in order}\}. +\] +Equivalently, $T_\sigma(R)$ is the set of descendants of $v_t$ lying in $B_R(v_0)$. +\end{definition} + +\subsection{Game conventions} + +A \emph{cop configuration} $X$ is a finite multiset of vertices, represented by its finitely supported multiplicity function $X(v)\in\mathbb Z_{\geq0}$. For $A\subseteq V(G)$, set +\[ +X(A):=\sum_{v\in A}X(v), +\qquad +|X|:=X(V(G)), +\qquad +\supp(X):=\{v:X(v)>0\}. +\] +Thus counts of cops include multiplicity, several cops may occupy one vertex, and $A$ is occupied exactly when $X(A)>0$. We write $X=[x_1,\ldots,x_k]$ for a multiset of cop positions, with repeated entries allowed. Independent uniform sampling is with replacement and produces the multiset of sampled positions. + +Capture occurs whenever a cop and the robber occupy the same vertex, including before the first cop move; once capture occurs, no further move is made. Conditional on no capture, the cops move first in each round, simultaneously, and each cop moves to a neighboring vertex minimizing its distance to the robber's current vertex. Ties may be broken arbitrarily. Some formulations also allow cops to pass; the results remain valid there by having the selected witnesses make the prescribed distance-decreasing moves. Under the tree-ball hypotheses below, Lemma~\ref{lem:buffer} shows that each witness selected in our proofs has a unique distance-decreasing move. + +We analyze only length-$t$ nonbacktracking robber paths starting at $v_0$. In the full game a robber walk may reverse an edge or, under common conventions, remain stationary. Neither behavior is covered here. Fix a prescribed path +\[ +\tau=(v_0,v_1,\ldots,v_t). +\] +It survives through round $0$ precisely when no initial capture occurs. If it has survived through round $s-1$, then the robber is at $v_{s-1}$ when round $s$ begins. After the cops move toward $v_{s-1}$, the robber is captured if a cop occupies $v_{s-1}$. Otherwise the prescribed move $v_{s-1}\to v_s$ is \emph{legal} if no cop occupies $v_s$ after the cop move. The path survives through round $s$ if no capture or blockage has occurred through that move. + +For a robber position $v$ and a neighbor $u\in N(v)$, an individual cop at $c\neq v$ whose $c$--$v$ geodesic is unique \emph{contributes to branch $u$ at $v$} if that geodesic has penultimate vertex $u$. The \emph{branch-load support} at $v$ is the set of branches receiving at least one contributing cop; its size counts branches, not cops. + +\subsection{Main results and scope} + +Although $R+t$ is the sharp uniform radius for arbitrary endpoint pairs in $B_R(v_0)\times B_t(v_0)$, the synchronized pairs arising along a prescribed length-$t$ nonbacktracking path satisfy the endpoint-sensitive criterion already at radius $R$. Accordingly, the persistence theorem assumes only that the cop-placement ball $B_R(v_0)$ is a tree-ball. + +Exact rooted-tree counts identify first-level cones as the $t=1$ path tubes and show that the length-$t$ tubes partition the outer ball. Conditional on the prescribed path continuing, a witness's initial depth determines a potential capture or blockage time within the horizon or certifies descendant pressure throughout it. This yields a persistence theorem uniform over all length-$t$ nonbacktracking paths starting at $v_0$, and only over that class. + +The next results quantify the price of exhaustive static coverage. Exactly +\[ +N_t=d(d-1)^{t-1} +\] +cops are necessary and sufficient to occupy every tube at a fixed root. Along fixed-degree sequences with $t\to\infty$, conditional uniform sampling has a threshold of order $N_t\log N_t$; its leading factor depends explicitly on $R-t$ and tends to $1$ when $R-t\to\infty$. + +Finally, Section~\ref{sec:profile-loss} records information lost by finite-order tube profiles. Even after retaining every shallow cop multiplicity, such a profile need not determine branch support after $r+1$ rounds. This exact nondeterminacy does not rule out one-sided certificates that reject ambiguous profile fibers. + +\section{Cone and Tube Counts} + +\begin{lemma}[Sharp shell and cone counts] +\label{lem:shell} +Let $G$ be $d$-regular with $d\geq3$, and suppose $B_r(v)$ is a tree-ball. Then for every $u\in N(v)$ and $1\leq j\leq r$, +\[ +|C_u(v,r)\cap S_j(v)|=(d-1)^{j-1}, +\qquad +|S_j(v)|=d(d-1)^{j-1}. +\] +Hence +\[ +|C_u(v,r)|=\sum_{j=1}^r(d-1)^{j-1} +=\frac{(d-1)^r-1}{d-2}, +\] +and +\[ +|B_r(v)|=1+d\frac{(d-1)^r-1}{d-2}. +\] +\end{lemma} + +\begin{proof} +The branch rooted at $u$ is a $(d-1)$-ary rooted tree of depth $r-1$. Its level $j-1$ contains $(d-1)^{j-1}$ vertices, giving the first formula. Summing over the $d$ branches gives the shell count, and the remaining identities follow by summing the geometric series. +\end{proof} + +For $t=1$, geodesic cones are exactly the path tubes. We now count the general length-$t$ objects that drive the persistence argument. + + +\begin{lemma}[Tube count and partition] +\label{lem:tube-partition} +Let $R\geq t$, and suppose $B_R(v_0)$ is a tree-ball. For every length-$t$ nonbacktracking path $\sigma$ starting at $v_0$, +\[ +|T_\sigma(R)| +=\sum_{j=t}^R(d-1)^{j-t} +=\frac{(d-1)^{R-t+1}-1}{d-2}. +\] +The length-$t$ tubes are pairwise disjoint and form a partition +\[ +B_R(v_0)\setminus B_{t-1}(v_0) +=\bigsqcup_{\sigma}T_\sigma(R), +\] +where $\sigma$ ranges over all length-$t$ nonbacktracking paths from $v_0$. Their number is +\[ +N_t=d(d-1)^{t-1}. +\] +\end{lemma} + +\begin{proof} +At total depth $j\geq t$, the descendants of the terminal vertex $v_t$ form the depth-$(j-t)$ level of a $(d-1)$-ary rooted tree and hence contribute $(d-1)^{j-t}$ vertices. Summing gives the tube size. + +Every vertex of depth at least $t$ has a unique rooted prefix of length $t$, so it lies in exactly one tube. The first edge of such a prefix has $d$ choices, and every later edge has $d-1$ choices, giving $N_t=d(d-1)^{t-1}$. +\end{proof} + +For a fixed rooted tree-ball $(G,v_0,R)$, after the root has been specified, let $C$ be uniform on $B_R(v_0)$. The probability that $C$ lies in a prescribed length-$t$ tube is +\[ +q_{t,R}:=\Pr(C\in T_\sigma(R)\mid G,v_0,R) +=\frac{|T_\sigma(R)|}{|B_R(v_0)|}. +\] +The partition gives the exact identity +\begin{equation} +\label{eq:tube-mass} +N_tq_{t,R} +=1-\frac{|B_{t-1}(v_0)|}{|B_R(v_0)|}. +\end{equation} +Put $h=R-t$ and $b=d-1$. Lemmas~\ref{lem:shell} and~\ref{lem:tube-partition} give +\[ +q_{t,R}=\frac{b^{h+1}-1}{(b+1)b^{t+h}-2} +\] +and +\[ +\frac{1}{N_tq_{t,R}} +=\frac{b^{h+1}-2/((b+1)b^{t-1})}{b^{h+1}-1} +=\frac{1}{1-b^{-(h+1)}}+O(b^{-t}), +\] +uniformly for $h\geq0$. Thus $q_{t,R}=\Theta(N_t^{-1})$ uniformly over $R\geq t$, but its leading constant depends on $R-t$ when that difference remains bounded. + +\section{Path-Tube Persistence} + +\subsection{The interception schedule} + +\begin{lemma}[Interception schedule along a tube] +\label{lem:interception} +Let $R\geq t$, suppose $B_R(v_0)$ is a tree-ball, and let +\[ +\sigma=(v_0,v_1,\ldots,v_t) +\] +be a length-$t$ nonbacktracking path starting at $v_0$. Let a cop start at $c\in T_\sigma(R)$ at depth $j=\dist(c,v_0)$. Suppose the robber follows $\sigma$ for as long as the path survives. For every $s\in\{1,\ldots,t\}$ such that the path has survived through round $s-1$, after the cop move in round $s$: +\begin{enumerate}[label=(\roman*)] +\item if $j=2s-1$, the cop reaches $v_{s-1}$ and captures the robber; +\item if $j=2s$, the cop reaches $v_s$ and blocks the intended move; +\item if $j\geq2s+1$, the cop remains a strict descendant of $v_s$. +\end{enumerate} +The alternative $j\leq2s-2$ cannot occur: in that case the path was already captured or blocked by the end of round $s-1$. +\end{lemma} + +\begin{proof} +Set $s_*=\lceil j/2\rceil$. For every reached round $k\leq\min\{s_*,t\}$, an induction shows that before the cop move the cop is a descendant of $v_{k-1}$ and has depth $j-k+1$. This is true at $k=1$. At round $k$, the endpoint-depth sum for the cop and $v_{k-1}$ is $j$, while their rooted-path length is $j-2k+2$. Hence the left side of Lemma~\ref{lem:buffer}'s inequality, with $q=R$, is +\[ +j+(j-2k+2)=2(j-k+1)\leq2j\leq2R. +\] +The rooted path is therefore the unique ambient geodesic, and the cop moves one step toward $v_0$, to depth $j-k$. If $kt$, that time lies beyond the horizon and the cop remains descendant pressure throughout the first $t$ reached rounds. +\end{remark} + +\subsection{Persistence along nonbacktracking paths} + +\begin{theorem}[Nonbacktracking path-tube persistence] +\label{thm:persistence} +Let $d\geq3$ and $1\leq t\leq R$, suppose $B_R(v_0)$ is a tree-ball, and let $X_0$ be any initial cop configuration satisfying +\[ +X_0(T_\sigma(R))\geq1 +\] +for every length-$t$ tube. Cops outside $B_R(v_0)$ are permitted; the proof ignores them unless they cause capture, blockage, or additional branch support. If $X_0(\{v_0\})>0$, the robber is captured before any move. Otherwise, let +\[ +\tau=(v_0,v_1,\ldots,v_t) +\] +be any length-$t$ nonbacktracking path starting at $v_0$. At every round $s\in\{1,\ldots,t\}$ for which the robber has followed $\tau$ through round $s-1$, at least one of the following occurs during round $s$: +\begin{enumerate}[label=(\alph*)] +\item on the cops' move, a cop captures the robber at $v_{s-1}$; +\item after the cops' move, the robber is not captured, but a cop occupies $v_s$, so the intended move is illegal; +\item the move to $v_s$ is legal, and after that move the branch-load support at $v_s$ has size at least $2$. +\end{enumerate} +\end{theorem} + +\begin{proof} +Assume there was no initial capture, and fix a round $s$ reached along $\tau$. If a cop reaches $v_{s-1}$, then (a) holds. If no cop captures the robber but a cop occupies $v_s$, then (b) holds. Assume instead that the move to $v_s$ is legal and produce two branch witnesses. + +\emph{Descendant witness.} +Choose a cop $c^\downarrow$ from $T_\tau(R)$ and let $j_\downarrow$ be its initial depth. Survival through round $s-1$ rules out $j_\downarrow\leq2s-2$, while the failure of alternatives~(a) and~(b) rules out $j_\downarrow\in\{2s-1,2s\}$. Hence $j_\downarrow\geq2s+1$, and Lemma~\ref{lem:interception} implies that after the round-$s$ cop move it is a strict descendant of $v_s$, at depth $j_\downarrow-s$. Its rooted path to $v_s$ has length $j_\downarrow-2s$, so the endpoint-depth sum plus this length is +\[ +(j_\downarrow-s)+s+(j_\downarrow-2s) +=2(j_\downarrow-s)\leq2R. +\] +Lemma~\ref{lem:buffer}, with $q=R$, makes this the unique ambient geodesic. Thus the cop contributes to a child branch of $v_s$. + +\emph{Parent witness.} +Choose $u_0\in N(v_0)\setminus\{v_1\}$, extend $(v_0,u_0)$ to a length-$t$ nonbacktracking path $\sigma'$, and choose a cop $c^\uparrow\in T_{\sigma'}(R)$ of initial depth $j$. Then $R\geq j\geq t\geq s$. We prove by induction on $k\in\{1,\ldots,s\}$ that before its move in round $k$, the cop has depth $j-k+1$ and lies in the root branch through $u_0$. This is immediate for $k=1$. At round $k$, the rooted path from the cop to $v_{k-1}$ passes through $v_0$; both its length and the sum of the endpoint depths equal +\[ +(j-k+1)+(k-1)=j. +\] +Lemma~\ref{lem:buffer}, with $q=R$, therefore forces one step toward $v_0$. If $k0$. If $(m_n)$ is polylogarithmic and $\Pr(\mathcal C_n)\to1$, then $t_n=O(\log\log n)$. If $t_n=\Theta(\log n)$ and $\Pr(\mathcal C_n)\to1$, then +\[ +m_n=\Omega(N_{t_n}\log N_{t_n}), +\qquad +N_{t_n}=d(d-1)^{t_n-1}; +\] +in particular, $m_n\geq n^c$ eventually for some $c>0$. +\end{corollary} + +\begin{proof} +If $t_n\neq O(\log\log n)$, pass to a subsequence on which $t_n/\log\log n\to\infty$, and then to a further subsequence on which $t_n$ is strictly increasing. Apply the proof of Theorem~\ref{thm:sampling-threshold} along this subsequence, with $t$ replaced by $t_n$. Now $t_n\to\infty$ and $N_{t_n}=(\log n)^{\omega(1)}$. Because the threshold is $\Theta(N_{t_n}\log N_{t_n})$ uniformly in $R_n\geq t_n$, polylogarithmic $m_n$ satisfies +\[ +m_n\leq\frac12\frac{\log N_{t_n}}{q_n} +\] +eventually on this subsequence, where $q_n$ is the common tube mass. Part~(ii) of Theorem~\ref{thm:sampling-threshold}, with $\varepsilon=1/2$, then gives $\Pr(\mathcal C_n)\to0$, a contradiction. Hence $t_n=O(\log\log n)$. + +If $t_n=\Theta(\log n)$ and $m_n=o(N_{t_n}\log N_{t_n})$ along a subsequence, pass if necessary to a further subsequence with strictly increasing horizons and apply the same subsequence argument. This contradicts $\Pr(\mathcal C_n)\to1$. Thus $m_n=\Omega(N_{t_n}\log N_{t_n})$. Since $N_{t_n}=n^{\Omega(1)}$, this is at least $n^c$ eventually for some $c>0$. +\end{proof} + +\begin{remark}[Partial coverage is cheaper] +\label{rem:partial-coverage} +At one fixed, known root, the expected fraction of length-$t$ tubes occupied by $m$ independent uniform samples is +\[ +1-(1-q_{t,R})^m. +\] +Since $q_{t,R}=\Theta(N_t^{-1})$, $m=\Theta(N_t)$ samples occupy a fixed positive fraction of the tubes in expectation, while $m=\omega(N_t)$ occupies a $1-o(1)$ fraction in expectation. Under uniform sampling, reducing the expected number of empty tubes to $O(1)$ requires $\Theta(N_t\log N_t)$ samples; the same order is required to leave at most $O(1)$ tubes empty with probability tending to one. These are occupancy statements only: they give neither an adversarial guarantee over all paths from that root nor a root-independent placement, and they do not show that partial coverage supports an adaptive chase. +\end{remark} + +\section{Information Loss in Tube Profiles} +\label{sec:profile-loss} + +Fix integers $Q\geq r\geq1$ and $k\geq0$, and suppose $B_Q(v_0)$ is a tree-ball. Let $\mathcal P_r(v_0)$ be the set of length-$r$ nonbacktracking paths starting at $v_0$, and let +\[ +\mathcal C_{Q,k}(v_0) +:=\{X: X\text{ is a $k$-cop multiset with }\supp(X)\subseteq B_Q(v_0)\}. +\] +For $X\in\mathcal C_{Q,k}(v_0)$, $\sigma\in\mathcal P_r(v_0)$, and $r\leq j\leq Q$, set +\[ +N_X(\sigma,j) +:=\sum_{\substack{x\in S_j(v_0)\\ +\text{the rooted path from $v_0$ to $x$ begins with }\sigma}}X(x). +\] +The \emph{augmented order-$r$ tube profile} is +\[ +\Pi_{r;Q,k}(X) +:=\left( +\bigl(X(x)\bigr)_{x\in B_{r-1}(v_0)}, +\bigl(N_X(\sigma,j)\bigr)_{\substack{\sigma\in\mathcal P_r(v_0)\\ r\leq j\leq Q}} +\right). +\] +It records the complete configuration through depth $r-1$ and the exact depth histogram in every length-$r$ tube; the $j=r$ coordinates also recover the multiplicities at depth $r$. It forgets only how cops at a fixed greater depth split among descendants below the terminal vertex of a length-$r$ prefix. For an admissible profile $P$, define its fixed-universe fiber by +\[ +\mathcal F_{r;Q,k}(P) +:=\{X\in\mathcal C_{Q,k}(v_0):\Pi_{r;Q,k}(X)=P\}. +\] + +\begin{proposition}[Augmented tube profiles do not determine later support] +\label{prop:profile-nondeterminacy} +Fix $d\geq3$ and $r\geq1$, put $Q=2r+3$, and suppose $B_Q(v_0)$ is a tree-ball. There exist $X,Y\in\mathcal C_{Q,2}(v_0)$ and a length-$(r+1)$ nonbacktracking path $\tau=(v_0,\ldots,v_{r+1})$ such that +\[ +\Pi_{r;Q,2}(X)=\Pi_{r;Q,2}(Y). +\] +The path survives all $r+1$ rounds from both configurations, with all relevant cop moves unique, but immediately after the robber's legal move to $v_{r+1}$ the branch-load supports in $X$ and $Y$ have sizes $2$ and $1$, respectively. Hence, for this fixed path $\tau$, the predicate that the branch-load support after $r+1$ safe rounds has size at least $2$ does not factor through $\Pi_{r;Q,2}$. +\end{proposition} + +\begin{proof} +Choose $\tau$ and two distinct children $a,b$ of $v_{r+1}$. Choose descendants $x_a$ of $a$ and $x_b$ of $b$ at total depth $Q=2r+3$ from $v_0$, and choose two distinct descendants $y_a,y_a'$ of $a$ at the same total depth. Such choices exist because the vertices at descendant-distance $r+1$ below $a$ number $(d-1)^{r+1}\geq2$. Define +\[ +X=[x_a,x_b], +\qquad +Y=[y_a,y_a']. +\] +Both configurations have zero multiplicity on $B_{r-1}(v_0)$, and their only nonzero tube-depth coordinate is the cell indexed by $((v_0,\ldots,v_r),Q)$, where both have value $2$. Thus their augmented profiles agree. + +Every robber position on $\tau$ is an ancestor of every cop in the construction. Immediately before the cop move in round $s$, each cop has total depth $Q-s+1$, while the robber is at $v_{s-1}$. Their rooted path has length $Q-2s+2$, and +\[ +(Q-s+1)+(s-1)+(Q-2s+2)=2Q-2s+2\leq2Q. +\] +Lemma~\ref{lem:buffer}, with $q=Q$, therefore makes this path the unique ambient geodesic and forces one step upward. After that move, the cop's distances to $v_{s-1}$ and $v_s$ are +\[ +2r+4-2s\geq2 +\qquad\text{and}\qquad +2r+3-2s\geq1 +\] +for $1\leq s\leq r+1$. Thus no capture or blockage occurs. After $r+1$ cop moves, the configurations are $[a,b]$ and $[a,a]$, so the branch-load support sizes after the robber moves to $v_{r+1}$ are $2$ and $1$. +\end{proof} + +For $r=1$, the profile includes the multiplicity at $v_0$ as well as the depth histogram in every first-level tube, yet it still does not determine the two-round outcome. Proposition~\ref{prop:profile-nondeterminacy} identifies one ambiguous fiber, not a barrier to every one-sided certificate: a sound certificate may reject that fiber. + +\section{Limitations and Further Directions} +\label{sec:open} + +The present arguments do not address five natural directions; no claim of novelty is made for the questions themselves. + +\subsection{Arbitrary robber walks} + +Theorem~\ref{thm:persistence} treats only length-$t$ nonbacktracking paths starting at $v_0$. Stationary moves and reversals destroy the monotone depth evolution used by the interception schedule. + +\begin{question} +Can a static or adaptive local certificate give an analogue of Theorem~\ref{thm:persistence} for arbitrary length-$t$ robber walks from $v_0$, including stationary moves and reversals? How do repeated vertices and reversed edges change the required witnesses and coverage cost? +\end{question} + +\subsection{Partial and adaptive coverage} + +Remark~\ref{rem:partial-coverage} separates partial from exhaustive conditional coverage at one fixed root. Along a prescribed nonbacktracking path, the persistence proof uses its occupied tube for the descendant witness and a tube with a different first edge for the parent witness; exhaustive coverage supplies both uniformly. A large expected covered fraction alone gives no persistence or adaptive-chase guarantee. + +\begin{question} +For a fixed horizon $t$, is there a condition strictly weaker than occupancy of every length-$t$ tube that guarantees capture or a quantified decrease in the number of compatible future nonbacktracking continuations? Can it be updated after each move by reusing witnesses among tubes with a common shorter prefix? +\end{question} + +\subsection{Uniformly favorable augmented-profile fibers} + +Fix integers $Q\geq r+1\geq2$ and $k\geq1$, suppose $B_Q(v_0)$ is a tree-ball, and fix a length-$(r+1)$ nonbacktracking path $\tau=(v_0,\ldots,v_{r+1})$. The graph, root, radius, cop number, allowed support region, and allowed distance-minimizing tie-breaks are thereby fixed. + +\begin{question} +For which admissible profiles $P$ do both of the following hold? +\begin{enumerate}[label=(\roman*)] +\item Some $X\in\mathcal F_{r;Q,k}(P)$ and some allowed sequence of cop moves let $\tau$ survive through round $r+1$. +\item For every $X\in\mathcal F_{r;Q,k}(P)$ and every allowed sequence of cop moves, the robber is captured or blocked by round $r+1$, or $\tau$ survives and its final branch-load support has size at least $2$. +\end{enumerate} +\end{question} + +\subsection{Global overlap of tube systems} + +Fix a graph $G$, integers $R\geq t\geq1$, a set $A$ of admissible roots whose radius-$R$ balls are tree-balls, and a root-independent set $W$ of allowed cop locations. For $v\in A$ and a length-$t$ nonbacktracking path $\sigma$ from $v$, write $T_\sigma^v(R)$ for its rooted tube. Define the global tube hypergraph +\[ +\mathcal H_{R,t}(G,A;W) +:=\left(W,\{T_\sigma^v(R)\cap W:v\in A,\ \sigma\text{ is a length-$t$ nonbacktracking path from }v\}\right). +\] +A root-independent set of cop positions supplies the exhaustive tube certificate simultaneously for every $v\in A$ exactly when it is a transversal of $\mathcal H_{R,t}$. This is the local-to-global gap absent from the conditional sampling theorem; multiplicity at an already selected vertex does not improve coverage. + +\begin{question} +How do girth, expansion, and nonbacktracking path counts bound the transversal number, fractional transversal number, and codegrees of $\mathcal H_{R,t}$? Can these estimates produce a root-independent placement with controlled cost? +\end{question} + +\subsection{Beyond exact tree-balls} + +The persistence proof uses only the unique geodesics of synchronized witnesses inside $B_R(v_0)$. With cycles, rooted branches would have to give way to a shortest-path directed acyclic graph in which paths may split or merge. No persistence theorem or coverage bound is proved in that setting. + +\begin{question} +For a radius-$R$ ball obtained from a tree by adding one edge, can geodesic tubes and branch-load support be replaced by notions for which an analogue of Theorem~\ref{thm:persistence} holds? More generally, how do the answer and the minimum exhaustive-coverage cost depend on bounded tree excess? +\end{question} + +\section{Conclusion} + +We analyzed a local certificate indexed by length-$t$ nonbacktracking robber paths starting at a fixed root $v_0$ in radius-$R$ tree-ball geometry. The larger radius $R+t$ is sharp, among possibly infinite $d$-regular graphs, for uniform control of arbitrary endpoint pairs, but the synchronized chase witnesses require only $B_R(v_0)$. The length-$t$ tubes partition the outer ball into $N_t=d(d-1)^{t-1}$ parts, and occupying every tube gives the stated capture, blockage, or two-branch-support alternative along every prescribed path in this class. + +At one fixed root the deterministic occupancy cost is $N_t$. Along fixed-degree rooted sequences with $t\to\infty$, the conditional uniform-sampling threshold is +\[ +\left(\frac{1}{1-(d-1)^{-(h_t+1)}}+o(1)\right)N_t\log N_t, +\qquad h_t=R_t-t. +\] +Because the sampling distribution is chosen after the root, it is not a legal standard-game initial placement and gives no cop-number bound. The arbitrary-walk, partial/adaptive, global, augmented-profile, and cyclic directions above are not addressed by the present results. + +\section*{Acknowledgments} +I warmly thank Eric Jovinelly, who led me through Graph Theory at Brown and greatly assisted me in building both graph-theoretic and broader mathematical skills and intuition. I also thank my peers in the Spring 2026 S02 of Graph Theory at Brown. + +\begin{thebibliography}{99} + +\bibitem{AignerFromme} +M.~Aigner and M.~Fromme, +\emph{A game of cops and robbers}, +Discrete Applied Mathematics, vol.~8, no.~1, 1984, pp.~1--12. + +\bibitem{BradshawHosseiniMoharStacho} +P.~Bradshaw, S.~A.~Hosseini, B.~Mohar, and L.~Stacho, +\emph{On the cop number of graphs of high girth}, +Journal of Graph Theory, vol.~102, no.~1, 2023, pp.~15--34. + +\bibitem{Frankl} +P.~Frankl, +\emph{Cops and robbers in graphs with large girth and Cayley graphs}, +Discrete Applied Mathematics, vol.~17, no.~3, 1987, pp.~301--305. + +\bibitem{FriezeKrivelevichLoh} +A.~Frieze, M.~Krivelevich, and P.-S.~Loh, +\emph{Variations on cops and robbers}, +Journal of Graph Theory, vol.~69, no.~4, 2012, pp.~383--402. + +\bibitem{HMG} +S.~A.~Hosseini, B.~Mohar, and S.~Gonzalez Hermosillo de la Maza, +\emph{Meyniel's conjecture on graphs of bounded degree}, +Journal of Graph Theory, vol.~97, no.~3, 2021, pp.~401--407. + +\bibitem{LuPeng} +L.~Lu and X.~Peng, +\emph{On Meyniel's conjecture of the cop number}, +Journal of Graph Theory, vol.~71, no.~2, 2012, pp.~192--205. + +\bibitem{NowakowskiWinkler} +R.~Nowakowski and P.~Winkler, +\emph{Vertex-to-vertex pursuit in a graph}, +Discrete Mathematics, vol.~43, nos.~2--3, 1983, pp.~235--239. + +\bibitem{PralatWormald} +P.~Pra\l at and N.~Wormald, +\emph{Meyniel's conjecture holds for random graphs}, +Random Structures \& Algorithms, vol.~48, no.~2, 2016, pp.~396--421. + +\bibitem{Quilliot} +A.~Quilliot, +\emph{Jeux et points fixes sur les graphes}, +Th\`ese de 3\`eme cycle, Universit\'e de Paris~VI, 1978. + +\bibitem{ScottSudakov} +A.~Scott and B.~Sudakov, +\emph{A bound for the cops and robbers problem}, +SIAM Journal on Discrete Mathematics, vol.~25, no.~3, 2011, pp.~1438--1442. + +\end{thebibliography} + +\end{document} diff --git a/paper/near-critical-growing-radius-domination-paper.tex b/paper/near-critical-growing-radius-domination-paper.tex new file mode 100644 index 0000000..ffcb1c9 --- /dev/null +++ b/paper/near-critical-growing-radius-domination-paper.tex @@ -0,0 +1,2545 @@ +\documentclass[11pt]{amsart} +\usepackage[margin=1in]{geometry} +\usepackage{amsmath,amssymb,amsthm,mathtools} +\usepackage{booktabs} +\usepackage{enumitem} +\usepackage{xcolor} +\usepackage{hyperref} +\usepackage{microtype} +\usepackage[T1]{fontenc} +\usepackage{lmodern} + +\newtheorem{theorem}{Theorem}[section] +\newtheorem{proposition}[theorem]{Proposition} +\newtheorem{lemma}[theorem]{Lemma} +\newtheorem{corollary}[theorem]{Corollary} +\theoremstyle{definition} +\newtheorem{definition}[theorem]{Definition} +\newtheorem{remark}[theorem]{Remark} + +\newcommand{\eps}{\varepsilon} +\newcommand{\Fcal}{\mathcal F} +\newcommand{\Pcal}{\mathcal P} +\newcommand{\Dcal}{\mathcal D} +\newcommand{\E}{\mathbb E} +\newcommand{\Prb}{\mathbb P} +\newcommand{\R}{\mathbb R} + +\hypersetup{ + colorlinks=true, + linkcolor=blue!50!black, + citecolor=blue!50!black, + urlcolor=blue!50!black, + pdftitle={The Annealed Critical Window for Growing-Radius Domination in Random Regular Graphs}, + pdfauthor={Levi Neuwirth}, + pdfsubject={Growing-radius domination and random regular graphs}, + pdfkeywords={random regular graphs, domination, configuration model, method of types, first moment} +} + +\title[The Annealed Critical Window for Growing-Radius Domination]{The Annealed Critical Window for\\Growing-Radius Domination in Random Regular Graphs} +\author{Levi Neuwirth} +\address{Brown University} +\date{July 24, 2026} +\keywords{random regular graphs, domination, configuration model, method of types, first moment} + +\begin{document} + +\begin{abstract} +Fix $d\ge3$ and let +\[ + B_h=1+d\frac{(d-1)^h-1}{d-2} +\] +be the radius-$h$ volume of the infinite $d$-regular tree. We determine the bounded annealed critical window for distance-$h$ domination in the random $d$-regular configuration model. Writing $L_h=\log B_h$, uniformly for bounded $s$ we prove +\[ + \frac{B_h}{L_h^2} + \Psi_{d,h}\!\left( + \frac{L_h-2\log L_h+s}{B_h} + \right) + \longrightarrow 1-e^{-s}, +\] +where $\Psi_{d,h}$ is the exact microcanonical first-moment exponent. Equivalently, +\[ + H(\alpha)-\Psi_{d,h}(\alpha) + =(1-\alpha)^{B_h}(1+o(1)) +\] +throughout the critical window. The lower zero is pinned within +$B_h^{-1/7+o(1)}$ of the scalar coupon root +$H(C/B_h)=(1-C/B_h)^{B_h}$ and therefore has a complete fixed-order inverse-logarithmic expansion; in particular, +\[ + B_h\alpha_h^{\rm ann} + =L_h-2\log L_h + +\frac{3\log L_h-1}{L_h} + +O\!\left(\frac{(\log L_h)^2}{L_h^2}\right). +\] +Consequently, for every fixed $\omega>0$ and the stated growth condition, a uniformly random simple $d$-regular graph satisfies +\[ + \gamma_h(G_{n,d})\ge + \frac n{B_h} + \bigl(L_h-2\log L_h-\omega\bigr) +\] +with high probability. + +The proof uses exact graph-distance labels, a two-sided method-of-types count, and an exact reduction to a compact tridiagonal variational functional. Although that functional is nonconcave, boundary repulsion and a monotone reverse transfer identify its unique global optimizer. Quantitative stable/free shadowing gives the activity law at the required scale. The new upper half of the critical-window argument comes from an explicit capped-free profile: all nonterminal layers retain the free Bernoulli entropy, and the entire entropy defect is the cost of one terminal nonemptiness conditioning event. The result also transfers as a lower bound to internally two-path $(h,2)$ domination. It remains annealed: quenched matching and the direct two-branch leading constant are open. +\end{abstract} + +\maketitle + +\noindent\textbf{Keywords.} random regular graphs; domination number; growing-radius domination; configuration model; method of types; belief propagation; first moment. + +\section{Introduction and related work}\label{sec:intro} + +\subsection{Main result and scale} +A set $S\subseteq V(G)$ is \emph{distance-$h$ dominating} if every vertex of $G$ lies within graph distance $h$ of $S$; its minimum size is denoted $\gamma_h(G)$. For graphs of maximum degree $d$, one selected vertex can cover at most +\[ + B_h=1+d\frac{(d-1)^h-1}{d-2} +\] +vertices, so the elementary volume bound is $\gamma_h(G)\ge n/B_h$. In ideal tree-ball geometry, independent selection and patching place the natural covering scale at $n\log B_h/B_h$. + +The first-moment balance is subtler than the leading scale. If $\alpha=C/B_h$, subset entropy is approximately +$C(\log B_h-\log C+1)/B_h$, while the probability that a tree ball is missed is approximately $e^{-C}$. Equating these terms predicts +\[ + C=\log B_h-2\log\log B_h+O(1). +\] +The present paper determines that bounded annealed window and its entire limiting profile. With $L_h=\log B_h$, uniformly for bounded $s$, +\[ + \frac{B_h}{L_h^2} + \Psi_{d,h}\!\left( + \frac{L_h-2\log L_h+s}{B_h} + \right) + \longrightarrow 1-e^{-s}. +\] +Thus the annealed exponent changes sign at $s=0$, and its lower zero is governed, to power accuracy in $B_h$, by the scalar equation +\[ + H(C/B_h)=(1-C/B_h)^{B_h}. +\] +This gives not only the first two terms but the complete fixed-order inverse-logarithmic expansion of the annealed transition. + +For the random graph itself, the first moment yields a fixed-slack lower theorem: for every fixed $\omega>0$, subject to +\[ + \frac{n(\log B_h)^2}{B_h}\gg h\log n, +\] +one has with high probability +\[ + \gamma_h(G_{n,d})\ge + \frac n{B_h} + \bigl(\log B_h-2\log\log B_h-\omega\bigr). +\] +At the comparison scale $B_h\asymp\sqrt n$, this is +\[ + \gamma_h(G_{n,d})\ge + \frac n{B_h} + \bigl(\tfrac12\log n-2\log\log n-\omega+O(1)\bigr) + =\Omega(\sqrt n\log n). +\] +This is an annealed lower transition, not a quenched matching theorem. Proving the existence of dominating sets at the same coordinate remains separate. + +The comparison scale is also relevant to pursuit-evasion: Meyniel's conjecture predicts that $O(\sqrt n)$ cops suffice in every connected $n$-vertex graph~\cite{LuPeng2012,ScottSudakov2011}, and it is known for several random-graph models, including random regular graphs~\cite{PralatWormald2016,PralatWormald2019}. Our conclusion is not a cop-number lower bound. It says that one particular static exhaustive coverage mechanism can require a logarithmic factor more than the Meyniel scale. + +\subsection{Why the first moment is difficult} +For an independent random subset of density $\alpha$, a fixed tree-like radius-$h$ ball is missed with probability approximately $(1-\alpha)^{B_h}$. Balancing subset entropy against this coupon term predicts the coordinate +\[ + \alpha B_h\approx \log B_h-2\log\log B_h. +\] +Turning that heuristic into a theorem is not a product-measure calculation. In the configuration model, coverage events for different vertices overlap heavily, and optimizing over the selected set allows highly organized profiles. A direct bounded-differences argument is also ineffective in the growing-radius regime: changing one pairing can alter the radius-$h$ coverage status of order $B_h$ vertices, so the natural Lipschitz constant grows on exactly the scale that must be resolved. + +The key device is to label every vertex by its \emph{exact} distance to the candidate set. Local consistency of these labels---adjacent labels differ by at most one, and every positive label has a neighbor one level lower---forces them to be genuine graph distances on any graph. This removes the need to approximate neighborhoods by trees and converts the first moment into an exact finite-dimensional method-of-types problem. The price is a nonconcave variational functional whose global optimizer must be identified uniformly as the number of distance levels grows. + +\subsection{Related work} +The configuration or pairing model and its transfer to uniformly random simple regular graphs are standard; see Wormald's survey~\cite{Wormald1999} and Janson's simplicity theorem~\cite{Janson2009}. Fixed-radius domination in random regular graphs has been studied algorithmically: Duckworth analyzed randomized greedy algorithms for distance-$k$ dominating sets~\cite{Duckworth2005}, while Duckworth and Wormald treated independent domination~\cite{DuckworthWormald2006}. The broader covering viewpoint is classical in coding theory~\cite{CohenHonkalaLitsynLobstein1997}. + +The closest message-passing antecedents are statistical-mechanical. Zhao, Habibulla, and Zhou developed a cavity-method and belief-propagation treatment of minimum dominating sets~\cite{ZhaoHabibullaZhou2015}; Habibulla and Qin studied a distance-two version with distance-labeled messages~\cite{HabibullaQin2019}. Those works are replica-symmetric and fixed-radius. The transfer equations below are closely related in spirit; the distinctions here are the exact graph-level type count, the proof of global optimization, and the growing-radius asymptotics. + +Cutler and Radcliffe used Shearer entropy to obtain universal extremal bounds for domination polynomials of regular graphs~\cite{CutlerRadcliffe2016}. Such universal bounds cannot by themselves detect the random-covering penalty: structured regular graphs may admit efficient covering codes. In the binomial random graph, Glebov, Liebenau, and Szab\'o proved two-point concentration at the first-moment threshold in a sufficiently dense regime~\cite{GlebovLiebenauSzabo2015}. That result motivates, but does not supply, the corresponding quenched statement for random regular graphs. + +The pursuit application comes from branch-based local coverage. Aigner and Fromme introduced the multiple-cop game in its modern graph-theoretic form~\cite{AignerFromme1984}; Pra\l at and Wormald later combined random placement with deterministic pursuit in random graphs~\cite{PralatWormald2016,PralatWormald2019}. The local tube certificate in~\cite[Theorem~3.3 and Section~6.4]{Neuwirth2026Tube} motivates a root-independent two-witness covering problem. Section~\ref{subsec:tube-connection} states the exact graph-general parameter used here and carefully limits the resulting obstruction. + +\subsection{Proof architecture and contributions} +The proof has four acts. + +\paragraph{Act I: exact types and a compact functional.} +Exact distance labels produce a two-sided type estimate +\[ + \log \E N_{\mathbf n} + =n\Phi_{d,h}(\mathbf n/n)+O_d(h\log n). +\] +Optimizing the local profile entropy at fixed tridiagonal edge masses reduces $\Phi_{d,h}$ exactly to a compact functional $\Fcal_{d,h}$ in $2h+1$ variables. The lower side of the type estimate also yields the pointwise entropy anchor +$\Psi_{d,h}(\alpha)\le H(\alpha)$. + +\paragraph{Act II: globality without concavity.} +The compact functional is genuinely nonconcave. Nevertheless, entropy singularities repel every maximizing profile from the boundary. Interior KKT points are equivalent to positive message solutions. An exact reverse transfer reconstructs every positive stationary solution from one terminal parameter, and the associated activity is strictly increasing from $0$ to $\infty$. Hence the grand-canonical optimizer is unique at every activity, and duality gives +\[ + \Psi_{d,h}(\alpha)=\Psi^{\mathrm{stat}}_{d,h}(\alpha), + \qquad + \Psi_{d,h}'(\alpha)=-\log z(\alpha). +\] + +\paragraph{Act III: quantitative orbit matching.} +The reverse orbit has exact stable and free outer solutions. They overlap over linearly many levels. Retaining the actual transverse errors gives, for +$c=\alpha B_h/\log B_h<4/3$, +\[ + -\log z(\alpha)-\log\frac1\alpha + =B_h(1-\alpha)^{B_h} + \left(1+B_h^{-c/4+o(1)}\right) + +B_h^{-c/4+o(1)}. +\] +The restriction $c<4/3$ is exactly where the additive reconstruction error remains smaller than the coupon term. + +\paragraph{Act IV: one terminal conditioning cost.} +An explicit capped-free profile agrees with the free Bernoulli tree law at every nonterminal layer. Its only entropy loss comes from conditioning the terminal lower-neighbor set to be nonempty. That loss is exactly +\[ + p_h\Delta_d(\varepsilon_h), + \qquad + p_h\varepsilon_h^d=(1-\alpha)^{B_h}, + \qquad + \Delta_d(\varepsilon)=\varepsilon^d(1+o(1)). +\] +This supplies the sharp upper bound on $H-\Psi$. Integrating the quantitative activity law supplies the matching lower bound and the universal scaling function $1-e^{-s}$. + +The principal contributions are therefore: an exact growing-radius type count with no local-tree assumption; an exact compact reduction; a global solution of a nonconcave annealed variational problem; quantitative stable/free matching; and a bounded-window theorem anchored by a self-contained feasible profile. Quenched matching and direct two-branch asymptotics remain separate problems. + +\section{Models, notation, and main results}\label{sec:main} +Fix $d\ge3$ throughout and put +\[ + b=d-1,\qquad D=\frac{b+1}{b-1}=\frac d{d-2}, +\] +\begin{equation}\label{eq:Bh} + B_h=1+d\frac{b^h-1}{b-1}=Db^h-\frac2{b-1}. +\end{equation} +For a probability vector $r=(r_1,\ldots,r_k)$, write +\[ + H(r)=-\sum_{j=1}^k r_j\log r_j, +\] +with $0\log0=0$; for a scalar $a\in[0,1]$, $H(a)$ denotes the binary entropy $H(a,1-a)$. + +\begin{center} +\begin{tabular}{ll} +\toprule +Notation & Meaning\\ +\midrule +$B_h$ & maximum radius-$h$ ball volume at degree $d$\\ +$\gamma_h(G)$ & minimum size of a distance-$h$ dominating set\\ +$\Phi_{d,h}$ & full local-profile type functional\\ +$\Fcal_{d,h}$ & compact tridiagonal profile functional\\ +$\Psi_{d,h}(\alpha)$ & microcanonical annealed exponent at density $\alpha$\\ +$z=e^\theta$ & grand-canonical activity\\ +$b=d-1$, $D=d/(d-2)$ & recurring degree constants\\ +\bottomrule +\end{tabular} +\end{center} + +\begin{definition}[Internally two-path $(h,2)$ domination]\label{def:two-path} +A set $S\subseteq V(G)$ is \emph{internally two-path $(h,2)$ dominating} if every vertex $v\notin S$ has two $v$--$S$ paths of length at most $h$ whose only common vertex is $v$. In particular, the paths begin through distinct neighbors of $v$. Let $\gamma_{h,2}^{\mathrm{int}}(G)$ denote the minimum size of such a set. +\end{definition} + +Every internally two-path $(h,2)$-dominating set is distance-$h$ dominating, since either witness path alone ends in $S$ within distance $h$. Therefore +\begin{equation}\label{eq:two-path-dominates} + \gamma_{h,2}^{\mathrm{int}}(G)\ge \gamma_h(G). +\end{equation} + +\subsection{Connection with static tube coverage}\label{subsec:tube-connection} +In the tree-ball setting of~\cite{Neuwirth2026Tube}, a length-$t$ tube with residual depth $h=R-t$ depends, after the root is allowed to vary, only on its terminal directed edge. The resulting forward cone excludes one branch at its head. A root-independent set meets every such directed cone exactly when, at every unselected vertex, at least two distinct branches contain a selected vertex within distance $h$; in a tree-ball these are precisely the two internally disjoint paths of Definition~\ref{def:two-path}. Thus internally two-path domination is a graph-general strengthening of the global exhaustive tube certificate. + +Combining~\eqref{eq:two-path-dominates} with the main theorem shows that any such exhaustive static certificate has size $\Omega(\sqrt n\log n)$ at the comparison scale $B_h\asymp\sqrt n$. Meyniel's conjecture concerns the existence of a fully adaptive winning strategy with $O(\sqrt n)$ cops, so there is no contradiction: the lower bound applies only to this static exhaustive mechanism. Partial coverage, adaptive reassignment, and epoch-based pursuit remain outside the argument. + +The pairing model consists of $n$ labeled buckets of $d$ half-edges paired uniformly at random; conditioning the resulting multigraph on simplicity gives the uniformly random simple $d$-regular graph $G_{n,d}$, for $dn$ even. Let $Z_{n,d,h}(m)$ count distance-$h$ dominating $m$-sets in the pairing model. + +For a distance-$h$ dominating set $S$, assign each vertex its exact label $\operatorname{dist}(v,S)\in\{0,\ldots,h\}$. The local consistency used below is equivalent to genuine distance: adjacent labels differ by at most one, and every positive label has a neighbor one level lower. The descending condition gives a path to label zero of the displayed length, while the Lipschitz condition gives the reverse inequality. No tree-neighborhood assumption is involved. + +The type count developed below gives +\begin{equation}\label{eq:type-count-intro} + \log\E Z_{n,d,h}(m) + \le n\Psi_{d,h}(m/n)+O_d(h\log(n+1)), +\end{equation} +uniformly in $h$. Here $\Psi_{d,h}$ is the exact annealed variational exponent. + +\begin{theorem}[Bounded annealed critical window]\label{thm:bounded-critical-window} +Put $L_h=\log B_h$. For every fixed $M<\infty$, uniformly for +\[ + C=L_h-2\log L_h+s, + \qquad |s|\le M, + \qquad \alpha=C/B_h, +\] +one has +\begin{equation}\label{eq:defect-bounded-window} + H(\alpha)-\Psi_{d,h}(\alpha) + =(1-\alpha)^{B_h}\left(1+o(1)\right). +\end{equation} +More precisely, +\begin{equation}\label{eq:defect-bounded-two-sided} + 1-B_h^{-1/7+o(1)} + \le + \frac{H(\alpha)-\Psi_{d,h}(\alpha)}{(1-\alpha)^{B_h}} + \le + 1+B_h^{-(d-2)/d+o(1)}. +\end{equation} +All $o(1)$ terms are uniform over the displayed window. +\end{theorem} + +\begin{corollary}[Critical scaling function]\label{cor:critical-scaling-function} +Uniformly for bounded $s$, +\begin{equation}\label{eq:critical-scaling-function} + \boxed{ + \frac{B_h}{(\log B_h)^2} + \Psi_{d,h}\!\left( + \frac{\log B_h-2\log\log B_h+s}{B_h} + \right) + \longrightarrow 1-e^{-s}. + } +\end{equation} +\end{corollary} + +\begin{corollary}[Annealed zero and scalar expansion]\label{cor:annealed-zero-expansion} +Let +\[ + \alpha_h^{\rm ann} + =\inf\{\alpha:\Psi_{d,h}(\alpha)\ge0\}, + \qquad + C_h^{\rm ann}=B_h\alpha_h^{\rm ann}, +\] +and let $\widehat C_{B_h}$ be the solution near +$\log B_h-2\log\log B_h$ of +\begin{equation}\label{eq:scalar-coupon-root} + H(C/B_h)=(1-C/B_h)^{B_h}. +\end{equation} +Then +\begin{equation}\label{eq:graph-scalar-zero} + C_h^{\rm ann} + =\widehat C_{B_h}+O\!\left(B_h^{-1/7+o(1)}\right). +\end{equation} +Writing $L=\log B_h$ and $\ell=\log L$, +\begin{align} + C_h^{\rm ann} + ={}&L-2\ell + +\frac{3\ell-1}{L} + +\frac{5\ell^2-12\ell+3}{2L^2}\notag\\ + &+\frac{18\ell^3-81\ell^2+84\ell-17}{6L^3} + +O\!\left(\frac{\ell^4}{L^4}\right). + \label{eq:annealed-zero-expansion} +\end{align} +\end{corollary} + +\begin{theorem}[Fixed-slack random-regular lower bound]\label{thm:random-fixed-window} +Fix $\omega>0$. If $h=h(n)\to\infty$ and +\begin{equation}\label{eq:fixed-window-growth} + \frac{n(\log B_h)^2}{B_h}\gg h\log n, +\end{equation} +then, with high probability, +\begin{equation}\label{eq:random-fixed-window} + \gamma_h(G_{n,d}) + \ge + \frac n{B_h} + \left(\log B_h-2\log\log B_h-\omega\right). +\end{equation} +The same conclusion holds for every stronger feasible-set notion, including internally two-path $(h,2)$ domination. +\end{theorem} + +The following older-form consequences retain uniform information deeper in the subcritical region and will be useful for comparison. + +\begin{theorem}[Near-critical annealed negativity]\label{thm:near-critical} +Fix $\eta\in(0,1)$, put $L_h=\log B_h$, and let $W_h\to\infty$. Uniformly for real $C$ satisfying +\begin{equation}\label{eq:C-window} + \eta L_h\le C\le L_h-2\log L_h-W_h, +\end{equation} +one has +\begin{equation}\label{eq:near-critical-psi} + \Psi_{d,h}(C/B_h) + \le-\left(\frac12-o(1)\right)e^{-C} + \qquad(h\to\infty), +\end{equation} +where the $o(1)$ may depend on $d$, $\eta$, and the prescribed sequence $W_h$, but is uniform over the displayed interval. +\end{theorem} + +\begin{remark}[The coefficient $1/2$]\label{rem:half-artifact} +The coefficient $1/2$ is not predicted to be sharp. It comes from the symmetric choice of the upper integration point in Section~\ref{sec:integration}. The activity law and the diagnostics in Section~\ref{sec:numerics} are consistent with the normalized ratio approaching $1-O(e^{-W_h})$ deeper in the near-critical window. +\end{remark} + +\begin{corollary}[Fixed-fraction slack]\label{cor:fixed-slack} +For every fixed $00, + \qquad + \frac{n e^{W_h}L_h^2}{B_h}\gg h\log n. +\end{equation} +Then, with high probability, +\begin{equation}\label{eq:near-gamma-lower} + \gamma_h(G_{n,d})\ge + \frac n{B_h}\left(L_h-2\log L_h-W_h\right). +\end{equation} +The same conclusion holds for every parameter whose feasible sets are necessarily distance-$h$ dominating, including internally two-path $(h,2)$ domination. +\end{theorem} + +\begin{corollary}[Fixed-fraction random lower bound]\label{cor:random-fixed} +For every fixed $\eps\in(0,1)$, if +\[ + \frac{n}{B_h^{1-\eps}}\gg h\log n, +\] +then, with high probability, +\[ + \gamma_h(G_{n,d})\ge + (1-\eps)\frac{n\log B_h}{B_h}. +\] +\end{corollary} + +When $B_h\asymp\sqrt n$, one has $\log B_h=\tfrac12\log n+O(1)$, and Theorem~\ref{thm:random-near-critical} gives +\[ + \gamma_h(G_{n,d})\ge + \frac n{B_h}\left(\frac12\log n-2\log\log n-W_h+O(1)\right) + =\Omega(\sqrt n\log n). +\] +The implicit constant in the final order statement depends on the comparison constants in $B_h\asymp\sqrt n$; no exact prefactor $\sqrt n$ is asserted. + + +\section{Two-sided exact type counting and the entropy anchor}\label{sec:two-sided-types} +For completeness, we record the exact local-profile count that underlies both the variational formula and the pointwise entropy bound used later. + +Let $\mathcal T_{d,h}$ be the finite set of local profiles +\[ + \tau=(i,\mathbf c),\qquad + i\in\{0,\ldots,h\},\quad + \mathbf c=(c_0,\ldots,c_h),\quad + \sum_jc_j=d, +\] +with +\[ + c_j=0\quad\text{if }|i-j|>1, + \qquad + c_{i-1}\ge1\quad\text{if }i>0. +\] +For a probability vector $\xi=(\xi_\tau)_{\tau\in\mathcal T_{d,h}}$, put +\[ + q_{ij}(\xi) + =\frac1d\sum_{\tau=(i,\mathbf c)}\xi_\tau c_j, +\] +and call $\xi$ feasible when $q_{ij}=q_{ji}$. Its selected density is +\[ + \alpha(\xi)=\sum_{\tau:\,i(\tau)=0}\xi_\tau. +\] +Define +\begin{equation}\label{eq:full-profile-functional} + \Phi_{d,h}(\xi) + =-\sum_\tau\xi_\tau\log\xi_\tau + +\sum_\tau\xi_\tau\log\binom d{\mathbf c(\tau)} + +\frac d2\sum_{i,j}q_{ij}(\xi)\log q_{ij}(\xi), +\end{equation} +with $0\log0=0$. The exact variational exponent is +\[ + \Psi_{d,h}(\alpha) + =\max\{\Phi_{d,h}(\xi):\xi\text{ feasible},\ \alpha(\xi)=\alpha\}. +\] +Section~\ref{sec:compact-reduction} proves the exact reduction from this full profile functional to the compact tridiagonal functional~\eqref{eq:compact}. + +\begin{theorem}[Two-sided count for one integer type]\label{thm:two-sided-type} +There is a constant $K_d$ such that the following holds for every $h,n$ and every admissible integer profile $(n_\tau)_{\tau\in\mathcal T_{d,h}}$. Put +\[ + \xi_\tau=\frac{n_\tau}{n}, + \qquad + H_{ij}=\sum_{\tau=(i,\mathbf c)}n_\tau c_j=dnq_{ij}(\xi). +\] +Assume $\sum_\tau n_\tau=n$, $H_{ij}=H_{ji}$, and every $H_{ii}$ is even; these conditions define admissibility here. Let $N_{\mathbf n}$ denote the number of vertex sets whose exact-distance local-profile counts equal $\mathbf n$ in the $d$-regular pairing model. Then +\begin{equation}\label{eq:exact-type-count} + \E N_{\mathbf n} + =\frac{n!}{\prod_\tau n_\tau!} + \prod_\tau\binom d{\mathbf c(\tau)}^{n_\tau} + \frac{ + \displaystyle\prod_{0\le i0} + \left\{ + \log\bigl((1+\lambda)^d-1\bigr)-da\log\lambda + \right\}, + \qquad \frac1d\le a\le1. +\end{equation} +The minimizing parameter is characterized by +\begin{equation}\label{eq:sd-marginal} + a=\frac{\lambda(1+\lambda)^{d-1}}{(1+\lambda)^d-1}. +\end{equation} + + +\subsection{Exact local entropy reduction}\label{sec:compact-reduction} +The passage from the full profile functional to $\Fcal_{d,h}$ is exact. + +\begin{lemma}[Conditional-product reduction]\label{lem:compact-reduction} +Fix a symmetric tridiagonal directed-edge distribution $q$, and write $x_i,\ell_i,p_i,y_i,a_i$ as above, with $x_{h+1}=0$. Among all feasible local-profile laws inducing $q$, the maximum of the vertex entropy and arrangement terms in~\eqref{eq:full-profile-functional} is +\begin{align} + &H(p_0,\ldots,p_h) + +p_0 d\,H\!\left(\frac{\ell_0}{p_0},\frac{x_1}{p_0}\right)\notag\\ + &\quad+\sum_{i=1}^h p_i\left[ + s_d(a_i)+d(1-a_i) + H\!\left(\frac{\ell_i}{y_i},\frac{x_{i+1}}{y_i}\right) + \right].\label{eq:local-reduction} +\end{align} +The maximizing law is unique whenever all displayed masses are positive. Conditional on label $i\ge1$, the set of lower-label half-edges has the entropy-maximizing tilted nonempty-subset law with coordinate marginal $a_i$; conditional on that set, every remaining half-edge independently receives label $i$ or $i+1$ with probabilities $\ell_i/y_i$ and $x_{i+1}/y_i$. +\end{lemma} + +\begin{proof} +First expose the central label. Its entropy is $H(p_0,\ldots,p_h)$. Conditional on the central label $i$, choosing a local count vector and then assigning the $d$ labeled half-edges contributes exactly the entropy of the induced law on words of length $d$ over the allowed neighboring labels. + +For $i=0$, only labels $0$ and $1$ are allowed and their coordinate marginals are $\ell_0/p_0$ and $x_1/p_0$. Subadditivity of entropy is sharp only for independent coordinates, giving the first term of~\eqref{eq:local-reduction}. + +Fix $i\ge1$. Mark the coordinates whose neighbor label is $i-1$. Their random subset is nonempty and has common coordinate marginal +\[ + a_i=\frac{q_{i,i-1}}{p_i}=\frac{x_i}{p_i}. +\] +By definition, its entropy is at most $s_d(a_i)$, with equality for the exponential tilt +\[ + \Pr(A)\propto \lambda^{|A|},\qquad \varnothing\ne A\subseteq[d], +\] +where $\lambda$ satisfies~\eqref{eq:sd-marginal}; this also proves the dual formula~\eqref{eq:sd}. Given the lower-neighbor set, the remaining $d-|A|$ coordinates must split between labels $i$ and $i+1$. Conditional entropy is maximized by independent splitting with probabilities $\ell_i/y_i$ and $x_{i+1}/y_i$. Its expectation is +\[ + d(1-a_i)H\!\left(\frac{\ell_i}{y_i},\frac{x_{i+1}}{y_i}\right). +\] +The chain rule for entropy proves~\eqref{eq:local-reduction}; strict entropy concavity gives uniqueness in the positive interior. +\end{proof} + +\begin{proposition}[Exact compact functional]\label{prop:exact-compact} +For every feasible tridiagonal $q$, maximizing~\eqref{eq:full-profile-functional} over local-profile laws inducing $q$ gives exactly~\eqref{eq:compact}. Consequently the compact and full variational values agree at every selected density. +\end{proposition} + +\begin{proof} +Insert~\eqref{eq:local-reduction} into~\eqref{eq:full-profile-functional}. The edge term is +\[ + \frac d2\left(\sum_{i=0}^h\ell_i\log\ell_i+2\sum_{i=1}^h x_i\log x_i\right). +\] +Expanding the two categorical entropies in~\eqref{eq:local-reduction}, the $x_i\log x_i$ terms cancel against the off-diagonal edge terms. The remaining $p_i$, $y_i$, and $\ell_i$ terms collect to~\eqref{eq:compact}, including the root contribution $(d-1)p_0\log p_0$. Since every full feasible profile induces a feasible $q$ and Lemma~\ref{lem:compact-reduction} constructs a maximizing profile for every feasible $q$, the variational values coincide. +\end{proof} + +Define the feasible polytope +\[ + \Pcal_{d,h} + =\left\{(x,\ell): + x_i,\ell_i\ge0, + \ \sum_i p_i=1, + \ d x_i\ge p_i\ (1\le i\le h) + \right\}. +\] +With the convention $0\log0=0$, the exact microcanonical exponent at a profile is +\begin{equation}\label{eq:compact} +\begin{aligned} + \Fcal_{d,h}(x,\ell) + ={}&(d-1)p_0\log p_0\\ + &+\sum_{i=1}^h + \left[ + -p_i\log p_i+p_i s_d(a_i)+d y_i\log y_i + \right] + -\frac d2\sum_{i=0}^h\ell_i\log\ell_i. +\end{aligned} +\end{equation} +For activity $z=e^\theta>0$, the grand-canonical functional is +\begin{equation}\label{eq:grand-functional} + \Fcal_{d,h}^{(z)}(x,\ell) + =\Fcal_{d,h}(x,\ell)+p_0\log z. +\end{equation} +The exact variational values are +\[ + \Psi_{d,h}(\alpha) + =\max_{\substack{(x,\ell)\in\Pcal_{d,h}\\p_0=\alpha}} + \Fcal_{d,h}(x,\ell), +\] +\[ + \phi_{d,h}(z) + =\max_{(x,\ell)\in\Pcal_{d,h}} + \Fcal_{d,h}^{(z)}(x,\ell) + =\max_\alpha\{\Psi_{d,h}(\alpha)+\alpha\log z\}. +\] +The polytope is compact, so all maxima exist. + +\subsection{The feasible density interval} +The local capacities imply the Moore lower bound. First, +\[ + p_1\le d x_1\le d p_0. +\] +For $i\ge2$, symmetry and the preceding descent edge give +\[ + x_i\le p_{i-1}-x_{i-1} + \le\frac{d-1}{d}p_{i-1}, +\] +so +\[ + p_i\le d x_i\le(d-1)p_{i-1}. +\] +Therefore +\[ + 1=\sum_{i=0}^h p_i\le B_h p_0, + \qquad p_0\ge\frac1{B_h}. +\] +Equality is feasible in the type polytope: take +\[ + p_0=\frac1{B_h}, + \qquad + p_i=\frac{d(d-1)^{i-1}}{B_h}, + \qquad + x_i=\frac{(d-1)^{i-1}}{B_h}, +\] +with $\ell_i=0$ for $i0\}, +\] +and its positive support is the prefix $\{0,\dots,k\}$. + +\begin{lemma}[Relative boundary repulsion]\label{lem:relative-boundary} +Fix a finite activity $z>0$. A maximizer of $\Fcal_{d,h}^{(z)}$ cannot lie on any proper local face within its effective horizon. More precisely, if its effective horizon is $k$, then +\[ + \ell_i>0\quad(0\le i\le k), + \qquad + a_i>\frac1d\quad(1\le i\le k). +\] +In particular, $a_k<1$. +\end{lemma} + +\begin{proof} +For each $k\ge1$, a strictly interior feasible profile exists. Choose +\[ + \frac1d<\chi<\frac12, + \qquad + 00, + \quad + \ell_i=p_i-x_i-x_{i+1}>0\ (1\le i0. +\] + +Mix a proposed boundary maximizer with such an interior profile. If $a_i=1/d$, equation~\eqref{eq:sd-lower} contributes a strictly positive multiple of $t\log(1/t)$. If $\ell_i=0$, the term +\[ + -\frac d2\ell_i\log\ell_i +\] +contributes a strictly positive multiple of $t\log(1/t)$. All terms that remain away from their endpoints change by only $O(t)$. + +The only apparent negative singularity occurs if $a_k=1$, equivalently $y_k=\ell_k=0$. In that case, with $y_k(t)=\beta t+O(t^2)$ for some $\beta>0$, +\[ + p_k(t)s_d\left(1-\frac{y_k(t)}{p_k(t)}\right) + +d y_k(t)\log y_k(t)=O(t) +\] +by~\eqref{eq:sd-upper}; the two logarithmic singularities cancel. The remaining diagonal-edge term contributes +\[ + -\frac d2y_k(t)\log y_k(t) + =\frac d2\beta\,t\log\frac1t+O(t), +\] +which is strictly positive. If several faces are active simultaneously, all leading $t\log(1/t)$ coefficients are nonnegative and at least one is positive. Hence every proper relative boundary point admits an improving inward direction. +\end{proof} + +The horizon itself is also repelling. + +\begin{lemma}[Horizon extension]\label{lem:horizon-extension} +A maximizer of $\Fcal_{d,h}^{(z)}$ cannot have effective horizon $k0$, introduce a new level of mass $p_{k+1}=\tau$ with +\[ + x_{k+1}=a_*\tau, + \qquad + y_{k+1}=\ell_{k+1}=(1-a_*)\tau. +\] +Keep $p_k$ fixed by reducing $\ell_k$ by $a_*\tau$, and preserve total mass by reducing $\ell_0$ by $\tau$. All old variables remain feasible for sufficiently small $\tau$. When $k=0$, take instead +\[ + p_0=1-\tau, + \quad + x_1=a_*\tau, + \quad + \ell_0=1-(1+a_*)\tau. +\] + +The new level contributes +\[ + \left[1-\frac d2(1-a_*)\right] + \tau\log\frac1\tau+O(\tau). +\] +The coefficient is positive by the choice of $a_*$. All changes to previously positive coordinates are $O(\tau)$. Thus the extension strictly increases the grand functional. +\end{proof} + +\begin{theorem}[Full interiority]\label{thm:full-interiority} +For every $d\ge3$, $h\ge1$, and $z>0$, every maximizer of the exact grand-canonical functional~\eqref{eq:grand-functional} has full effective horizon $h$ and satisfies +\[ + x_i>0, + \quad + \ell_i>0, + \quad + a_i>\frac1d +\] +for all applicable indices. +\end{theorem} + +\begin{proof} +Combine Lemmas~\ref{lem:relative-boundary} and~\ref{lem:horizon-extension}. +\end{proof} + + + +\section{Stationary messages and exact KKT reconstruction}\label{sec:stationarity} +Recall $b=d-1$. For $1\le i\le h$, let $A_i$ be the cavity weight when the recipient edge already supplies a lower-label neighbor, and let $B_i$ be the weight when it does not. For label zero use $B_0$, and set $A_{h+1}=0$. Define +\[ + S_0=B_0+A_1, + \qquad + S_i=B_{i-1}+B_i+A_{i+1}\quad(1\le i\le h), +\] +where the terminal convention makes $S_h=B_{h-1}+B_h$. The positive stationary system is +\begin{align} + \kappa B_0&=zS_0^b,\label{eq:msg-root}\\ + \kappa A_i&=S_i^b,&&1\le i\le h,\label{eq:msg-A}\\ + \kappa B_i&=S_i^b-(S_i-B_{i-1})^b,&&1\le i\le h.\label{eq:msg-B} +\end{align} +The following subsection derives this system directly from the compact exact functional and proves the converse reconstruction. + + +\subsection{Full KKT-to-message correspondence}\label{sec:kkt} +We now derive the stationary message system directly from the compact KKT equations and prove the converse reconstruction. + +Let $\lambda_i$ be the minimizer in~\eqref{eq:sd}. Then +\begin{equation}\label{eq:sd-identities} + s_d'(a_i)=-d\log\lambda_i, + \qquad + s_d(a_i)-a_i s_d'(a_i)=\log((1+\lambda_i)^d-1). +\end{equation} +At an interior grand-canonical KKT point, let $\mu$ be the multiplier for +\[ + \sum_{i=0}^h\ell_i+2\sum_{i=1}^h x_i=1. +\] +Since $\ell_i$ enters one layer mass and $x_i$ enters two, the KKT equations are +\begin{equation}\label{eq:kkt-weight} + \frac{\partial\Fcal^{(z)}}{\partial\ell_i}=\mu, + \qquad + \frac{\partial\Fcal^{(z)}}{\partial x_i}=2\mu. +\end{equation} + +The analytic derivatives used below are, at the root, +\[ + g_0=(d-1)(\log p_0+1)+\log z-\frac d2(\log\ell_0+1), +\] +and, for $i\ge1$, +\[ + g_i=-\log p_i-1+s_d(a_i)-a_i s_d'(a_i) + +d(\log y_i+1)-\frac d2(\log\ell_i+1). +\] +Thus $\partial\Fcal^{(z)}/\partial\ell_i=g_i$. The derivative with respect to $x_i$ is the sum of the layer contribution immediately to its left and the $x_i$-derivative of the layer-$i$ contribution; explicitly, for $i\ge2$, +\[ + \frac{\partial\Fcal^{(z)}}{\partial x_i} + =g_{i-1}+\frac d2(\log\ell_{i-1}+1) + +g_i+s_d'(a_i)-d(\log y_i+1)+\frac d2(\log\ell_i+1), +\] +with the same formula at $i=1$ after replacing the left layer expression by its root analogue. + +Define, up to a common positive scale, +\begin{equation}\label{eq:belief-messages} + B_i=\sqrt{\ell_i}, + \qquad + A_i=\frac{x_i}{B_{i-1}} + \quad(1\le i\le h), + \qquad + A_{h+1}=0. +\end{equation} +Then +\[ + y_i=B_i(B_i+A_{i+1}). +\] + +\begin{lemma}[Edge KKT identifies the local tilt]\label{lem:kkt-lambda} +For every $1\le i\le h$, +\begin{equation}\label{eq:lambda-message} + \lambda_i + =\frac{B_{i-1}}{B_i+A_{i+1}}. +\end{equation} +\end{lemma} + +\begin{proof} +For $i\ge2$, the derivative of $\Fcal$ with respect to $x_i$ is the sum of the $p_{i-1}$ and $p_i$ contributions. Subtract the two diagonal equations in~\eqref{eq:kkt-weight}, use~\eqref{eq:sd-identities}, and cancel the common constants. The result is +\[ + \frac d2\log\ell_{i-1} + -d\log\left(\lambda_i\frac{y_i}{\sqrt{\ell_i}}\right)=0. +\] +Thus +\[ + \lambda_i y_i=\sqrt{\ell_{i-1}\ell_i}, +\] +which is~\eqref{eq:lambda-message}. The root contribution at $i=1$ has the same algebra, with the activity term cancelling through the root diagonal equation. +\end{proof} + +\begin{proposition}[Compact KKT equals two-message stationarity]\label{prop:kkt-message} +Every strict interior KKT point determines positive messages satisfying~\eqref{eq:msg-root}--\eqref{eq:msg-B}. Conversely, every positive message solution reconstructs a strict interior KKT point by +\[ + q_{ii}=\frac{B_i^2}{Z_e}, + \qquad + q_{i-1,i}=\frac{B_{i-1}A_i}{Z_e}. +\] +\end{proposition} + +\begin{proof} +Put +\[ + T_i=B_i+A_{i+1}, + \qquad + S_i=B_{i-1}+T_i. +\] +By Lemma~\ref{lem:kkt-lambda}, $\lambda_i=B_{i-1}/T_i$. Exponentiating the diagonal KKT equation and using~\eqref{eq:sd-identities} shows that one common constant $\kappa>0$ satisfies +\[ + \kappa + =\frac{((1+\lambda_i)^d-1)y_i^d}{p_iB_i^d} + =\frac{S_i^d-T_i^d}{p_i} +\] +for every $i\ge1$. Hence +\[ + p_i=\frac{S_i^d-T_i^d}{\kappa}. +\] +The descent marginal formula gives +\[ + x_i=a_ip_i + =\frac{B_{i-1}S_i^b}{\kappa}. +\] +Since $x_i=B_{i-1}A_i$, this is~\eqref{eq:msg-A}. Subtracting $x_i$ from $p_i$ gives +\[ + y_i=\frac{T_i(S_i^b-T_i^b)}{\kappa}. +\] +Since $y_i=B_iT_i$, this is~\eqref{eq:msg-B}. At the root, the diagonal KKT equation gives +\[ + \kappa=\frac{zp_0^b}{B_0^d}. +\] +Because $p_0=B_0(B_0+A_1)=B_0S_0$, this is~\eqref{eq:msg-root}. + +Conversely, let positive messages satisfy the stationarity equations. Put $\sigma=Z_e^{-1/2}$ and introduce normalized messages +\[ + \widehat A_i=\sigma A_i, + \qquad + \widehat B_i=\sigma B_i, + \qquad + \widehat\kappa=\sigma^{b-1}\kappa. +\] +Then +\[ + \widehat\kappa\widehat A_i=\widehat S_i^b, + \qquad + \widehat\kappa\widehat B_i + =\widehat S_i^b-(\widehat S_i-\widehat B_{i-1})^b, + \qquad + \widehat\kappa\widehat B_0=z\widehat S_0^b. +\] +The reconstructed beliefs are simply +\[ + \ell_i=\widehat B_i^2, + \qquad + x_i=\widehat B_{i-1}\widehat A_i. +\] +They have total mass one by the definition of $Z_e$. Writing +\[ + \widehat T_i=\widehat B_i+\widehat A_{i+1}, + \qquad + \lambda_i=\frac{\widehat B_{i-1}}{\widehat T_i}, +\] +the message equations give the exact row identities +\[ + p_i=\frac{\widehat S_i^d-\widehat T_i^d}{\widehat\kappa}, + \qquad + x_i=\frac{\widehat B_{i-1}\widehat S_i^b}{\widehat\kappa}, + \qquad + y_i=\frac{\widehat T_i(\widehat S_i^b-\widehat T_i^b)}{\widehat\kappa}. +\] +Consequently $a_i=x_i/p_i$ is exactly the tilted conditioned-binomial marginal with parameter $\lambda_i$, and +\[ + \lambda_i y_i + =\widehat B_{i-1}\widehat B_i + =\sqrt{\ell_{i-1}\ell_i}. +\] +Substitution into the diagonal derivative gives, for every $i\ge1$, +\[ + g_i=\log\widehat\kappa+\frac{d-2}{2}. +\] +The root equation gives the same value for $g_0$. Finally, the displayed edge identity and $s_d'(a_i)=-d\log\lambda_i$ make the difference between the $x_i$ derivative and $g_{i-1}+g_i$ vanish exactly. Thus, with +\[ + \mu=\log\widehat\kappa+\frac{d-2}{2}, +\] +one has +\[ + \frac{\partial\Fcal^{(z)}}{\partial\ell_i}=\mu, + \qquad + \frac{\partial\Fcal^{(z)}}{\partial x_i}=2\mu, +\] +which is~\eqref{eq:kkt-weight}. Positivity makes the point strict interior, and common message scaling cancels from the beliefs. +\end{proof} + + + +\subsection{One-dimensional positive stationary locus} +The equations are homogeneous: common message scaling changes $\kappa$ but not $z$ or the reconstructed profile. In the gauge $\kappa=1$, choosing $B_h>0$ determines all preceding messages uniquely by +\begin{equation}\label{eq:message-backward} + A_i=B_i+(B_i+A_{i+1})^b, + \qquad + B_{i-1}=A_i^{1/b}-B_i-A_{i+1}, +\end{equation} +for $i=h,h-1,\ldots,1$. Positivity is automatic because +\[ + B_{i-1}=\left((B_i+A_{i+1})^b+B_i\right)^{1/b}-(B_i+A_{i+1})>0. +\] +Thus the positive stationary locus is one dimensional before the activity is imposed. + + +\section{Exact reverse transfer and monotone activity shooting} +Normalize $A_1=1$ and put +\[ + \rho_i=\frac{B_i}{A_i}, + \qquad + u_i=\frac{A_{i+1}}{A_i}, + \qquad + v_i=\rho_i+u_i. +\] +At the terminal level, $u_h=0$, so +\[ + \rho_h=v_h=:s\in(0,1). +\] +Let +\[ + \Dcal=\{(\rho,v):0<\rho\le v\le1\}. +\] + +\begin{proposition}[Exact reverse map]\label{prop:reverse-map} +Given a next-level state $(\rho',v')\in\Dcal$, define +\[ + w=(1-\rho')^{1/b}, + \qquad + e=1-w, +\] +\[ + R=v'\frac ew, + \qquad + M=\left(e+\frac w{v'}\right)^b. +\] +Then its unique positive predecessor is +\begin{equation}\label{eq:reverse-map} + \boxed{ + \rho=\frac{R}{R+M}, + \qquad + v=\frac{1+R}{R+M}. + } +\end{equation} +The remaining coordinates are +\begin{equation}\label{eq:reverse-out} + u=\frac1{R+M},\qquad q=\frac{M-1}{R+M}. +\end{equation} +The map sends $\Dcal$ into itself. +\end{proposition} + +\begin{proof} +The forward transfer identities imply +\[ + \frac\rho u=v'\frac ew=R. +\] +The next lower-neighbor fraction also gives +\[ + e=\frac{\rho(1-\rho)^{1/b}} + {u^{1/b}(u+\rho)}. +\] +Substituting $\rho=Ru$ and solving yields +\[ + u=\frac1{R+M}, +\] +which gives~\eqref{eq:reverse-map}. Uniqueness follows from the algebraic solution. Since +\[ + e+\frac w{v'}\ge e+w=1, +\] +we have $M\ge1$, and therefore +\[ + 0<\rho0, +\] +\[ + \frac{\partial}{\partial m}\log z + =\frac1m+ + \frac{b(1-v)}{(1+m)(1+vm)}>0. +\] +The quantity $m$ is strictly increasing in $\rho_1$, so Proposition~\ref{prop:order-preserving} completes the proof. +\end{proof} + +\begin{lemma}[Endpoint activities]\label{lem:z-endpoints} +For fixed $d,h$, +\[ + \lim_{s\downarrow0}z_h(s)=0, + \qquad + \lim_{s\uparrow1}z_h(s)=\infty. +\] +\end{lemma} + +\begin{proof} +For terminal $(\rho',v')=(s,s)$ with $s\downarrow0$, one reverse step has $R=O(s^2)$ and $M=\Theta(s^{-b})$, so the predecessor tends to $(0,0)$. The reverse map is continuous at every interior state and has the displayed boundary limit; induction over the fixed number $h-1$ of reverse steps therefore sends every earlier state to $(0,0)$. Equation~\eqref{eq:z-root-reverse} then gives $z\to0$. + +As $s\uparrow1$, one has $w\to0$, $R\to\infty$, and $M\to1$, so one reverse step tends to $(1,1)$. The same finite-step induction sends every earlier state to $(1,1)$. Hence $m\to\infty$, $v_1\to1$, and~\eqref{eq:z-root-reverse} gives $z\to\infty$. Continuity of the reverse map and of~\eqref{eq:z-root-reverse} also makes $s\mapsto z_h(s)$ continuous. +\end{proof} + +\begin{theorem}[Unique positive stationary point]\label{thm:unique-stationary} +For every $d\ge3$, $h\ge1$, and $z>0$, the exact grand-canonical stationarity equations have exactly one positive solution up to common message scaling. +\end{theorem} + +\begin{proof} +Every positive solution has one terminal parameter $s=\rho_h\in(0,1)$ and is uniquely reconstructed by Proposition~\ref{prop:reverse-map}. Lemmas~\ref{lem:z-monotone} and~\ref{lem:z-endpoints} show that $s\mapsto z_h(s)$ is a strictly increasing bijection from $(0,1)$ to $(0,\infty)$. +\end{proof} + + +For a positive stationary message profile whose reconstructed selected density is $\alpha$, define +\[ + \Psi^{\mathrm{stat}}_{d,h}(\alpha) + :=\Fcal_{d,h}(x,\ell), +\] +where $(x,\ell)$ is its normalized compact profile. Theorem~\ref{thm:unique-stationary} shows that this value is single-valued along the positive reverse-transfer branch. + +\section{Exact grand- and microcanonical globality} +Although $\Fcal_{d,h}$ is nonconcave, the preceding interiority and uniqueness results determine its global optimizer. + +\begin{theorem}[Grand-canonical globality]\label{thm:grand-globality} +For every $d\ge3$, $h\ge1$, and $z>0$, the exact grand-canonical functional $\Fcal_{d,h}^{(z)}$ has a unique global maximizer. It is the positive stationary profile corresponding to the unique terminal parameter $s$ satisfying $z_h(s)=z$. +\end{theorem} + +\begin{proof} +A maximizer exists by compactness. Theorem~\ref{thm:full-interiority} places every maximizer in the strict interior, so every maximizer satisfies the interior stationarity equations. Theorem~\ref{thm:unique-stationary} gives only one such point. +\end{proof} + +The microcanonical problem follows by duality. + +\begin{theorem}[Microcanonical globality]\label{thm:micro-globality} +For every density +\[ + \frac1{B_h}<\alpha<1, +\] +the exact microcanonical functional has a unique global maximizer, and it is the positive stationary profile on the reverse-transfer branch with selected density $\alpha$. Equivalently, +\[ + \boxed{ + \Psi_{d,h}(\alpha)=\Psi^{\mathrm{stat}}_{d,h}(\alpha) + } +\] +throughout the interior feasible interval. +\end{theorem} + +\begin{proof} +Write $\theta=\log z$ and +\[ + \phi_{d,h}(\theta) + =\max_{(x,\ell)\in\Pcal_{d,h}} + \{\Fcal_{d,h}(x,\ell)+\theta p_0\}. +\] +By Theorem~\ref{thm:grand-globality}, the maximizer is unique for every $\theta$. Danskin's theorem~\cite{RockafellarWets1998} therefore gives +\[ + \phi_{d,h}'(\theta)=\alpha(\theta), +\] +the density of that maximizer. The derivative of a differentiable convex function is continuous here (equivalently, one may use continuity of the unique optimizer). It is also strictly increasing. Indeed, if $\theta_1<\theta_2$ had the same maximizing density, the two optimality inequalities would force each optimizer to maximize at both activities. Grand-canonical uniqueness would make the profiles equal, but the root equation assigns one activity to an interior stationary profile, a contradiction. To identify the endpoint limits, let $\theta_k\to-\infty$ and pass, by compactness, to a convergent subsequence of optimizers. Comparing with a feasible profile of density $1/B_h$ shows that any limit must minimize $p_0$ over the polytope, hence has density $1/B_h$. Similarly, along $\theta_k\to\infty$, comparison with the all-selected profile forces every subsequential limit to have density $1$. Therefore +\[ + \alpha(\theta)\longrightarrow\frac1{B_h} + \quad(\theta\to-\infty), + \qquad + \alpha(\theta)\longrightarrow1 + \quad(\theta\to\infty). +\] +Hence every interior density is attained. + +Fix $\alpha$ and choose $\theta$ with $\alpha(\theta)=\alpha$. For every profile $P$ of density $\alpha$, +\[ + \Fcal(P)+\theta\alpha + \le + \Fcal(P_\theta)+\theta\alpha, +\] +so $P_\theta$ is the microcanonical maximizer. Uniqueness follows from grand-canonical uniqueness. +\end{proof} + +\begin{corollary}[Concavity and the envelope identity]\label{cor:envelope} +The exact value function $\Psi_{d,h}$ is strictly concave on $(1/B_h,1)$ and differentiable there. If $z(\alpha)$ is the activity exposing density $\alpha$, then +\[ + \boxed{ + \Psi_{d,h}'(\alpha)=-\log z(\alpha). + } +\] +\end{corollary} + +\begin{proof} +This is the standard differentiable Legendre correspondence produced by the unique maximizers in Theorems~\ref{thm:grand-globality} and~\ref{thm:micro-globality}. Strict monotonicity of $\alpha(\theta)$ gives strict concavity. +\end{proof} + +For later use, we collect the two conclusions as +\begin{equation}\label{eq:globality} + \Psi_{d,h}(\alpha)=\Psi^{\rm stat}_{d,h}(\alpha), + \qquad + \Psi'_{d,h}(\alpha)=-\log z(\alpha). +\end{equation} + +Explicit positive Hessian directions at stratified profiles do not contradict these theorems: they occur at nonstationary stratified profiles. The compact functional is genuinely nonconcave, but no competing stationary maximum exists. + + +\section{Corrected stationary telescoping and root formulas}\label{sec:telescoping} +Define +\begin{align} + Z_v&=zS_0^d+ + \sum_{i=1}^h + \left[S_i^d-(S_i-B_{i-1})^d\right], + \label{eq:Zv}\\ + Z_e&=\sum_{i=0}^hB_i^2 + +2\sum_{i=0}^{h-1}B_iA_{i+1}. + \label{eq:Ze} +\end{align} +The stationary vertex and edge normalizers satisfy the following exact telescoping identity. + +\begin{proposition}[Corrected telescoping identity]\label{prop:correct-telescope} +Every positive stationary solution satisfies +\[ + \boxed{Z_v=\kappa Z_e.} +\] +\end{proposition} + +\begin{proof} +For $1\le i\le h$, put +\[ + V_i=S_i^d-(S_i-B_{i-1})^d. +\] +Using~\eqref{eq:msg-A}--\eqref{eq:msg-B}, +\begin{align*} + V_i + &=\kappa\left( + B_{i-1}A_i+B_i^2+B_iA_{i+1} + \right). +\end{align*} +The root contribution is +\[ + zS_0^d=\kappa(B_0^2+B_0A_1). +\] +After summation, every square $B_i^2$ appears once and every cross term $B_iA_{i+1}$ appears twice: once from each adjacent vertex contribution, with the root supplying the first copy of $B_0A_1$. This is exactly $\kappa Z_e$. +\end{proof} + +Normalize $A_1=1$ and write $r=B_0$. Proposition~\ref{prop:correct-telescope} gives +\begin{equation}\label{eq:alpha-root-correct} + \alpha=\frac{r(1+r)}{Z_e}. +\end{equation} +The pressure is +\[ + \phi=\log Z_v-\frac d2\log Z_e, +\] +and the root equation is +\[ + \log z=\log\kappa+\log r-(d-1)\log(1+r). +\] +Eliminating $Z_e$ gives the corrected root-only formulas +\begin{equation}\label{eq:root-pressure-correct} + \boxed{ + \phi + =\log z + +\frac d2\log\frac{1+r}{r} + +\frac{d-2}{2}\log\alpha, + } +\end{equation} +\begin{equation}\label{eq:root-micro-correct} +\boxed{ +\begin{aligned} + \Psi={}& + (1-\alpha)\log\kappa + -\left(\frac{d-2}{2}+\alpha\right)\log r + +\frac{d-2}{2}\log\alpha\\ + &+\left((d-1)\alpha-\frac{d-2}{2}\right)\log(1+r). +\end{aligned} +} +\end{equation} +The corrected formulas are used throughout this paper. Calculations made directly from $Z_v$ and $Z_e$ are unaffected, provided the power differences are evaluated stably as discussed in Section~\ref{sec:numerics}. + +For later diagnostics, the same telescope gives a cancellation-free identity for the entropy defect. Put +\[ + u=u_1=\frac{A_2}{A_1}, + \qquad c_0=\frac{d-2}{2}, +\] +and define +\[ + E_{\rm root}:=\log\kappa-b\log u, + \qquad + N_e:=\log Z_e-(D+1)\log u. +\] + +\begin{proposition}[Exact entropy-defect identity]\label{prop:defect-identity} +Every positive stationary solution of selected density $\alpha$ satisfies +\begin{equation}\label{eq:defect-identity} + \boxed{ + H(\alpha)-\Psi_{d,h}(\alpha) + =-(1-\alpha)\log(1-\alpha) + -E_{\rm root}+c_0N_e + +\alpha\log\frac z\alpha. + } +\end{equation} +\end{proposition} + +\begin{proof} +Proposition~\ref{prop:correct-telescope} gives +\[ + \phi=\log\kappa-c_0\log Z_e. +\] +Since $c_0(D+1)=b$, this is +$\phi=E_{\rm root}-c_0N_e$. Now use +$\Psi=\phi-\alpha\log z$ and expand +$H(\alpha)=-\alpha\log\alpha-(1-\alpha)\log(1-\alpha)$. +\end{proof} + + + +\section{Terminal logarithmic coordinate and exact outer orbits}\label{sec:terminal} +Retain the normalization $A_1=1$ and the reverse-transfer coordinates +\[ + \rho_i=\frac{B_i}{A_i},\qquad + u_i=\frac{A_{i+1}}{A_i},\qquad + v_i=\rho_i+u_i,\qquad + q_i=1-v_i. +\] +At the terminal wall $u_h=0$. We write +\begin{equation}\label{eq:terminal-t} + \rho_h=v_h=s=1-e^{-t},\qquad q_h=e^{-t}. +\end{equation} +The exact reverse dynamics are those of Proposition~\ref{prop:reverse-map}. + +Two boundary orbits are exact. On the stable boundary $\rho=0$, the forward map is +$u\mapsto u^{1/b}$ and the reverse map is $u'\mapsto (u')^b$. On the free boundary +$q=0$, the forward map is $u\mapsto u^b$ and the reverse map is +$u'\mapsto (u')^{1/b}$. + +For terminal parameter $t$, define the free density +\begin{equation}\label{eq:free-a} + a_h(t):=1-e^{-t/b^h}. +\end{equation} +The exact free orbit with this density is +\begin{equation}\label{eq:free-orbit} + \bar u_i=e^{-t/b^{h-i}},\qquad + \bar\rho_i=1-\bar u_i,\qquad + \bar q_i=0. +\end{equation} + + +\section{Uniform one-step estimates} +The slack theorem requires only terminal parameters linear in $h$ and uniformly below the critical coefficient. + +\begin{definition}[Subcritical terminal window]\label{def:terminal-window} +Fix constants +\[ + 0<\tau_-\le\tau_+<\frac{4\log b}{3D}. +\] +A sequence of terminal parameters is in the subcritical linear window if +\[ + \tau_-h\le t\le\tau_+h. +\] +\end{definition} + +All constants below may depend on $d,\tau_-,\tau_+$ but not on $h$ or $t$ in this window. + +\subsection{Free-side perturbations} +At one reverse step, keep $\rho'$ fixed and write $q'=1-v'$. The free predecessor with the same $\rho'$ has +\[ + w=(1-\rho')^{1/b}, + \qquad + \rho_0=1-w, + \qquad + u_0=w, + \qquad + q_0=0. +\] + +\begin{lemma}[Uniform free-side step]\label{lem:free-step} +There are $\delta_0,C>0$ such that, whenever $0\le q'\le\delta_0$, the actual predecessor satisfies +\begin{align} + 1-\rho&=w(1+\eta_\rho),\label{eq:free-rho-step}\\ + q&=bw^2q'(1+\eta_q),\label{eq:free-q-step} +\end{align} +with +\[ + |\eta_\rho|+|\eta_q|\le Cq'. +\] +The estimates are uniform for $00$ such that, whenever $0\le\delta\le\delta_1$, the predecessor satisfies +\begin{align} + \log u&=b\log V+O_d(V\delta),\label{eq:stable-logu}\\ + \frac\rho v&=\frac{V^2}{b}\delta(1+O_d(\delta)).\label{eq:stable-delta} +\end{align} +Put +\[ + \widetilde a_i:=\frac{\rho_i}{b^iu_i^D}. +\] +Then +\begin{equation}\label{eq:stable-invariant-step} + \frac{\widetilde a_i}{\widetilde a_{i+1}} + =1+O_d(\delta). +\end{equation} +For sufficiently small $\delta_1$, the transverse ratios contract backward: +\[ + \frac{\rho_i}{v_i}\le\frac{1+o(1)}b\frac{\rho_{i+1}}{v_{i+1}}. +\] +\end{lemma} + +\begin{proof} +Here +\[ + w=(1-V\delta)^{1/b}, + \qquad + e=1-w=\frac{V\delta}{b}(1+O_d(\delta)), +\] +so +\[ + R=V\frac ew=\frac{V^2\delta}{b}(1+O_d(\delta)). +\] +Also +\[ + e+\frac wV + =\frac1V\left(w+Ve\right) + =\frac1V\left(1-e(1-V)\right), +\] +whence +\[ + M=V^{-b}(1+O_d(V\delta)). +\] +Since $R/M=O_d(V^{b+2}\delta)$, equation~\eqref{eq:reverse-out} yields~\eqref{eq:stable-logu}; moreover $\rho/u=R$, which gives~\eqref{eq:stable-delta}. + +For~\eqref{eq:stable-invariant-step}, use $\rho=uR$ and compute +\[ + \frac{\widetilde a_i}{\widetilde a_{i+1}} + =\frac{bRu^{1-D}(u')^D}{\rho'}. +\] +The preceding expansions give +\[ + \frac{bR}{\rho'}=V(1+O_d(\delta)), + \qquad + u^{1-D}(u')^D=V^{-1}(1+O_d(\delta)), +\] +because $(b-1)D=b+1$. Their product is $1+O_d(\delta)$. +\end{proof} + + +\section{Free shadowing to an overlap layer} +In the quantitative statements below, notation of the form +$1+B_h^{-\eta+o(1)}$ denotes $1+\epsilon_h$ with +$|\epsilon_h|\le B_h^{-\eta+o(1)}$; no sign is implied. +For $t$ in the terminal window, choose +\begin{equation}\label{eq:H-choice} + H=\left\lfloor\frac{3Dt}{4\log b}\right\rfloor, + \qquad + m=h-H. +\end{equation} +Then $H,m$ are both linear in $h$. Define the formal free transverse sequence, for $m\le i\le h$, by +\begin{equation}\label{eq:qbar} + \bar q_i + =b^{h-i} + \exp\left\{-Dt+\frac{2t}{b-1}b^{i-h}\right\}. +\end{equation} +It satisfies $\bar q_h=e^{-t}$ and +\[ + \bar q_i=b\bar u_i^2\bar q_{i+1}. +\] + +\begin{proposition}[Uniform free shadow]\label{prop:free-shadow} +Uniformly in every subcritical terminal window, for every $m\le i\le h$, +\begin{align} + q_i&=\bar q_i\left(1+O\!\left(h e^{-Dt/4}\right)\right),\label{eq:q-shadow}\\ + 1-\rho_i&=\bar u_i\left(1+O\!\left(e^{-Dt/4}\right)\right).\label{eq:rho-shadow} +\end{align} +In particular, +\begin{align} + q_m&=b^H\exp\left\{-Dt+\frac{2t}{b-1}b^{-H}\right\} + \left(1+O\!\left(h e^{-Dt/4}\right)\right),\label{eq:qm}\\ + \rho_m&=t b^{-H} + \left(1+O\!\left(t b^{-H}+e^{-Dt/4}\right)\right),\label{eq:rhom}\\ + \frac{\rho_m}{q_m}&=\exp\{-Dt/2+o(h)\}.\label{eq:rho-over-q} +\end{align} +If $L=\log B_h$ and $c_t=Dt/L$, then, uniformly when $c_t$ stays in a compact subset of the terminal window, +\begin{equation}\label{eq:overlap-scales-quantitative} + q_m=B_h^{-c_t/4+o(1)},\qquad + \rho_m=B_h^{-3c_t/4+o(1)},\qquad + \frac{\rho_m}{q_m}=B_h^{-c_t/2+o(1)}. +\end{equation} +\end{proposition} + +\begin{proof} +Let +\[ + E_i=\log\frac{1-\rho_i}{\bar u_i}. +\] +Lemma~\ref{lem:free-step} gives +\[ + E_i=\frac1bE_{i+1}+O(q_{i+1}), +\] +and +\[ + \log\frac{q_i}{\bar q_i} + =\log\frac{q_{i+1}}{\bar q_{i+1}} + +\frac2bE_{i+1}+O(q_{i+1}). +\] +Both errors vanish at $i=h$. + +Write $r=h-i$. The logarithm of the formal transverse sequence is +\[ + f(r)=r\log b-Dt+\frac{2t}{b-1}b^{-r}. +\] +The function $f$ is convex, so its maximum on $0\le r\le H$ occurs at an endpoint. At $r=0$, $f(0)=-t$, while the definition of $H$ gives +\[ + f(H)\le-\frac14Dt+O_d(1). +\] +Since $D/4<1$, it follows that +\[ + \max_{m\le i\le h}\bar q_i=O_d(e^{-Dt/4}). +\] + +A first-failure bootstrap now gives +\[ + \max_i|E_i|=O(e^{-Dt/4}), + \qquad + \max_i\left|\log\frac{q_i}{\bar q_i}\right| + =O(h e^{-Dt/4}). +\] +For large $h$ these estimates prevent the first failure, proving +\eqref{eq:q-shadow}--\eqref{eq:rho-shadow} and the stated formula for $q_m$. +Furthermore, +\[ + 1-e^{-t/b^H}=t b^{-H}\left(1+O(t b^{-H})\right), +\] +which gives~\eqref{eq:rhom}. Finally, +\[ + \log\frac{\rho_m}{q_m} + =\log t-2H\log b+Dt+o(h) + =-\frac12Dt+o(h). +\] +Because $H=3Dt/(4\log b)+O(1)$ and $L=h\log b+O(1)$, the three estimates in~\eqref{eq:overlap-scales-quantitative} follow. +\end{proof} + + +\section{Stable shadowing from the overlap to the root} +Put +\[ + x=-\log u_1, + \qquad + \widetilde a=\widetilde a_1 + =\frac{\rho_1}{b u_1^D}. +\] + +\begin{proposition}[Uniform stable shadow]\label{prop:stable-shadow} +Under the terminal-window assumptions and the matching choice~\eqref{eq:H-choice}, put +\[ + \chi_m:=q_m+\frac{\rho_m}{q_m}. +\] +Then +\begin{align} + x&=b^{m-1}q_m\left(1+O(\chi_m)\right),\label{eq:x-from-q}\\ + \widetilde a&=\frac{\rho_m}{b^m} + \left(1+O(q_m+\rho_m)\right).\label{eq:atilde-from-rho} +\end{align} +If the messages are normalized by $A_1=1$, then +\begin{equation}\label{eq:Am-stable} + \log A_m + =\frac{b}{b-1}\left(1-b^{-(m-1)}\right)\log u_1 + +O(m\rho_m), +\end{equation} +and +\begin{equation}\label{eq:r-Am} + B_0=\widetilde a\,A_m^2 + \left(1+O(q_m+m\rho_m)\right). +\end{equation} +All constants are uniform in the terminal window. +\end{proposition} + +\begin{proof} +Set $\delta_i=\rho_i/v_i$. Proposition~\ref{prop:free-shadow} gives $\delta_m=O(\rho_m)$, and Lemma~\ref{lem:stable-step} implies geometric backward contraction. Hence +\[ + \sum_{i=2}^m\delta_i=O(\rho_m). +\] +Iteration of~\eqref{eq:stable-invariant-step} gives +\[ + \widetilde a + =\frac{\rho_m}{b^m u_m^D}\left(1+O(\rho_m)\right). +\] +Since $u_m=1-q_m-\rho_m$, this proves~\eqref{eq:atilde-from-rho}. + +The stable-step estimate also gives +\[ + \log v_i=b\log v_{i+1}+O(\rho_{i+1}). +\] +After iteration, +\[ + -\log v_1 + =b^{m-1}[-\log v_m] + +O\left(\sum_{j=2}^m b^{j-2}\rho_j\right) + =b^{m-1}[-\log v_m]+O(b^{m-1}\rho_m). +\] +Since $v_m=1-q_m$, +\[ + -\log v_m=q_m(1+O(q_m)). +\] +Backward contraction gives $\delta_1=O(b^{-(m-1)}\rho_m)$, and therefore +\[ + 0\le\log\frac{v_1}{u_1} + =\log\left(1+\frac{\rho_1}{u_1}\right) + =O(b^{-(m-1)}\rho_m). +\] +This is absorbed by the preceding error. Dividing by the main term +$b^{m-1}q_m$ proves~\eqref{eq:x-from-q}. + +Likewise,~\eqref{eq:stable-logu} and contraction give +\[ + \log u_i=b^{-(i-1)}\log u_1+O(\rho_m) +\] +uniformly for $1\le i0. +\end{equation} +In particular, for all sufficiently large $h$, +\begin{equation}\label{eq:activity-lower} + -\log z(\alpha) + \ge\log\frac1\alpha + +\frac12B_h(1-\alpha)^{B_h} +\end{equation} +uniformly on~\eqref{eq:density-window}. +\end{theorem} + +\begin{proof} +First work in a terminal window and put $c_t=Dt/\log B_h$. We spell out the quantitative root reconstruction because its additive error is later compared with a vanishing coupon term. Proposition~\ref{prop:stable-shadow} and the exact root formulas give +\[ + B_0=\widetilde a\,u_1^{D+1} + \left(1+O(q_m+m\rho_m)\right), + \qquad + \kappa=u_1^b\left(1+O(\rho_m)\right). +\] +Since $z=B_0\kappa/(1+B_0)^b$, $x=-\log u_1$, and +$B_0=B_h^{-1+o(1)}$, it follows that +\[ + -\log z + =\log\frac1{\widetilde a}+Kx + +O(q_m+m\rho_m+B_0) + =\log\frac1{\widetilde a}+Kx + +B_h^{-c_t/4+o(1)}, + \qquad K=b+D+1=bD. +\] +The arithmetic identity +\[ + Kb^{h-1}=Db^h=B_h+\frac2{b-1} +\] +and Theorem~\ref{thm:matching} yield +\[ + Kx + =B_h(1-a_h(t))^{B_h} + \left(1+B_h^{-c_t/4+o(1)}\right). +\] +Lemma~\ref{lem:Ze-localization} gives +\[ + \log\frac{\alpha}{\widetilde a}=B_h^{-c_t/4+o(1)}, + \qquad + B_h|\alpha-a_h(t)|=B_h^{-c_t/4+o(1)}. +\] +The latter estimate changes the coupon term relatively by +$1+B_h^{-c_t/4+o(1)}$. Hence +\begin{equation}\label{eq:activity-terminal-quantitative} + -\log z-\log\frac1\alpha + =B_h(1-\alpha)^{B_h} + \left(1+B_h^{-c_t/4+o(1)}\right) + +B_h^{-c_t/4+o(1)}. +\end{equation} + +It remains to identify the terminal range corresponding to~\eqref{eq:density-window}. By~\eqref{eq:alpha-atilde}, uniformly in every terminal window, +\[ + \alpha B_h=Dt\left(1+B_h^{-c_t/4+o(1)}\right). +\] +As in the qualitative proof, choose a slightly broader terminal window whose endpoint densities bracket~\eqref{eq:density-window}; strict activity and density monotonicity then expose every density in the displayed interval. Uniformly there, $c_t=c+o(1)$, and~\eqref{eq:activity-terminal-quantitative} becomes~\eqref{eq:activity-additive-quantitative}. + +Finally, +\[ + B_h(1-\alpha)^{B_h}=B_h^{1-c+o(1)}. +\] +The additive error in~\eqref{eq:activity-additive-quantitative} is therefore relatively +$B_h^{3c/4-1+o(1)}$, while the multiplicative orbit error is +$B_h^{-c/4+o(1)}$. Taking the worst exponent over $[c_-,c_+]$ proves +\eqref{eq:activity-law}--\eqref{eq:activity-delta}; +\eqref{eq:activity-lower} follows immediately. +\end{proof} + + +\section{A capped-free profile and the bounded critical window}\label{sec:bounded-window} +Put +\[ + B=B_h,\qquad L=\log B, + \qquad + \Dcal_h(\alpha):=H(\alpha)-\Psi_{d,h}(\alpha), + \qquad + Q_h(\alpha):=(1-\alpha)^B. +\] +We now construct a feasible profile whose only entropy loss is the conditioning event at the terminal wall. + +Put $r=1-\alpha$ and +\[ + T_j=1+b+\cdots+b^j=\frac{b^{j+1}-1}{b-1}. +\] +For $h\ge2$, define +\begin{equation}\label{eq:capped-free-messages} + A_i=r^{T_{i-1}}\quad(1\le i\le h), + \qquad A_{h+1}=0, +\end{equation} +\begin{equation}\label{eq:capped-free-B} + B_0=\alpha, + \qquad B_i=A_i-A_{i+1}\quad(1\le i\frac{C}{Db} + =\frac{d-2}{d(d-1)}C. +\] +Under~\eqref{eq:explicit-feasibility-threshold} and $|s|\le M$, this is at least $\log(d/(d-1))$. Therefore +\[ + \varepsilon_h=(1-\alpha)^{b^{h-1}} + \le e^{-\alpha b^{h-1}} + \le\frac{d-1}{d}, +\] +as required. +\end{proof} + +Define +\[ + \Delta_d(\varepsilon) + :=dH(\varepsilon)-s_d(1-\varepsilon). +\] + +\begin{lemma}[Exact capped-free entropy loss]\label{lem:capped-free-loss} +Let $\Fcal_{d,h}^{\rm cap}(\alpha)$ be the compact functional at the profile +\eqref{eq:capped-free-beliefs}. Then +\begin{equation}\label{eq:capped-free-loss} + H(\alpha)-\Fcal_{d,h}^{\rm cap}(\alpha) + =p_h\Delta_d(\varepsilon_h), +\end{equation} +and +\begin{equation}\label{eq:capped-coupon-exact} + p_h\varepsilon_h^d=(1-\alpha)^B. +\end{equation} +Moreover, as $\varepsilon\downarrow0$, +\begin{equation}\label{eq:terminal-gap-asymptotic} + \Delta_d(\varepsilon) + =\varepsilon^d\left(1+O_d(\varepsilon^{d-1})\right). +\end{equation} +\end{lemma} + +\begin{proof} +First replace the terminal entropy $s_d(a_h)$ by the unconstrained coordinate entropy $dH(a_h)=dH(\varepsilon_h)$. For every nonterminal layer, and for the relaxed terminal layer, the dual entropy identity and the message row identities reduce the layer contribution to +\[ + p_i\log\kappa + -d x_i\log B_{i-1} + +d x_{i+1}\log B_i, + \qquad x_{h+1}=0. +\] +The sum over $i=1,\ldots,h$ telescopes to +\[ + (1-\alpha)\log\kappa-dx_1\log B_0. +\] +The root contribution is +\[ + (d-1)\alpha\log\alpha + -\frac d2\alpha^2\log(\alpha^2). +\] +Using $\kappa=1/r$, $x_1=\alpha r$, and $B_0=\alpha$, the relaxed total is exactly $H(\alpha)$. Restoring the nonempty terminal condition subtracts the right side of~\eqref{eq:capped-free-loss}. + +Since $A_h=rA_{h-1}^b$, +\[ + p_h\varepsilon_h^d + =A_{h-1}A_h\left(\frac{A_h}{A_{h-1}}\right)^d + =rA_h^d + =r^{1+dT_{h-1}} + =r^B, +\] +which proves~\eqref{eq:capped-coupon-exact}. + +For~\eqref{eq:terminal-gap-asymptotic}, let $\eta$ be the coordinate-exclusion probability before conditioning on a nonempty subset. Then +\[ + \varepsilon=\frac{\eta-\eta^d}{1-\eta^d}, + \qquad + 1-\varepsilon=\frac{1-\eta}{1-\eta^d}, + \qquad + \eta-\varepsilon=(1-\varepsilon)\eta^d, +\] +so $\eta=\varepsilon+O(\varepsilon^d)$. Substituting the first two identities into the entropy of the conditioned product measure gives +\[ + \Delta_d(\varepsilon) + =-\log(1-\eta^d) + -d\,\mathrm{KL}\!\left( + \operatorname{Ber}(\varepsilon) + \Vert\operatorname{Ber}(\eta) + \right). +\] +The Bernoulli chi-square bound makes the KL term +$O_d(\varepsilon^{2d-1})$, while +$-\log(1-\eta^d)=\varepsilon^d(1+O_d(\varepsilon^{d-1}))$. +\end{proof} + +\begin{proof}[Proof of Theorem~\ref{thm:bounded-critical-window}] +The upper type anchor gives $\Psi_{d,h}(\alpha)\le H(\alpha)$, while +Lemma~\ref{lem:capped-free-feasible} supplies a feasible compact profile for every sufficiently large $h$, uniformly in the displayed window. Hence Lemma~\ref{lem:capped-free-loss} implies +\[ + 0\le \Dcal_h(\alpha) + \le Q_h(\alpha) + \left(1+O_d(\varepsilon_h^{d-1})\right). +\] +There, +\[ + \varepsilon_h=(1-\alpha)^{b^{h-1}} + =B^{-\frac{d-2}{d(d-1)}+o(1)}, +\] +which proves the upper bound in~\eqref{eq:defect-bounded-two-sided}. + +For the lower bound, define +\[ + \mathcal A_h(a) + :=-\log z(a)-\log\frac1a. +\] +The envelope identity~\eqref{eq:globality} gives +\begin{equation}\label{eq:defect-derivative} + -\Dcal_h'(a)=-\log(1-a)+\mathcal A_h(a). +\end{equation} +Put +\[ + C_0=L-2\log L+s, + \quad \alpha_0=C_0/B, + \qquad + C_1=\frac87L, + \quad \alpha_1=C_1/B. +\] +For all large $h$, the interval lies in the fixed density window with +$c_-=3/4$ and $c_+=8/7$. Theorem~\ref{thm:activity-law} applies uniformly there with relative error $B^{-1/7+o(1)}$. Since +$-\log(1-a)\ge0$ and $\Dcal_h(\alpha_1)\ge0$, +\begin{align*} + \Dcal_h(\alpha_0) + &\ge + \left(1-B^{-1/7+o(1)}\right) + B\int_{\alpha_0}^{\alpha_1}(1-a)^B\,da\\ + &=\left(1-B^{-1/7+o(1)}\right) + \frac{B}{B+1} + \left[(1-\alpha_0)^{B+1}-(1-\alpha_1)^{B+1}\right]. +\end{align*} +Finally, +\[ + \frac{(1-\alpha_1)^B}{(1-\alpha_0)^B} + =B^{-1/7}L^{-2}e^{s+o(1)}, +\] +and $\alpha_0=O(L/B)$. This proves the lower bound in +\eqref{eq:defect-bounded-two-sided} and hence~\eqref{eq:defect-bounded-window}. +\end{proof} + +\begin{proof}[Proof of Corollary~\ref{cor:critical-scaling-function}] +Uniformly for bounded $s$, +\[ + H\!\left(\frac{L-2\log L+s}{B}\right) + =\frac{L^2}{B}(1+o(1)), +\] +whereas +\[ + \left(1-\frac{L-2\log L+s}{B}\right)^B + =e^{-s}\frac{L^2}{B}(1+o(1)). +\] +Apply Theorem~\ref{thm:bounded-critical-window}. +\end{proof} + +\begin{proof}[Proof of Corollary~\ref{cor:annealed-zero-expansion}] +For every fixed $\epsilon>0$, Corollary~\ref{cor:critical-scaling-function} gives opposite signs at $s=-\epsilon$ and $s=\epsilon$ for all large $h$; because strict concavity makes the superlevel set +$\{\alpha:\Psi_{d,h}(\alpha)\ge0\}$ an interval, its lower endpoint satisfies +\[ + C_h^{\rm ann}=L-2\log L+o(1). +\] + +Let +\[ + F_B(C):=H(C/B)-(1-C/B)^B. +\] +Theorem~\ref{thm:bounded-critical-window} gives +\[ + \Psi_{d,h}(C/B) + =F_B(C)+O\!\left((1-C/B)^B B^{-1/7+o(1)}\right) +\] +uniformly in a fixed neighborhood of the scalar root. There, +\[ + F_B'(C)=(1+o(1))(1-C/B)^B, +\] +uniformly and with positive sign. A two-sided mean-value comparison therefore proves~\eqref{eq:graph-scalar-zero}. + +It remains to invert the scalar equation. For $C=L+O(\ell)$, +\eqref{eq:scalar-coupon-root} is equivalent to +\begin{equation}\label{eq:scalar-log-equation} + C+\log C+\log(L-\log C+1) + =L+O(L^2/B). +\end{equation} +The equation first gives $C=L-2\ell+O(\ell/L)$; substituting this estimate successively into the two logarithms determines the coefficients through order $L^{-3}$. If $P_3$ denotes the displayed polynomial in~\eqref{eq:annealed-zero-expansion}, direct expansion gives +\[ + P_3+\log P_3+\log(L-\log P_3+1)-L + =O(\ell^4/L^4), +\] +while the derivative of the left side of~\eqref{eq:scalar-log-equation} is $1+O(1/L)$. Thus the mean-value theorem yields +\[ + \widehat C_B-P_3 + =O(\ell^4/L^4+L^2/B) + =O(\ell^4/L^4), +\] +which supplies the claimed bootstrap remainder. Finally, +$B^{-1/7+o(1)}$ is smaller than every fixed inverse power of $L$, so~\eqref{eq:graph-scalar-zero} transfers the expansion to $C_h^{\rm ann}$. Repeating the same formal substitution and mean-value estimate yields the expansion to any prescribed fixed inverse power of $L$. +\end{proof} + +\section{Near-critical integration and the random-graph deduction}\label{sec:integration} +We first prove Theorem~\ref{thm:near-critical}. The pointwise anchor is Corollary~\ref{cor:Psi-le-H}. + +\begin{proof}[Proof of Theorem~\ref{thm:near-critical}] +Put $B=B_h$ and $L=\log B$. For a value $C$ in~\eqref{eq:C-window}, set +\[ + \alpha_- =\frac CB, + \qquad + C^\sharp=\frac{L+C}{2}, + \qquad + \alpha_+=\frac{C^\sharp}{B}. +\] +For large $h$, the interval $[\alpha_-,\alpha_+]$ lies in a fixed density window +\[ + \frac\eta2\frac LB + \le a\le + \frac LB, +\] +so Theorem~\ref{thm:activity-law} applies uniformly. Exact globality and the envelope identity~\eqref{eq:globality} give +\[ + \Psi(\alpha_-) + =\Psi(\alpha_+)+\int_{\alpha_-}^{\alpha_+}\log z(a)\,da. +\] +Corollary~\ref{cor:Psi-le-H} and~\eqref{eq:activity-lower} imply +\begin{align*} + \Psi(\alpha_-) + &\le H(\alpha_+) + -\int_{\alpha_-}^{\alpha_+}\log\frac1a\,da + -\frac12\int_{\alpha_-}^{\alpha_+}B(1-a)^B\,da\\ + &\le H(\alpha_-) + -\frac12\frac{B}{B+1} + \left[(1-\alpha_-)^{B+1}-(1-\alpha_+)^{B+1}\right]. +\end{align*} +The second inequality uses $H'(a)=\log(1/a)+\log(1-a)$. + +Write +\[ + W(C):=L-2\log L-C. +\] +By hypothesis $W(C)\ge W_h\to\infty$. Uniformly over~\eqref{eq:C-window}, +\[ + H(\alpha_-)=O\left(\frac{L^2}{B}\right) + =o(e^{-C}), +\] +because +\[ + e^{-C}=e^{W(C)}\frac{L^2}{B}. +\] +Also $C=O(L)$ gives +\[ + (1-\alpha_-)^{B+1}=e^{-C}(1+o(1)). +\] +Finally, +\[ + C^\sharp-C=\frac{L-C}{2} + \ge\log L+\frac{W_h}{2}, +\] +so +\[ + (1-\alpha_+)^{B+1} + =e^{-C^\sharp}(1+o(1)) + =o(e^{-C}). +\] +All estimates are uniform, proving~\eqref{eq:near-critical-psi}. +\end{proof} + +\begin{proof}[Proof of Corollary~\ref{cor:fixed-slack}] +For $C=cL$ with $c\in[c_-,c_+]$, one has +\[ + L-2\log L-C=(1-c)L-2\log L\to\infty +\] +uniformly. Theorem~\ref{thm:near-critical} gives $e^{-C}=B^{-c}$. +\end{proof} + +\begin{proof}[Proof of Theorem~\ref{thm:random-fixed-window}] +Put +\[ + C_h^*=L_h-2\log L_h-\omega, + \qquad + T_n=\frac{nC_h^*}{B_h}. +\] +If $T_n\le1$, then the real-valued lower bound follows from +$\gamma_h(G)\ge1$. Otherwise set +\[ + m_n=\lceil T_n\rceil-1, + \qquad + C_n=\frac{m_nB_h}{n}. +\] +Condition~\eqref{eq:fixed-window-growth} implies $B_h/n=o(1)$. Indeed, $h=\Theta_d(L_h)$. If $B_h\ge n$ along a subsequence and $x=B_h/n\ge1$, then +\[ + \frac{nL_h^2/B_h}{h\log n} + =\Theta_d\!\left( + \frac{L_h}{x\log n} + \right) + =\Theta_d\!\left( + \frac{1+\log x/\log n}{x} + \right) + =O_d(1), +\] +contradicting~\eqref{eq:fixed-window-growth}. Hence $B_h0$. If $T_n\le1$, the real-valued lower bound follows from $\gamma_h(G)\ge1$. Otherwise put +\[ + m_n=\lceil T_n\rceil-1, + \qquad + C_n=\frac{m_nB_h}{n}. +\] +Then $m_n0$ such that $C_h^*\ge2\eta L_h$ eventually. Then, for large $h$, +\[ + \eta L_h\le C_n\le C_h^*. +\] +Apply Theorem~\ref{thm:near-critical} with this $\eta$ and the same $W_h$. Since $e^{-C_n}\ge e^{-C_h^*}=e^{W_h}L_h^2/B_h$ and $\frac12-o(1)\ge\frac13$ for sufficiently large $h$, +\[ + \log\E Z_{n,d,h}(m_n) + \le-\frac13 + \frac{n e^{W_h}L_h^2}{B_h} + +O_d(h\log n), +\] +which tends to $-\infty$ by~\eqref{eq:near-growth-condition}. Markov's inequality shows that the configuration model has no dominating set of size exactly $m_n$ with high probability. Any smaller dominating set could be padded to size $m_n$, so +\[ + \gamma_h(G)\ge m_n+1=\lceil T_n\rceil\ge T_n. +\] +Conditioning on simplicity transfers the conclusion to the uniformly random simple $d$-regular graph. +\end{proof} + +\begin{proof}[Proof of Corollary~\ref{cor:random-fixed}] +Choose +\[ + W_h=\eps L_h-2\log L_h. +\] +Then $W_h\to\infty$, +\[ + C_h^*=(1-\eps)L_h, + \qquad + \frac{C_h^*}{L_h}=1-\eps>0, +\] +and +\[ + \frac{e^{W_h}L_h^2}{B_h}=B_h^{-(1-\eps)}. +\] +Thus Theorem~\ref{thm:random-near-critical} applies directly. +\end{proof} + + + +\section{Numerical verification and conditioning}\label{sec:numerics} +The proofs above do not use numerical evidence. The accompanying package nevertheless checks every stationary identity, the one-step shadowing expansions, the density targeting, and the activity law with controlled-precision arithmetic. + +A cancellation issue discovered during numerical verification is important for reproducibility. Direct evaluation of +\[ + \phi=\log Z_v-\frac d2\log Z_e +\] +can be ill-conditioned near criticality: both logarithms are large, and individual vertex terms of the form $S_i^d-(S_i-B_{i-1})^d$ may lose precision. The consolidated solver evaluates these differences as +\[ + S_i^d\bigl[-\operatorname{expm1}(d\log(1-B_{i-1}/S_i))\bigr] +\] +and reports the microcanonical exponent through the corrected root-only formula~\eqref{eq:root-micro-correct}. The direct partition-function route is retained as an independent comparison. A row is rejected unless +\[ + \frac{|\Psi_{Z_v}-\Psi_{\rm root}|}{|\Psi_{\rm root}|}\le10^{-6}. +\] +Thus a cross-check residual comparable to the reported quantity gates the record rather than remaining hidden in an internal data structure. + +For the diagnostic choice $W_h=\log\log B_h$, the regenerated cubic values are +\begin{center} +\begin{tabular}{cccc} +\toprule +$h$ & $C$ & $-\Psi/e^{-C}$ & activity-law ratio\\ +\midrule +20 & $6.8451$ & $0.6484$ & $0.5873$\\ +30 & $12.6345$ & $0.9418$ & $0.9520$\\ +40 & $18.7408$ & $0.9757$ & $0.9956$\\ +52 & $26.2980$ & $0.9819$ & $0.9997$\\ +\bottomrule +\end{tabular} +\end{center} +The activity-law ratio is +\[ + \frac{-\log z-\log(1/\alpha)}{B_h(1-\alpha)^{B_h}}. +\] +The effective onset is slower when $C/\log B_h$ is small, consistent with uniformity only on compact intervals bounded away from zero. Every numerical row records the checker hash, solver hash, precision, stationarity residual, telescoping residual, and the discrepancy between the direct and root-only free-energy routes. + + + + +The bounded-window audit evaluates both the true stationary optimizer and the capped-free profile at the center $s=0$. The two quantities approach the coupon term from opposite sides: +\begin{center} +\begin{tabular}{cccc} +\toprule +$(d,h)$ & $(H-\Psi)/Q_h$ & cap loss$/Q_h$ & $C_h^{\rm ann}-\widehat C_{B_h}$\\ +\midrule +$(3,20)$ & $0.8750056$ & $1.0593258$ & $-1.03077\times10^{-1}$\\ +$(3,30)$ & $0.9902396$ & $1.0076650$ & $-8.00386\times10^{-3}$\\ +$(3,40)$ & $0.9990005$ & $1.0009330$ & $-8.57353\times10^{-4}$\\ +$(4,24)$ & $0.9999148$ & $1.0000702$ & $-6.78418\times10^{-5}$\\ +$(4,32)$ & $0.9999988$ & $1.0000012$ & $-1.01763\times10^{-6}$\\ +\bottomrule +\end{tabular} +\end{center} +The cap-loss and product identities are evaluated independently from the compact functional; their recorded residuals are below $10^{-120}$ in the displayed high-precision runs. The stationary entropy-defect identity~\eqref{eq:defect-identity} and the graph-zero computations provide separate checks of the two sides of the proof. + +\section{Consequences and open problems}\label{sec:open} +Theorems~\ref{thm:bounded-critical-window} and~\ref{thm:random-fixed-window} determine the annealed transition, its bounded window, and its universal scaling function. The capped-free construction also isolates the mechanism behind the coupon term: at the critical scale, the leading entropy defect is the cost of forbidding one empty terminal branch set. Two harder frontiers remain logically separate. + +\paragraph{Quenched matching.} +The random-regular theorem is a first-moment lower bound. A matching upper bound near $C_h^{\rm ann}$ would require proving that dominating sets actually exist above the annealed crossing. Plausible routes include a second-moment analysis with an overlap variational problem, small-subgraph conditioning, or an algorithmic construction whose output reaches the scalar coupon coordinate. The bounded-window theorem now supplies a precise target and separates any quenched correction from uncertainty in the annealed calculation. + +\paragraph{Direct two-branch asymptotics.} +Internally two-path $(h,2)$ domination is stronger than ordinary distance domination, so the present lower bound transfers automatically. It does not identify the parameter's own leading constant or critical correction. A direct treatment must remember branch information without falsely treating two paths that later merge as internally disjoint. Directed nonbacktracking cavity states are a natural exact intermediary, but the corresponding type system and variational reduction remain to be developed. + +The exponent $1/7$ in the graph--scalar comparison is a bookkeeping exponent rather than a predicted optimum. Improving it would sharpen finite-$h$ convergence but would not change the scaling function or any fixed-order inverse-logarithmic term. + +\section*{Acknowledgements} +\emph{To be completed by the author before circulation.} + +\begin{thebibliography}{99} +\bibitem{AignerFromme1984} +M. Aigner and M. Fromme, +\emph{A game of cops and robbers}, +Discrete Applied Mathematics 8 (1984), 1--12; DOI: \href{https://doi.org/10.1016/0166-218X(84)90073-8}{10.1016/0166-218X(84)90073-8}. + +\bibitem{CohenHonkalaLitsynLobstein1997} +G. Cohen, I. Honkala, S. Litsyn, and A. Lobstein, +\emph{Covering Codes}, +North-Holland Mathematical Library 54, Elsevier, 1997. + +\bibitem{CutlerRadcliffe2016} +J. Cutler and A. J. Radcliffe, +\emph{Counting dominating sets and related structures in graphs}, +Discrete Mathematics 339 (2016), 1593--1599; DOI: \href{https://doi.org/10.1016/j.disc.2015.12.011}{10.1016/j.disc.2015.12.011}. + +\bibitem{Duckworth2005} +W. Duckworth, +\emph{Randomized greedy algorithms for finding small $k$-dominating sets of regular graphs}, +Random Structures \& Algorithms 27 (2005), 401--412; DOI: \href{https://doi.org/10.1002/rsa.20082}{10.1002/rsa.20082}. + +\bibitem{DuckworthWormald2006} +W. Duckworth and N. C. Wormald, +\emph{On the independent domination number of random regular graphs}, +Combinatorics, Probability and Computing 15 (2006), 513--522; DOI: \href{https://doi.org/10.1017/S0963548305007431}{10.1017/S0963548305007431}. + +\bibitem{GlebovLiebenauSzabo2015} +R. Glebov, A. Liebenau, and T. Szab\'o, +\emph{On the concentration of the domination number of the random graph}, +SIAM Journal on Discrete Mathematics 29 (2015), 1186--1206; DOI: \href{https://doi.org/10.1137/12090054X}{10.1137/12090054X}. + +\bibitem{HabibullaQin2019} +Y. Habibulla and S.-m. Qin, +\emph{Two-distance minimal dominating set problem studied by statistical mechanics and simulated annealing}, +arXiv:1910.07933, 2019; \href{https://arxiv.org/abs/1910.07933}{arXiv link}. + +\bibitem{Janson2009} +S. Janson, +\emph{The probability that a random multigraph is simple}, +Combinatorics, Probability and Computing 18 (2009), 205--225; DOI: \href{https://doi.org/10.1017/S0963548308009644}{10.1017/S0963548308009644}. + +\bibitem{LuPeng2012} +L. Lu and X. Peng, +\emph{On Meyniel's conjecture of the cop number}, +Journal of Graph Theory 71 (2012), 192--205; DOI: \href{https://doi.org/10.1002/jgt.20642}{10.1002/jgt.20642}. + +\bibitem{Neuwirth2026Tube} +L. Neuwirth, +\emph{Branch-tube persistence and static coverage in tree-ball geometry}, +preprint, 2026; \href{https://levineuwirth.org/essays/branch-based-local-capture-in-tree-balls/}{project page}. + +\bibitem{PralatWormald2016} +P. Pra\l at and N. C. Wormald, +\emph{Meyniel's conjecture holds for random graphs}, +Random Structures \& Algorithms 48 (2016), 396--421; DOI: \href{https://doi.org/10.1002/rsa.20587}{10.1002/rsa.20587}. + +\bibitem{PralatWormald2019} +P. Pra\l at and N. C. Wormald, +\emph{Meyniel's conjecture holds for random $d$-regular graphs}, +Random Structures \& Algorithms 55 (2019), 719--741; DOI: \href{https://doi.org/10.1002/rsa.20874}{10.1002/rsa.20874}. + +\bibitem{RockafellarWets1998} +R. T. Rockafellar and R. J.-B. Wets, +\emph{Variational Analysis}, +Grundlehren der mathematischen Wissenschaften 317, Springer, 1998; DOI: \href{https://doi.org/10.1007/978-3-642-02431-3}{10.1007/978-3-642-02431-3}. + +\bibitem{ScottSudakov2011} +A. Scott and B. Sudakov, +\emph{A bound for the cops and robbers problem}, +SIAM Journal on Discrete Mathematics 25 (2011), 1438--1442; DOI: \href{https://doi.org/10.1137/100812963}{10.1137/100812963}. + +\bibitem{Wormald1999} +N. C. Wormald, +\emph{Models of random regular graphs}, +in Surveys in Combinatorics, 1999, London Mathematical Society Lecture Note Series 267, Cambridge University Press, 1999, pp. 239--298; DOI: \href{https://doi.org/10.1017/CBO9780511721335.010}{10.1017/CBO9780511721335.010}. + +\bibitem{ZhaoHabibullaZhou2015} +J.-H. Zhao, Y. Habibulla, and H.-J. Zhou, +\emph{Statistical mechanics of the minimum dominating set problem}, +Journal of Statistical Physics 159 (2015), 1154--1174; DOI: \href{https://doi.org/10.1007/s10955-015-1220-2}{10.1007/s10955-015-1220-2}. +\end{thebibliography} + +\end{document} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8a0b5eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "levineuwirth-tools" +version = "0.1.0" +description = "Build-time tooling for levineuwirth.org" +requires-python = ">=3.14" +dependencies = [ + # Visualization + "matplotlib>=3.9,<4", + "altair>=5.4,<6", + # Embedding pipeline + # Upper bounds are intentionally generous (next major) but always + # present so that an unrelated `uv sync` upgrade can't silently pull + # an API-breaking 4.x release. Bump deliberately when validating. + "sentence-transformers>=3.4,<4", + "faiss-cpu>=1.9,<2", + "numpy>=2.0,<3", + "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", + # Not imported by this repo: required at runtime by nomic-embed's + # remote modeling code (nomic-bert-2048, loaded by embed.py's page + # pass under trust_remote_code with a pinned code_revision). + "einops>=0.8.2,<1", +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + +[tool.uv.sources] +torch = [{ index = "pytorch-cpu" }] + +[tool.uv] diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..9c9de1a Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/css/annotations.css b/static/css/annotations.css new file mode 100644 index 0000000..a05baaa --- /dev/null +++ b/static/css/annotations.css @@ -0,0 +1,221 @@ +/* annotations.css — User highlight marks and annotation tooltip */ + +/* ============================================================ + HIGHLIGHT MARKS + ============================================================ */ + +mark.user-annotation { + /* Reset browser UA mark color (can be blue on dark system themes) */ + background-color: transparent; + color: inherit; + border-radius: 3px; + padding: 0.18em 0.28em; + cursor: pointer; + transition: filter 0.1s ease; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +/* Double class = specificity (0,2,1), beats UA mark rule and the base reset above */ +mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.32); } +mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.34); } +mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.34); } +mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.34); } + +mark.user-annotation:hover { filter: brightness(0.80); } + +/* Dark mode — slightly more opaque so highlights stay visible */ +[data-theme="dark"] mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.40); } +[data-theme="dark"] mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.42); } +[data-theme="dark"] mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); } +[data-theme="dark"] mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.42); } + +/* Cappuccino — dark warm bg, so highlights need dark-mode-level opacity */ +[data-theme="cappuccino"] mark.user-annotation.user-annotation--amber { background-color: rgba(245, 180, 70, 0.42); } +[data-theme="cappuccino"] mark.user-annotation.user-annotation--sage { background-color: rgba(130, 175, 140, 0.42); } +[data-theme="cappuccino"] mark.user-annotation.user-annotation--steel { background-color: rgba(140, 170, 200, 0.42); } +[data-theme="cappuccino"] mark.user-annotation.user-annotation--rose { background-color: rgba(220, 140, 140, 0.42); } + +/* System dark mode fallback (for prefers-color-scheme without explicit data-theme) */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.40); } + :root:not([data-theme]) mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.42); } + :root:not([data-theme]) mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); } + :root:not([data-theme]) mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.42); } +} + +/* ============================================================ + ANNOTATION TOOLTIP + Appears on hover over a mark. Inverted colours like the + selection popup (--text bg, --bg text). + ============================================================ */ + +.ann-tooltip { + position: absolute; + z-index: 750; + min-width: 8rem; + max-width: 15rem; + background: var(--text); + color: var(--bg); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.22); + padding: 0.45rem 0.6rem; + font-family: var(--font-sans); + font-size: 0.73rem; + line-height: 1.45; + + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease, visibility 0.12s ease; + pointer-events: none; +} + +.ann-tooltip.is-visible { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.ann-tooltip-note { + margin-bottom: 0.35rem; + white-space: pre-wrap; + word-break: break-word; +} + +.ann-tooltip-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.ann-tooltip-date { + opacity: 0.55; + font-size: 0.68rem; +} + +.ann-tooltip-delete { + background: none; + border: none; + color: var(--bg); + opacity: 0.65; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.7rem; + padding: 0; + line-height: 1; + transition: opacity 0.1s ease; +} + +.ann-tooltip-delete:hover { opacity: 1; } + +/* ============================================================ + ANNOTATE PICKER + Appears above the selection when "Annotate" is clicked. + Inverted colors (dark bg, light text) — same as the tooltip. + ============================================================ */ + +.ann-picker { + position: absolute; + z-index: 760; + background: var(--text); + color: var(--bg); + border-radius: 5px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.28); + padding: 0.55rem 0.6rem; + font-family: var(--font-sans); + font-size: 0.75rem; + width: 13rem; + + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease, visibility 0.12s ease; + pointer-events: none; +} + +.ann-picker.is-visible { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.ann-picker-swatches { + display: flex; + gap: 0.45rem; + margin-bottom: 0.5rem; + align-items: center; +} + +.ann-picker-swatch { + width: 1.05rem; + height: 1.05rem; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + flex-shrink: 0; + outline: none; + transition: transform 0.1s ease, border-color 0.1s ease; +} + +.ann-picker-swatch--amber { background: rgba(245, 158, 11, 0.9); } +.ann-picker-swatch--sage { background: rgba(107, 158, 120, 0.9); } +.ann-picker-swatch--steel { background: rgba(112, 150, 184, 0.9); } +.ann-picker-swatch--rose { background: rgba(200, 116, 116, 0.9); } + +.ann-picker-swatch.is-selected, +.ann-picker-swatch:focus-visible { + border-color: var(--bg); + transform: scale(1.2); +} + +.ann-picker-note { + display: block; + width: 100%; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + color: inherit; + font-family: var(--font-sans); + font-size: 0.72rem; + padding: 0.28rem 0.4rem; + margin-bottom: 0.45rem; + outline: none; +} + +.ann-picker-note::placeholder { opacity: 0.5; } + +.ann-picker-note:focus { + border-color: rgba(255, 255, 255, 0.45); +} + +.ann-picker-actions { + display: flex; + justify-content: flex-end; +} + +.ann-picker-submit { + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + color: inherit; + font-family: var(--font-sans); + font-size: 0.72rem; + padding: 0.22rem 0.55rem; + cursor: pointer; + line-height: 1.4; + transition: background 0.1s ease; +} + +.ann-picker-submit:hover { + background: rgba(255, 255, 255, 0.28); +} + +/* ============================================================ + REDUCE MOTION + ============================================================ */ + +[data-reduce-motion] mark.user-annotation { transition: none; } +[data-reduce-motion] .ann-tooltip { transition: none; } +[data-reduce-motion] .ann-picker { transition: none; } diff --git a/static/css/archive.css b/static/css/archive.css new file mode 100644 index 0000000..895f350 --- /dev/null +++ b/static/css/archive.css @@ -0,0 +1,463 @@ +/* archive.css — the link archive: /archive/ and /archive//. + * + * Gated in head.html via $if(archive)$ (build/Archive.hs sets the flag on + * the index and every entry page). The archive pages are structured + * surfaces rather than prose, but they render inside #markdownBody — so + * every rule here is scoped under #markdownBody to clear the id-specificity + * prose rules in typography.css (heading scales, figure framing, paragraph + * indent) that would otherwise win over a bare class. + * + * Treatment: "framed / structured" — the archival chrome (banner, + * provenance panel, the embedded artifact viewer) is given visible borders + * so a reader is never in doubt that this is a preservation copy, not the + * original. All colour comes from tokens, so dark mode follows for free; + * the embedded artifact itself is shown raw and is deliberately not themed. + */ + +/* Structured pages, not essays — no first-line indent on any paragraph. */ +#markdownBody :is(.archive-banner-text, .archive-degraded, .archive-note, + .archive-private, .archive-status-note, .archive-index-intro, + .archive-removal, .archive-empty), +#markdownBody .archive-fulltext-wrap > p { + text-indent: 0; +} + +/* ============================================================ + ENTRY HEADER + ARCHIVAL BANNER + The banner is a bordered callout, stacked: a small-caps label, + one plain-language line, and the original link given real + weight — the original is the hero, never the archived copy. + ============================================================ */ + +#markdownBody .archive-header { + margin-bottom: 0.5rem; +} + +#markdownBody .archive-header .page-title { + margin-bottom: 0; +} + +#markdownBody .archive-banner { + margin-top: 1.4rem; + padding: 0.9rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + border: 1px solid var(--border-muted); + border-radius: 2px; + background: var(--bg-subtle); +} + +#markdownBody .archive-banner-label { + margin: 0; + font-family: var(--font-sans); + font-size: 0.7rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.13em; + color: var(--text-muted); +} + +#markdownBody .archive-banner-text { + margin: 0; + font-family: var(--font-serif); + font-size: 0.95rem; + line-height: 1.5; + color: var(--text); +} + +#markdownBody .archive-banner-original { + align-self: flex-start; + font-family: var(--font-sans); + font-size: 0.85rem; + font-weight: 600; +} + +/* Degraded / js-required snapshots: a dashed-border note. Restrained — + the monochrome palette has no alarm colour and wants none. */ +#markdownBody .archive-degraded { + margin: 1rem 0 0; + padding: 0.7rem 1rem; + border: 1px dashed var(--border-muted); + border-radius: 2px; + font-family: var(--font-serif); + font-size: 0.9rem; + line-height: 1.55; + color: var(--text-muted); +} + +#markdownBody .archive-degraded-label { + margin-right: 0.4rem; + font-family: var(--font-sans); + font-size: 0.7rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.1em; + color: var(--text); +} + +/* Private entry: the artifact is held offline, not published — a calm + informational panel in place of the artifact viewer. */ +#markdownBody .archive-private { + margin: 1.8rem 0; + padding: 1rem 1.2rem; + border: 1px solid var(--border); + border-radius: 2px; + background: var(--bg-subtle); + font-family: var(--font-serif); + font-size: 0.95rem; + line-height: 1.6; + color: var(--text-muted); +} + +/* Link-rot status — a header note for non-live states (archive.py check), + and the status word in the provenance panel. The palette is monochrome, + so a `rotted` entry is marked by weight and a heavier left rule, never + colour. */ +#markdownBody .archive-status-note { + margin: 1rem 0 0; + padding: 0.7rem 1rem; + border: 1px solid var(--border-muted); + border-left-width: 3px; + border-radius: 2px; + font-family: var(--font-serif); + font-size: 0.92rem; + line-height: 1.55; + color: var(--text); +} + +#markdownBody .archive-status-note--rotted { + border-left-color: var(--text); +} + +#markdownBody .archive-status-note--moved { + color: var(--text-muted); +} + +#markdownBody .archive-status { + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.04em; +} + +#markdownBody .archive-status--live { + color: var(--text-muted); +} + +#markdownBody .archive-status--rotted { + font-weight: 600; +} + +/* ============================================================ + PROVENANCE PANEL + A bordered box with a small-caps label; the metadata is a + two-column key/value grid — labels auto-sized, values take + the rest, long URLs and hashes wrap rather than overflow. + ============================================================ */ + +#markdownBody .archive-provenance { + margin: 1.8rem 0; + padding: 1rem 1.2rem 1.1rem; + border: 1px solid var(--border); + border-radius: 2px; +} + +#markdownBody .archive-panel-title { + margin: 0 0 0.7rem; + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 600; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.12em; + color: var(--text-faint); +} + +#markdownBody .archive-meta { + margin: 0; + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.34rem 1.1rem; +} + +#markdownBody .archive-meta dt { + font-family: var(--font-sans); + font-size: 0.78rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.05em; + color: var(--text-faint); +} + +#markdownBody .archive-meta dd { + margin: 0; + font-family: var(--font-serif); + font-size: 0.92rem; + color: var(--text); + overflow-wrap: anywhere; +} + +#markdownBody .archive-meta dd code { + font-family: var(--font-mono); + font-size: 0.82rem; +} + +/* The author's reason-for-archiving note, set in the page measure. */ +#markdownBody .archive-note { + margin: 1.6rem 0; + font-family: var(--font-serif); + font-size: 0.97rem; + font-style: italic; + line-height: 1.6; + color: var(--text-muted); +} + +/* ============================================================ + ARTIFACT VIEWER + A
    (not a
    — that carries prose framing) with a + mono caption bar that names the raw artifact and links to it, + and the artifact embedded raw beneath: the PDF renders in the + browser's native viewer, the HTML snapshot loads sandboxed. + ============================================================ */ + +#markdownBody .archive-viewer { + margin: 1.8rem 0; + border: 1px solid var(--border-muted); + border-radius: 2px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); +} + +#markdownBody .archive-viewer-bar { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--border-muted); + background: var(--bg-subtle); +} + +#markdownBody .archive-viewer-name { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-muted); +} + +#markdownBody .archive-viewer-open { + font-family: var(--font-sans); + font-size: 0.76rem; + white-space: nowrap; +} + +#markdownBody .archive-frame { + display: block; + width: 100%; + height: 80vh; + border: 0; + background: var(--bg); +} + +/* ============================================================ + EXTRACTED FULL TEXT + Always in the DOM, for embed.py / Pagefind. PDF text is + collapsed in a
    and keeps its pdftotext layout in a + scrollable mono block; HTML text shows as serif paragraphs. + ============================================================ */ + +#markdownBody .archive-fulltext-wrap { + margin: 1.8rem 0 0; +} + +#markdownBody .archive-fulltext-title, +#markdownBody .archive-section-title { + margin: 0 0 0.6rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); + font-family: var(--font-sans); + font-size: 0.78rem; + font-weight: 600; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.1em; + color: var(--text-muted); +} + +#markdownBody summary.archive-fulltext-title { + cursor: pointer; +} + +#markdownBody .archive-fulltext-wrap > p { + margin: 0 0 0.85rem; + font-family: var(--font-serif); + font-size: 0.95rem; + line-height: 1.6; + color: var(--text); +} + +/* The pdftotext block: scroll-capped so it never dominates the page. */ +#markdownBody .archive-fulltext { + margin: 0.8rem 0 0; + padding: 0.9rem 1rem; + max-height: 60vh; + overflow: auto; + border: 1px solid var(--border); + border-radius: 2px; + background: var(--bg-subtle); + font-family: var(--font-mono); + font-size: 0.8rem; + line-height: 1.5; + color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ============================================================ + REFERENCED BY / RELATED + The site-wide .backlinks-list / .similar-links-list styles + (components.css) carry the lists themselves; these rules add + only the section framing and the granular fragment groups. + ============================================================ */ + +#markdownBody .archive-backlinks, +#markdownBody .archive-related { + margin: 1.8rem 0 0; +} + +#markdownBody .referenced-by-group { + margin-top: 0.9rem; +} + +#markdownBody .referenced-by-fragment { + margin: 0 0 0.3rem; + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 600; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.08em; + color: var(--text-faint); +} + +/* ============================================================ + REMOVAL NOTICE + A quiet italic footer line, set off by a top rule — present + on every archive page and on the index. + ============================================================ */ + +#markdownBody .archive-removal { + margin: 2.4rem 0 0; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-family: var(--font-serif); + font-size: 0.85rem; + font-style: italic; + line-height: 1.55; + color: var(--text-faint); +} + +/* ============================================================ + INDEX PAGE — /archive/ + A text list in the catalog idiom: one hairline between rows, + the title in serif, type + date + any quality flag in quiet + sans pushed to the row's end. + ============================================================ */ + +#markdownBody .archive-index-header { + margin-bottom: 1.8rem; +} + +#markdownBody .archive-index-intro { + margin: 0.6rem 0 0; + font-family: var(--font-serif); + font-size: 1rem; + line-height: 1.6; + color: var(--text-muted); +} + +#markdownBody .archive-list { + margin: 0; + padding: 0; + list-style: none; +} + +#markdownBody .archive-list-item { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.4rem 1rem; + flex-wrap: wrap; + padding: 0.7rem 0; + border-bottom: 1px solid var(--border); +} + +#markdownBody .archive-list-item:last-child { + border-bottom: none; +} + +#markdownBody .archive-list-link { + font-family: var(--font-serif); + font-size: 1.05rem; + color: var(--text); + text-decoration: none; +} + +#markdownBody .archive-list-link:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +#markdownBody .archive-list-meta { + font-family: var(--font-sans); + font-size: 0.78rem; + color: var(--text-faint); + white-space: nowrap; +} + +/* Non-'ok' capture flag — a dashed chip, echoing the entry-page note. */ +#markdownBody .archive-quality-flag { + padding: 0.05em 0.4em; + border: 1px dashed var(--border-muted); + border-radius: 2px; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +/* A rotted entry is the one health state worth a solid, inked flag. */ +#markdownBody .archive-quality-flag--rotted { + border-style: solid; + border-color: var(--text); + color: var(--text); +} + +#markdownBody .archive-empty { + font-family: var(--font-serif); + font-style: italic; + color: var(--text-muted); +} + +/* ============================================================ + MOBILE + Collapse the provenance grid to stacked rows; trim the frame. + ============================================================ */ + +@media (max-width: 540px) { + #markdownBody .archive-meta { + grid-template-columns: 1fr; + gap: 0; + } + + #markdownBody .archive-meta dt { + margin-top: 0.55rem; + } + + #markdownBody .archive-meta dt:first-of-type { + margin-top: 0; + } + + #markdownBody .archive-frame { + height: 70vh; + } +} diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..70771c9 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,338 @@ +/* base.css — Reset, custom properties, @font-face, dark mode */ + +/* ============================================================ + FONTS + ============================================================ */ + +@font-face { + font-family: "Spectral"; + src: url("../fonts/spectral-regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Spectral"; + src: url("../fonts/spectral-italic.woff2") format("woff2"); + font-weight: 400; + font-style: italic; + font-display: swap; +} +@font-face { + font-family: "Spectral"; + src: url("../fonts/spectral-semibold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Spectral"; + src: url("../fonts/spectral-semibold-italic.woff2") format("woff2"); + font-weight: 600; + font-style: italic; + font-display: swap; +} +@font-face { + font-family: "Spectral"; + src: url("../fonts/spectral-bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Spectral"; + src: url("../fonts/spectral-bold-italic.woff2") format("woff2"); + font-weight: 700; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "Fira Sans"; + src: url("../fonts/fira-sans-regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Fira Sans"; + src: url("../fonts/fira-sans-semibold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("../fonts/jetbrains-mono-regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "JetBrains Mono"; + src: url("../fonts/jetbrains-mono-italic.woff2") format("woff2"); + font-weight: 400; + font-style: italic; + font-display: swap; +} + + +/* ============================================================ + CUSTOM PROPERTIES (light mode defaults) + ============================================================ */ + +:root { + /* Color palette */ + --bg: #faf8f4; + --bg-nav: #faf8f4; + /* Nav logo silhouette color — override per theme to change logo appearance */ + --nav-logo-fg: #1a1a1a; + --bg-offset: #f2f0eb; + --text: #1a1a1a; + --text-muted: #555555; + --text-faint: #888888; + --border: #cccccc; + --border-muted: #aaaaaa; + + /* Link colors */ + --link: #1a1a1a; + --link-underline: #888888; + --link-hover: #1a1a1a; + --link-hover-underline: #1a1a1a; + --link-visited: #444444; + + /* Selection */ + --selection-bg: #1a1a1a; + --selection-text: #faf8f4; + + /* Typography */ + --font-serif: "Spectral", "Georgia", "Times New Roman", serif; + --font-sans: "Fira Sans", "Helvetica Neue", "Arial", sans-serif; + --font-mono: "JetBrains Mono", "Consolas", "Menlo", monospace; + + /* Scale & Rhythm (1 line = 33px or 1.65rem) */ + --text-size: 20px; + --text-size-small: 0.85em; + --line-height: 1.65; + + /* Layout */ + --body-max-width: 800px; + --page-padding: 1.5rem; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-medium: 0.28s ease; + --transition-slow: 0.5s ease; + + /* Writing activity heatmap (light mode) */ + --hm-0: #e8e8e4; /* empty cell */ + --hm-1: #b4b4b0; /* < 500 words */ + --hm-2: #787874; /* 500–1999 words */ + --hm-3: #424240; /* 2000–4999 words */ + --hm-4: #1a1a1a; /* 5000+ words */ + + /* Aliases (introduced for build.css, components.css, and the + annotation system, all of which referenced custom properties that + were never defined). Browsers silently fall back to the property's + initial value when var(--undefined) is used, so without these + aliases the build/annotation pages would degrade to default + greys/serif fonts. + + Defining them here keeps a single source of truth — change the + primitive token (--border-muted, --font-sans, --bg-offset) and + every consumer follows. */ + --rule: var(--border-muted); + --font-ui: var(--font-sans); + --bg-subtle: var(--bg-offset); + + /* Layout breakpoints — referenced from JS via getComputedStyle and + documented here for grep. CSS @media queries cannot use custom + properties, so the @media values throughout components.css and + layout.css must be kept in lockstep with these. */ + --bp-phone: 540px; + --bp-tablet: 680px; + --bp-desktop: 900px; + --bp-wide: 1500px; +} + + +/* ============================================================ + DARK MODE (Refined to Charcoal & Ink) + ============================================================ */ + +/* Explicit dark mode */ +[data-theme="dark"] { + --bg: #121212; + --bg-nav: #181818; + --nav-logo-fg: #d4d0c8; + --bg-offset: #1a1a1a; + /* --text-faint was previously #6a6660, which yields ~2.8:1 contrast + on the #121212 background and fails WCAG AA. Bumped to #8b8680 for + a contrast of ~3.5:1, the minimum for non-text UI elements. */ + --text: #d4d0c8; + --text-muted: #8c8881; + --text-faint: #8b8680; + --border: #333333; + --border-muted: #444444; + + --link: #d4d0c8; + --link-underline: #8b8680; + --link-hover: #ffffff; + --link-hover-underline: #ffffff; + --link-visited: #a39f98; + + --selection-bg: #d4d0c8; + --selection-text: #121212; + + /* Aliases — kept in sync with the light-mode definitions above. */ + --bg-subtle: var(--bg-offset); + + /* Writing activity heatmap (dark mode) */ + --hm-0: #252524; + --hm-1: #484844; + --hm-2: #6e6e6a; + --hm-3: #9e9e9a; + --hm-4: #d4d0c8; +} + +/* ============================================================ + CAPPUCCINO (warm milky-coffee — opt-in only, no system trigger) + ============================================================ */ + +[data-theme="cappuccino"] { + --bg: #553a28; + --bg-nav: #503626; + --nav-logo-fg: #ead0a0; + --bg-offset: #4a3324; + --text: #ead0a0; /* warm microfoam — golden cream */ + --text-muted: #d0b888; + --text-faint: #b09575; + --border: #7a5d44; + --border-muted: #6a4f38; + + --link: #ead0a0; + --link-underline: #b09575; + --link-hover: #f5e6c0; + --link-hover-underline: #f5e6c0; + --link-visited: #d0b888; + + --selection-bg: #ead0a0; + --selection-text: #3a2418; + + --bg-subtle: var(--bg-offset); + + --hm-0: #4a3324; + --hm-1: #6a4f38; + --hm-2: #8d6c4e; + --hm-3: #b09575; + --hm-4: #ead0a0; +} + +/* System dark mode fallback */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #121212; + --bg-nav: #181818; + --nav-logo-fg: #d4d0c8; + --bg-offset: #1a1a1a; + --text: #d4d0c8; + --text-muted: #8c8881; + --text-faint: #8b8680; + --border: #333333; + --border-muted: #444444; + + --link: #d4d0c8; + --link-underline: #8b8680; + --link-hover: #ffffff; + --link-hover-underline: #ffffff; + --link-visited: #a39f98; + + --selection-bg: #d4d0c8; + --selection-text: #121212; + + --bg-subtle: var(--bg-offset); + + --hm-0: #252524; + --hm-1: #484844; + --hm-2: #6e6e6a; + --hm-3: #9e9e9a; + --hm-4: #d4d0c8; + } +} + + +/* ============================================================ + RESET & BASE + ============================================================ */ + +*, *::before, *::after { + box-sizing: border-box; +} + +html { + background-color: var(--bg); + color: var(--text); + font-family: var(--font-serif); + font-size: var(--text-size); + line-height: var(--line-height); + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + margin: 0; + padding: 0; + background-color: var(--bg); + color: var(--text); + transition: background-color var(--transition-fast), + color var(--transition-fast); +} + +::selection { + background-color: var(--selection-bg); + color: var(--selection-text); +} + +/* Global keyboard-focus indicator. Applies only when the user navigates + with the keyboard (`:focus-visible`), not on mouse click, so it does + not interfere with normal click feedback. Buttons, anchors, summaries + and form controls all share a single 2px outline so the focus path is + visible regardless of the surrounding component styling. + + Individual components may override this with a tighter or differently + positioned ring, but the default is always present. */ +:focus-visible { + outline: 2px solid var(--text); + outline-offset: 2px; + border-radius: 2px; +} + +button:focus, +a:focus, +summary:focus, +[role="button"]:focus { + outline: none; /* fall back to :focus-visible above */ +} + +button:focus-visible, +a:focus-visible, +summary:focus-visible, +[role="button"]:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--text); + outline-offset: 2px; +} + +img, video, svg { + max-width: 100%; + height: auto; +} + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 3.3rem 0; /* Two strict line-heights */ +} diff --git a/static/css/build.css b/static/css/build.css new file mode 100644 index 0000000..33c4ffe --- /dev/null +++ b/static/css/build.css @@ -0,0 +1,165 @@ +/* ============================================================ + Build telemetry page (/build/) + ============================================================ */ + +.build-section { + margin: 2.5rem 0; +} + +/* Summary + tag tables */ +.build-table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; + font-size: 0.95em; +} + +.build-table th, +.build-table td { + padding: 0.35rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--rule); +} + +.build-table th { + font-family: var(--font-ui); + font-size: 0.8em; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 2px solid var(--rule); +} + +.build-table td:not(:first-child) { + font-variant-numeric: tabular-nums; +} + +.build-total td { + font-weight: 600; + border-top: 2px solid var(--rule); + border-bottom: none; +} + +/* Word-length distribution bars */ +.build-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 480px; +} + +.build-bar-row { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.9em; +} + +.build-bar-label { + width: 6.5rem; + flex-shrink: 0; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 0.85em; + text-align: right; +} + +.build-bar-wrap { + flex: 1; + background: var(--rule); + height: 1rem; + border-radius: 2px; + overflow: hidden; +} + +.build-bar { + display: block; + height: 100%; + background: var(--text); + border-radius: 2px; + transition: width 0.2s ease; + min-width: 2px; +} + +.build-bar-count { + width: 2.5rem; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + font-size: 0.85em; + color: var(--text-muted); +} + +/* Build info dl */ +.build-meta { + display: grid; + grid-template-columns: 12rem 1fr; + gap: 0.25rem 1rem; + font-size: 0.95em; +} + +.build-meta dt { + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 0.85em; +} + +.build-meta dd { + margin: 0; + font-variant-numeric: tabular-nums; +} + +/* ============================================================ + Writing statistics page (/stats/) + ============================================================ */ + +/* Heatmap figure */ +.stats-heatmap { + margin: 1.5rem 0 1rem; + overflow-x: auto; +} + +.heatmap-svg { + display: block; + max-width: 100%; +} + +/* Heatmap intensity classes (driven by CSS vars in base.css) */ +.heatmap-svg .hm0, +.heatmap-legend .hm0 { fill: var(--hm-0); } +.heatmap-svg .hm1, +.heatmap-legend .hm1 { fill: var(--hm-1); } +.heatmap-svg .hm2, +.heatmap-legend .hm2 { fill: var(--hm-2); } +.heatmap-svg .hm3, +.heatmap-legend .hm3 { fill: var(--hm-3); } +.heatmap-svg .hm4, +.heatmap-legend .hm4 { fill: var(--hm-4); } + +.heatmap-svg .hm-lbl { + font-size: 9px; + fill: var(--text-faint); + font-family: sans-serif; +} + +/* Legend row below heatmap */ +.heatmap-legend { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.5rem; + font-size: 0.75em; + font-family: var(--font-ui, var(--font-sans)); + color: var(--text-faint); +} + +/* Ordered lists on the Notable section */ +.build-page-list { + font-size: 0.95em; + padding-left: 1.5rem; + margin: 0.25rem 0 1rem; +} + +.build-page-list li { + margin: 0.3rem 0; + font-variant-numeric: tabular-nums; +} diff --git a/static/css/catalog.css b/static/css/catalog.css new file mode 100644 index 0000000..a7b1229 --- /dev/null +++ b/static/css/catalog.css @@ -0,0 +1,119 @@ +/* ── Music Catalog ────────────────────────────────────────────────────────── */ + +.catalog-abstract { + font-size: 1.05rem; + color: var(--text-muted); + margin-bottom: 1.5rem; +} + +.catalog-prose { + margin-bottom: 2.5rem; +} + +/* ── Featured section ─────────────────────────────────────────────────────── */ + +.catalog-featured { + margin-bottom: 3rem; +} + +.catalog-featured-title { + color: var(--text); +} + +/* ── Per-category sections ────────────────────────────────────────────────── */ + +.catalog-page { + margin-top: 0.5rem; +} + +.catalog-section { + margin-bottom: 2.5rem; +} + +.catalog-section-title { + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + padding-bottom: 0.35rem; + margin-top: 0; + margin-bottom: 0.6rem; +} + +/* ── Entry list ───────────────────────────────────────────────────────────── */ + +.catalog-list { + list-style: none; + margin: 0; + padding: 0; +} + +.catalog-entry { + padding: 0.55rem 0; + border-bottom: 1px solid var(--border-subtle, color-mix(in srgb, var(--border) 50%, transparent)); +} + +.catalog-entry:last-child { + border-bottom: none; +} + +.catalog-entry-main { + display: flex; + align-items: baseline; + gap: 0.45rem; + flex-wrap: wrap; +} + +.catalog-title { + font-weight: 600; + color: var(--text); + text-decoration: none; +} + +.catalog-title:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +.catalog-year, +.catalog-duration { + font-family: var(--font-ui); + font-size: 0.82rem; + color: var(--text-muted); +} + +.catalog-year::before { + content: "·"; + margin-right: 0.35rem; + color: var(--text-muted); +} + +.catalog-duration::before { + content: "·"; + margin-right: 0.35rem; + color: var(--text-muted); +} + +.catalog-ind { + font-size: 0.7rem; + color: var(--text-muted); + cursor: default; + line-height: 1; + position: relative; + top: -0.05em; +} + +.catalog-instrumentation { + font-family: var(--font-ui); + font-size: 0.82rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +.catalog-empty { + color: var(--text-muted); + font-style: italic; +} diff --git a/static/css/commonplace.css b/static/css/commonplace.css new file mode 100644 index 0000000..cb1dbbd --- /dev/null +++ b/static/css/commonplace.css @@ -0,0 +1,141 @@ +/* ── Commonplace ──────────────────────────────────────────────────────────── */ + +/* ── Intro ────────────────────────────────────────────────────────────────── */ + +.cp-intro { + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +/* ── View toggle ──────────────────────────────────────────────────────────── */ + +.cp-view-toggle { + display: flex; + gap: 0.5rem; + margin: 1.5rem 0 2.5rem; +} + +.cp-toggle-btn { + font-family: var(--font-ui); + font-size: 0.73rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + padding: 0.2rem 0.7rem; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.cp-toggle-btn:hover { + color: var(--text); + border-color: var(--text-muted); +} + +.cp-toggle-btn.is-active { + color: var(--text); + border-color: var(--text); +} + +/* ── Theme sections ───────────────────────────────────────────────────────── */ + +.cp-theme-heading { + font-family: var(--font-ui); + font-size: 0.73rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.1em; + color: var(--text-muted); + font-weight: 400; + margin: 2.5rem 0 1.5rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); +} + +.cp-theme-section:first-child .cp-theme-heading { + margin-top: 0; +} + +/* ── Entry ────────────────────────────────────────────────────────────────── */ + +.cp-entry { + margin: 0 0 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border-muted); +} + +.cp-entry:last-child { + border-bottom: none; + padding-bottom: 0; +} + +/* ── Quote block — overrides default #markdownBody blockquote ─────────────── */ + +#markdownBody .cp-quote { + background-image: none; + padding-left: 0; + color: var(--text); + margin: 0 0 0.5rem; +} + +#markdownBody .cp-quote p { + margin: 0; + line-height: 1.75; +} + +/* ── Attribution ──────────────────────────────────────────────────────────── */ + +.cp-attribution { + font-family: var(--font-ui); + font-size: 0.8rem; + color: var(--text-muted); + margin: 0; +} + +.cp-attribution a { + color: inherit; + text-decoration: none; + border-bottom: 1px solid var(--border); + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.cp-attribution a:hover { + color: var(--text); + border-color: var(--text-muted); +} + +/* ── Commentary ───────────────────────────────────────────────────────────── */ + +.cp-commentary { + font-family: var(--font-ui); + font-size: 0.8rem; + color: var(--text-muted); + font-style: italic; + margin: 0.7rem 0 0; +} + +/* ── Tags ─────────────────────────────────────────────────────────────────── */ + +.cp-tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.7rem; +} + +.cp-tag { + font-family: var(--font-ui); + font-size: 0.67rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + color: var(--text-faint); + padding: 0.1rem 0.4rem; + border: 1px solid var(--border-muted); +} + +/* ── Empty state ──────────────────────────────────────────────────────────── */ + +.cp-empty { + color: var(--text-muted); + font-style: italic; +} diff --git a/static/css/components.css b/static/css/components.css new file mode 100644 index 0000000..6490bab --- /dev/null +++ b/static/css/components.css @@ -0,0 +1,1915 @@ +/* components.css — Nav, TOC, metadata block, page footer. + Loaded on every page. */ + +/* ============================================================ + SKIP LINK + ============================================================ */ + +.skip-link { + position: absolute; + top: -100%; + left: 0; + z-index: 9999; + padding: 0.5em 1em; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + text-decoration: none; + font-size: 0.875rem; +} + +.skip-link:focus { + top: 0; +} + +/* ============================================================ + NAV + Two-row: primary links (always visible) + portal links + (collapsed by default, expanded via nav.js + localStorage). + ============================================================ */ + +nav.site-nav { + display: flex; + flex-direction: column; + align-items: stretch; + font-family: var(--font-sans); +} + +/* Row 1: centered primary links + right-side controls */ +.nav-row-primary { + position: relative; + display: flex; + justify-content: center; + align-items: center; + padding: 0.65rem 1.5rem; +} + +.nav-primary { + display: flex; + align-items: center; + gap: 0; + font-size: 0.8rem; + font-weight: 600; + font-variant-caps: small-caps; + letter-spacing: 0.06em; +} + +.nav-primary a { + text-decoration: none; + color: var(--text-muted); + padding: 0.15rem 0.85rem; + transition: color var(--transition-fast); +} + +.nav-primary a:hover { + color: var(--text); +} + +.nav-primary a + a { + border-left: 1px solid var(--border); +} + +/* Home logo — square button flush into the top-left corner of the nav bar. + The rooted-L mark lives in /logo-sprite.svg and is referenced with + (cacheable once, not ~33 KB inlined per page). Its two-tone + cutout still renders because CSS custom properties cascade into the + use-element shadow tree: the letter is drawn in --logo-ink and the + root filament is punched through in --logo-bg. Mapping --logo-bg to + --bg-nav (the button's own surface) makes the roots read as the nav + background showing through. Both tokens are theme-driven in + base.css — override --nav-logo-fg / --bg-nav there to restyle per + theme. */ +.nav-logo { + position: absolute; + left: 0; + top: 0; + bottom: 0; + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + text-decoration: none; + background-color: var(--bg-nav); + --logo-ink: var(--nav-logo-fg); + --logo-bg: var(--bg-nav); +} +.nav-logo__mark { + width: 76%; + height: 76%; + display: block; +} + +/* Controls cluster: portals toggle + theme toggle, pinned right */ +.nav-controls { + position: absolute; + right: 1.25rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.nav-portal-toggle { + background: none; + border: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + color: var(--text-faint); + padding: 0; + line-height: 1; + transition: color var(--transition-fast); + white-space: nowrap; +} +.nav-portal-toggle:hover { + color: var(--text-muted); +} + +.nav-portal-arrow { + font-size: 0.6rem; + display: inline-block; + margin-right: 0.2em; + vertical-align: middle; +} + +/* Settings button + panel */ +.settings-wrap { + position: relative; +} + +.settings-toggle { + background: none; + border: none; + cursor: pointer; + color: var(--text-faint); + font-size: 0.85rem; + font-family: var(--font-sans); + padding: 0; + line-height: 1; + transition: color var(--transition-fast); +} +.settings-toggle:hover, +.settings-toggle[aria-expanded="true"] { + color: var(--text-muted); +} + +.settings-panel { + position: absolute; + top: calc(100% + 0.6rem); + right: 0; + z-index: 800; + min-width: 148px; + padding: 0.5rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + + display: none; + flex-direction: column; + gap: 0.1rem; +} + +.settings-panel.is-open { + display: flex; +} + +.settings-section { + padding: 0.3rem 0.2rem; +} +.settings-section + .settings-section { + border-top: 1px solid var(--border); + padding-top: 0.4rem; + margin-top: 0.1rem; +} + +.settings-label { + font-family: var(--font-sans); + font-size: 0.62rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + margin-bottom: 0.3rem; + line-height: 1; +} + +.settings-row { + display: flex; + gap: 0.3rem; +} + +.settings-btn { + flex: 1; + background: var(--bg-offset); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 500; + padding: 0.3rem 0.5rem; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast), + border-color var(--transition-fast); + white-space: nowrap; + line-height: 1.2; +} +.settings-btn:hover:not(:disabled) { + background: var(--border); + color: var(--text); +} +.settings-btn.is-active { + background: var(--text); + border-color: var(--text); + color: var(--bg); +} +.settings-btn:disabled { + opacity: 0.35; + cursor: default; +} +.settings-btn--full { + width: 100%; + flex: none; + text-align: center; +} + +.settings-btn--danger { + color: rgba(180, 80, 80, 0.85); +} +.settings-btn--danger:hover { + color: rgba(180, 80, 80, 1); +} + +.settings-col { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + + +/* ============================================================ + FOCUS MODE — header hide + TOC hide lives in layout.css. + Settings gear stays accessible at top-right. + ============================================================ */ + +[data-focus-mode] body > header .site-nav { + visibility: hidden; + height: 0; + overflow: hidden; +} + +[data-focus-mode] .settings-wrap { + visibility: visible; + position: fixed; + top: 0.75rem; + right: 1.25rem; + z-index: 1000; +} + +[data-focus-mode] .settings-panel { + position: fixed; + top: 2.5rem; + right: 1.25rem; +} + + +/* ============================================================ + REDUCE MOTION + Collapses all transitions and animations site-wide. + Applied to before first paint by theme.js. + ============================================================ */ + +[data-reduce-motion] *, +[data-reduce-motion] *::before, +[data-reduce-motion] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; +} + +/* Mobile TOC bar — hidden by default; revealed whenever the desktop + TOC sidebar is hidden (see layout.css: #toc hides at ≤900px). + Declared here (before the media queries) so source-order cascade + lets the mobile rules win. */ +#toc-mobile-bar { + display: none; +} + +/* ── Mobile TOC bar (≤900px — mirrors #toc hide breakpoint) ────────── + Fixed bottom strip: progress line + current section label. + toc.js sets --toc-progress and syncs the label. Uses (max-width) + rather than (pointer: coarse) so Android's "request desktop site" + mode — which fakes a ~980px viewport — correctly shows the desktop + sidebar TOC instead of this bar. + ──────────────────────────────────────────────────────────────────── */ + +@media (max-width: 900px) { + /* Bottom padding so the last content line isn't hidden + behind the fixed mobile TOC bar. */ + body { + padding-bottom: 2.5rem; + } + + #toc-mobile-bar { + display: flex; + align-items: center; + position: fixed; + bottom: 0; left: 0; right: 0; + height: 2.25rem; + background: var(--bg-nav); + border-top: 1px solid var(--border); + padding: 0 1rem; + z-index: 90; + overflow: hidden; + } + + /* Progress track (faint full-width line at top of bar) */ + #toc-mobile-bar::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: var(--border); + } + + /* Progress fill — grows with scroll via --toc-progress */ + #toc-mobile-bar::after { + content: ''; + position: absolute; + top: 0; left: 0; + height: 2px; + width: calc(var(--toc-progress, 0) * 100%); + background: var(--text-muted); + transition: width 0.12s ease; + } + + .toc-mobile-label { + font-family: var(--font-sans); + font-size: 0.68rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +/* ── Mobile nav (≤540px) ───────────────────────────────────────────── + Controls are position:absolute on desktop (out of flex flow, pinned + right). On narrow viewports they collide with the primary links. + Fix: bring them into flow, wrap the row, add a separator line. + ──────────────────────────────────────────────────────────────────── */ + +@media (max-width: 540px) { + .nav-logo { + display: none; + } + + .nav-row-primary { + flex-wrap: wrap; + justify-content: center; + padding: 0.4rem 0.75rem; + gap: 0.25rem 0; + } + + .nav-primary { + flex-wrap: wrap; + justify-content: center; + font-size: 0.72rem; + } + + /* Larger vertical padding → usable tap targets without extra markup */ + .nav-primary a { + padding: 0.35rem 0.55rem; + } + + /* border-left separators look broken on wrapped rows (the first link + of a new row still gets a left border). Replace with a gap. */ + .nav-primary a + a { + border-left: none; + margin-left: 0.1rem; + } + + /* Pull controls out of absolute positioning, span full width, center */ + .nav-controls { + position: static; + width: 100%; + justify-content: center; + padding-top: 0.3rem; + border-top: 1px solid var(--border); + margin-top: 0.15rem; + } + + .nav-portal-toggle { + padding: 0.3rem 0; + } + + /* Portal row: tighter side padding on narrow screens */ + .nav-portals { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + .nav-portals a { + padding: 0.3rem 0.55rem; + } +} + +/* Row 2: portal links — hidden until nav.js adds .is-open */ +.nav-portals { + display: none; + justify-content: center; + flex-wrap: wrap; + gap: 0; + padding: 0.4rem 1.5rem 0.5rem; + border-top: 1px solid var(--border); +} + +.nav-portals.is-open { + display: flex; +} + +.nav-portals a { + font-size: 0.75rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + text-decoration: none; + color: var(--text-faint); + padding: 0.1rem 0.7rem; + transition: color var(--transition-fast); +} +.nav-portals a + a { + border-left: 1px solid var(--border); +} +.nav-portals a:hover { + color: var(--text); +} + + +/* ============================================================ + TABLE OF CONTENTS + Sticky left sidebar (position: sticky set in layout.css). + toc.js manages: .is-active links, .is-collapsed state, + and the --toc-progress custom property for the progress bar. + ============================================================ */ + +#toc { + font-family: var(--font-sans); + font-size: 0.85rem; + line-height: 1.5; + padding-right: 1.5rem; + padding-left: 0.5rem; +} + +/* Header row: current section label + collapse toggle */ +.toc-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + padding-bottom: 0.55rem; + margin-bottom: 0.35rem; + position: relative; +} + +/* Progress track — full width, faint */ +.toc-header::before { + content: ''; + position: absolute; + bottom: 0; left: 0; + width: 100%; height: 1px; + background: var(--border); +} + +/* Progress fill — grows left→right with scroll */ +.toc-header::after { + content: ''; + position: absolute; + bottom: 0; left: 0; + height: 1px; + width: calc(var(--toc-progress, 0) * 100%); + background: var(--text-muted); + transition: width 0.12s ease; +} + +/* Current section label */ +.toc-active-label { + font-size: 0.72rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} + +#toc.is-collapsed .toc-active-label { + color: var(--text-muted); +} + +/* Collapse toggle button */ +.toc-toggle { + background: none; + border: none; + cursor: pointer; + color: var(--text-faint); + font-size: 0.72rem; + padding: 0; + line-height: 1; + flex-shrink: 0; + transition: color var(--transition-fast), transform 0.28s ease; +} +.toc-toggle:hover { + color: var(--text-muted); +} +#toc.is-collapsed .toc-toggle { + transform: rotate(-90deg); +} + +/* Nav: animates open/closed via max-height. The collapsed state hides + the nav from the keyboard tab order *and* the accessibility tree + using `aria-hidden="true"` (set by toc.js). The transition still + works because we keep `max-height: 0` for the visual collapse. */ +.toc-nav { + /* Fill the sticky sidebar's remaining height and scroll when the + outline is taller than the viewport. The subtracted ~2.6rem + mirrors #toc's own max-height budget (layout.css) minus the + .toc-header row (label + progress rule + its bottom margin). */ + max-height: calc(100vh - var(--nav-height, 4rem) - 3rem - 2.6rem); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + transition: max-height 0.3s ease; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.toc-nav::-webkit-scrollbar { width: 6px; } +.toc-nav::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} +#toc.is-collapsed .toc-nav { + max-height: 0; + /* overflow:hidden so the outline is clipped (not scrolled) while + the max-height transition runs down to 0. */ + overflow: hidden; +} +#toc.is-collapsed .toc-nav a, +#toc.is-collapsed .toc-nav button { + /* Belt-and-suspenders: even if aria-hidden is somehow stripped, + the collapsed nav cannot receive focus. The earlier rule used + `visibility: hidden` which already removed elements from the + focus order, but `visibility` interacts poorly with the + max-height transition; tabindex=-1 set by toc.js is preferred. */ + pointer-events: none; +} + +/* Nav list */ +.toc-nav ol { + list-style: none; + padding: 0; + margin: 0; +} + +.toc-nav > ol > li { + margin: 0.3rem 0; +} + +.toc-nav > ol > li > ol { + margin: 0.1rem 0 0.2rem 0; +} +.toc-nav > ol > li > ol > li { + margin: 0.15rem 0; +} + +.toc-nav a { + text-decoration: none; + color: var(--text-faint); + display: block; + padding: 0.1rem 0; + transition: color var(--transition-fast); +} +.toc-nav a:hover { + color: var(--text); +} + +/* JS adds .is-active to the current heading's link */ +.toc-nav a.is-active { + color: var(--text); + font-weight: 600; +} + +/* h2-level links slightly more prominent */ +.toc-nav > ol > li > a { + color: var(--text-muted); +} +.toc-nav > ol > li > a:hover, +.toc-nav > ol > li > a.is-active { + color: var(--text); +} + +/* h3-level links indented and smaller */ +.toc-nav > ol > li > ol > li > a { + padding-left: 0.75rem; + font-size: 0.74rem; +} + + +/* ============================================================ + PAGE TITLE + ============================================================ */ + +.page-title { + font-family: var(--font-sans); + font-size: 2.6rem; + font-weight: 600; + margin: 0 0 1.65rem 0; + line-height: 1.1; + color: var(--text); +} + + +/* ============================================================ + METADATA BLOCK + Vertical sequence above the body: tags, description, + authors (optional), jump links to bottom footer sections. + ============================================================ */ + +.metadata { + margin-bottom: 2.475rem; + font-family: var(--font-sans); + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.meta-row { + font-size: 0.82rem; + color: var(--text-muted); + line-height: 1.5; +} + +/* Tags, authors, jump links: centered */ +.meta-tags, +.meta-authors, +.meta-pagelinks { + text-align: center; +} + +/* Description: blockquote style, left-aligned */ +.meta-description { + font-style: italic; + padding-left: 1.25em; + border-left: 2px solid var(--border); + color: var(--text-muted); +} + +/* Tags: centered row of small-caps links separated by middots */ +.meta-tags { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0; + color: var(--text-faint); + font-size: 0.72rem; +} + +.meta-tag { + color: var(--text-faint); + text-decoration: none; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + transition: color var(--transition-fast); +} + +.meta-tag:hover { + color: var(--text-muted); +} + +.meta-tag + .meta-tag::before { + content: " · "; + color: var(--text-faint); + letter-spacing: 0; + font-variant-caps: normal; +} + +/* Affiliation: institution name below author, in sans to contrast the serif author row */ +.meta-affiliation { + font-family: var(--font-sans); + font-size: 0.7rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-faint); + text-align: center; +} + +.meta-affiliation a { + color: var(--text-faint); + text-decoration: none; + transition: color var(--transition-fast); +} + +.meta-affiliation a:hover { + color: var(--text-muted); +} + +/* Authors: "by" label + name */ +.meta-authors { + font-size: 0.78rem; + color: var(--text-faint); +} +.meta-authors .meta-label { + font-variant-caps: all-small-caps; + font-weight: 600; + letter-spacing: 0.06em; + font-size: 0.68rem; + color: var(--text-faint); + margin-right: 0.4rem; +} + +/* Jump links: inline, separated by middots */ +.meta-pagelinks { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0; + font-size: 0.75rem; + color: var(--text-faint); +} +.meta-pagelinks a { + color: var(--text-faint); + text-decoration: none; +} +.meta-pagelinks a:hover { + color: var(--text-muted); + text-decoration: underline; + text-underline-offset: 2px; +} +.meta-pagelinks a + a::before { + content: " · "; + color: var(--border); + display: inline-block; + text-decoration: none; + white-space: pre; +} + +/* Top-of-page parallel tag strip — surfaces the full epistemic profile + inline so a reader sees status, trust score, and every orientation + axis at a glance, without scrolling to the footer. The trust chip + anchors the left edge; status sits next to it; the labeled rows + follow with their leading centerdot separators. Wraps freely. */ +.meta-epistemic-strip { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: center; + gap: 0.35rem 0.55rem; +} + + +/* ============================================================ + ESSAY SUMMARY BOX + Optional key-points / summary block rendered from the + `summary:` frontmatter field, between metadata and content. + ============================================================ */ + +.essay-summary { + margin: 1.5rem 0 0; + padding: 1.25rem 1.5rem; + border-left: 3px solid var(--border-muted); + background: var(--bg-offset, var(--bg)); + font-size: 0.92rem; + line-height: 1.6; +} + +.essay-summary-label { + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + margin-bottom: 0.5rem; +} + +.essay-summary p { + margin: 0 0 0.5em; +} + +.essay-summary p:last-child { + margin-bottom: 0; +} + +/* ============================================================ + CONTENT DIVIDERS + Decorative rule with centered logo. Used both at the top of + the body (content-divider, between metadata and prose) and at + the aftermatter boundary (aftermatter-divider). + ============================================================ */ + +.content-divider, +.aftermatter-divider { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.content-divider { + margin: 2rem 0 2.5rem; +} + +.aftermatter-divider { + margin: 3.5rem 0 2.5rem; +} + +.content-divider::before, +.content-divider::after, +.aftermatter-divider::before, +.aftermatter-divider::after { + content: ''; + flex: 1; + border-top: 1px dashed var(--text-faint); + opacity: 0.5; +} + +.content-divider-logo, +.aftermatter-logo { + display: block; + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + background-color: var(--text-faint); + opacity: 0.45; + mask-image: url('/images/link-icons/internal.svg'); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-image: url('/images/link-icons/internal.svg'); + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + transition: opacity var(--transition-fast); +} + +.content-divider-logo:hover, +.aftermatter-logo:hover { + opacity: 0.7; +} + +/* ============================================================ + AFTERMATTER HEADINGS + Right-aligned, all-small-caps, muted — visually distinct from + main-body headings to signal supplementary material. + ============================================================ */ + +.aftermatter :is(h2, h3, h4) { + text-align: right; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.aftermatter h2 { + font-size: 1.3rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid var(--border-muted); +} + +.aftermatter h3 { + font-size: 1.05rem; +} + +/* ============================================================ + FOOTER ORNAMENT + Inline SVG floral ornament separating body from page metadata. + Uses currentColor for automatic light/dark theming. + ============================================================ */ + +.footer-ornament { + display: block; + width: 100%; + height: auto; + padding: 0.5rem var(--page-padding); + color: var(--text-faint); + opacity: 0.5; +} + +/* ============================================================ + BOTTOM METADATA FOOTER + Full-width section outside the essay's three-column layout. + Structure: [Further Reading row?] [Bibliography row?] [metadata grid]. + ============================================================ */ + +.page-meta-footer { + padding: 1.5rem var(--page-padding) 2rem; + font-family: var(--font-sans); + font-size: 0.82rem; + color: var(--text-muted); + display: flex; + flex-direction: column; + align-items: center; + gap: 2.5rem; +} + +/* Each section is constrained and centered. */ +.page-meta-footer > * { + width: 100%; + max-width: min(1200px, 100%); +} + +/* Shared heading style for full-width section headings */ +.meta-footer-full > h3, +.meta-footer-section h3 { + font-size: 0.78rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + margin: 0 0 0.75rem 0; + font-family: var(--font-sans); + text-rendering: optimizeLegibility; +} + +/* Full-width rows: Further Reading and Bibliography */ +.meta-footer-full { + width: 100%; +} + +/* Secondary row: RELATED | EPISTEMIC | VERSION HISTORY. + auto-fit + minmax lets the grid render 3 columns when all are present, + 2 when RELATED is absent (zero similar pages), and stack gracefully + at narrow viewports without any further media queries. */ +.meta-footer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + justify-content: center; + gap: 1.65rem 2.25rem; + padding-top: 0.25rem; +} + +.meta-footer-section p, +.meta-footer-section ul { + margin: 0; + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.5; +} + +.meta-footer-section ul { + padding-left: 1em; + list-style: none; +} + +.meta-footer-section ul li::before { + content: "–\00a0"; + color: var(--text-faint); +} + +/* ============================================================ + BACKLINKS — promoted to a full-width aftermatter section. + Each entry: source title in serif body face, then a blockquote of + the sentence containing the link. A small "¶" affordance reveals + the full paragraph in a hover/focus-activated popup. + ============================================================ */ + +/* The "BACKLINKS" small-caps label is noticeably larger than the + secondary-row headings (which stay at 0.78rem) because Backlinks + is now a primary aftermatter section. */ +.meta-footer-backlinks > h3 { + font-size: 1.05rem; + letter-spacing: 0.09em; + margin-bottom: 1rem; +} + +.meta-footer-backlinks .backlinks-list { + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 1.1rem; +} + +.meta-footer-backlinks .backlinks-list li::before { + content: none; +} + +.backlink-item { + padding: 0; +} + +/* Source title — serif body face, prominent but restrained */ +.backlink-source { + display: inline-block; + font-family: var(--font-serif); + font-size: 1.32rem; + font-variant: normal; + font-weight: 500; + letter-spacing: 0; + color: var(--text); + text-decoration: none; + border-bottom: 1px solid var(--border-muted); + padding-bottom: 0.05em; + transition: color var(--transition-fast), + border-color var(--transition-fast); +} + +.backlink-source:hover, +.backlink-source:focus-visible { + color: var(--text); + border-color: var(--border); +} + +/* Sentence-level context quoted beneath the title */ +.backlink-quote { + margin: 0.55rem 0 0 0; + padding: 0 0 0 1rem; + border-left: 2px solid var(--border-muted); + font-family: var(--font-serif); + font-size: 1.12rem; + font-style: italic; + line-height: 1.55; + color: var(--text-muted); + quotes: none; +} + +.backlink-quote::before, +.backlink-quote::after { + content: none; +} + +.backlink-quote a { + color: var(--text-muted); + text-decoration-color: var(--border-muted); +} + +.backlink-quote a:hover { + color: var(--text); + text-decoration-color: var(--border); +} + +/* Hover/focus popup — full paragraph context */ +.backlink-full { + position: relative; + display: inline-block; + margin-left: 0.35em; + vertical-align: baseline; +} + +.backlink-full-trigger { + background: none; + border: 1px solid var(--border-muted); + border-radius: 999px; + width: 1.1rem; + height: 1.1rem; + padding: 0; + line-height: 1; + font-family: var(--font-sans); + font-size: 0.72rem; + color: var(--text-faint); + cursor: help; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color var(--transition-fast), + border-color var(--transition-fast), + background-color var(--transition-fast); +} + +.backlink-full-trigger:hover, +.backlink-full:focus-within .backlink-full-trigger { + color: var(--text); + border-color: var(--border); + background-color: var(--bg-offset); +} + +.backlink-full-popup { + display: none; + position: absolute; + z-index: 200; + bottom: calc(100% + 0.4rem); + left: 0; + width: min(34rem, 80vw); + padding: 0.75rem 0.95rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 3px; + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.08); + font-family: var(--font-serif); + font-style: normal; + font-size: 0.88rem; + line-height: 1.55; + color: var(--text-muted); + text-align: left; +} + +.backlink-full:hover .backlink-full-popup, +.backlink-full:focus-within .backlink-full-popup { + display: block; +} + +.backlink-full-popup a { + color: var(--text-muted); + text-decoration-color: var(--border-muted); +} + +.backlink-full-popup a:hover { + color: var(--text); +} + +/* ============================================================ + RELATED (SIMILAR LINKS) — promoted to a full-width aftermatter + section, sitting just under Backlinks. Source file: + data/similar-links.json (produced by tools/embed.py). + ============================================================ */ + +.meta-footer-similar > h3 { + font-size: 1.05rem; + letter-spacing: 0.09em; + margin-bottom: 1rem; +} + +.meta-footer-similar .similar-links-list { + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.meta-footer-similar .similar-links-list li::before { + content: none; +} + +.meta-footer-similar .similar-links-item a { + font-family: var(--font-serif); + font-size: 1.18rem; + font-weight: 500; + color: var(--text); + text-decoration: none; + border-bottom: 1px solid var(--border-muted); + padding-bottom: 0.05em; + transition: color var(--transition-fast), + border-color var(--transition-fast); +} + +.meta-footer-similar .similar-links-item a:hover, +.meta-footer-similar .similar-links-item a:focus-visible { + color: var(--text); + border-bottom-color: var(--border); +} + +/* ============================================================ + VERSION HISTORY — cap at 3 with a
    "more" affordance. + .version-history-list inherits list styling from .meta-footer-section ul. + ============================================================ */ + +.version-history-more { + margin-top: 0.35rem; +} + +.version-history-more > summary { + list-style: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + color: var(--text-faint); + user-select: none; + padding-left: 1em; + transition: color var(--transition-fast); +} + +.version-history-more > summary::-webkit-details-marker { + display: none; +} + +.version-history-more > summary::before { + content: "▸\00a0"; + font-size: 0.7em; + vertical-align: 0.1em; +} + +.version-history-more[open] > summary::before { + content: "▾\00a0"; +} + +.version-history-more > summary:hover { + color: var(--text-muted); +} + +.version-history-more > .version-history-list { + margin-top: 0.25rem; +} + + +/* ============================================================ + EPISTEMIC METADATA BLOCK + Compact display (status chip + confidence % + dot scales) + with an expandable
    for the full profile. + ============================================================ */ + +/* Status chip */ +.ep-status { + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +/* Every labeled row beneath the primary line shares one class so new + epistemic fields can be added in the template without touching CSS. + Used by confidence, importance, evidence, scope, novelty, practicality. */ +.ep-row { + font-family: var(--font-sans); + font-size: 0.72rem; + color: var(--text-faint); +} +.ep-row::before { content: "·\00a0"; color: var(--border); } + +/* Dot scale (importance / evidence) — only ever used nested inside an + .ep-row wrapper, which already supplies the centerdot separator. */ +.ep-dots { + font-size: 0.55rem; + letter-spacing: 0.12em; + color: var(--text-muted); + line-height: 1; + vertical-align: 0.15em; +} +.ep-row .ep-dots::before { content: none; } + +/* Trust chip on the primary row — bordered numeric chip plus a small-caps + "trust" label, mirroring .ep-status typography so the two sit together. */ +.ep-trust { + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-muted); +} +.ep-score { + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant-caps: normal; + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; + color: var(--text-muted); + border: 1px solid var(--border-muted); + border-radius: 2px; + padding: 0.05em 0.5em; + margin-right: 0.25em; +} + +/* Definition list for expanded profile */ +.ep-expanded { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.2rem 0.75rem; + margin: 0.5rem 0 0 0; + font-size: 0.75rem; + line-height: 1.45; +} + +.ep-expanded dt { + color: var(--text-faint); + font-family: var(--font-sans); + font-variant-caps: all-small-caps; + letter-spacing: 0.04em; +} + +.ep-expanded dd { + margin: 0; + color: var(--text-muted); + font-family: var(--font-sans); +} + + +/* ============================================================ + PAGINATION NAV + ============================================================ */ + +.paginate-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 1.25rem; + margin-top: 2.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--border-muted); + font-family: var(--font-sans); +} + +.paginate-btn { + color: var(--text-faint); + text-decoration: none; + font-size: 1rem; + line-height: 1; + transition: color var(--transition-fast); +} + +.paginate-btn:hover { + color: var(--text-muted); +} + +.paginate-info { + font-size: 0.75rem; + color: var(--text-faint); + font-variant-numeric: tabular-nums; +} + + +/* ============================================================ + CITATION MARKERS (inline superscripts) + ============================================================ */ + +.cite-marker { + font-family: var(--font-sans); + font-size: 0.62em; + line-height: 1; + vertical-align: super; + margin: 0 0.08em; +} + +.cite-link { + color: var(--text-faint); + text-decoration: none; + border: 1px solid var(--border-muted); + border-radius: 2px; + padding: 0.05em 0.28em; + font-weight: 600; + letter-spacing: 0.01em; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.cite-link:hover { + color: var(--text-muted); + border-color: var(--border); + text-decoration: none; +} + +/* Tooltip shown on hover */ +.cite-tooltip { + position: absolute; + z-index: 200; + max-width: 340px; + padding: 0.55rem 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 3px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07); + font-family: var(--font-sans); + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.5; + pointer-events: auto; +} + +.cite-tooltip em { + font-style: italic; +} + + +/* ============================================================ + BIBLIOGRAPHY (inside .page-meta-footer #bibliography) + ============================================================ */ + +/* Number badge preceding each entry */ +.ref-num { + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 600; + color: var(--text-faint); + margin-right: 0.4em; + white-space: nowrap; +} + +/* Each bibliography entry */ +.csl-bib-body .csl-entry { + font-size: 0.95rem; + color: var(--text-muted); + line-height: 1.6; + margin-bottom: 0.6rem; + padding-left: 1.6em; + text-indent: -1.6em; +} + +.csl-bib-body .csl-entry a { + color: var(--text-faint); + word-break: break-all; +} + +.csl-bib-body .csl-entry a:hover { + color: var(--text-muted); +} + + +/* ============================================================ + COLLAPSIBLE SECTIONS + collapse.js wraps h2/h3 content in .section-body divs and + injects .section-toggle buttons into the heading elements. + ============================================================ */ + +.section-body { + overflow: hidden; + transition: max-height 0.3s ease; +} + +.section-toggle { + background: none; + border: none; + cursor: pointer; + color: var(--text-faint); + padding: 0 0 0 0.5em; + font-size: 0.5em; + line-height: 1; + vertical-align: middle; + position: relative; + top: -0.1em; + opacity: 0; + transition: color var(--transition-fast), opacity var(--transition-fast); +} + +h2:hover .section-toggle, +h3:hover .section-toggle, +.section-toggle:hover, +.section-toggle:focus { + opacity: 1; +} + +.section-toggle:hover { + color: var(--text-muted); +} + +.section-toggle::before { + content: '▼'; +} + +.section-toggle[aria-expanded="false"]::before { + content: '▶'; +} + + +/* ============================================================ + PAGEFIND SEARCH UI + CSS custom property overrides so pagefind-ui matches the + site's design tokens. Pagefind reads these via var(). + ============================================================ */ + +#search { + margin-top: 1.65rem; + --pagefind-ui-scale: 0.85; + --pagefind-ui-primary: var(--text-muted); + --pagefind-ui-text: var(--text); + --pagefind-ui-background: var(--bg); + --pagefind-ui-border: var(--border); + --pagefind-ui-tag: var(--bg-offset); + --pagefind-ui-border-width: 1px; + --pagefind-ui-border-radius: 2px; + --pagefind-ui-font: var(--font-sans); +} + +#search-timing { + font-family: var(--font-mono); + font-size: var(--text-size-small); + color: var(--text-faint); + margin-top: 0.5rem; + min-height: 1.2em; /* reserve space to prevent layout shift */ +} + +/* Search tabs (Keyword / Semantic toggle) */ +.search-tabs { + display: flex; + gap: 0; + margin-bottom: 0.1rem; + border-bottom: 1px solid var(--border); +} + +.search-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + padding: 0.45rem 1rem; + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: var(--text-faint); + cursor: pointer; + transition: color 0.1s, border-color 0.1s; +} + +.search-tab:hover { color: var(--text-muted); } +.search-tab.is-active { color: var(--text); border-bottom-color: var(--text); } + +/* Search panels (keyword / semantic) — only active panel visible */ +.search-panel { display: none; } +.search-panel.is-active { display: block; } + +/* Semantic query input — mirrors Pagefind UI style */ +.semantic-query-input { + width: 100%; + margin-top: 1.65rem; + padding: 0.5rem 0.75rem; + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 2px; + box-sizing: border-box; + outline: none; +} + +.semantic-query-input:focus { border-color: var(--text-muted); } + +.semantic-status { + font-family: var(--font-mono); + font-size: var(--text-size-small); + color: var(--text-faint); + margin-top: 0.5rem; + min-height: 1.2em; +} + +/* Semantic results list */ +.semantic-results-list { + list-style: none; + margin: 1.2rem 0 0; + padding: 0; +} + +.semantic-result { + padding: 0.85rem 0; + border-bottom: 1px solid var(--border); +} + +.semantic-result:last-child { border-bottom: none; } + +.semantic-result-title { + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.9rem; + color: var(--text); + text-decoration: none; +} + +.semantic-result-title:hover { text-decoration: underline; } + +.semantic-result-heading { + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: var(--text-faint); +} + +.semantic-result-excerpt { + margin: 0.3rem 0 0; + font-size: var(--text-size-small); + color: var(--text-muted); + line-height: 1.5; +} + +/* ============================================================ + COMPOSITION LANDING PAGE + ============================================================ */ + +.composition-meta .meta-row.composition-details { + display: flex; + flex-wrap: wrap; + gap: 0 1.25rem; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 0.85rem; +} + +.comp-detail + .comp-detail::before { + content: "·"; + margin-right: 1.25rem; + color: var(--border); +} + +.meta-row.composition-actions { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; +} + +.comp-btn { + display: inline-block; + padding: 0.3rem 0.8rem; + font-family: var(--font-ui); + font-size: 0.82rem; + font-weight: 600; + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; + background: transparent; + cursor: pointer; +} + +.comp-btn:hover { + background: var(--bg-offset); +} + +.comp-btn--secondary { + color: var(--text-muted); +} + +/* Full-piece recording player */ + +.comp-recording { + margin: 1.25rem 0; +} + +.comp-recording-audio { + width: 100%; + height: 2rem; + accent-color: var(--text-muted); +} + +/* Movement list */ + +.composition-movements { + margin: 1.25rem 0; + border-top: 1px solid var(--border); +} + +.comp-movement { + padding: 0.65rem 0; + border-bottom: 1px solid var(--border); +} + +.comp-movement-header { + display: flex; + align-items: baseline; + gap: 0.75rem; + flex-wrap: wrap; +} + +.comp-movement-name { + font-weight: 600; + flex: 1 1 auto; +} + +.comp-movement-duration { + font-family: var(--font-ui); + font-size: 0.82rem; + color: var(--text-muted); +} + +.comp-movement-score { + font-family: var(--font-ui); + font-size: 0.82rem; + color: var(--text-muted); + text-decoration: none; +} + +.comp-movement-score:hover { + text-decoration: underline; +} + +.movement-audio { + display: block; + width: 100%; + height: 2rem; + margin-top: 0.5rem; + accent-color: var(--text-muted); +} + + +/* ============================================================ + TRANSCLUSION + States for
    placeholders injected by + Filters.Transclusion and populated by transclude.js. + ============================================================ */ + +/* Loading placeholder — subtle pulse so the page doesn't look broken + while the fetch is in flight. */ +.transclude--loading { + min-height: 1.65rem; + background: var(--bg-offset); + border-radius: 2px; + animation: transclude-pulse 1.2s ease-in-out infinite; +} + +@keyframes transclude-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* Loaded — transparent wrapper; content reads as native. */ +.transclude--loaded { + /* No visual treatment: transcluded content is visually native. */ +} + +/* Optional: uncomment to show a subtle left rule on loaded transclusions. +.transclude--loaded { + border-left: 2px solid var(--border); + padding-left: 1rem; + margin-left: -1rem; +} +*/ + +/* Error state — faint inline notice. */ +.transclude--error { + font-family: var(--font-mono); + font-size: var(--text-size-small); + color: var(--text-faint); + padding: 0.4rem 0; +} + +/* Content wrapper — display:contents so the injected nodes participate + in normal document flow without adding a structural block. */ +.transclude--content { + display: contents; +} + +/* ============================================================ + COPY BUTTON + Injected by copy.js into every
     block. Fades in on hover.
    +   ============================================================ */
    +
    +.copy-btn {
    +    position: sticky;
    +    float: right;
    +    top: 0;
    +    right: 0;
    +    margin-top: -0.4rem;
    +    margin-right: -0.5rem;
    +    margin-bottom: -1.6em;
    +    font-family: var(--font-sans);
    +    font-size: 0.72rem;
    +    font-weight: 500;
    +    letter-spacing: 0.03em;
    +    text-transform: uppercase;
    +    color: var(--text-faint);
    +    background: var(--bg);
    +    border: 1px solid var(--border-muted);
    +    border-radius: 3px;
    +    padding: 0.2em 0.55em;
    +    cursor: pointer;
    +    opacity: 0;
    +    transition: opacity 0.15s, color 0.15s, border-color 0.15s;
    +    user-select: none;
    +    line-height: 1.6;
    +    z-index: 1;
    +}
    +
    +pre:hover .copy-btn,
    +.copy-btn:focus-visible {
    +    opacity: 1;
    +}
    +
    +.copy-btn:hover {
    +    color: var(--text-muted);
    +    border-color: var(--border);
    +}
    +
    +.copy-btn[data-copied] {
    +    color: var(--text-muted);
    +    border-color: var(--border);
    +    opacity: 1;
    +}
    +
    +/* ============================================================
    +   TOUCH DEVICES
    +   (hover: none) and (pointer: coarse) reliably targets
    +   touchscreen-primary devices (phones, tablets) without
    +   relying on viewport width or user-agent sniffing.
    +
    +   Goals:
    +     1. Suppress :hover states that "stick" after a tap
    +     2. Ensure tap targets meet ~44px minimum
    +     3. (popup disable is handled in popups.js)
    +   ============================================================ */
    +
    +@media (hover: none) and (pointer: coarse) {
    +    /* Nav: ensure adequate vertical tap area even on wider tablets
    +       where the ≤540px query doesn't fire */
    +    .nav-primary a {
    +        padding-top: 0.35rem;
    +        padding-bottom: 0.35rem;
    +    }
    +
    +    .nav-portal-toggle {
    +        padding-top: 0.3rem;
    +        padding-bottom: 0.3rem;
    +    }
    +
    +    /* Settings gear: expand hit area without visual change */
    +    .settings-toggle {
    +        padding: 0.5rem;
    +        margin: -0.5rem;
    +    }
    +
    +    /* Portal row links */
    +    .nav-portals a {
    +        padding-top: 0.3rem;
    +        padding-bottom: 0.3rem;
    +    }
    +
    +    /* Suppress stuck hover states — reset to their non-hover values */
    +    .nav-primary a:hover           { color: var(--text-muted); }
    +    .nav-portals a:hover           { color: var(--text-faint); }
    +    .nav-portal-toggle:hover       { color: var(--text-faint); }
    +    .settings-toggle:hover         { color: var(--text-faint); }
    +    .toc-nav a:hover               { color: var(--text-faint); }
    +    .toc-nav > ol > li > a:hover   { color: var(--text-muted); }
    +    .hp-pro-row a:hover            { color: var(--text-muted); }
    +    .hp-curiosity-row a:hover      { color: var(--text-muted); }
    +    .hp-portal-name:hover          { text-decoration-color: var(--border-muted); }
    +    .meta-tag:hover                { color: var(--text-faint); }
    +    .backlink-source:hover         { color: var(--text-muted); }
    +    .section-toggle:hover          { color: var(--text-faint); }
    +
    +    /* Copy button: always visible on touch (no persistent hover) */
    +    .copy-btn { opacity: 1; }
    +}
    +
    +/* ============================================================
    +   PDF EMBED
    +   Inline PDF.js viewer iframes rendered from {{pdf:...}} directives.
    +   ============================================================ */
    +
    +.pdf-embed-wrapper {
    +    margin: 1.5rem 0;
    +    border: 1px solid var(--border);
    +    border-radius: 2px;
    +    overflow: hidden;
    +    background: var(--bg-subtle);
    +}
    +
    +.pdf-embed {
    +    display: block;
    +    width: 100%;
    +    height: 70vh;
    +    min-height: 420px;
    +    border: none;
    +}
    +
    +@media (max-width: 600px) {
    +    .pdf-embed {
    +        height: 60vh;
    +        min-height: 300px;
    +    }
    +}
    +
    +/* ── Archive affordance ─────────────────────────────────────────────────────
    +   The superscript "A" appended after a body link whose target is preserved
    +   in the local archive (build/Filters/Archive.hs). Loaded site-wide because
    +   the marker appears in essay/prose content, not on archive pages. */
    +
    +.archive-affordance {
    +    font-size: 0.7em;
    +    margin-left: 0.15em;
    +    line-height: 0;
    +}
    +
    +.archive-affordance a {
    +    font-family: var(--font-sans);
    +    font-weight: 600;
    +    text-decoration: none;
    +    color: var(--text-faint);
    +    border: 1px solid var(--border-muted);
    +    border-radius: 2px;
    +    padding: 0 0.25em;
    +}
    +
    +.archive-affordance a:hover {
    +    color: var(--text);
    +    border-color: var(--text-muted);
    +    background: var(--bg-subtle);
    +}
    +
    +/* Dead-link flip — a body link whose archived target is `rotted` has its
    +   href redirected to the local copy (build/Filters/Archive.hs). A dotted
    +   underline marks the link as redirected; its marker becomes a solid chip
    +   reading "archived" rather than the quiet bordered "A". */
    +.archive-rotted {
    +    text-decoration-style: dotted;
    +}
    +
    +.archive-affordance--rotted a {
    +    color: var(--bg);
    +    background: var(--text-muted);
    +    border-color: var(--text-muted);
    +}
    +
    +.archive-affordance--rotted a:hover {
    +    color: var(--bg);
    +    background: var(--text);
    +    border-color: var(--text);
    +}
    diff --git a/static/css/gallery.css b/static/css/gallery.css
    new file mode 100644
    index 0000000..6e8e9f3
    --- /dev/null
    +++ b/static/css/gallery.css
    @@ -0,0 +1,537 @@
    +/* gallery.css — Exhibit system (always-visible inline blocks with overlay
    +   expansion) and annotation system (static or collapsible callout boxes). */
    +
    +
    +/* ============================================================
    +   EXHIBIT WRAPPER
    +   Base styles shared by all exhibit types.
    +   ============================================================ */
    +
    +.exhibit {
    +    position: relative;
    +    display: block;
    +    margin: 1.65rem 0;
    +    padding: 0; /* reset 
    default margin */ +} + + +/* ============================================================ + MATH FOCUSABLE + Wrapper injected around every .katex-display by gallery.js. + Clicking anywhere on the wrapper opens the overlay. + ============================================================ */ + +.math-focusable { + position: relative; + display: block; + cursor: zoom-in; +} + +/* Expand glyph — decorative affordance, not interactive */ +.exhibit-expand { + position: absolute; + top: 50%; + right: 0.4rem; + transform: translateY(-50%); + font-family: var(--font-sans); + font-size: 0.75rem; + line-height: 1; + color: var(--text-faint); + padding: 0.2rem 0.35rem; + border-radius: 2px; + pointer-events: none; /* click handled by wrapper */ + opacity: 0; + transition: opacity var(--transition-fast); + user-select: none; +} + +.math-focusable:hover .exhibit-expand { + opacity: 1; +} + +/* Caption tooltip — appears above the math on hover, like alt text */ +.math-focusable[data-caption]::after { + content: attr(data-caption); + position: absolute; + bottom: calc(100% + 0.5rem); + left: 50%; + transform: translateX(-50%); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 2px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07); + padding: 0.3rem 0.65rem; + font-family: var(--font-sans); + font-size: 0.72rem; + font-style: italic; + color: var(--text-muted); + /* Explicit width prevents the box from collapsing to a single-word column */ + width: min(440px, 70vw); + white-space: normal; + text-align: center; + pointer-events: none; + opacity: 0; + transition: opacity var(--transition-fast); + z-index: 10; +} + +.math-focusable[data-caption]:hover::after { + opacity: 1; +} + + +/* ============================================================ + PROOF EXHIBIT + Always-visible inline block. Header is a non-interactive label. + ============================================================ */ + +.exhibit--proof .exhibit-header { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding-bottom: 0.45rem; + margin-bottom: 0.6rem; + border-bottom: 1px solid var(--border-muted); +} + +.exhibit--proof .exhibit-header-label { + font-style: italic; + font-weight: 600; + color: var(--text); + flex-shrink: 0; +} + +.exhibit--proof .exhibit-header-name { + font-family: var(--font-sans); + font-size: 0.82rem; + color: var(--text-muted); +} + + +/* ============================================================ + ANNOTATION + Editorial callout boxes: definitions, notes, remarks, warnings. + Two variants: static (always visible) and collapsible. + ============================================================ */ + +.annotation { + position: relative; + margin: 1.65rem 0; + border: 1px solid var(--border); + border-radius: 2px; +} + +.annotation-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.85rem; + border-bottom: 1px solid var(--border); + background: var(--bg-offset); +} + +.annotation-label { + font-family: var(--font-sans); + font-size: 0.68rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + color: var(--text-faint); + flex-shrink: 0; +} + +.annotation-name { + font-family: var(--font-sans); + font-size: 0.78rem; + color: var(--text-muted); + font-style: italic; + flex: 1; + min-width: 0; +} + +.annotation-toggle { + background: none; + border: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.68rem; + color: var(--text-faint); + padding: 0; + line-height: 1; + flex-shrink: 0; + transition: color var(--transition-fast); + white-space: nowrap; +} + +.annotation-toggle:hover { + color: var(--text-muted); +} + +.annotation-body { + padding: 0.825rem 0.85rem; +} + +/* Collapsible variant: body hidden until toggled. + Only max-height transitions — padding applies instantly so that + scrollHeight reads the correct full height before the animation starts. */ +.annotation--collapsible .annotation-body { + overflow: hidden; + max-height: 0; + padding-top: 0; + padding-bottom: 0; + transition: max-height 0.35s ease; +} + +.annotation--collapsible.is-open .annotation-body { + padding-top: 0.825rem; + padding-bottom: 0.825rem; +} + +/* Static variant: always open, no toggle */ +.annotation--static .annotation-header { + border-bottom: 1px solid var(--border); +} + + +/* ============================================================ + OVERLAY + Full-screen dark stage. Content floats inside — no panel box. + Modeled on Gwern's image-focus: the overlay IS the backdrop, + content is centered directly within it. + ============================================================ */ + +#gallery-overlay { + position: fixed; + inset: 0; + z-index: 500; + background: rgba(0, 0, 0, 0.92); + cursor: zoom-out; + display: flex; + align-items: center; + justify-content: center; +} + +#gallery-overlay[hidden] { + display: none; +} + +/* Content stage — large light area centered in the dark screen. + Fixed width (not max-width) so JS can detect overflow for font fitting. */ +#gallery-overlay-body { + background: var(--bg); + padding: 3.5rem 4.5rem; + width: 88vw; + max-height: 80vh; + overflow: hidden; /* JS shrinks font until content fits; auto as last resort */ + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 80px rgba(0, 0, 0, 0.5); + cursor: default; /* undo zoom-out from parent */ +} + +#gallery-overlay-body .katex-display { + /* font-size is set entirely by JS after measuring — no CSS value here */ + overflow-x: hidden; + margin: 0; +} + +/* Close — top right of screen */ +#gallery-overlay-close { + position: absolute; + top: 1.1rem; + right: 1.5rem; + background: none; + border: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: 1.5rem; + color: rgba(255, 255, 255, 0.5); + padding: 0.3rem 0.5rem; + line-height: 1; + transition: color var(--transition-fast); +} + +#gallery-overlay-close:hover { + color: rgba(255, 255, 255, 0.95); +} + +/* Group name — top center of screen */ +#gallery-overlay-name { + position: absolute; + top: 1.5rem; + left: 50%; + transform: translateX(-50%); + font-family: var(--font-sans); + font-size: 0.9rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.09em; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; + pointer-events: none; +} + +/* Caption — bottom center of screen, above counter */ +#gallery-overlay-caption { + position: absolute; + bottom: 3.5rem; + left: 50%; + transform: translateX(-50%); + font-family: var(--font-sans); + font-size: 1rem; + font-style: italic; + color: rgba(255, 255, 255, 0.65); + text-align: center; + max-width: 60vw; + pointer-events: none; +} + +#gallery-overlay-caption:empty, +#gallery-overlay-caption[hidden] { + display: none; +} + +/* Counter — bottom center of screen */ +#gallery-overlay-counter { + position: absolute; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + font-family: var(--font-sans); + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.38); + pointer-events: none; + white-space: nowrap; +} + +/* Nav buttons — floating at the screen edges, vertically centered */ +.gallery-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: 2.25rem; + color: rgba(255, 255, 255, 0.35); + padding: 0.75rem 1rem; + line-height: 1; + transition: color var(--transition-fast); +} + +.gallery-nav-btn:hover:not(:disabled) { + color: rgba(255, 255, 255, 0.9); +} + +.gallery-nav-btn:disabled { + opacity: 0; + pointer-events: none; +} + +#gallery-overlay-prev { left: 1rem; } +#gallery-overlay-next { right: 1rem; } + + +/* ============================================================ + SCORE FRAGMENT + Inline SVG music notation. Integrates with the gallery focusable + and named-exhibit systems. No header — caption only. + ============================================================ */ + +.score-fragment { + position: relative; + display: block; + margin: 2rem 0; + cursor: zoom-in; +} + +/* Reset #markdownBody figure box styles — must use ID+class to beat + the specificity of `#markdownBody figure` (0,1,1 vs 0,1,0). */ +#markdownBody figure.score-fragment { + background: none; + border: none; + padding: 0; + box-shadow: none; + max-width: 100%; /* override fit-content so SVG fills the column */ + margin: 2rem 0; /* override the base 3.3rem auto */ +} + +.score-fragment-inner { + display: block; + line-height: 0; /* collapse inline gap below the SVG block */ +} + +/* Make the LilyPond SVG responsive and theme-aware. + CSS width/height override the SVG's own width/height presentation + attributes. color: var(--text) is inherited by currentColor fills/strokes + (set by Filters.Score at build time). */ +.score-fragment-inner svg { + width: 100%; + height: auto; + display: block; + color: var(--text); +} + +/* Remove LilyPond's white background rectangle */ +.score-fragment-inner svg > rect:first-child { + fill: none; +} + +/* Expand glyph: top-right corner, not vertically centred */ +.score-fragment .exhibit-expand { + top: 0.5rem; + transform: none; +} + +.score-fragment:hover .exhibit-expand { + opacity: 1; +} + +.score-caption { + font-family: var(--font-sans); + font-size: 0.82rem; + font-style: italic; + color: var(--text-muted); + text-align: center; + margin-top: 0.65rem; + line-height: 1.4; +} + + +/* ============================================================ + OVERLAY — SCORE MODE + Tighter horizontal padding; SVG fills available width. + ============================================================ */ + +#gallery-overlay-body.is-score { + padding: 2rem 1.5rem; + /* Caption sits at bottom: 3.5rem in the viewport. The body is flex-centered, + so its bottom edge = (100vh + body-height) / 2. To guarantee clearance, + body-height must be ≤ 100vh − 2 × caption-offset − some margin. + calc(100vh − 12rem) keeps the bottom edge ≥ 6rem above the viewport bottom + regardless of screen size. */ + max-height: calc(100vh - 12rem); +} + +#gallery-overlay-body.is-score svg { + width: 100%; + height: auto; + max-height: calc(100vh - 16rem); /* body height minus 2rem padding each side */ + display: block; + color: var(--text); +} + +#gallery-overlay-body.is-score svg > rect:first-child { + fill: none; +} + + +/* ============================================================ + TOC EXHIBIT INTEGRATION + ============================================================ */ + +/* Compact inline exhibit links under a heading entry */ +.toc-exhibits-inline { + font-size: 0.69rem; + color: var(--text-faint); + line-height: 1.4; + margin-top: 0.1rem; + padding-left: 0.75rem; +} + +.toc-exhibits-inline a { + display: flex; + align-items: baseline; + gap: 0.4em; + color: var(--text-faint); + text-decoration: none; + padding: 0.1rem 0; + font-size: 0.74rem; + transition: color var(--transition-fast); +} + +.toc-exhibits-inline a:hover { + color: var(--text-muted); +} + +/* Contained Herein — collapsible global exhibit index */ +.toc-contained { + margin-top: 0.9rem; + padding-top: 0.6rem; + border-top: 1px solid var(--border); +} + +.toc-contained-toggle { + display: flex; + align-items: center; + gap: 0.35em; + background: none; + border: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.69rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + color: var(--text-faint); + padding: 0; + line-height: 1.5; + width: 100%; + text-align: left; + transition: color var(--transition-fast); +} + +.toc-contained-toggle:hover { + color: var(--text-muted); +} + +.toc-contained-arrow { + font-size: 0.55rem; + display: inline-block; + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.toc-contained.is-open .toc-contained-arrow { + transform: rotate(90deg); +} + +.toc-contained-list { + list-style: none; + padding: 0; + margin: 0.35rem 0 0 0; + display: none; +} + +.toc-contained.is-open .toc-contained-list { + display: block; +} + +.toc-contained-list li { + margin: 0.2rem 0; +} + +.toc-contained-list a { + display: flex; + align-items: baseline; + gap: 0.4em; + font-size: 0.72rem; + color: var(--text-faint); + text-decoration: none; + transition: color var(--transition-fast); +} + +.toc-contained-list a:hover { + color: var(--text-muted); +} + +.toc-exhibit-type-badge { + font-size: 0.6rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-faint); + flex-shrink: 0; +} diff --git a/static/css/home.css b/static/css/home.css new file mode 100644 index 0000000..a417a87 --- /dev/null +++ b/static/css/home.css @@ -0,0 +1,106 @@ +/* home.css — Homepage-specific styles (loaded only on index.html) */ + +/* ============================================================ + UTILITY ROWS + Professional row and curiosities row: compact Fira Sans + link strips separated by middots. Two rows, visually equal. + ============================================================ */ + +.hp-pro-row, +.hp-curiosity-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + font-family: var(--font-sans); + font-size: var(--text-size-small); + margin-top: 0.75rem; +} + +/* First row: extra top margin since the hr is gone — breathing + room between the intro block and the utility rows. */ +.hp-pro-row { + margin-top: 2rem; +} + +.hp-pro-row a, +.hp-curiosity-row a { + color: var(--text-muted); + text-decoration: none; + transition: color var(--transition-fast); + padding: 0.1rem 0.15rem; +} + +.hp-pro-row a:hover, +.hp-curiosity-row a:hover { + color: var(--text); +} + +.hp-sep { + color: var(--text-faint); + padding: 0 0.2em; + user-select: none; + pointer-events: none; +} + +/* ============================================================ + LATIN BENEDICTION + Right-aligned closing salutation, set in lang="la" for + correct hyphenation and screen-reader pronunciation. + ============================================================ */ + +.hp-latin { + text-align: right; + margin-top: 1.5rem; +} + +.hp-latin p { + margin: 0; + color: var(--text-muted); +} + +/* ============================================================ + PORTAL LIST + Annotated portal directory, eight items, one per line. + Name link — em dash — short description in faint text. + ============================================================ */ + +.hp-portals { + margin-top: 2.5rem; + font-family: var(--font-sans); +} + +.hp-portal-item { + display: flex; + align-items: baseline; + gap: 0.4em; + padding: 0.2rem 0; +} + +.hp-portal-name { + color: var(--text); + text-decoration: underline; + text-decoration-color: var(--border-muted); + text-decoration-thickness: 0.15em; + text-underline-offset: 0.2em; + text-decoration-skip-ink: auto; + transition: text-decoration-color var(--transition-fast), color var(--transition-fast); + flex-shrink: 0; +} + +.hp-portal-name:hover { + text-decoration-color: var(--link-hover-underline); +} + +.hp-portal-dash { + color: var(--text-faint); + padding: 0 0.15em; + user-select: none; + flex-shrink: 0; +} + +.hp-portal-desc { + font-size: var(--text-size-small); + color: var(--text-faint); + line-height: 1.4; +} diff --git a/static/css/images.css b/static/css/images.css new file mode 100644 index 0000000..4938316 --- /dev/null +++ b/static/css/images.css @@ -0,0 +1,115 @@ +/* images.css — Figure layout, captions, and lightbox overlay */ + +/* ============================================================ + FIGURE BASE (supplements typography.css figure rules) + ============================================================ */ + +figure { + margin: 2rem 0; + max-width: 100%; + text-align: center; +} + +figure img { + max-width: 100%; + height: auto; + display: block; + border-radius: 3px; +} + +figcaption { + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: var(--text-muted); + text-align: center; + margin-top: 0.6rem; +} + +/* Zoom cursor for lightbox-enabled images */ +img[data-lightbox] { + cursor: zoom-in; +} + + +/* ============================================================ + LIGHTBOX OVERLAY + ============================================================ */ + +.lightbox-overlay { + position: fixed; + inset: 0; + z-index: 800; + background: rgba(0, 0, 0, 0.88); + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + + opacity: 0; + visibility: hidden; + pointer-events: none; + + transition: + opacity var(--transition-fast), + visibility var(--transition-fast); +} + +.lightbox-overlay.is-open { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.lightbox-img { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + border-radius: 3px; + display: block; +} + +.lightbox-caption { + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: rgba(255, 255, 255, 0.65); + text-align: center; + max-width: 60ch; + line-height: 1.5; +} + +.lightbox-close { + position: absolute; + top: 1rem; + right: 1.25rem; + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 2em; + padding: 0.25em 0.75em; + font-family: var(--font-sans); + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: background var(--transition-fast); +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.22); +} + + +/* ============================================================ + REDUCED MOTION + ============================================================ */ + +[data-reduce-motion] .lightbox-overlay { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .lightbox-overlay { + transition: none; + } +} diff --git a/static/css/item-card.css b/static/css/item-card.css new file mode 100644 index 0000000..bd191b9 --- /dev/null +++ b/static/css/item-card.css @@ -0,0 +1,263 @@ +/* item-card.css — shared list-page components (item cards, count toggle, See Also) */ + +/* ============================================================ + SEE ALSO + Supplementary navigation block at the top of a tag page — + parent, siblings, and direct children of the current scope. + Quieter than the home portal grid; reads as a subordinate + wayfinding aid rather than primary content. Content flows + inline (name, em-dash, description) so long descriptions + wrap naturally on narrow screens instead of overflowing. + ============================================================ */ + +.see-also { + margin: 0.15rem 0 1.5rem; + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.45; +} + +.see-also-list { + list-style: none; + margin: 0; + padding: 0; +} + +.see-also-item { + padding: 0.03rem 0; +} + +.see-also-name { + color: var(--text-muted); + text-decoration: underline; + text-decoration-color: var(--border); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; + transition: color var(--transition-fast), text-decoration-color var(--transition-fast); +} + +.see-also-name:hover { + color: var(--text); + text-decoration-color: var(--border-muted); +} + +.see-also-dash { + color: var(--text-faint); + padding: 0 0.2em; + user-select: none; +} + +.see-also-desc { + color: var(--text-faint); +} + +/* Hierarchy markers — parent up, children right-and-down. + Subtle enough that it reads at a glance without calling attention. */ +.see-also-parent .see-also-name::before { + content: "↑ "; + color: var(--text-faint); +} + +.see-also-child { + padding-left: 0.9rem; +} + +.see-also-child .see-also-name::before { + content: "↳ "; + color: var(--text-faint); + padding-right: 0.1em; +} + + + +/* ============================================================ + COUNT CONTROL + ============================================================ */ + +.list-controls { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.75rem; +} + +.list-controls-label { + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--text-faint); +} + +.list-controls-options { + display: flex; + gap: 0.3rem; +} + +.list-count-btn { + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 2px; + padding: 0.15em 0.55em; + cursor: pointer; + transition: border-color 0.1s, color 0.1s; +} + +.list-count-btn:hover { + border-color: var(--border-muted); + color: var(--text); +} + +.list-count-btn.is-active { + border-color: var(--text-muted); + color: var(--text); + font-weight: 600; +} + +/* ============================================================ + ENTRY LIST + ============================================================ */ + +.item-card-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.item-card { + display: flex; + gap: 0.9rem; + align-items: flex-start; + padding: 0.85rem 0; + border-bottom: 1px solid var(--border); +} + +.item-card:first-child { + border-top: 1px solid var(--border); +} + +/* ============================================================ + KIND BADGE + ============================================================ */ + +.item-card-kind { + font-family: var(--font-sans); + font-size: 0.68rem; + font-variant: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + flex-shrink: 0; + margin-top: 0.35em; + min-width: 5.5rem; + text-align: left; + line-height: 1.6; +} + +/* ============================================================ + ENTRY CONTENT + ============================================================ */ + +.item-card-main { + flex: 1; + min-width: 0; +} + +.item-card-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.item-card-title { + font-family: var(--font-serif); + font-size: 1rem; + color: var(--text); + text-decoration: none; + line-height: 1.35; +} + +.item-card-title:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.item-card-date { + font-family: var(--font-sans); + font-size: 0.72rem; + color: var(--text-faint); + white-space: nowrap; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.item-card-abstract { + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: var(--text-muted); + margin: 0.3rem 0 0; + line-height: 1.55; + /* Clamp to two lines */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Library (and any other surface that sets $full-abstract$) renders the + abstract unclamped. Resetting display drops the -webkit-box flex that + drives the clamp; overflow reset keeps long abstracts visible. */ +.item-card-abstract.is-full { + display: block; + overflow: visible; +} + +/* Revision note — italicized line under the abstract carrying the + most-recent 'revised:' entry's note. Muted color, smaller than + body, matches the card's secondary-text register. */ +.item-card-revision-note { + font-family: var(--font-sans); + font-size: 0.78rem; + color: var(--text-faint); + margin: 0.2rem 0 0; + line-height: 1.4; +} + +/* ============================================================ + MOBILE (≤540px) + The desktop layout packs [badge | title ... date] in a + single flex row. On narrow phones the date's nowrap width + + title min-content + gap overflows the viewport, pushing + titles off the right edge. Stack title-over-date and shrink + the badge so .item-card-main gets real room. + ============================================================ */ + +@media (max-width: 540px) { + .item-card { + gap: 0.65rem; + } + + .item-card-kind { + min-width: 0; + font-size: 0.62rem; + } + + .item-card-header { + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + } + + .item-card-title { + font-size: 0.95rem; + overflow-wrap: anywhere; + } + + .item-card-date { + font-size: 0.68rem; + } +} diff --git a/static/css/layout.css b/static/css/layout.css new file mode 100644 index 0000000..0d90988 --- /dev/null +++ b/static/css/layout.css @@ -0,0 +1,258 @@ +/* layout.css — Three-column page structure: TOC | body | sidenotes */ + +/* ============================================================ + PAGE WRAPPER + The outer shell. Wide enough for TOC + body + sidenotes. + ============================================================ */ + +/* Body is plain block — keeps the sticky nav header (a direct body + child) working reliably on iOS WebKit, where position: sticky on a + direct flex/grid child silently degrades to static. The sticky-footer + math (full-viewport min-height + flex column to push footer down) + moves to .page-shell, which wraps everything below the nav. */ + +.page-shell { + display: flex; + flex-direction: column; + min-height: calc(100dvh - var(--nav-height, 4rem)); +} + +/* ============================================================ + HEADER + Full-width, sits above the three-column area. + (Nav styles live in components.css) + ============================================================ */ + +/* Site-nav header only — exclude the essay-frontmatter
    that + essay/reading/blog templates emit as a body-level sibling so its + monogram and figure columns can span full viewport width. Without + the :not() guard, the essay header inherits the sticky / nav-bg / + border-bottom chrome meant only for the top navigation, painting + the wrong color band over the page and pinning the frontmatter to + the viewport top. */ +body > header:not(.essay-frontmatter) { + width: 100%; + border-bottom: 1px solid var(--border); + background-color: var(--bg-nav); + position: sticky; + top: 0; + z-index: 100; +} + +/* ============================================================ + CONTENT AREA + Three-column grid: [toc 220px] [body] [phantom 220px] + The phantom right column mirrors the TOC width so that the + body column is geometrically centered on the viewport. + Sidenotes are absolutely positioned inside #markdownBody and + overflow into the phantom column naturally. + ============================================================ */ + +#content { + display: grid; + grid-template-columns: 1fr minmax(0, var(--body-max-width)) 1fr; + align-items: start; + flex: 1; + width: 100%; + padding: 2rem var(--page-padding); +} + +/* ============================================================ + LEFT COLUMN — TABLE OF CONTENTS + Sticky sidebar, collapses below a breakpoint. + (TOC content + scroll tracking in toc.js / components.css) + ============================================================ */ + +#toc { + grid-column: 1; + position: sticky; + top: calc(var(--nav-height, 4rem) + 1.5rem); + max-height: calc(100vh - var(--nav-height, 4rem) - 3rem); + overflow-y: auto; + padding-right: 1.5rem; + align-self: start; +} + +/* ============================================================ + CENTER COLUMN — MAIN BODY + Pinned to column 2 explicitly so it stays centered even when + the TOC is hidden (below 900px breakpoint). + #markdownBody must be position: relative — sidenotes.js + appends absolutely-positioned sidenote columns inside it. + ============================================================ */ + +#markdownBody { + grid-column: 2; + width: min(var(--body-max-width), 100%); + position: relative; /* REQUIRED by sidenotes.js */ + min-width: 0; +} + +/* ============================================================ + STANDALONE PAGES (no #content wrapper) + essay-index, blog-index, tag-index, page, blog-post, search — + these emit #markdownBody as a direct child of .page-shell. + Without the #content flex-row wrapper there is no centering; + fix it here. (Was body > #markdownBody before the page-shell + wrapper was introduced to keep iOS sticky working.) + ============================================================ */ + +.page-shell > #markdownBody { + align-self: center; + padding: 2rem var(--page-padding); + flex: 1 0 auto; +} + +@media (max-width: 680px) { + .page-shell > #markdownBody { + padding: 1.25rem var(--page-padding); + } +} + + +/* ============================================================ + FOOTER + ============================================================ */ + +.page-shell > footer { + width: 100%; + border-top: 1px solid var(--border); + padding: 1.5rem var(--page-padding); + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: var(--text-muted); + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; +} + +.footer-left { + display: flex; + align-items: center; + flex: 1; + gap: 1rem; +} + +.footer-left a { + color: var(--text-faint); + text-decoration: none; + transition: color var(--transition-fast); +} + +.footer-left a:hover { + color: var(--text-muted); +} + +.footer-center { + font-size: 0.72rem; + color: var(--text-faint); +} + +.footer-license a { + color: var(--text-faint); + text-decoration: none; + transition: color var(--transition-fast); +} + +.footer-license a:hover { + color: var(--text-muted); +} + +.footer-totop { + background: none; + border: none; + cursor: pointer; + color: var(--text-faint); + font-family: var(--font-sans); + font-size: var(--text-size-small); + padding: 0; + line-height: 1; + transition: color var(--transition-fast); +} + +.footer-totop:hover { + color: var(--text-muted); +} + +.footer-right { + flex: 1; + text-align: right; +} + +.footer-build-link { + font-size: 0.72rem; + color: var(--text-faint); + font-variant-numeric: tabular-nums; + text-decoration: none; +} + +.footer-build-link:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + + +/* ============================================================ + RESPONSIVE BREAKPOINTS + ============================================================ */ + +/* Below ~1100px: not enough horizontal space for sidenotes. + The .sidenote asides are hidden by sidenotes.css; the Pandoc-generated + section.footnotes is shown instead (also handled by sidenotes.css). */ + +/* ============================================================ + FOCUS MODE + Hides the TOC, fades the header until hovered. + Activated by [data-focus-mode] on (settings.js). + ============================================================ */ + +[data-focus-mode] #toc { + display: none; +} + +/* Below ~900px: hide the TOC. + #markdownBody stays in grid-column 2, so it remains centered + with the phantom right column still balancing it. */ +@media (max-width: 900px) { + #toc { + display: none; + } +} + +/* Below ~680px: collapse to single-column, full-width body. */ +@media (max-width: 680px) { + #content { + grid-template-columns: 1fr; + padding: 1.25rem var(--page-padding); + } + + #markdownBody { + grid-column: 1; + width: 100%; + } + + /* Footer: stack vertically so three sections don't fight for width. */ + .page-shell > footer { + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 0.9rem 1rem; + text-align: center; + } + + .footer-left { + justify-content: center; + } + + .footer-right { + text-align: center; + } +} + +/* Below ~900px: body spans full width (TOC hidden, no phantom column). */ +@media (max-width: 900px) and (min-width: 681px) { + #markdownBody { + grid-column: 1 / -1; + } +} diff --git a/static/css/library.css b/static/css/library.css new file mode 100644 index 0000000..944836a --- /dev/null +++ b/static/css/library.css @@ -0,0 +1,456 @@ +/* library.css — Library + Bibliography page components, plus the + epistemic-filter UI that now lives on /search.html. */ + +/* ============================================================ + LIBRARY PALETTE — scoped warm "candlelight" accent + Only loaded on /library.html (via $if(library)$) and /search.html, + so defining at :root here is effectively page-scoped. Consumed by + section headings, the divider, the intro blockquote, and the + "more on this shelf" link to give the library page a fine-press + feel without touching global colors. + ============================================================ */ + +:root { + --library-accent: #524638; /* muted warm-gray — reads as "warm" only if you look */ + --library-accent-muted: #7c7164; /* dusty taupe */ + --library-intro-ink: #3c3328; +} + +[data-theme="dark"] { + --library-accent: #b5a890; /* soft oat */ + --library-accent-muted: #897f6e; + --library-intro-ink: #c8beac; +} + +[data-theme="cappuccino"] { + --library-accent: #d0b888; /* foam — matches text-muted */ + --library-accent-muted: #957a5a; + --library-intro-ink: #dcc296; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --library-accent: #b5a890; + --library-accent-muted: #897f6e; + --library-intro-ink: #c8beac; + } +} + +/* ============================================================ + FILTER UI + Originally the Library's sort+filter panel; the sort panel + and numeric filter were dropped in Phase 4 when the Library + unified onto item-cards. The filter panel itself remains in + use on /search.html, which reuses the same classes and + filter-panel markup. + ============================================================ */ + +.library-filter-toggle { + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 2px; + padding: 0.15em 0.55em; + cursor: pointer; + margin-left: auto; + transition: border-color 0.1s, color 0.1s; +} + +.library-filter-toggle:hover, +.library-filter-toggle[aria-expanded="true"] { + border-color: var(--text-muted); + color: var(--text); +} + +.filter-toggle-badge { + font-weight: 600; +} + +.library-filters { + border-top: 1px solid var(--border); + padding-top: 0.75rem; + margin-top: 0.75rem; +} + +.library-filters[hidden] { + display: none; +} + +.filter-row { + display: flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.45rem; +} + +.filter-label { + font-family: var(--font-sans); + font-size: 0.72rem; + color: var(--text-faint); + width: 5.5rem; + flex-shrink: 0; +} + +.filter-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; +} + +.filter-prefix { + font-family: var(--font-sans); + font-size: 0.72rem; + color: var(--text-faint); +} + +.filter-btn { + font-family: var(--font-sans); + font-size: 0.72rem; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 2px; + padding: 0.1em 0.45em; + cursor: pointer; + transition: border-color 0.1s, color 0.1s; +} + +.filter-btn:hover { + border-color: var(--border-muted); + color: var(--text); +} + +.filter-btn.is-active { + border-color: var(--text-muted); + color: var(--text); + font-weight: 600; +} + +.filter-number { + font-family: var(--font-sans); + font-size: 0.72rem; + width: 3rem; + padding: 0.1em 0.3em; + border: 1px solid var(--border); + border-radius: 2px; + background: transparent; + color: var(--text); +} + +.filter-number:focus { + outline: none; + border-color: var(--text-muted); +} + +.filter-row-actions { + justify-content: flex-end; + margin-top: 0.25rem; +} + +.filter-clear-btn { + font-family: var(--font-sans); + font-size: 0.72rem; + color: var(--text-faint); + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +.filter-clear-btn:hover { + color: var(--text); +} + +/* Search-page result filtering (applied via search-filters.js) */ + +.search-filtered { + display: none !important; +} + +/* Search page filter controls — just the toggle, no sort buttons */ + +.search-filter-controls { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} + +/* ============================================================ + PORTAL SECTIONS + Shared by the Library and by /bibliography// pages + for the Writings + References section headers. + ============================================================ */ + +.library-section { + margin-bottom: 3rem; +} + +/* Section heading — fine-press chapter marker. Spectral small-caps in + walnut ink, letterspaced for a traditional printer's-ornament look. + The ornament span inherits currentColor and picks up the same accent. */ +.library-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(--library-accent); + text-transform: none; + font-weight: 400; + margin: 0 0 0.9rem 0; +} + +/* Per-shelf ornament sitting before the heading text. SVGs in + /images/dingbats/ painted via mask-image so they inherit + currentColor (→ the same muted tone as the heading). */ +.library-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; +} + +.library-section-ornament[data-ornament="research"] { + mask-image: url('/images/dingbats/research.svg'); + -webkit-mask-image: url('/images/dingbats/research.svg'); +} +.library-section-ornament[data-ornament="nonfiction"] { + mask-image: url('/images/dingbats/nonfiction.svg'); + -webkit-mask-image: url('/images/dingbats/nonfiction.svg'); +} +.library-section-ornament[data-ornament="fiction"] { + mask-image: url('/images/dingbats/fiction.svg'); + -webkit-mask-image: url('/images/dingbats/fiction.svg'); +} +.library-section-ornament[data-ornament="poetry"] { + mask-image: url('/images/dingbats/poetry.svg'); + -webkit-mask-image: url('/images/dingbats/poetry.svg'); +} +.library-section-ornament[data-ornament="music"] { + mask-image: url('/images/dingbats/clef.svg'); + -webkit-mask-image: url('/images/dingbats/clef.svg'); +} +.library-section-ornament[data-ornament="ai"] { + mask-image: url('/images/dingbats/ai.svg'); + -webkit-mask-image: url('/images/dingbats/ai.svg'); +} +.library-section-ornament[data-ornament="tech"] { + mask-image: url('/images/dingbats/tech.svg'); + -webkit-mask-image: url('/images/dingbats/tech.svg'); +} +.library-section-ornament[data-ornament="miscellany"] { + mask-image: url('/images/dingbats/trefoil.svg'); + -webkit-mask-image: url('/images/dingbats/trefoil.svg'); +} + +/* Inter-shelf divider. Adjacent-sibling selector renders between + actually-emitted sections — a section hidden by its $if$ gate + leaves no orphan divider because it never hits the DOM. */ +.library-section + .library-section::before { + content: ""; + display: block; + width: 240px; + max-width: 60%; + height: 24px; + margin: 2.5rem auto; + color: var(--library-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; +} + +/* "More on this shelf →" link, shown when the portal has more + items than the shelf's cap. Right-aligned, italic, subdued. */ +.library-more { + text-align: right; + font-family: var(--font-serif); + font-style: italic; + font-size: 0.95rem; + color: var(--library-accent-muted); + margin: 0.75rem 0 0; +} + +.library-more a { + color: inherit; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color var(--transition-fast), color var(--transition-fast); +} + +.library-more a:hover { + color: var(--library-accent); + border-bottom-color: var(--library-accent-muted); +} + +/* Leading blockquote above the shelves, lifted from content/library.md. + Set as an epigraph: narrower measure, italic serif, warm ink, no + left-bar decoration. */ +.library-intro { + max-width: 32rem; + margin: 1.5rem 0 2.75rem; + font-family: var(--font-serif); + font-style: italic; + font-size: 0.95rem; + line-height: 1.55; + color: var(--library-intro-ink); +} + +.library-intro blockquote { + border: none; + margin: 0; + padding: 0; +} + +.library-intro blockquote p { + margin: 0 0 0.4rem 0; +} + +.library-intro blockquote p:last-of-type { + margin: 0; + font-style: normal; + font-size: 0.9rem; + color: var(--library-accent-muted); +} + +.library-intro a { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--library-accent-muted); + text-decoration-thickness: 0.05em; + text-underline-offset: 0.2em; +} + +.library-intro a:hover { + text-decoration-color: var(--library-accent); +} + +/* Card-level typography refinements scoped to the library page. + Oldstyle figures on dates read as book-typesetting rather than + tabular UI; small-caps on the item-kind badge keeps it in the + same fine-press register as the section heading. Scoped via + `.library-section` so /new.html and tag pages keep their + current treatment (lining figures, uppercase badges). */ +.library-section .item-card-date { + font-variant-numeric: oldstyle-nums proportional-nums; + font-feature-settings: "onum" 1, "pnum" 1; +} + +.library-section .item-card-kind { + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.06em; +} + +.library-section h2 a { + color: inherit; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color var(--transition-fast); +} + +.library-section h2 a:hover { + border-bottom-color: var(--library-accent-muted); +} + +/* ============================================================ + SEE ALSO — compact chain (Phase 4) + Secondary navigation above the portal sections: New, + Commonplace, Colophon, Bibliography. Single-line `·`-separated + list, sans-serif, muted, reads as a utility row rather than + content. + ============================================================ */ + +.library-see-also { + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--text-muted); + margin: 0.25rem 0 2rem; +} + +.library-see-also a { + color: var(--text-muted); + text-decoration: underline; + text-decoration-color: var(--border); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; + transition: color var(--transition-fast), text-decoration-color var(--transition-fast); +} + +.library-see-also a:hover { + color: var(--text); + text-decoration-color: var(--border-muted); +} + +.library-see-also-sep { + color: var(--text-faint); + padding: 0 0.35em; + user-select: none; +} + +/* ============================================================ + BIBLIOGRAPHY INDEX — alphabet jump strip + letter headers + Complete A–Z row at top of /bibliography/ so the page reads + as a scannable reference. Absent letters render as muted + non-links so the strip stays visually complete without gaps. + ============================================================ */ + +.bibliography-alphabet { + display: flex; + flex-wrap: wrap; + gap: 0.15em; + margin: 0.25rem 0 1.5rem; + font-family: var(--font-sans); + font-size: 0.85rem; + line-height: 1.4; +} + +.bibliography-alphabet .alpha { + display: inline-block; + min-width: 1.2em; + padding: 0.05em 0.2em; + text-align: center; + color: var(--text-muted); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.bibliography-alphabet a.alpha:hover { + color: var(--text); + border-bottom-color: var(--border-muted); +} + +.bibliography-alphabet .alpha-empty { + color: var(--text-faint); + opacity: 0.45; + cursor: default; +} + +.bibliography-letter { + font-family: var(--font-sans); + font-size: 0.85rem; + font-variant: small-caps; + text-transform: lowercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 500; + margin: 1.5rem 0 0.5rem; + scroll-margin-top: 1rem; +} diff --git a/static/css/links.css b/static/css/links.css new file mode 100644 index 0000000..b62cc6c --- /dev/null +++ b/static/css/links.css @@ -0,0 +1,140 @@ +/* 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; +} + +[data-theme="cappuccino"] { + --links-accent: #c0c8e0; + --links-accent-muted: #8e95ad; + --links-intro-ink: #cdd4e8; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --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
      . One row per item. Bullet markers + suppressed; the brand icon stamped by typography.css's + a[data-link-icon-type="svg"]::after rule already reads as the row's + ornament. */ +.links-section ul { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: 1fr; + gap: 0.3rem; +} + +.links-section li { + margin: 0; + font-family: var(--font-serif); + font-size: 0.95rem; +} diff --git a/static/css/marks.css b/static/css/marks.css new file mode 100644 index 0000000..1a49837 --- /dev/null +++ b/static/css/marks.css @@ -0,0 +1,285 @@ +/* ============================================================ + FRONTMATTER MARKS — monogram + epistemic figure + See MARKS.md for the full specification. + + The header is a three-column grid sitting above the + cursive-L `content-divider`. Either or both mark columns + may be absent; the grid collapses cleanly because empty + columns size to zero (the conditional template guards + suppress the slot div entirely when its SVG is empty). + ============================================================ */ + +/* Three-column grid spanning the full viewport width: + [ monogram ] [ title block — 1fr ] [ epistemic figure ] + The header lives outside #content so the grid stretches + edge-to-edge; the side columns are pinned to the same + `clamp()` width as the marks themselves so the middle + column stays symmetric in the viewport even when only one + slot has content (e.g. essays without status: render only + the monogram, but the right column still reserves its + width — without this, the title block would centre in an + off-axis 1fr cell and visibly slide right). The padding + mirrors #content's `2rem var(--page-padding)` so the + header aligns with the body below. */ +.essay-frontmatter { + display: grid; + grid-template-columns: + clamp(170px, 17vw, 280px) + minmax(0, 1fr) + clamp(170px, 17vw, 280px); + column-gap: clamp(0.75rem, 2vw, 1.75rem); + row-gap: 0.75rem; + align-items: center; + padding: 2rem var(--page-padding); + width: 100%; +} + +/* Reading variant (poetry / fiction) and blog variant share the + same grid; declared explicitly so future tweaks can diverge. */ +.essay-frontmatter--reading, +.essay-frontmatter--blog { + grid-template-columns: + clamp(170px, 17vw, 280px) + minmax(0, 1fr) + clamp(170px, 17vw, 280px); +} + +/* Title block: centred in its grid column, capped to roughly + the body column's measure so prose lines stay readable on + ultrawide viewports. `justify-self: center` is the explicit + override of grid items' default `stretch`; without it auto + margins do not centre because the item is already filling + the cell at max-width. */ +.frontmatter-title { + min-width: 0; + width: 100%; + max-width: var(--body-max-width); + justify-self: center; + text-align: center; +} + +.frontmatter-title > .page-title { + margin-bottom: 0.25rem; +} + +/* Abstract paragraph reads better left-aligned even inside a + centred title block — multi-line prose with ragged-right + on a centred axis becomes hard to track. */ +.frontmatter-title .meta-description { + text-align: left; +} + +/* Compact-strip chips sit comfortably wider than the abstract; + centre them so the row balances visually under the title. */ +.frontmatter-title .meta-epistemic-strip { + justify-content: center; +} + +/* Tailmatter: the body-level wrapper that hosts the metadata-tail + row (tags + keywords + affiliation + pagelinks). Constrained to + the body column's measure and centred so its contents read at the + same width they did before the layout split, while sitting + *above* #content so the TOC sidebar starts right under the + frontmatter divider rather than competing with it for the top + of the page. */ +.essay-tailmatter { + width: min(var(--body-max-width), 100%); + margin: 0 auto; + padding: 0 var(--page-padding); + box-sizing: border-box; +} + +/* The cursive-L frontmatter divider runs edge-to-edge of the + viewport — the dashed lines on either side of the L use the + existing `flex: 1` rule on `.content-divider::before` and + `::after` to fill whatever container they sit inside, so as a + body-level child the divider naturally spans the full page + width. The page-padding keeps the dashes off the literal edge + while still reading as a full-width separator. */ +.content-divider--frontmatter { + padding: 0 var(--page-padding); + box-sizing: border-box; +} + +/* Monogram placeholder (rendered when no mark.svg exists for the + piece — see Marks.monogramPlaceholder). Lower opacity so it reads + as a neutral frame, balancing the epistemic-figure column without + being mistaken for an authored glyph. */ +.frontmatter-mark--placeholder svg { + opacity: 0.35; +} + +/* Subtitle: a short secondary line, lighter than the H1, never + competing with it. Kept restrained so existing essays without + a subtitle render unchanged. */ +.essay-subtitle { + font-family: var(--font-serif); + font-size: 1.05rem; + font-style: italic; + color: var(--text-muted); + margin: 0 0 0.75rem 0; + line-height: 1.35; +} + +/* Mark slot: a column wrapper that hosts the monogram or the + epistemic figure. The slot itself is a plain block; the SVG + inside controls its dimensions. line-height: 0 suppresses the + baseline descender gap that would otherwise add a few pixels + below the figure. */ +.frontmatter-mark-slot { + line-height: 0; +} + +/* The mark itself: a
      wrapping the inlined SVG. Two + inherited styles need to be overridden: + – `figure { margin: 2rem 0; max-width: 100% }` in images.css + (otherwise vertical margin pushes the mark off-grid). + – `#markdownBody figure { background, padding, border, + border-radius, box-shadow }` in typography.css (the + card-style chrome that wraps inline body figures with + a subtle paper-edge effect — not what we want for a + frontispiece glyph that should sit seamlessly on the + page background). + The `#markdownBody` prefix on the second rule has higher + specificity than a plain `.frontmatter-mark`, so we match + that prefix here too. */ +.frontmatter-mark, +#markdownBody .frontmatter-mark { + margin: 0; + padding: 0; + max-width: none; + background: none; + border: none; + border-radius: 0; + box-shadow: none; + color: var(--text); +} + +/* Frontmatter header context: scale with viewport. 170 px floor on + narrow desktops, 280 px cap on ultrawide displays (matches the + monogram viewBox so the placeholder roundel reaches its native + edge). 17vw is the slope between the two — at ~1000 px it equals + the floor, at ~1650 px it equals the cap. */ +.essay-frontmatter .frontmatter-mark { + width: clamp(170px, 17vw, 280px); + height: clamp(170px, 17vw, 280px); +} + +/* Item-card context: small inline glyph beside the kind badge. + Sized to read as a marker, not a competing figure. */ +.item-card-monogram { + flex-shrink: 0; + line-height: 0; + color: var(--text-muted); + margin-top: 0.15em; +} + +.item-card-monogram .frontmatter-mark { + width: 72px; + height: 72px; +} + +/* SVG fills its parent figure exactly. !important defeats the + global `img, video, svg { max-width: 100%; height: auto }` in + base.css for our specific case (which would otherwise leave the + figure as a tall, narrow strip when the column is grid-auto'd). */ +.frontmatter-mark svg { + display: block; + width: 100%; + height: 100%; + color: inherit; +} + +/* Epistemic figure inside an anchor: the anchor inherits color + from the slot (so currentColor in the SVG resolves to --text) + and stays inline-block so it doesn't pick up underline / link + chrome from the surrounding prose styles. */ +.frontmatter-mark-slot--right > a { + display: inline-block; + color: inherit; + text-decoration: none; + line-height: 0; +} + +/* ============================================================ + COMPACT-STRIP CHIP ADDITIONS + ============================================================ */ + +/* Inline trend arrow (↑ ↓ →) appended to the confidence percentage. + No leading separator — sits flush against the value it modifies. */ +.ep-trend { + display: inline-block; + margin-left: 0.15em; + font-family: var(--font-sans); + font-size: 0.7rem; + color: var(--text-muted); + vertical-align: 0.05em; +} +.ep-row .ep-trend::before { content: none; } + +/* Peer-status chip — same typography as .ep-status, distinct + data-term so future revisions can style it differently. */ +.ep-peer-status { + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-muted); +} +.ep-peer-status::before { + content: "·\00a0"; + color: var(--border); +} + +/* Retracted: strikethrough across the chip text — the spec's + §4.1 visual marker for a retracted piece. */ +.ep-peer-status--retracted { + text-decoration: line-through; + text-decoration-thickness: 1px; +} + +/* Proved-confidence variant: same row typography, italic to + differentiate from a numeric XX% confidence chip. */ +.ep-row--proved { + font-style: italic; +} + +/* ============================================================ + MOBILE / NARROW SCREENS + Stack the marks above and below the title at the same + breakpoint where the TOC sidebar collapses (900px), and + shrink the SVGs at the mobile breakpoint (680px) per + MARKS.md §3.3 (170px desktop → 130px mobile). + ============================================================ */ + +@media (max-width: 900px) { + .essay-frontmatter { + grid-template-columns: minmax(0, 1fr); + justify-items: center; + } + .frontmatter-mark-slot--left { order: 0; } + .frontmatter-title { order: 1; } + .frontmatter-mark-slot--right { order: 2; } + .frontmatter-title { text-align: center; } +} + +@media (max-width: 680px) { + .frontmatter-mark { + width: 130px; + height: 130px; + } +} + +/* ============================================================ + FOCUS MODE + The Display panel's focus mode (toggled via the + data-focus-mode attribute on , see settings.js) + suppresses marks alongside the TOC and other chrome. + ============================================================ */ +[data-focus-mode] .frontmatter-mark, +[data-focus-mode] .frontmatter-mark-slot { + display: none; +} +[data-focus-mode] .essay-frontmatter { + grid-template-columns: minmax(0, 1fr); +} diff --git a/static/css/memento-mori.css b/static/css/memento-mori.css new file mode 100644 index 0000000..e11c6c5 --- /dev/null +++ b/static/css/memento-mori.css @@ -0,0 +1,297 @@ +/* ── Memento Mori ─────────────────────────────────────────────────────────── */ + +/* ── Grid container ───────────────────────────────────────────────────────── */ + +#weeks-grid-wrapper { + margin: 2.5rem 0 1.5rem; +} + +.weeks-grid { + display: grid; + grid-template-columns: repeat(52, 1fr); + gap: 2px; + width: 100%; +} + +/* ── Individual week cells ────────────────────────────────────────────────── */ + +.week { + aspect-ratio: 1; + border: 1px solid var(--border); + cursor: default; +} + +.week--past { + background: var(--text); + border-color: var(--text); +} + +.week--current { + background: var(--text-muted); + border-color: var(--text); + box-shadow: 0 0 0 1.5px var(--text); + z-index: 1; + position: relative; +} + +.week--future { + background: transparent; +} + +/* First week of each decade: more prominent border for visual orientation. */ +.week--decade { + border-color: var(--text-muted); +} + +.week:hover { + opacity: 0.6; +} + +/* ── Legend ───────────────────────────────────────────────────────────────── */ + +.weeks-legend { + font-family: var(--font-ui); + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.6rem; + margin-bottom: 0; +} + +/* ── Tooltip ──────────────────────────────────────────────────────────────── */ + +.weeks-tooltip { + position: fixed; + z-index: 500; + pointer-events: none; + background: var(--bg); + border: 1px solid var(--border); + padding: 0.45rem 0.65rem; + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 190px; + max-width: 240px; +} + +.weeks-tooltip[hidden] { + display: none; +} + +.tt-age { + font-family: var(--font-ui); + font-size: 0.88rem; + font-weight: 600; + color: var(--text); +} + +.tt-week { + font-family: var(--font-ui); + font-size: 0.78rem; + color: var(--text-muted); +} + +.tt-date { + font-family: var(--font-ui); + font-size: 0.78rem; + color: var(--text-muted); +} + +.tt-meta { + font-family: var(--font-ui); + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 0.15rem; + padding-top: 0.2rem; + border-top: 1px solid var(--border); +} + +/* ── Week popup ───────────────────────────────────────────────────────────── */ + +.week-popup-backdrop { + position: fixed; + inset: 0; + z-index: 600; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; +} + +.week-popup-backdrop[hidden] { + display: none; +} + +.week-popup-card { + background: var(--bg); + border: 1px solid var(--border); + padding: 1.25rem 1.5rem 1.5rem; + width: min(640px, calc(100vw - 2rem)); +} + +.week-popup-header { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + align-items: start; + margin-bottom: 1.1rem; +} + +.week-popup-title { + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 600; + color: var(--text); + grid-column: 1; + grid-row: 1; +} + +.week-popup-sub { + font-family: var(--font-ui); + font-size: 0.75rem; + color: var(--text-muted); + grid-column: 1; + grid-row: 2; + margin-top: 0.15rem; +} + +.week-popup-close { + grid-column: 2; + grid-row: 1 / 3; + font-family: var(--font-ui); + font-size: 1.2rem; + line-height: 1; + padding: 0.1rem 0.4rem; + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + cursor: pointer; + align-self: start; +} + +.week-popup-close:hover { + color: var(--text); + border-color: var(--border); +} + +/* ── Days row ─────────────────────────────────────────────────────────────── */ + +.week-popup-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.75rem; +} + +.wday { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; +} + +.wday-dot { + width: 0.75rem; + height: 0.75rem; + border: 1px solid var(--border); +} + +.wday--past .wday-dot { + background: var(--text); + border-color: var(--text); +} + +.wday--today .wday-dot { + background: var(--text-muted); + border-color: var(--text); + box-shadow: 0 0 0 1.5px var(--text); +} + +.wday--future .wday-dot { + background: transparent; +} + +.wday-name { + font-family: var(--font-ui); + font-size: 0.7rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.wday--today .wday-name { + color: var(--text); +} + +.wday-date { + font-family: var(--font-ui); + font-size: 0.68rem; + color: var(--text-muted); + text-align: center; +} + +.wday-daynum { + font-family: var(--font-ui); + font-size: 0.62rem; + color: var(--text-faint); + text-align: center; + white-space: nowrap; + opacity: 0; + transition: opacity 0.15s; +} + +.wday:hover .wday-daynum { + opacity: 1; +} + +/* ── Countdown ────────────────────────────────────────────────────────────── */ + +#countdown-wrapper { + margin: 3rem 0 2rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.countdown-label { + font-family: var(--font-ui); + font-size: 0.78rem; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +.countdown-value { + font-family: var(--font-mono); + font-size: clamp(1.6rem, 5vw, 2.8rem); + font-weight: 400; + color: var(--text); + letter-spacing: -0.02em; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.countdown-switcher { + display: flex; + gap: 0.5rem; + margin-top: 0.35rem; + flex-wrap: wrap; +} + +.countdown-btn { + font-family: var(--font-ui); + font-size: 0.75rem; + padding: 0.2rem 0.65rem; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.countdown-btn:hover { + color: var(--text); + border-color: var(--text-muted); +} + +.countdown-btn.is-active { + color: var(--text); + border-color: var(--text); +} diff --git a/static/css/now.css b/static/css/now.css new file mode 100644 index 0000000..7c89d7d --- /dev/null +++ b/static/css/now.css @@ -0,0 +1,200 @@ +/* now.css — /current.html. + * + * The Now page is a research-first status surface curated like the + * Library, with explicit per-item temporality. It composes on top of + * item-card.css (gated separately in head.html via $if(now)$) and + * library.css's section primitives are deliberately not pulled in — + * Now uses its own header treatment without dingbats or shelf + * dividers, since sections here are project-states rather than + * content shelves. + */ + +/* ============================================================ + PAGE-LEVEL "LAST UPDATED" MASTHEAD + Title leads, date follows in display-weight Spectral italic. + Descending visual hierarchy — BIG sans title → medium serif + italic date → tiny italic relative. The date carries enough + typographic weight to be the page's thesis without competing + with the title for the "what page is this" anchor. + ============================================================ */ + +.now-header { + margin-bottom: 3rem; +} + +/* The page title remains the primary anchor — sized & weighted + by .page-title in components.css. Generous bottom margin gives + the masthead-date its own breathing room below. */ +.now-header .page-title { + margin-top: 0; + margin-bottom: 1.1rem; +} + +.now-stamp { + margin: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; + line-height: 1.15; +} + +.now-stamp-label { + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant: all-small-caps; + letter-spacing: 0.12em; + color: var(--text-faint); +} + +/* The masthead datum. Spectral italic at ~1.85rem so it carries + presence as the page's thesis without competing with the + 2.6rem sans title above it. Old-style numerals keep the + literary register; tabular-nums stay aligned if the day + width changes. */ +.now-stamp-date { + font-family: var(--font-serif); + font-size: 1.85rem; + font-style: italic; + font-weight: 400; + color: var(--text); + line-height: 1.1; + margin-top: 0.05rem; + font-feature-settings: "onum" 1, "tnum" 1; +} + +/* Small italic footer line under the masthead — qualifies + the absolute date with a human-readable "how recent." */ +.now-stamp-relative { + font-family: var(--font-serif); + font-size: 0.92rem; + font-style: italic; + color: var(--text-faint); + margin-top: 0.3rem; +} + +/* ============================================================ + INTRO PROSE + Optional body content from current.md, rendered between the + stamp and the first section. Sits in normal page measure; no + drop cap, no special treatment. + ============================================================ */ + +.now-intro { + margin: 0 0 2.5rem; + font-family: var(--font-serif); + font-size: 1rem; + color: var(--text-muted); + line-height: 1.6; +} + +.now-intro p:last-child { + margin-bottom: 0; +} + +/* ============================================================ + SECTION HEADINGS + Spectral small-caps in a quieter accent than library shelves. + No ornaments — sections are project-states, not content shelves, + and shouldn't carry the same identity weight. + ============================================================ */ + +.now-section { + margin-bottom: 2.75rem; +} + +.now-section-heading { + font-family: var(--font-serif); + font-size: 1.15rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.09em; + color: var(--text-muted); + text-transform: none; + font-weight: 400; + margin: 0 0 0.85rem 0; +} + +/* The recently-shipped section uses a slightly fainter heading + + a thin top divider to separate it visually from active work. */ +.now-section--shipped { + margin-top: 3.5rem; + padding-top: 2.25rem; + border-top: 1px solid var(--border); +} + +.now-section--shipped .now-section-heading { + color: var(--text-faint); + font-style: italic; + letter-spacing: 0.11em; +} + +/* ============================================================ + STATUS CHIP + Replaces the kind badge slot in item-card. Same min-width as + .item-card-kind so titles align across active and shipped + sections. Tabular sans-caps, very low-contrast frame. + ============================================================ */ + +.now-card .now-kind { + /* Override item-card-kind text-only treatment with chip layout */ + display: flex; + align-items: flex-start; + margin-top: 0.25em; +} + +.now-status { + display: inline-block; + font-family: var(--font-sans); + font-size: 0.66rem; + font-variant: all-small-caps; + letter-spacing: 0.08em; + line-height: 1; + padding: 0.32em 0.55em 0.28em; + border: 1px solid var(--border); + border-radius: 2px; + color: var(--text-muted); + background: transparent; + white-space: nowrap; +} + +/* Per-status accents — each is intentionally restrained. Active + states (in-review, drafting, building) get slightly stronger + ink; passive states (paused, shipped) recede. "accepted" is the + one affirmative state — full ink and a solid, confident border. */ +.now-status--accepted { color: var(--text); border-color: var(--text); font-weight: 600; } +.now-status--in-review { color: var(--text); border-color: var(--text-muted); } +.now-status--revising { color: var(--text); border-color: var(--text-muted); } +.now-status--drafting { color: var(--text); } +.now-status--building { color: var(--text); } +.now-status--early-stage { color: var(--text-muted); border-style: dashed; } +.now-status--paused { color: var(--text-faint); border-style: dashed; opacity: 0.75; } +.now-status--shipped { color: var(--text-faint); border-color: var(--text-faint); } + +.now-card--shipped { + opacity: 0.92; +} + +/* ============================================================ + MOBILE + Mirror item-card.css's mobile rules for header stacking, + then shrink the chip column so titles get real room. + ============================================================ */ + +@media (max-width: 540px) { + .now-card .now-kind { + margin-top: 0; + } + + .now-status { + font-size: 0.62rem; + padding: 0.28em 0.45em 0.24em; + } + + .now-stamp-date { + font-size: 1.55rem; + } + + .now-stamp-relative { + font-size: 0.88rem; + } +} diff --git a/static/css/photography.css b/static/css/photography.css new file mode 100644 index 0000000..394a2a2 --- /dev/null +++ b/static/css/photography.css @@ -0,0 +1,1066 @@ +/* Photography section — Phase 1 minimal styles. + * + * Phase 2 will introduce the masonry / grid / chronological mode toggle, + * darkroom lightbox treatment, and richer card hover states. For now the + * goal is structural correctness and clean defaults so a single photo + * page reads end-to-end. + * + * Style scope: applied via $if(photography)$ gate in templates/partials/head.html, + * so these rules only load on photography pages. + */ + +/* --------------------------------------------------------------------------- + * Single-photo page + * --------------------------------------------------------------------------- */ + +.photo-entry { + max-width: 60rem; + margin: 0 auto; +} + +/* Single-photo entries keep their own centred header; series pages use the + * essay frontmatter grid instead (see .photo-series-frontmatter below). */ +.photo-header { + margin-bottom: 2.25rem; + text-align: center; +} + +.photo-header .page-title { + margin-bottom: 0.9rem; +} + +.photo-header .meta-row { + justify-content: center; +} + +.photo-header .photo-captured { + margin-top: 0.35rem; +} + +/* --------------------------------------------------------------------------- + * Series frontmatter — the essay header, with photographs in it. + * + * A series landing has an essay's shape, so it uses an essay's grid: + * [ mark ] [ title block ] [ mark ]. The left slot, which on an essay holds + * the monogram, holds the lead frame; the right, which holds the epistemic + * figure, holds the series' statistics. Both are marks in the same sense — + * small, fixed-width, and about the thing rather than part of it. + * --------------------------------------------------------------------------- */ + +.photo-series-frontmatter { + align-items: start; +} + +.photo-series-lead { + margin: 0; +} + +.photo-series-lead img { + width: 100%; + height: auto; + display: block; + background: var(--bg-muted); + cursor: zoom-in; + /* A quiet mount, so a photograph in the mark column reads as a plate + rather than as a picture that failed to be full width. */ + border: 1px solid var(--border); + padding: 0.3rem; +} + +/* Statistics, in the register of /build/ and /stats/ — this site already + * treats counting things as worth showing. Tabular figures so the values + * form a column, small-caps labels so they stay subordinate to them. */ +.photo-series-stats { + margin: 0; + font-family: var(--font-sans); + font-size: 0.7rem; + line-height: 1.5; + font-variant-numeric: tabular-nums; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.photo-series-stats .stat-row { + display: grid; + grid-template-columns: 3.6rem minmax(0, 1fr); + gap: 0.4rem; + align-items: baseline; +} + +.photo-series-stats dt { + font-variant: all-small-caps; + letter-spacing: 0.08em; + color: var(--text-faint); +} + +.photo-series-stats dd { + margin: 0; + color: var(--text-muted); + overflow-wrap: anywhere; +} + +/* Below the grid's breakpoint the marks stack under the title, where a + * fixed narrow column makes no sense. */ +@media (max-width: 900px) { + .photo-series-frontmatter .frontmatter-mark-slot--left { + max-width: 22rem; + margin: 0 auto; + } + + .photo-series-stats { + align-items: center; + } + + .photo-series-stats .stat-row { + grid-template-columns: 3.6rem auto; + justify-content: center; + } +} + +.photo-figure { + margin: 1rem 0 1.25rem; + text-align: center; +} + +.photo-figure img { + max-width: 100%; + height: auto; + cursor: zoom-in; +} + +.photo-caption { + margin-top: 0.5rem; + font-style: italic; + color: var(--text-muted); + font-size: 0.95em; +} + +.photo-captured { + color: var(--text-muted); + font-size: 0.95em; +} + +.photo-location { + font-style: italic; +} + +/* Color palette strip (Phase 1 stub; populated by Phase 3 auto-extraction + * once tools/extract-palette.py lands). Renders only when frontmatter has + * a non-empty palette: list. */ +.photo-palette { + display: flex; + gap: 2px; + margin: 1rem 0 1.5rem; + height: 0.75rem; +} + +.photo-swatch { + flex: 1; + border-radius: 1px; +} + +/* Per-photo metadata block — camera, lens, exposure, etc. Two-column + * description list, definition-list semantics for accessibility. */ +.photo-meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 1rem; + margin: 1.25rem 0; + padding: 0.75rem 1rem; + border-left: 2px solid var(--border-muted); + background: var(--bg-muted); + font-size: 0.9em; +} + +.photo-meta dt { + font-weight: 600; + color: var(--text-muted); + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.photo-meta dd { + margin: 0; +} + +.photo-license { + color: inherit; +} + +.photo-external-links { + margin: 0.75rem 0 1.25rem; + font-size: 0.9em; + color: var(--text-muted); +} + +.photo-external-links .meta-label { + margin-right: 0.5rem; + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.photo-external-link { + color: var(--text); +} + +/* --------------------------------------------------------------------------- + * Section landing — /photography/ + * --------------------------------------------------------------------------- */ + +.photography-intro { + max-width: 36rem; + margin: 0 auto 2rem; + color: var(--text-muted); +} + +.photography-empty { + text-align: center; + color: var(--text-faint); + font-style: italic; + margin: 4rem auto; +} + +/* --------------------------------------------------------------------------- + * Mode toggle UI + * --------------------------------------------------------------------------- */ + +.photography-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +/* Mode toggle. + * + * These are anchors, so they were inheriting body-link underlines and reading + * as a row of links inside a box; the active one inverted to a solid slab of + * ink, which shouted louder than anything else on a page of photographs. The + * register here follows .list-count-btn in item-card.css — the site's + * existing quiet control — in small caps, with the active state carried by + * ink and border rather than by fill. */ +.photography-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 0 0 1.75rem; +} + +.photography-mode-toggle { + display: inline-flex; + gap: 0.3rem; + flex-wrap: wrap; + justify-content: center; +} + +.photography-mode-toggle .mode-btn { + padding: 0.3rem 0.75rem; + background: none; + border: 1px solid var(--border); + border-radius: 2px; + color: var(--text-faint); + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.7rem; + font-variant: all-small-caps; + letter-spacing: 0.09em; + line-height: 1.6; + text-decoration: none; + transition: + color var(--transition-fast), + border-color var(--transition-fast); +} + +.photography-mode-toggle .mode-btn:hover { + color: var(--text); + border-color: var(--text-muted); + text-decoration: none; +} + +.photography-mode-toggle .mode-btn.is-active { + color: var(--text); + border-color: var(--text-muted); + font-weight: 600; +} + +.photography-mode-toggle .mode-btn:focus-visible { + outline: 2px solid var(--text-muted); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .photography-mode-toggle .mode-btn { transition: none; } +} + +/* --------------------------------------------------------------------------- + * Grid container — base + per-mode rules. + * + * The same .photography-grid markup feeds three layout strategies; the + * data-photography-mode attribute (set by photography-modes.js) keys all + * per-mode rules. JS also sets inline grid-row spans on each card in + * masonry mode; clearing the attribute clears the inline style. + * --------------------------------------------------------------------------- */ + +.photography-grid { + list-style: none; + padding: 0; + margin: 0; + display: grid; +} + +/* Masonry — variable-height cells respecting native aspect ratios. + * grid-auto-rows + JS-applied grid-row spans synthesize true masonry. */ +.photography-grid[data-photography-mode="masonry"] { + /* 16rem, not 22rem. The grid lives inside the 800px body measure, where + * 22rem tracks left room for only two columns — near enough to + * chronological's single wide column that, with a corpus shot entirely in + * one orientation, the two modes were indistinguishable. At 16rem three + * columns fit, and the three modes read as three different things: + * many small squares, several native-proportion frames, one large column. */ + grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); + grid-auto-rows: 8px; /* must match ROW_UNIT in photography-modes.js */ + gap: 8px; /* must match ROW_GAP in photography-modes.js */ + align-items: stretch; +} + +.photography-grid[data-photography-mode="masonry"] .photo-card-img { + width: 100%; + height: auto; + display: block; +} + +/* Uniform grid — a dense contact sheet. + * + * Masonry and grid were nearly indistinguishable in practice: both drew + * captioned cards at similar widths, and with a corpus shot mostly in one + * orientation masonry's variable heights never got to vary. The two modes + * now answer different questions. Masonry is for reading the collection — + * native proportions, captions always legible. Grid is for scanning it — + * tighter tiles, square crops, and the caption withheld until you single a + * frame out. Nothing on screen competes with the pictures. + */ +.photography-grid[data-photography-mode="grid"] { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr)); + gap: 0.4rem; +} + +.photography-grid[data-photography-mode="grid"] .photo-card-link { + position: relative; + display: block; +} + +.photography-grid[data-photography-mode="grid"] .photo-card-img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--bg-muted); +} + +/* The caption rides over the frame rather than below it, so tiles stay + * flush and the grid reads as one field of images. + * + * Hidden with opacity and transform, never display/visibility: the title is + * the accessible name of the surrounding link, and removing it from the box + * tree would take it out of the accessibility tree with it. This way it is + * always announced and merely not painted. */ +.photography-grid[data-photography-mode="grid"] .photo-card-meta { + position: absolute; + inset: auto 0 0 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.05rem; + margin: 0; + padding: 1.75rem 0.6rem 0.5rem; + font-size: 0.78em; + color: #f2f2f2; + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.78) 0%, + rgba(0, 0, 0, 0.55) 45%, + rgba(0, 0, 0, 0) 100% + ); + opacity: 0; + transform: translateY(0.35rem); + transition: opacity 140ms ease, transform 140ms ease; + pointer-events: none; +} + +.photography-grid[data-photography-mode="grid"] .photo-card-link:hover .photo-card-meta, +.photography-grid[data-photography-mode="grid"] .photo-card-link:focus-visible .photo-card-meta, +.photography-grid[data-photography-mode="grid"] .photo-card:focus-within .photo-card-meta { + opacity: 1; + transform: translateY(0); +} + +.photography-grid[data-photography-mode="grid"] .photo-card-title { + font-weight: 500; + line-height: 1.25; + /* Two lines maximum: a long title must not grow a scrim tall enough to + * cover the frame it is describing. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.photography-grid[data-photography-mode="grid"] .photo-card-date { + color: rgba(255, 255, 255, 0.72); + font-size: 0.92em; +} + +/* A hover-only affordance is no affordance on a touchscreen. Where hover + * does not exist, the caption is simply always shown. */ +@media (hover: none) { + .photography-grid[data-photography-mode="grid"] .photo-card-meta { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .photography-grid[data-photography-mode="grid"] .photo-card-meta { + transition: none; + } +} + +/* Chronological — single column, large, contemplative. */ +.photography-grid[data-photography-mode="chronological"] { + grid-template-columns: minmax(0, 48rem); + justify-content: center; + gap: 3rem; +} + +.photography-grid[data-photography-mode="chronological"] .photo-card-img { + width: 100%; + height: auto; + display: block; +} + +.photography-grid[data-photography-mode="chronological"] .photo-card-meta { + margin-top: 0.75rem; + font-size: 1em; +} + +/* Series lead image. + * + * A series landing sets `photo:` to one of its own children's filenames — + * the frame that should introduce the set. No file is duplicated: photo-url + * resolves against the containing directory, and a landing lives in the same + * directory as its children, so it addresses the child's image directly. + * Deliberately not lazy-loaded; it is the first thing on the page. */ +.photo-series-lead img { + width: 100%; + height: auto; + display: block; + background: var(--bg-muted); + cursor: zoom-in; +} + +/* --------------------------------------------------------------------------- + * Card chrome — shared across all three modes + * --------------------------------------------------------------------------- */ + +.photo-card { + margin: 0; +} + +.photo-card-link { + display: block; + text-decoration: none; + color: inherit; + overflow: hidden; +} + +.photo-card-img { + background: var(--bg-muted); +} + +.photo-card-meta { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-top: 0.4rem; + font-size: 0.9em; +} + +.photo-card-title { + font-weight: 500; +} + +.photo-card-date { + color: var(--text-muted); + font-variant-numeric: oldstyle-nums; + flex-shrink: 0; +} + +/* --------------------------------------------------------------------------- + * Darkroom lightbox — photography pages only + * + * Augments the existing lightbox (static/css/images.css) when the JS + * detects body[data-page-type="photography"] and adds the .darkroom + * class to the overlay. Non-photography lightbox styling is unaffected. + * --------------------------------------------------------------------------- */ + +.lightbox-overlay.darkroom { + background: #000; +} + +.lightbox-overlay.darkroom .lightbox-vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient( + ellipse at center, + rgba(0, 0, 0, 0) 35%, + rgba(0, 0, 0, 0.55) 100% + ); + transition: opacity 200ms ease; +} + +.lightbox-overlay:not(.darkroom) .lightbox-vignette, +.lightbox-overlay:not(.darkroom) .lightbox-info-toggle, +.lightbox-overlay:not(.darkroom) .lightbox-info-panel { + display: none; +} + +.lightbox-overlay.darkroom .lightbox-caption { + color: rgba(255, 255, 255, 0.78); + font-style: italic; + font-size: 0.95em; + max-width: 48rem; + margin: 1rem auto 0; + text-align: center; +} + +.lightbox-overlay.darkroom .lightbox-close { + color: rgba(255, 255, 255, 0.8); +} + +/* "i" toggle button — top-right corner alongside the close button. */ +.lightbox-info-toggle { + position: absolute; + top: 0.75rem; + right: 3.5rem; + z-index: 2; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 50%; + color: rgba(255, 255, 255, 0.8); + font-size: 1rem; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.lightbox-info-toggle:hover, +.lightbox-info-toggle[aria-pressed="true"] { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.7); +} + +/* Info panel — slides in from below; muted serif for the EXIF strip. */ +.lightbox-info-panel { + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, 100%); + max-width: 48rem; + width: calc(100% - 2rem); + padding: 1rem 1.25rem; + background: rgba(0, 0, 0, 0.85); + color: rgba(255, 255, 255, 0.85); + border-top: 1px solid rgba(255, 255, 255, 0.12); + transition: transform 220ms ease; + pointer-events: none; +} + +.lightbox-overlay.is-info-visible .lightbox-info-panel { + transform: translate(-50%, 0); + pointer-events: auto; +} + +.lightbox-info-panel dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 1.25rem; + margin: 0; + font-size: 0.9em; +} + +.lightbox-info-panel dt { + font-weight: 500; + color: rgba(255, 255, 255, 0.55); + text-transform: lowercase; + letter-spacing: 0.04em; +} + +.lightbox-info-panel dd { + margin: 0; + color: rgba(255, 255, 255, 0.92); +} + +/* --------------------------------------------------------------------------- + * Map page — /photography/map/ + * + * The viewport is sized in viewport-relative units so the map fills + * a useful portion of the screen without dominating it; the + * surrounding page chrome (header, attribution paragraph, footer) + * stays visible at typical desktop heights. + * + * Leaflet's own stylesheet handles tile / control / popup styling; + * these rules cover only the page-level shell, the tooltip content + * we render via tooltipHtml(), and the no-pin / error fallback states. + * --------------------------------------------------------------------------- */ + +.photography-map { + height: 70vh; + min-height: 32rem; + margin: 1.5rem 0 1rem; + border: 1px solid var(--border); + background: var(--bg-muted); +} + +.photography-map--empty, +.photography-map--error { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 2rem; +} + +.photography-map-empty, +.photography-map-error, +.photography-map-fallback { + max-width: 32rem; + margin: 0 auto; + color: var(--text-muted); + font-style: italic; +} + +.photography-map-note { + margin: 0.5rem 0 2rem; + color: var(--text-faint); + font-size: 0.85em; + max-width: 48rem; +} + +.photography-map-note code { + background: var(--bg-muted); + padding: 0 0.3em; + border-radius: 2px; + font-size: 0.9em; +} + +/* Tooltip content — rendered by photography-map.js inside Leaflet's + * default tooltip wrapper. The wrapper class .photography-map-tooltip-wrap + * lets us tighten Leaflet's default padding without bleeding into the + * other Leaflet popups. */ + +.leaflet-tooltip.photography-map-tooltip-wrap { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 2px; + padding: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + font-family: inherit; +} + +/* Leaflet draws tooltip arrows via ::before; recolor to match. */ +.leaflet-tooltip.photography-map-tooltip-wrap::before { + border-top-color: var(--border); +} + +.photography-map-tooltip { + width: 14rem; + max-width: 16rem; +} + +.photography-map-tooltip-img { + display: block; + width: 100%; + height: 8rem; + object-fit: cover; + margin: 0; + border-bottom: 1px solid var(--border); +} + +.photography-map-tooltip-title { + padding: 0.4rem 0.6rem 0.1rem; + font-weight: 500; + line-height: 1.2; +} + +.photography-map-tooltip-date { + padding: 0 0.6rem 0.4rem; + color: var(--text-muted); + font-size: 0.85em; + font-variant-numeric: oldstyle-nums; +} + +/* --------------------------------------------------------------------------- + * By-year index — /photography/by-year/ + * + * A simple narrow column of years, each link a small card with the + * year (large oldstyle figures) and a count (muted small caps). + * --------------------------------------------------------------------------- */ + +.photography-header--narrow { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; +} + +.photography-by-year-back { + margin: 0 0 1rem; + color: var(--text-muted); + font-size: 0.9em; +} + +.photography-by-year-list { + list-style: none; + padding: 0; + margin: 1rem 0 2rem; + max-width: 24rem; +} + +.photography-by-year-item { + margin: 0; +} + +.photography-by-year-link { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-muted); + text-decoration: none; + color: inherit; + transition: color 120ms ease; +} + +.photography-by-year-link:hover { + color: var(--text); +} + +.photography-by-year-year { + font-size: 1.4em; + font-variant-numeric: oldstyle-nums; +} + +.photography-by-year-count { + color: var(--text-muted); + font-size: 0.85em; + text-transform: lowercase; + letter-spacing: 0.04em; +} + +.photography-by-year-count::before { + content: ""; + display: inline-block; + width: 0; +} + +.photography-by-year-count::after { + content: " photographs"; +} + +/* --------------------------------------------------------------------------- + * Contact sheet — /photography/contact-sheet/ + * + * Frame-numbered grid in the visual register of an analog contact + * print: small uniform thumbnails, thin white border per frame, + * frame number in the corner via a CSS counter. The dark page + * backdrop is a soft "film-room" gray rather than full black so + * the white frames don't ring against the surrounding page chrome + * too hard. + * --------------------------------------------------------------------------- */ + +.photography-contact-sheet { + counter-reset: contact-frame-no; + list-style: none; + padding: 1.5rem; + margin: 1rem 0 2rem; + background: #1c1c1c; + border: 1px solid #2a2a2a; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr)); + gap: 1.25rem; +} + +.contact-frame { + counter-increment: contact-frame-no; + margin: 0; + background: #f5f3ee; + padding: 0.5rem 0.5rem 0.4rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); + transition: transform 120ms ease; +} + +.contact-frame:hover { + transform: scale(1.015); +} + +.contact-frame-link { + display: block; + color: #1c1c1c; + text-decoration: none; + position: relative; +} + +.contact-frame-img { + width: 100%; + aspect-ratio: 3 / 2; + object-fit: cover; + display: block; + background: #2a2a2a; +} + +.contact-frame-link::before { + /* Frame number — analog convention: oldstyle figures, mono, + * small, top-left of the print under the image. */ + content: counter(contact-frame-no, decimal-leading-zero); + position: absolute; + top: -1.4em; + left: 0; + font-family: var(--font-mono, monospace); + font-size: 0.75em; + color: rgba(245, 243, 238, 0.55); + letter-spacing: 0.05em; +} + +.contact-frame-label { + display: block; + margin-top: 0.35rem; + font-size: 0.75em; + color: #4a4a4a; + line-height: 1.2; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------------------------- + * Series landing — /photography// + * --------------------------------------------------------------------------- */ + +/* The lede. Takes .essay-summary's size and leading so a series reads like + * an essay's frontmatter, but stays centred in its column and drops the rule + * and label — a bordered box does not centre, and "Summary" is redundant + * above two lines of prose. text-wrap: balance keeps the ragged edges even + * instead of leaving one word alone on the last line. */ +.photo-series-abstract { + margin: 1.1rem auto 0; + font-family: var(--font-serif); + font-size: 0.95rem; + line-height: 1.65; + color: var(--text-muted); + max-width: 32rem; + text-align: center; + text-wrap: balance; +} + +.photo-series-prose { + max-width: 36rem; + margin: 1.5rem 0; + color: var(--text); +} + +/* --------------------------------------------------------------------------- + * Slideshow + * + * Shares the darkroom overlay's ground and vignette so the two full-screen + * views read as one idea. Chrome is deliberately faint and sits away from the + * picture: at this size the photograph is the only thing that should hold + * attention, and the controls are there to be found, not noticed. + * --------------------------------------------------------------------------- */ + +body.slideshow-open { + overflow: hidden; +} + +.slideshow-overlay { + /* Inherits position, layout, and — critically — the opacity/visibility + * hiding from .lightbox-overlay in images.css. Only the differences are + * declared here; redeclaring display would override the base class's + * hiding mechanism and pin the overlay open. */ + z-index: 1000; + gap: 1.25rem; + padding: 2.5rem 1.5rem 1.5rem; + background: #000; +} + +.slideshow-figure { + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.9rem; + max-width: min(100%, 1600px); + min-height: 0; +} + +.slideshow-img { + max-width: 100%; + /* Leave room for caption and control bar without them ever overlapping + the frame — the photograph is never cropped to fit chrome. */ + max-height: calc(100vh - 11rem); + width: auto; + height: auto; + object-fit: contain; + cursor: pointer; + display: block; +} + +.slideshow-caption { + text-align: center; + color: rgba(255, 255, 255, 0.8); + font-family: var(--font-serif); + display: flex; + flex-direction: column; + gap: 0.15rem; + max-width: 48rem; +} + +.slideshow-title { + font-size: 0.98rem; +} + +.slideshow-date { + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.5); + font-variant-numeric: oldstyle-nums; +} + +.slideshow-bar { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.slideshow-btn { + background: none; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 2px; + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; + line-height: 1; + padding: 0.4rem 0.7rem; + cursor: pointer; + transition: color 120ms ease, border-color 120ms ease; +} + +.slideshow-btn:hover { + color: #fff; + border-color: rgba(255, 255, 255, 0.55); +} + +.slideshow-btn:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.75); + outline-offset: 2px; +} + +.slideshow-close { + position: absolute; + top: 1rem; + right: 1.25rem; + font-size: 1.4rem; + padding: 0.15rem 0.6rem; +} + +.slideshow-counter { + margin: 0 0 0 0.6rem; + color: rgba(255, 255, 255, 0.45); + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + letter-spacing: 0.06em; +} + +@media (max-width: 540px) { + .slideshow-img { max-height: calc(100vh - 13rem); } + .slideshow-counter { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .slideshow-btn { transition: none; } +} + +/* --------------------------------------------------------------------------- + * Map pins + * + * A div icon rather than Leaflet's default bitmap: it carries the number of + * photographs at a place, sizes itself to that, and gives the map no image + * that has to resolve. The palette is the site's, so a pin reads as part of + * the page rather than as part of Leaflet. + * --------------------------------------------------------------------------- */ + +.photography-map-pin { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; +} + +.photography-map-pin span { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--text); + color: var(--bg); + font-family: var(--font-sans); + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + line-height: 1; + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.65); + transition: transform var(--transition-fast); +} + +.photography-map-pin:hover span { + transform: scale(1.12); +} + +/* Thumbnail strip inside a place tooltip — a glance at what is there, + capped at four so the tooltip stays a tooltip. */ +.photography-map-strip { + display: flex; + gap: 2px; + margin-bottom: 0.4rem; +} + +.photography-map-strip-img { + width: 46px; + height: 46px; + object-fit: cover; + display: block; + background: var(--bg-muted); +} + +.photography-map-tooltip--group .photography-map-tooltip-meta { + font-family: var(--font-sans); + font-size: 0.68rem; + color: var(--text-faint); + font-variant-numeric: tabular-nums; +} + +@media (prefers-reduced-motion: reduce) { + .photography-map-pin span { transition: none; } +} diff --git a/static/css/popups.css b/static/css/popups.css new file mode 100644 index 0000000..5d1ce7d --- /dev/null +++ b/static/css/popups.css @@ -0,0 +1,473 @@ +/* popups.css — Hover preview popup styles. */ + +/* ============================================================ + POPUP CONTAINER + Absolutely positioned, single shared element. + ============================================================ */ + +.link-popup { + position: absolute; + z-index: 500; + max-width: 420px; + min-width: 200px; + /* Never taller than the viewport: positionPopup clamps the popup + into the visible region, which only works if the box itself can + fit there. The 10px matches positionPopup's GAP on each side; + overflow scrolls the rare popup that still exceeds the cap. */ + max-height: calc(100vh - 20px); + overflow-y: auto; + padding: 0.7rem 0.9rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 3px; + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.09); + font-family: var(--font-sans); + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.55; + pointer-events: auto; + + opacity: 0; + visibility: hidden; + transition: opacity 0.14s ease, visibility 0.14s ease; +} + +/* Rich layout for reference popups (provider-rendered: arXiv, + Wikipedia, CrossRef, …) — wider, larger type, image banner. Internal + page previews and item-card popups (new/library pages) deliberately + keep the compact base layout. Toggled by popups.js per show. */ +.link-popup--rich { + max-width: 560px; + padding: 0.85rem 1.05rem; +} + +.link-popup--rich .popup-title { + font-size: 0.98rem; + line-height: 1.3; + margin-bottom: 0.35rem; +} + +.link-popup--rich .popup-authors, +.link-popup--rich .popup-meta { + font-size: 0.78rem; +} + +.link-popup--rich .popup-abstract, +.link-popup--rich .popup-extract { + font-size: 0.84rem; + line-height: 1.6; +} + +.link-popup.is-visible { + opacity: 1; + visibility: visible; +} + + +/* ============================================================ + SHARED POPUP CONTENT + ============================================================ */ + +/* Internal-page popups with a monogram render in two columns: + the monogram on the left, title + abstract + meta on the right. + Without a monogram, the body fills normally (default block flow). */ +.popup-internal.has-monogram { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + gap: 0.65rem; + align-items: start; +} + +/* Source label spans both columns when a monogram is present so it + reads as the popup's source attribution, not as a column entry. */ +.popup-internal.has-monogram > .popup-source { + grid-column: 1 / -1; +} + +.popup-monogram { + grid-column: 1; + line-height: 0; + color: var(--text); +} + +.popup-monogram svg { + display: block; + width: 100%; + height: auto; + color: inherit; +} + +.popup-internal-body { + grid-column: 2; + min-width: 0; +} + +.popup-title { + font-weight: 600; + color: var(--text); + font-size: 0.82rem; + margin-bottom: 0.3rem; + line-height: 1.35; +} + +/* Lead image (Wikipedia pageimages thumbnail, arXiv lead figure) — a + full-width banner between the source label and the title, sized for + the rich provider layout. Photos crop to fill (faces usually sit in + the upper portion, hence the 30% focal point); figures — plots, + architecture diagrams — must never be cropped, so .is-figure + letterboxes with contain on a muted backdrop instead. width/height + attrs from the provider reserve the aspect ratio, so the popup is + measured and positioned correctly before the image arrives. */ +.popup-image-banner { + display: block; + width: 100%; + height: auto; + max-height: 220px; + object-fit: cover; + object-position: center 30%; + border-radius: 5px; + border: 1px solid var(--border-muted); + margin: 0.4rem 0 0.55rem; +} + +.popup-image-banner.is-figure { + object-fit: contain; + background: color-mix(in srgb, var(--text-faint) 7%, transparent); +} + +.popup-abstract, +.popup-extract { + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.55; +} + +/* Source label ("Wikipedia", "arXiv") and tag line in internal popups */ +.popup-source, +.popup-tags { + display: flex; + align-items: center; + gap: 0.3em; + font-size: 0.65rem; + font-weight: 600; + font-variant-caps: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + margin-bottom: 0.25rem; +} + +/* Icon preceding the source label — same mask-image technique as inline link icons */ +.popup-source[data-popup-source]::before { + content: ''; + display: inline-block; + flex-shrink: 0; + width: 0.85em; + height: 0.85em; + background-color: currentColor; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + opacity: 0.7; +} + +.popup-source[data-popup-source="wikipedia"]::before { + mask-image: url('/images/link-icons/wikipedia.svg'); + -webkit-mask-image: url('/images/link-icons/wikipedia.svg'); +} +.popup-source[data-popup-source="arxiv"]::before { + mask-image: url('/images/link-icons/arxiv.svg'); + -webkit-mask-image: url('/images/link-icons/arxiv.svg'); +} +.popup-source[data-popup-source="doi"]::before { + mask-image: url('/images/link-icons/doi.svg'); + -webkit-mask-image: url('/images/link-icons/doi.svg'); +} +.popup-source[data-popup-source="github"]::before { + mask-image: url('/images/link-icons/github.svg'); + -webkit-mask-image: url('/images/link-icons/github.svg'); +} +.popup-source[data-popup-source="forgejo"]::before { + mask-image: url('/images/link-icons/forgejo.svg'); + -webkit-mask-image: url('/images/link-icons/forgejo.svg'); +} +.popup-source[data-popup-source="youtube"]::before { + mask-image: url('/images/link-icons/youtube.svg'); + -webkit-mask-image: url('/images/link-icons/youtube.svg'); +} +.popup-source[data-popup-source="internet-archive"]::before { + mask-image: url('/images/link-icons/internet-archive.svg'); + -webkit-mask-image: url('/images/link-icons/internet-archive.svg'); +} +.popup-source[data-popup-source="biorxiv"]::before, +.popup-source[data-popup-source="medrxiv"]::before { + mask-image: url('/images/link-icons/arxiv.svg'); + -webkit-mask-image: url('/images/link-icons/arxiv.svg'); +} +.popup-source[data-popup-source="openlibrary"]::before { + mask-image: url('/images/link-icons/worldcat.svg'); + -webkit-mask-image: url('/images/link-icons/worldcat.svg'); +} +.popup-source[data-popup-source="pubmed"]::before { + mask-image: url('/images/link-icons/orcid.svg'); + -webkit-mask-image: url('/images/link-icons/orcid.svg'); +} +.popup-source[data-popup-source="internal"]::before { + mask-image: url('/images/link-icons/internal.svg'); + -webkit-mask-image: url('/images/link-icons/internal.svg'); +} + +/* Author list (arXiv, DOI, GitHub, etc.) */ +.popup-authors { + font-size: 0.75rem; + color: var(--text-faint); + margin-bottom: 0.3rem; + font-style: italic; +} + +/* Journal / year / language / stars line */ +.popup-meta { + font-size: 0.72rem; + color: var(--text-faint); + margin-bottom: 0.3rem; + font-variant-numeric: tabular-nums; +} + + +/* ============================================================ + CITATION POPUP + Shows the .csl-entry from the bibliography in-page. + ============================================================ */ + +.popup-citation-entry { + display: block; +} + +.popup-citation-entry + .popup-citation-entry { + margin-top: 0.55rem; + padding-top: 0.55rem; + border-top: 1px solid var(--border); +} + +.popup-citation .csl-entry { + font-family: var(--font-sans); + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.6; + /* Override the hanging-indent style used in the bibliography list */ + padding-left: 0; + text-indent: 0; + margin-bottom: 0; +} + +.popup-citation .csl-entry a { + color: var(--text-faint); + word-break: break-all; +} + +.popup-citation .ref-num { + font-weight: 600; + color: var(--text-faint); + margin-right: 0.35em; + font-variant-numeric: tabular-nums; + text-decoration: none; +} + +.popup-citation .ref-num:hover { + color: var(--text); +} + +/* Epistemic preview popup — content cloned from #epistemic; ep-* styles inherited */ +.popup-epistemic { + min-width: 230px; +} + +/* Epistemic term definition popup — shown on hover for filter labels, + metadata strip items, and footer DT elements. */ +[data-ep-term] { + cursor: help; +} + +.popup-ep-term { + max-width: 320px; +} + +.popup-ep-term .popup-source a { + color: inherit; + text-decoration: none; +} +.popup-ep-term .popup-source a:hover { + text-decoration: underline; +} + +/* PDF thumbnail popup — first-page image generated by pdftoppm at build time */ +.link-popup:has(.popup-pdf) { + padding: 0; + overflow: hidden; +} + +.popup-pdf { + line-height: 0; /* collapse whitespace gap below */ +} + +.popup-pdf-thumb { + display: block; + width: 320px; + max-width: 100%; + height: auto; + max-height: 420px; + object-fit: contain; + object-position: top left; +} + +/* ============================================================ + SOURCE-FILE PREVIEW POPUP + Shown on hover for inline `path/to/file` references and for + Markdown links to the Forgejo source viewer (see + build/Filters/SourceRefs.hs). The body is fetched from + /source/ and Prism-highlighted client-side. + ============================================================ */ + +.link-popup:has(.popup-source-code) { + max-width: 560px; + padding: 0; + overflow: hidden; +} + +.popup-source-code { + display: block; +} + +/* Header strip naming the file. Doubles as a visual seam between the + chrome (border, padding) and the code body below. */ +.popup-source-path { + padding: 0.45rem 0.65rem 0.4rem; + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-faint); + word-break: break-all; + background: var(--bg); +} + +.popup-source-pre { + margin: 0; + padding: 0.55rem 0.65rem; + max-height: 360px; + overflow: auto; + background: var(--bg); + font-family: var(--font-mono); + font-size: 0.7rem; + line-height: 1.5; + color: var(--text); + white-space: pre; + border: none; + border-radius: 0; +} + +.popup-source-pre code { + background: transparent; + padding: 0; + border: none; + font-family: inherit; + font-size: inherit; + color: inherit; + white-space: pre; +} + +/* "N more lines · view full file →" — visually parallel to the path + header strip; together they bracket the code body. */ +.popup-source-truncated { + padding: 0.4rem 0.65rem; + border-top: 1px solid var(--border); + font-size: 0.65rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); + background: var(--bg); +} + +/* Inline source-ref link styling: keep the inline-code chrome of the + wrapped intact and resist the link-classifier's external/ + internal stamp (Filters.Links explicitly skips us). The hover hint + is a faint dotted underline — same convention as date-hover and + epistemic-term cues. */ +a.source-ref { + color: inherit; + text-decoration: none; +} + +a.source-ref:hover { + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: var(--border-muted); + text-underline-offset: 0.2em; +} + +a.source-ref code { + /* The underlying inline-code styling is inherited from typography.css; + this rule exists only to ensure the link wrapper does not fight it. */ + color: inherit; +} + +/* PGP signature popup */ +.popup-sig pre { + margin: 0; + font-family: var(--font-mono); + font-size: 0.68rem; + line-height: 1.45; + white-space: pre; + overflow-x: auto; + color: var(--text-muted); +} + +/* ============================================================ + DATE POPUP — compact, humanised span + "ago" framing, plus + optional cadence line for the frontmatter history link. + ============================================================ */ + +.link-popup:has(.popup-date) { + min-width: 0; + max-width: 260px; + padding: 0.5rem 0.7rem; +} + +.popup-date { + font-family: var(--font-sans); + font-size: 0.78rem; + line-height: 1.45; + color: var(--text-muted); +} + +.popup-date-primary { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.popup-date-cadence { + margin-top: 0.15rem; + font-size: 0.72rem; + font-variant-caps: all-small-caps; + letter-spacing: 0.05em; + color: var(--text-faint); +} + +/* Subtle cue that the date is interactive. Dotted underline is the standard + convention for explanatory hover; kept faint so it doesn't shout. */ +.date-hover { + cursor: help; + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: var(--border-muted); + text-underline-offset: 0.2em; +} + +/* Don't decorate container elements (li, dd, time) that already fit into + a list with their own layout — the text decoration looks noisy on them. */ +li.date-hover, +dd.date-hover { + text-decoration: none; + cursor: help; +} diff --git a/static/css/print.css b/static/css/print.css new file mode 100644 index 0000000..87f209f --- /dev/null +++ b/static/css/print.css @@ -0,0 +1,340 @@ +/* print.css — Clean paper output. + Loaded LAST in head.html via so its rules win + the cascade at print page widths (~595–820 CSS px on A4/Letter), + which otherwise trip every screen breakpoint below 900px (mobile + TOC bar, body bottom padding) and below 1499px (sidenote → footnote + fallback). + + Hides chrome, expands body full-width, renders in black on white. */ + +@media print { + + /* ---------------------------------------------------------------- + Force light on paper. The custom-property overrides drive the + rest of the cascade — use them consistently below instead of + reaching for hardcoded #fff/#000 again. + ---------------------------------------------------------------- */ + :root, + [data-theme="dark"], + [data-theme="cappuccino"] { + --bg: #ffffff; + --bg-nav: #ffffff; + --bg-offset: #ffffff; + --bg-subtle: #f9f9f9; + --text: #000000; + --text-muted: #333333; + --text-faint: #555555; + --border: #cccccc; + --border-muted: #aaaaaa; + --rule: #cccccc; + } + + /* Reading-mode body warm tints would otherwise repaint pages cream. */ + body.reading-mode { --bg: #ffffff; } + + /* ---------------------------------------------------------------- + Hide chrome. + + Site nav is `body > header` (templates/partials/nav.html); the + essay frontmatter is `.page-shell > header.essay-frontmatter` + (essay/blog-post/reading templates) and MUST stay visible — its +

      is the page title. + + The mark slots (monogram + epistemic figure) and frontmatter + divider are decorative; suppressing them lets the title block + collapse to a clean masthead. + ---------------------------------------------------------------- */ + body > header, + .page-shell > footer, + #toc, + #toc-mobile-bar, + #reading-progress, + .skip-link, + .settings-wrap, + .selection-popup, + .link-popup, + .ann-tooltip, + .ann-picker, + .sidenote-popup-overlay, + .toc-toggle, + .section-toggle, + .nav-portals, + .nav-portal-toggle, + .footer-ornament, + .content-divider, + .aftermatter-divider, + .frontmatter-mark-slot, + .metadata .meta-pagelinks, + .page-meta-footer #backlinks, + .page-meta-footer #similar-links, + .version-history-more { + display: none !important; + } + + /* The mobile TOC bar's screen rule also adds `body { padding-bottom: 2.5rem }` + to clear the fixed strip — undo it on paper. */ + body { + padding-bottom: 0 !important; + } + + /* ---------------------------------------------------------------- + Layout — single full-width column + ---------------------------------------------------------------- */ + body { + font-size: 11pt; + line-height: 1.55; + background: var(--bg); + color: var(--text); + margin: 0; + padding: 0; + } + + .page-shell { + display: block !important; + min-height: 0 !important; + } + + #content { + display: block !important; + width: 100% !important; + padding: 0 !important; + margin: 0 !important; + } + + #markdownBody, + .page-shell > #markdownBody, + body.reading-mode .page-shell > #markdownBody, + body.reading-mode.poetry > #markdownBody, + body.reading-mode.fiction > #markdownBody { + width: 100% !important; + max-width: 100% !important; + grid-column: unset !important; + margin: 0 !important; + padding: 0 !important; + } + + /* ---------------------------------------------------------------- + Essay frontmatter — collapse the 3-column viewport-spanning grid + to a single linear masthead. Mark slots already hidden above. + ---------------------------------------------------------------- */ + .essay-frontmatter { + display: block !important; + padding: 0 0 0.6em 0 !important; + margin: 0 0 1em 0 !important; + border-bottom: 1px solid var(--border); + } + + .frontmatter-title { + max-width: 100% !important; + width: 100% !important; + text-align: left !important; + justify-self: stretch !important; + } + + .frontmatter-title > .page-title { + font-size: 22pt; + margin: 0 0 0.2em 0; + line-height: 1.15; + } + + .essay-subtitle { + font-size: 12pt; + margin: 0 0 0.4em 0; + } + + .frontmatter-title .meta-description { + text-align: left !important; + } + + .frontmatter-title .meta-epistemic-strip { + justify-content: flex-start !important; + } + + .essay-tailmatter { + max-width: 100% !important; + width: 100% !important; + padding: 0 !important; + margin: 0 0 1em 0 !important; + } + + .essay-summary { + background: transparent !important; + border: 1px solid var(--border-muted); + padding: 0.6em 0.8em !important; + margin: 0.5em 0 1em 0 !important; + } + + /* ---------------------------------------------------------------- + Sidenotes — render inline as parenthetical asides. + + The build's Sidenotes filter replaces Pandoc's + `section.footnotes` entirely with `` + siblings of the ref, so there is no end-of-document footnote + section to fall back to. Sidenotes.css hides .sidenote at + narrow widths (which print triggers); print.css promotes them + back inline as small italic parentheticals so the surrounding + sentence flow stays intact — block-style footnotes mid-sentence + leave dangling clauses on the next line. + ---------------------------------------------------------------- */ + .sidenote { + display: inline !important; + position: static !important; + width: auto !important; + max-width: none !important; + margin: 0; + padding: 0; + border: none; + font-size: 0.85em; + font-style: italic; + color: var(--text-muted); + } + + .sidenote::before { content: " ["; font-style: normal; } + .sidenote::after { content: "]"; font-style: normal; } + + .sidenote-para { + display: inline !important; + margin: 0 !important; + } + .sidenote-para + .sidenote-para::before { + content: " / "; + font-style: normal; + color: var(--text-faint); + } + + .sidenote-num { + display: none !important; + } + + .sidenote-ref a { + color: var(--text); + text-decoration: none; + } + + /* ---------------------------------------------------------------- + User annotations — strip on-screen highlight backgrounds; keep + a faint underline so the marker still surfaces on paper. + ---------------------------------------------------------------- */ + mark.user-annotation { + background: transparent !important; + color: inherit !important; + padding: 0 !important; + text-decoration: underline; + text-decoration-color: var(--text-faint); + text-decoration-thickness: 0.5pt; + text-underline-offset: 0.18em; + } + + /* ---------------------------------------------------------------- + Figures — strip on-screen card chrome (bg-offset card, inner + image border, drop shadow). Plain image + caption reads more + naturally on paper. + ---------------------------------------------------------------- */ + #markdownBody figure { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + margin: 1em auto !important; + max-width: 100% !important; + } + + #markdownBody figure img { + border: none !important; + } + + #markdownBody figcaption { + font-size: 9pt; + margin-top: 0.3em; + } + + /* ---------------------------------------------------------------- + Drop cap — quieten the magazine flourish; at 3.8em it prints as + a giant black blob that wastes the first half page. + ---------------------------------------------------------------- */ + #markdownBody > p:first-of-type::first-letter, + #markdownBody .dropcap p::first-letter, + body.reading-mode.fiction > #markdownBody h2 + p::first-letter { + font-size: 2.4em; + line-height: 0.9; + } + + /* ---------------------------------------------------------------- + Page setup + ---------------------------------------------------------------- */ + @page { + margin: 2cm 2cm; + } + + /* ---------------------------------------------------------------- + Typography adjustments + ---------------------------------------------------------------- */ + h1, h2, h3, h4 { + page-break-after: avoid; + break-after: avoid; + } + + p, li, blockquote { + orphans: 3; + widows: 3; + } + + pre, figure, .exhibit, table { + page-break-inside: avoid; + break-inside: avoid; + } + + /* Decorative inline link-icon glyphs (wikipedia W, arxiv X, github, + etc.) — they render as an inline-block 0.75em × 0.75em masked + SVG, fine on screen but on paper they print as an opaque black + speckle next to every external link. Suppress them entirely. + Critical: this also unsets the fixed width/height/mask so any + `::after` content from another rule renders as plain inline text. */ + a[data-link-icon-type="svg"]::after, + a[data-link-icon]::after { + content: none !important; + display: none !important; + } + + /* External links keep their default underline — readers can follow + the live URL via the PDF's preserved link metadata. We don't + inline the href as printed text because (a) it duplicates the + link, (b) `word-break: break-all` URLs interact badly with the + link-icon ::after we just suppressed, and (c) it makes the page + visually noisy. */ + + /* ---------------------------------------------------------------- + Code blocks — strip background, border only + ---------------------------------------------------------------- */ + pre, code { + background: transparent !important; + border: 1px solid var(--border-muted) !important; + box-shadow: none !important; + } + + /* ---------------------------------------------------------------- + Page meta footer — keep epistemic + version history compact; + collapse the auto-fit grid so columns don't get pushed onto a + new page when the body ended near a break. + ---------------------------------------------------------------- */ + .page-meta-footer { + margin-top: 1.5em; + padding: 1em 0 0 0 !important; + border-top: 1px solid var(--border); + gap: 0.8em !important; + } + + .meta-footer-full, + .meta-footer-grid { + width: 100% !important; + max-width: 100% !important; + } + + .meta-footer-grid { + display: block !important; + } + + .meta-footer-section { + margin-bottom: 0.8em; + } +} diff --git a/static/css/reading.css b/static/css/reading.css new file mode 100644 index 0000000..746be61 --- /dev/null +++ b/static/css/reading.css @@ -0,0 +1,128 @@ +/* reading.css — Codex layout for fiction and poetry. + Loaded only on pages where the "reading" context field is set. */ + +/* ============================================================ + SCROLL PROGRESS BAR + A 2px warm-gray line fixed at the very top of the viewport. + z-index 200 sits above the sticky nav (z-index 100). + ============================================================ */ + +#reading-progress { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0%; + background-color: var(--text-faint); + z-index: 200; + pointer-events: none; + transition: width 0.08s linear; +} + +/* ============================================================ + READING MODE — BASE + Slightly warmer background; narrower, book-like measure. + The class is applied to by default.html. + ============================================================ */ + +body.reading-mode { + --bg: #fdf9f1; + background-color: var(--bg); +} + +[data-theme="dark"] body.reading-mode { + --bg: #1c1917; + background-color: var(--bg); +} + +[data-theme="cappuccino"] body.reading-mode { + --bg: #634432; + background-color: var(--bg); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) body.reading-mode { + --bg: #1c1917; + background-color: var(--bg); + } +} + +/* Reading body: narrower than the essay default (800px → ~62ch). + reading.html emits #markdownBody as a direct child of .page-shell + (no #content grid); centering is handled by the matching + .page-shell > #markdownBody rule in layout.css. */ +body.reading-mode .page-shell > #markdownBody { + max-width: 62ch; +} + +/* ============================================================ + POETRY + ============================================================ */ + +/* Slightly narrower measure for verse */ +body.reading-mode.poetry > #markdownBody { + max-width: 52ch; +} + +/* Generous line height and stanza spacing */ +body.reading-mode.poetry > #markdownBody > p { + line-height: 1.85; + margin-bottom: 1.6rem; + /* Ragged right — no hyphenation, no justification */ + hyphens: none; + text-align: left; +} + +/* Suppress the prose dropcap — poetry opens without a floated initial */ +body.reading-mode.poetry > #markdownBody > p:first-of-type::first-letter { + float: none; + font-size: inherit; + line-height: inherit; + padding: 0; + color: inherit; +} + +/* Suppress the smallcaps lead-in on the first line */ +body.reading-mode.poetry > #markdownBody > p:first-of-type::first-line { + font-variant-caps: normal; + letter-spacing: normal; +} + +/* ============================================================ + FICTION + ============================================================ */ + +/* Chapter headings: Fira Sans smallcaps, centered, spaced */ +body.reading-mode.fiction > #markdownBody h2 { + text-align: center; + font-family: var(--font-sans); + font-size: 0.85rem; + font-weight: 600; + font-variant: small-caps; + letter-spacing: 0.12em; + text-transform: lowercase; + color: var(--text-muted); + margin: 3.5rem 0 2.5rem; +} + +/* Drop cap at the start of each chapter (h2 → next paragraph) */ +body.reading-mode.fiction > #markdownBody h2 + p::first-letter { + float: left; + font-size: 3.8em; + line-height: 0.8; + padding-top: 0.05em; + padding-right: 0.1em; + color: var(--text); + font-variant-caps: normal; +} + +/* Smallcaps lead-in for chapter-opening lines */ +body.reading-mode.fiction > #markdownBody h2 + p::first-line { + font-variant-caps: small-caps; + letter-spacing: 0.05em; +} + +/* Scene breaks (***) — reuse the asterism but give it more breathing room */ +body.reading-mode.fiction > #markdownBody hr { + margin: 3rem 0; +} diff --git a/static/css/score-reader.css b/static/css/score-reader.css new file mode 100644 index 0000000..81283da --- /dev/null +++ b/static/css/score-reader.css @@ -0,0 +1,509 @@ +/* score-reader.css — Full-page score reader layout. + Used only on /music/{slug}/score/ pages via score-reader-default.html. + + Two ideas drive the design. The sheet is the only bright object on + screen — it sits on the theme's ground with a hairline edge and a soft + shadow, reading as paper on a stand rather than as a document bleeding + into the background. And the chrome recedes: after a few seconds of + stillness the bar and the page-turn affordances fade, because a score + reader should show a score. */ + +.score-reader-page { + /* Bar height. The viewport is offset by exactly this much and does not + reflow when the bar fades — recomputing the fit on every idle + transition would make the page jump while being read. */ + --score-chrome: 2.75rem; + --score-gutter: 1.25rem; + + /* Paper. Set per theme rather than derived, because "slightly brighter + than the ground" is a different mix in a warm off-white, a near-black, + and a brown. */ + --score-paper: #fffdfa; + + overflow: hidden; /* the viewport scrolls, not the document */ +} + +[data-theme="dark"] .score-reader-page { --score-paper: #1c1c1c; } +[data-theme="cappuccino"] .score-reader-page { --score-paper: #5e4230; } + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .score-reader-page { --score-paper: #1c1c1c; } +} + +/* ------------------------------------------------------------------ + Top bar +------------------------------------------------------------------ */ + +.score-reader-bar { + position: fixed; + top: 0; left: 0; right: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 1rem; + padding: 0 1.25rem; + height: var(--score-chrome); + background: var(--bg); + border-bottom: 1px solid var(--border); + font-family: var(--font-sans); + font-size: 0.825rem; + transition: opacity 0.4s ease, transform 0.4s ease; +} + +.score-reader-bar-left { + flex: 0 0 auto; +} + +.score-reader-movements { + display: flex; + align-items: center; + gap: 0.25rem; + flex: 1 1 auto; + overflow-x: auto; + scrollbar-width: none; +} +.score-reader-movements::-webkit-scrollbar { display: none; } + +.score-reader-bar-right { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 0 0 auto; +} + +.score-reader-back { + color: var(--text-muted); + text-decoration: none; + white-space: nowrap; +} +.score-reader-back:hover { color: var(--text); } + +.score-reader-mvt { + background: none; + border: none; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.8rem; + padding: 0.2rem 0.5rem; + cursor: pointer; + white-space: nowrap; + border-radius: 3px; + transition: background 0.15s, color 0.15s; +} +.score-reader-mvt:hover, +.score-reader-mvt.is-active { + background: var(--bg-subtle); + color: var(--text); +} + +.score-reader-pdf { + color: var(--text-muted); + text-decoration: none; + font-size: 0.8rem; +} +.score-reader-pdf:hover { color: var(--text); } + +/* Zoom group ------------------------------------------------------- */ + +.score-reader-zoom { + display: flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; +} + +.score-reader-zoom-btn { + background: none; + border: none; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.75rem; + line-height: 1; + padding: 0.35rem 0.5rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.score-reader-zoom-btn + .score-reader-zoom-btn { + border-left: 1px solid var(--border); +} +.score-reader-zoom-btn:hover:not(:disabled) { + background: var(--bg-subtle); + color: var(--text); +} +.score-reader-zoom-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.score-reader-zoom-mode { + min-width: 3.6em; + font-variant-caps: all-small-caps; + letter-spacing: 0.06em; + font-variant-numeric: tabular-nums; +} + +/* ------------------------------------------------------------------ + Stage and viewport +------------------------------------------------------------------ */ + +.score-reader-stage { + position: fixed; + inset: 0; + background: var(--bg); +} + +.score-reader-viewport { + position: absolute; + top: var(--score-chrome); + left: 0; right: 0; bottom: 0; + overflow: auto; + display: flex; + padding: var(--score-gutter); + overscroll-behavior: contain; +} + +/* `margin: auto` rather than `justify-content: center`: it centres the + sheet while it fits, but unlike the flex property it does not clip the + leading edge once the sheet is larger than the viewport — which is the + whole point of zooming into an orchestral page. */ +.score-page { + margin: auto; + flex: 0 0 auto; + position: relative; + + /* Page 1's width/height, written into the element by the build. */ + --ar: var(--score-aspect-num, 1.294); + aspect-ratio: var(--ar) / 1; + + /* Fit-to-height without scripting. The width has to be derived from the + viewport height rather than capped with max-height: `width` would be a + definite size, so a max-height clamp shortens the box without + narrowing it, and the aspect ratio breaks instead of the box + shrinking. min() keeps a tall page from overflowing a narrow screen. */ + --score-fit-width: min(100%, + calc((100vh - var(--score-chrome) + - 2 * var(--score-gutter)) + * var(--ar))); + width: var(--score-fit-width); +} + +/* A portrait sheet fills the width instead and scrolls, matching what the + reader's own default does once its script is running. */ +.score-page[data-orient="portrait"] { + --score-fit-width: 100%; + background: var(--score-paper); + border: 1px solid var(--border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), + 0 10px 28px rgba(0, 0, 0, 0.06); + + /* The ink colour every inlined score inherits. */ + color: var(--text); + + /* `fill` is an inherited SVG property, and it crosses the HTML→SVG + boundary — so any element that never declares a fill of its own + (the common case for glyph outlines) draws in the theme's ink. + `stroke` is deliberately NOT set here: its initial value is `none`, + and inheriting a stroke would outline every filled notehead. */ + fill: currentColor; +} + +/* Once the reader is live, zoom owns the sheet's width and the fit caps + have to come off — otherwise max-height would clamp every zoom level + back to one screenful. */ +.js-ready .score-page { + /* Falls back to the same fit the stylesheet computes on its own, not to + 100%. --score-page-width is only set once the first page has been + fetched and measured, and on a 1.5 MB orchestral page that is a long + gap to spend at full container width. */ + width: var(--score-page-width, var(--score-fit-width)); +} + +/* Recolour the engraver's own values instead of rewriting them at build + time. A presentation attribute (fill="#000000") carries specificity + zero, so any author-stylesheet rule beats it — no per-engraver build + step, and nothing to change when a fourth engraver appears. + + Ink and ground are mapped separately, and everything else is left + alone. Recolouring *every* fill is the obvious shortcut and it is + wrong: MuseScore opens each page with a full-bleed white path, so a + blanket rule paints an opaque sheet of ink over notation that was + themed correctly underneath it. LilyPond emits no white at all, which + is why that only shows up on a real MuseScore export. + + Leaving unmatched colours untouched also preserves intentional colour — + analytical markings, cue-note grey, a coloured ossia — instead of + flattening it into the text colour. */ +.score-page svg [fill="#000000" i], +.score-page svg [fill="#000" i], +.score-page svg [fill="black" i] { fill: currentColor; } + +.score-page svg [stroke="#000000" i], +.score-page svg [stroke="#000" i], +.score-page svg [stroke="black" i] { stroke: currentColor; } + +/* Page grounds join the sheet rather than staying paper-white in a dark + theme. `none` is untouched throughout — slurs, ties and beams need it. */ +.score-page svg [fill="#ffffff" i], +.score-page svg [fill="#fff" i], +.score-page svg [fill="white" i] { fill: var(--score-paper); } + +.score-page svg { + display: block; + width: 100%; + height: 100%; +} + +/* Scripting-off fallback. Renders in the engraver's own colours, which is + an honest degradation — legible in any theme, if not native to it. */ +.score-page-fallback { + display: block; + width: 100%; + height: auto; +} + +.score-page.is-loading { + opacity: 0.35; + transition: opacity 0.2s ease 0.15s; +} + +.score-page-error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + margin: 0; + padding: 1rem; + text-align: center; + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--text-muted); +} + +/* ------------------------------------------------------------------ + Page turning +------------------------------------------------------------------ */ + +.score-reader-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 50; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-muted); + font-size: 1.15rem; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, opacity 0.4s ease; +} +.score-reader-nav:hover:not(:disabled) { + color: var(--text); + border-color: var(--text-muted); +} +.score-reader-nav:disabled { + opacity: 0.25; + cursor: default; +} + +.score-reader-prev { left: 0.85rem; } +.score-reader-next { right: 0.85rem; } + +/* Folio — the page number as it would be printed on the sheet, rather + than as a counter chip in the corner of an application. */ +.score-folio { + position: absolute; + bottom: 0.7rem; + left: 50%; + transform: translateX(-50%); + z-index: 50; + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant-caps: all-small-caps; + font-variant-numeric: tabular-nums; + letter-spacing: 0.07em; + color: var(--text-faint); + pointer-events: none; + transition: opacity 0.4s ease; +} + +/* ------------------------------------------------------------------ + Instrument-label gutter + + A second copy of the page, clipped to its label column and translated + to sit against the viewport's left edge, so the instrument names stay + readable once the real ones have been panned off. It scrolls + vertically with the page — it is positioned inside the sheet, not + inside the viewport — which is what keeps each name beside its own + staff. +------------------------------------------------------------------ */ + +.score-gutter { + position: absolute; + left: 0; top: 0; bottom: 0; + z-index: 2; + width: var(--score-gutter-w, 0px); + overflow: hidden; + background: var(--score-paper); + border-right: 1px solid var(--border); + box-shadow: 3px 0 8px rgba(0, 0, 0, 0.07); + + /* The page underneath owns every drag, swipe and click. */ + pointer-events: none; + + opacity: 0; + transition: opacity 0.15s ease; +} +.score-gutter.is-visible { opacity: 1; } + +/* Full page width inside the clip, so the copy lines up with the original + rather than being squashed into the gutter. The width is set from the + measured page box in updateGutter(); this is only the starting value. */ +.score-gutter-inner { + width: 100%; + height: 100%; +} +.score-gutter-inner svg { + display: block; + width: 100%; + height: 100%; +} + +/* Zoomed past the viewport, the folio is over the staves rather than in + the page's bottom margin, so it takes a ground of its own. */ +.is-panning .score-folio { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.1rem 0.55rem; +} + +/* ------------------------------------------------------------------ + Idle — chrome recedes +------------------------------------------------------------------ */ + +.score-reader-page.is-idle .score-reader-bar { + opacity: 0; + transform: translateY(-100%); + pointer-events: none; +} +.score-reader-page.is-idle .score-reader-nav, +.score-reader-page.is-idle .score-folio { + opacity: 0; + pointer-events: none; +} + +/* ------------------------------------------------------------------ + Narrow screens +------------------------------------------------------------------ */ + +@media (max-width: 640px) { + .score-reader-page { --score-gutter: 0.5rem; } + + /* Swipe replaces them; at this width they would sit on the music. */ + .score-reader-nav { display: none; } + + .score-reader-zoom { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .score-reader-bar, + .score-reader-nav, + .score-folio, + .score-gutter, + .score-page.is-loading { + transition: none; + } + .score-reader-page.is-idle .score-reader-bar { + transform: none; + } +} + +/* ------------------------------------------------------------------ + Composition landing page additions (metadata block) +------------------------------------------------------------------ */ + +.composition-details { + display: flex; + flex-wrap: wrap; + gap: 0 1.5rem; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.875rem; +} + +.comp-detail { white-space: nowrap; } + +.composition-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.comp-btn { + display: inline-block; + padding: 0.35em 0.9em; + border: 1px solid var(--border); + border-radius: 3px; + font-family: var(--font-sans); + font-size: 0.825rem; + text-decoration: none; + color: var(--text); + background: var(--bg); + transition: background 0.15s, border-color 0.15s; +} +.comp-btn:hover { + background: var(--bg-subtle); + border-color: var(--text-muted); +} +.comp-btn--secondary { color: var(--text-muted); } + +/* ------------------------------------------------------------------ + Movement list +------------------------------------------------------------------ */ + +.composition-movements { + border-top: 1px solid var(--border); + margin: 1.5rem 0; + padding-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.comp-movement-header { + display: flex; + align-items: baseline; + gap: 0.75rem; + font-family: var(--font-sans); + font-size: 0.875rem; +} + +.comp-movement-name { + font-weight: 600; + color: var(--text); +} + +.comp-movement-duration { + color: var(--text-muted); + font-size: 0.8rem; +} + +.comp-movement-score { + color: var(--text-muted); + font-size: 0.8rem; + text-decoration: none; + margin-left: auto; +} +.comp-movement-score:hover { color: var(--text); } + +.movement-audio { + width: 100%; + margin-top: 0.4rem; + accent-color: var(--text-muted); +} diff --git a/static/css/selection-popup.css b/static/css/selection-popup.css new file mode 100644 index 0000000..e3715f1 --- /dev/null +++ b/static/css/selection-popup.css @@ -0,0 +1,184 @@ +/* selection-popup.css — Custom text-selection toolbar */ + +/* ============================================================ + CONTAINER + Inverted: --text as background, --bg as foreground. + This gives a dark pill in light mode and a light pill in dark + mode — same contrast relationship as the page selection colour. + ============================================================ */ + +.selection-popup { + position: absolute; + z-index: 700; + display: flex; + align-items: center; + padding: 0 0.25rem; + gap: 0; + + background: var(--text); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.22); + + opacity: 0; + visibility: hidden; + transform: translateY(5px); + transition: opacity 0.13s ease, visibility 0.13s ease, transform 0.13s ease; + + pointer-events: none; + user-select: none; + -webkit-user-select: none; +} + +.selection-popup.is-visible { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +/* Caret pointing down (popup is above selection) */ +.selection-popup::after { + content: ''; + position: absolute; + top: 100%; + left: calc(var(--caret-left, 50%) - 5px); + border: 5px solid transparent; + border-top-color: var(--text); + pointer-events: none; +} + +/* Flip: caret pointing up (popup is below selection) */ +.selection-popup.is-below::after { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--text); +} + + +/* ============================================================ + BUTTONS + ============================================================ */ + +.selection-popup-btn { + background: none; + border: none; + color: var(--bg); + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.025em; + padding: 0.38rem 0.6rem; + cursor: pointer; + border-radius: 3px; + white-space: nowrap; + line-height: 1; + transition: background 0.1s ease; +} + +.selection-popup-btn:hover, +.selection-popup-btn:focus-visible { + background: rgba(128, 128, 128, 0.2); + outline: none; +} + +/* Placeholder buttons — visually present but muted, not interactive */ +.selection-popup-btn--placeholder { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + + +/* ============================================================ + SEPARATOR + ============================================================ */ + +.selection-popup-sep { + display: block; + width: 1px; + height: 1rem; + background: var(--bg); + opacity: 0.2; + margin: 0 0.15rem; + flex-shrink: 0; +} + + +/* ============================================================ + ANNOTATION FORM MODE + Replaces the button row with a compact form: colour swatches, + optional note textarea, Save / Cancel. + ============================================================ */ + +.selection-popup.is-form-mode { + flex-direction: column; + align-items: stretch; + padding: 0.5rem; + gap: 0.4rem; + min-width: 13rem; +} + +.selection-popup-ann-form { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +/* ── Colour swatches ── */ + +.ann-swatches { + display: flex; + gap: 0.4rem; + padding: 0 0.1rem; +} + +.ann-swatch { + width: 1.05rem; + height: 1.05rem; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: border-color 0.1s ease, transform 0.1s ease; +} + +.ann-swatch:hover { transform: scale(1.15); } +.ann-swatch.is-selected { border-color: var(--bg); } + +.ann-swatch--amber { background: #f59e0b; } +.ann-swatch--sage { background: #6b9e78; } +.ann-swatch--steel { background: #7096b8; } +.ann-swatch--rose { background: #c87474; } + +/* ── Note textarea ── */ + +.ann-note-input { + background: rgba(128, 128, 128, 0.15); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 3px; + color: var(--bg); + font-family: var(--font-sans); + font-size: 0.72rem; + line-height: 1.4; + padding: 0.3rem 0.45rem; + resize: none; + width: 100%; + box-sizing: border-box; + outline: none; +} + +.ann-note-input::placeholder { opacity: 0.45; } +.ann-note-input:focus { border-color: rgba(255, 255, 255, 0.35); } + +/* ── Save / Cancel row ── */ + +.ann-form-actions { + display: flex; + gap: 0.3rem; + justify-content: flex-end; +} + +/* Reduce motion */ +[data-reduce-motion] .ann-swatch { transition: none; } diff --git a/static/css/sidenotes.css b/static/css/sidenotes.css new file mode 100644 index 0000000..d8fb1ca --- /dev/null +++ b/static/css/sidenotes.css @@ -0,0 +1,252 @@ +/* sidenotes.css — Inline sidenote layout. + The Haskell Sidenotes filter converts Pandoc footnotes to: + N + N + + Layout strategy + ─────────────── + On wide viewports (≥ 1500px) the sidenote is positioned + absolutely relative to #markdownBody (which is position: relative). + We do NOT use float because float with negative margin is unreliable + across browsers when the float's margin box is effectively zero-width; + it tends to wrap below the paragraph rather than escaping to the right. + + position: absolute with no explicit top/bottom uses the "hypothetical + static position" — the spot the element would occupy if position: static. + For an inline inside a

      , this is roughly the line containing + the sidenote reference, giving correct vertical alignment without JS. + + On narrow viewports the is hidden and the +

      the Sidenotes filter appends at document + end is shown instead (Pandoc's own footnote section never exists — + the filter consumes every Note, and re-emits this fallback itself). +*/ + +/* ============================================================ + SIDENOTE REFERENCE (in-text superscript) + ============================================================ */ + +.sidenote-ref { + font-size: 0.7em; + line-height: 0; + position: relative; + top: -0.4em; + font-family: var(--font-sans); + font-feature-settings: normal; +} + +.sidenote-ref a { + color: var(--text-faint); + text-decoration: none; + padding: 0 0.1em; + transition: color var(--transition-fast); +} + +.sidenote-ref a:hover, +.sidenote-ref.is-active a { + color: var(--text); +} + +/* Highlight the sidenote when its ref is hovered (CSS: adjacent sibling). */ +.sidenote-ref:hover + .sidenote, +.sidenote.is-active { + color: var(--text); +} + + +/* ============================================================ + SIDENOTE SPAN + position: absolute anchors to #markdownBody (position: relative). + left: 100% + gap puts the left edge just past the right side of + the body column. No top/bottom → hypothetical static position. + ============================================================ */ + +.sidenote { + position: absolute; + left: calc(100% + 1.5rem); /* 1.5rem gap from body right edge */ + width: clamp(200px, calc(50vw - var(--body-max-width) / 2 - var(--page-padding) - 1.5rem), 320px); + + font-family: var(--font-serif); + font-size: 0.82rem; + line-height: 1.55; + color: var(--text-muted); + + text-indent: 0; + font-feature-settings: normal; + hyphens: none; + hanging-punctuation: none; +} + +/* Number badge inside the sidenote — inline box, not a superscript */ +.sidenote-num { + display: inline-block; + font-family: var(--font-sans); + font-size: 0.65em; + font-weight: 600; + line-height: 1.35; + vertical-align: baseline; + color: var(--text-faint); + border: 1px solid var(--border-muted); + border-radius: 2px; + padding: 0 0.3em; + margin-right: 0.35em; +} + +/* Paragraphs injected by blocksToHtml (rendered as inline-block spans + to keep them valid inside the outer ) */ +.sidenote-para { + display: block; + margin: 0; + text-indent: 0; +} + +.sidenote-para + .sidenote-para { + margin-top: 0.4em; +} + +.sidenote a { + color: var(--text-muted); +} + + +/* ============================================================ + RESPONSIVE + ───────────────────────────────────────────────────────────── + Side columns are 1fr (fluid). Sidenotes need at least + body(800px) + gap(24px) + sidenote(200px) + padding(48px) ≈ 1072px, + but a comfortable threshold is kept at 1500px so sidenotes + have enough room not to feel cramped. + ============================================================ */ + +@media (min-width: 1500px) { + section.footnotes { + display: none; + } +} + +@media (max-width: 1499px) { + .sidenote { + display: none; + } + + section.footnotes { + display: block; + margin-top: 3.3rem; + padding-top: 1.65rem; + border-top: 1px solid var(--border-muted); + } +} + + +/* ============================================================ + FOOTNOTES FALLBACK LIST — the section the Sidenotes filter + appends at document end; visible on narrow viewports only + (see the media queries above). Letter labels are rendered + explicitly because an
        's automatic numbers would disagree + with the in-text letter refs. + ============================================================ */ + +section.footnotes .footnotes-list { + list-style: none; + margin: 0; + padding: 0; +} + +.footnote-item { + position: relative; + padding-left: 1.5rem; + margin-bottom: 0.85rem; + font-size: 0.85rem; + line-height: 1.6; + color: var(--text-muted); +} + +.footnote-label { + position: absolute; + left: 0; + top: 0.15em; + font-family: var(--font-sans); + font-size: 0.75em; + color: var(--text-faint); +} + +/* First paragraph flows on the label's line; later ones stack. */ +.footnote-item > p { + margin: 0 0 0.5em; +} +.footnote-item > p:first-of-type { + display: inline; +} + +.footnote-back { + margin-left: 0.35em; + text-decoration: none; + font-family: var(--font-sans); + color: var(--text-faint); + transition: color var(--transition-fast); +} + +.footnote-back:hover { + color: var(--text-muted); +} + + +/* ============================================================ + MOBILE SIDENOTE POPUP + Bottom sheet shown when tapping a sidenote ref on narrow + viewports (where .sidenote is hidden). The overlay is + display:none by default; sidenotes.js adds .is-open to show it. + No media query needed — JS only opens the popup when the + sidenote span is hidden, so it never fires on wide viewports. + ============================================================ */ + +.sidenote-popup-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 500; + background: rgba(0, 0, 0, 0.45); + align-items: flex-end; +} + +.sidenote-popup-overlay.is-open { + display: flex; +} + +.sidenote-popup { + background: var(--bg); + border-top: 1px solid var(--border); + border-radius: 0.75rem 0.75rem 0 0; + padding: 1.25rem 1.25rem 2.75rem; + width: 100%; + max-height: 65vh; + overflow-y: auto; + position: relative; + font-family: var(--font-serif); + font-size: 0.9rem; + line-height: 1.6; + color: var(--text); + outline: none; +} + +.sidenote-popup-close { + position: absolute; + top: 0.7rem; + right: 0.9rem; + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + color: var(--text-faint); + cursor: pointer; + padding: 0.1rem 0.35rem; +} + +.sidenote-popup-close:hover { + color: var(--text); +} + +/* Preserve sidenote-num badge style inside the popup */ +.sidenote-popup .sidenote-num { + display: inline-block; +} diff --git a/static/css/syntax.css b/static/css/syntax.css new file mode 100644 index 0000000..ce4f3a9 --- /dev/null +++ b/static/css/syntax.css @@ -0,0 +1,93 @@ +/* syntax.css — Monochrome Prism.js syntax highlighting. + No toggle — always active on language-annotated code blocks. + No hue: only bold, italic, and opacity variations matching site palette. + Dark mode is automatic via CSS custom properties. */ + + +/* Comments: faint + italic — least prominent */ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata, +.token.shebang { + color: var(--text-faint); + font-style: italic; +} + +/* Keywords, control flow: bold — most prominent */ +.token.keyword, +.token.rule, +.token.important, +.token.atrule, +.token.builtin, +.token.deleted { + font-weight: 700; +} + +/* Strings: muted */ +.token.string, +.token.char, +.token.attr-value, +.token.regex, +.token.template-string, +.token.inserted { + color: var(--text-muted); +} + +/* Numbers and booleans: full text color (explicit for completeness) */ +.token.number, +.token.boolean, +.token.constant { + color: var(--text); +} + +/* Punctuation and operators: faint — structural noise, recede */ +.token.punctuation, +.token.operator { + color: var(--text-faint); +} + +/* Functions and class names: semibold */ +.token.function, +.token.function-definition, +.token.class-name, +.token.maybe-class-name { + font-weight: 600; +} + +/* Tags (HTML/XML): semibold */ +.token.tag { + font-weight: 600; +} + +/* Attribute names: muted */ +.token.attr-name, +.token.property { + color: var(--text-muted); +} + +/* Selectors (CSS): semibold */ +.token.selector { + font-weight: 600; +} + +/* Type annotations and namespaces: italic + muted */ +.token.namespace, +.token.type-annotation, +.token.type { + font-style: italic; + color: var(--text-muted); +} + +/* Variables and parameters: italic */ +.token.variable, +.token.parameter { + font-style: italic; +} + +/* URLs: muted underline */ +.token.url { + color: var(--text-muted); + text-decoration: underline; + text-decoration-color: var(--border); +} diff --git a/static/css/typography.css b/static/css/typography.css new file mode 100644 index 0000000..00e4395 --- /dev/null +++ b/static/css/typography.css @@ -0,0 +1,840 @@ +/* typography.css — Spectral body text, Fira Sans headings, OT features, and editorial flourishes */ + +/* ============================================================ + BODY TEXT + ============================================================ */ + +#markdownBody { + font-family: var(--font-serif); + font-size: 1rem; + line-height: var(--line-height); + + /* OT features: ligatures, old-style figures, kerning */ + font-feature-settings: 'liga' 1, 'onum' 1, 'kern' 1; + font-variant-ligatures: common-ligatures; + font-variant-numeric: oldstyle-nums; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + hyphens: auto; + hanging-punctuation: first last; +} + + +/* ============================================================ + LITERARY FLOURISHES + ============================================================ */ + +/* 1. Automated Dropcap for the opening paragraph */ +#markdownBody > p:first-of-type::first-letter { + float: left; + font-size: 3.8em; + line-height: 0.8; + padding-top: 0.05em; + padding-right: 0.1em; + color: var(--text); + font-variant-caps: normal; /* Prevent smcp collision */ +} + +/* 2. Magazine-style Lead-in (Small caps for the rest of the first line) */ +#markdownBody > p:first-of-type::first-line { + font-variant-caps: small-caps; + letter-spacing: 0.05em; + color: var(--text); +} + +/* 3. Explicit dropcap — wrap any paragraph in a ::: dropcap ::: fenced div */ +#markdownBody .dropcap p::first-letter { + float: left; + font-size: 3.8em; + line-height: 0.8; + padding-top: 0.05em; + padding-right: 0.1em; + color: var(--text); + font-variant-caps: normal; +} + +#markdownBody .dropcap p::first-line { + font-variant-caps: small-caps; + letter-spacing: 0.05em; + color: var(--text); +} + +/* 3. Epigraphs: Whispered preludes */ +.epigraph { + font-style: italic; + font-size: 0.95em; + margin: 0 0 3.3rem 0; /* 2x baseline grid */ + padding-left: 1.5em; + border-left: 1px solid var(--border-muted); + color: var(--text-muted); +} + +/* 4. Pull Quotes */ +.pull-quote { + font-size: 1.25em; + font-style: italic; + text-align: center; + margin: 3.3rem 0; /* 2x baseline grid */ + padding: 1.65rem 2rem; /* 1x baseline grid padding */ + border-top: 1px solid var(--border-muted); + border-bottom: 1px solid var(--border-muted); + color: var(--text); +} + +/* 5. Section-break ornaments (replace standard
        ). + Selected by `data-dingbat` on , resolved in build/Dingbat.hs: + 1. frontmatter `dingbat:` key, 2. section default, 3. fallback. + Best practice: set `dingbat:` explicitly in frontmatter. + Typographic ornaments (asterism, asterisks) use character glyphs. + Designed ornaments use SVGs from /images/dingbats/ rendered via mask-image + so they inherit currentColor (→ --text-muted on
        ). + To add a new ornament: extend build/Dingbat.hs and add both a character + or SVG rule below (plus the SVG file if applicable). */ +#markdownBody hr { + border: none; + text-align: center; + margin: 3.3rem 0; /* 2x baseline grid */ + color: var(--text-muted); + font-family: var(--font-serif); +} +#markdownBody hr::after { + display: inline-block; + font-size: 1.5em; + /* Fallback when no data-dingbat is set. */ + content: "⁂"; + letter-spacing: 0.5em; + padding-left: 0.5em; /* Optically center the letter-spacing */ +} + +/* --- Typographic ornaments ------------------------------------------ */ + +/* asterism — ⁂ typographic asterism */ +body[data-dingbat="asterism"] #markdownBody hr::after { + content: "⁂"; + letter-spacing: 0.5em; + padding-left: 0.5em; +} + +/* asterisks — classic spaced asterisks used in print fiction */ +body[data-dingbat="asterisks"] #markdownBody hr::after { + content: "* * *"; + letter-spacing: 0.4em; + padding-left: 0; + font-size: 1.3em; +} + +/* --- SVG ornaments -------------------------------------------------- */ +/* Shared mask/sizing for every SVG-driven variant. The per-variant rules + below only supply the mask-image URL. */ +body[data-dingbat="fleuron"] #markdownBody hr::after, +body[data-dingbat="trefoil"] #markdownBody hr::after, +body[data-dingbat="lozenge"] #markdownBody hr::after, +body[data-dingbat="clef"] #markdownBody hr::after, +body[data-dingbat="memento"] #markdownBody hr::after, +body[data-dingbat="tech"] #markdownBody hr::after, +body[data-dingbat="ai"] #markdownBody hr::after { + content: ""; + width: 1.6em; + height: 1.6em; + font-size: 1em; /* override the 1.5em from the base rule */ + vertical-align: middle; + letter-spacing: 0; + padding-left: 0; + background-color: currentColor; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + +body[data-dingbat="fleuron"] #markdownBody hr::after { + mask-image: url('/images/dingbats/fleuron.svg'); + -webkit-mask-image: url('/images/dingbats/fleuron.svg'); +} + +body[data-dingbat="trefoil"] #markdownBody hr::after { + mask-image: url('/images/dingbats/trefoil.svg'); + -webkit-mask-image: url('/images/dingbats/trefoil.svg'); +} + +body[data-dingbat="lozenge"] #markdownBody hr::after { + mask-image: url('/images/dingbats/lozenge.svg'); + -webkit-mask-image: url('/images/dingbats/lozenge.svg'); +} + +body[data-dingbat="clef"] #markdownBody hr::after { + mask-image: url('/images/dingbats/clef.svg'); + -webkit-mask-image: url('/images/dingbats/clef.svg'); +} + +body[data-dingbat="memento"] #markdownBody hr::after { + mask-image: url('/images/dingbats/memento.svg'); + -webkit-mask-image: url('/images/dingbats/memento.svg'); +} + +body[data-dingbat="tech"] #markdownBody hr::after { + mask-image: url('/images/dingbats/tech.svg'); + -webkit-mask-image: url('/images/dingbats/tech.svg'); +} + +body[data-dingbat="ai"] #markdownBody hr::after { + mask-image: url('/images/dingbats/ai.svg'); + -webkit-mask-image: url('/images/dingbats/ai.svg'); +} + + +/* ============================================================ + PARAGRAPHS & SPACING + Anchored strictly to the 1.65rem baseline grid. + ============================================================ */ + +#markdownBody p { + margin: 0; +} + +#markdownBody p + p, +#markdownBody .dropcap + p { + text-indent: 1.5em; +} + +/* Reset indent after any block-level element */ +#markdownBody :is(blockquote, pre, ul, ol, figure, table, h1, h2, h3, h4, h5, h6, hr) + p { + text-indent: 0; +} + +/* Space between block-level elements: Exactly 1 line-height */ +#markdownBody :is(blockquote, pre, ul, ol, figure, table) { + margin: 1.65rem 0; +} + + +/* ============================================================ + HEADINGS + Fira Sans Semibold. Margins tied to the 1.65rem rhythm. + ============================================================ */ + +#markdownBody :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-sans); + font-weight: 600; + line-height: 1.25; + color: var(--text); + + /* Disable Spectral OT features for Sans */ + font-feature-settings: normal; + font-variant-numeric: normal; + font-variant-ligatures: normal; + text-rendering: optimizeLegibility; +} + +/* Top margin: 3.3rem (2 lines). Bottom margin: 0.825rem (half line). */ +#markdownBody h1 { font-size: 2.6rem; margin: 0 0 1.65rem 0; } +#markdownBody h2 { font-size: 1.85rem; margin: 3.3rem 0 0.825rem 0; } +#markdownBody h3 { font-size: 1.45rem; margin: 2.475rem 0 0.825rem 0; } +#markdownBody h4 { font-size: 1.15rem; margin: 1.65rem 0 0.825rem 0; } +#markdownBody h5 { font-size: 1rem; margin: 1.65rem 0 0.825rem 0; } +#markdownBody h6 { font-size: 1rem; margin: 1.65rem 0 0.825rem 0; font-weight: 400; font-style: italic; } + +/* Section heading self-links (¶ pilcrow) */ +#markdownBody .heading { position: relative; } +#markdownBody .heading a::after { + content: "\00B6"; + font-size: 0.75em; + position: absolute; + bottom: 0.15em; + right: -1.25em; + visibility: hidden; + opacity: 0; + transition: opacity 0.1s ease; +} +#markdownBody .heading:hover a::after { + visibility: visible; + opacity: 0.4; +} + + +/* ============================================================ + LINKS + Thick, descender-clearing strokes. + ============================================================ */ + +a { + color: var(--link); + text-decoration: underline; + text-decoration-color: var(--border-muted); + text-decoration-thickness: 0.15em; + text-underline-offset: 0.15em; + text-decoration-skip-ink: auto; /* Clears descenders */ + transition: text-decoration-color var(--transition-fast), color var(--transition-fast); +} + +a:hover { + text-decoration-color: var(--link-hover); +} + +a:visited { + color: var(--link-visited); +} + + +/* (external link icons moved to bottom of file — see data-link-icon section) */ + + +/* ============================================================ + INLINE ELEMENTS & HIGHLIGHTING + ============================================================ */ + +strong { font-weight: 700; } +strong.semibold { font-weight: 600; } +em { font-style: italic; } + +/* Abbreviations: force uppercase acronyms to x-height */ +abbr { + font-variant-caps: all-small-caps; + letter-spacing: 0.03em; + text-decoration: none; + cursor: help; +} + +/* True superscripts & subscripts */ +sup { font-variant-position: super; line-height: 1; } +sub { font-variant-position: sub; line-height: 1; } + +/* Realistic Ink Highlighting — excludes user annotation marks */ +#markdownBody mark:not(.user-annotation) { + background-color: transparent; + background-image: linear-gradient( + 104deg, + rgba(250, 235, 120, 0) 0%, + rgba(250, 235, 120, 0.8) 2%, + rgba(250, 220, 100, 0.9) 98%, + rgba(250, 220, 100, 0) 100% + ); + background-size: 100% 0.7em; + background-position: 0 88%; + background-repeat: no-repeat; + padding: 0 0.2em; + color: inherit; +} +[data-theme="dark"] #markdownBody mark:not(.user-annotation) { + background-image: linear-gradient( + 104deg, + rgba(100, 130, 180, 0) 0%, + rgba(100, 130, 180, 0.4) 2%, + rgba(80, 110, 160, 0.5) 98%, + rgba(80, 110, 160, 0) 100% + ); +} +[data-theme="cappuccino"] #markdownBody mark:not(.user-annotation) { + background-image: linear-gradient( + 104deg, + rgba(245, 215, 130, 0) 0%, + rgba(245, 215, 130, 0.45) 2%, + rgba(230, 195, 110, 0.55) 98%, + rgba(230, 195, 110, 0) 100% + ); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) #markdownBody mark:not(.user-annotation) { + background-image: linear-gradient( + 104deg, + rgba(100, 130, 180, 0) 0%, + rgba(100, 130, 180, 0.4) 2%, + rgba(80, 110, 160, 0.5) 98%, + rgba(80, 110, 160, 0) 100% + ); + } +} + + +/* ============================================================ + CODE + ============================================================ */ + +code, kbd, samp { + font-family: var(--font-mono); + font-size: 0.88em; + font-feature-settings: 'liga' 1, 'calt' 1; + background-color: var(--bg-offset); + border: 1px solid var(--border); + border-radius: 2px; + padding: 0.1em 0.3em; +} + +pre { + position: relative; + font-family: var(--font-mono); + font-size: 0.88em; + font-feature-settings: 'liga' 1, 'calt' 1; + background-color: var(--bg-offset); + border: 1px solid var(--border-muted); + border-radius: 4px; + padding: 1.65rem 1.5rem; /* Anchored to baseline grid */ + overflow-x: auto; + line-height: 1.65; +} + +pre code { + font-size: 1em; + background: none; + border: none; + padding: 0; + border-radius: 0; +} + + +/* ============================================================ + BLOCKQUOTES + Woven/stitched edge instead of a solid line. + ============================================================ */ + +#markdownBody blockquote { + margin-left: 0; + margin-right: 0; + padding-left: 1.5em; + border-left: none; + + /* Woven/stitched border using background gradients */ + background-image: linear-gradient(to bottom, var(--border-muted) 50%, transparent 50%); + background-position: left top; + background-repeat: repeat-y; + background-size: 2px 8px; /* 2px wide, 8px repeating pattern */ + + color: var(--text-muted); + font-style: italic; +} + +#markdownBody blockquote em { + font-style: normal; +} + +/* ── Poem excerpt ──────────────────────────────────────────────────────────── */ +/* Usage:
        +
        Title — Author
        + The blockquote inherits the default #markdownBody blockquote styles entirely + (dashed left line, muted italic). The figure cancels the image-figure box + styling (bg / border / padding / shadow) and uses the inherited centering + (max-width: fit-content + margin: auto from #markdownBody figure). + When a source link is present, a stretched ::after covers the whole figure + so the entire excerpt is clickable. */ + +#markdownBody figure.poem-excerpt { + background: none; + border: none; + padding: 0; + box-shadow: none; + position: relative; +} + +/* blockquote — no overrides; inherits default dashed-left-line treatment */ + +/* Figcaption: overrides the right-aligned italic image-caption default. + Raised above the stretched-link overlay via z-index: 2. */ +#markdownBody figure.poem-excerpt figcaption { + text-align: left; + padding-left: 1.5em; /* aligns with blockquote text */ + font-family: var(--font-ui); + font-size: 0.73rem; + font-style: normal; + font-variant-caps: all-small-caps; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-top: 0.5rem; + position: relative; + z-index: 2; +} + +#markdownBody figure.poem-excerpt figcaption a { + color: inherit; + text-decoration: none; + border-bottom: 1px solid var(--border); + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +/* Stretched link: clicking anywhere on the figure navigates to the poem page. */ +#markdownBody figure.poem-excerpt figcaption a::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + cursor: pointer; +} + +#markdownBody figure.poem-excerpt figcaption a:hover { + color: var(--text); + border-color: var(--text-muted); +} + +/* ── Prose excerpt (inline pull-quote from an article or book) ─────────────── */ +/* Usage:
        +
        Source — Author
        + Left-aligned (overrides figure centering). Same stretched-link pattern. */ + +#markdownBody figure.prose-excerpt { + background: none; + border: none; + padding: 0; + box-shadow: none; + width: auto; + max-width: 100%; + margin: 2.5rem 0; + position: relative; +} + +/* blockquote — no overrides; inherits default dashed-left-line treatment */ + +#markdownBody figure.prose-excerpt figcaption { + text-align: left; + padding-left: 1.5em; + font-family: var(--font-ui); + font-size: 0.73rem; + font-style: normal; + font-variant-caps: all-small-caps; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-top: 0.5rem; + position: relative; + z-index: 2; +} + +#markdownBody figure.prose-excerpt figcaption a { + color: inherit; + text-decoration: none; + border-bottom: 1px solid var(--border); + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +#markdownBody figure.prose-excerpt figcaption a::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + cursor: pointer; +} + +#markdownBody figure.prose-excerpt figcaption a:hover { + color: var(--text); + border-color: var(--text-muted); +} + + +/* ============================================================ + LISTS + ============================================================ */ + +#markdownBody ul, +#markdownBody ol { + padding-left: 1.75em; +} + +#markdownBody li + li { + margin-top: 0.4125rem; /* Quarter line-height */ +} + +#markdownBody li > p { + margin: 0; + text-indent: 0; +} + + +/* ============================================================ + FIGURES & CAPTIONS + Archival Photo Plates format. + ============================================================ */ + +#markdownBody figure { + margin: 3.3rem auto; + background: var(--bg-offset); + padding: 1rem; + border: 1px solid var(--border); + border-radius: 2px; + box-shadow: 0 4px 12px rgba(0,0,0,0.03); /* Extremely subtle depth */ + max-width: fit-content; +} + +#markdownBody figure img { + display: block; + border: 1px solid var(--border-muted); /* Inner bounding box for the image */ +} + +/* Image figures: size the box to the image and constrain the caption to the + same width. `display: table` + `caption-side: bottom` makes the figure's + intrinsic width depend only on its table-cell content (the image), so long + captions wrap to the image width instead of stretching the figure off-screen. */ +#markdownBody figure:has(> img) { + display: table; +} + +#markdownBody figure:has(> img) > figcaption { + display: table-caption; + caption-side: bottom; +} + +#markdownBody figcaption { + font-family: var(--font-sans); + font-size: 0.92em; + color: var(--text-muted); + text-align: right; /* Editorial, museum-placard feel */ + margin-top: 1rem; + font-style: italic; + letter-spacing: 0.02em; +} + + +/* ============================================================ + TABLES + ============================================================ */ + +#markdownBody table { + border-collapse: collapse; + width: 100%; + font-size: 0.9em; + font-feature-settings: 'lnum' 1, 'tnum' 1; + font-variant-numeric: lining-nums tabular-nums; +} + +#markdownBody th, +#markdownBody td { + border: 1px solid var(--border); + padding: 0.4em 0.75em; + text-align: left; +} + +#markdownBody th { + font-family: var(--font-sans); + font-weight: 600; + background-color: var(--bg-offset); +} + + +/* ============================================================ + ABSTRACT BLOCK + ============================================================ */ + +.abstract { + font-size: 0.95em; + margin: 1.65rem 0 3.3rem 0; /* Baseline grid */ + padding-left: 1.5em; + border-left: 2px solid var(--border); + color: var(--text-muted); +} + +.abstract p + p { + text-indent: 0; + margin-top: 0.825rem; /* Half line-height */ +} + +/* Paragraphs inside annotation bodies: no first-line indent. + Annotations are short callouts, not body prose; the indent reads as a + typographic glitch when stacked inside the bordered box. */ +#markdownBody .annotation-body p + p { + text-indent: 0; + margin-top: 0.825rem; +} + + +/* ============================================================ + MATHEMATICS (KaTeX) + Display math is scaled slightly below body size so long equations + fit within the 680px column. KaTeX uses em units throughout its + output, so font-size here cascades and scales all rendered glyphs. + Inline math (.katex without .katex-display) is unaffected. + overflow-x: auto is a safety net for anything still too wide. + ============================================================ */ + +.katex-display { + font-size: 0.85em; + overflow-x: auto; + overflow-y: hidden; +} + + +/* ============================================================ + PROOF ENVIRONMENT + Traditional mathematical proof styling. + ============================================================ */ + +.proof { + margin: 1.65rem 0; +} + +/* "Proof." label — italic bold, matching LaTeX convention */ +.proof-label { + font-style: italic; + font-weight: 600; +} + +/* Paragraphs inside proof: no extra indent after first */ +.proof p + p { + text-indent: 0; + margin-top: 0.825rem; +} + +/* QED tombstone — floated right so it sits at the end of the last line */ +.proof-qed { + float: right; + margin-left: 1em; + font-style: normal; +} + + +/* ============================================================ + SMALLCAPS UTILITY + ============================================================ */ + +.smallcaps { + font-variant-caps: all-small-caps; + letter-spacing: 0.03em; +} + + +/* ============================================================ + EXTERNAL LINK ICONS + Uses data-link-icon attribute set at build time by Filters/Links.hs. + mask-image renders the SVG in currentColor so icons adapt to dark/light mode. + ============================================================ */ + +a[data-link-icon-type="svg"]::after { + content: ''; + display: inline-block; + width: 0.75em; + height: 0.75em; + margin-left: 0.2em; + vertical-align: 0.05em; + background-color: currentColor; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + opacity: 0.5; + transition: opacity var(--transition-fast); +} + +a[data-link-icon-type="svg"]:hover::after { + opacity: 0.8; +} + +a[data-link-icon="external"]::after { + mask-image: url('/images/link-icons/external.svg'); + -webkit-mask-image: url('/images/link-icons/external.svg'); +} + +a[data-link-icon="wikipedia"]::after { + mask-image: url('/images/link-icons/wikipedia.svg'); + -webkit-mask-image: url('/images/link-icons/wikipedia.svg'); +} + +a[data-link-icon="arxiv"]::after { + mask-image: url('/images/link-icons/arxiv.svg'); + -webkit-mask-image: url('/images/link-icons/arxiv.svg'); +} + +a[data-link-icon="doi"]::after { + mask-image: url('/images/link-icons/doi.svg'); + -webkit-mask-image: url('/images/link-icons/doi.svg'); +} + +a[data-link-icon="github"]::after { + mask-image: url('/images/link-icons/github.svg'); + -webkit-mask-image: url('/images/link-icons/github.svg'); +} + +a[data-link-icon="forgejo"]::after { + mask-image: url('/images/link-icons/forgejo.svg'); + -webkit-mask-image: url('/images/link-icons/forgejo.svg'); +} + +a[data-link-icon="worldcat"]::after { + mask-image: url('/images/link-icons/worldcat.svg'); + -webkit-mask-image: url('/images/link-icons/worldcat.svg'); +} + +a[data-link-icon="orcid"]::after { + mask-image: url('/images/link-icons/orcid.svg'); + -webkit-mask-image: url('/images/link-icons/orcid.svg'); +} + +a[data-link-icon="internet-archive"]::after { + mask-image: url('/images/link-icons/internet-archive.svg'); + -webkit-mask-image: url('/images/link-icons/internet-archive.svg'); +} + +a[data-link-icon="tensorflow"]::after { + mask-image: url('/images/link-icons/tensorflow.svg'); + -webkit-mask-image: url('/images/link-icons/tensorflow.svg'); +} + +a[data-link-icon="anthropic"]::after { + mask-image: url('/images/link-icons/anthropic.svg'); + -webkit-mask-image: url('/images/link-icons/anthropic.svg'); +} + +a[data-link-icon="openai"]::after { + mask-image: url('/images/link-icons/openai.svg'); + -webkit-mask-image: url('/images/link-icons/openai.svg'); +} + +a[data-link-icon="twitter"]::after { + mask-image: url('/images/link-icons/twitter.svg'); + -webkit-mask-image: url('/images/link-icons/twitter.svg'); +} + +a[data-link-icon="reddit"]::after { + mask-image: url('/images/link-icons/reddit.svg'); + -webkit-mask-image: url('/images/link-icons/reddit.svg'); +} + +a[data-link-icon="youtube"]::after { + mask-image: url('/images/link-icons/youtube.svg'); + -webkit-mask-image: url('/images/link-icons/youtube.svg'); +} + +a[data-link-icon="tiktok"]::after { + mask-image: url('/images/link-icons/tiktok.svg'); + -webkit-mask-image: url('/images/link-icons/tiktok.svg'); +} + +a[data-link-icon="substack"]::after { + mask-image: url('/images/link-icons/substack.svg'); + -webkit-mask-image: url('/images/link-icons/substack.svg'); +} + +a[data-link-icon="hacker-news"]::after { + mask-image: url('/images/link-icons/hacker-news.svg'); + -webkit-mask-image: url('/images/link-icons/hacker-news.svg'); +} + +a[data-link-icon="lesswrong"]::after { + mask-image: url('/images/link-icons/lesswrong.svg'); + -webkit-mask-image: url('/images/link-icons/lesswrong.svg'); +} + +a[data-link-icon="new-york-times"]::after { + mask-image: url('/images/link-icons/new-york-times.svg'); + -webkit-mask-image: url('/images/link-icons/new-york-times.svg'); +} + +a[data-link-icon="nasa"]::after { + mask-image: url('/images/link-icons/nasa.svg'); + -webkit-mask-image: url('/images/link-icons/nasa.svg'); +} + +a[data-link-icon="apple"]::after { + mask-image: url('/images/link-icons/apple.svg'); + -webkit-mask-image: url('/images/link-icons/apple.svg'); +} + +a[data-link-icon="internal"]::after { + mask-image: url('/images/link-icons/internal.svg'); + -webkit-mask-image: url('/images/link-icons/internal.svg'); +} + +a[data-link-icon="document"]::after { + mask-image: url('/images/link-icons/document.svg'); + -webkit-mask-image: url('/images/link-icons/document.svg'); +} diff --git a/static/css/vita.css b/static/css/vita.css new file mode 100644 index 0000000..84b407e --- /dev/null +++ b/static/css/vita.css @@ -0,0 +1,326 @@ +/* vita.css — /about.html and /cv/projects/. + * + * The vita is a record surface. Where Now answers "what is moving right + * now" with a status chip per item, the vita answers "what is on file" and + * carries no temporality beyond the dates themselves — so it borrows + * item-card.css's card rhythm and now.css's section headings, but drops the + * badge column entirely. Entries here have no state to report. + * + * SPECIFICITY NOTE. Almost every selector below is prefixed with + * #markdownBody, and that is load-bearing rather than habit. typography.css + * styles the prose surface through #markdownBody (1,0,1) — including + * `:is(h1..h6)` at display sizes with 2.475rem margins, and a 1.5em + * text-indent on `p + p` for essay prose. A bare `.vita-entry-title` (0,1,0) + * loses to those no matter what order the stylesheets load in, which is + * exactly what went wrong the first time: entry titles rendered at 1.45rem + * sans with heading margins, and every second paragraph in a card sat + * indented like a continuation of running prose. + * + * Now sidesteps this by never using heading elements inside its cards. The + * vita keeps them — a record page wants a real document outline for anyone + * navigating by heading — and pays for them with specificity instead. + */ + +/* ============================================================ + INTRO PROSE + Body content from the markdown — pointers and the research + threads on /about, the framing note on the project index. + Mirrors .now-intro so the data-driven pages open alike. + ============================================================ */ + +#markdownBody .vita-intro { + margin: 0 0 2.75rem; + font-family: var(--font-serif); + color: var(--text-muted); + line-height: 1.6; +} + +#markdownBody .vita-intro h2 { + font-family: var(--font-serif); + font-size: 1.15rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.09em; + color: var(--text-muted); + font-weight: 400; + margin: 1.9rem 0 0.5rem; +} + +#markdownBody .vita-intro > p:first-of-type { + margin-top: 0; +} + +/* The intro is a framing note, not running prose, so it opts out of the + essay indent on consecutive paragraphs too. Two short paragraphs where + the second starts 1.5em in reads as a mistake rather than a convention. */ +#markdownBody .vita-intro p { + text-indent: 0; +} + +/* ============================================================ + SECTIONS + Identical treatment to .now-section — same size, tracking and + ink — because the two pages are siblings and a reader moving + between them should not have to re-learn the hierarchy. + ============================================================ */ + +#markdownBody .vita-section { + margin: 0 0 2.5rem; +} + +#markdownBody .vita-section-heading { + font-family: var(--font-serif); + font-size: 1.15rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.09em; + color: var(--text-muted); + text-transform: none; + font-weight: 400; + margin: 0 0 0.7rem 0; + line-height: 1.3; +} + +/* ============================================================ + ENTRIES + .vita-card composes on .item-card, which is a flex row sized + for a badge column. The vita emits no badge, so the single + .item-card-main child takes the full width. + ============================================================ */ + +#markdownBody .vita-card { + padding: 0.8rem 0; +} + +/* Kill the prose indent inside cards outright. Card paragraphs are + labelled data — a role, a venue, an author list — not successive + paragraphs of an essay, and the 1.5em indent reads as damage. */ +#markdownBody .vita-card p { + text-indent: 0; + margin: 0; +} + +/* The title. Serif at body size, matching .item-card-title rather than any + heading step: these are list entries that happen to be headings for + outline purposes, not section openers. */ +#markdownBody .vita-entry-title { + font-family: var(--font-serif); + font-size: 1rem; + font-weight: 600; + line-height: 1.35; + color: var(--text); + margin: 0; + font-feature-settings: normal; +} + +#markdownBody .vita-entry-title a { + color: inherit; + text-decoration: none; +} + +#markdownBody .vita-entry-title a:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +/* Role, degree, position — and any secondary facts hung off it. Serif and + roman: the size step down from the title and the muted ink already + separate the two, so italic was doing no work the hierarchy needed and + read as emphasis on a line that is only labelling. */ +#markdownBody .vita-role { + font-family: var(--font-serif); + font-size: 0.92rem; + color: var(--text-muted); + margin: 0.1rem 0 0; + line-height: 1.45; +} + +/* Location and GPA trail the role in a fainter ink so the role still + leads the line. */ +#markdownBody .vita-quiet { + color: var(--text-faint); + font-family: var(--font-sans); + font-size: 0.78rem; +} + +#markdownBody .vita-quiet a.vita-location { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--border); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; + transition: color var(--transition-fast), text-decoration-color var(--transition-fast); +} + +#markdownBody .vita-quiet a.vita-location:hover { + color: var(--text-muted); + text-decoration-color: var(--border-muted); +} + +/* Author lists run long and are reference material rather than reading + material — sans, small, muted, tight. */ +#markdownBody .vita-authors { + font-family: var(--font-sans); + font-size: 0.78rem; + color: var(--text-muted); + margin: 0.25rem 0 0; + line-height: 1.45; +} + +/* Venue: journal, conference, or publication state. Deliberately roman. + Citation convention italicizes journal and book titles but not conference + names or status words, and publications.yml already marks the journal + names it wants italic (\textit{Journal of the American Medical + Informatics Association}). Setting the whole line italic flattened that + — italic nested in italic reads as nothing — and put "Preprint" in a + register reserved for titles. Roman here lets the data carry the emphasis + where it belongs. */ +#markdownBody .vita-venue { + font-family: var(--font-serif); + font-size: 0.9rem; + color: var(--text-muted); + margin: 0.1rem 0 0; + line-height: 1.45; +} + +#markdownBody .vita-venue em { + font-style: italic; +} + +/* Presentation state ("Presented", "Accepted"). Sits at the end of the + venue line as small-caps text rather than a bordered chip — a frame + here would compete with the link chips below it. */ +#markdownBody .vita-status { + font-family: var(--font-sans); + font-size: 0.72rem; + font-variant: all-small-caps; + letter-spacing: 0.07em; + color: var(--text-faint); +} + +#markdownBody .vita-status::before { + content: " · "; +} + +/* Preamble, notes, project descriptions — the connective prose. Same + register as .item-card-abstract.is-full, unclamped. */ +#markdownBody .vita-note { + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: var(--text-muted); + margin: 0.4rem 0 0; + line-height: 1.55; +} + +#markdownBody .vita-bullets { + margin: 0.4rem 0 0; + padding-left: 0.85rem; + list-style: none; +} + +#markdownBody .vita-bullets li { + font-family: var(--font-sans); + font-size: var(--text-size-small); + color: var(--text-muted); + line-height: 1.55; + margin: 0.25rem 0 0; + position: relative; +} + +/* Hairline dash rather than a bullet glyph: these are claims, not an + enumerated sequence, and a disc would assert more structure than the + content has. */ +#markdownBody .vita-bullets li::before { + content: ""; + position: absolute; + left: -0.85rem; + top: 0.7em; + width: 0.4rem; + height: 1px; + background: var(--border-muted); +} + +/* ============================================================ + LINK CHIPS + The page's one interactive surface, and the reason a vita on + the web beats a vita on paper. Geometry is lifted from + .now-status so the shape is already familiar from /current; + the difference is that these are links, and respond. + ============================================================ */ + +#markdownBody .vita-links { + margin: 0.5rem 0 0; + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +#markdownBody .vita-chip { + display: inline-block; + font-family: var(--font-sans); + font-size: 0.66rem; + font-variant: all-small-caps; + letter-spacing: 0.08em; + line-height: 1; + padding: 0.32em 0.55em 0.28em; + border: 1px solid var(--border); + border-radius: 2px; + color: var(--text-muted); + background: transparent; + white-space: nowrap; + text-decoration: none; + transition: + color var(--transition-fast), + border-color var(--transition-fast); +} + +#markdownBody .vita-chip:hover { + color: var(--text); + border-color: var(--text-muted); +} + +#markdownBody .vita-chip:focus-visible { + outline: 2px solid var(--text-muted); + outline-offset: 2px; +} + +/* The equal-contribution legend. Belongs to the section rather than to + any one entry, so it sits below the list in the faintest register. */ +#markdownBody .vita-footnote { + font-family: var(--font-serif); + font-size: 0.85rem; + font-style: italic; + color: var(--text-faint); + margin: 0.8rem 0 0; + text-indent: 0; +} + +/* ============================================================ + MOBILE (≤540px) + item-card.css already stacks .item-card-header and drops the + date below the title at this width, so only the type steps. + ============================================================ */ + +@media (max-width: 540px) { + #markdownBody .vita-entry-title { + font-size: 0.95rem; + overflow-wrap: anywhere; + } + + #markdownBody .vita-role, + #markdownBody .vita-venue { + font-size: 0.88rem; + } + + #markdownBody .vita-chip { + font-size: 0.62rem; + padding: 0.28em 0.45em 0.24em; + } +} + +@media (prefers-reduced-motion: reduce) { + #markdownBody .vita-chip, + #markdownBody .vita-quiet a.vita-location { + transition: none; + } +} diff --git a/static/css/viz.css b/static/css/viz.css new file mode 100644 index 0000000..b0cdd6f --- /dev/null +++ b/static/css/viz.css @@ -0,0 +1,86 @@ +/* viz.css — Styles for inline data visualizations (.viz-figure, .viz-interactive) */ + +/* ============================================================ + Static figures (Matplotlib SVG) + ============================================================ */ + +.viz-figure { + margin: 2rem 0; + break-inside: avoid; +} + +.viz-figure svg { + width: 100%; + height: auto; + display: block; +} + +/* Force labels and text glyphs to use currentColor instead of default/hardcoded black */ +.viz-figure svg g[id^="text_"] path, +.viz-figure svg g[id^="text_"] use { + fill: currentColor !important; +} + +/* Ensure axis lines and ticks also follow currentColor */ +.viz-figure svg g[id^="axes_"] path, +.viz-figure svg g[id^="xtick_"] line, +.viz-figure svg g[id^="ytick_"] line, +.viz-figure svg g[id^="xtick_"] use, +.viz-figure svg g[id^="ytick_"] use { + stroke: currentColor !important; +} + +/* Catch explicit styles on axes paths */ +.viz-figure svg g[id^="patch_"] path[style*="stroke: #000000"] { + stroke: currentColor !important; +} + +/* ============================================================ + Interactive figures (Vega-Lite via vega-embed) + ============================================================ */ + +.viz-interactive { + margin: 2rem 0; + break-inside: avoid; +} + +.vega-container { + width: 100%; + overflow: hidden; +} + +/* vega-embed injects a
        containing a or */ +.vega-container > div { + width: 100% !important; +} + +.vega-container canvas, +.vega-container svg { + max-width: 100%; + display: block; +} + +/* ============================================================ + Captions (shared) + ============================================================ */ + +.viz-caption { + font-size: var(--text-size-small); + color: var(--text-muted); + text-align: center; + margin-top: 0.5rem; + font-style: italic; +} + +/* ============================================================ + Error display (build-time script failures) + ============================================================ */ + +.viz-error { + padding: 0.75rem 1rem; + border: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--text-size-small); + margin: 1.5rem 0; +} diff --git a/static/css/work.css b/static/css/work.css new file mode 100644 index 0000000..a757d5a --- /dev/null +++ b/static/css/work.css @@ -0,0 +1,382 @@ +/* work.css — /work.html, the professional front door. + * + * This page has a different job from every other surface on the site. Vita + * and the project index are RECORD pages: generated from the CV data, complete, + * and unranked. Work is an ARGUMENT page — hand-written, deliberately partial, + * and ordered. Four entries carry it, and a reader must be able to tell within + * a screen what the claim is and where the evidence lives. + * + * SPECIFICITY NOTE. Every selector below is prefixed with #markdownBody for the + * same reason vita.css does it: typography.css owns the prose surface through + * #markdownBody (1,0,1), including :is(h1..h6) at display sizes and the 1.5em + * text-indent on `p + p` that essay prose relies on. A bare .work-entry (0,1,0) + * loses to that regardless of load order — entry headings would render at essay + * scale and every second paragraph inside a card would sit indented like + * running prose. The prefix is load-bearing, not habit. + */ + +/* ============================================================ + HEADER + page.html emits

        $title$

        from the + frontmatter, which is "Work" — correct for the nav label and + the tag, but a weak thing to lead with on a page people + land on cold from an application link with no context. So the + h1 demotes to a small-caps eyebrow and the name below it takes + the display slot, which is what every research page worth + copying does. The h1 keeps its text and its place in the + document outline; only its visual weight changes. + ============================================================ */ + +#markdownBody .page-title { + margin: 0 0 0.35rem; + font-family: var(--font-serif); + font-size: 0.85rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.12em; + font-weight: 400; + color: var(--text-faint); +} + +#markdownBody .work-name { + margin: 0 0 0.9rem; + font-family: var(--font-serif); + font-size: 2rem; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.01em; + color: var(--text); +} + +#markdownBody .work-name p { + margin: 0; + text-indent: 0; +} + +/* ============================================================ + LEDE + Identity and availability, above the fold. Serif, slightly + larger than body, and never indented — this is the 15-second + read and it must not look like the start of an essay. + ============================================================ */ + +#markdownBody .work-lede { + margin: 0 0 1.5rem; + font-family: var(--font-serif); + font-size: 1.02rem; + line-height: 1.55; +} + +#markdownBody .work-lede p { + margin: 0 0 0.75rem; + text-indent: 0; +} + +#markdownBody .work-lede p:last-child { + margin-bottom: 0; +} + +/* The availability line is the one piece of this page that a recruiter must + not be able to miss, so it gets a rule and the UI face rather than serif. */ +#markdownBody .work-lede p:last-child strong { + display: block; + margin-top: 1.15rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-family: var(--font-ui); + font-size: 0.92rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +/* ============================================================ + LINK ROW + Primary destinations. Monospace to read as apparatus rather + than prose, and wrapped so it degrades to two lines on phones + instead of overflowing. + ============================================================ */ + +#markdownBody .work-links { + margin: 0 0 1.9rem; + padding: 0.6rem 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.8; +} + +#markdownBody .work-links p { + margin: 0; + text-indent: 0; +} + +/* ============================================================ + SECTION HEADINGS + Borrowed from library.css's shelf headings: serif small-caps, + letterspaced, set at body scale rather than display scale. + The library uses this to get a fine-press feel; here it does + the same job while staying quieter than typography.css's + default h2, which lands at display size with 2.475rem margins + and would shout on a page whose whole argument is restraint. + The library's warm accent palette is deliberately NOT carried + over — a professional front door wants the typographic idiom + without the decoration. + ============================================================ */ + +#markdownBody h2 { + margin: 0 0 1.1rem; + font-family: var(--font-serif); + font-size: 1.05rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.09em; + font-weight: 400; + color: var(--text-muted); +} + +/* ============================================================ + THESIS + The position statement. Every strong research page consulted + (Crichton's "My angle", Shriram's "My Focus") states one + before listing artifacts; without it four projects read as + four hobbies rather than one direction. Set at lede scale so + it carries weight, with the opening question emphasized. + ============================================================ */ + +/* Deliberately NO left rule here, unlike .work-entry. The rule is the + device that marks a project card; wearing it would make the page's + central claim read as a fifth entry rather than the frame the other + four sit inside. */ +#markdownBody .work-thesis { + margin: 0 0 2.5rem; + font-family: var(--font-serif); + font-size: 1.02rem; + line-height: 1.6; +} + +#markdownBody .work-thesis p { + margin: 0 0 0.8rem; + text-indent: 0; +} + +#markdownBody .work-thesis p:first-child { + font-style: italic; + color: var(--text); +} + +#markdownBody .work-thesis p:last-child { + margin-bottom: 0; + color: var(--text-muted); +} + +/* ============================================================ + FRAMING NOTE + Tells the reader the hierarchy is deliberate and where the + complete records are. Muted: it is scaffolding, not content. + ============================================================ */ + +#markdownBody .work-note { + margin: 0 0 2.25rem; + font-family: var(--font-serif); + font-size: 0.88rem; + line-height: 1.55; + color: var(--text-faint); +} + +#markdownBody .work-note p { + margin: 0; + text-indent: 0; +} + +/* ============================================================ + ENTRIES + One per flagship project. The left rule is the only ornament: + it groups the heading, prose, limitation, and links into a + single scannable block without drawing a box around them. + ============================================================ */ + +#markdownBody .work-entry { + margin: 0 0 1.7rem; + padding-left: 1.25rem; + border-left: 2px solid var(--border); +} + +#markdownBody .work-entry:hover { + border-left-color: var(--border-muted); + transition: border-color var(--transition-fast); +} + +#markdownBody .work-entry h3 { + margin: 0 0 0.2rem; + font-family: var(--font-serif); + font-size: 1.14rem; + font-weight: 600; + line-height: 1.3; + letter-spacing: 0; +} + +#markdownBody .work-entry p { + margin: 0 0 0.6rem; + text-indent: 0; +} + +/* Affiliation / status line under the entry title. */ +#markdownBody .work-meta { + margin: 0 0 0.6rem; + font-family: var(--font-ui); + font-size: 0.78rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-faint); +} + +#markdownBody .work-meta p { + margin: 0; + text-indent: 0; +} + +/* ============================================================ + LIMITATIONS + Every entry carries one. This is a credibility device, not a + disclaimer to bury — it is set on the offset ground so a + skeptical reader finds it without hunting, but stays quieter + than the claim it qualifies. + ============================================================ */ + +#markdownBody .work-limit { + margin: 0 0 0.6rem; + padding: 0; + background: none; + border-left: none; + font-size: 0.85rem; + line-height: 1.45; + color: var(--text-faint); +} + +#markdownBody .work-limit p { + margin: 0; + text-indent: 0; +} + +#markdownBody .work-limit strong { + color: var(--text); +} + +/* ============================================================ + ENTRY LINKS + Evidence for the entry directly above. Monospace, matching the + top link row, so "where the proof is" reads consistently. + ============================================================ */ + +#markdownBody .work-entry-links { + margin: 0; + font-family: var(--font-mono); + font-size: 0.76rem; + line-height: 1.8; +} + +#markdownBody .work-entry-links p { + margin: 0; + text-indent: 0; +} + +/* ============================================================ + MORE WORK + Breadth, below depth. Each bucket is one paragraph with a bold + lead-in; no rules and no cards, so it stays visibly subordinate + to the four entries above. + ============================================================ */ + +#markdownBody .work-more { + font-size: 0.92rem; + line-height: 1.5; +} + +/* Bucket labels. Same small-caps idiom as the h2s but a step down, + so the four buckets read as subordinate to "More work" itself. */ +#markdownBody .work-more h3 { + margin: 1.6rem 0 0.5rem; + font-family: var(--font-serif); + font-size: 0.95rem; + font-variant: all-small-caps; + font-feature-settings: "smcp" 1; + letter-spacing: 0.07em; + font-weight: 400; + color: var(--text-faint); +} + +#markdownBody .work-more h3:first-child { + margin-top: 0; +} + +/* Title — one-phrase description, after Matuschak. The em-dash split + does the work a paragraph was doing before, in a fifth of the height. */ +#markdownBody .work-more ul { + margin: 0; + padding-left: 1.1rem; + list-style: none; +} + +#markdownBody .work-more li { + margin: 0 0 0.3rem; + text-indent: -1.1rem; + padding-left: 1.1rem; + color: var(--text-muted); +} + +#markdownBody .work-more li::before { + content: "·"; + display: inline-block; + width: 1.1rem; + margin-left: -1.1rem; + color: var(--text-faint); +} + +#markdownBody .work-more li a { + color: var(--text); +} + +/* ============================================================ + NARROW SCREENS + Below the tablet breakpoint the left rules cost more in + horizontal space than they return in grouping, so they thin + out and the indent collapses. + ============================================================ */ + +@media (max-width: 680px) { + #markdownBody .work-entry { + padding-left: 0.85rem; + } + + #markdownBody .work-links, + #markdownBody .work-entry-links { + font-size: 0.72rem; + } + + #markdownBody .work-lede { + font-size: 1rem; + } + + #markdownBody .work-name { + font-size: 1.6rem; + } +} + +/* Print: the page is a plausible thing to hand to someone on paper, so drop + the interactive ornament and let the offset grounds go white. */ +@media print { + #markdownBody .work-entry, + #markdownBody .work-limit { + border-left: none; + padding-left: 0; + } + + /* Entry links stay visible on paper — the URLs are the evidence, and a + printed copy is worth less without them. */ + #markdownBody .work-entry-links a::after { + content: " (" attr(href) ")"; + font-size: 0.85em; + color: #555; + } +} diff --git a/static/cv.pdf b/static/cv.pdf new file mode 100644 index 0000000..053a6c4 Binary files /dev/null and b/static/cv.pdf differ diff --git a/static/cv.thumb.png b/static/cv.thumb.png new file mode 100644 index 0000000..aa16ab9 Binary files /dev/null and b/static/cv.thumb.png differ diff --git a/static/favicon-96x96.png b/static/favicon-96x96.png new file mode 100644 index 0000000..dff8b75 Binary files /dev/null and b/static/favicon-96x96.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..c6b95f0 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..d83b053 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 831 832"> + <style> + :root { --ink:#2a2622; --bg:#faf8f2; } + @media (prefers-color-scheme: dark) { :root { --ink:#e8e4da; --bg:#16140f; } } + </style> + <g transform="translate(0,832) scale(0.1,-0.1)" stroke="none"> + <path d="M6800 8299 c-81 -9 -122 -16 -405 -73 -404 -81 -724 -212 -1012 -413 -44 -30 -27 24 37 120 21 31 30 37 63 37 50 0 60 25 24 56 -24 20 -26 27 -20 73 6 51 6 51 -23 51 -21 0 -35 -8 -49 -30 -18 -26 -23 -29 -62 -23 -34 5 -44 2 -49 -11 -7 -19 37 -72 68 -82 22 -7 23 -32 2 -62 -12 -17 -19 -20 -34 -12 -43 23 -145 4 -134 -24 7 -17 64 -39 91 -34 12 2 17 -2 14 -12 -3 -8 -9 -38 -15 -66 -8 -45 -16 -55 -60 -87 -28 -21 -62 -47 -76 -60 -77 -69 -94 -77 -110 -47 -9 16 -4 26 30 60 55 55 51 100 -9 100 -35 -1 -58 -20 -67 -55 -7 -27 -8 -27 -15 -8 -7 18 12 129 22 136 2 2 16 8 32 15 27 11 37 45 18 57 -5 3 -7 21 -4 40 6 37 -7 43 -106 48 -73 3 -77 -18 -20 -112 26 -44 19 -87 -12 -71 -51 28 -127 -14 -85 -46 11 -8 36 -14 56 -14 35 0 36 -1 32 -29 l-4 -30 -77 0 c-125 0 -160 31 -125 114 29 69 -14 131 -56 80 -7 -8 -27 -15 -44 -15 -70 0 -74 -49 -10 -110 l45 -43 -48 -26 c-64 -35 -72 -68 -16 -73 29 -2 39 -8 41 -23 10 -55 58 -42 74 21 l12 44 71 -1 c52 0 64 -3 47 -9 -23 -10 -50 -55 -39 -66 11 -11 54 5 85 31 40 34 55 32 91 -11 55 -68 54 -72 -65 -198 l-107 -113 -49 36 c-38 29 -46 40 -41 56 18 50 10 80 -22 83 -47 5 -78 -29 -69 -75 l7 -36 -42 6 c-70 9 -77 22 -36 70 38 44 42 59 23 75 -14 11 -65 5 -74 -9 -10 -16 -33 -9 -39 11 -6 20 -45 27 -79 14 -21 -8 -21 -53 0 -83 16 -22 15 -24 -20 -53 -55 -46 -50 -72 15 -66 28 3 57 12 66 21 14 13 20 14 54 2 42 -15 41 -15 66 -15 23 0 -143 -80 -187 -91 -42 -10 -102 6 -127 33 -16 18 -15 19 5 34 27 19 27 28 3 51 -11 10 -21 34 -23 53 -4 43 -38 53 -72 21 -12 -12 -25 -18 -29 -15 -3 4 -18 4 -32 0 -48 -12 -14 -116 39 -116 13 0 56 -57 56 -74 0 -3 -8 -6 -19 -6 -20 0 -71 -36 -71 -51 0 -19 79 -9 121 16 59 34 124 35 115 3 -4 -13 -8 -30 -9 -38 -3 -21 37 -25 53 -5 17 21 30 19 30 -4 0 -26 14 -34 50 -26 34 7 43 29 41 97 -1 48 -1 48 50 73 36 19 62 25 92 23 l42 -4 -30 -12 c-52 -21 -80 -40 -83 -56 -5 -23 49 -21 100 4 42 20 57 16 58 -17 0 -26 -172 -254 -187 -249 -8 3 -21 6 -29 6 -10 0 -14 12 -14 40 0 39 -1 40 -33 40 -21 0 -41 -8 -52 -20 -14 -15 -26 -18 -59 -14 -47 6 -81 -12 -71 -38 7 -17 35 -28 72 -28 20 0 21 -2 11 -25 -7 -16 -8 -29 -2 -35 12 -12 78 5 99 26 18 19 49 15 53 -7 2 -9 -24 -64 -58 -122 -33 -57 -60 -109 -60 -114 0 -18 -36 -43 -63 -43 -38 0 -42 17 -7 35 30 16 41 50 18 57 -9 3 -9 12 0 38 22 63 17 83 -20 76 -29 -6 -42 -16 -51 -40 -2 -5 -13 -2 -26 5 -23 14 -23 20 -5 92 6 26 5 27 -29 27 -19 0 -37 -6 -39 -12 -4 -10 -10 -10 -32 0 -53 24 -93 -40 -51 -82 14 -14 14 -18 0 -40 -29 -44 1 -55 62 -23 42 22 41 23 53 -23 13 -47 7 -60 -24 -60 -25 0 -76 -44 -76 -66 0 -18 16 -18 74 2 44 15 48 15 66 -1 11 -10 37 -18 60 -19 48 -1 48 2 6 -88 -84 -183 -114 -255 -186 -451 -32 -88 -61 -161 -64 -164 -8 -9 -35 16 -65 61 -45 66 -44 69 10 131 28 32 49 65 47 74 -5 23 -61 27 -83 4 -17 -17 -18 -16 -12 17 4 19 10 58 13 87 l7 51 53 -5 c36 -4 59 -2 69 7 19 16 20 66 0 74 -9 3 -15 18 -15 39 0 60 -55 80 -86 31 -20 -30 -28 -21 -39 44 -6 40 -4 47 23 75 35 36 66 102 56 119 -4 6 -23 8 -48 5 -41 -6 -42 -5 -44 25 -6 79 -74 62 -96 -24 -3 -16 -13 -28 -21 -28 -8 0 -18 -12 -21 -27 -7 -28 14 -53 47 -53 14 0 28 -26 29 -52 0 -5 -13 -8 -30 -8 -33 0 -72 -32 -64 -53 2 -7 31 -26 64 -43 l59 -29 0 -60 0 -60 -18 23 c-22 26 -72 30 -91 7 -17 -21 2 -65 50 -113 34 -34 37 -41 31 -76 l-6 -39 -29 37 c-15 21 -46 50 -68 66 l-40 30 -2 72 c-1 83 -8 98 -40 98 -33 0 -56 -40 -56 -95 0 -55 -26 -61 -44 -10 -29 78 -44 95 -76 84 -27 -10 -30 -15 -30 -52 0 -37 -4 -44 -33 -58 -52 -25 -48 -49 8 -49 46 0 46 0 31 -22 -9 -12 -16 -35 -16 -50 0 -55 99 -23 114 37 9 34 49 33 104 -2 50 -33 51 -35 12 -50 -69 -26 -23 -72 59 -59 38 6 40 4 60 -34 12 -22 21 -42 21 -44 0 -2 -21 -7 -47 -11 -118 -17 -181 -95 -78 -95 54 0 55 0 55 -30 0 -36 27 -41 55 -10 12 13 25 18 41 14 17 -5 30 1 46 19 l23 25 42 -44 c52 -53 52 -38 -5 -207 l-47 -138 -76 -3 -77 -3 19 41 c28 62 19 126 -19 141 -23 9 -62 -22 -62 -49 0 -18 -10 -29 -37 -43 -51 -24 -59 -76 -18 -109 l26 -20 -85 -38 c-108 -48 -156 -39 -156 29 0 29 4 34 29 40 32 7 81 50 81 70 0 22 -29 37 -60 33 -19 -3 -30 0 -30 8 0 6 -8 22 -19 35 -17 21 -17 24 -2 51 21 36 6 63 -37 63 -17 0 -34 3 -38 8 -12 11 -59 8 -72 -5 -17 -17 -15 -51 5 -87 17 -29 17 -31 -1 -44 -46 -34 6 -71 65 -46 42 17 45 7 26 -66 -17 -63 -15 -106 6 -132 12 -16 -39 -18 -79 -3 -21 8 -24 14 -16 22 20 20 15 58 -8 58 -13 0 -20 7 -20 19 0 43 -66 44 -77 1 -7 -27 -26 -23 -82 17 -2 1 4 21 14 43 22 48 10 70 -40 70 -36 0 -65 -26 -65 -58 0 -17 -6 -22 -25 -22 -22 0 -24 4 -27 58 -3 62 -22 76 -61 43 -16 -13 -21 -13 -46 2 -65 38 -94 9 -63 -64 21 -50 21 -50 1 -70 -48 -48 -4 -65 87 -33 38 13 70 24 72 24 7 0 1 -29 -7 -32 -5 -1 -13 -16 -17 -32 -7 -28 -6 -29 25 -24 24 4 35 0 42 -14 20 -36 60 -13 85 50 1 1 15 -5 33 -15 31 -18 42 -43 18 -43 -19 0 -57 -43 -57 -63 0 -25 39 -21 95 9 45 23 51 24 97 13 56 -15 55 -12 23 -64 -34 -54 -32 -59 20 -52 28 4 45 3 45 -4 0 -6 16 -9 38 -7 l37 3 0 57 0 56 38 6 c21 4 66 18 99 31 71 29 71 29 58 -16 -6 -18 -10 -51 -10 -72 0 -21 -9 -56 -20 -78 l-21 -39 -29 22 c-24 18 -35 20 -57 12 -36 -12 -42 -42 -12 -65 l23 -17 -25 -12 c-22 -10 -31 -8 -60 9 -66 40 -139 15 -139 -49 0 -24 5 -33 20 -37 14 -4 20 -12 18 -27 -3 -19 1 -21 40 -18 35 2 42 -1 42 -16 0 -10 6 -26 13 -36 29 -39 79 19 79 91 0 26 7 37 39 57 21 14 41 26 44 26 3 0 20 13 38 30 26 24 33 27 35 14 4 -19 61 -12 74 9 16 25 8 66 -18 91 -24 22 -26 28 -19 72 13 90 99 143 178 110 l36 -15 -15 -53 c-9 -29 -42 -142 -75 -252 -32 -109 -61 -214 -65 -232 -3 -19 -10 -36 -14 -39 -16 -10 -306 29 -525 72 -182 35 -316 70 -610 160 -146 44 -448 165 -634 254 -56 27 -105 49 -107 49 -3 0 -85 43 -182 97 -97 53 -195 106 -216 117 -44 24 -121 99 -201 196 -146 178 -255 385 -336 635 -63 195 -106 299 -152 368 -146 220 -404 266 -569 103 -136 -136 -172 -346 -87 -520 39 -80 92 -133 255 -253 16 -12 67 -50 114 -84 140 -103 532 -369 545 -369 2 0 46 -26 97 -58 51 -32 125 -76 163 -97 39 -21 111 -61 160 -88 50 -28 113 -62 140 -75 191 -93 336 -161 440 -204 66 -27 145 -60 175 -73 337 -146 942 -300 1393 -356 82 -10 97 -14 93 -28 -51 -178 -49 -175 -95 -142 -15 11 -39 23 -54 26 -23 6 -25 11 -20 36 10 52 -38 78 -83 45 -14 -10 -23 -11 -32 -4 -9 8 -20 8 -37 0 -32 -15 -35 -64 -5 -85 18 -12 18 -16 5 -33 -37 -48 11 -61 73 -19 41 28 54 30 84 14 19 -10 19 -12 -11 -30 -61 -37 -22 -68 43 -34 23 12 32 13 43 3 18 -15 17 -24 -41 -223 -74 -258 -101 -341 -108 -343 -11 -2 -107 42 -108 49 0 4 11 10 25 13 35 9 65 62 65 113 0 45 -20 66 -52 54 -20 -7 -78 -79 -78 -97 0 -7 -5 -16 -12 -20 -8 -5 -10 12 -5 66 6 68 9 76 38 99 58 45 73 131 23 131 -13 0 -34 -10 -46 -22 -20 -20 -22 -20 -35 -3 -19 26 -16 43 7 50 30 9 70 53 70 75 0 24 -29 40 -72 40 -32 0 -33 1 -34 43 -1 24 -7 53 -13 65 -24 44 -101 17 -101 -36 0 -17 -7 -21 -36 -24 -80 -7 -61 -91 30 -136 34 -17 54 -36 76 -75 45 -79 43 -82 -48 -82 l-77 0 -24 52 c-25 54 -52 70 -94 57 -30 -9 -37 -23 -26 -52 8 -20 6 -27 -11 -39 -14 -10 -20 -24 -18 -41 3 -26 5 -27 78 -30 41 -1 101 3 134 9 48 9 61 8 73 -4 13 -13 10 -17 -24 -33 -46 -23 -73 -67 -44 -72 11 -2 39 6 62 17 24 12 44 21 46 21 2 0 3 -23 3 -51 0 -35 6 -60 20 -79 l20 -28 -23 -6 c-57 -15 -81 -7 -107 34 -25 40 -52 50 -94 34 -18 -7 -21 -45 -5 -61 9 -9 6 -19 -10 -39 -29 -36 -14 -51 34 -37 30 9 37 9 41 -3 2 -8 6 -21 9 -29 11 -35 70 -8 91 41 9 22 21 34 34 34 23 0 25 -17 5 -44 -20 -27 -20 -85 1 -93 22 -8 65 27 92 75 28 50 42 52 66 11 14 -24 17 -43 13 -80 -9 -72 -86 -311 -98 -303 -7 4 -7 10 0 17 6 6 11 26 11 44 0 28 -4 33 -24 33 -26 0 -42 15 -48 45 -10 56 -78 9 -78 -55 0 -52 -13 -51 -30 1 -8 23 -20 43 -27 46 -15 6 -18 33 -3 33 11 0 13 39 4 64 -9 24 -42 19 -70 -9 l-25 -25 -26 20 c-32 25 -84 26 -106 2 -23 -25 -21 -49 5 -63 20 -11 21 -12 4 -25 -41 -29 7 -44 65 -20 27 11 41 11 68 2 48 -16 48 -31 1 -45 -81 -24 -71 -69 13 -57 76 12 235 -39 251 -79 3 -8 -13 -69 -34 -137 -22 -67 -40 -129 -41 -138 0 -10 -5 -7 -17 10 -33 48 -84 90 -130 106 -45 16 -47 19 -44 51 4 31 2 33 -27 33 -22 0 -31 5 -31 16 0 9 -10 34 -22 55 -18 33 -25 38 -47 32 -39 -9 -46 -20 -46 -73 0 -34 -4 -50 -13 -50 -19 0 -85 36 -120 65 l-30 25 29 29 c45 46 37 141 -14 160 -14 5 -27 19 -30 30 -5 22 -49 29 -73 13 -19 -11 -31 -74 -23 -112 7 -34 -1 -49 -27 -50 -7 0 -19 -11 -28 -25 -24 -36 9 -69 62 -63 31 4 42 0 64 -23 15 -16 42 -36 60 -45 l33 -17 -33 -14 c-28 -12 -35 -11 -48 2 -18 18 -44 19 -79 3 -25 -11 -34 -41 -15 -53 6 -4 8 -21 4 -41 -7 -38 3 -43 46 -21 21 11 29 11 47 -1 44 -27 79 6 61 57 -11 32 -5 36 57 36 73 0 87 -16 40 -44 -20 -11 -33 -26 -30 -31 12 -20 81 -11 120 16 40 27 71 26 129 -3 l28 -15 -52 -34 c-29 -19 -62 -46 -73 -61 -20 -25 -21 -25 -28 -4 -10 26 -40 27 -63 2 -15 -16 -22 -18 -41 -9 -81 37 -63 -75 20 -124 40 -23 40 -36 1 -95 -27 -42 -27 -42 -59 22 -27 56 -37 61 -84 49 -53 -14 -9 -134 49 -134 30 0 11 -19 -26 -26 -48 -9 -99 19 -141 76 -20 27 -48 53 -64 58 -24 8 -27 14 -23 40 6 46 -13 74 -51 70 -48 -5 -68 -47 -44 -90 11 -18 14 -34 9 -36 -6 -2 -20 -12 -33 -23 -29 -25 -36 -24 -54 10 -17 34 -42 45 -78 36 -44 -11 -43 -59 1 -98 19 -18 19 -18 -9 -47 -40 -39 -37 -53 8 -48 21 3 46 8 55 12 13 6 19 2 24 -19 5 -21 11 -26 33 -23 24 3 26 7 29 58 3 71 10 100 23 100 6 0 8 -9 4 -20 -9 -27 33 -28 66 -1 l24 18 41 -44 42 -44 -39 -19 c-59 -30 -91 -92 -55 -106 19 -7 70 20 94 51 25 32 42 32 42 0 0 -20 5 -25 24 -25 25 0 36 16 36 55 0 20 52 45 93 45 3 0 8 -17 12 -37 9 -52 31 -83 59 -83 56 0 73 72 32 133 l-25 36 29 33 29 32 6 -27 c10 -40 58 -38 75 3 14 35 6 92 -15 110 -21 17 -19 43 9 98 53 105 117 108 168 8 25 -50 21 -95 -21 -226 -29 -92 -122 -405 -155 -522 -11 -39 -22 -58 -32 -58 -12 0 -14 5 -9 18 4 9 10 30 13 46 5 28 4 29 -32 22 -33 -6 -38 -4 -43 13 -13 52 -86 3 -82 -54 4 -44 -5 -52 -35 -31 -26 16 -46 69 -46 119 0 24 3 27 36 27 87 0 103 74 22 103 -14 5 -18 18 -18 51 0 86 -40 106 -80 41 -17 -27 -28 -35 -51 -35 -40 0 -69 -16 -69 -39 0 -21 53 -71 76 -71 8 0 14 -8 14 -17 0 -10 3 -35 6 -56 l7 -37 -40 0 c-26 0 -50 -8 -67 -21 -46 -36 -25 -95 25 -69 18 10 19 7 19 -40 0 -41 3 -50 18 -50 24 0 62 28 62 45 0 23 18 27 42 11 12 -9 32 -16 44 -16 30 0 35 -9 19 -34 -7 -11 -16 -36 -20 -54 -14 -70 76 -25 111 56 8 17 21 32 29 32 16 0 47 -39 39 -52 -3 -4 -21 -63 -40 -131 -54 -191 -54 -192 -95 -156 -13 12 -40 33 -59 48 -19 14 -37 29 -40 32 -3 3 -43 34 -90 67 -47 34 -89 65 -95 70 -63 52 -297 189 -421 246 -230 105 -416 146 -666 146 -790 0 -1281 -451 -1263 -1160 13 -500 267 -923 679 -1129 239 -119 375 -145 751 -146 423 0 696 37 1085 149 228 65 596 209 630 247 3 3 29 18 59 33 49 25 238 148 301 195 46 35 167 111 176 111 5 0 34 -17 64 -37 56 -37 173 -111 265 -166 399 -237 905 -410 1341 -457 275 -30 735 5 924 70 14 5 63 20 110 35 106 33 289 114 398 177 46 26 86 48 88 48 11 0 235 159 303 215 124 102 331 312 408 414 10 13 30 37 44 55 14 17 41 54 60 81 19 28 36 52 39 55 3 3 26 37 51 75 25 39 79 122 120 185 113 173 156 276 185 442 49 280 -56 450 -269 436 -220 -14 -346 -219 -442 -723 -96 -501 -134 -596 -299 -760 -667 -661 -1489 -842 -2363 -520 -240 89 -873 448 -873 496 0 4 26 28 58 54 117 95 292 274 457 465 86 100 233 308 347 490 55 88 228 402 228 414 0 2 24 55 54 117 218 461 395 1028 597 1914 67 291 117 517 131 587 l11 58 71 27 c72 28 403 191 486 239 64 37 224 137 230 144 3 3 41 30 85 59 262 178 591 491 786 746 297 390 439 754 439 1126 0 453 -208 762 -602 893 -155 52 -414 76 -598 55z m577 -300 c83 -28 164 -90 229 -174 31 -41 63 -184 70 -314 11 -194 -37 -462 -125 -700 -127 -346 -501 -818 -871 -1102 -25 -19 -47 -37 -50 -40 -51 -63 -635 -417 -655 -397 -4 4 18 107 50 230 164 650 327 1120 542 1558 57 118 109 217 114 218 5 2 9 8 9 14 0 13 158 274 217 358 94 136 215 289 280 357 26 27 96 24 190 -8z m-357 -34 c0 -2 -19 -28 -43 -57 -83 -103 -252 -341 -293 -413 -6 -11 -42 -72 -79 -135 -281 -479 -523 -1126 -723 -1937 l-57 -232 -120 -50 c-168 -68 -462 -167 -472 -158 -14 15 242 812 313 972 7 17 26 67 43 110 91 246 258 593 427 885 27 47 52 90 57 95 4 6 54 80 111 167 259 391 457 584 731 714 89 42 105 48 105 39z m-1264 -3076 c-236 -1090 -426 -1774 -613 -2214 -89 -207 -189 -419 -241 -510 -6 -11 -43 -76 -83 -145 -105 -184 -258 -408 -368 -540 -25 -30 -55 -65 -66 -79 -134 -163 -315 -334 -235 -221 61 86 178 354 249 573 110 338 405 1477 581 2247 17 74 68 283 112 463 79 315 83 328 108 333 112 24 318 80 430 117 74 24 136 43 137 42 2 -1 -4 -30 -11 -66z m-3986 -2559 c106 -26 276 -107 412 -196 57 -37 105 -70 108 -74 3 -3 46 -37 95 -75 82 -62 224 -175 251 -200 15 -13 108 -92 136 -115 l22 -17 -28 -74 c-15 -41 -30 -84 -33 -97 -8 -33 -47 -26 -51 9 -8 66 -94 83 -100 20 -2 -18 1 -38 7 -44 17 -17 13 -27 -12 -27 -20 0 -23 4 -19 30 4 25 0 30 -24 36 -15 4 -39 20 -52 36 -52 62 -136 7 -87 -58 19 -26 19 -41 -2 -49 -10 -3 -27 -12 -37 -20 -49 -37 50 -63 102 -27 25 17 85 23 98 9 4 -3 -1 -13 -10 -22 -24 -25 -21 -55 7 -55 27 0 103 61 112 90 8 26 35 26 35 0 0 -23 -123 -309 -188 -434 -26 -50 -45 -95 -44 -99 5 -16 -58 34 -69 54 -18 34 18 93 61 100 70 11 102 46 63 68 -15 8 -16 13 -4 33 36 69 -21 117 -78 65 -14 -13 -34 -27 -44 -30 -26 -10 -32 -35 -12 -57 16 -18 16 -21 -5 -55 -26 -42 -36 -44 -44 -9 -5 17 1 45 16 81 27 66 19 88 -33 88 -26 0 -36 -6 -44 -22 -18 -42 -45 -29 -45 21 0 36 -4 46 -21 51 -22 7 -79 -12 -79 -26 0 -5 -11 -9 -24 -9 -13 0 -29 -7 -37 -16 -18 -22 -4 -42 50 -71 1 -1 -4 -12 -13 -25 -20 -29 -20 -57 0 -65 16 -6 70 28 92 58 20 29 39 12 66 -62 14 -39 26 -72 26 -74 0 -9 -161 8 -207 22 -81 26 -132 -9 -86 -59 9 -10 27 -18 40 -18 22 0 23 -3 17 -35 -6 -34 -5 -35 24 -35 24 0 40 11 74 50 43 49 45 50 76 39 39 -13 39 -16 7 -41 -16 -13 -25 -29 -25 -49 0 -36 18 -38 92 -8 52 20 110 20 123 -1 5 -8 -54 -109 -80 -135 -5 -6 -41 -17 -79 -24 l-68 -14 -30 32 c-50 54 -146 28 -110 -30 11 -18 10 -23 -12 -40 -72 -57 -4 -64 142 -15 60 21 102 30 102 24 0 -17 -168 -179 -224 -215 -118 -78 -249 -107 -491 -107 -194 0 -256 8 -407 54 -205 63 -387 178 -496 314 -69 86 -77 100 -110 202 -147 446 -44 944 249 1208 235 212 643 313 959 236z" fill="var(--ink)"/> + <path d="M5613 7535 c0 -22 2 -30 4 -17 2 12 2 30 0 40 -3 9 -5 -1 -4 -23z M4833 7015 c0 -27 2 -38 4 -22 2 15 2 37 0 50 -2 12 -4 0 -4 -28z M4880 6932 c0 -5 14 -7 30 -4 17 2 30 6 30 8 0 2 -13 4 -30 4 -16 0 -30 -4 -30 -8z M4671 6838 c-1 -9 3 -19 9 -23 5 -3 7 -13 4 -23 -5 -14 -4 -14 4 -2 6 8 7 19 3 25 -5 5 -11 17 -14 25 -4 12 -6 12 -6 -2z M4443 6510 c0 -30 2 -43 4 -27 2 15 2 39 0 55 -2 15 -4 2 -4 -28z M4530 6525 c-14 -17 -7 -45 12 -45 9 0 8 3 -2 10 -12 8 -13 13 -2 30 14 23 10 27 -8 5z M4586 6441 c-3 -5 0 -14 7 -19 10 -8 13 -6 14 9 2 19 -11 26 -21 10z M4353 6020 c0 -30 2 -43 4 -27 2 15 2 39 0 55 -2 15 -4 2 -4 -28z M4248 5763 c-2 -5 -1 -37 3 -73 l6 -65 1 73 c2 65 -1 85 -10 65z M3960 5445 c0 -8 2 -15 4 -15 2 0 13 -3 23 -6 17 -5 17 -5 0 6 -9 6 -15 15 -12 21 4 5 1 9 -4 9 -6 0 -11 -7 -11 -15z M4120 4890 c0 -22 3 -40 8 -40 4 0 6 18 4 40 -2 22 -6 40 -8 40 -2 0 -4 -18 -4 -40z M3920 4576 c-6 -8 -24 -17 -38 -19 l-27 -4 26 -2 c15 0 34 8 44 19 10 11 16 20 13 20 -3 0 -11 -6 -18 -14z M4161 4399 c-1 -33 35 -71 45 -46 3 9 4 22 1 29 -3 7 -6 2 -6 -11 -1 -32 -28 -11 -34 27 l-4 27 -2 -26z M3832 4113 c-2 -45 -5 -52 -24 -56 l-23 -4 23 -2 c28 -1 34 14 29 70 l-4 44 -1 -52z M3874 4085 c1 -12 9 -21 21 -22 14 -1 15 0 3 3 -9 3 -19 13 -21 22 -3 12 -4 11 -3 -3z M3654 3876 c-3 -8 -4 -25 -2 -38 3 -17 5 -13 9 15 5 38 2 47 -7 23z M3623 3535 c0 -33 2 -45 4 -27 2 18 2 45 0 60 -2 15 -4 0 -4 -33z M3554 3386 c-3 -8 -4 -25 -2 -38 3 -17 5 -13 9 15 5 38 2 47 -7 23z M3450 3372 c0 -5 14 -7 30 -4 17 2 30 6 30 8 0 2 -13 4 -30 4 -16 0 -30 -4 -30 -8z M3471 3272 c-1 -15 -5 -21 -13 -16 -10 5 -10 4 -1 -6 17 -18 26 -8 20 22 l-4 23 -2 -23z M3638 2783 c12 -2 30 -2 40 0 9 3 -1 5 -23 4 -22 0 -30 -2 -17 -4z M3649 2442 c5 -34 6 -35 9 -8 2 16 0 32 -5 37 -5 5 -7 -7 -4 -29z M3017 2213 c-4 -3 -7 -11 -7 -17 0 -7 3 -7 8 0 4 7 18 14 32 17 l25 4 -26 2 c-14 0 -29 -2 -32 -6z M3132 2191 c-7 -4 -25 -6 -40 -5 l-27 4 25 -9 c22 -7 60 2 60 14 0 7 -3 6 -18 -4z M3511 2156 c-8 -9 -8 -16 -2 -20 5 -4 12 -2 16 4 3 5 3 10 -2 11 -4 1 3 5 17 9 22 7 23 8 4 9 -12 1 -27 -5 -33 -13z M3598 1783 c12 -2 30 -2 40 0 9 3 -1 5 -23 4 -22 0 -30 -2 -17 -4z M3333 1285 c0 -22 2 -30 4 -17 2 12 2 30 0 40 -3 9 -5 -1 -4 -23z M2839 1067 c5 -5 24 -7 42 -5 33 3 33 4 -8 8 -23 3 -39 2 -34 -3z M2607 658 c2 -5 15 -8 29 -8 13 0 24 3 24 8 0 4 -13 7 -29 7 -16 0 -27 -3 -24 -7z" fill="var(--ink)"/> + <path d="M5346 7728 c-20 -29 -20 -45 0 -56 13 -7 15 -18 10 -46 -6 -30 -11 -36 -32 -36 -29 0 -58 -14 -97 -46 -31 -26 -75 -31 -109 -13 -25 13 -35 5 -18 -16 10 -12 9 -16 -4 -21 -16 -6 -16 -8 0 -25 18 -19 43 -84 44 -111 0 -12 -8 -18 -24 -18 -48 0 -89 -41 -117 -118 -31 -84 -43 -100 -84 -118 -31 -13 -28 -16 27 -28 19 -4 36 -21 54 -52 23 -39 26 -52 20 -97 -17 -126 -17 -125 -37 -120 -27 7 -68 -23 -84 -60 -7 -18 -18 -68 -25 -112 -13 -86 -26 -119 -59 -149 -19 -18 -20 -20 -4 -32 39 -28 76 24 93 132 15 96 28 135 58 166 34 37 46 25 67 -65 15 -62 41 -114 51 -104 2 2 -5 40 -16 86 -11 45 -20 87 -20 93 0 7 17 9 47 5 35 -4 54 -12 73 -34 14 -15 34 -35 45 -43 17 -14 18 -14 6 8 -17 32 -86 80 -135 94 -23 6 -42 12 -43 13 -2 1 2 17 8 36 14 46 13 123 -2 151 -6 13 -25 37 -41 55 -30 31 -30 31 -10 51 10 10 31 51 46 90 44 115 81 128 51 18 -11 -40 -15 -80 -12 -102 6 -37 44 -86 82 -107 11 -6 6 3 -12 22 -40 40 -49 83 -29 140 9 24 16 54 16 67 0 13 5 24 11 24 5 0 15 14 20 30 6 17 16 30 22 30 7 0 7 4 -1 12 -7 7 -12 21 -12 32 0 12 -9 40 -20 63 -10 24 -15 45 -10 48 8 5 53 -126 53 -155 0 -8 10 -26 22 -40 12 -14 31 -38 41 -54 29 -46 91 -86 134 -86 48 0 72 24 44 45 -16 12 -21 11 -36 -2 -29 -26 -69 -19 -101 17 -33 37 -31 45 7 30 40 -15 22 7 -19 24 -19 8 -41 28 -49 44 -14 28 -14 30 12 54 15 14 37 31 49 37 31 16 94 14 138 -5 56 -23 115 -2 68 24 -11 6 -23 6 -33 -1 -12 -7 -30 -5 -65 7 -64 21 -112 20 -160 -4 -39 -20 -54 -15 -55 16 0 6 -3 25 -6 41 -6 28 -4 31 29 37 19 4 52 20 72 36 21 17 41 30 45 30 4 0 10 -17 14 -38 3 -21 14 -46 24 -55 18 -16 18 -15 14 39 -3 41 -2 55 7 50 6 -4 11 -13 11 -21 0 -14 50 -78 56 -72 2 2 -5 21 -16 42 -22 43 -18 61 6 27 8 -12 20 -22 27 -22 6 0 -3 15 -21 33 -23 24 -32 42 -32 67 0 21 -9 44 -22 59 -13 14 -17 21 -9 14 11 -9 19 -8 37 4 23 14 25 13 61 -27 21 -23 51 -49 66 -57 26 -13 28 -19 24 -51 -3 -20 -8 -47 -12 -58 -5 -15 -2 -23 11 -28 24 -9 34 10 34 70 l0 51 66 12 c37 7 84 21 106 32 29 15 43 17 58 9 16 -8 20 -8 20 4 0 33 -69 47 -106 22 -33 -23 -130 -47 -173 -43 -33 3 -50 13 -92 56 -54 55 -48 61 24 24 43 -22 32 -7 -16 22 -58 33 -129 35 -151 3z M4812 7209 c26 -15 36 -65 30 -156 l-5 -83 -36 0 c-44 0 -95 -30 -119 -69 -15 -25 -19 -27 -32 -13 -11 12 -12 16 -1 20 7 2 28 18 47 34 31 28 33 32 17 43 -14 11 -24 7 -65 -23 -26 -20 -48 -40 -48 -45 0 -4 9 -20 19 -35 11 -15 32 -81 46 -147 23 -105 39 -144 53 -130 4 4 -21 132 -35 179 -4 16 0 15 29 -8 37 -27 110 -37 124 -15 13 22 -13 31 -49 18 -33 -12 -39 -11 -76 14 -23 15 -41 35 -41 43 0 13 3 14 14 5 11 -10 16 -8 21 8 10 32 82 91 111 91 15 0 41 -13 61 -30 18 -16 39 -30 46 -30 7 0 0 10 -15 21 -38 28 -35 39 11 39 45 0 60 15 39 36 -13 13 -18 13 -43 0 -41 -22 -48 -12 -40 58 15 119 15 117 -24 153 -20 18 -40 33 -46 33 -5 0 -2 -5 7 -11z M4502 6659 c-33 -26 -51 -46 -48 -55 7 -18 2 -18 -37 2 l-32 16 33 -30 c29 -28 32 -37 32 -87 0 -48 -5 -63 -30 -95 -16 -20 -34 -49 -40 -64 -13 -35 -13 -113 0 -121 17 -11 2 -24 -64 -54 -86 -41 -90 -47 -82 -131 6 -53 4 -76 -8 -99 -22 -42 -73 -75 -113 -74 -32 1 -33 0 -19 -16 9 -10 16 -23 16 -29 0 -6 15 -23 34 -39 41 -35 47 -52 38 -126 l-7 -58 -58 -24 c-70 -28 -93 -47 -109 -91 -6 -18 -19 -35 -27 -37 -11 -3 -7 -8 14 -18 17 -8 43 -23 59 -33 l28 -20 49 28 c66 37 128 34 147 -6 14 -30 32 -37 32 -13 0 8 -19 28 -42 45 -52 38 -105 40 -166 5 -23 -13 -49 -22 -57 -19 -18 7 -18 9 -1 42 22 41 58 71 113 92 44 17 53 17 69 5 21 -16 64 -20 64 -6 0 5 -7 14 -15 21 -13 11 -13 15 -3 21 23 15 60 10 138 -18 91 -32 112 -29 180 32 54 48 82 55 112 28 10 -9 18 -11 18 -5 0 19 -57 52 -89 52 -23 0 -45 -13 -87 -51 -53 -48 -60 -51 -98 -46 -22 3 -58 12 -79 21 l-39 17 46 41 c40 34 45 43 34 56 -11 13 -11 14 0 8 8 -5 21 -1 32 9 17 15 25 16 67 5 59 -15 114 0 93 25 -6 8 -24 14 -38 14 -61 0 -142 18 -164 36 -13 11 -25 21 -28 23 -2 2 2 7 9 11 8 5 24 3 40 -5 14 -8 37 -14 49 -14 36 0 21 24 -19 30 l-34 6 33 7 c17 4 46 10 63 13 17 3 53 26 81 51 55 49 89 65 115 57 16 -5 15 -3 -1 10 -27 22 -72 20 -112 -5 -34 -21 -34 -21 -34 0 0 11 4 30 9 43 8 21 19 25 100 37 78 11 90 15 80 27 -12 14 -66 13 -124 -3 -20 -6 -20 -5 -1 16 11 11 26 21 33 21 26 0 7 25 -21 28 -22 3 -36 -6 -67 -38 l-39 -41 -43 21 c-23 12 -51 28 -61 36 -19 14 -19 15 9 44 30 31 78 50 130 50 34 0 124 39 143 62 10 13 9 18 -3 28 -12 10 -23 7 -62 -18 -79 -49 -88 -49 -117 1 -14 24 -28 57 -32 73 -5 27 -2 32 25 45 41 20 144 14 195 -11 22 -11 41 -18 43 -17 8 8 -85 57 -120 62 -23 4 -47 11 -53 16 -6 5 -22 8 -35 7 -32 -4 -37 26 -7 46 19 12 32 13 69 4 57 -13 99 -4 99 21 0 31 -24 43 -38 20 -11 -17 -22 -19 -92 -17 -98 4 -105 19 -30 75 38 28 47 40 38 50 -16 20 -29 16 -86 -28z m-22 -308 c6 -16 23 -39 36 -52 28 -26 18 -39 -31 -39 -16 0 -42 -7 -57 -14 -27 -14 -28 -14 -28 7 0 28 46 127 58 127 5 0 15 -13 22 -29z m-20 -239 c50 -29 65 -43 64 -60 -4 -127 -3 -122 -31 -133 -16 -6 -49 -13 -74 -16 -55 -6 -58 -2 -66 102 -4 50 -11 76 -25 92 l-20 21 29 16 c40 22 50 20 123 -22z m-172 -44 c9 -9 12 -9 12 0 0 7 5 12 10 12 6 0 10 -22 10 -50 0 -27 7 -70 16 -96 19 -56 9 -68 -46 -58 -44 7 -44 8 -30 35 9 15 10 46 6 96 -7 72 -2 85 22 61z m5 -215 c3 -5 -4 -24 -14 -43 -11 -19 -19 -27 -19 -19 0 9 -9 28 -21 43 l-20 26 35 0 c19 0 37 -3 39 -7z m-73 -23 c10 -18 8 -19 -20 -14 -18 4 -36 1 -45 -6 -13 -10 -15 -9 -15 8 0 15 8 22 28 25 15 2 30 5 34 6 4 0 12 -8 18 -19z m179 -24 c32 -24 32 -25 12 -36 -12 -6 -21 -15 -21 -20 0 -15 -84 -89 -94 -83 -17 11 1 92 28 128 15 19 30 35 35 35 4 0 22 -11 40 -24z m-151 -177 c-28 -18 -35 -9 -30 41 3 32 0 55 -8 65 -10 13 -9 15 8 15 12 0 24 6 29 13 5 7 9 -17 11 -57 2 -49 -1 -72 -10 -77z M4610 6415 c13 -14 26 -25 28 -25 3 0 -5 11 -18 25 -13 14 -26 25 -28 25 -3 0 5 -11 18 -25z M4492 5455 c-57 -7 -107 -16 -112 -21 -5 -5 -17 -31 -27 -57 l-18 -48 -85 -4 c-47 -3 -104 -7 -126 -10 -39 -6 -43 -4 -63 29 -23 36 -76 86 -92 86 -5 0 -9 -18 -9 -41 0 -22 -5 -61 -12 -86 -10 -41 -9 -47 7 -59 11 -8 38 -14 62 -14 49 0 51 -5 23 -60 -25 -49 -25 -66 -1 -112 19 -35 19 -36 2 -52 -10 -9 -21 -34 -25 -55 -3 -21 -17 -55 -32 -76 -23 -35 -24 -39 -9 -54 15 -15 18 -14 41 14 13 17 26 46 29 65 7 43 17 62 35 68 18 6 7 -146 -14 -187 -12 -22 -13 -37 -5 -65 13 -49 1 -68 -63 -98 -29 -14 -62 -35 -72 -47 -12 -13 -31 -21 -52 -21 -32 0 -32 1 -24 33 6 19 30 51 60 78 47 43 63 80 41 93 -5 3 -36 -20 -69 -52 -62 -59 -81 -103 -69 -154 8 -33 -50 -76 -98 -72 -42 3 -50 -22 -10 -32 14 -3 25 -10 25 -14 0 -5 3 -16 7 -25 5 -15 3 -16 -18 -5 l-24 11 22 -18 c13 -11 24 -36 28 -63 8 -50 25 -83 63 -115 22 -19 25 -30 26 -91 l1 -69 -40 -3 c-50 -4 -55 -9 -80 -80 -15 -44 -25 -58 -43 -60 -17 -3 -22 2 -23 20 -2 40 2 62 16 85 12 18 12 23 -2 34 -13 11 -18 10 -35 -11 -15 -18 -19 -36 -16 -74 l3 -51 -38 -6 c-51 -8 -76 -33 -60 -58 10 -16 9 -18 -8 -14 -32 9 -21 -6 18 -24 43 -20 60 -50 69 -117 6 -43 5 -46 -15 -46 -34 0 -67 -41 -75 -92 -7 -44 -9 -47 -65 -72 -32 -14 -63 -26 -69 -26 -5 0 -14 -6 -20 -13 -8 -9 -2 -30 21 -77 30 -60 32 -71 33 -181 1 -65 5 -123 9 -129 4 -7 -5 -24 -21 -41 -31 -33 -77 -39 -104 -14 -15 14 -18 14 -23 1 -4 -9 4 -21 20 -31 14 -10 22 -12 18 -4 -6 8 -4 10 4 5 7 -5 10 -10 7 -13 -3 -3 4 -23 14 -44 27 -52 25 -110 -6 -180 -16 -36 -25 -69 -22 -88 3 -19 0 -27 -7 -25 -7 3 -19 -20 -30 -55 -15 -49 -16 -63 -5 -76 20 -24 -31 -20 -64 6 l-26 20 -10 -23 c-43 -95 -96 -255 -89 -264 12 -15 1 -24 -28 -24 -25 0 -30 -19 -11 -38 14 -14 32 -16 23 -2 -3 5 5 12 19 16 34 8 75 -23 76 -57 0 -38 36 -79 68 -79 43 0 101 19 190 62 45 22 88 38 96 35 7 -3 26 -37 40 -75 26 -67 62 -112 100 -126 16 -5 17 -4 6 9 -7 8 -18 15 -25 15 -13 0 -25 24 -56 106 -19 49 -75 104 -106 104 -6 0 -49 -18 -95 -40 -45 -22 -104 -46 -130 -54 l-48 -14 0 28 c0 32 -34 80 -58 80 -9 0 6 13 33 28 33 18 57 40 70 65 20 37 21 38 65 32 25 -3 54 -8 65 -11 19 -5 19 -4 2 9 -9 8 -45 20 -80 27 -57 11 -63 10 -72 -7 -16 -28 -84 -93 -99 -93 -10 0 -3 29 25 103 62 160 53 147 90 134 18 -7 67 -46 111 -87 62 -60 78 -81 78 -104 0 -21 11 -40 40 -68 34 -31 48 -38 81 -38 28 0 39 4 36 13 -3 11 -15 13 -83 16 -21 1 -36 26 -48 82 -3 15 -37 53 -81 93 -42 38 -74 71 -71 73 5 5 121 32 139 33 4 0 19 -24 32 -54 13 -30 29 -57 34 -61 18 -11 12 28 -9 63 -11 18 -18 37 -15 42 3 4 -12 18 -33 30 -26 16 -32 24 -22 28 8 3 28 16 43 28 40 33 56 30 75 -16 20 -46 81 -87 100 -68 14 14 15 12 16 -82 1 -52 7 -86 20 -109 18 -35 84 -101 100 -101 5 0 -3 9 -18 21 -47 37 -66 89 -66 179 0 77 1 80 19 70 24 -12 61 -13 61 -1 0 12 -25 21 -56 21 -16 0 -43 17 -73 45 -54 50 -81 61 -36 15 23 -24 25 -30 11 -30 -17 0 -66 45 -66 61 0 11 53 5 123 -14 63 -17 81 -14 130 25 24 20 31 21 59 10 40 -15 48 -8 12 11 l-27 14 29 11 c16 5 37 13 47 17 12 5 17 2 17 -10 0 -28 37 -80 78 -111 l37 -27 -32 34 c-18 19 -38 50 -44 69 -7 19 -18 42 -26 52 -13 14 -10 21 22 57 35 39 38 40 85 34 42 -5 56 -2 97 23 26 16 57 33 67 37 18 7 18 9 4 23 -14 15 -20 14 -59 -5 -24 -11 -61 -30 -81 -42 -33 -18 -42 -19 -74 -9 l-36 12 -57 -60 c-84 -91 -142 -96 -162 -16 -7 27 -22 53 -38 65 -24 19 -35 20 -141 15 -73 -4 -121 -3 -130 3 -12 8 -8 11 17 11 25 0 39 8 60 35 l27 34 78 -6 77 -6 62 64 c80 83 104 85 170 17 49 -49 95 -66 145 -54 21 6 23 10 14 21 -8 10 -26 12 -63 8 l-52 -5 -42 50 -42 50 31 6 c43 9 72 35 57 53 -15 18 -26 17 -56 -7 -18 -14 -41 -20 -73 -20 -46 0 -49 -2 -108 -69 -71 -79 -105 -97 -159 -84 -37 9 -38 10 -36 53 2 32 9 51 30 72 24 25 32 28 66 23 36 -7 41 -4 92 44 29 28 69 58 89 67 34 17 35 18 15 32 -17 12 -25 10 -74 -17 l-55 -31 0 25 c0 16 14 41 38 65 32 34 47 42 112 55 115 23 122 22 189 -39 65 -58 111 -82 137 -72 28 11 -3 36 -36 30 -23 -5 -37 2 -83 44 -31 28 -65 56 -77 62 l-22 12 27 28 c17 17 25 35 22 44 -9 24 -29 19 -61 -14 -38 -41 -75 -59 -144 -71 -31 -6 -69 -19 -84 -29 -26 -19 -27 -19 -53 2 -19 14 -41 21 -70 20 -40 0 -45 2 -54 30 -19 55 -14 77 23 95 34 16 34 16 78 -19 64 -52 79 -56 28 -8 -25 23 -39 38 -32 35 8 -4 36 5 63 20 50 27 111 34 161 20 23 -7 23 -7 -2 11 -15 11 -47 20 -85 22 -50 3 -68 -1 -108 -22 l-48 -26 -9 26 c-11 29 -13 82 -4 136 l7 38 30 -22 c44 -32 108 -28 161 10 45 33 56 56 31 66 -9 3 -34 -8 -61 -29 -72 -55 -124 -58 -147 -8 -9 21 -8 27 6 35 10 5 28 31 42 58 29 58 65 80 130 80 63 0 91 -17 152 -91 57 -71 94 -108 136 -134 15 -10 -31 42 -102 114 -89 90 -136 131 -151 131 -18 0 -21 4 -16 23 3 12 6 28 6 36 0 11 3 11 18 0 15 -13 16 -12 4 3 -9 11 -11 32 -6 66 l6 50 44 7 c24 3 48 4 53 1 4 -3 14 -32 20 -65 13 -67 38 -104 84 -127 l32 -15 -32 28 c-36 31 -47 56 -57 126 -5 32 -17 59 -36 81 -29 32 -29 34 -11 50 10 9 49 24 86 34 73 18 98 40 76 66 -12 14 -16 13 -42 -9 -16 -13 -46 -27 -67 -30 -85 -14 -127 -49 -104 -86 9 -15 8 -19 -9 -24 -29 -9 -36 2 -41 63 -4 40 -2 55 10 61 12 7 11 10 -6 22 -11 8 -27 31 -35 51 l-15 36 38 6 c22 3 51 11 66 16 25 10 26 12 11 27 -14 14 -19 14 -56 -1 -74 -29 -105 -16 -114 49 -1 11 -6 25 -10 31 -4 7 2 14 16 18 12 3 46 23 75 46 58 43 72 44 72 4 0 -55 -71 -90 -123 -62 -20 10 -21 10 -9 -5 18 -22 78 -40 107 -33 40 10 55 33 55 87 0 58 15 77 86 108 26 12 48 20 49 19 13 -16 78 -177 82 -203 3 -19 12 -40 21 -47 28 -23 7 69 -37 163 -31 65 -42 104 -46 152 l-5 65 37 7 c34 6 38 4 97 -59 34 -36 68 -66 76 -66 9 0 4 8 -12 18 -15 10 -40 36 -57 59 -16 22 -46 50 -66 63 l-37 23 30 29 c25 25 33 28 50 19 28 -15 42 -14 42 4 0 14 -4 17 -32 19 -7 1 -21 9 -31 19 -18 18 -19 18 -41 -2 -12 -12 -25 -21 -28 -21 -4 0 -8 26 -11 58 -3 31 -6 66 -8 77 -3 16 5 23 46 36 39 12 51 21 53 39 4 22 4 21 23 -8 19 -33 94 -92 113 -91 6 0 2 6 -8 12 -56 35 -77 63 -85 115 -9 61 -27 84 -98 124 -29 17 -53 36 -53 43 0 7 -2 19 -5 27 -4 10 21 13 119 15 116 2 125 1 131 -17 14 -43 97 -80 135 -60 l22 12 -22 15 c-16 11 -25 12 -33 4 -18 -18 -55 -13 -67 9 -15 28 14 126 43 144 12 8 49 17 82 21 126 13 137 16 124 31 -6 8 -17 13 -23 13 -6 -1 -57 -7 -114 -14z m-452 -117 c40 -71 40 -92 -1 -84 -17 3 -38 6 -46 6 -9 0 -13 7 -10 18 3 9 8 38 12 65 3 26 8 47 10 47 3 0 18 -24 35 -52z m130 -148 c26 -13 40 -27 40 -40 0 -12 -5 -17 -14 -14 -9 4 -15 -7 -20 -36 -7 -43 -9 -45 -69 -55 -34 -5 -38 -3 -47 21 -9 22 -6 34 15 70 14 23 25 50 25 58 0 22 23 20 70 -4z m-360 -926 c7 -19 9 -38 6 -41 -6 -6 -36 58 -36 77 0 22 19 -1 30 -36z m20 -274 c0 -33 -5 -35 -46 -20 -26 10 -26 11 -9 30 28 31 55 26 55 -10z m-15 -49 c3 -5 -2 -25 -11 -44 -12 -26 -25 -36 -45 -39 -22 -4 -29 -1 -29 13 0 9 7 19 15 23 11 4 14 13 9 31 -6 23 -3 25 24 25 17 0 33 -4 37 -9z m-210 -107 c-12 -19 -21 -23 -38 -18 -32 9 -32 12 -11 35 44 50 84 36 49 -17z m75 8 c-4 -20 -15 -52 -25 -71 -16 -33 -18 -34 -31 -16 -12 16 -11 20 6 32 14 10 20 26 20 52 0 42 6 52 25 45 8 -4 10 -16 5 -42z m-90 -244 c0 -14 -30 -48 -42 -48 -5 0 -2 14 7 30 16 30 35 40 35 18z m9 -129 c1 -9 -7 -19 -16 -22 -10 -2 -26 -8 -36 -12 -16 -7 -17 -4 -11 21 3 16 16 37 28 46 20 16 21 16 28 0 4 -9 7 -24 7 -33z m-109 -16 c0 -12 -33 -43 -47 -43 -21 0 -15 26 10 38 26 13 37 14 37 5z m-65 -94 c8 -29 13 -55 10 -57 -6 -6 -45 89 -45 111 0 35 20 5 35 -54z m106 -34 c24 -62 24 -60 -6 -73 -33 -15 -62 -15 -71 0 -5 7 -3 8 6 3 10 -6 12 2 7 41 -3 27 -9 57 -13 67 -6 16 -2 18 26 15 28 -3 34 -9 51 -53z m156 -85 c14 -21 5 -58 -19 -78 -12 -9 -19 -8 -34 3 -29 22 -57 18 -103 -15 -23 -17 -53 -35 -66 -40 -23 -9 -23 -9 -22 59 l2 68 33 -15 c27 -13 38 -14 67 -3 26 9 40 9 57 0 19 -9 21 -9 11 2 -18 18 -16 27 5 32 37 10 58 6 69 -13z m-19 -110 c28 -26 28 -55 1 -74 -16 -11 -28 -12 -47 -5 -53 20 -89 -13 -102 -97 -11 -68 -37 -99 -91 -108 -35 -6 -36 -6 -67 54 l-31 60 34 0 c21 0 44 9 63 25 18 15 42 25 62 25 28 0 31 2 20 15 -7 8 -18 15 -25 15 -24 0 -4 38 23 45 14 3 41 19 61 36 42 34 69 37 99 9z m-109 -382 c47 1 102 3 121 5 32 2 35 -1 45 -37 6 -21 25 -51 42 -66 30 -27 31 -28 9 -39 -11 -6 -33 -20 -48 -32 l-27 -22 -83 24 c-46 13 -90 31 -99 40 -16 16 -21 15 -67 -8 -36 -18 -59 -23 -80 -18 -41 9 -46 38 -17 105 13 30 26 67 29 83 l7 28 40 -33 c40 -32 43 -33 128 -30z m-189 -203 l44 -45 -29 0 c-17 0 -45 -4 -63 -9 -21 -6 -32 -6 -32 0 0 22 23 99 29 99 4 0 27 -20 51 -45z M3011 2121 c3 -32 11 -66 17 -77 8 -15 6 -30 -10 -65 -28 -60 -28 -133 0 -187 12 -24 20 -48 16 -53 -3 -5 -28 -22 -55 -38 l-49 -28 38 -53 c20 -29 43 -56 51 -58 7 -3 0 16 -17 41 l-32 45 41 20 c36 18 43 19 70 5 36 -18 51 -45 47 -86 -3 -31 -4 -32 -50 -32 -35 0 -53 -6 -71 -23 l-24 -22 -25 20 c-42 32 -62 25 -117 -41 -37 -45 -50 -67 -45 -81 3 -11 -1 -27 -9 -36 -51 -59 -65 -153 -30 -200 19 -25 19 -31 7 -55 -8 -14 -15 -39 -16 -54 -1 -15 -12 -36 -24 -45 -12 -10 -26 -37 -33 -61 -14 -57 -30 -69 -103 -78 -75 -9 -89 -21 -60 -52 38 -40 29 -62 -44 -111 -37 -25 -61 -47 -55 -51 18 -10 12 -25 -6 -18 -16 6 -16 5 -4 -10 7 -9 20 -17 27 -17 8 0 14 -9 14 -20 0 -33 35 -93 69 -117 38 -28 53 -30 18 -3 -27 22 -63 102 -53 119 4 5 -4 18 -17 28 l-24 18 51 31 51 31 28 -24 c33 -27 78 -33 57 -8 -7 9 -19 14 -26 11 -20 -8 -40 34 -23 46 30 20 115 58 132 58 11 0 15 5 11 11 -7 11 -86 8 -129 -6 -15 -4 -23 1 -31 19 -15 32 -10 36 42 36 67 0 91 14 103 57 16 55 27 71 42 56 9 -9 17 -8 35 4 36 23 51 10 73 -65 23 -76 45 -109 94 -144 l32 -22 -37 41 c-25 28 -43 64 -57 110 -21 71 -58 113 -98 113 -30 0 -1 18 47 29 34 7 75 6 163 -6 98 -14 126 -15 165 -4 57 15 60 17 40 37 -14 14 -18 14 -46 0 -18 -9 -55 -16 -89 -16 -106 0 -122 14 -84 76 23 38 91 73 160 81 44 5 49 8 39 21 -9 11 -25 13 -66 8 -68 -8 -119 -29 -157 -65 -28 -27 -32 -28 -62 -15 -29 12 -33 19 -33 53 0 23 8 48 20 64 18 22 25 25 46 17 43 -17 60 -12 130 36 82 56 131 67 162 34 14 -15 27 -20 41 -15 19 6 21 2 21 -35 0 -47 28 -82 84 -105 19 -8 41 -27 48 -42 13 -26 38 -43 38 -25 0 20 -42 61 -84 82 -49 23 -56 34 -56 91 0 27 -6 42 -19 51 -18 11 -18 12 5 31 13 10 31 31 38 45 8 15 26 40 41 56 25 27 25 30 10 45 -17 17 -23 12 -109 -94 -28 -35 -29 -36 -91 -31 -75 4 -156 30 -179 55 -16 18 -15 20 10 40 44 35 71 37 94 8 30 -38 166 -101 145 -67 -3 6 -26 15 -50 21 -61 15 -78 36 -63 77 8 20 23 35 42 42 37 13 117 13 152 0 39 -15 41 -4 2 17 -23 13 -56 18 -113 18 -75 1 -82 3 -110 31 -16 16 -41 37 -55 47 -14 10 -17 16 -8 13 11 -3 17 1 16 12 0 9 9 39 20 66 l19 49 31 -45 c31 -45 62 -77 62 -63 0 4 -22 37 -49 74 -27 37 -52 82 -56 99 -6 32 -6 32 32 32 21 0 44 4 52 9 14 9 98 -12 116 -30 5 -5 10 -36 10 -67 0 -65 15 -90 87 -148 56 -44 89 -45 123 0 51 67 158 84 171 28 10 -41 32 -60 89 -77 29 -8 57 -15 62 -15 14 1 -31 25 -67 35 -22 7 -40 21 -48 37 -24 51 -68 78 -126 78 -55 0 -84 -14 -124 -62 -33 -39 -64 -36 -101 9 -27 33 -31 45 -31 99 0 68 -5 76 -79 117 l-44 25 31 16 c39 20 71 20 100 0 13 -9 25 -14 27 -12 3 2 -15 16 -38 32 -52 34 -101 31 -161 -12 -53 -38 -86 -48 -97 -31 -4 7 -3 21 2 31 7 13 6 20 -3 23 -8 3 -13 22 -13 49 0 34 -6 51 -25 71 -49 53 -57 50 -49 -14z m36 -43 c-3 -7 -5 -2 -5 12 0 14 2 19 5 13 2 -7 2 -19 0 -25z m27 -170 c21 -29 20 -54 -3 -103 l-19 -40 -11 28 c-18 45 -11 137 10 137 4 0 15 -10 23 -22z m-144 -450 c-22 -21 -49 -38 -61 -38 -22 0 -22 0 -3 23 10 12 32 36 48 54 27 30 30 31 42 15 12 -15 8 -22 -26 -54z m118 -25 c54 -39 58 -43 42 -43 -16 1 -101 70 -87 70 5 0 25 -12 45 -27z m-3 -30 l34 -27 -52 -38 c-73 -55 -110 -50 -132 16 -12 34 -12 36 10 36 13 0 33 9 46 20 32 26 54 25 94 -7z m-181 -36 c8 -21 2 -48 -24 -127 -18 -55 -36 -100 -40 -100 -12 0 -30 50 -30 81 0 32 20 78 52 118 11 15 16 32 12 44 -10 30 19 15 30 -16z m61 -247 c7 0 15 -14 17 -30 l5 -31 -56 3 c-53 3 -56 4 -53 27 2 14 8 35 13 49 l9 23 26 -20 c15 -12 32 -21 39 -21z" fill="var(--bg)"/> + </g> +</svg> \ No newline at end of file diff --git a/static/fonts/fira-sans-regular.woff2 b/static/fonts/fira-sans-regular.woff2 new file mode 100644 index 0000000..6eb5e4f Binary files /dev/null and b/static/fonts/fira-sans-regular.woff2 differ diff --git a/static/fonts/fira-sans-semibold.woff2 b/static/fonts/fira-sans-semibold.woff2 new file mode 100644 index 0000000..13e078c Binary files /dev/null and b/static/fonts/fira-sans-semibold.woff2 differ diff --git a/static/fonts/jetbrains-mono-italic.woff2 b/static/fonts/jetbrains-mono-italic.woff2 new file mode 100644 index 0000000..d11ec1d Binary files /dev/null and b/static/fonts/jetbrains-mono-italic.woff2 differ diff --git a/static/fonts/jetbrains-mono-regular.woff2 b/static/fonts/jetbrains-mono-regular.woff2 new file mode 100644 index 0000000..766ad82 Binary files /dev/null and b/static/fonts/jetbrains-mono-regular.woff2 differ diff --git a/static/fonts/spectral-bold-italic.woff2 b/static/fonts/spectral-bold-italic.woff2 new file mode 100644 index 0000000..c2892a0 Binary files /dev/null and b/static/fonts/spectral-bold-italic.woff2 differ diff --git a/static/fonts/spectral-bold.woff2 b/static/fonts/spectral-bold.woff2 new file mode 100644 index 0000000..9d769c8 Binary files /dev/null and b/static/fonts/spectral-bold.woff2 differ diff --git a/static/fonts/spectral-italic.woff2 b/static/fonts/spectral-italic.woff2 new file mode 100644 index 0000000..b269421 Binary files /dev/null and b/static/fonts/spectral-italic.woff2 differ diff --git a/static/fonts/spectral-regular.woff2 b/static/fonts/spectral-regular.woff2 new file mode 100644 index 0000000..ca96631 Binary files /dev/null and b/static/fonts/spectral-regular.woff2 differ diff --git a/static/fonts/spectral-semibold-italic.woff2 b/static/fonts/spectral-semibold-italic.woff2 new file mode 100644 index 0000000..b523dcf Binary files /dev/null and b/static/fonts/spectral-semibold-italic.woff2 differ diff --git a/static/fonts/spectral-semibold.woff2 b/static/fonts/spectral-semibold.woff2 new file mode 100644 index 0000000..4ac5c79 Binary files /dev/null and b/static/fonts/spectral-semibold.woff2 differ diff --git a/static/gpg/pubkey.asc b/static/gpg/pubkey.asc new file mode 100644 index 0000000..cc0af88 --- /dev/null +++ b/static/gpg/pubkey.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEYw0NIRYJKwYBBAHaRw8BAQdADR++EP+ENf143YCIYfJ9hsvvECChdqT/YrQi +bcP5iti0KWxuQGxldmluZXV3aXJ0aC5vcmcgPGxuQGxldmluZXV3aXJ0aC5vcmc+ +iI8EEBYKACAFAmMNDSEGCwkHCAMCBBUICgIEFgIBAAIZAQIbAwIeAQAhCRCwGgGt +G1yWYxYhBM2QrpY4O7r0FaHXQLAaAa0bXJZjqVMA/i6PkQSa3gk9Pr4c26BQwKZV +67r6jfsaiEux6/wJMaBAAPwNAgJSeLCzIEYhFa/GTX8iHVixiNVKdrYXVtxsKbyK +D7gzBGm9dD0WCSsGAQQB2kcPAQEHQC7h1p+9tCmC5KUqp7wJC/AbShfe9S4NcsBa +2/guvOMoiO8EGBYKACAWIQTNkK6WODu69BWh10CwGgGtG1yWYwUCab10PQIbAgCB +CRCwGgGtG1yWY3YgBBkWCgAdFiEEyaQqb61ET75Wb9c4UxvcHMJwcGYFAmm9dD0A +CgkQUxvcHMJwcGZiXwD/ThKmvRJigUpPlIsWhTMufXbK1NOSf8a9Z8JhXxIwB8cB +AJ7qC4wMBIaPy+9AwliWo7m8NKc/HkiVyoMxAIUBLHsPH/sA/2F1ACNZKJBVp9ze +sxr9TPZnMMWSjLSAEoTsjXFKRwb+AP0XMJn+hiTSy2S7bEC3hsu3xJSKePHByFGD +26I8RLM4BA== +=Xwzi +-----END PGP PUBLIC KEY BLOCK----- diff --git a/static/images/canto31.jpg b/static/images/canto31.jpg new file mode 100644 index 0000000..94a3433 Binary files /dev/null and b/static/images/canto31.jpg differ diff --git a/static/images/dingbats/academic.svg b/static/images/dingbats/academic.svg new file mode 100644 index 0000000..831a009 --- /dev/null +++ b/static/images/dingbats/academic.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M12 4 L 22 8 L 12 12 L 2 8 Z"/> + <path d="M6 10 L 6 15 C 6 17, 9 18.5, 12 18.5 C 15 18.5, 18 17, 18 15 L 18 10 L 17 10.4 L 17 14.7 C 17 16, 14.7 17.3, 12 17.3 C 9.3 17.3, 7 16, 7 14.7 L 7 10.4 Z"/> + <rect x="20.6" y="8" width="0.8" height="6"/> + <path d="M19.8 14 L 22.2 14 L 21.6 16.5 L 20.4 16.5 Z"/> +</svg> diff --git a/static/images/dingbats/ai.svg b/static/images/dingbats/ai.svg new file mode 100644 index 0000000..5debc7e --- /dev/null +++ b/static/images/dingbats/ai.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <circle cx="12" cy="2.8" r="1.2"/> + <rect x="11.5" y="3.8" width="1" height="3"/> + <path fill-rule="evenodd" d="M5.5 7 L18.5 7 A 1.5 1.5 0 0 1 20 8.5 L20 17.5 A 1.5 1.5 0 0 1 18.5 19 L5.5 19 A 1.5 1.5 0 0 1 4 17.5 L4 8.5 A 1.5 1.5 0 0 1 5.5 7 Z M8 11 L11 11 L11 14 L8 14 Z M13 11 L16 11 L16 14 L13 14 Z"/> + <rect x="9.5" y="16" width="5" height="0.8" rx="0.4"/> + <circle cx="2.5" cy="12" r="0.7"/> + <circle cx="21.5" cy="12" r="0.7"/> +</svg> diff --git a/static/images/dingbats/artistic.svg b/static/images/dingbats/artistic.svg new file mode 100644 index 0000000..f589cb6 --- /dev/null +++ b/static/images/dingbats/artistic.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"> + <path d="M12 3 C 6.5 3, 2.5 7, 2.5 12 C 2.5 16, 5.5 19, 9.5 19 C 11 19, 11.5 18, 11.5 17 C 11.5 16.2, 11 15.7, 11 15 C 11 14.2, 11.7 13.5, 12.5 13.5 L 16 13.5 C 19 13.5, 21.5 11, 21.5 8 C 21.5 5, 17 3, 12 3 Z M6.5 11 A 1.4 1.4 0 1 1 6.5 13.8 A 1.4 1.4 0 1 1 6.5 11 Z M9.5 6.5 A 1.4 1.4 0 1 1 9.5 9.3 A 1.4 1.4 0 1 1 9.5 6.5 Z M14.5 6 A 1.4 1.4 0 1 1 14.5 8.8 A 1.4 1.4 0 1 1 14.5 6 Z M18 9 A 1.4 1.4 0 1 1 18 11.8 A 1.4 1.4 0 1 1 18 9 Z"/> +</svg> diff --git a/static/images/dingbats/clef.svg b/static/images/dingbats/clef.svg new file mode 100644 index 0000000..7006f98 --- /dev/null +++ b/static/images/dingbats/clef.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <ellipse cx="7" cy="18" rx="3.4" ry="2.4" transform="rotate(-22 7 18)"/> + <rect x="9.2" y="4" width="1.6" height="14"/> + <ellipse cx="15" cy="18" rx="3.4" ry="2.4" transform="rotate(-22 15 18)"/> + <rect x="17.2" y="4" width="1.6" height="14"/> + <path d="M9.2 4 L18.8 4 L18.8 7 L9.2 7 Z"/> +</svg> diff --git a/static/images/dingbats/fiction.svg b/static/images/dingbats/fiction.svg new file mode 100644 index 0000000..44a3fee --- /dev/null +++ b/static/images/dingbats/fiction.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M3 6 L 11.5 5 L 11.5 20 L 3 19 Z"/> + <path d="M21 6 L 12.5 5 L 12.5 20 L 21 19 Z"/> + <path d="M11.5 5 L 12.5 5 L 12.5 20 L 11.5 20 Z" fill-opacity="0.55"/> +</svg> diff --git a/static/images/dingbats/fleuron.svg b/static/images/dingbats/fleuron.svg new file mode 100644 index 0000000..60bdba9 --- /dev/null +++ b/static/images/dingbats/fleuron.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M12 2 C 6 8 6 16 12 22 C 18 16 18 8 12 2 Z"/> + <path d="M11.5 4 L11.5 22 L12.5 22 L12.5 4 Z"/> +</svg> diff --git a/static/images/dingbats/library-divider.svg b/static/images/dingbats/library-divider.svg new file mode 100644 index 0000000..f5c3076 --- /dev/null +++ b/static/images/dingbats/library-divider.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 24" fill="currentColor"> + <rect x="20" y="11.5" width="80" height="1"/> + <circle cx="106" cy="12" r="1.5"/> + <path fill-rule="evenodd" d="M120 4 L 126 12 L 120 20 L 114 12 Z M120 8.5 L 123 12 L 120 15.5 L 117 12 Z"/> + <circle cx="134" cy="12" r="1.5"/> + <rect x="140" y="11.5" width="80" height="1"/> +</svg> diff --git a/static/images/dingbats/lozenge.svg b/static/images/dingbats/lozenge.svg new file mode 100644 index 0000000..f6476ec --- /dev/null +++ b/static/images/dingbats/lozenge.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"> + <path d="M12 2 L22 12 L12 22 L2 12 Z M12 6 L18 12 L12 18 L6 12 Z"/> + <circle cx="12" cy="12" r="1.5"/> +</svg> diff --git a/static/images/dingbats/memento.svg b/static/images/dingbats/memento.svg new file mode 100644 index 0000000..401f5d7 --- /dev/null +++ b/static/images/dingbats/memento.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"> + <rect x="5" y="2" width="14" height="2"/> + <rect x="5" y="20" width="14" height="2"/> + <path d="M7 4 L17 4 L12.5 12 L17 20 L7 20 L11.5 12 Z M9 6 L15 6 L12 11 Z M9 18 L15 18 L12 13 Z"/> +</svg> diff --git a/static/images/dingbats/nonfiction.svg b/static/images/dingbats/nonfiction.svg new file mode 100644 index 0000000..e31b6f3 --- /dev/null +++ b/static/images/dingbats/nonfiction.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M20 2 L 22 4 L 10 16 L 7 19 L 4 20 L 5 17 L 8 14 L 18 4 Z"/> + <path d="M15 7 L 13 7 L 14 5 Z"/> + <path d="M18 4 L 16 4 L 17 2 Z"/> + <path d="M12 10 L 10 10 L 11 8 Z"/> +</svg> diff --git a/static/images/dingbats/poetry.svg b/static/images/dingbats/poetry.svg new file mode 100644 index 0000000..3c9f580 --- /dev/null +++ b/static/images/dingbats/poetry.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M5 4 C 3 9, 3 16, 6 20 L 8 20 C 5 16, 5 9, 7 5 Z"/> + <path d="M19 4 C 21 9, 21 16, 18 20 L 16 20 C 19 16, 19 9, 17 5 Z"/> + <rect x="4.5" y="3" width="15" height="2" rx="0.8"/> + <rect x="9.8" y="5" width="0.7" height="15"/> + <rect x="11.65" y="5" width="0.7" height="15"/> + <rect x="13.5" y="5" width="0.7" height="15"/> +</svg> diff --git a/static/images/dingbats/professional.svg b/static/images/dingbats/professional.svg new file mode 100644 index 0000000..b0ac07e --- /dev/null +++ b/static/images/dingbats/professional.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"> + <path d="M9 3 C 8.2 3, 7.5 3.7, 7.5 4.5 L 7.5 7.5 L 9 7.5 L 9 4.7 C 9 4.6, 9.1 4.5, 9.2 4.5 L 14.8 4.5 C 14.9 4.5, 15 4.6, 15 4.7 L 15 7.5 L 16.5 7.5 L 16.5 4.5 C 16.5 3.7, 15.8 3, 15 3 Z"/> + <path d="M4 7 L 20 7 C 20.6 7, 21 7.4, 21 8 L 21 19 C 21 19.6, 20.6 20, 20 20 L 4 20 C 3.4 20, 3 19.6, 3 19 L 3 8 C 3 7.4, 3.4 7, 4 7 Z M11 12.5 L 13 12.5 L 13 14.5 L 11 14.5 Z"/> +</svg> diff --git a/static/images/dingbats/research.svg b/static/images/dingbats/research.svg new file mode 100644 index 0000000..7695c1e --- /dev/null +++ b/static/images/dingbats/research.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M6 3 C 4 9, 5 17, 11 21 L 11 19 C 7 16, 6 10, 7 5 Z"/> + <path d="M18 3 C 20 9, 19 17, 13 21 L 13 19 C 17 16, 18 10, 17 5 Z"/> + <rect x="9.5" y="20" width="5" height="1.5" rx="0.6"/> + <circle cx="12" cy="12" r="1"/> +</svg> diff --git a/static/images/dingbats/social.svg b/static/images/dingbats/social.svg new file mode 100644 index 0000000..fa87e87 --- /dev/null +++ b/static/images/dingbats/social.svg @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <circle cx="5" cy="6" r="2"/> + <circle cx="19" cy="6" r="2"/> + <circle cx="5" cy="18" r="2"/> + <circle cx="19" cy="18" r="2"/> + <circle cx="12" cy="12" r="2.4"/> + <path d="M6.4 7.2 L10.4 11.2 L 9.6 12 L 5.6 8 Z"/> + <path d="M17.6 7.2 L13.6 11.2 L 14.4 12 L 18.4 8 Z"/> + <path d="M6.4 16.8 L10.4 12.8 L 9.6 12 L 5.6 16 Z"/> + <path d="M17.6 16.8 L13.6 12.8 L 14.4 12 L 18.4 16 Z"/> +</svg> diff --git a/static/images/dingbats/tech.svg b/static/images/dingbats/tech.svg new file mode 100644 index 0000000..3da2263 --- /dev/null +++ b/static/images/dingbats/tech.svg @@ -0,0 +1,15 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"> + <path d="M7 7 L17 7 L17 17 L7 17 Z M9.5 9.5 L14.5 9.5 L14.5 14.5 L9.5 14.5 Z"/> + <rect x="3" y="9" width="4" height="1"/> + <rect x="3" y="11.5" width="4" height="1"/> + <rect x="3" y="14" width="4" height="1"/> + <rect x="17" y="9" width="4" height="1"/> + <rect x="17" y="11.5" width="4" height="1"/> + <rect x="17" y="14" width="4" height="1"/> + <rect x="9" y="3" width="1" height="4"/> + <rect x="11.5" y="3" width="1" height="4"/> + <rect x="14" y="3" width="1" height="4"/> + <rect x="9" y="17" width="1" height="4"/> + <rect x="11.5" y="17" width="1" height="4"/> + <rect x="14" y="17" width="1" height="4"/> +</svg> diff --git a/static/images/dingbats/trefoil.svg b/static/images/dingbats/trefoil.svg new file mode 100644 index 0000000..66ddc97 --- /dev/null +++ b/static/images/dingbats/trefoil.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <circle cx="12" cy="6" r="4"/> + <circle cx="6.5" cy="14" r="4"/> + <circle cx="17.5" cy="14" r="4"/> + <rect x="11.25" y="10" width="1.5" height="11"/> +</svg> diff --git a/static/images/link-icons/anthropic.svg b/static/images/link-icons/anthropic.svg new file mode 100644 index 0000000..1bbabc3 --- /dev/null +++ b/static/images/link-icons/anthropic.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="23 35 205 180"><path d="M47.1 124.4l-30.1 76c0 .3 7.6.6 16.9.6h16.9l3.6-9.2 6-15.8 2.4-6.5 31.5-.3 31.5-.2 3 7.7 6.2 16 3.2 8.3h16.9c9.3 0 16.9-.2 16.9-.4s-8.5-21.7-18.9-47.8L123 77.2 111.8 49H94.5 77.2l-30.1 75.4zm57.3-10.4l9.6 25.2c0 .5-8.8.8-19.5.8s-19.5-.3-19.5-.8c0-.4 3.4-9.5 7.6-20.2l9.6-24.8c1.1-2.9 2.1-5.2 2.3-5s4.7 11.3 9.9 24.8zM140 50.2c0 .7 13.4 34.8 29.8 75.8l29.7 74.5 16.9.3c9.9.1 16.6-.1 16.4-.7-.1-.5-13.8-34.7-30.2-76l-30-75.1h-16.3c-12.4 0-16.3.3-16.3 1.2z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/apple.svg b/static/images/link-icons/apple.svg new file mode 100644 index 0000000..77e6f03 --- /dev/null +++ b/static/images/link-icons/apple.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 814 1000"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/arxiv.svg b/static/images/link-icons/arxiv.svg new file mode 100644 index 0000000..7f29980 --- /dev/null +++ b/static/images/link-icons/arxiv.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <!-- + arXiv chi (χ): two thick diagonal bars. + The bottom-left→top-right diagonal is continuous. + The top-right→bottom-left diagonal is broken at center (chi, not X). + All shapes are filled so mask-image works reliably. + --> + <!-- Bar 1: top-left → bottom-right (continuous) --> + <polygon points="2,2.5 3.5,2.5 14,13.5 12.5,13.5" fill="black"/> + <!-- Bar 2 upper: top-right → center --> + <polygon points="14,2.5 12.5,2.5 8.8,7.6 10.3,7.6" fill="black"/> + <!-- Bar 2 lower: center → bottom-left --> + <polygon points="5.7,8.4 7.2,8.4 3.5,13.5 2,13.5" fill="black"/> +</svg> diff --git a/static/images/link-icons/document.svg b/static/images/link-icons/document.svg new file mode 100644 index 0000000..fa16eb9 --- /dev/null +++ b/static/images/link-icons/document.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path fill="black" d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0H4zm0 1h5v4h4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zm5.5.25L13 4.5h-3.5V1.25zM5 8h6v1H5V8zm0 2.5h6v1H5v-1z"/> +</svg> diff --git a/static/images/link-icons/doi.svg b/static/images/link-icons/doi.svg new file mode 100644 index 0000000..00bfdc8 --- /dev/null +++ b/static/images/link-icons/doi.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <!-- + DOI: two chain-link rings side by side. + Stroked paths with no fill — stroke pixels are opaque and work with mask-image. + --> + <!-- Left ring --> + <rect x="1" y="5.5" width="7.5" height="5" rx="2.5" + stroke="black" stroke-width="1.5" fill="none"/> + <!-- Right ring --> + <rect x="7.5" y="5.5" width="7.5" height="5" rx="2.5" + stroke="black" stroke-width="1.5" fill="none"/> +</svg> diff --git a/static/images/link-icons/email.svg b/static/images/link-icons/email.svg new file mode 100644 index 0000000..40b9d6f --- /dev/null +++ b/static/images/link-icons/email.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path fill="black" d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2zm13 2.383-4.708 2.825L15 11.105V5.383zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741zM1 11.105l4.708-2.897L1 5.383v5.722z"/> +</svg> diff --git a/static/images/link-icons/external.svg b/static/images/link-icons/external.svg new file mode 100644 index 0000000..173e37d --- /dev/null +++ b/static/images/link-icons/external.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <!-- + Standard external-link icon: arrow emerging from a box. + Fill-based paths for reliable mask-image rendering. + --> + <!-- Box (open top-right corner) --> + <path fill="black" d=" + M6.5 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9.5H12.5V12.5H3.5V3.5H6.5V2Z + "/> + <!-- Arrow shaft + head pointing up-right --> + <path fill="black" d=" + M8 2h6v6h-1.5V4.6L7.06 10.06 5.94 8.94 11.4 3.5H8V2Z + "/> +</svg> diff --git a/static/images/link-icons/forgejo.svg b/static/images/link-icons/forgejo.svg new file mode 100644 index 0000000..e5b1297 --- /dev/null +++ b/static/images/link-icons/forgejo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="38.5 0.5 135 211" stroke="currentColor" fill="none"><style>.st-line{stroke-width:25;stroke-linecap:round;stroke-linejoin:round}.st-circle{stroke-width:15}</style><g transform="translate(6 6)"><path d="M58 168V70c0-27.6 22.4-50 50-50h20" class="st-line"/><path d="M58 168v-30c0-27.6 22.4-50 50-50h20" class="st-line"/><circle cx="142" cy="20" r="18" class="st-circle"/><circle cx="142" cy="88" r="18" class="st-circle"/><circle cx="58" cy="180" r="18" class="st-circle"/></g></svg> \ No newline at end of file diff --git a/static/images/link-icons/github.svg b/static/images/link-icons/github.svg new file mode 100644 index 0000000..13b5d4b --- /dev/null +++ b/static/images/link-icons/github.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"> + <!-- GitHub Mark — official GitHub logo path --> + <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> +</svg> diff --git a/static/images/link-icons/hacker-news.svg b/static/images/link-icons/hacker-news.svg new file mode 100644 index 0000000..623f97b --- /dev/null +++ b/static/images/link-icons/hacker-news.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="30 30 448 448"><path d="M 30,30 V 478 H 478 V 30 Z"/><path fill="#fff" stroke="#fff" stroke-width="6" d="M 269.2,281.1 V 382 H 237.8 V 279.3 L 158,126 h 37.3 c 52.5,98.3 49.2,101.2 59.3,125.6 12.3,-27 5.8,-24.4 60.6,-125.6 H 350 Z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/internal.svg b/static/images/link-icons/internal.svg new file mode 100644 index 0000000..634dc94 --- /dev/null +++ b/static/images/link-icons/internal.svg @@ -0,0 +1,56 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> +<g transform="translate(0,512) scale(0.1,-0.1)" fill="black" stroke="none"> +<path d="M1980 4795 c-36 -7 -105 -29 -153 -48 -388 -157 -699 -499 -824 -905 +-24 -79 -27 -103 -27 -252 -1 -185 8 -231 75 -377 81 -177 207 -329 389 -467 +286 -216 583 -314 870 -287 58 5 106 8 108 7 4 -5 -103 -353 -190 -616 -65 +-195 -151 -417 -199 -511 l-18 -37 -142 49 c-162 56 -432 130 -564 155 -176 +32 -428 17 -566 -35 -185 -70 -334 -241 -385 -441 -24 -93 -15 -219 19 -294 +58 -125 145 -220 254 -275 61 -31 66 -26 8 8 -48 28 -86 61 -70 61 4 0 45 -18 +90 -39 102 -49 180 -70 303 -80 101 -9 160 -4 112 10 -17 5 -6 7 36 8 149 2 +334 52 486 130 53 27 100 46 104 43 4 -4 4 -2 1 4 -3 6 8 19 26 29 31 18 31 +18 17 0 -9 -11 22 1 77 30 51 27 96 46 99 42 19 -19 -316 -205 -482 -267 -43 +-16 -74 -29 -67 -30 26 -1 165 56 288 118 147 73 284 161 399 257 43 36 82 65 +86 65 4 0 91 -40 192 -89 203 -98 409 -191 507 -228 117 -44 72 -18 -82 47 +-84 35 -217 95 -297 133 -80 38 -179 85 -220 104 -89 42 -87 40 -72 55 9 9 16 +8 29 -4 59 -52 637 -303 838 -363 50 -15 117 -36 150 -46 70 -21 190 -43 328 +-60 87 -10 208 -8 292 5 17 3 49 7 73 11 23 3 40 10 37 15 -4 6 6 7 22 4 18 +-4 53 4 97 20 38 14 79 26 92 26 12 0 26 4 29 10 3 5 28 21 56 35 28 14 69 40 +92 58 56 45 77 56 52 28 -32 -37 -104 -86 -200 -136 -49 -26 -79 -44 -65 -40 +109 32 306 172 388 276 187 238 233 564 118 845 -45 110 -90 179 -175 267 +-110 115 -268 206 -456 263 -78 24 -108 28 -225 28 -158 1 -222 -13 -350 -76 +-65 -32 -101 -58 -160 -117 -129 -131 -190 -258 -198 -411 -6 -119 8 -189 58 +-288 76 -151 245 -264 431 -286 73 -9 182 14 259 55 75 40 163 131 198 206 23 +50 27 71 27 153 0 113 -17 153 -94 222 -50 45 -132 78 -192 78 -50 0 -128 -31 +-187 -74 -78 -58 -122 -140 -122 -229 0 -83 37 -150 93 -170 7 -2 9 -8 5 -12 +-14 -14 -69 18 -102 59 -66 82 -77 121 -67 246 9 108 46 191 122 274 94 103 +219 136 383 102 98 -20 147 -42 241 -107 168 -116 249 -265 248 -459 0 -149 +-49 -266 -152 -367 -150 -147 -348 -207 -600 -183 -224 22 -397 80 -751 250 +-135 64 -272 128 -305 140 -80 31 -84 35 -53 67 112 119 293 487 418 848 78 +227 174 570 165 586 -3 4 32 20 77 35 170 56 517 228 738 366 356 221 629 492 +759 752 70 140 92 224 98 377 7 151 -10 242 -63 349 -85 169 -335 310 -552 +310 -184 0 -422 -92 -625 -243 -209 -155 -454 -466 -612 -778 -88 -173 -133 +-285 -203 -504 -57 -181 -162 -542 -162 -560 0 -25 -153 -33 -251 -15 -123 23 +-226 106 -254 204 -22 75 -26 180 -8 205 20 30 30 27 79 -24 41 -41 87 -65 +100 -52 11 10 -45 114 -75 140 -107 89 -269 36 -315 -105 -9 -28 -16 -63 -16 +-79 0 -16 -4 -29 -8 -29 -13 0 -106 100 -150 162 -63 86 -108 173 -143 278 +-28 84 -33 111 -37 233 -5 158 6 250 49 379 92 283 268 482 501 566 65 23 88 +27 203 27 149 0 195 -12 282 -76 74 -55 110 -96 143 -164 86 -175 76 -368 -28 +-570 -48 -92 -166 -212 -244 -248 -141 -66 -262 -57 -357 29 -71 64 -92 97 +-107 168 -17 78 -17 230 0 278 16 45 66 131 73 125 2 -3 -5 -23 -16 -46 -31 +-60 -27 -107 17 -192 80 -158 231 -208 363 -120 82 55 128 165 116 277 -7 65 +-34 118 -89 171 -68 66 -126 88 -233 88 -82 0 -97 -3 -157 -33 -123 -60 -211 +-179 -260 -347 -18 -66 -22 -103 -22 -210 1 -117 3 -136 27 -194 93 -223 314 +-330 554 -268 59 16 163 62 163 72 0 3 -18 -4 -40 -15 -77 -40 -63 -19 23 33 +48 28 118 79 154 112 37 33 70 59 74 58 4 -1 6 3 6 10 -2 20 126 277 135 271 +4 -3 5 2 2 10 -3 9 -2 16 3 16 5 0 14 15 20 33 43 123 50 353 14 486 -32 120 +-62 173 -149 260 -93 93 -186 145 -298 166 -92 18 -285 18 -374 0z m2265 -264 +c50 -23 130 -108 155 -164 86 -194 25 -476 -157 -735 -66 -94 -289 -314 -410 +-405 -204 -152 -533 -330 -698 -378 -30 -8 -30 -8 -9 9 15 11 32 53 53 128 44 +156 159 496 226 663 196 496 421 821 610 881 78 25 178 25 230 1z m-2560 +-1908 c140 -71 275 -114 430 -138 49 -8 66 -13 44 -14 -106 -3 -354 68 -484 +139 -79 43 -191 115 -200 129 -3 7 24 -8 62 -32 37 -24 104 -62 148 -84z +m-394 -1469 c197 -37 519 -135 519 -158 0 -19 -172 -175 -263 -237 -167 -115 +-285 -154 -442 -146 -249 11 -395 138 -356 311 22 99 131 193 268 231 66 18 +175 18 274 -1z"/> +</g> +</svg> diff --git a/static/images/link-icons/internet-archive.svg b/static/images/link-icons/internet-archive.svg new file mode 100644 index 0000000..e4feede --- /dev/null +++ b/static/images/link-icons/internet-archive.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13.1 15"><path d="M.1 1.5c.08.12.12.3.23.4H12.6l.3-.4L6.5 0 .1 1.5z m.3 1h12.2v1.1H.4z M1 4.2h1l.2.1.2 3.7-.1 4.5H1l-.3-.1L.5 8c0-1.5.2-3.7.2-3.7l.3-.1z m10 0h1.1l.2.1.2 3.7-.1 4.5-.3.1h-1l-.3-.1-.2-4.4c0-1.5.2-3.7.2-3.7l.2-.2z m-6.7 0h1l.2.1.2 3.7-.1 4.5-.3.1h-.9l-.3-.1-.2-4.4c0-1.5.2-3.7.2-3.7l.2-.2z m3.4 0H9l.2 3.7-.1 4.5-.3.1h-1l-.3-.1L7.3 8c0-1.5.2-3.7.2-3.7l.2-.1z M.4 13.1h12.3v.6H.4z M0 14.3h13.1v.7H0z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/key.svg b/static/images/link-icons/key.svg new file mode 100644 index 0000000..37eb664 --- /dev/null +++ b/static/images/link-icons/key.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path fill="black" d="M0 8a4 4 0 0 1 7.465-2H14L15 7l1 1-1 1-1 1-1-1-1 1-1-1-1 1H7.465A4 4 0 0 1 0 8zm4 0a2 2 0 1 0 4 0 2 2 0 0 0-4 0z"/> +</svg> diff --git a/static/images/link-icons/lesswrong.svg b/static/images/link-icons/lesswrong.svg new file mode 100644 index 0000000..0a7c385 --- /dev/null +++ b/static/images/link-icons/lesswrong.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 60" fill="none" stroke="#000" stroke-width="10" stroke-linejoin="miter"><path d="M10 8 L10 52 L36 52"/><path d="M44 8 L54 52 L64 22 L74 52 L84 8"/></svg> diff --git a/static/images/link-icons/nasa.svg b/static/images/link-icons/nasa.svg new file mode 100644 index 0000000..60517a6 --- /dev/null +++ b/static/images/link-icons/nasa.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 284.86 299.23" stroke="#000" stroke-width="4"><path d="M93.991 106.699c1.576 5.961 4.119 8.266 8.613 8.266 4.659 0 7.102-2.799 7.102-8.266V5.2h29.184v101.499c0 14.307-1.856 20.506-9.11 27.762-5.228 5.229-14.871 9.271-27.047 9.271-9.837 0-19.25-3.256-25.253-9.27-5.263-5.273-8.154-10.689-12.672-27.764L46.9 39.033c-1.577-5.961-4.119-8.265-8.613-8.265-4.66 0-7.103 2.798-7.103 8.265v101.5H2v-101.5c0-14.306 1.857-20.506 9.111-27.762C16.337 6.044 25.981 2 38.158 2c9.837 0 19.25 3.257 25.253 9.27 5.263 5.273 8.154 10.689 12.672 27.764zM251.552 297.23l-33.704-105.437c-.372-1.164-.723-2.152-1.263-2.811-.926-1.127-2.207-1.719-3.931-1.719s-3.004.592-3.931 1.719c-.539.658-.891 1.646-1.262 2.811L173.758 297.23h-30.167l36.815-115.177c1.918-6 4.66-11.094 8.139-14.488 5.971-5.821 13.007-8.868 24.11-8.868s18.14 3.047 24.109 8.867c3.479 3.395 6.221 8.488 8.14 14.488l36.814 115.177zm-165.931 0c22.529 0 33.518-4.062 42.2-11.389 9.607-8.105 14.202-16.973 14.202-30.213 0-11.699-5.047-22.535-12.731-29.019-10.046-8.479-22.525-11.151-42.872-11.151l-28.5-.001c-10.89 0-15.23-1.117-18.663-3.98-2.358-1.964-3.463-4.885-3.463-8.328 0-3.559 1.01-7.074 3.892-9.475 2.558-2.131 6.045-3.109 12.745-3.109H134.8v-28.668H58.723c-22.529 0-33.517 4.063-42.2 11.389-9.606 8.105-14.202 16.972-14.202 30.212 0 11.701 5.047 22.536 12.731 29.019 10.048 8.479 22.525 11.152 42.872 11.152l28.501.002c10.89 0 15.23 1.115 18.663 3.979 2.358 1.965 3.463 4.885 3.463 8.328 0 3.559-1.01 7.074-3.891 9.475-2.559 2.131-6.046 3.109-12.746 3.109H2.25l-.15 28.537 83.521.13zm166.334-153.266L218.251 38.527c-.372-1.164-.723-2.152-1.263-2.811-.926-1.127-2.207-1.719-3.931-1.719s-3.004.592-3.931 1.719c-.539.658-.891 1.646-1.262 2.811l-33.703 105.437h-30.167l36.815-115.177c1.918-6 4.66-11.094 8.139-14.488 5.971-5.821 13.007-8.868 24.11-8.868s18.14 3.047 24.109 8.867c3.479 3.395 6.221 8.488 8.14 14.488l36.814 115.177z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/new-york-times.svg b/static/images/link-icons/new-york-times.svg new file mode 100644 index 0000000..e07e599 --- /dev/null +++ b/static/images/link-icons/new-york-times.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 0.75 13.5 14.75"><path d="M13.994 2.824c0-1.565-1.7-1.956-3.025-1.956v.235c.8 0 1.423.235 1.423.783 0 .313-.267.783-1.067.783-.623 0-1.957-.313-2.936-.626-1.156-.39-2.224-.704-3.113-.704-1.78 0-3.025 1.174-3.025 2.504 0 1.174.98 1.565 1.334 1.722l.09-.157c-.178-.157-.445-.313-.445-.783 0-.313.356-.86 1.245-.86.8 0 1.868.313 3.29.704 1.245.313 2.58.548 3.29.626V7.52L9.724 8.537v.078l1.334 1.017v3.365c-.712.39-1.512.47-2.224.47-1.334 0-2.49-.313-3.47-1.252l3.647-1.565v-5.4L4.565 6.972C4.92 5.955 5.9 5.25 6.878 4.78l-.09-.235c-2.67.626-5.07 2.817-5.07 5.478 0 3.13 2.936 5.478 6.227 5.478 3.558 0 5.87-2.504 5.87-5.087h-.178c-.534 1.017-1.334 1.956-2.313 2.426v-3.21l1.423-1.017v-.078L11.325 7.52V5.094c1.334 0 2.67-.783 2.67-2.27zm-7.74 8.608l-1.067.47c-.623-.704-.98-1.643-.98-2.974 0-.548 0-1.174.178-1.643l1.868-.704zm8.273 2.582c-8.54 4.07-4.27 2.035 0 0z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/openai.svg b/static/images/link-icons/openai.svg new file mode 100644 index 0000000..590ac4c --- /dev/null +++ b/static/images/link-icons/openai.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 14 14"><path d="M6.563 7.142l1.445-.85 1.445.85v1.7l-1.445.85-1.445-.85zm-.595 1.36l-1.2-.68v-3.4c.085-1.445 1.7-2.55 3.145-2.125.255.085 1.275.425.765.595L6.053 4.337c-.255.34-.085.765-.17 1.2v2.9zm7.14-2.38l-2.9-1.7c-.425 0-.68.34-1.105.425l-2.55 1.445v-1.36l2.975-1.7c1.36-.68 3.145.255 3.485 1.615.085.34.085.765.085 1.105zm-1.105 1.7l-3.315-1.87c.425-.17.765-.5 1.2-.68l3.06 1.785a2.55 2.55 0 0 1 .34 3.655c-.17.17-.935.935-1.02.5V8.077c0-.085-.085-.17-.255-.255zM2.567 5.697c.255-.425 1.02-1.2 1.36-1.02v3.23c.17.34.595.425.935.68l2.55 1.445c-.425.255-.765.5-1.2.68l-2.975-1.7c-1.02-.765-1.36-2.295-.68-3.315zm.68 5.865c-.255-.425-.5-1.36-.255-1.615l2.805 1.615c.425 0 .68-.34 1.105-.425l2.55-1.445v1.36l-3.06 1.7c-1.105.425-2.55 0-3.145-1.105zm5.525 2.295c-.5 0-1.445-.255-1.53-.595l2.805-1.615c.255-.34.085-.765.17-1.2V7.482l1.2.68v3.4c-.085 1.275-1.275 2.295-2.55 2.295zm5.1-7.14c.595-1.7-.425-3.74-2.125-4.25-.765-.425-1.785.17-2.295-.5-1.445-1.2-3.74-.935-4.845.595-.425.5-.5 1.2-1.275 1.275-1.615.68-2.465 2.72-1.785 4.335.255.595.85 1.02.5 1.7-.255 1.785 1.2 3.485 2.9 3.74.68.085 1.36-.17 1.785.425 1.445 1.105 3.655.765 4.76-.68.425-.5.5-1.275 1.275-1.275 1.615-.68 2.465-2.72 1.785-4.335a3.4 3.4 0 0 0-.68-1.02z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/orcid.svg b/static/images/link-icons/orcid.svg new file mode 100644 index 0000000..6b79813 --- /dev/null +++ b/static/images/link-icons/orcid.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path fill="black" d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM5.5 4.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM5 7h1v6H5V7zm3 0h2.5a3 3 0 0 1 0 6H8V7zm1 1v4h1.5a2 2 0 0 0 0-4H9z"/> +</svg> diff --git a/static/images/link-icons/pdf.svg b/static/images/link-icons/pdf.svg new file mode 100644 index 0000000..9782667 --- /dev/null +++ b/static/images/link-icons/pdf.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M181.9 256.1c-5-16-4.9-46.9-2-46.9 8.4 0 7.6 36.9 2 46.9zm-1.7 47.2c-7.7 20.2-17.3 43.3-28.4 62.7 18.3-7 39-17.2 62.9-21.9-12.7-9.6-24.9-23.4-34.5-40.8zM86.1 428.1c0 .8 13.2-5.4 34.9-40.2-6.7 6.3-29.1 24.5-34.9 40.2zM248 160h136v328c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V24C0 10.7 10.7 0 24 0h200v136c0 13.2 10.8 24 24 24zm-8 171.8c-20-12.2-33.3-29-42.7-53.8 4.5-18.5 11.6-46.6 6.2-64.2-4.7-29.4-42.4-26.5-47.8-6.8-5 18.3-.4 44.1 8.1 77-11.6 27.6-28.7 64.6-40.8 85.8-.1 0-.1.1-.2.1-27.1 13.9-73.6 44.5-54.5 68 5.6 6.9 16 10 21.5 10 17.9 0 35.7-18 61.1-61.8 25.8-8.5 54.1-19.1 79-23.2 21.7 11.8 47.1 19.5 64 19.5 29.2 0 31.2-32 19.7-43.4-13.9-13.6-54.3-9.7-73.6-7.2zM377 105L279 7c-4.5-4.5-10.6-7-17-7h-6v128h128v-6.1c0-6.3-2.5-12.4-7-16.9zm-74.1 255.3c4.1-2.7-2.5-11.9-42.8-9 37.1 15.8 42.8 9 42.8 9z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/person.svg b/static/images/link-icons/person.svg new file mode 100644 index 0000000..999f3db --- /dev/null +++ b/static/images/link-icons/person.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path fill="black" d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 1a7 7 0 0 0-7 7h14a7 7 0 0 0-7-7z"/> +</svg> diff --git a/static/images/link-icons/reddit.svg b/static/images/link-icons/reddit.svg new file mode 100644 index 0000000..03bed49 --- /dev/null +++ b/static/images/link-icons/reddit.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 341.8 341.8"><ellipse cx="170.9" cy="170.035" rx="170.301" ry="170.95"/><path d="M284.434 170.035c0-13.796-11.154-24.993-24.898-24.993-6.772 0-12.748 2.6-17.13 6.998-16.93-12.196-40.434-20.194-66.328-21.194l11.353-53.384 36.85 7.798c.398 9.397 8.166 16.995 17.727 16.995 9.76 0 17.727-7.998 17.727-17.795s-7.967-17.795-17.727-17.795c-6.97 0-12.947 4-15.735 9.997l-41.23-8.797c-1.195-.2-2.4 0-3.386.6s-1.593 1.6-1.992 2.8l-12.548 59.583c-26.49.8-50.194 8.597-67.324 21.194-4.382-4.2-10.557-6.998-17.13-6.998-13.744 0-24.898 11.197-24.898 24.993 0 10.197 5.975 18.795 14.74 22.793-.398 2.4-.598 5-.598 7.598 0 38.39 44.418 69.38 99.392 69.38s99.392-30.99 99.392-69.38c0-2.6-.2-5-.598-7.398 8.166-4 14.34-12.796 14.34-22.993zm-170.3 17.795c0-9.797 7.967-17.795 17.727-17.795s17.727 7.998 17.727 17.795-7.967 17.795-17.727 17.795-17.727-7.998-17.727-17.795zm98.994 46.986c-12.15 12.196-35.255 12.996-42.027 12.996s-30.077-1-42.027-12.996c-1.793-1.8-1.793-4.8 0-6.598a4.68 4.68 0 0 1 6.573 0c7.57 7.598 23.902 10.397 35.654 10.397s27.886-2.8 35.654-10.397a4.68 4.68 0 0 1 6.573 0c1.394 2 1.394 4.8-.398 6.598zm-3.187-29.19c-9.76 0-17.727-7.998-17.727-17.795s7.967-17.795 17.727-17.795 17.727 7.998 17.727 17.795-7.967 17.795-17.727 17.795z" fill="#fff"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/substack.svg b/static/images/link-icons/substack.svg new file mode 100644 index 0000000..e280b98 --- /dev/null +++ b/static/images/link-icons/substack.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11 12"><path d="M 0,0 H 11 V 1.4 H 0 Z"/><path d="M 0,2.7 H 11 V 4.1 H 0 Z"/><path d="M 0,5.4 H 11 L 11,12 5.5,9 0,12 Z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/tensorflow.svg b/static/images/link-icons/tensorflow.svg new file mode 100644 index 0000000..0d4631e --- /dev/null +++ b/static/images/link-icons/tensorflow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M1.292 5.856L11.54 0v24l-4.095-2.378V7.603l-6.168 3.564.015-5.31zm21.43 5.311l-.014-5.31L12.46 0v24l4.095-2.378V14.87l3.092 1.788-.018-4.618-3.074-1.756V7.603l6.168 3.564z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/tiktok.svg b/static/images/link-icons/tiktok.svg new file mode 100644 index 0000000..d65fa2d --- /dev/null +++ b/static/images/link-icons/tiktok.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M412.19 118.66a109.27 109.27 0 0 1-9.45-5.5 132.87 132.87 0 0 1-24.27-20.62c-18.1-20.71-24.86-41.72-27.35-56.43h.1C349.14 23.9 350 16 350.13 16h-82.44v318.78l-.18 12.69c0 .52-.05 1-.08 1.56 0 .23 0 .47-.05.71v.18a70 70 0 0 1-35.22 55.56 68.8 68.8 0 0 1-34.11 9c-38.41 0-69.54-31.32-69.54-70s31.13-70 69.54-70a68.9 68.9 0 0 1 21.41 3.39l.1-83.94a153.14 153.14 0 0 0-118 34.52 161.79 161.79 0 0 0-35.3 43.53c-3.48 6-16.61 30.11-18.2 69.24-1 22.21 5.67 45.22 8.85 54.73v.2c2 5.6 9.75 24.71 22.38 40.82A167.53 167.53 0 0 0 115 470.66v-.2l.2.2c39.91 27.12 84.16 25.34 84.16 25.34 7.66-.31 33.32 0 62.46-13.81 32.32-15.31 50.72-38.12 50.72-38.12a158.46 158.46 0 0 0 27.64-45.93c7.46-19.61 9.95-43.13 9.95-52.53V176.49c1 .6 14.32 9.41 14.32 9.41s19.19 12.3 49.13 20.31c21.48 5.7 50.42 6.9 50.42 6.9v-81.84c-10.14 1.1-30.73-2.1-51.81-12.61z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/twitter.svg b/static/images/link-icons/twitter.svg new file mode 100644 index 0000000..d84636e --- /dev/null +++ b/static/images/link-icons/twitter.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 612 612"><path d="M612 116.26c-22.525 9.98-46.694 16.75-72.088 19.772 25.93-15.527 45.777-40.155 55.184-69.41C570.774 81 543.927 91.44 515.32 97.1c-22.907-24.437-55.5-39.658-91.63-39.658-69.334 0-125.55 56.217-125.55 125.5 0 9.828 1.11 19.427 3.25 28.606-104.33-5.24-196.84-55.223-258.75-131.17-10.823 18.5-16.98 40.078-16.98 63.1 0 43.56 22.18 81.993 55.835 104.48-20.575-.688-39.926-6.348-56.867-15.756v1.568c0 60.806 43.29 111.55 100.7 123.1-10.517 2.83-21.607 4.398-33.08 4.398a120.5 120.5 0 0 1-23.634-2.333c15.985 49.907 62.336 86.2 117.25 87.194-42.947 33.654-97.1 53.655-155.92 53.655-10.134 0-20.116-.612-29.944-1.72 55.567 35.68 121.54 56.485 192.44 56.485 230.95 0 357.2-191.3 357.2-357.2l-.42-16.253c24.666-17.593 46.005-39.697 62.794-64.86z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/wikipedia.svg b/static/images/link-icons/wikipedia.svg new file mode 100644 index 0000000..0754253 --- /dev/null +++ b/static/images/link-icons/wikipedia.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M640 51.2l-.3 12.2c-28.1.8-45 15.8-55.8 40.3-25 57.8-103.3 240-155.3 358.6H415l-81.9-193.1c-32.5 63.6-68.3 130-99.2 193.1-.3.3-15 0-15-.3C172 352.3 122.8 243.4 75.8 133.4 64.4 106.7 26.4 63.4.2 63.7c0-3.1-.3-10-.3-14.2h161.9v13.9c-19.2 1.1-52.8 13.3-43.3 34.2 21.9 49.7 103.6 240.3 125.6 288.6 15-29.7 57.8-109.2 75.3-142.8-13.9-28.3-58.6-133.9-72.8-160-9.7-17.8-36.1-19.4-55.8-19.7V49.8l142.5.3v13.1c-19.4.6-38.1 7.8-29.4 26.1 18.9 40 30.6 68.1 48.1 104.7 5.6-10.8 34.7-69.4 48.1-100.8 8.9-20.6-3.9-28.6-38.6-29.4.3-3.6 0-10.3.3-13.6 44.4-.3 111.1-.3 123.1-.6v13.6c-22.5.8-45.8 12.8-58.1 31.7l-59.2 122.8c6.4 16.1 63.3 142.8 69.2 156.7L559.2 91.8c-8.6-23.1-36.4-28.1-47.2-28.3V49.6l127.8 1.1.2.5z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/worldcat.svg b/static/images/link-icons/worldcat.svg new file mode 100644 index 0000000..57a15ac --- /dev/null +++ b/static/images/link-icons/worldcat.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="174.46" height="168.206" viewBox="0 0 177.525 171.162" xmlns:v="https://vecta.io/nano"><path d="M100.26.831c9.76-1.34 19.63-.63 29.45-.74 5.17-.12 10.18 3.69 10.97 8.89 1.49 6.53-4.35 13.32-11.03 12.8-13.51-.72-27.67-1.39-40.44 3.85-9.54 4.01-15.41 13.69-17.11 23.6-1.58 5.71-.43 13.39-6.51 16.71-6.64 4.43-16.7-.88-16.52-8.91 1.29-14.8 5.8-30.16 16.57-40.91 9.13-9.25 21.94-13.76 34.62-15.29zm-70.23 6.74c5.65-1.98 12.33 1.72 13.78 7.5 1.4 4.97-1.69 9.53-2.97 14.16-3.91 11.79-7.82 24.71-4.37 37.08 3.2 10.86 13.4 17.39 23.26 21.58 5.15 1.66 8.67 7.33 7.36 12.69-1.16 6.02-8.06 10.07-13.87 7.95-12.81-5.38-25.32-13.27-32.63-25.45-6.76-11.04-8.11-24.62-6.1-37.22 1.49-10.8 5.1-21.14 8.55-31.44 1.02-3.29 3.72-5.85 6.99-6.85zm74.19 27.23c15.27-2.9 32.25-.95 44.79 8.85 13.45 10.41 20.4 26.7 25.34 42.5 1.33 4.91 4.53 9.99 2.54 15.18-1.99 6.04-9.74 9.09-15.27 5.88-2.86-1.4-4.8-4.17-5.56-7.21-3.2-11.39-6.81-22.96-13.76-32.7-3.75-5.34-9.26-9.51-15.6-11.2-7.64-2.07-15.68-.92-23.28.74-5.16 1.53-11.36-1.38-13.08-6.57-1.7-4.25-.17-9.34 3.26-12.25 2.99-2.43 7-2.54 10.62-3.22zm14.84 36.63c4.34-1.28 9.54.14 12.12 4 7.71 12.94 13.47 28.12 10.82 43.4-2.51 15.96-13.69 28.85-25.91 38.64a375.65 375.65 0 0 1-15.48 11.5c-4.47 3.48-11.67 2.75-15.2-1.74-3.83-4.49-2.93-12.16 1.94-15.54 11.91-8.27 25.09-16.59 31.59-30.1 5.26-11.43.36-24.11-5.71-34.14-3.85-5.61-.69-14.16 5.83-16.02zM6.83 102.491c3.6-1.56 8.02-1.09 11.13 1.34 10.53 7.92 21.53 16.1 34.57 19.19 9.67 2.31 19.78-1.58 26.78-8.29 2.9-2.42 5.32-5.32 7.84-8.11 3.94-4.63 11.88-4.49 15.89-.03 3.59 3.81 3.91 10.37.39 14.32-8.13 9.01-17.49 17.55-29.2 21.48-12.76 4.73-27.1 2.85-39.3-2.64-10.75-4.65-20.28-11.54-29.73-18.35-3.09-2.09-4.8-5.77-5.2-9.38.95-3.87 2.83-8.01 6.83-9.53z"/></svg> \ No newline at end of file diff --git a/static/images/link-icons/youtube.svg b/static/images/link-icons/youtube.svg new file mode 100644 index 0000000..4d0ab5a --- /dev/null +++ b/static/images/link-icons/youtube.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"></path></svg> \ No newline at end of file diff --git a/static/js/annotations.js b/static/js/annotations.js new file mode 100644 index 0000000..9abac29 --- /dev/null +++ b/static/js/annotations.js @@ -0,0 +1,292 @@ +/* annotations.js — localStorage-based personal highlights and annotations. + Persists across sessions via localStorage. Re-anchors on page load via + exact text match using a TreeWalker text-stream search. + + Public API (window.Annotations): + .add(text, color, note) → ann object + .remove(id) +*/ +(function () { + 'use strict'; + + var STORAGE_KEY = 'site-annotations'; + var tooltip = null; + var tooltipTimer = null; + var tooltipPinned = false; /* keyboard-opened: blur must not dismiss */ + var tooltipMark = null; /* mark that opened the tooltip, for focus return */ + + /* ------------------------------------------------------------------ + Storage + ------------------------------------------------------------------ */ + + function loadAll() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; } + catch (e) { return []; } + } + + function saveAll(list) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); } + catch (e) {} + } + + function forPage() { + var path = location.pathname; + return loadAll().filter(function (a) { return a.url === path; }); + } + + function uid() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + } + + /* ------------------------------------------------------------------ + CRUD + ------------------------------------------------------------------ */ + + function addRaw(ann) { + var list = loadAll(); + list.push(ann); + saveAll(list); + } + + function removeById(id) { + saveAll(loadAll().filter(function (a) { return a.id !== id; })); + document.querySelectorAll('mark[data-ann-id="' + id + '"]').forEach(function (mark) { + var parent = mark.parentNode; + if (!parent) return; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + }); + hideTooltip(true); + } + + /* ------------------------------------------------------------------ + Text-stream search — finds the first occurrence of searchText in + the visible text of root, skipping existing annotation marks. + ------------------------------------------------------------------ */ + + function findTextRange(searchText, root) { + var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); + var nodes = []; + var node; + while ((node = walker.nextNode())) { + /* Skip text already inside an annotation mark */ + if (node.parentElement && node.parentElement.closest('mark.user-annotation')) continue; + nodes.push(node); + } + + /* Build one long string, tracking each node's span */ + var full = ''; + var spans = []; + nodes.forEach(function (n) { + spans.push({ node: n, start: full.length, end: full.length + n.nodeValue.length }); + full += n.nodeValue; + }); + + var idx = full.indexOf(searchText); + if (idx === -1) return null; + var end = idx + searchText.length; + + var startNode, startOff, endNode, endOff; + for (var i = 0; i < spans.length; i++) { + var s = spans[i]; + if (startNode === undefined && idx >= s.start && idx < s.end) { + startNode = s.node; startOff = idx - s.start; + } + if (endNode === undefined && end > s.start && end <= s.end) { + endNode = s.node; endOff = end - s.start; + } + if (startNode && endNode) break; + } + + if (!startNode || !endNode) return null; + var range = document.createRange(); + range.setStart(startNode, startOff); + range.setEnd(endNode, endOff); + return range; + } + + /* ------------------------------------------------------------------ + Apply a single annotation to the DOM + ------------------------------------------------------------------ */ + + function applyAnnotation(ann) { + var root = document.getElementById('markdownBody') || document.body; + var range = findTextRange(ann.text, root); + if (!range) return false; + + var mark = document.createElement('mark'); + mark.className = 'user-annotation user-annotation--' + ann.color; + mark.setAttribute('data-ann-id', ann.id); + if (ann.note) mark.setAttribute('data-note', ann.note); + mark.setAttribute('data-created', ann.created || ''); + + try { + range.surroundContents(mark); + } catch (e) { + /* Range crosses element boundaries — extract and re-insert */ + var frag = range.extractContents(); + mark.appendChild(frag); + range.insertNode(mark); + } + + bindMarkEvents(mark, ann); + return true; + } + + function applyAll() { + forPage().forEach(function (ann) { applyAnnotation(ann); }); + } + + /* ------------------------------------------------------------------ + Tooltip + ------------------------------------------------------------------ */ + + function initTooltip() { + tooltip = document.createElement('div'); + tooltip.className = 'ann-tooltip'; + tooltip.setAttribute('role', 'tooltip'); + document.body.appendChild(tooltip); + + tooltip.addEventListener('mouseenter', function () { clearTimeout(tooltipTimer); }); + tooltip.addEventListener('mouseleave', function () { hideTooltip(false); }); + + /* Keyboard flow: Escape closes a pinned tooltip and returns focus + to its mark; tabbing out of the tooltip dismisses it. */ + tooltip.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + hideTooltip(true); + if (tooltipMark) tooltipMark.focus(); + } + }); + tooltip.addEventListener('focusout', function (e) { + if (!tooltip.contains(e.relatedTarget)) hideTooltip(false); + }); + } + + /* Defer to the shared utility (loaded synchronously from + templates/partials/head.html) so this file cannot drift from + popups.js, semantic-search.js, or build/Utils.hs. */ + function escHtml(s) { + return window.lnUtils.escapeHtml(s); + } + + function showTooltip(mark, ann) { + clearTimeout(tooltipTimer); + tooltipPinned = false; + tooltipMark = mark; + + var note = ann.note || ''; + var created = ann.created ? new Date(ann.created).toLocaleDateString() : ''; + + tooltip.innerHTML = + (note ? '<div class="ann-tooltip-note">' + escHtml(note) + '</div>' : '') + + '<div class="ann-tooltip-meta">' + + (created ? '<span class="ann-tooltip-date">' + escHtml(created) + '</span>' : '') + + '<button class="ann-tooltip-delete" data-ann-id="' + escHtml(ann.id) + '">Delete</button>' + + '</div>'; + + tooltip.querySelector('.ann-tooltip-delete').addEventListener('click', function () { + removeById(ann.id); + }); + + /* Measure then position */ + tooltip.style.visibility = 'hidden'; + tooltip.classList.add('is-visible'); + + var rect = mark.getBoundingClientRect(); + var tw = tooltip.offsetWidth; + var th = tooltip.offsetHeight; + var sx = window.scrollX, sy = window.scrollY; + var vw = window.innerWidth; + + var left = rect.left + sx + rect.width / 2 - tw / 2; + left = Math.max(sx + 8, Math.min(left, sx + vw - tw - 8)); + + var top = rect.top + sy - th - 8; + if (top < sy + 8) top = rect.bottom + sy + 8; + + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; + tooltip.style.visibility = ''; + } + + function hideTooltip(immediate) { + clearTimeout(tooltipTimer); + tooltipPinned = false; + if (immediate) { + if (tooltip) tooltip.classList.remove('is-visible'); + } else { + tooltipTimer = setTimeout(function () { + if (tooltip) tooltip.classList.remove('is-visible'); + }, 120); + } + } + + function bindMarkEvents(mark, ann) { + mark.addEventListener('mouseenter', function () { + clearTimeout(tooltipTimer); + showTooltip(mark, ann); + }); + mark.addEventListener('mouseleave', function () { hideTooltip(false); }); + + /* Keyboard: focus mirrors hover; Enter/Space pins the tooltip and + moves focus to its Delete button; Escape dismisses. */ + mark.setAttribute('tabindex', '0'); + mark.addEventListener('focus', function () { + clearTimeout(tooltipTimer); + showTooltip(mark, ann); + }); + mark.addEventListener('blur', function () { + if (!tooltipPinned) hideTooltip(false); + }); + mark.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + showTooltip(mark, ann); + tooltipPinned = true; + var del = tooltip.querySelector('.ann-tooltip-delete'); + if (del) del.focus(); + } else if (e.key === 'Escape') { + hideTooltip(true); + } + }); + } + + /* ------------------------------------------------------------------ + Public API + ------------------------------------------------------------------ */ + + window.Annotations = { + add: function (text, color, note) { + var ann = { + id: uid(), + url: location.pathname, + text: text, + color: color || 'amber', + note: note || '', + created: new Date().toISOString(), + }; + addRaw(ann); + applyAnnotation(ann); + return ann; + }, + remove: removeById, + clearAll: function () { + saveAll([]); + document.querySelectorAll('mark.user-annotation').forEach(function (mark) { + var parent = mark.parentNode; + if (!parent) return; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + }); + hideTooltip(true); + }, + }; + + document.addEventListener('DOMContentLoaded', function () { + initTooltip(); + applyAll(); + }); +}()); diff --git a/static/js/collapse.js b/static/js/collapse.js new file mode 100644 index 0000000..43f6d3d --- /dev/null +++ b/static/js/collapse.js @@ -0,0 +1,125 @@ +/* collapse.js — Collapsible h2/h3 sections in essay body. + Self-guards via #markdownBody check; no-ops on non-essay pages. + Persists collapsed state per heading in localStorage. + Retriggered sidenote positioning after each transition via window resize. + + Exposes window.reinitCollapse(container) for use by transclude.js when + newly injected content contains collapsible headings. +*/ +(function () { + 'use strict'; + + /* Keys are namespaced by pathname: Pandoc auto-slugs (#introduction, + #background) recur across essays, and an un-namespaced key would + collapse the same-named section on every page. */ + var PREFIX = 'section-collapsed:' + location.pathname + ':'; + var store = window.lnUtils && window.lnUtils.safeStorage; + + function initHeading(heading) { + // Idempotence guard: reinitCollapse may be called more than once on + // the same container — never re-wrap a section or stack toggle + // buttons (matches the popups.js/sidenotes.js convention). + if (heading.dataset.collapseBound === '1') return; + + var level = parseInt(heading.tagName[1], 10); + var content = []; + var node = heading.nextElementSibling; + + // Collect sibling elements until the next same-or-higher heading. + while (node) { + if (/^H[1-6]$/.test(node.tagName) && + parseInt(node.tagName[1], 10) <= level) break; + content.push(node); + node = node.nextElementSibling; + } + if (!content.length) return; + heading.dataset.collapseBound = '1'; + + // Wrap collected nodes in a .section-body div. + var wrapper = document.createElement('div'); + wrapper.className = 'section-body'; + wrapper.id = 'section-body-' + heading.id; + heading.parentNode.insertBefore(wrapper, content[0]); + content.forEach(function (el) { wrapper.appendChild(el); }); + + // Inject toggle button into the heading. + var btn = document.createElement('button'); + btn.className = 'section-toggle'; + btn.setAttribute('aria-label', 'Toggle section'); + btn.setAttribute('aria-controls', wrapper.id); + heading.appendChild(btn); + + // Restore persisted state without transition flash. + var key = PREFIX + heading.id; + var collapsed = store ? store.get(key) === '1' : false; + + function setCollapsed(c, animate) { + if (!animate) wrapper.style.transition = 'none'; + if (c) { + wrapper.style.maxHeight = '0'; + wrapper.classList.add('is-collapsed'); + btn.setAttribute('aria-expanded', 'false'); + } else { + // Animate: transition 0 → scrollHeight, then release to 'none' + // in transitionend so late-rendering content (e.g. KaTeX) is + // never clipped. No animation: go straight to 'none'. + wrapper.style.maxHeight = animate + ? wrapper.scrollHeight + 'px' + : 'none'; + wrapper.classList.remove('is-collapsed'); + btn.setAttribute('aria-expanded', 'true'); + } + if (!animate) { + // Re-enable transition after layout pass. + requestAnimationFrame(function () { + requestAnimationFrame(function () { + wrapper.style.transition = ''; + }); + }); + } + } + + setCollapsed(collapsed, false); + + btn.addEventListener('click', function (e) { + e.stopPropagation(); + var isCollapsed = wrapper.classList.contains('is-collapsed'); + if (!isCollapsed) { + // Pin height before collapsing so CSS transition has a from-value. + wrapper.style.maxHeight = wrapper.scrollHeight + 'px'; + void wrapper.offsetHeight; // force reflow + } + setCollapsed(!isCollapsed, true); + if (store) store.set(key, isCollapsed ? '0' : '1'); + }); + + // After open animation: release the height cap so late-rendering + // content (KaTeX, images) is never clipped. + // After close animation: cap is already 0, nothing to do. + // Also retrigger sidenote layout after each transition. + wrapper.addEventListener('transitionend', function () { + if (!wrapper.classList.contains('is-collapsed')) { + wrapper.style.maxHeight = 'none'; + } + window.dispatchEvent(new Event('resize')); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + var body = document.getElementById('markdownBody'); + if (!body) return; + if (body.hasAttribute('data-no-collapse')) return; + + var headings = Array.from(body.querySelectorAll('h2[id], h3[id]')); + if (!headings.length) return; + + headings.forEach(initHeading); + }); + + // Public entry point for transclude.js: initialize collapse toggles on + // headings inside a newly injected fragment. + window.reinitCollapse = function (container) { + Array.from(container.querySelectorAll('h2[id], h3[id]')) + .forEach(initHeading); + }; +}()); diff --git a/static/js/copy.js b/static/js/copy.js new file mode 100644 index 0000000..b9c9f26 --- /dev/null +++ b/static/js/copy.js @@ -0,0 +1,54 @@ +/* copy.js — Copy-to-clipboard button for <pre> code blocks. + * + * Injects a .copy-btn into every <pre> element on the page. + * The button is visually hidden until the block is hovered (CSS handles this). + * On click: copies the text content of the block, shows "copied" briefly. + */ + +(function () { + 'use strict'; + + var RESET_DELAY = 1800; /* ms before label reverts to "copy" */ + + function attachButton(pre) { + var btn = document.createElement('button'); + btn.className = 'copy-btn'; + btn.textContent = 'copy'; + btn.setAttribute('aria-label', 'Copy code to clipboard'); + + btn.addEventListener('click', function () { + var code = pre.querySelector('code'); + var text; + if (code) { + text = code.innerText; + } else { + /* Code-less <pre>: clone and strip the injected button so + its label is not copied along with the content. */ + var clone = pre.cloneNode(true); + var cloneBtn = clone.querySelector('.copy-btn'); + if (cloneBtn) cloneBtn.remove(); + text = clone.innerText; + } + + navigator.clipboard.writeText(text).then(function () { + btn.textContent = 'copied'; + btn.setAttribute('data-copied', ''); + setTimeout(function () { + btn.textContent = 'copy'; + btn.removeAttribute('data-copied'); + }, RESET_DELAY); + }).catch(function () { + btn.textContent = 'error'; + setTimeout(function () { + btn.textContent = 'copy'; + }, RESET_DELAY); + }); + }); + + pre.insertBefore(btn, pre.firstChild); + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('pre').forEach(attachButton); + }); +}()); diff --git a/static/js/gallery.js b/static/js/gallery.js new file mode 100644 index 0000000..b41a6d9 --- /dev/null +++ b/static/js/gallery.js @@ -0,0 +1,504 @@ +/* gallery.js — Two orthogonal systems: + 1. Named exhibits (.exhibit[data-exhibit-name]) — TOC integration only. + 2. Math focusables — every .katex-display gets a hover expand button + that opens a full-size overlay. Navigation is global (all focusables + in document order). Group name in overlay comes from nearest exhibit + or heading. */ + +(function () { + 'use strict'; + + function slugify(name) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + } + + /* ============================================================ + NAMED EXHIBITS (TOC integration) + ============================================================ */ + + var exhibits = []; + + function discoverExhibits() { + document.querySelectorAll('.exhibit[data-exhibit-name]').forEach(function (el) { + var name = el.dataset.exhibitName || ''; + var type = el.dataset.exhibitType || 'equation'; + /* Preserve an author-assigned id (e.g. #thm-persistence, used for + cross-references both within the page and from other essays) — + only generate one from the name when the element has none. */ + var id = el.id || ('exhibit-' + slugify(name)); + el.id = id; + exhibits.push({ el: el, type: type, name: name, id: id }); + }); + } + + function initProofExhibit(entry) { + var body = entry.el.querySelector('.exhibit-body'); + if (!body) return; + + var header = document.createElement('div'); + header.className = 'exhibit-header'; + + var label = document.createElement('span'); + label.className = 'exhibit-header-label'; + label.textContent = 'Proof.'; + + var name = document.createElement('span'); + name.className = 'exhibit-header-name'; + name.textContent = entry.name; + + header.appendChild(label); + header.appendChild(name); + entry.el.insertBefore(header, body); + } + + /* ============================================================ + MATH FOCUSABLES + Auto-discover every .katex-display in #markdownBody. + KaTeX must be called with output:'htmlAndMathml' so the + original LaTeX survives in <annotation encoding="application/x-tex">. + ============================================================ */ + + var focusables = []; /* { katexEl, wrapperEl, source, groupName } */ + + function getSource(katexEl) { + var ann = katexEl.querySelector('annotation[encoding="application/x-tex"]'); + return ann ? ann.textContent.trim() : ''; + } + + function getGroupName(katexEl, markdownBody) { + /* Named exhibit takes priority */ + var exhibit = katexEl.closest('.exhibit[data-exhibit-name]'); + if (exhibit) return exhibit.dataset.exhibitName || ''; + + /* Otherwise: nearest preceding heading */ + var headings = Array.from(markdownBody.querySelectorAll(':is(h1,h2,h3,h4,h5,h6)')); + var nearest = null; + headings.forEach(function (h) { + if (h.compareDocumentPosition(katexEl) & Node.DOCUMENT_POSITION_FOLLOWING) { + nearest = h; + } + }); + return nearest ? nearest.textContent.trim() : ''; + } + + function getCaption(katexEl) { + var exhibit = katexEl.closest('.exhibit[data-exhibit-caption]'); + if (!exhibit) return ''; + /* A proof's caption belongs to the proof as a whole, not to each + individual equation line within it. Only propagate for equation + exhibits where the math IS the primary content. */ + if (exhibit.dataset.exhibitType === 'proof') return ''; + return exhibit.dataset.exhibitCaption || ''; + } + + /* Make an exhibit wrapper keyboard-operable: role=button, tabindex, + and Enter/Space sharing the click path. closeOverlay()'s focus + return relies on the wrapper being focusable. */ + function bindActivation(el, activate) { + el.setAttribute('role', 'button'); + el.setAttribute('tabindex', '0'); + el.addEventListener('click', activate); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activate(); + } + }); + } + + function discoverFocusableMath(markdownBody) { + markdownBody.querySelectorAll('.katex-display').forEach(function (katexEl) { + var source = getSource(katexEl); + var groupName = getGroupName(katexEl, markdownBody); + var caption = getCaption(katexEl); + + /* Wrap in .math-focusable — the entire wrapper is the click target */ + var wrapper = document.createElement('div'); + wrapper.className = 'math-focusable'; + if (caption) wrapper.dataset.caption = caption; /* drives CSS ::after tooltip */ + katexEl.parentNode.insertBefore(wrapper, katexEl); + wrapper.appendChild(katexEl); + + /* Decorative expand glyph (pointer-events: none in CSS) */ + var glyph = document.createElement('span'); + glyph.className = 'exhibit-expand'; + glyph.setAttribute('aria-hidden', 'true'); + glyph.textContent = '⤢'; + wrapper.appendChild(glyph); + + var entry = { + type: 'math', + katexEl: katexEl, + wrapperEl: wrapper, + source: source, + groupName: groupName, + caption: caption + }; + focusables.push(entry); + + /* Click or Enter/Space anywhere on the wrapper opens the overlay */ + bindActivation(wrapper, function () { + openOverlay(focusables.indexOf(entry)); + }); + }); + } + + function discoverFocusableScores(markdownBody) { + markdownBody.querySelectorAll('.score-fragment').forEach(function (figEl) { + var svgEl = figEl.querySelector('svg'); + if (!svgEl) return; + + var captionEl = figEl.querySelector('.score-caption'); + var captionText = captionEl ? captionEl.textContent.trim() : ''; + var name = figEl.dataset.exhibitName || ''; + var groupName = name || getGroupName(figEl, markdownBody); + + /* Expand glyph — decorative affordance, same as math focusables */ + var glyph = document.createElement('span'); + glyph.className = 'exhibit-expand'; + glyph.setAttribute('aria-hidden', 'true'); + glyph.textContent = '⤢'; + figEl.appendChild(glyph); + + var entry = { + type: 'score', + wrapperEl: figEl, + svgEl: svgEl, + groupName: groupName, + caption: captionText + }; + focusables.push(entry); + + bindActivation(figEl, function () { + openOverlay(focusables.indexOf(entry)); + }); + }); + } + + /* ============================================================ + OVERLAY + ============================================================ */ + + var overlay, overlayGroup, overlayBody, overlayCaption; + var overlayPrev, overlayNext, overlayCounter, overlayClose; + var currentIdx = -1; + + function buildOverlay() { + overlay = document.createElement('div'); + overlay.id = 'gallery-overlay'; + overlay.setAttribute('hidden', ''); + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + + /* All children are absolute or flex-centered — no panel wrapper */ + + overlayClose = document.createElement('button'); + overlayClose.id = 'gallery-overlay-close'; + overlayClose.setAttribute('aria-label', 'Close'); + overlayClose.textContent = '✕'; + overlay.appendChild(overlayClose); + + overlayGroup = document.createElement('div'); + overlayGroup.id = 'gallery-overlay-name'; + overlay.appendChild(overlayGroup); + + overlayBody = document.createElement('div'); + overlayBody.id = 'gallery-overlay-body'; + overlay.appendChild(overlayBody); + + overlayCaption = document.createElement('div'); + overlayCaption.id = 'gallery-overlay-caption'; + overlay.appendChild(overlayCaption); + + overlayCounter = document.createElement('div'); + overlayCounter.id = 'gallery-overlay-counter'; + overlay.appendChild(overlayCounter); + + overlayPrev = document.createElement('button'); + overlayPrev.id = 'gallery-overlay-prev'; + overlayPrev.className = 'gallery-nav-btn'; + overlayPrev.setAttribute('aria-label', 'Previous equation'); + overlayPrev.textContent = '←'; + overlay.appendChild(overlayPrev); + + overlayNext = document.createElement('button'); + overlayNext.id = 'gallery-overlay-next'; + overlayNext.className = 'gallery-nav-btn'; + overlayNext.setAttribute('aria-label', 'Next equation'); + overlayNext.textContent = '→'; + overlay.appendChild(overlayNext); + + document.body.appendChild(overlay); + + /* Clicking the dark surround (not the content stage) closes */ + overlay.addEventListener('click', function (e) { + if (e.target === overlay) closeOverlay(); + }); + overlayClose.addEventListener('click', closeOverlay); + overlayPrev.addEventListener('click', function (e) { e.stopPropagation(); navigate(-1); }); + overlayNext.addEventListener('click', function (e) { e.stopPropagation(); navigate(+1); }); + + document.addEventListener('keydown', function (e) { + if (overlay.hasAttribute('hidden')) return; + if (e.key === 'Escape') { closeOverlay(); return; } + if (e.key === 'ArrowLeft') { navigate(-1); return; } + if (e.key === 'ArrowRight') { navigate(+1); return; } + if (e.key === 'Tab') { trapTab(e); return; } + }); + } + + /* Focus trap for the overlay: cycle Tab/Shift+Tab through the + focusable controls inside #gallery-overlay so keyboard users + cannot tab out into the (currently inert) page background. */ + function trapTab(e) { + var focusable = Array.from(overlay.querySelectorAll( + 'button:not([disabled]), [tabindex]:not([tabindex="-1"])' + )); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + var active = document.activeElement; + if (e.shiftKey) { + if (active === first || !overlay.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + if (active === last || !overlay.contains(active)) { + e.preventDefault(); + first.focus(); + } + } + } + + function openOverlay(idx) { + currentIdx = idx; + /* Show before rendering — measurements (scrollWidth etc.) return 0 + on elements inside display:none, so the fit loop needs the overlay + to be visible before it runs. The browser will not repaint until + JS yields, so the user sees only the final fitted size. */ + overlay.removeAttribute('hidden'); + renderOverlay(); + overlayClose.focus(); + } + + function closeOverlay() { + var returnTo = currentIdx >= 0 ? focusables[currentIdx].wrapperEl : null; + overlay.setAttribute('hidden', ''); + currentIdx = -1; + if (returnTo) returnTo.focus(); + } + + function navigate(delta) { + var next = currentIdx + delta; + if (next < 0 || next >= focusables.length) return; + currentIdx = next; + renderOverlay(); + focusables[currentIdx].wrapperEl.scrollIntoView({ + behavior: 'instant', + block: 'center' + }); + } + + function renderOverlay() { + var entry = focusables[currentIdx]; + + overlayGroup.textContent = entry.groupName; + + if (entry.type === 'score') { + overlayBody.className = 'is-score'; + overlayBody.style.overflow = 'hidden'; + overlayBody.innerHTML = ''; + overlayBody.appendChild(entry.svgEl.cloneNode(true)); + } else { + overlayBody.className = ''; + overlayBody.style.overflow = 'hidden'; + + /* Re-render from source, or clone rendered HTML */ + if (entry.source && typeof katex !== 'undefined') { + try { + overlayBody.innerHTML = katex.renderToString(entry.source, { + displayMode: true, + throwOnError: false + }); + } catch (e) { + overlayBody.innerHTML = entry.katexEl.outerHTML; + } + } else { + overlayBody.innerHTML = entry.katexEl.outerHTML; + } + + /* Fit font size — set directly on .katex-display to avoid cascade. + The overlay must already be visible (not display:none) for + scrollWidth/clientWidth to return real values. */ + var katexEl = overlayBody.querySelector('.katex-display'); + if (katexEl) { + var maxSize = 1.4; + var minSize = 0.4; + var step = 0.05; + var fitted = false; + for (var fs = maxSize; fs >= minSize; fs -= step) { + katexEl.style.fontSize = fs + 'em'; + if (overlayBody.scrollWidth <= overlayBody.clientWidth && + overlayBody.scrollHeight <= overlayBody.clientHeight) { + fitted = true; + break; + } + } + if (!fitted) overlayBody.style.overflow = 'auto'; /* absolute last resort */ + } + } + + overlayCaption.textContent = entry.caption || ''; + overlayCaption.hidden = !entry.caption; + + var total = focusables.length; + overlayCounter.textContent = (currentIdx + 1) + ' / ' + total; + overlayPrev.disabled = (currentIdx === 0); + overlayNext.disabled = (currentIdx === total - 1); + } + + /* ============================================================ + TOC INTEGRATION + ============================================================ */ + + function patchTOC() { + var toc = document.getElementById('toc'); + if (!toc || exhibits.length === 0) return; + + var headings = Array.from( + document.querySelectorAll('#markdownBody :is(h1,h2,h3,h4,h5,h6)[id]') + ); + + var headingMap = new Map(); + exhibits.forEach(function (entry) { + var nearest = null; + headings.forEach(function (h) { + if (h.compareDocumentPosition(entry.el) & Node.DOCUMENT_POSITION_FOLLOWING) { + nearest = h; + } + }); + if (nearest) { + if (!headingMap.has(nearest.id)) headingMap.set(nearest.id, []); + headingMap.get(nearest.id).push(entry); + } + }); + + toc.querySelectorAll('a[data-target]').forEach(function (link) { + var list = headingMap.get(link.dataset.target); + if (!list || list.length === 0) return; + + var row = document.createElement('div'); + row.className = 'toc-exhibits-inline'; + + list.forEach(function (entry) { + var a = document.createElement('a'); + a.href = '#' + entry.id; + + var badge = document.createElement('span'); + badge.className = 'toc-exhibit-type-badge'; + badge.textContent = entry.type; + a.appendChild(badge); + a.appendChild(document.createTextNode(entry.name)); + row.appendChild(a); + }); + + var li = link.closest('li'); + if (li) li.appendChild(row); + }); + + /* Contained Herein */ + var tocNav = toc.querySelector('.toc-nav'); + if (!tocNav) return; + + var contained = document.createElement('div'); + contained.className = 'toc-contained'; + + var toggleBtn = document.createElement('button'); + toggleBtn.className = 'toc-contained-toggle'; + toggleBtn.setAttribute('aria-expanded', 'false'); + + var arrow = document.createElement('span'); + arrow.className = 'toc-contained-arrow'; + arrow.textContent = '▶'; + toggleBtn.appendChild(arrow); + toggleBtn.appendChild(document.createTextNode(' Contained Herein')); + contained.appendChild(toggleBtn); + + var ul = document.createElement('ul'); + ul.className = 'toc-contained-list'; + + exhibits.forEach(function (entry) { + var li = document.createElement('li'); + var a = document.createElement('a'); + a.href = '#' + entry.id; + + var badge = document.createElement('span'); + badge.className = 'toc-exhibit-type-badge'; + badge.textContent = entry.type; + a.appendChild(badge); + a.appendChild(document.createTextNode(entry.name)); + li.appendChild(a); + ul.appendChild(li); + }); + + contained.appendChild(ul); + tocNav.appendChild(contained); + + toggleBtn.addEventListener('click', function () { + var open = contained.classList.toggle('is-open'); + toggleBtn.setAttribute('aria-expanded', String(open)); + }); + } + + /* ============================================================ + ANNOTATIONS (collapsible .annotation--collapsible boxes) + ============================================================ */ + + function initAnnotations() { + document.querySelectorAll('.annotation--collapsible').forEach(function (el) { + var toggle = el.querySelector('.annotation-toggle'); + var body = el.querySelector('.annotation-body'); + if (!toggle || !body) return; + + function setOpen(open) { + el.classList.toggle('is-open', open); + toggle.setAttribute('aria-expanded', String(open)); + toggle.textContent = open ? '▾ collapse' : '▸ expand'; + body.style.maxHeight = open ? body.scrollHeight + 'px' : '0'; + } + + setOpen(false); + toggle.addEventListener('click', function () { + setOpen(!el.classList.contains('is-open')); + }); + }); + } + + /* ============================================================ + INIT + ============================================================ */ + + document.addEventListener('DOMContentLoaded', function () { + var markdownBody = document.getElementById('markdownBody'); + + discoverExhibits(); + exhibits.forEach(function (entry) { + if (entry.type === 'proof') initProofExhibit(entry); + }); + + if (markdownBody) { + discoverFocusableMath(markdownBody); + discoverFocusableScores(markdownBody); + if (focusables.length > 0) buildOverlay(); + } + + if (exhibits.length > 0) patchTOC(); + + initAnnotations(); + }); + +})(); diff --git a/static/js/katex-bootstrap.js b/static/js/katex-bootstrap.js new file mode 100644 index 0000000..1c553cb --- /dev/null +++ b/static/js/katex-bootstrap.js @@ -0,0 +1,40 @@ +/* katex-bootstrap.js — Render every <span class="math"> / <div class="math"> + block once KaTeX has finished loading. + + Pandoc emits math blocks with the `math` class and the LaTeX source as + the element's text content. KaTeX is loaded with `defer` so this + bootstrap can simply run on DOMContentLoaded — KaTeX guarantees its + own definitions are available by then. + + Used to live as an inline `onload="..."` attribute on the KaTeX + <script> tag in templates/default.html, which blocked any future + strict CSP. Externalized here so the entire site can run with + `script-src 'self'` plus a single CDN allowance. +*/ +(function () { + 'use strict'; + + function renderAll() { + if (typeof katex === 'undefined') return; + var nodes = Array.from(document.getElementsByClassName('math')); + nodes.forEach(function (el) { + if (el.tagName !== 'SPAN' && el.tagName !== 'DIV') return; + var src = el.textContent; + try { + katex.render(src, el, { + displayMode: el.classList.contains('display'), + output: 'htmlAndMathml', + throwOnError: false + }); + } catch (_) { + /* leave the original source visible if KaTeX rejects it */ + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', renderAll); + } else { + renderAll(); + } +})(); diff --git a/static/js/lightbox.js b/static/js/lightbox.js new file mode 100644 index 0000000..ae5a503 --- /dev/null +++ b/static/js/lightbox.js @@ -0,0 +1,260 @@ +(function () { + 'use strict'; + + document.addEventListener('DOMContentLoaded', function () { + + // ---------------------------------------------------------------- + // Build the overlay DOM + // ---------------------------------------------------------------- + + var overlay = document.createElement('div'); + overlay.className = 'lightbox-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Image lightbox'); + + var img = document.createElement('img'); + img.className = 'lightbox-img'; + /* Default accessible name; overwritten in open() with the + triggering image's alt when present. Avoids a nameless + lightbox image when the source img has no alt text. */ + img.alt = 'Lightbox image'; + + var caption = document.createElement('p'); + caption.className = 'lightbox-caption'; + + var closeBtn = document.createElement('button'); + closeBtn.className = 'lightbox-close'; + closeBtn.setAttribute('aria-label', 'Close lightbox'); + closeBtn.textContent = '×'; + + // Darkroom-mode: photography pages only. Three additional + // elements layered behind / beneath the photo: a vignette + // overlay, an info panel showing EXIF-style metadata, and an + // "i" toggle button for revealing/hiding the panel. + // All three sit dormant on non-photography pages — the + // "darkroom" class on the overlay gates their visibility in CSS. + var vignette = document.createElement('div'); + vignette.className = 'lightbox-vignette'; + vignette.setAttribute('aria-hidden', 'true'); + + var infoPanel = document.createElement('div'); + infoPanel.className = 'lightbox-info-panel'; + infoPanel.setAttribute('aria-hidden', 'true'); + + var infoBtn = document.createElement('button'); + infoBtn.className = 'lightbox-info-toggle'; + infoBtn.setAttribute('aria-label', 'Toggle photo metadata'); + infoBtn.setAttribute('aria-pressed', 'false'); + infoBtn.textContent = 'ℹ'; // ℹ — information source + + overlay.appendChild(vignette); + overlay.appendChild(closeBtn); + overlay.appendChild(infoBtn); + overlay.appendChild(img); + overlay.appendChild(caption); + overlay.appendChild(infoPanel); + document.body.appendChild(overlay); + + // ---------------------------------------------------------------- + // Darkroom helpers — populate / clear info panel + // ---------------------------------------------------------------- + + function isDarkroomPage() { + return document.body.dataset.pageType === 'photography'; + } + + // Mapping from data-photo-* attribute to the human-readable + // label shown in the panel. Order is the rendering order; only + // attributes present on the trigger image produce panel rows. + var PANEL_FIELDS = [ + ['photoCaptured', 'Captured'], + ['photoLocation', 'Location'], + ['photoCamera', 'Camera'], + ['photoLens', 'Lens'], + ['photoFilm', 'Film'], + ['photoExposure', 'Exposure'] + ]; + + function populateInfoPanel(triggerImg) { + infoPanel.innerHTML = ''; + if (!triggerImg || !triggerImg.dataset) return false; + var dl = document.createElement('dl'); + var any = false; + PANEL_FIELDS.forEach(function (entry) { + var key = entry[0]; + var label = entry[1]; + var value = triggerImg.dataset[key]; + if (!value) return; + any = true; + var dt = document.createElement('dt'); + dt.textContent = label; + var dd = document.createElement('dd'); + dd.textContent = value; + dl.appendChild(dt); + dl.appendChild(dd); + }); + if (any) infoPanel.appendChild(dl); + return any; + } + + function setInfoVisible(visible) { + overlay.classList.toggle('is-info-visible', visible); + infoPanel.setAttribute('aria-hidden', visible ? 'false' : 'true'); + infoBtn.setAttribute('aria-pressed', visible ? 'true' : 'false'); + } + + // ---------------------------------------------------------------- + // Open / close helpers + // ---------------------------------------------------------------- + + var triggerEl = null; + + function open(src, alt, captionText, trigger) { + triggerEl = trigger || null; + img.src = src; + /* Prefer the source img's alt; fall back to the figure + caption; fall back to a generic label so the lightbox + image always has an accessible name. */ + img.alt = alt || captionText || 'Lightbox image'; + caption.textContent = captionText || ''; + caption.hidden = !captionText; + + // Darkroom mode is keyed off body data-page-type. The + // class is set BEFORE is-open so the dark backdrop is in + // place at the start of the transition rather than fading + // in over the existing one. + var darkroom = isDarkroomPage(); + overlay.classList.toggle('darkroom', darkroom); + var hasInfo = darkroom ? populateInfoPanel(triggerEl) : false; + infoBtn.hidden = !hasInfo; + setInfoVisible(false); + + overlay.classList.add('is-open'); + document.documentElement.style.overflow = 'hidden'; + closeBtn.focus(); + } + + function close() { + overlay.classList.remove('is-open'); + setInfoVisible(false); + document.documentElement.style.overflow = ''; + if (triggerEl) { + triggerEl.focus(); + triggerEl = null; + } + // Clear src after transition to stop background loading. + // The darkroom class is also cleared on the same delay so + // the page chrome doesn't re-appear on top of a fading + // black backdrop. + var delay = parseFloat( + getComputedStyle(overlay).transitionDuration || '0' + ) * 1000; + setTimeout(function () { + if (!overlay.classList.contains('is-open')) { + img.src = ''; + overlay.classList.remove('darkroom'); + } + }, delay + 50); + } + + // ---------------------------------------------------------------- + // Wire up lightbox-marked images + // ---------------------------------------------------------------- + + var images = document.querySelectorAll('img[data-lightbox]'); + + images.forEach(function (el) { + // Keyboard activation: the trigger acts as a button, and the + // tabindex also lets close() return focus to it. + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + + function activate() { + // Look for a sibling figcaption in the parent figure + var figcaptionText = ''; + var parent = el.parentElement; + if (parent) { + var figcaption = parent.querySelector('figcaption'); + if (figcaption) { + figcaptionText = figcaption.textContent.trim(); + } + } + open(el.src, el.alt, figcaptionText, el); + } + + el.addEventListener('click', activate); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activate(); + } + }); + }); + + // ---------------------------------------------------------------- + // Close handlers + // ---------------------------------------------------------------- + + // Close button + closeBtn.addEventListener('click', close); + + // Click on overlay background (not the image itself) + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { + close(); + } + }); + + // Info-panel button (darkroom only — gated by .darkroom class + // on overlay; CSS hides the button on non-photography pages). + infoBtn.addEventListener('click', function () { + setInfoVisible(!overlay.classList.contains('is-info-visible')); + }); + + /* Focus trap for the overlay: cycle Tab/Shift+Tab through the + focusable controls inside the lightbox so keyboard users + cannot tab out into the obscured page background. Same + approach as gallery.js's trapTab; the [hidden] exclusion + covers infoBtn, which is hidden outside darkroom mode. */ + function trapTab(e) { + var focusable = Array.from(overlay.querySelectorAll( + 'button:not([disabled]):not([hidden]), [tabindex]:not([tabindex="-1"])' + )); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + var active = document.activeElement; + if (e.shiftKey) { + if (active === first || !overlay.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + if (active === last || !overlay.contains(active)) { + e.preventDefault(); + first.focus(); + } + } + } + + // Escape closes; Tab is trapped; "i" toggles info panel (darkroom only). + document.addEventListener('keydown', function (e) { + if (!overlay.classList.contains('is-open')) return; + if (e.key === 'Escape') { + close(); + } else if (e.key === 'Tab') { + trapTab(e); + } else if ((e.key === 'i' || e.key === 'I') + && overlay.classList.contains('darkroom') + && !infoBtn.hidden) { + setInfoVisible(!overlay.classList.contains('is-info-visible')); + } + }); + + }); + +}()); diff --git a/static/js/list-pagination.js b/static/js/list-pagination.js new file mode 100644 index 0000000..485f02c --- /dev/null +++ b/static/js/list-pagination.js @@ -0,0 +1,26 @@ +(function () { + var STORAGE_KEY = 'list-page-count'; + var DEFAULT = 25; + + function applyCount(n) { + var entries = document.querySelectorAll('.item-card'); + var limit = (n === 'all') ? Infinity : parseInt(n, 10); + entries.forEach(function (el, i) { + el.hidden = i >= limit; + }); + document.querySelectorAll('.list-count-btn').forEach(function (btn) { + btn.classList.toggle('is-active', btn.dataset.count === String(n)); + }); + try { localStorage.setItem(STORAGE_KEY, n); } catch (e) {} + } + + document.addEventListener('DOMContentLoaded', function () { + var saved; + try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {} + applyCount(saved || DEFAULT); + + document.querySelectorAll('.list-count-btn').forEach(function (btn) { + btn.addEventListener('click', function () { applyCount(btn.dataset.count); }); + }); + }); +}()); diff --git a/static/js/nav.js b/static/js/nav.js new file mode 100644 index 0000000..4834134 --- /dev/null +++ b/static/js/nav.js @@ -0,0 +1,43 @@ +/* nav.js — Portal row expand/collapse with localStorage persistence. + Loaded with defer. +*/ +(function () { + const STORAGE_KEY = 'portals-open'; + + document.addEventListener('DOMContentLoaded', function () { + // Return-to-top button + var totop = document.querySelector('.footer-totop'); + if (totop) { + totop.addEventListener('click', function () { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + } + + const portals = document.querySelector('.nav-portals'); + const toggle = document.querySelector('.nav-portal-toggle'); + if (!portals || !toggle) return; + + // safeStorage (utils.js, loaded synchronously before us) so a + // storage-blocked context can't throw before the click listener + // below binds; guarded like theme.js in case utils.js itself + // failed to load. + const store = window.lnUtils && window.lnUtils.safeStorage; + + function setOpen(open) { + portals.classList.toggle('is-open', open); + toggle.setAttribute('aria-expanded', String(open)); + // Rotate arrow indicator if present. + const arrow = toggle.querySelector('.nav-portal-arrow'); + if (arrow) arrow.textContent = open ? '▲' : '▼'; + if (store) store.set(STORAGE_KEY, open ? '1' : '0'); + } + + // Restore persisted state; default is collapsed. + const stored = store ? store.get(STORAGE_KEY) : null; + setOpen(stored === '1'); + + toggle.addEventListener('click', function () { + setOpen(!portals.classList.contains('is-open')); + }); + }); +})(); diff --git a/static/js/now.js b/static/js/now.js new file mode 100644 index 0000000..4c372cf --- /dev/null +++ b/static/js/now.js @@ -0,0 +1,75 @@ +/* now.js — Keep the Current page's "Last updated" relative phrase + honest. + + build/Now.hs renders `.now-stamp-relative` ("3 days ago") at build + time, relative to the build machine's clock. A page served days + later from cache/CDN would then lie. We recompute the phrase in the + browser from the `<time datetime>` attribute (an unambiguous + YYYY-MM-DD), against the visitor's own clock. + + The bucket thresholds below mirror `relativeTime` in build/Now.hs + exactly — keep the two in sync. The server-rendered text remains the + no-JS fallback and is only replaced once we've recomputed. */ +(function () { + 'use strict'; + + function relative(days) { + if (days < 0) return ''; // future / clock skew + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 7) return days + ' days ago'; + + var n, unit; + if (days < 28) { n = Math.floor(days / 7); unit = 'week'; } + else if (days < 365) { n = Math.floor(days / 30); unit = 'month'; } + else { n = Math.floor(days / 365); unit = 'year'; } + return n === 1 ? ('1 ' + unit + ' ago') + : (n + ' ' + unit + 's ago'); + } + + function update() { + var stamp = document.querySelector('.now-stamp'); + if (!stamp) return; + + var timeEl = stamp.querySelector('.now-stamp-date'); + if (!timeEl) return; + + var iso = timeEl.getAttribute('datetime'); + var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); + if (!m) return; // unparseable — leave the SSR fallback as-is + + // Calendar-day difference, computed via UTC epoch days so DST + // transitions can't add or drop a day. "today" uses the + // visitor's *local* date components, matching what they'd + // read off a wall calendar. + var then = Date.UTC(+m[1], +m[2] - 1, +m[3]); + var local = new Date(); + var today = Date.UTC( + local.getFullYear(), + local.getMonth(), + local.getDate() + ); + var days = Math.round((today - then) / 86400000); + var text = relative(days); + + var rel = stamp.querySelector('.now-stamp-relative'); + if (!text) { + // No meaningful relative phrase (e.g. dated in the future): + // drop any stale server-rendered one rather than keep a lie. + if (rel) rel.remove(); + return; + } + if (!rel) { + rel = document.createElement('span'); + rel.className = 'now-stamp-relative'; + stamp.appendChild(rel); + } + rel.textContent = text; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', update); + } else { + update(); + } +})(); diff --git a/static/js/photography-map.js b/static/js/photography-map.js new file mode 100644 index 0000000..ea7eda9 --- /dev/null +++ b/static/js/photography-map.js @@ -0,0 +1,279 @@ +/* Photography section — Leaflet map. + * + * Loaded only on /photography/map/ via the photography-map context flag + * gating in templates/partials/head.html and templates/default.html. + * + * Pin source: /photography/map.json — emitted by the Hakyll + * photographyMapDataRule, with city-precision (or per-photo override) + * coordinate rounding applied at build time. Full-precision coords + * never reach the client. + * + * Tile source: CartoDB Positron — free for all volumes; required + * attribution is wired in below. Subdomains a-d are load-balanced. + * + * Marker behavior: + * * Click: navigate to the photo entry page. + * * Hover: tooltip with thumbnail + title + captured date. + * * Dense areas: leaflet.markercluster groups overlapping pins, + * expanding on click. + * + * The page chrome (header, toggle, attribution paragraph) renders + * pre-JS so search engines and no-JS readers see the orientation + * copy. Only the map viewport itself depends on Leaflet loading. + */ +(function () { + 'use strict'; + + var MAP_DATA_URL = '/photography/map.json'; + var MAP_ELEMENT = 'photography-map'; + var TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; + var TILE_ATTRIB = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> ' + + 'contributors © <a href="https://carto.com/attributions">CARTO</a>'; + var TILE_SUBDOMS = 'abcd'; + var FALLBACK_VIEW = [20, 0]; // [lat, lon] when there are zero pins + var FALLBACK_ZOOM = 2; + + // Override the default Leaflet marker icon paths so they resolve + // to the vendored copy under /leaflet/images/. Leaflet's default + // resolution uses the URL of leaflet.js, which fails for vendored + // setups since the script lives in /js/, not /leaflet/. + function configureMarkerIconPaths() { + if (typeof L === 'undefined' || !L.Icon || !L.Icon.Default) return; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: '/leaflet/images/marker-icon-2x.png', + iconUrl: '/leaflet/images/marker-icon.png', + shadowUrl: '/leaflet/images/marker-shadow.png' + }); + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // ------------------------------------------------------------------ + // Place-level helpers + // ------------------------------------------------------------------ + + // "Munich, Germany" -> "Munich". The country repeats across every pin in + // a country-sized view and earns no space in a marker label. + // + // A rounded coordinate can hold more than one place — Bagsværd and + // Kongens Lyngby are four kilometres apart and land on the same + // city-precision point — so the label lists every distinct one rather + // than naming whichever frame happened to come first and quietly + // mislabelling the rest. + function placeLabel(group) { + var seen = []; + group.pins.forEach(function (p) { + if (!p.location) return; + var name = p.location.split(',')[0].trim(); + if (name && seen.indexOf(name) === -1) seen.push(name); + }); + if (seen.length) return seen.join(' · '); + return group.pins[0].title || 'Untitled'; + } + + // A click should land somewhere that shows the place. If every frame here + // belongs to one series, that series is the answer; a lone frame goes to + // itself; a mixed pile has no single right destination, so it gets none + // and stays a hover-only marker. + function groupTarget(group) { + if (group.pins.length === 1) return group.pins[0].url || null; + var series = group.pins[0].series; + if (!series) return null; + var shared = group.pins.every(function (p) { return p.series === series; }); + return shared ? '/photography/' + series + '/' : null; + } + + function groupTooltipHtml(group) { + var count = group.pins.length; + if (count === 1) return tooltipHtml(group.pins[0]); + + var strip = group.pins.slice(0, 4).map(function (p) { + return p.thumb + ? '<img class="photography-map-strip-img" src="' + + escapeHtml(p.thumb) + '" alt="" loading="lazy">' + : ''; + }).join(''); + + var dates = group.pins + .map(function (p) { return p.captured; }) + .filter(Boolean) + .sort(); + var when = dates.length + ? (dates[0] === dates[dates.length - 1] + ? dates[0] + : dates[0] + ' – ' + dates[dates.length - 1]) + : ''; + + return '<div class="photography-map-tooltip photography-map-tooltip--group">' + + '<div class="photography-map-strip">' + strip + '</div>' + + '<div class="photography-map-tooltip-title">' + escapeHtml(placeLabel(group)) + '</div>' + + '<div class="photography-map-tooltip-meta">' + count + ' photographs' + + (when ? ' · ' + escapeHtml(when) : '') + '</div>' + + '</div>'; + } + + function tooltipHtml(pin) { + var thumb = pin.thumb + ? '<img class="photography-map-tooltip-img" src="' + escapeHtml(pin.thumb) + '" alt="" loading="lazy">' + : ''; + var date = pin.captured + ? '<div class="photography-map-tooltip-date">' + escapeHtml(pin.captured) + '</div>' + : ''; + return '' + + '<div class="photography-map-tooltip">' + + thumb + + '<div class="photography-map-tooltip-title">' + escapeHtml(pin.title || '(untitled)') + '</div>' + + date + + '</div>'; + } + + function renderEmptyState(container) { + container.classList.add('photography-map--empty'); + container.innerHTML = + '<p class="photography-map-empty">' + + 'No geo-tagged photographs yet. Photos with a ' + + '<code>geo:</code> frontmatter field will appear here.' + + '</p>'; + } + + function renderErrorState(container, message) { + container.classList.add('photography-map--error'); + container.innerHTML = + '<p class="photography-map-error">' + + escapeHtml(message || 'Could not load the map.') + + '</p>'; + } + + document.addEventListener('DOMContentLoaded', function () { + var container = document.getElementById(MAP_ELEMENT); + if (!container) return; + + // Leaflet must be present; the conditional script load in + // default.html should guarantee this on /photography/map/, but + // a defensive fallback is cheap. + if (typeof L === 'undefined') { + renderErrorState(container, 'Map library failed to load.'); + return; + } + + configureMarkerIconPaths(); + + fetch(MAP_DATA_URL, { cache: 'force-cache' }) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function (pins) { + if (!Array.isArray(pins) || pins.length === 0) { + renderEmptyState(container); + return; + } + + var map = L.map(container, { + scrollWheelZoom: false, // require explicit interaction + zoomControl: true, + attributionControl: true + }).setView(FALLBACK_VIEW, FALLBACK_ZOOM); + + L.tileLayer(TILE_URL, { + attribution: TILE_ATTRIB, + subdomains: TILE_SUBDOMS, + maxZoom: 19 + }).addTo(map); + + // One marker per PLACE, not per photograph. + // + // geo-precision rounds coordinates before they reach this + // file, so every frame from a city shares one position + // exactly. Adding them as individual markers gave + // markercluster nothing to separate: clicking a cluster + // spiderfied 28 identical points into a starburst of legs + // radiating from a single spot — a drawing of the clustering + // algorithm rather than of anywhere. Aggregating first says + // what the data actually says: two places, with counts. + var groups = {}; + pins.forEach(function (pin) { + if (typeof pin.lat !== 'number' || typeof pin.lon !== 'number') return; + var key = pin.lat + ',' + pin.lon; + if (!groups[key]) { + groups[key] = { lat: pin.lat, lon: pin.lon, pins: [] }; + } + groups[key].pins.push(pin); + }); + + var hasCluster = typeof L.markerClusterGroup === 'function'; + var layer = hasCluster ? L.markerClusterGroup() : L.featureGroup(); + var placed = 0; + + Object.keys(groups).forEach(function (key) { + var g = groups[key]; + var count = g.pins.length; + var first = g.pins[0]; + + // A div icon, not the default PNG: it scales with the + // count, carries the number, and removes the map's only + // dependency on a bitmap that has to resolve. + var size = count > 1 ? 34 : 22; + var marker = L.marker([g.lat, g.lon], { + icon: L.divIcon({ + className: 'photography-map-pin', + html: count > 1 ? '<span>' + count + '</span>' : '<span></span>', + iconSize: [size, size], + iconAnchor: [size / 2, size / 2] + }), + title: placeLabel(g) + (count > 1 ? ' — ' + count + ' photographs' : '') + }); + + marker.bindTooltip(groupTooltipHtml(g), { + direction: 'top', + offset: [0, -18], + className: 'photography-map-tooltip-wrap', + opacity: 1 + }); + + // Send a click somewhere that shows the whole place: the + // series when they share one, otherwise the single frame. + var target = groupTarget(g); + if (target) { + marker.on('click', function () { window.location.href = target; }); + } + + layer.addLayer(marker); + placed += 1; + }); + + map.addLayer(layer); + + // Frame the visible pins with a small padding. Single- + // pin portfolios get a moderate zoom rather than the + // hard-coded zoom level so the marker doesn't feel + // marooned in negative space. + if (placed === 1) { + var only = groups[Object.keys(groups)[0]]; + map.setView([only.lat, only.lon], 9); + } else { + var bounds = layer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds.pad(0.15)); + } + } + + // Allow scroll-wheel zoom only after the user clicks + // into the map — prevents the page from "trapping" the + // scroll on someone passing through. + map.once('focus', function () { map.scrollWheelZoom.enable(); }); + map.on('blur', function () { map.scrollWheelZoom.disable(); }); + }) + .catch(function (err) { + renderErrorState(container, 'Could not load map data: ' + err.message); + }); + }); + +}()); diff --git a/static/js/photography-modes.js b/static/js/photography-modes.js new file mode 100644 index 0000000..2273458 --- /dev/null +++ b/static/js/photography-modes.js @@ -0,0 +1,227 @@ +/* Photography section — view mode toggle and masonry row-span computation. + * + * Mode toggle: + * - Three modes: masonry (default), grid, chronological. + * - Selection persists to localStorage under "photography-mode". + * - Mode is applied as data-photography-mode on .photography-grid; + * CSS keys all per-mode rules off this attribute. + * + * Masonry row-spans: + * - In masonry mode, .photography-grid uses CSS grid with + * grid-auto-rows: 1px so each card can occupy a precise integer + * number of "row units" matching its image's natural aspect. + * - For each photo card we set inline grid-row: span N once the + * image's natural dimensions are known. Pre-load, the card uses + * orientation-derived defaults from data-orientation so initial + * paint is roughly the right shape. + * + * No-op gracefully: + * - If the page has no .photography-grid (i.e. we're not on the + * /photography/ landing) the script returns early. + * - If localStorage is unavailable, mode toggling still works for + * the duration of the visit; just no persistence. + */ +(function () { + 'use strict'; + + var STORAGE_KEY = 'photography-mode'; + // Modes applied inline on /photography/ (CSS toggle). "map" is a + // separate page and is excluded here so it follows normal navigation. + var VALID_MODES = ['masonry', 'grid', 'chronological']; + // Grid is the default: it shows the most frames per screen and keeps + // chrome off the pictures until one is pointed at. Masonry is the + // deliberate second choice, for reading rather than scanning. + var DEFAULT_MODE = 'grid'; + + // Row unit (px) — must match grid-auto-rows in photography.css. + // Smaller unit = finer granularity = better aspect-ratio matching + // at a small cost in inline-style verbosity. 8px is a common sweet + // spot for masonry. + var ROW_UNIT = 8; + + // Vertical gap between rows in masonry mode (px). Must match + // photography.css .photography-grid[data-photography-mode="masonry"] gap. + var ROW_GAP = 8; + + // Approximate aspect ratios for each declared orientation; used as + // a placeholder span before the image's natural dimensions arrive. + var ORIENTATION_RATIO = { + portrait: 3 / 2, // height / width — taller than wide + landscape: 2 / 3, + square: 1 + }; + + document.addEventListener('DOMContentLoaded', function () { + var toggleButtons = document.querySelectorAll('.photography-mode-toggle .mode-btn'); + var grid = document.querySelector('.photography-grid'); + + // The script is loaded site-wide on photography pages but only + // does work when there's a toggle (and, separately, when there's + // a grid for masonry row-spans). Bail early if neither is present. + if (toggleButtons.length === 0 && !grid) return; + + // ---------------------------------------------------------------- + // localStorage helpers (shared by the toggle and any future + // mode-aware behaviour on photography pages) + // ---------------------------------------------------------------- + + function readStoredMode() { + try { + var stored = window.localStorage.getItem(STORAGE_KEY); + if (stored && VALID_MODES.indexOf(stored) !== -1) { + return stored; + } + } catch (e) { /* localStorage unavailable */ } + return DEFAULT_MODE; + } + + function writeStoredMode(mode) { + try { window.localStorage.setItem(STORAGE_KEY, mode); } + catch (e) { /* localStorage unavailable */ } + } + + // ---------------------------------------------------------------- + // Mode toggle + // ---------------------------------------------------------------- + + function applyMode(mode) { + if (!grid) return; + grid.setAttribute('data-photography-mode', mode); + toggleButtons.forEach(function (btn) { + var match = btn.dataset.mode === mode; + btn.classList.toggle('is-active', match); + btn.setAttribute('aria-pressed', match ? 'true' : 'false'); + }); + if (mode === 'masonry') { + // Measure after the browser has applied the new column + // template, not before: applyRowSpan reads card.clientWidth, + // and the track width it needs is the one the mode it is + // switching *into* produces. + window.requestAnimationFrame(applyAllRowSpans); + } else { + clearAllRowSpans(); + } + } + + toggleButtons.forEach(function (btn) { + btn.addEventListener('click', function (e) { + var mode = btn.dataset.mode; + if (!mode) return; + + // Persist the chosen mode regardless of whether we're + // applying it inline or following a link. This is what + // makes the round-trip through /photography/map/ remember + // which grid view to land on. + if (VALID_MODES.indexOf(mode) !== -1) { + writeStoredMode(mode); + } + + // On the landing page (where the grid is in the DOM), + // apply masonry/grid/chronological inline and suppress + // the anchor's default navigation. The "map" link + // ALWAYS navigates — there's no inline alternative. + if (grid && VALID_MODES.indexOf(mode) !== -1) { + e.preventDefault(); + applyMode(mode); + } + }); + }); + + // ---------------------------------------------------------------- + // Masonry row-spans + // ---------------------------------------------------------------- + + function rowSpanForRatio(ratio, contentWidth) { + // ratio = naturalHeight / naturalWidth; contentWidth = rendered width + var imageHeight = ratio * contentWidth; + // Allow ~1.4em for the meta strip (title + date below image). + var metaHeight = 28; + var totalHeight = imageHeight + metaHeight; + return Math.max(1, Math.ceil((totalHeight + ROW_GAP) / (ROW_UNIT + ROW_GAP))); + } + + function applyRowSpan(card) { + var img = card.querySelector('.photo-card-img'); + var width = card.clientWidth; + if (!width) return; /* not visible yet — wait for resize */ + + /* A card can legitimately have no image: a series landing that has + not been given a lead frame yet renders as title and date alone. + Returning early left such a card with no span at all, so it + occupied a single 8px auto-row and its title printed on top of + whatever sat beside it. Measure the content instead — the card + is short, and short is the correct answer. */ + if (!img) { + var main = card.querySelector('.item-card-main') || card; + var contentHeight = main.scrollHeight || 24; + card.style.gridRowEnd = 'span ' + Math.max( + 1, + Math.ceil((contentHeight + ROW_GAP) / (ROW_UNIT + ROW_GAP)) + ); + return; + } + + var ratio; + if (img.naturalWidth && img.naturalHeight) { + ratio = img.naturalHeight / img.naturalWidth; + } else { + var orient = card.dataset.orientation || 'landscape'; + ratio = ORIENTATION_RATIO[orient] || ORIENTATION_RATIO.landscape; + } + card.style.gridRowEnd = 'span ' + rowSpanForRatio(ratio, width); + } + + function applyAllRowSpans() { + grid.querySelectorAll('.photo-card').forEach(applyRowSpan); + } + + function clearAllRowSpans() { + grid.querySelectorAll('.photo-card').forEach(function (card) { + card.style.gridRowEnd = ''; + }); + } + + // Refine spans once each image's natural dimensions are known. + // No-op when the grid is absent (e.g. on the map page). + if (grid) { + grid.querySelectorAll('.photo-card-img').forEach(function (img) { + if (img.complete && img.naturalWidth) { + // Guard the mode here too. Without it, an image that was + // already cached applied an inline row span while the page + // was in grid or chronological mode, where 8px auto-rows + // do not exist and the span means something else entirely. + if (grid.getAttribute('data-photography-mode') === 'masonry') { + applyRowSpan(img.closest('.photo-card')); + } + } else { + img.addEventListener('load', function () { + if (grid.getAttribute('data-photography-mode') === 'masonry') { + applyRowSpan(img.closest('.photo-card')); + } + }); + } + }); + + // Re-flow on resize — cell width changes affect height. + var resizeRaf = null; + window.addEventListener('resize', function () { + if (resizeRaf) cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(function () { + if (grid.getAttribute('data-photography-mode') === 'masonry') { + applyAllRowSpans(); + } + }); + }); + } + + // ---------------------------------------------------------------- + // Boot — only applies a mode when we're on a page that has the + // grid. The map page has the toggle but no grid; for it, + // localStorage is read/written by the click handler above and + // by the grid page's boot when the user navigates back. + // ---------------------------------------------------------------- + + if (grid) applyMode(readStoredMode()); + }); + +}()); diff --git a/static/js/photography-slideshow.js b/static/js/photography-slideshow.js new file mode 100644 index 0000000..b04c43b --- /dev/null +++ b/static/js/photography-slideshow.js @@ -0,0 +1,242 @@ +/* photography-slideshow.js — sequential full-screen viewing of a grid. + * + * Slideshow is not a fourth grid layout, so it is deliberately not one of + * photography-modes.js's VALID_MODES: it does not rearrange the page, it + * replaces it for a while and then gives it back. That is also why it is not + * persisted to localStorage — nobody wants to land on a page and find it + * already playing. + * + * Frames come from whatever .photography-grid is on the page, in DOM order, + * so the same control works on the section landing, a series, and a by-year + * index without knowing anything about them. + * + * The overlay borrows .lightbox-overlay.darkroom styling from lightbox.js so + * the two full-screen experiences look like one idea, but it does not drive + * that module: lightbox.js builds its own DOM around a single image and + * exposes no API for sequences. + */ +(function () { + 'use strict'; + + var ADVANCE_MS = 5000; + + document.addEventListener('DOMContentLoaded', function () { + var trigger = document.querySelector('[data-mode="slideshow"]'); + var grid = document.querySelector('.photography-grid'); + if (!trigger || !grid) return; + + var frames = []; + grid.querySelectorAll('.photo-card').forEach(function (card) { + var im = card.querySelector('img'); + if (!im) return; + var titleEl = card.querySelector('.photo-card-title'); + var dateEl = card.querySelector('.photo-card-date'); + var link = card.querySelector('.photo-card-link'); + frames.push({ + src: im.currentSrc || im.src, + alt: im.alt || '', + title: titleEl ? titleEl.textContent.trim() : '', + date: dateEl ? dateEl.textContent.trim() : '', + href: link ? link.getAttribute('href') : null + }); + }); + if (frames.length === 0) { + trigger.hidden = true; + return; + } + + // Autoplay is opt-in for anyone who has asked for less motion. + var reduceMotion = window.matchMedia + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + var index = 0; + var timer = null; + var lastFocus = null; + + // ------------------------------------------------------------------ + // DOM + // ------------------------------------------------------------------ + var overlay = document.createElement('div'); + overlay.className = 'lightbox-overlay darkroom slideshow-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Slideshow'); + // Visibility is carried by .is-open, exactly as lightbox.js does it. + // .lightbox-overlay hides itself with opacity/visibility/pointer-events + // rather than display, so toggling the `hidden` attribute did nothing + // to it — the overlay stayed invisible while open() had already locked + // body scrolling, which read as the page freezing for no reason. + + var vignette = document.createElement('div'); + vignette.className = 'lightbox-vignette'; + vignette.setAttribute('aria-hidden', 'true'); + + var figure = document.createElement('figure'); + figure.className = 'slideshow-figure'; + + var img = document.createElement('img'); + img.className = 'slideshow-img'; + img.decoding = 'async'; + + var caption = document.createElement('figcaption'); + caption.className = 'slideshow-caption'; + + figure.appendChild(img); + figure.appendChild(caption); + + function control(cls, label, glyph) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'slideshow-btn ' + cls; + b.setAttribute('aria-label', label); + b.textContent = glyph; + return b; + } + + var prevBtn = control('slideshow-prev', 'Previous photograph', '‹'); + var nextBtn = control('slideshow-next', 'Next photograph', '›'); + var playBtn = control('slideshow-play', 'Play slideshow', '▶'); + var closeBtn = control('slideshow-close', 'Close slideshow', '×'); + + var counter = document.createElement('p'); + counter.className = 'slideshow-counter'; + // Position changes are announced, but politely — a screen reader + // should not interrupt itself every five seconds. + counter.setAttribute('aria-live', 'polite'); + + var bar = document.createElement('div'); + bar.className = 'slideshow-bar'; + bar.appendChild(prevBtn); + bar.appendChild(playBtn); + bar.appendChild(nextBtn); + bar.appendChild(counter); + + overlay.appendChild(vignette); + overlay.appendChild(closeBtn); + overlay.appendChild(figure); + overlay.appendChild(bar); + document.body.appendChild(overlay); + + // ------------------------------------------------------------------ + // Behaviour + // ------------------------------------------------------------------ + function preload(i) { + if (i < 0 || i >= frames.length) return; + var p = new Image(); + p.src = frames[i].src; + } + + function show(i) { + index = (i + frames.length) % frames.length; + var f = frames[index]; + img.src = f.src; + img.alt = f.alt; + caption.innerHTML = ''; + if (f.title) { + var t = document.createElement('span'); + t.className = 'slideshow-title'; + t.textContent = f.title; + caption.appendChild(t); + } + if (f.date) { + var d = document.createElement('span'); + d.className = 'slideshow-date'; + d.textContent = f.date; + caption.appendChild(d); + } + counter.textContent = (index + 1) + ' of ' + frames.length; + preload(index + 1); + } + + function playing() { return timer !== null; } + + function play() { + if (playing()) return; + timer = window.setInterval(function () { show(index + 1); }, ADVANCE_MS); + playBtn.textContent = '❚❚'; + playBtn.setAttribute('aria-label', 'Pause slideshow'); + } + + function pause() { + if (!playing()) return; + window.clearInterval(timer); + timer = null; + playBtn.textContent = '▶'; + playBtn.setAttribute('aria-label', 'Play slideshow'); + } + + function open(startAt) { + lastFocus = document.activeElement; + show(typeof startAt === 'number' ? startAt : 0); + overlay.classList.add('is-open'); + document.body.classList.add('slideshow-open'); + closeBtn.focus(); + if (!reduceMotion) play(); + } + + function close() { + pause(); + overlay.classList.remove('is-open'); + document.body.classList.remove('slideshow-open'); + if (lastFocus && lastFocus.focus) lastFocus.focus(); + } + + // Manual navigation stops the clock: someone steering does not want + // the timer yanking the frame out from under them mid-look. + function step(delta) { pause(); show(index + delta); } + + trigger.addEventListener('click', function (e) { + e.preventDefault(); + open(0); + }); + + prevBtn.addEventListener('click', function () { step(-1); }); + nextBtn.addEventListener('click', function () { step(1); }); + closeBtn.addEventListener('click', close); + playBtn.addEventListener('click', function () { + playing() ? pause() : play(); + }); + + // Clicking the photograph opens its own page — the slideshow is for + // looking, and this is the way out of it into reading. + img.addEventListener('click', function () { + var f = frames[index]; + if (f.href) { close(); window.location.href = f.href; } + }); + + overlay.addEventListener('click', function (e) { + if (e.target === overlay || e.target === vignette) close(); + }); + + document.addEventListener('keydown', function (e) { + if (!overlay.classList.contains('is-open')) return; + switch (e.key) { + case 'Escape': close(); break; + case 'ArrowRight': e.preventDefault(); step(1); break; + case 'ArrowLeft': e.preventDefault(); step(-1); break; + case 'Home': e.preventDefault(); step(-index); break; + case 'End': e.preventDefault(); step(frames.length - 1 - index); break; + case ' ': + e.preventDefault(); + playing() ? pause() : play(); + break; + case 'Tab': trapTab(e); break; + } + }); + + function trapTab(e) { + var f = [closeBtn, prevBtn, playBtn, nextBtn]; + var first = f[0], last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); first.focus(); + } + } + + // A tab switched away should not silently burn through the set. + document.addEventListener('visibilitychange', function () { + if (document.hidden) pause(); + }); + }); +}()); diff --git a/static/js/popups.js b/static/js/popups.js new file mode 100644 index 0000000..0682eaf --- /dev/null +++ b/static/js/popups.js @@ -0,0 +1,1199 @@ +/* popups.js — Hover preview popups. + Content providers (in dispatch priority order): + 1. Local annotations — /data/annotations.json (any URL, author-defined) + 2. Citations — DOM lookup, cite-link[href^="#ref-"] + 3. Internal pages — same-origin fetch, title + authors + tags + abstract + stats + 4. Wikipedia — MediaWiki action API, full lead section + 5. arXiv — export.arxiv.org Atom API + 6. DOI / CrossRef — api.crossref.org, title/authors/abstract + 7. GitHub — api.github.com, repo description + stars + 8. Open Library — openlibrary.org JSON API, book description + 9. bioRxiv / medRxiv — api.biorxiv.org, abstract + 10. YouTube — oEmbed, title + channel (no key required) + 11. Internet Archive — archive.org/metadata, title + description + 12. PubMed — NCBI esummary, title + authors + journal + + Production nginx CSP must add to connect-src: + https://*.wikipedia.org https://api.crossref.org + https://api.github.com https://openlibrary.org + https://api.biorxiv.org https://www.youtube.com + (The wildcard covers per-language Wikipedias: en, es, fr, simple, + zh-yue, be-tarask, … — the popup picks the host from the link URL.) + + Production nginx must also reverse-proxy three CORS-broken upstreams + (immutable metadata — long cache TTL is safe). See nginx/popup-proxy.conf. + /proxy/arxiv/ -> https://export.arxiv.org/ + /proxy/archive/ -> https://archive.org/ + /proxy/pubmed/ -> https://eutils.ncbi.nlm.nih.gov/ +*/ +(function () { + 'use strict'; + + var SHOW_DELAY = 250; + var HIDE_DELAY = 150; + + var popup = null; + var showTimer = null; + var hideTimer = null; + var activeTarget = null; + var cache = Object.create(null); /* url → html; only successful results stored */ + var annotations = null; /* null = not yet loaded */ + + /* ------------------------------------------------------------------ + Init — load annotations first, then bind all targets + ------------------------------------------------------------------ */ + + function init() { + // Hover popups are meaningless on touch-primary devices and interfere + // with tap navigation (first tap = hover, second tap = follow link). + if (window.matchMedia('(hover: none) and (pointer: coarse)').matches) return; + + popup = document.createElement('div'); + popup.className = 'link-popup'; + popup.setAttribute('aria-live', 'polite'); + popup.setAttribute('aria-hidden', 'true'); + document.body.appendChild(popup); + + popup.addEventListener('mouseenter', cancelHide); + popup.addEventListener('mouseleave', scheduleHide); + + loadAnnotations().then(function () { + bindTargets(document.body); + }); + } + + /* Epistemic term definitions — concise summaries from the colophon, + shown on hover for filter labels, metadata strip items, etc. */ + var EP_DEFS = { + status: 'A controlled vocabulary describing where the work stands: Draft, Working model, Durable, Refined, Superseded, or Deprecated.', + confidence: 'An integer from 0\u2013100, representing the author\u2019s credence in the central thesis.', + importance: 'How much the author thinks this matters, on a 1\u20135 dot scale. Useful for orienting a reader who has limited time.', + evidence: 'How well-evidenced the claims are, on a 1\u20135 scale. High importance and low evidence indicates a speculative position.', + trust: 'A 0\u2013100 score derived automatically from confidence (60%) and evidence quality (40%). Answers \u201chow much should you trust the central claim?\u201d and nothing else.', + scope: 'An orientation from personal to civilizational. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.', + novelty: 'An orientation from conventional to innovative. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.', + practicality: 'An orientation from abstract to exceptional. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.', + stability: 'Auto-computed from git history. Very new or barely-touched documents are volatile; actively-revised are revising; older settled documents are fairly stable, stable, or established.' + }; + + function bindTargets(root) { + /* Epistemic term definitions — filter labels, metadata strip, footer */ + root.querySelectorAll('[data-ep-term]').forEach(function (el) { + bind(el, epistemicTermContent); + }); + + /* Citation markers */ + root.querySelectorAll('a.cite-link[href^="#ref-"]').forEach(function (el) { + bind(el, citationContent); + }); + + /* Epistemic jump link — preview of the status/confidence/dot block */ + root.querySelectorAll('a[href="#epistemic"]').forEach(function (el) { + bind(el, epistemicContent); + }); + + /* Internal links — absolute (/foo) and relative (../../foo) same-origin hrefs. + relativizeUrls in Hakyll makes index-page links relative, so we must match both. */ + root.querySelectorAll('a[href^="/"], a[href^="./"], a[href^="../"]').forEach(function (el) { + /* Photography cards are their own preview. + A card already shows the photograph, and in grid mode reveals + the title over it on hover — so the generic internal-link + popup fires at the same moment, in the same gesture, saying + the same thing in a floating panel that covers neighbouring + frames. The in-picture caption supersedes it here. This is + scoped to the grid, so a photograph linked from prose still + gets the normal popup. */ + if (el.closest('.photography-grid')) return; + + /* Author links, backlink source links, and Related items always get popups */ + var inAuthors = el.closest('.meta-authors'); + var isBacklink = el.classList.contains('backlink-source'); + var isSimilar = el.classList.contains('similar-link'); + /* PDF-typed Related items are handled below by the pdf-link binder */ + if (isSimilar && el.classList.contains('pdf-link')) return; + if (!inAuthors && !isBacklink && !isSimilar) { + if (el.closest('nav, #toc, footer, .page-meta-footer, .metadata')) return; + if (el.classList.contains('cite-link') || el.classList.contains('meta-tag')) return; + if (el.classList.contains('pdf-link')) return; + if (el.classList.contains('content-divider-logo') || el.classList.contains('aftermatter-logo')) return; + } + bind(el, internalContent); + }); + + /* Source-file references — wrapped at build time by + build/Filters/SourceRefs.hs around inline `path` and Forgejo + links. Bind before the generic external-link loop so the + idempotent guard in bind() prevents the Forgejo provider + from also claiming these. */ + root.querySelectorAll('a.source-ref[data-source-path]').forEach(function (el) { + bind(el, sourceContent); + }); + + /* PDF links — rewritten to viewer URL by Links.hs; thumbnail on hover */ + root.querySelectorAll('a.pdf-link[data-pdf-src]').forEach(function (el) { + bind(el, pdfContent); + }); + + /* PGP signature links in footer */ + root.querySelectorAll('a.footer-sig-link').forEach(function (el) { + bind(el, sigContent); + }); + + /* External links — single dispatcher handles all providers */ + root.querySelectorAll('a[href^="http"]').forEach(function (el) { + if (el.closest('nav, #toc, footer, .page-meta-footer')) return; + var provider = getProvider(el.getAttribute('href') || ''); + if (provider) bind(el, provider); + }); + + /* Date hover popups — any element tagged with data-date-start. + Handles the frontmatter range link, version-history list items, + last-reviewed in the epistemic block, blog post dates, etc. */ + root.querySelectorAll('[data-date-start]').forEach(function (el) { + bind(el, dateContent); + }); + } + + /* Public re-init hook used by transclude.js after it injects new + content into the DOM. Idempotent — bind() marks each element so + repeated calls don't stack listeners. */ + window.reinitPopups = function (container) { + if (!popup) return; /* init() not yet run / touch device */ + if (annotations === null) { + loadAnnotations().then(function () { bindTargets(container || document.body); }); + } else { + bindTargets(container || document.body); + } + }; + + /* Returns the appropriate provider function for a given URL, or null. + Local annotations win over the table; otherwise the first entry in + PROVIDERS whose `match` regex hits is selected. */ + function getProvider(href) { + if (!href) return null; + if (annotations && annotations[href]) return annotationContent; + for (var i = 0; i < PROVIDERS.length; i++) { + if (PROVIDERS[i].match.test(href)) { + var entry = PROVIDERS[i]; + return function (target) { return providerContent(target, entry); }; + } + } + return null; + } + + function bind(el, provider) { + /* Idempotent: skip elements that already have a popup binding, + so reinitPopups() called from transclude.js cannot stack + listeners on already-bound nodes. */ + if (el.dataset.popupBound === '1') return; + el.dataset.popupBound = '1'; + el.addEventListener('mouseenter', function () { scheduleShow(el, provider); }); + el.addEventListener('mouseleave', scheduleHide); + el.addEventListener('focus', function () { scheduleShow(el, provider); }); + el.addEventListener('blur', scheduleHide); + } + + /* ------------------------------------------------------------------ + Lifecycle + ------------------------------------------------------------------ */ + + function scheduleShow(target, provider) { + cancelHide(); + clearTimeout(showTimer); + activeTarget = target; + showTimer = setTimeout(function () { + provider(target).then(function (content) { + if (!content || activeTarget !== target) return; + /* Providers may return either an HTML string or a DOM + Node — the latter is used by epistemicContent so the + popup receives cloned nodes instead of a re-parsed + HTML round-trip. Handle both forms. */ + popup.innerHTML = ''; + if (typeof content === 'string') { + popup.innerHTML = content; + } else if (content instanceof Node) { + popup.appendChild(content); + } else { + return; + } + /* Reference popups (provider-rendered: arXiv, Wikipedia, + …) get the larger glanceable layout; internal page + previews keep the compact one. Toggled per show since + the popup element is shared. */ + popup.classList.toggle('link-popup--rich', + !!popup.querySelector('.popup-provider')); + positionPopup(target); + popup.classList.add('is-visible'); + popup.setAttribute('aria-hidden', 'false'); + /* Images with width/height attrs reserve their space + before load; one without them grows the popup after + positioning and can push it past the viewport edge. + Re-clamp when such an image arrives. */ + popup.querySelectorAll('img:not([height])').forEach(function (im) { + if (im.complete) return; + im.addEventListener('load', function () { + if (activeTarget === target && + popup.classList.contains('is-visible')) { + positionPopup(target); + } + }, { once: true }); + }); + }).catch(function () { /* silently fail */ }); + }, SHOW_DELAY); + } + + function scheduleHide() { + clearTimeout(showTimer); + hideTimer = setTimeout(function () { + popup.classList.remove('is-visible'); + popup.setAttribute('aria-hidden', 'true'); + activeTarget = null; + }, HIDE_DELAY); + } + + function cancelHide() { clearTimeout(hideTimer); } + + /* ------------------------------------------------------------------ + Positioning — centres below target, flips above if clipped + ------------------------------------------------------------------ */ + + function positionPopup(target) { + var rect = target.getBoundingClientRect(); + var pw = popup.offsetWidth; + var ph = popup.offsetHeight; + var vw = window.innerWidth; + var vh = window.innerHeight; + var sy = window.scrollY; + var sx = window.scrollX; + var GAP = 10; + + var left = rect.left + sx + rect.width / 2 - pw / 2; + left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP)); + + /* Below if it fits, else above if THAT fits, else whichever + side has more room. The final clamp guarantees the popup + never extends past either viewport edge — without it, the + flip-above branch positions tall popups (rich layouts, lead + figures) above the visible region for targets near the top + of the screen. CSS caps .link-popup at viewport height + (same 10px gap), so the clamp always has room to work. */ + var fitsBelow = rect.bottom + GAP + ph <= vh; + var fitsAbove = rect.top - GAP - ph >= 0; + var top; + if (fitsBelow) { + top = rect.bottom + sy + GAP; + } else if (fitsAbove) { + top = rect.top + sy - ph - GAP; + } else { + top = (vh - rect.bottom >= rect.top) + ? rect.bottom + sy + GAP + : rect.top + sy - ph - GAP; + } + top = Math.max(sy + GAP, Math.min(top, sy + vh - ph - GAP)); + + popup.style.left = left + 'px'; + popup.style.top = top + 'px'; + } + + /* ------------------------------------------------------------------ + Content providers + ------------------------------------------------------------------ */ + + /* Cross-origin JSON fetch helper. + Validates Content-Type before parsing so a CORS-enabled endpoint + cannot return text/html and have it interpreted as JSON. The + caller's `.catch` still applies if the JSON parse itself fails. + + Mirror helpers exist for text/* (XML/Atom) and HTML responses. */ + function fetchJson(url, init) { + return fetch(url, init).then(function (r) { + if (!r.ok) return null; + var ct = (r.headers.get('content-type') || '').toLowerCase(); + if (ct && !/(?:^|[\s;,])(?:application\/[a-z+.-]*json|text\/json)\b/.test(ct)) { + return null; + } + return r.json(); + }); + } + + function fetchXml(url, init) { + return fetch(url, init).then(function (r) { + if (!r.ok) return null; + var ct = (r.headers.get('content-type') || '').toLowerCase(); + if (ct && !/(?:xml|atom)/.test(ct)) { + return null; + } + return r.text(); + }); + } + + /* 0. Local annotations — synchronous map lookup after eager load */ + function loadAnnotations() { + if (annotations !== null) return Promise.resolve(annotations); + return fetch('/data/annotations.json', { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : {}; }) + .then(function (data) { annotations = data; return data; }) + .catch(function () { annotations = {}; return {}; }); + } + + function annotationContent(target) { + var href = target.getAttribute('href'); + var ann = href && annotations && annotations[href]; + if (!ann) return Promise.resolve(null); + return Promise.resolve( + '<div class="popup-annotation">' + + (ann.title ? '<div class="popup-title">' + esc(ann.title) + '</div>' : '') + + (ann.annotation ? '<div class="popup-abstract">' + esc(ann.annotation) + '</div>' : '') + + '</div>' + ); + } + + /* 1. Citations — synchronous DOM lookup; supports multi-citation groups + via data-cite-keys (space-separated list of ref-* IDs). + Returns a DocumentFragment of cloned bibliography entries instead + of stringifying innerHTML, so a malicious or malformed cite target + cannot smuggle markup through the popup's innerHTML setter. */ + function citationContent(target) { + return new Promise(function (resolve) { + var keysAttr = target.getAttribute('data-cite-keys'); + var ids = keysAttr + ? keysAttr.trim().split(/\s+/) + : [(target.getAttribute('href') || '').slice(1)]; + var entries = ids + .map(function (id) { return document.getElementById(id); }) + .filter(Boolean); + if (!entries.length) { resolve(null); return; } + + var wrapper = document.createElement('div'); + wrapper.className = 'popup-citation'; + entries.forEach(function (entry) { + var item = document.createElement('div'); + item.className = 'popup-citation-entry'; + Array.prototype.forEach.call(entry.childNodes, function (n) { + item.appendChild(n.cloneNode(true)); + }); + wrapper.appendChild(item); + }); + resolve(wrapper); + }); + } + + /* 2. Internal pages — same-origin fetch, rich preview */ + function internalContent(target) { + /* Resolve relative hrefs (../../foo) to canonical path (/foo) for fetch + cache. */ + var raw = target.getAttribute('href'); + if (!raw) return Promise.resolve(null); + var href = new URL(raw, window.location.href).pathname; + if (cache[href]) return Promise.resolve(cache[href]); + + return fetch(href, { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.text() : null; }) + .then(function (text) { + if (!text) return null; + var doc = new DOMParser().parseFromString(text, 'text/html'); + var titleEl = doc.querySelector('h1.page-title'); + if (!titleEl) return null; + + /* Monogram \u2014 only when the source page renders a real + authored mark.svg (not the placeholder roundel that + the empty-frontmatter slot uses for symmetric layout). + Serialised as an outerHTML string and trusted as + already-sanitised SVG produced by our own build. */ + var monoEl = doc.querySelector( + 'figure.frontmatter-mark--monogram:not(.frontmatter-mark--placeholder) svg' + ); + var mono = monoEl ? monoEl.outerHTML : ''; + + /* Abstract */ + var abstrEl = doc.querySelector('.meta-description'); + var abstract = abstrEl ? abstrEl.textContent.trim() : ''; + if (abstract.length > 300) + abstract = abstract.slice(0, 300).replace(/\s\S+$/, '') + '\u2026'; + + /* Authors */ + var authorEls = doc.querySelectorAll('.meta-authors a'); + var authors = Array.from(authorEls).map(function (a) { + return a.textContent.trim(); + }).join(', '); + + /* Tags */ + var tagEls = doc.querySelectorAll('.meta-tags a'); + var tags = Array.from(tagEls).map(function (a) { + return a.textContent.trim(); + }).join(' · '); + + /* Reading stats — word count and reading time from meta block */ + var wcEl = doc.querySelector('.meta-word-count'); + var rtEl = doc.querySelector('.meta-reading-time'); + var stats = [ + wcEl ? wcEl.textContent.trim() : '', + rtEl ? rtEl.textContent.trim() : '' + ].filter(Boolean).join(' · '); + + return store(href, + '<div class="popup-internal' + (mono ? ' has-monogram' : '') + '">' + + srcHtml('internal', 'levineuwirth.org') + + (mono ? '<div class="popup-monogram" aria-hidden="true">' + mono + '</div>' : '') + + '<div class="popup-internal-body">' + + (tags ? '<div class="popup-tags">' + esc(tags) + '</div>' : '') + + '<div class="popup-title">' + esc(titleEl.textContent.trim()) + '</div>' + + (authors ? '<div class="popup-authors">' + esc(authors) + '</div>' : '') + + (abstract ? '<div class="popup-abstract">' + esc(abstract) + '</div>' : '') + + (stats ? '<div class="popup-meta">' + esc(stats) + '</div>' : '') + + '</div>' + + '</div>'); + }) + .catch(function () { return null; }); + } + + /* ------------------------------------------------------------------ + External providers — declarative table. + + Each entry drives a generic fetch + render pipeline. Adding a new + source means: write a URL regex, a URL builder, and a parser that + maps the upstream response to the normalized field shape below. + + { title, authors?, meta?, abstract? | extract?, tags? } + + providerContent() handles cache/fetch/error-swallow; renderPopup() + handles HTML composition and truncation. Per-provider quirks live + inside each parse() — e.g. CrossRef + Internet Archive strip + upstream HTML before returning. The shared truncate step only + normalizes whitespace and applies the length cap. + + Render order is fixed: tags → title → authors → meta → body → + stats. `meta` and `stats` share the `.popup-meta` CSS class but + differ in position: `meta` reads as a subtitle (journal, year), + while `stats` reads as a footer line (language, star count). + + Quirk fields on a provider entry: + icon — override for data-popup-source (CSS icon key) when + it differs from the provider name. + fetchInit — options passed to fetch() (e.g. GitHub's Accept). + bodyLimit — char cap for abstract/extract (default 500). */ + + function truncate(s, limit) { + if (!s) return ''; + s = s.replace(/\s+/g, ' ').trim(); + if (s.length > limit) s = s.slice(0, limit).replace(/\s\S+$/, '') + '\u2026'; + return s; + } + + /* Authors: array → "a, b, c et al." (3 max); string → trimmed + passthrough (some parsers pre-join their own bylines, e.g. Internet + Archive composes "creator, year"). */ + function formatAuthors(a) { + if (!a) return ''; + if (typeof a === 'string') return a.trim(); + if (!a.length) return ''; + var head = a.slice(0, 3).join(', '); + return a.length > 3 ? head + ' et\u00a0al.' : head; + } + + function renderPopup(p, fields) { + if (!fields || !fields.title) return null; + var iconKey = p.icon || p.name; + var authors = formatAuthors(fields.authors); + var bodyKey = fields.extract !== undefined ? 'extract' : 'abstract'; + var body = truncate(fields[bodyKey], p.bodyLimit || 500); + + /* The shared popup-provider class is what scheduleShow keys the + larger .link-popup--rich layout on — reference popups (arXiv, + Wikipedia, …) get the roomier glanceable treatment; internal + page previews and item cards keep the compact one. */ + var html = '<div class="popup-' + p.name + ' popup-provider">' + + srcHtml(iconKey, p.label); + /* Optional lead image (Wikipedia pageimages thumbnail, arXiv + lead figure) as a full-width banner under the source label. + Accepted srcs: https://… (API-supplied) or root-relative + /proxy/… (same-origin proxied figures); anything else + (protocol-relative, data:, …) is dropped rather than guessed + at. width/height attrs reserve the aspect ratio so the popup + is positioned correctly before the image arrives. Photos + cover-crop; figures (plots, diagrams) must never be cropped, + so imageKind 'figure' letterboxes with object-fit: contain. */ + var img = fields.image; + if (img && img.src && + (/^https:\/\//.test(img.src) || /^\/(?!\/)/.test(img.src))) { + html += '<img class="popup-image-banner' + + (fields.imageKind === 'figure' ? ' is-figure' : '') + + '" src="' + esc(img.src) + '"' + + (img.width && img.height + ? ' width="' + (+img.width) + '" height="' + (+img.height) + '"' + : '') + + ' alt="" loading="lazy">'; + } + if (fields.tags) html += '<div class="popup-tags">' + esc(fields.tags) + '</div>'; + html += '<div class="popup-title">' + esc(fields.title) + '</div>'; + if (authors) html += '<div class="popup-authors">' + esc(authors) + '</div>'; + if (fields.meta) html += '<div class="popup-meta">' + esc(fields.meta) + '</div>'; + if (body) html += '<div class="popup-' + bodyKey + '">' + esc(body) + '</div>'; + if (fields.stats) html += '<div class="popup-meta">' + esc(fields.stats) + '</div>'; + html += '</div>'; + return html; + } + + function providerContent(target, p) { + var href = target.getAttribute('href'); + if (!href) return Promise.resolve(null); + if (cache[href]) return Promise.resolve(cache[href]); + + var match = href.match(p.match); + if (!match) return Promise.resolve(null); + + var ctx = { match: match, href: href }; + /* p.url runs synchronously (before the .catch below attaches) and + can throw — e.g. decodeURIComponent on a malformed percent + sequence in the link path. Treat a throw as "no popup". */ + var url; + try { url = p.url(ctx); } + catch (e) { return Promise.resolve(null); } + var fetcher = p.fetchType === 'xml' ? fetchXml : fetchJson; + + return fetcher(url, p.fetchInit).then(function (data) { + if (!data) return null; + var fields = p.parse(data, ctx); + if (!fields) return null; + var finish = function (f) { + var html = renderPopup(p, f); + return html ? store(href, html) : null; + }; + if (!p.enrich) return finish(fields); + /* enrich() is best-effort decoration (e.g. the arXiv lead + figure needs a second, potentially large fetch). Time-box + it so the metadata popup is never held hostage; if the + enrichment lands late, refresh the cache so the NEXT + hover gets the decorated version. enrich must return a + new fields object, leaving the original untouched for + the timeout path. */ + var enriched = Promise.resolve() + .then(function () { return p.enrich(fields, ctx); }) + .catch(function () { return null; }); + var timeout = new Promise(function (resolve) { + setTimeout(resolve, 1800, undefined); + }); + return Promise.race([enriched, timeout]).then(function (f) { + if (f !== undefined) return finish(f || fields); + enriched.then(function (late) { if (late) finish(late); }); + return finish(fields); + }); + }).catch(function () { return null; }); + } + + /* bioRxiv + medRxiv share the response schema, so both entries below + use this parser — only the upstream path differs. */ + function biorxivParse(data) { + var paper = data && data.collection && data.collection[0]; + if (!paper || !paper.title) return null; + var authors = paper.authors + ? paper.authors.split(';').map(function (s) { return s.trim(); }).filter(Boolean) + : []; + return { title: paper.title, authors: authors, abstract: paper.abstract || '' }; + } + + var PROVIDERS = [ + /* Wikipedia — MediaWiki action API, full lead section, text-only. + Uses .popup-extract rather than .popup-abstract; the parser + signals this by returning `extract` instead of `abstract`. + + The API host matches the link's own subdomain, so es.wikipedia.org + links fetch the Spanish extract, de.wikipedia.org fetches German, + etc. Bare wikipedia.org and www. fall through to en. */ + { + name: 'wikipedia', label: 'Wikipedia', + match: /wikipedia\.org\/wiki\/([^#?]+)/, + fetchType: 'json', + bodyLimit: 600, + url: function (ctx) { + var hostMatch = ctx.href.match(/\/\/([a-z0-9-]+)\.wikipedia\.org\//i); + var sub = hostMatch ? hostMatch[1].toLowerCase() : 'en'; + if (sub === 'www') sub = 'en'; + /* pageimages|extracts in one call: the article's lead + image thumbnail rides along with the intro text. + Thumbnails come from upload.wikimedia.org — that host + must stay in the CSP's img-src. 480px because the + banner spans the rich popup's full width. */ + return 'https://' + sub + '.wikipedia.org/w/api.php' + + '?action=query&prop=extracts%7Cpageimages&exintro=1' + + '&piprop=thumbnail&pithumbsize=480' + + '&format=json&redirects=1' + + '&titles=' + encodeURIComponent(decodeURIComponent(ctx.match[1])) + + '&origin=*'; + }, + parse: function (data) { + var pages = data && data.query && data.query.pages; + if (!pages) return null; + var page = Object.values(pages)[0]; + if (!page || page.missing !== undefined) return null; + var doc = new DOMParser().parseFromString(page.extract || '', 'text/html'); + /* Math elements blend display chars with raw LaTeX source + in the DOM — strip them before textContent extraction. */ + doc.querySelectorAll('.mwe-math-element').forEach(function (el) { + el.parentNode.removeChild(el); + }); + var text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim(); + if (!text) return null; + var thumb = page.thumbnail; + return { + title: page.title, + extract: text, + image: thumb && thumb.source + ? { src: thumb.source, + width: thumb.width, + height: thumb.height } + : null, + imageKind: 'photo' + }; + } + }, + + /* arXiv — Atom API (CORS-broken upstream, proxied). + ID forms: new-style 2403.12345(v2), and old-style + archive/0211159 or archive.SC/0211159 (pre-2007); /pdf/ URLs + may carry a trailing .pdf, which stays outside the capture. */ + { + name: 'arxiv', label: 'arXiv', + match: /arxiv\.org\/(?:abs|pdf)\/((?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?\/\d{7})(?:v\d+)?)/, + fetchType: 'xml', + url: function (ctx) { + return '/proxy/arxiv/api/query?id_list=' + + encodeURIComponent(ctx.match[1].replace(/v\d+$/, '')); + }, + parse: function (xml) { + var doc = new DOMParser().parseFromString(xml, 'application/xml'); + var titleEl = doc.querySelector('entry > title'); + var summaryEl = doc.querySelector('entry > summary'); + if (!titleEl || !summaryEl) return null; + return { + title: titleEl.textContent.trim().replace(/\s+/g, ' '), + authors: Array.from(doc.querySelectorAll('entry > author > name')) + .map(function (el) { return el.textContent.trim(); }), + abstract: summaryEl.textContent.trim().replace(/\s+/g, ' ') + }; + }, + /* Lead figure, best-effort: arXiv's LaTeXML HTML rendition + (when one exists — roughly 2024+ papers with convertible + sources) carries the paper's figures. The first + figure.ltx_figure img is almost always the teaser / + architecture figure. Page and image both ride through + /proxy/arxiv-html/ (arxiv.org upstream — export.arxiv.org + rate-limits the /html/ asset tree), so img-src 'self' + covers them and nginx caches the heavy page fetch. */ + enrich: function (fields, ctx) { + var id = ctx.match[1].replace(/v\d+$/, ''); + return fetch('/proxy/arxiv-html/' + encodeURIComponent(id)) + .then(function (r) { + if (!r.ok) return fields; + return r.text().then(function (text) { + var doc = new DOMParser().parseFromString(text, 'text/html'); + var img = doc.querySelector('figure.ltx_figure img.ltx_graphics'); + var src = img && img.getAttribute('src'); + if (!src || /^(?:data|javascript):/i.test(src)) return fields; + /* Resolve against the page URL (r.url keeps any + redirect) — relative srcs like + "2410.21276v1/assets/x.png" become siblings + under /proxy/arxiv-html/. An upstream-absolute + "/html/…" src maps back into proxy space; any + other host is dropped. */ + var resolved = new URL(src, r.url); + if (resolved.origin !== location.origin) return fields; + var path = resolved.pathname; + if (path.indexOf('/html/') === 0) { + path = '/proxy/arxiv-html/' + path.slice('/html/'.length); + } + if (path.indexOf('/proxy/arxiv-html/') !== 0) return fields; + return Object.assign({}, fields, { + image: { src: path, + width: img.getAttribute('width'), + height: img.getAttribute('height') }, + imageKind: 'figure' + }); + }); + }) + .catch(function () { return fields; }); + } + }, + + /* DOI → CrossRef — strips upstream JATS-HTML from abstract. */ + { + name: 'doi', label: 'CrossRef', + match: /(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/, + fetchType: 'json', + url: function (ctx) { + return 'https://api.crossref.org/works/' + encodeURIComponent(ctx.match[1]); + }, + parse: function (data) { + var msg = data && data.message; + if (!msg) return null; + var title = (msg.title && msg.title[0]) || ''; + if (!title) return null; + var journal = (msg['container-title'] && msg['container-title'][0]) || ''; + var parts = msg.issued && msg.issued['date-parts']; + var year = parts && parts[0] && parts[0][0]; + return { + title: title, + authors: (msg.author || []).map(function (a) { + return (a.given ? a.given + ' ' : '') + (a.family || ''); + }), + meta: [journal, year].filter(Boolean).join(', '), + abstract: (msg.abstract || '').replace(/<[^>]+>/g, '') + }; + } + }, + + /* GitHub — repo description + language + stars. */ + { + name: 'github', label: 'GitHub', + match: /github\.com\/([^/]+)\/([^/?#]+)/, + fetchType: 'json', + fetchInit: { headers: { 'Accept': 'application/vnd.github.v3+json' } }, + url: function (ctx) { + return 'https://api.github.com/repos/' + ctx.match[1] + '/' + ctx.match[2]; + }, + parse: function (data) { + if (!data || !data.full_name) return null; + var stars = data.stargazers_count; + return { + title: data.full_name, + abstract: data.description || '', + stats: [data.language, + stars != null ? '\u2605\u00a0' + stars : null] + .filter(Boolean).join(' \u00b7 ') + }; + } + }, + + /* Forgejo (self-hosted git) — same shape as GitHub, but field + naming differs (`stars_count` vs `stargazers_count`). */ + { + name: 'forgejo', label: 'Forgejo', + match: /git\.levineuwirth\.org\/([^/]+)\/([^/?#]+)/, + fetchType: 'json', + url: function (ctx) { + return 'https://git.levineuwirth.org/api/v1/repos/' + + ctx.match[1] + '/' + ctx.match[2]; + }, + parse: function (data) { + if (!data || !data.full_name) return null; + var stars = data.stars_count; + return { + title: data.full_name, + abstract: data.description || '', + stats: [data.language, + stars != null ? '\u2605\u00a0' + stars : null] + .filter(Boolean).join(' \u00b7 ') + }; + } + }, + + /* Open Library — works/books JSON appended to href. */ + { + name: 'openlibrary', label: 'Open Library', + match: /openlibrary\.org\/(?:works|books)\//, + fetchType: 'json', + bodyLimit: 300, + url: function (ctx) { return ctx.href.replace(/[?#].*$/, '') + '.json'; }, + parse: function (data) { + if (!data || !data.title) return null; + var desc = data.description; + if (desc && typeof desc === 'object') desc = desc.value; + return { title: data.title, abstract: desc || '' }; + } + }, + + /* bioRxiv — shares schema with medRxiv via biorxivParse. */ + { + name: 'biorxiv', label: 'bioRxiv', + match: /biorxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/, + fetchType: 'json', + url: function (ctx) { + return 'https://api.biorxiv.org/details/biorxiv/' + + encodeURIComponent(ctx.match[1].replace(/v\d+$/, '')) + '/json'; + }, + parse: biorxivParse + }, + + /* medRxiv — identical shape as bioRxiv; different upstream path. */ + { + name: 'medrxiv', label: 'medRxiv', + match: /medrxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/, + fetchType: 'json', + url: function (ctx) { + return 'https://api.biorxiv.org/details/medrxiv/' + + encodeURIComponent(ctx.match[1].replace(/v\d+$/, '')) + '/json'; + }, + parse: biorxivParse + }, + + /* YouTube — oEmbed (no API key required). */ + { + name: 'youtube', label: 'YouTube', + match: /youtube\.com\/watch|youtu\.be\//, + fetchType: 'json', + url: function (ctx) { + return 'https://www.youtube.com/oembed?url=' + + encodeURIComponent(ctx.href) + '&format=json'; + }, + parse: function (data) { + if (!data || !data.title) return null; + return { title: data.title, authors: data.author_name || '' }; + } + }, + + /* Internet Archive — item metadata (CORS-broken upstream, proxied). + CSS icon key is `internet-archive` (hyphenated), but the + provider/class name stays short — hence the `icon` override. */ + { + name: 'archive', label: 'Internet Archive', icon: 'internet-archive', + match: /archive\.org\/details\/([^/?#]+)/, + fetchType: 'json', + bodyLimit: 280, + url: function (ctx) { + return '/proxy/archive/metadata/' + encodeURIComponent(ctx.match[1]); + }, + parse: function (data) { + var meta = data && data.metadata; + if (!meta) return null; + var first = function (v) { return Array.isArray(v) ? v[0] : (v || ''); }; + var title = first(meta.title); + if (!title) return null; + var creator = first(meta.creator); + var year = first(meta.year); + return { + title: title, + authors: [creator, year].filter(Boolean).join(', '), + abstract: first(meta.description).replace(/<[^>]+>/g, '') + }; + } + }, + + /* PubMed — NCBI esummary (CORS-broken upstream, proxied). */ + { + name: 'pubmed', label: 'PubMed', + match: /pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)/, + fetchType: 'json', + url: function (ctx) { + return '/proxy/pubmed/entrez/eutils/esummary.fcgi' + + '?db=pubmed&id=' + ctx.match[1] + '&retmode=json'; + }, + parse: function (data, ctx) { + var paper = data && data.result && data.result[ctx.match[1]]; + if (!paper || !paper.title) return null; + return { + title: paper.title, + authors: (paper.authors || []).map(function (a) { return a.name; }), + meta: [paper.fulljournalname || paper.source || '', + (paper.pubdate || '').slice(0, 4)].filter(Boolean).join(', ') + }; + } + } + ]; + + /* ------------------------------------------------------------------ + Helpers + ------------------------------------------------------------------ */ + + function store(href, html) { + cache[href] = html; + return html; + } + + /* Epistemic jump link — pulls the parallel-tag strip from the top of + the page (which holds the author-declared orientation tags) and + the expanded DL from the #epistemic footer section (which holds + the git-derived stability/last-reviewed/trend). The popup combines + both so a reader hovering the link mid-page sees the full profile + without scrolling. + + Returns a DocumentFragment instead of an HTML string so the popup + receives cloned nodes (defense in depth — if a future change ever + allowed user-authored HTML into the source section, the popup + would still see exactly the same already-rendered DOM rather than + a re-parsed string). */ + function epistemicContent() { + var wrap = document.createElement('div'); + wrap.className = 'popup-epistemic'; + + var strip = document.querySelector('.meta-epistemic-strip'); + if (strip) { + wrap.appendChild(strip.cloneNode(true)); + } + + var section = document.getElementById('epistemic'); + var expanded = section ? section.querySelector('.ep-expanded') : null; + if (expanded) { + wrap.appendChild(expanded.cloneNode(true)); + } + + if (!strip && !expanded) return Promise.resolve(null); + return Promise.resolve(wrap); + } + + /* Epistemic term definition — shows a concise description from the + colophon for any element tagged with data-ep-term="<field>". */ + function epistemicTermContent(target) { + var term = target.dataset.epTerm; + var def = term && EP_DEFS[term]; + if (!def) return Promise.resolve(null); + var label = term.charAt(0).toUpperCase() + term.slice(1); + return Promise.resolve( + '<div class="popup-ep-term">' + + '<div class="popup-source" data-popup-source="colophon">' + + '<a href="/colophon.html#living-documents">Colophon</a>' + + '</div>' + + '<div class="popup-title">' + esc(label) + '</div>' + + '<div class="popup-abstract">' + esc(def) + '</div>' + + '</div>' + ); + } + + /* Local PDF — shows the build-time first-page thumbnail (.thumb.png). + Returns null (no popup) if the thumbnail file does not exist. */ + function pdfContent(target) { + var src = target.dataset.pdfSrc; + if (!src) return Promise.resolve(null); + var thumb = src.replace(/\.pdf$/i, '.thumb.png'); + if (cache[thumb]) return Promise.resolve(cache[thumb]); + /* HEAD request: verify thumbnail exists before committing to a popup. */ + return fetch(thumb, { method: 'HEAD', credentials: 'same-origin' }) + .then(function (r) { + if (!r.ok) return null; + return store(thumb, + '<div class="popup-pdf">' + + '<img class="popup-pdf-thumb" src="' + esc(thumb) + '" alt="PDF first page">' + + '</div>'); + }) + .catch(function () { return null; }); + } + + /* Source-file preview — fetches /source/<path> (a same-origin copy + emitted by the source-preview Hakyll rule), runs Prism on the + first chunk of lines, and returns a DocumentFragment so the popup + receives ready-highlighted DOM rather than re-parsing innerHTML. + + The raw response is cached; rendering is repeated per hover so + a cached entry never gets re-parented (a Node can only live in + one place at a time). */ + function sourceContent(target) { + var path = target.dataset.sourcePath; + if (!path) return Promise.resolve(null); + var fetchUrl = '/source/' + path; + + var cached = cache[fetchUrl]; + var pending = (cached !== undefined) + ? Promise.resolve(cached) + : fetch(fetchUrl, { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.text() : null; }) + .then(function (text) { cache[fetchUrl] = text; return text; }) + .catch(function () { return null; }); + + return pending.then(function (text) { + if (text == null) return null; + return renderSourcePopup(path, text); + }); + } + + /* Build the popup body for sourceContent. Truncates to MAX_LINES + so a 2,000-line file doesn't blow the popup height; the link's + href still points at the Forgejo full-file viewer for readers + who want more. */ + function renderSourcePopup(path, text) { + var MAX_LINES = 80; + var lines = text.split('\n'); + var truncated = lines.length > MAX_LINES; + var preview = lines.slice(0, MAX_LINES).join('\n'); + var lang = languageFromPath(path); + + var wrap = document.createElement('div'); + wrap.className = 'popup-source-code'; + + var label = document.createElement('div'); + label.className = 'popup-source-path'; + label.textContent = path; + wrap.appendChild(label); + + var pre = document.createElement('pre'); + pre.className = lang ? 'popup-source-pre language-' + lang : 'popup-source-pre'; + var code = document.createElement('code'); + if (lang) code.className = 'language-' + lang; + code.textContent = preview; + pre.appendChild(code); + wrap.appendChild(pre); + + /* Prism is loaded with `defer` from the page template; by the + time a hover delay fires it is reliably available. Guard + anyway so a missing component (e.g. an unrecognised lang) + degrades to plain monospace rather than throwing. */ + if (lang && window.Prism && Prism.languages && Prism.languages[lang]) { + try { Prism.highlightElement(code); } catch (_) { /* keep plain */ } + } + + if (truncated) { + var more = document.createElement('div'); + more.className = 'popup-source-truncated'; + var n = lines.length - MAX_LINES; + more.textContent = + n + ' more line' + (n === 1 ? '' : 's') + + ' · view full file →'; + wrap.appendChild(more); + } + + return wrap; + } + + /* Map a path's extension (or basename, for Makefile) onto the set + of Prism languages bundled in /js/prism.min.js: bash, haskell, + javascript, css, markup, yaml, python, makefile. Returns null + when no mapping applies; the caller falls back to plain text. */ + function languageFromPath(path) { + var basename = path.split('/').pop(); + if (basename === 'Makefile') return 'makefile'; + var m = path.match(/\.([a-z0-9]+)$/i); + if (!m) return null; + switch (m[1].toLowerCase()) { + case 'hs': + case 'cabal': return 'haskell'; + case 'js': + case 'mjs': return 'javascript'; + case 'css': return 'css'; + case 'html': + case 'svg': return 'markup'; + case 'py': return 'python'; + case 'sh': + case 'bash': + case 'conf': return 'bash'; + case 'yaml': + case 'yml': return 'yaml'; + case 'md': return 'markup'; + default: return null; + } + } + + /* PGP signature — fetch the .sig file and display ASCII armor */ + function sigContent(target) { + var href = target.getAttribute('href'); + if (!href) return Promise.resolve(null); + if (cache[href]) return Promise.resolve(cache[href]); + return fetch(href, { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.text() : null; }) + .then(function (text) { + if (!text) return null; + var html = '<div class="popup-sig"><pre>' + esc(text.trim()) + '</pre></div>'; + cache[href] = html; + return html; + }); + } + + /* ------------------------------------------------------------------ + Date popups — explain a date or date range in human terms. + Single date: "6 months ago" + Range: "~4 weeks · started 6 months ago" + Frontmatter: range + "17 revisions" cadence line (only when + data-date-commits is present on the trigger). + ------------------------------------------------------------------ */ + + function dateContent(target) { + var startAttr = target.getAttribute('data-date-start'); + if (!startAttr) return Promise.resolve(null); + var start = parseIsoDate(startAttr); + if (!start) return Promise.resolve(null); + + var endAttr = target.getAttribute('data-date-end'); + var end = endAttr ? parseIsoDate(endAttr) : null; + var commits = target.getAttribute('data-date-commits'); + + var today = new Date(); + var lines = []; + + if (end) { + var spanDays = daysBetween(start, end); + var agoDays = daysBetween(start, today); + /* "~" prefix when we've rounded to a unit larger than days. */ + var span = humanDuration(spanDays, true); + var ago = humanAgo(agoDays); /* '' when start is in the future */ + lines.push( + '<div class="popup-date-primary">' + + esc(span) + (ago ? ' · started ' + esc(ago) : '') + + '</div>'); + if (commits && /^\d+$/.test(commits)) { + var n = parseInt(commits, 10); + lines.push( + '<div class="popup-date-cadence">' + + n + ' revision' + (n === 1 ? '' : 's') + + '</div>'); + } + } else { + var days = daysBetween(start, today); + var ago2 = humanAgo(days); /* '' when the date is in the future */ + if (ago2) { + lines.push( + '<div class="popup-date-primary">' + + esc(ago2) + '</div>'); + } + } + + /* Nothing renderable (e.g. a lone future date): no popup. */ + if (!lines.length) return Promise.resolve(null); + + return Promise.resolve('<div class="popup-date">' + lines.join('') + '</div>'); + } + + /* Parse "YYYY-MM-DD" (UTC midnight) to a Date. Returns null on failure. */ + function parseIsoDate(s) { + var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); + if (!m) return null; + var d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); + return isNaN(d.getTime()) ? null : d; + } + + /* Whole-day difference b − a, floored. Negative when b precedes a, + so callers can detect future dates instead of mislabelling them. */ + function daysBetween(a, b) { + var ms = b.getTime() - a.getTime(); + return Math.floor(ms / 86400000); + } + + /* "5 days" / "3 weeks" / "4 months" / "2 years" — the unit is chosen + to match the magnitude so the number stays small and readable. + `approx` prefixes "~" when the returned unit is coarser than days. */ + function humanDuration(days, approx) { + if (days <= 1) return '1 day'; + if (days < 14) return days + ' days'; + if (days < 60) { + var w = Math.round(days / 7); + return (approx ? '~' : '') + w + ' week' + (w === 1 ? '' : 's'); + } + if (days < 365) { + var mo = Math.round(days / 30); + return (approx ? '~' : '') + mo + ' month' + (mo === 1 ? '' : 's'); + } + var y = Math.round(days / 365); + return (approx ? '~' : '') + y + ' year' + (y === 1 ? '' : 's'); + } + + /* Past-tense phrasing for a date N days in the past. Returns '' for + future dates (negative N) — mirror now.js — so callers render + nothing rather than a false "N days ago". */ + function humanAgo(days) { + if (days < 0) return ''; /* future / clock skew */ + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 14) return days + ' days ago'; + return humanDuration(days, true) + ' ago'; + } + + /* Defer to the shared utility (loaded synchronously from + templates/partials/head.html) so this file cannot drift from + annotations.js, semantic-search.js, or build/Utils.hs. */ + function esc(s) { + return window.lnUtils.escapeHtml(s); + } + + /* Emit a .popup-source label with a data-popup-source attribute so CSS + can prepend the matching icon via ::before + mask-image. */ + function srcHtml(key, label) { + return '<div class="popup-source" data-popup-source="' + esc(key) + '">' + esc(label) + '</div>'; + } + + document.addEventListener('DOMContentLoaded', init); +}()); diff --git a/static/js/prism.min.js b/static/js/prism.min.js new file mode 100644 index 0000000..3b487ae --- /dev/null +++ b/static/js/prism.min.js @@ -0,0 +1,48 @@ +/** + * Combined by jsDelivr. + * Original files: + * - /npm/prismjs@1.29.0/prism.min.js + * - /npm/prismjs@1.29.0/components/prism-bash.min.js + * - /npm/prismjs@1.29.0/components/prism-haskell.min.js + * - /npm/prismjs@1.29.0/components/prism-javascript.min.js + * - /npm/prismjs@1.29.0/components/prism-css.min.js + * - /npm/prismjs@1.29.0/components/prism-markup.min.js + * - /npm/prismjs@1.29.0/components/prism-yaml.min.js + * - /npm/prismjs@1.29.0/components/prism-python.min.js + * - /npm/prismjs@1.29.0/components/prism-makefile.min.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/** + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/prismjs@1.29.0/prism.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,a={},r={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof s?new s(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,"&").replace(/</g,"<").replace(/\u00a0/g," ")},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function e(t,n){var a,s;switch(n=n||{},r.util.type(t)){case"Object":if(s=r.util.objId(t),n[s])return n[s];for(var i in a={},n[s]=a,t)t.hasOwnProperty(i)&&(a[i]=e(t[i],n));return a;case"Array":return s=r.util.objId(t),n[s]?n[s]:(a=[],n[s]=a,t.forEach((function(t,r){a[r]=e(t,n)})),a);default:return t}},getLanguage:function(e){for(;e;){var n=t.exec(e.className);if(n)return n[1].toLowerCase();e=e.parentElement}return"none"},setLanguage:function(e,n){e.className=e.className.replace(RegExp(t,"gi"),""),e.classList.add("language-"+n)},currentScript:function(){if("undefined"==typeof document)return null;if("currentScript"in document)return document.currentScript;try{throw new Error}catch(a){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(a.stack)||[])[1];if(e){var t=document.getElementsByTagName("script");for(var n in t)if(t[n].src==e)return t[n]}return null}},isActive:function(e,t,n){for(var a="no-"+t;e;){var r=e.classList;if(r.contains(t))return!0;if(r.contains(a))return!1;e=e.parentElement}return!!n}},languages:{plain:a,plaintext:a,text:a,txt:a,extend:function(e,t){var n=r.util.clone(r.languages[e]);for(var a in t)n[a]=t[a];return n},insertBefore:function(e,t,n,a){var s=(a=a||r.languages)[e],i={};for(var o in s)if(s.hasOwnProperty(o)){if(o==t)for(var l in n)n.hasOwnProperty(l)&&(i[l]=n[l]);n.hasOwnProperty(o)||(i[o]=s[o])}var u=a[e];return a[e]=i,r.languages.DFS(r.languages,(function(t,n){n===u&&t!=e&&(this[t]=i)})),i},DFS:function e(t,n,a,s){s=s||{};var i=r.util.objId;for(var o in t)if(t.hasOwnProperty(o)){n.call(t,o,t[o],a||o);var l=t[o],u=r.util.type(l);"Object"!==u||s[i(l)]?"Array"!==u||s[i(l)]||(s[i(l)]=!0,e(l,n,o,s)):(s[i(l)]=!0,e(l,n,null,s))}}},plugins:{},highlightAll:function(e,t){r.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var a={callback:n,container:e,selector:'code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'};r.hooks.run("before-highlightall",a),a.elements=Array.prototype.slice.apply(a.container.querySelectorAll(a.selector)),r.hooks.run("before-all-elements-highlight",a);for(var s,i=0;s=a.elements[i++];)r.highlightElement(s,!0===t,a.callback)},highlightElement:function(t,n,a){var s=r.util.getLanguage(t),i=r.languages[s];r.util.setLanguage(t,s);var o=t.parentElement;o&&"pre"===o.nodeName.toLowerCase()&&r.util.setLanguage(o,s);var l={element:t,language:s,grammar:i,code:t.textContent};function u(e){l.highlightedCode=e,r.hooks.run("before-insert",l),l.element.innerHTML=l.highlightedCode,r.hooks.run("after-highlight",l),r.hooks.run("complete",l),a&&a.call(l.element)}if(r.hooks.run("before-sanity-check",l),(o=l.element.parentElement)&&"pre"===o.nodeName.toLowerCase()&&!o.hasAttribute("tabindex")&&o.setAttribute("tabindex","0"),!l.code)return r.hooks.run("complete",l),void(a&&a.call(l.element));if(r.hooks.run("before-highlight",l),l.grammar)if(n&&e.Worker){var g=new Worker(r.filename);g.onmessage=function(e){u(e.data)},g.postMessage(JSON.stringify({language:l.language,code:l.code,immediateClose:!0}))}else u(r.highlight(l.code,l.grammar,l.language));else u(r.util.encode(l.code))},highlight:function(e,t,n){var a={code:e,grammar:t,language:n};if(r.hooks.run("before-tokenize",a),!a.grammar)throw new Error('The language "'+a.language+'" has no grammar.');return a.tokens=r.tokenize(a.code,a.grammar),r.hooks.run("after-tokenize",a),s.stringify(r.util.encode(a.tokens),a.language)},tokenize:function(e,t){var n=t.rest;if(n){for(var a in n)t[a]=n[a];delete t.rest}var r=new l;return u(r,r.head,e),o(e,r,t,r.head,0),function(e){var t=[],n=e.head.next;for(;n!==e.tail;)t.push(n.value),n=n.next;return t}(r)},hooks:{all:{},add:function(e,t){var n=r.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=r.hooks.all[e];if(n&&n.length)for(var a,s=0;a=n[s++];)a(t)}},Token:s};function s(e,t,n,a){this.type=e,this.content=t,this.alias=n,this.length=0|(a||"").length}function i(e,t,n,a){e.lastIndex=t;var r=e.exec(n);if(r&&a&&r[1]){var s=r[1].length;r.index+=s,r[0]=r[0].slice(s)}return r}function o(e,t,n,a,l,c){for(var d in n)if(n.hasOwnProperty(d)&&n[d]){var p=n[d];p=Array.isArray(p)?p:[p];for(var m=0;m<p.length;++m){if(c&&c.cause==d+","+m)return;var h=p[m],f=h.inside,v=!!h.lookbehind,b=!!h.greedy,y=h.alias;if(b&&!h.pattern.global){var F=h.pattern.toString().match(/[imsuy]*$/)[0];h.pattern=RegExp(h.pattern.source,F+"g")}for(var x=h.pattern||h,k=a.next,w=l;k!==t.tail&&!(c&&w>=c.reach);w+=k.value.length,k=k.next){var A=k.value;if(t.length>e.length)return;if(!(A instanceof s)){var P,$=1;if(b){if(!(P=i(x,w,e,v))||P.index>=e.length)break;var S=P.index,E=P.index+P[0].length,_=w;for(_+=k.value.length;S>=_;)_+=(k=k.next).value.length;if(w=_-=k.value.length,k.value instanceof s)continue;for(var j=k;j!==t.tail&&(_<E||"string"==typeof j.value);j=j.next)$++,_+=j.value.length;$--,A=e.slice(w,_),P.index-=w}else if(!(P=i(x,0,A,v)))continue;S=P.index;var C=P[0],L=A.slice(0,S),z=A.slice(S+C.length),O=w+A.length;c&&O>c.reach&&(c.reach=O);var T=k.prev;if(L&&(T=u(t,T,L),w+=L.length),g(t,T,$),k=u(t,T,new s(d,f?r.tokenize(C,f):C,y,C)),z&&u(t,k,z),$>1){var M={cause:d+","+m,reach:O};o(e,t,n,k.prev,w,M),c&&M.reach>c.reach&&(c.reach=M.reach)}}}}}}function l(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function u(e,t,n){var a=t.next,r={value:n,prev:t,next:a};return t.next=r,a.prev=r,e.length++,r}function g(e,t,n){for(var a=t.next,r=0;r<n&&a!==e.tail;r++)a=a.next;t.next=a,a.prev=t,e.length-=r}if(e.Prism=r,s.stringify=function e(t,n){if("string"==typeof t)return t;if(Array.isArray(t)){var a="";return t.forEach((function(t){a+=e(t,n)})),a}var s={type:t.type,content:e(t.content,n),tag:"span",classes:["token",t.type],attributes:{},language:n},i=t.alias;i&&(Array.isArray(i)?Array.prototype.push.apply(s.classes,i):s.classes.push(i)),r.hooks.run("wrap",s);var o="";for(var l in s.attributes)o+=" "+l+'="'+(s.attributes[l]||"").replace(/"/g,""")+'"';return"<"+s.tag+' class="'+s.classes.join(" ")+'"'+o+">"+s.content+"</"+s.tag+">"},!e.document)return e.addEventListener?(r.disableWorkerMessageHandler||e.addEventListener("message",(function(t){var n=JSON.parse(t.data),a=n.language,s=n.code,i=n.immediateClose;e.postMessage(r.highlight(s,r.languages[a],a)),i&&e.close()}),!1),r):r;var c=r.util.currentScript();function d(){r.manual||r.highlightAll()}if(c&&(r.filename=c.src,c.hasAttribute("data-manual")&&(r.manual=!0)),!r.manual){var p=document.readyState;"loading"===p||"interactive"===p&&c&&c.defer?document.addEventListener("DOMContentLoaded",d):window.requestAnimationFrame?window.requestAnimationFrame(d):window.setTimeout(d,16)}return r}(_self); +/** + * Prism: Lightweight, robust, elegant syntax highlighting + * + * @license MIT <https://opensource.org/licenses/MIT> + * @author Lea Verou <https://lea.verou.me> + * @namespace + * @public + */"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism),Prism.languages.markup={comment:{pattern:/<!--(?:(?!<!--)[\s\S])*?-->/,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/<!DOCTYPE(?:[^>"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|<!--(?:[^-]|-(?!->))*-->)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^<!|>$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(e){"entity"===e.type&&(e.attributes.title=e.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(e,t){var n={};n["language-"+t]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:Prism.languages[t]},n.cdata=/^<!\[CDATA\[|\]\]>$/i;var a={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:n}};a["language-"+t]={pattern:/[\s\S]+/,inside:Prism.languages[t]};var r={};r[e]={pattern:RegExp(/(<__[^>]*>)(?:<!\[CDATA\[(?:[^\]]|\](?!\]>))*\]\]>|(?!<!\[CDATA\[)[\s\S])*?(?=<\/__>)/.source.replace(/__/g,(function(){return e})),"i"),lookbehind:!0,greedy:!0,inside:a},Prism.languages.insertBefore("markup","cdata",r)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(e,t){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp(/(^|["'\s])/.source+"(?:"+e+")"+/\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))/.source,"i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[t,"language-"+t],inside:Prism.languages[t]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml,function(e){var t=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;e.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:"+/[^;{\s"']|\s+(?!\s)/.source+"|"+t.source+")*?"+/(?:;|(?=\s*\{))/.source),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+t.source+"|"+/(?:[^\\\r\n()"']|\\[\s\S])*/.source+")\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+t.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+t.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:t,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},e.languages.css.atrule.inside.rest=e.languages.css;var n=e.languages.markup;n&&(n.tag.addInlined("style","css"),n.tag.addAttribute("style","css"))}(Prism),Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/},Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp(/(^|[^\w$])/.source+"(?:"+/NaN|Infinity/.source+"|"+/0[bB][01]+(?:_[01]+)*n?/.source+"|"+/0[oO][0-7]+(?:_[0-7]+)*n?/.source+"|"+/0[xX][\dA-Fa-f]+(?:_[\dA-Fa-f]+)*n?/.source+"|"+/\d+(?:_\d+)*n/.source+"|"+/(?:\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\.\d+(?:_\d+)*)(?:[Ee][+-]?\d+(?:_\d+)*)?/.source+")"+/(?![\w$])/.source),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp(/((?:^|[^$\w\xA0-\uFFFF."'\])\s]|\b(?:return|yield))\s*)/.source+/\//.source+"(?:"+/(?:\[(?:[^\]\\\r\n]|\\.)*\]|\\.|[^/\\\[\r\n])+\/[dgimyus]{0,7}/.source+"|"+/(?:\[(?:[^[\]\\\r\n]|\\.|\[(?:[^[\]\\\r\n]|\\.|\[(?:[^[\]\\\r\n]|\\.)*\])*\])*\]|\\.|[^/\\\[\r\n])+\/[dgimyus]{0,7}v[dgimyus]{0,7}/.source+")"+/(?=(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:$|[\r\n,.;:})\]]|\/\/))/.source),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute(/on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)/.source,"javascript")),Prism.languages.js=Prism.languages.javascript,function(){if(void 0!==Prism&&"undefined"!=typeof document){Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector);var e={js:"javascript",py:"python",rb:"ruby",ps1:"powershell",psm1:"powershell",sh:"bash",bat:"batch",h:"c",tex:"latex"},t="data-src-status",n="loading",a="loaded",r="pre[data-src]:not(["+t+'="'+a+'"]):not(['+t+'="'+n+'"])';Prism.hooks.add("before-highlightall",(function(e){e.selector+=", "+r})),Prism.hooks.add("before-sanity-check",(function(s){var i=s.element;if(i.matches(r)){s.code="",i.setAttribute(t,n);var o=i.appendChild(document.createElement("CODE"));o.textContent="Loading…";var l=i.getAttribute("data-src"),u=s.language;if("none"===u){var g=(/\.(\w+)$/.exec(l)||[,"none"])[1];u=e[g]||g}Prism.util.setLanguage(o,u),Prism.util.setLanguage(i,u);var c=Prism.plugins.autoloader;c&&c.loadLanguages(u),function(e,t,n){var a=new XMLHttpRequest;a.open("GET",e,!0),a.onreadystatechange=function(){4==a.readyState&&(a.status<400&&a.responseText?t(a.responseText):a.status>=400?n("✖ Error "+a.status+" while fetching file: "+a.statusText):n("✖ Error: File does not exist or is empty"))},a.send(null)}(l,(function(e){i.setAttribute(t,a);var n=function(e){var t=/^\s*(\d+)\s*(?:(,)\s*(?:(\d+)\s*)?)?$/.exec(e||"");if(t){var n=Number(t[1]),a=t[2],r=t[3];return a?r?[n,Number(r)]:[n,void 0]:[n,n]}}(i.getAttribute("data-range"));if(n){var r=e.split(/\r\n?|\n/g),s=n[0],l=null==n[1]?r.length:n[1];s<0&&(s+=r.length),s=Math.max(0,Math.min(s-1,r.length)),l<0&&(l+=r.length),l=Math.max(0,Math.min(l,r.length)),e=r.slice(s,l).join("\n"),i.hasAttribute("data-start")||i.setAttribute("data-start",String(s+1))}o.textContent=e,Prism.highlightElement(o)}),(function(e){i.setAttribute(t,"failed"),o.textContent=e}))}})),Prism.plugins.fileHighlight={highlight:function(e){for(var t,n=(e||document).querySelectorAll(r),a=0;t=n[a++];)Prism.highlightElement(t)}};var s=!1;Prism.fileHighlight=function(){s||(console.warn("Prism.fileHighlight is deprecated. Use `Prism.plugins.fileHighlight.highlight` instead."),s=!0),Prism.plugins.fileHighlight.highlight.apply(this,arguments)}}}(); +; + +!function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",a={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},n={bash:a,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:n},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:a}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:n},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:n.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:n.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},a.inside=e.languages.bash;for(var s=["comment","function-name","for-or-select","assign-left","parameter","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],o=n.variable[1].inside,i=0;i<s.length;i++)o[s[i]]=e.languages.bash[s[i]];e.languages.sh=e.languages.bash,e.languages.shell=e.languages.bash}(Prism);; + +Prism.languages.haskell={comment:{pattern:/(^|[^-!#$%*+=?&@|~.:<>^\\\/])(?:--(?:(?=.)[^-!#$%*+=?&@|~.:<>^\\\/].*|$)|\{-[\s\S]*?-\})/m,lookbehind:!0},char:{pattern:/'(?:[^\\']|\\(?:[abfnrtv\\"'&]|\^[A-Z@[\]^_]|ACK|BEL|BS|CAN|CR|DC1|DC2|DC3|DC4|DEL|DLE|EM|ENQ|EOT|ESC|ETB|ETX|FF|FS|GS|HT|LF|NAK|NUL|RS|SI|SO|SOH|SP|STX|SUB|SYN|US|VT|\d+|o[0-7]+|x[0-9a-fA-F]+))'/,alias:"string"},string:{pattern:/"(?:[^\\"]|\\(?:\S|\s+\\))*"/,greedy:!0},keyword:/\b(?:case|class|data|deriving|do|else|if|in|infixl|infixr|instance|let|module|newtype|of|primitive|then|type|where)\b/,"import-statement":{pattern:/(^[\t ]*)import\s+(?:qualified\s+)?(?:[A-Z][\w']*)(?:\.[A-Z][\w']*)*(?:\s+as\s+(?:[A-Z][\w']*)(?:\.[A-Z][\w']*)*)?(?:\s+hiding\b)?/m,lookbehind:!0,inside:{keyword:/\b(?:as|hiding|import|qualified)\b/,punctuation:/\./}},builtin:/\b(?:abs|acos|acosh|all|and|any|appendFile|approxRational|asTypeOf|asin|asinh|atan|atan2|atanh|basicIORun|break|catch|ceiling|chr|compare|concat|concatMap|const|cos|cosh|curry|cycle|decodeFloat|denominator|digitToInt|div|divMod|drop|dropWhile|either|elem|encodeFloat|enumFrom|enumFromThen|enumFromThenTo|enumFromTo|error|even|exp|exponent|fail|filter|flip|floatDigits|floatRadix|floatRange|floor|fmap|foldl|foldl1|foldr|foldr1|fromDouble|fromEnum|fromInt|fromInteger|fromIntegral|fromRational|fst|gcd|getChar|getContents|getLine|group|head|id|inRange|index|init|intToDigit|interact|ioError|isAlpha|isAlphaNum|isAscii|isControl|isDenormalized|isDigit|isHexDigit|isIEEE|isInfinite|isLower|isNaN|isNegativeZero|isOctDigit|isPrint|isSpace|isUpper|iterate|last|lcm|length|lex|lexDigits|lexLitChar|lines|log|logBase|lookup|map|mapM|mapM_|max|maxBound|maximum|maybe|min|minBound|minimum|mod|negate|not|notElem|null|numerator|odd|or|ord|otherwise|pack|pi|pred|primExitWith|print|product|properFraction|putChar|putStr|putStrLn|quot|quotRem|range|rangeSize|read|readDec|readFile|readFloat|readHex|readIO|readInt|readList|readLitChar|readLn|readOct|readParen|readSigned|reads|readsPrec|realToFrac|recip|rem|repeat|replicate|return|reverse|round|scaleFloat|scanl|scanl1|scanr|scanr1|seq|sequence|sequence_|show|showChar|showInt|showList|showLitChar|showParen|showSigned|showString|shows|showsPrec|significand|signum|sin|sinh|snd|sort|span|splitAt|sqrt|subtract|succ|sum|tail|take|takeWhile|tan|tanh|threadToIOResult|toEnum|toInt|toInteger|toLower|toRational|toUpper|truncate|uncurry|undefined|unlines|until|unwords|unzip|unzip3|userError|words|writeFile|zip|zip3|zipWith|zipWith3)\b/,number:/\b(?:\d+(?:\.\d+)?(?:e[+-]?\d+)?|0o[0-7]+|0x[0-9a-f]+)\b/i,operator:[{pattern:/`(?:[A-Z][\w']*\.)*[_a-z][\w']*`/,greedy:!0},{pattern:/(\s)\.(?=\s)/,lookbehind:!0},/[-!#$%*+=?&@|~:<>^\\\/][-!#$%*+=?&@|~.:<>^\\\/]*|\.[-!#$%*+=?&@|~.:<>^\\\/]+/],hvariable:{pattern:/\b(?:[A-Z][\w']*\.)*[_a-z][\w']*/,inside:{punctuation:/\./}},constant:{pattern:/\b(?:[A-Z][\w']*\.)*[A-Z][\w']*/,inside:{punctuation:/\./}},punctuation:/[{}[\];(),.:]/},Prism.languages.hs=Prism.languages.haskell;; + +Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript;; + +!function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism);; + +Prism.languages.markup={comment:{pattern:/<!--(?:(?!<!--)[\s\S])*?-->/,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/<!DOCTYPE(?:[^>"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|<!--(?:[^-]|-(?!->))*-->)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^<!|>$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^<!\[CDATA\[|\]\]>$/i;var t={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:<!\\[CDATA\\[(?:[^\\]]|\\](?!\\]>))*\\]\\]>|(?!<!\\[CDATA\\[)[^])*?(?=</__>)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml;; + +!function(e){var n=/[*&][^\s[\]{},]+/,r=/!(?:<[\w\-%#;/?:@&=+$,.!~*'()[\]]+>|(?:[a-zA-Z\d-]*!)?[\w\-%#;/?:@&=+$.~*'()]+)?/,t="(?:"+r.source+"(?:[ \t]+"+n.source+")?|"+n.source+"(?:[ \t]+"+r.source+")?)",a="(?:[^\\s\\x00-\\x08\\x0e-\\x1f!\"#%&'*,\\-:>?@[\\]`{|}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]|[?:-]<PLAIN>)(?:[ \t]*(?:(?![#:])<PLAIN>|:<PLAIN>))*".replace(/<PLAIN>/g,(function(){return"[^\\s\\x00-\\x08\\x0e-\\x1f,[\\]{}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]"})),d="\"(?:[^\"\\\\\r\n]|\\\\.)*\"|'(?:[^'\\\\\r\n]|\\\\.)*'";function o(e,n){n=(n||"").replace(/m/g,"")+"m";var r="([:\\-,[{]\\s*(?:\\s<<prop>>[ \t]+)?)(?:<<value>>)(?=[ \t]*(?:$|,|\\]|\\}|(?:[\r\n]\\s*)?#))".replace(/<<prop>>/g,(function(){return t})).replace(/<<value>>/g,(function(){return e}));return RegExp(r,n)}e.languages.yaml={scalar:{pattern:RegExp("([\\-:]\\s*(?:\\s<<prop>>[ \t]+)?[|>])[ \t]*(?:((?:\r?\n|\r)[ \t]+)\\S[^\r\n]*(?:\\2[^\r\n]+)*)".replace(/<<prop>>/g,(function(){return t}))),lookbehind:!0,alias:"string"},comment:/#.*/,key:{pattern:RegExp("((?:^|[:\\-,[{\r\n?])[ \t]*(?:<<prop>>[ \t]+)?)<<key>>(?=\\s*:\\s)".replace(/<<prop>>/g,(function(){return t})).replace(/<<key>>/g,(function(){return"(?:"+a+"|"+d+")"}))),lookbehind:!0,greedy:!0,alias:"atrule"},directive:{pattern:/(^[ \t]*)%.+/m,lookbehind:!0,alias:"important"},datetime:{pattern:o("\\d{4}-\\d\\d?-\\d\\d?(?:[tT]|[ \t]+)\\d\\d?:\\d{2}:\\d{2}(?:\\.\\d*)?(?:[ \t]*(?:Z|[-+]\\d\\d?(?::\\d{2})?))?|\\d{4}-\\d{2}-\\d{2}|\\d\\d?:\\d{2}(?::\\d{2}(?:\\.\\d*)?)?"),lookbehind:!0,alias:"number"},boolean:{pattern:o("false|true","i"),lookbehind:!0,alias:"important"},null:{pattern:o("null|~","i"),lookbehind:!0,alias:"important"},string:{pattern:o(d),lookbehind:!0,greedy:!0},number:{pattern:o("[+-]?(?:0x[\\da-f]+|0o[0-7]+|(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?|\\.inf|\\.nan)","i"),lookbehind:!0},tag:r,important:n,punctuation:/---|[:[\]{}\-,|>?]|\.\.\./},e.languages.yml=e.languages.yaml}(Prism);; + +Prism.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/,lookbehind:!0,inside:{"format-spec":{pattern:/(:)[^:(){}]+(?=\}$)/,lookbehind:!0},"conversion-option":{pattern:/![sra](?=[:}]$)/,alias:"punctuation"},rest:null}},string:/[\s\S]+/}},"triple-quoted-string":{pattern:/(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i,greedy:!0,alias:"string"},string:{pattern:/(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i,greedy:!0},function:{pattern:/((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/g,lookbehind:!0},"class-name":{pattern:/(\bclass\s+)\w+/i,lookbehind:!0},decorator:{pattern:/(^[\t ]*)@\w+(?:\.\w+)*/m,lookbehind:!0,alias:["annotation","punctuation"],inside:{punctuation:/\./}},keyword:/\b(?:_(?=\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,builtin:/\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\b/,boolean:/\b(?:False|None|True)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},Prism.languages.python["string-interpolation"].inside.interpolation.inside.rest=Prism.languages.python,Prism.languages.py=Prism.languages.python;; + +Prism.languages.makefile={comment:{pattern:/(^|[^\\])#(?:\\(?:\r\n|[\s\S])|[^\\\r\n])*/,lookbehind:!0},string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"builtin-target":{pattern:/\.[A-Z][^:#=\s]+(?=\s*:(?!=))/,alias:"builtin"},target:{pattern:/^(?:[^:=\s]|[ \t]+(?![\s:]))+(?=\s*:(?!=))/m,alias:"symbol",inside:{variable:/\$+(?:(?!\$)[^(){}:#=\s]+|(?=[({]))/}},variable:/\$+(?:(?!\$)[^(){}:#=\s]+|\([@*%<^+?][DF]\)|(?=[({]))/,keyword:/-include\b|\b(?:define|else|endef|endif|export|ifn?def|ifn?eq|include|override|private|sinclude|undefine|unexport|vpath)\b/,function:{pattern:/(\()(?:abspath|addsuffix|and|basename|call|dir|error|eval|file|filter(?:-out)?|findstring|firstword|flavor|foreach|guile|if|info|join|lastword|load|notdir|or|origin|patsubst|realpath|shell|sort|strip|subst|suffix|value|warning|wildcard|word(?:list|s)?)(?=[ \t])/,lookbehind:!0},operator:/(?:::|[?:+!])?=|[|@]/,punctuation:/[:;(){}]/}; +//# sourceMappingURL=/sm/0c218ab699ca9cfe5f9d16f7fa42522dfcb582c50a3a78b3a660a46d6c6502c5.map \ No newline at end of file diff --git a/static/js/random.js b/static/js/random.js new file mode 100644 index 0000000..ff2fc87 --- /dev/null +++ b/static/js/random.js @@ -0,0 +1,25 @@ +/* random.js — "Random Page" for the homepage. + Fetches /random-pages.json (essays + blog posts, generated at build time) + and navigates to a uniformly random entry on click. + Attaches to: #random-page-btn (legacy button) or [data-random] (link row). */ +(function () { + 'use strict'; + + function goRandom(e) { + e.preventDefault(); + fetch('/random-pages.json') + .then(function (r) { return r.json(); }) + .then(function (pages) { + if (!pages.length) return; + window.location.href = pages[Math.floor(Math.random() * pages.length)]; + }) + .catch(function () {}); + } + + document.addEventListener('DOMContentLoaded', function () { + var els = document.querySelectorAll('#random-page-btn, [data-random]'); + els.forEach(function (el) { + el.addEventListener('click', goRandom); + }); + }); +}()); diff --git a/static/js/reading.js b/static/js/reading.js new file mode 100644 index 0000000..8540ece --- /dev/null +++ b/static/js/reading.js @@ -0,0 +1,19 @@ +/* reading.js — Scroll-progress bar for reading (fiction, poetry) mode. */ +(function () { + 'use strict'; + + function update() { + var bar = document.getElementById('reading-progress'); + if (!bar) return; + var scrollTop = window.scrollY || document.documentElement.scrollTop; + var docHeight = document.documentElement.scrollHeight - window.innerHeight; + var pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0; + bar.style.width = Math.min(pct, 100).toFixed(2) + '%'; + } + + document.addEventListener('DOMContentLoaded', function () { + update(); + window.addEventListener('scroll', update, { passive: true }); + window.addEventListener('resize', update, { passive: true }); + }); +}()); diff --git a/static/js/score-reader.js b/static/js/score-reader.js new file mode 100644 index 0000000..0abc8f4 --- /dev/null +++ b/static/js/score-reader.js @@ -0,0 +1,537 @@ +/* score-reader.js — Page navigation, zoom and pan for the full score reader. + + Configuration is read from #score-reader-stage data attributes: + data-page-count — total number of score pages + data-pages — comma-separated list of absolute page SVG URLs + + Pages are fetched and inlined as live SVG rather than pointed at with an + <img>. Three things follow from that, and they are the reason for it: + the notation inherits the theme's `color` (an <img>-loaded SVG resolves + `currentColor` against its own root, so it can only ever draw black); + zooming is a width change on a vector, not a rescaled bitmap; and the + elements are in the DOM, which is what any later staff- or measure-level + feature will need. */ +(function () { + 'use strict'; + + var stage = document.getElementById('score-reader-stage'); + var viewport = document.getElementById('score-reader-viewport'); + var pageEl = document.getElementById('score-page'); + var folio = document.getElementById('score-folio'); + var prevBtn = document.getElementById('score-prev'); + var nextBtn = document.getElementById('score-next'); + var bar = document.getElementById('score-reader-bar'); + + if (!stage || !viewport || !pageEl || !folio || !prevBtn || !nextBtn) return; + + var pages = (stage.dataset.pages || '').split(',').filter(function (p) { + return p.length > 0; + }); + var pageCount = pages.length; + if (pageCount === 0) return; /* nothing to display */ + + var currentPage = 1; + var ratio = null; /* page width / height, learned from page 1 */ + var naturalW = null; /* page width in CSS px at 100 % */ + var cache = new Map(); + + /* ------------------------------------------------------------------ + Fetching and inlining + ------------------------------------------------------------------ */ + + /* An ordinary web link, a fragment, or a relative path. Mirrors the + convention Catalog.safeHref applies on the Haskell side. */ + var SAFE_HREF = /^(https?:|\/|#|\.)/i; + + /* innerHTML and adoptNode never execute a <script>, but inline `on*` + handlers and SVG anchors survive both — <a xlink:href="javascript:…"> + around a notehead is clickable. Strip them here, with the browser's + own parser, rather than in a build-time text pass: a hand-rolled + scanner over a sixty-page orchestral score risks corrupting valid + markup, which is a worse failure than the one it would prevent. + + Odd hrefs are the common case, not the exotic one. LilyPond emits + point-and-click anchors by default — hundreds per page, each holding + the absolute path of the source .ly — so this also keeps a local + filesystem layout from reaching a reader, and stops every notehead + from swallowing drags that should be panning the page. A `#` + fragment survives, because an engraver referencing shared glyphs + through <use href="#…"> depends on it. */ + function sanitize(svg) { + Array.prototype.forEach.call(svg.querySelectorAll('script'), function (n) { + n.parentNode.removeChild(n); + }); + var walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT); + var el = svg; + while (el) { + Array.prototype.slice.call(el.attributes).forEach(function (a) { + if (/^on/i.test(a.name)) { + el.removeAttribute(a.name); + } else if (a.localName === 'href' + && !SAFE_HREF.test(a.value.trim())) { + el.removeAttribute(a.name); + } + }); + el = walker.nextNode(); + } + return svg; + } + + function fetchPage(index) { + if (index < 1 || index > pageCount) return Promise.resolve(null); + if (cache.has(index)) return Promise.resolve(cache.get(index)); + + var p = fetch(pages[index - 1], { credentials: 'same-origin' }) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.text(); + }) + .then(function (text) { + var doc = new DOMParser().parseFromString(text, 'image/svg+xml'); + if (doc.querySelector('parsererror')) throw new Error('malformed SVG'); + var svg = doc.documentElement; + if (!svg || svg.localName !== 'svg') throw new Error('not an SVG'); + return sanitize(svg); + }) + .catch(function (err) { + cache.delete(index); /* a failure must not be cached */ + throw err; + }); + + cache.set(index, p); + return p; + } + + /* ------------------------------------------------------------------ + Geometry — read the page's own dimensions off the SVG + ------------------------------------------------------------------ */ + + var UNIT_PX = { + '': 1, px: 1, pt: 96 / 72, pc: 16, + mm: 96 / 25.4, cm: 96 / 2.54, 'in': 96 + }; + + function aspectOf(svg) { + var vb = svg.getAttribute('viewBox'); + if (vb) { + var p = vb.trim().split(/[\s,]+/).map(Number); + if (p.length === 4 && p[2] > 0 && p[3] > 0) return p[2] / p[3]; + } + /* width and height carry the same unit, so the ratio needs no + conversion even when that unit is millimetres. */ + var w = parseFloat(svg.getAttribute('width')); + var h = parseFloat(svg.getAttribute('height')); + if (w > 0 && h > 0) return w / h; + return null; + } + + function naturalWidthOf(svg) { + var raw = svg.getAttribute('width'); + if (!raw) return null; + var m = /^\s*([0-9.]+)\s*([a-z%]*)\s*$/i.exec(raw); + if (!m) return null; + var unit = (m[2] || '').toLowerCase(); + if (!(unit in UNIT_PX)) return null; /* %, em, … are not resolvable here */ + return parseFloat(m[1]) * UNIT_PX[unit]; + } + + /* ------------------------------------------------------------------ + Zoom + + Zoom sets the sheet's width in pixels and lets the SVG scale into + it, so the notation stays vector-crisp at every level. Panning is + the viewport's native scrolling — no transform bookkeeping, and + touch momentum comes free. + ------------------------------------------------------------------ */ + + var ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6]; + var mode = 'fit-height'; /* 'fit-height' | 'fit-width' | 'zoom' */ + var zoomFactor = 1; + + var modeBtn = document.querySelector('[data-action="zoom-cycle"]'); + var inBtn = document.querySelector('[data-action="zoom-in"]'); + var outBtn = document.querySelector('[data-action="zoom-out"]'); + + function available() { + var cs = getComputedStyle(viewport); + return { + w: viewport.clientWidth + - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), + h: viewport.clientHeight + - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom) + }; + } + + function applySize() { + if (!ratio) return; + var avail = available(); + var w; + if (mode === 'fit-width') w = avail.w; + else if (mode === 'fit-height') w = avail.h * ratio; + else w = (naturalW || avail.h * ratio) * zoomFactor; + + pageEl.style.setProperty('--score-page-width', + Math.max(80, Math.round(w)) + 'px'); + updateZoomUi(); + updateGutter(); + requestAnimationFrame(updatePanState); + } + + /* At fit, the folio sits in the page's own bottom margin and reads as a + printed page number. Zoomed in it floats over the staves instead, so + it needs a ground of its own to stop reading as ink on the music. */ + function updatePanState() { + document.body.classList.toggle('is-panning', + viewport.scrollWidth > viewport.clientWidth + 1 || + viewport.scrollHeight > viewport.clientHeight + 1); + } + + /* The zoom level the sheet is currently at, whatever mode produced it — + so stepping out of a fit mode continues from what is on screen rather + than jumping back to whatever the last explicit zoom was. */ + function effectiveFactor() { + var w = pageEl.getBoundingClientRect().width; + if (!naturalW || !w) return zoomFactor; + return w / naturalW; + } + + function updateZoomUi() { + if (!modeBtn) return; + if (mode === 'fit-height') modeBtn.textContent = 'Fit'; + else if (mode === 'fit-width') modeBtn.textContent = 'Width'; + else modeBtn.textContent = Math.round(effectiveFactor() * 100) + '%'; + + var f = effectiveFactor(); + if (inBtn) inBtn.disabled = f >= ZOOM_STEPS[ZOOM_STEPS.length - 1] - 0.001; + if (outBtn) outBtn.disabled = f <= ZOOM_STEPS[0] + 0.001; + } + + function step(direction) { + var f = effectiveFactor(); + var next = null; + if (direction > 0) { + for (var i = 0; i < ZOOM_STEPS.length; i++) { + if (ZOOM_STEPS[i] > f + 0.001) { next = ZOOM_STEPS[i]; break; } + } + } else { + for (var j = ZOOM_STEPS.length - 1; j >= 0; j--) { + if (ZOOM_STEPS[j] < f - 0.001) { next = ZOOM_STEPS[j]; break; } + } + } + if (next === null) return; + mode = 'zoom'; + zoomFactor = next; + applySize(); + } + + if (inBtn) inBtn.addEventListener('click', function () { step(1); }); + if (outBtn) outBtn.addEventListener('click', function () { step(-1); }); + if (modeBtn) modeBtn.addEventListener('click', function () { + mode = (mode === 'fit-height') ? 'fit-width' : 'fit-height'; + applySize(); + }); + + window.addEventListener('resize', applySize); + viewport.addEventListener('scroll', function () { + updatePanState(); + updateGutter(); + }, { passive: true }); + + /* ------------------------------------------------------------------ + Navigation + ------------------------------------------------------------------ */ + + /* Keep the canonical URL clean on a plain load: ?p= is only written + back once one was already present, or the reader has been navigated. */ + var qs = new URLSearchParams(window.location.search); + var syncUrl = qs.has('p'); + var initial = parseInt(qs.get('p'), 10); + if (!isNaN(initial) && initial >= 1 && initial <= pageCount) currentPage = initial; + + function render(index, svg) { + if (index !== currentPage) return; /* a later turn already won */ + + var node = svg.cloneNode(true); + node.setAttribute('aria-hidden', 'true'); + node.removeAttribute('width'); + node.removeAttribute('height'); + if (!node.getAttribute('preserveAspectRatio')) { + node.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + } + + if (ratio === null) { + ratio = aspectOf(svg); + naturalW = naturalWidthOf(svg); + /* Page shape picks the opening mode. Fit-height shows a whole + landscape page and stays readable; on a portrait page it + leaves most of a landscape window empty and, for a large + ensemble, shrinks the staves past reading. Only the initial + mode — a later Fit/Width press is the reader's own. */ + if (ratio !== null && ratio < 1) mode = 'fit-width'; + if (ratio) pageEl.style.setProperty('--score-aspect-num', String(ratio)); + } + + pageEl.replaceChildren(node); + pageEl.classList.remove('is-loading'); + + /* Size before measuring: the gutter is a fraction of the laid-out + page, and every page carries its own — a first page prints full + instrument names where later ones abbreviate. */ + applySize(); + buildGutter(node); + } + + /* ------------------------------------------------------------------ + Instrument-label gutter + + Zoomed in far enough to read a large-ensemble page, the left column + of names scrolls off and there is no way to tell which staff is + which. That is what makes a 33-stave score unreadable rather than + merely awkward, so a second copy of the page — clipped to that + column and pinned to the viewport's left edge — keeps the names in + view at any zoom. + + Where the column ends is measured, not declared. The leftmost long + horizontal rule on a page is where the staves begin; everything left + of it is names and brackets. Measuring through getBoundingClientRect + rather than parsing coordinates keeps this independent of each + engraver's units and nested transforms: MuseScore draws staff lines + as <polyline> in page coordinates and LilyPond as <line> inside + translated groups, and both measure the same way. + ------------------------------------------------------------------ */ + + var gutterEl = null; + var gutterInner = null; + var gutterFrac = 0; + + function measureGutter(svgEl) { + var box = svgEl.getBoundingClientRect(); + if (!box.width) return 0; + + /* A staff line runs the width of its system. Stems, ledger lines + and barlines do not, which is the whole filter. */ + var minRun = box.width * 0.2; + var left = Infinity; + var rules = svgEl.querySelectorAll('line, polyline'); + + for (var i = 0; i < rules.length; i++) { + var r = rules[i].getBoundingClientRect(); + if (r.height > 4 || r.width < minRun) continue; + if (r.left < left) left = r.left; + } + if (left === Infinity) return 0; + + var frac = (left - box.left) / box.width; + /* Reject a page with no label column and a measurement that has + swallowed most of the page; neither deserves a gutter. */ + return (frac > 0.02 && frac < 0.4) ? frac : 0; + } + + function buildGutter(node) { + if (gutterEl && gutterEl.parentNode) gutterEl.parentNode.removeChild(gutterEl); + gutterEl = null; + gutterFrac = measureGutter(node); + if (!gutterFrac) return; + + gutterInner = document.createElement('div'); + gutterInner.className = 'score-gutter-inner'; + gutterInner.appendChild(node.cloneNode(true)); + + gutterEl = document.createElement('div'); + gutterEl.className = 'score-gutter'; + gutterEl.setAttribute('aria-hidden', 'true'); /* the page already says this */ + gutterEl.appendChild(gutterInner); + pageEl.appendChild(gutterEl); + updateGutter(); + } + + function updateGutter() { + if (!gutterEl) return; + var scrollLeft = viewport.scrollLeft; + var pageW = pageEl.getBoundingClientRect().width; + + /* Both widths come from the laid-out sheet rather than from + --score-page-width. The copy has to be exactly as wide as the + page for its labels to land where the originals do, and reading + the box directly means that holds however the width was arrived + at — a fit expression, a zoom step, or a stylesheet override. */ + gutterInner.style.width = pageW + 'px'; + + /* The label column is a fixed share of the page, so the deeper the + zoom the more of the window it would claim — 42% of a 1600px + viewport at 4600px wide, which is absurd for a row of names. + Past a third of the window it is condensed horizontally instead + of being allowed to grow or being clipped: scaleX leaves every y + coordinate alone, so the names stay level with their own staves, + and condensed type is still readable where a truncated name is + not. */ + var natural = pageW * gutterFrac; + var cap = viewport.clientWidth * 0.33; + var shown = Math.min(natural, cap); + var squeeze = natural > 0 ? shown / natural : 1; + + gutterInner.style.transformOrigin = '0 0'; + gutterInner.style.transform = + squeeze < 1 ? 'scaleX(' + squeeze + ')' : ''; + gutterEl.style.setProperty('--score-gutter-w', + Math.round(shown) + 'px'); + gutterEl.style.transform = 'translateX(' + scrollLeft + 'px)'; + /* Only once the real labels have actually scrolled out from under it; + before that the gutter would be an exact copy of what is already + on screen, and its edge rule would be the only visible difference. */ + gutterEl.classList.toggle('is-visible', scrollLeft > 1); + } + + function fail(index, err) { + if (index !== currentPage) return; + pageEl.classList.remove('is-loading'); + var msg = document.createElement('p'); + msg.className = 'score-page-error'; + msg.textContent = 'Could not load page ' + index + '.'; + pageEl.replaceChildren(msg); + if (window.console) console.error('[score-reader]', err); + } + + function navigate(page) { + if (page < 1 || page > pageCount || page === currentPage) return; + show(page); + } + + function show(page) { + currentPage = page; + + folio.textContent = page + ' / ' + pageCount; + pageEl.setAttribute('aria-label', + 'Score page ' + page + ' of ' + pageCount); + prevBtn.disabled = (page === 1); + nextBtn.disabled = (page === pageCount); + updateActiveMovement(); + + if (syncUrl) history.replaceState(null, '', '?p=' + page); + + pageEl.classList.add('is-loading'); + fetchPage(page) + .then(function (svg) { render(page, svg); }) + .catch(function (err) { fail(page, err); }); + + /* Warm the neighbours so a turn in either direction is instant. */ + if (page > 1) fetchPage(page - 1).catch(function () {}); + if (page < pageCount) fetchPage(page + 1).catch(function () {}); + } + + /* ------------------------------------------------------------------ + Movement buttons — highlight the movement containing the page + ------------------------------------------------------------------ */ + + var mvtButtons = Array.prototype.slice.call( + document.querySelectorAll('.score-reader-mvt')); + + function updateActiveMovement() { + var active = null; + mvtButtons.forEach(function (btn) { + var p = parseInt(btn.dataset.page, 10); + if (!isNaN(p) && p <= currentPage) active = btn; + }); + mvtButtons.forEach(function (btn) { + btn.classList.toggle('is-active', btn === active); + }); + } + + mvtButtons.forEach(function (btn) { + btn.addEventListener('click', function () { + var p = parseInt(btn.dataset.page, 10); + if (!isNaN(p)) navigate(p); + }); + }); + + prevBtn.addEventListener('click', function () { navigate(currentPage - 1); }); + nextBtn.addEventListener('click', function () { navigate(currentPage + 1); }); + + /* ------------------------------------------------------------------ + Keyboard + ------------------------------------------------------------------ */ + + document.addEventListener('keydown', function (e) { + var panel = document.querySelector('.settings-panel'); + if (panel && panel.classList.contains('is-open')) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + + switch (e.key) { + case 'ArrowRight': case 'ArrowDown': case 'PageDown': case ' ': + navigate(currentPage + 1); e.preventDefault(); break; + case 'ArrowLeft': case 'ArrowUp': case 'PageUp': + navigate(currentPage - 1); e.preventDefault(); break; + case 'Home': + navigate(1); e.preventDefault(); break; + case 'End': + navigate(pageCount); e.preventDefault(); break; + case '+': case '=': + step(1); e.preventDefault(); break; + case '-': case '_': + step(-1); e.preventDefault(); break; + case '0': + mode = 'fit-height'; applySize(); e.preventDefault(); break; + case 'Escape': + history.back(); break; + } + }); + + /* ------------------------------------------------------------------ + Touch — swipe to turn, but only when there is nothing to pan. + Once the sheet is wider than the viewport a horizontal drag is the + reader panning across the system, and stealing it to turn the page + would make an orchestral score unreadable. + ------------------------------------------------------------------ */ + + var touchX = 0, touchY = 0; + + viewport.addEventListener('touchstart', function (e) { + touchX = e.changedTouches[0].clientX; + touchY = e.changedTouches[0].clientY; + }, { passive: true }); + + viewport.addEventListener('touchend', function (e) { + if (viewport.scrollWidth > viewport.clientWidth + 1) return; /* pannable */ + var dx = e.changedTouches[0].clientX - touchX; + var dy = e.changedTouches[0].clientY - touchY; + if (Math.abs(dx) < 50 || Math.abs(dy) > 30) return; + navigate(currentPage + (dx < 0 ? 1 : -1)); + }, { passive: true }); + + /* ------------------------------------------------------------------ + Idle — let the chrome recede so the sheet is the page + ------------------------------------------------------------------ */ + + var IDLE_MS = 3000; + var idleTimer = null; + + function poke() { + document.body.classList.remove('is-idle'); + clearTimeout(idleTimer); + idleTimer = setTimeout(function () { + var panel = document.querySelector('.settings-panel'); + if (panel && panel.classList.contains('is-open')) return poke(); + if (bar && bar.matches(':hover')) return poke(); + if (bar && bar.contains(document.activeElement)) return poke(); + document.body.classList.add('is-idle'); + }, IDLE_MS); + } + + ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel', 'focusin'] + .forEach(function (evt) { + document.addEventListener(evt, poke, { passive: true }); + }); + + /* ------------------------------------------------------------------ + Init + ------------------------------------------------------------------ */ + + /* Hands sizing over to applySize(); until this lands, the stylesheet's + own fit-height rules hold the sheet at the right size. */ + document.body.classList.add('js-ready'); + + show(currentPage); + applySize(); + poke(); + syncUrl = true; /* every later navigate() is a user action */ +}()); diff --git a/static/js/search-filters.js b/static/js/search-filters.js new file mode 100644 index 0000000..51a4b4b --- /dev/null +++ b/static/js/search-filters.js @@ -0,0 +1,405 @@ +/* search-filters.js — Epistemic effort + archive filters for the search page. + * + * Loads /data/epistemic-meta.json (a map of URL → epistemic fields) and + * /data/archive-meta.json (a map of /archive/ page URL → link-rot status) + * and hides search results whose source page doesn't match the active + * filters. Works for both Pagefind keyword results and semantic results. + * + * Naming: the epistemic `status` filter (draft / working model / …) and + * the archive link status (live / moved / rotted / error) are distinct + * dimensions — the latter lives under `archiveMode` / `archiveStatus` + * and its own button classes, never touching the epistemic namespace. + * + * Reuses the same CSS classes and filter-panel markup as library.html + * so the two pages look and behave identically. + */ +(function () { + 'use strict'; + + var KEY = 'search-filter-state'; + + var SCALES = { + scope: ['personal', 'average', 'broad', 'civilizational'], + novelty: ['conventional', 'moderate', 'idiosyncratic', 'innovative'], + practicality: ['abstract', 'moderate', 'high', 'exceptional'], + stability: ['volatile', 'revising', 'fairly stable', 'stable', 'established'] + }; + + var state = { + status: [], + confidence: null, + importance: null, + evidence: null, + score: null, + scope: null, + novelty: null, + practicality: null, + stability: null, + archiveMode: null, /* null | 'exclude' | 'only' */ + archiveStatus: [] /* link-rot statuses; archive results only */ + }; + + var epistemicMeta = null; /* URL → {status, confidence, …} loaded lazily */ + var archiveMeta = null; /* /archive/ URL → {status: live|moved|…} */ + + /* ---- Persistence ---- */ + + function load() { + try { + var raw = localStorage.getItem(KEY); + if (raw) { + var obj = JSON.parse(raw); + for (var k in state) { + if (obj.hasOwnProperty(k)) state[k] = obj[k]; + } + } + } catch (e) {} + } + + function save() { + try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} + } + + /* ---- Metadata loading ---- */ + + var metaPromise = null; + var archiveMetaPromise = null; + + function loadEpistemicMeta() { + if (epistemicMeta) return Promise.resolve(epistemicMeta); + if (metaPromise) return metaPromise; + metaPromise = fetch('/data/epistemic-meta.json') + .then(function (r) { return r.ok ? r.json() : {}; }) + .catch(function () { return {}; }) + .then(function (data) { epistemicMeta = data; return data; }); + return metaPromise; + } + + function loadArchiveMeta() { + if (archiveMeta) return Promise.resolve(archiveMeta); + if (archiveMetaPromise) return archiveMetaPromise; + archiveMetaPromise = fetch('/data/archive-meta.json') + .then(function (r) { return r.ok ? r.json() : {}; }) + .catch(function () { return {}; }) + .then(function (data) { archiveMeta = data; return data; }); + return archiveMetaPromise; + } + + /* Both maps load together: epistemicMeta doubles as the + "metadata is ready" marker in the apply/observer paths. */ + function loadMeta() { + return Promise.all([loadEpistemicMeta(), loadArchiveMeta()]); + } + + /* ---- Filtering logic ---- */ + + function passes(meta) { + if (!meta) return true; /* no metadata = don't filter out */ + + if (state.status.length) { + var s = (meta.status || '').toLowerCase(); + if (!s || state.status.indexOf(s) === -1) return false; + } + if (state.confidence !== null) { + if (!meta.confidence || +meta.confidence < state.confidence) return false; + } + if (state.importance !== null) { + if (!meta.importance || +meta.importance < state.importance) return false; + } + if (state.evidence !== null) { + if (!meta.evidence || +meta.evidence < state.evidence) return false; + } + if (state.score !== null) { + if (!meta.score || +meta.score < state.score) return false; + } + + var ords = ['scope', 'novelty', 'practicality', 'stability']; + for (var i = 0; i < ords.length; i++) { + var k = ords[i]; + if (state[k] !== null) { + var v = (meta[k] || '').toLowerCase(); + var idx = SCALES[k].indexOf(v); + if (idx === -1 || idx < state[k]) return false; + } + } + + return true; + } + + /* Archive dimension. `archiveMode` governs whether /archive/ pages + appear at all (exclude) or alone (only); `archiveStatus` further + restricts archive results to the selected link-rot statuses and + never affects non-archive results. */ + function isArchiveUrl(p) { + return !!p && p.indexOf('/archive/') === 0; + } + + function passesArchive(url) { + var isArch = isArchiveUrl(url); + if (state.archiveMode === 'exclude' && isArch) return false; + if (state.archiveMode === 'only' && !isArch) return false; + if (state.archiveStatus.length && isArch && archiveMeta) { + var rec = archiveMeta[url]; + var s = rec && rec.status; + if (!s || state.archiveStatus.indexOf(s) === -1) return false; + } + return true; + } + + function hasActiveFilters() { + if (state.status.length) return true; + if (state.archiveMode !== null || state.archiveStatus.length) return true; + var fields = ['confidence', 'importance', 'evidence', 'score', + 'scope', 'novelty', 'practicality', 'stability']; + for (var i = 0; i < fields.length; i++) { + if (state[fields[i]] !== null) return true; + } + return false; + } + + /* ---- URL extraction ---- */ + + /* Normalise a URL to a pathname for lookup in epistemicMeta. + Pagefind results use full URLs; semantic results use relative paths. + epistemicMeta keys are emitted as routed paths (".../index.html"), + while result links use the clean directory form (".../"), so the + trailing-slash form must be expanded before lookup. */ + function normUrl(href) { + if (!href) return null; + try { + var u = new URL(href, window.location.origin); + var p = u.pathname; + if (p.charAt(p.length - 1) === '/') p += 'index.html'; + return p; + } catch (e) { + return href; + } + } + + /* ---- Apply filters to rendered results ---- */ + + function applyToPagefind() { + if (!epistemicMeta || !hasActiveFilters()) { + /* Remove any previous filtering */ + document.querySelectorAll('.pagefind-ui__result.search-filtered').forEach(function (el) { + el.classList.remove('search-filtered'); + }); + return; + } + document.querySelectorAll('.pagefind-ui__result').forEach(function (el) { + var link = el.querySelector('.pagefind-ui__result-link'); + if (!link) return; + var url = normUrl(link.getAttribute('href')); + var meta = url ? epistemicMeta[url] : null; + el.classList.toggle('search-filtered', + !(passesArchive(url) && passes(meta))); + }); + } + + function applyToSemantic() { + if (!epistemicMeta || !hasActiveFilters()) { + document.querySelectorAll('.semantic-result.search-filtered').forEach(function (el) { + el.classList.remove('search-filtered'); + }); + return; + } + document.querySelectorAll('.semantic-result').forEach(function (el) { + var link = el.querySelector('.semantic-result-title'); + if (!link) return; + var url = normUrl(link.getAttribute('href')); + var meta = url ? epistemicMeta[url] : null; + el.classList.toggle('search-filtered', + !(passesArchive(url) && passes(meta))); + }); + } + + function applyFilters() { + applyToPagefind(); + applyToSemantic(); + syncUI(); + save(); + } + + /* ---- UI sync ---- */ + + function activeCount() { + var n = 0; + if (state.status.length) n++; + if (state.archiveMode !== null) n++; + if (state.archiveStatus.length) n++; + var fields = ['confidence', 'importance', 'evidence', 'score', + 'scope', 'novelty', 'practicality', 'stability']; + for (var i = 0; i < fields.length; i++) { + if (state[fields[i]] !== null) n++; + } + return n; + } + + function syncUI() { + var badge = document.querySelector('.filter-toggle-badge'); + var n = activeCount(); + if (badge) badge.textContent = n ? ' (' + n + ')' : ''; + + document.querySelectorAll('.filter-status-btn').forEach(function (btn) { + btn.classList.toggle('is-active', state.status.indexOf(btn.dataset.value) !== -1); + }); + + document.querySelectorAll('.filter-archive-mode-btn').forEach(function (btn) { + btn.classList.toggle('is-active', state.archiveMode === btn.dataset.value); + }); + + document.querySelectorAll('.filter-archive-status-btn').forEach(function (btn) { + btn.classList.toggle('is-active', state.archiveStatus.indexOf(btn.dataset.value) !== -1); + }); + + var ci = document.getElementById('filter-confidence'); + if (ci) ci.value = state.confidence !== null ? state.confidence : ''; + var si = document.getElementById('filter-score'); + if (si) si.value = state.score !== null ? state.score : ''; + + document.querySelectorAll('.filter-threshold-btn').forEach(function (btn) { + btn.classList.toggle('is-active', state[btn.dataset.field] === +btn.dataset.value); + }); + + document.querySelectorAll('.filter-ordinal-btn').forEach(function (btn) { + btn.classList.toggle('is-active', state[btn.dataset.field] === +btn.dataset.index); + }); + } + + /* ---- Init ---- */ + + document.addEventListener('DOMContentLoaded', function () { + load(); + + var panel = document.getElementById('search-filters'); + var toggle = document.querySelector('.library-filter-toggle'); + + if (activeCount() > 0 && panel && toggle) { + panel.hidden = false; + toggle.setAttribute('aria-expanded', 'true'); + } + + syncUI(); + + /* Load metadata eagerly if filters are active */ + if (hasActiveFilters()) { + loadMeta().then(applyFilters); + } + + /* Toggle panel */ + if (toggle && panel) { + toggle.addEventListener('click', function () { + var opening = panel.hidden; + panel.hidden = !opening; + toggle.setAttribute('aria-expanded', opening ? 'true' : 'false'); + }); + } + + /* Status buttons */ + document.querySelectorAll('.filter-status-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var v = btn.dataset.value; + var i = state.status.indexOf(v); + if (i === -1) state.status.push(v); + else state.status.splice(i, 1); + loadMeta().then(applyFilters); + }); + }); + + /* Archive mode buttons (exclude / only) — single-select toggle */ + document.querySelectorAll('.filter-archive-mode-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var v = btn.dataset.value; + state.archiveMode = (state.archiveMode === v) ? null : v; + loadMeta().then(applyFilters); + }); + }); + + /* Archive link-status buttons — multi-select, archive results only */ + document.querySelectorAll('.filter-archive-status-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var v = btn.dataset.value; + var i = state.archiveStatus.indexOf(v); + if (i === -1) state.archiveStatus.push(v); + else state.archiveStatus.splice(i, 1); + loadMeta().then(applyFilters); + }); + }); + + /* Threshold buttons (importance, evidence) */ + document.querySelectorAll('.filter-threshold-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var f = btn.dataset.field; + var v = +btn.dataset.value; + state[f] = (state[f] === v) ? null : v; + loadMeta().then(applyFilters); + }); + }); + + /* Ordinal buttons (scope, novelty, practicality, stability) */ + document.querySelectorAll('.filter-ordinal-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var f = btn.dataset.field; + var idx = +btn.dataset.index; + state[f] = (state[f] === idx) ? null : idx; + loadMeta().then(applyFilters); + }); + }); + + /* Number inputs (confidence, trust/score) */ + ['confidence', 'score'].forEach(function (field) { + var el = document.getElementById('filter-' + (field === 'score' ? 'score' : field)); + if (!el) return; + el.addEventListener('input', function () { + var v = el.value.trim(); + var n = parseInt(v, 10); + /* Non-numeric input deactivates the filter (null) rather + than coercing to an always-matching >= 0 threshold. */ + state[field] = (v !== '' && !isNaN(n)) + ? Math.max(0, Math.min(100, n)) + : null; + loadMeta().then(applyFilters); + }); + }); + + /* Clear all */ + var clearBtn = document.querySelector('.filter-clear-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', function () { + state.status = []; + state.confidence = null; + state.importance = null; + state.evidence = null; + state.score = null; + state.scope = null; + state.novelty = null; + state.practicality = null; + state.stability = null; + state.archiveMode = null; + state.archiveStatus = []; + applyFilters(); + }); + } + + /* Observe Pagefind result changes to re-apply filters. + Pagefind dynamically rebuilds the results container. */ + var searchEl = document.getElementById('search'); + if (searchEl) { + new MutationObserver(function () { + if (hasActiveFilters() && epistemicMeta) { + applyToPagefind(); + } + }).observe(searchEl, { childList: true, subtree: true }); + } + + /* Observe semantic results container */ + var semanticEl = document.getElementById('semantic-results'); + if (semanticEl) { + new MutationObserver(function () { + if (hasActiveFilters() && epistemicMeta) { + applyToSemantic(); + } + }).observe(semanticEl, { childList: true, subtree: true }); + } + }); +}()); diff --git a/static/js/search.js b/static/js/search.js new file mode 100644 index 0000000..2859c70 --- /dev/null +++ b/static/js/search.js @@ -0,0 +1,61 @@ +/* search.js — Pagefind UI initialisation for /search.html. + Loaded only on pages with search: true in frontmatter. + Pre-fills the search box from the ?q= query parameter so that + the selection popup's "Here" button lands ready to go. + Also instruments search timing and displays elapsed ms. */ +(function () { + 'use strict'; + + window.addEventListener('DOMContentLoaded', function () { + /* If the Pagefind bundle failed to load (e.g. 404), skip only the + Pagefind setup — the rest of this handler must still run. */ + var ui = null; + if (typeof PagefindUI === 'undefined') { + console.warn('search.js: PagefindUI not loaded — keyword search disabled.'); + } else { + ui = new PagefindUI({ + element: '#search', + showImages: false, + excerptLength: 30, + }); + } + + /* Timing instrumentation ------------------------------------------ */ + var timingEl = document.getElementById('search-timing'); + var searchEl = document.getElementById('search'); + var startTime = null; + + if (timingEl && searchEl) { + /* Input field is created synchronously by PagefindUI above. */ + var input = searchEl.querySelector('.pagefind-ui__search-input'); + if (input) { + input.addEventListener('input', function () { + if (input.value.trim().length > 0) { + startTime = performance.now(); + } else { + startTime = null; + timingEl.textContent = ''; + } + }); + } + + /* Watch for Pagefind rebuilding the results area. */ + new MutationObserver(function () { + if (startTime !== null) { + var elapsed = Math.round(performance.now() - startTime); + /* U+2009 thin space between number and unit */ + timingEl.textContent = elapsed + '\u2009ms'; + startTime = null; + } + }).observe(searchEl, { childList: true, subtree: true }); + } + + /* Pre-fill from URL parameter and trigger the search -------------- */ + var params = new URLSearchParams(window.location.search); + var q = params.get('q'); + if (q && ui) { + startTime = performance.now(); + ui.triggerSearch(q); + } + }); +}()); diff --git a/static/js/selection-popup.js b/static/js/selection-popup.js new file mode 100644 index 0000000..e8c62ff --- /dev/null +++ b/static/js/selection-popup.js @@ -0,0 +1,526 @@ +/* selection-popup.js — Custom text-selection toolbar. + Appears automatically after a short delay on any non-empty selection. + Adapts its buttons based on the context of the selection: + + code (known lang) → Copy · [MDN / Hoogle / Docs…] + code (unknown) → Copy + math → Copy · nLab · OEIS · Wolfram + prose (multi-word) → Annotate · BibTeX · Copy · DuckDuckGo · Here · Wikipedia + prose (one word) → Annotate · BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia + + When the selection sits inside a [lang] subtree whose primary subtag + differs from the document root (e.g. the ::: {lang="es"} blocks in + library.md), a Translate button is inserted in alphabetical position + that opens DeepL. Languages DeepL does not support fall back to + DeepL's auto-detect. +*/ +(function () { + 'use strict'; + + var SHOW_DELAY = 450; + + var popup = null; + var showTimer = null; + var picker = null; + var pickerColor = 'amber'; + + /* ------------------------------------------------------------------ + Documentation providers keyed by Prism language identifier. + Label: short button text. url: base search URL (query appended). + ------------------------------------------------------------------ */ + + /* DeepL-supported source language codes (primary subtags, lowercase). + Selections whose resolved lang is not in this set are sent with + source='auto' so DeepL can attempt its own detection. */ + var DEEPL_SOURCES = { + ar: 1, bg: 1, cs: 1, da: 1, de: 1, el: 1, en: 1, es: 1, et: 1, + fi: 1, fr: 1, he: 1, hu: 1, id: 1, it: 1, ja: 1, ko: 1, lt: 1, + lv: 1, nb: 1, nl: 1, no: 1, pl: 1, pt: 1, ro: 1, ru: 1, sk: 1, + sl: 1, sv: 1, th: 1, tr: 1, uk: 1, vi: 1, zh: 1, + }; + + var DOC_PROVIDERS = { + 'javascript': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' }, + 'typescript': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' }, + 'jsx': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' }, + 'tsx': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' }, + 'html': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' }, + 'css': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' }, + 'haskell': { label: 'Hoogle', url: 'https://hoogle.haskell.org/?hoogle=' }, + 'python': { label: 'Docs', url: 'https://docs.python.org/3/search.html?q=' }, + 'rust': { label: 'Docs', url: 'https://doc.rust-lang.org/std/?search=' }, + 'c': { label: 'Docs', url: 'https://en.cppreference.com/mwiki/index.php?search=' }, + 'cpp': { label: 'Docs', url: 'https://en.cppreference.com/mwiki/index.php?search=' }, + 'java': { label: 'Docs', url: 'https://docs.oracle.com/en/java/javase/21/docs/api/search.html?q=' }, + 'go': { label: 'Docs', url: 'https://pkg.go.dev/search?q=' }, + 'ruby': { label: 'Docs', url: 'https://ruby-doc.org/core/search?q=' }, + 'r': { label: 'Docs', url: 'https://www.rdocumentation.org/search?q=' }, + 'lua': { label: 'Docs', url: 'https://www.lua.org/search.html?q=' }, + 'scala': { label: 'Docs', url: 'https://docs.scala-lang.org/search/?q=' }, + }; + + /* ------------------------------------------------------------------ + Init + ------------------------------------------------------------------ */ + + function init() { + popup = document.createElement('div'); + popup.className = 'selection-popup'; + popup.setAttribute('role', 'toolbar'); + popup.setAttribute('aria-label', 'Text selection options'); + document.body.appendChild(popup); + + document.addEventListener('mouseup', onMouseUp); + document.addEventListener('keyup', onKeyUp); + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('keydown', onKeyDown); + window.addEventListener('scroll', function () { hide(); hidePicker(); }, { passive: true }); + } + + /* ------------------------------------------------------------------ + Event handlers + ------------------------------------------------------------------ */ + + function onMouseUp(e) { + if (popup.contains(e.target)) return; + clearTimeout(showTimer); + showTimer = setTimeout(tryShow, SHOW_DELAY); + } + + function onKeyUp(e) { + /* Typing capitals in the annotation picker's note input (or any + other editable field) releases Shift — don't re-summon the + toolbar over the UI the user is typing into. */ + var t = e.target; + if (t && t.nodeType === Node.ELEMENT_NODE) { + if (popup.contains(t)) return; + if (picker && picker.contains(t)) return; + if (t.isContentEditable || t.closest('input, textarea')) return; + } + if (e.shiftKey || e.key === 'End' || e.key === 'Home') { + clearTimeout(showTimer); + showTimer = setTimeout(tryShow, SHOW_DELAY); + } + } + + function onMouseDown(e) { + if (popup.contains(e.target)) return; + if (picker && picker.classList.contains('is-visible') && picker.contains(e.target)) return; + hide(); + hidePicker(); + } + + function onKeyDown(e) { + if (e.key === 'Escape') { hide(); hidePicker(); } + } + + /* ------------------------------------------------------------------ + Context detection + ------------------------------------------------------------------ */ + + function getContext(sel) { + if (!sel.rangeCount) return 'prose'; + var range = sel.getRangeAt(0); + var node = range.commonAncestorContainer; + var el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node; + if (!el) return 'prose'; + + if (el.closest('pre, code, .sourceCode, .highlight')) return 'code'; + if (el.closest('.math, .katex, .katex-html, .katex-display')) return 'math'; + + /* Fallback: commonAncestorContainer can land at <p> when selection + starts/ends just outside a KaTeX span — check via intersectsNode. */ + var mathEls = document.querySelectorAll('.math'); + for (var i = 0; i < mathEls.length; i++) { + if (range.intersectsNode(mathEls[i])) return 'math'; + } + + return 'prose'; + } + + /* Returns the primary subtag of the nearest [lang] ancestor of the + selection (e.g. "es-MX" → "es"), or null when that lang matches the + document root — in which case the text is in the page's default + language and no Translate button is warranted. */ + function getSelectionLang(sel) { + if (!sel.rangeCount) return null; + var node = sel.getRangeAt(0).commonAncestorContainer; + var el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node; + if (!el) return null; + + var langEl = el.closest('[lang]'); + if (!langEl) return null; + + var lang = (langEl.getAttribute('lang') || '').toLowerCase().split('-')[0]; + if (!lang) return null; + + var rootLang = (document.documentElement.getAttribute('lang') || 'en') + .toLowerCase().split('-')[0]; + if (lang === rootLang) return null; + + return lang; + } + + /* Returns the Prism language identifier for the code block containing + the current selection, or null if the language is not annotated. */ + function getCodeLanguage(sel) { + if (!sel.rangeCount) return null; + var node = sel.getRangeAt(0).commonAncestorContainer; + var el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node; + if (!el) return null; + /* Prism puts language-* on the <code> element; our Code.hs filter + ensures the class is always present when a language is specified. */ + var code = el.closest('code[class*="language-"]'); + if (!code) return null; + var m = code.className.match(/language-(\w+)/); + return m ? m[1].toLowerCase() : null; + } + + /* ------------------------------------------------------------------ + Core logic + ------------------------------------------------------------------ */ + + function tryShow() { + var sel = window.getSelection(); + var text = sel ? sel.toString().trim() : ''; + + if (!text || text.length < 2 || !sel.rangeCount) { hide(); return; } + + var range = sel.getRangeAt(0); + var rect = range.getBoundingClientRect(); + if (!rect.width && !rect.height) { hide(); return; } + var context = getContext(sel); + var oneWord = isSingleWord(text); + var codeLang = (context === 'code') ? getCodeLanguage(sel) : null; + var selLang = (context === 'prose') ? getSelectionLang(sel) : null; + + popup.innerHTML = buildHTML(context, oneWord, codeLang, selLang); + popup.style.visibility = 'hidden'; + popup.classList.add('is-visible'); + + position(rect); + popup.style.visibility = ''; + bindActions(text, rect); + } + + function hide() { + clearTimeout(showTimer); + if (popup) popup.classList.remove('is-visible'); + } + + /* ------------------------------------------------------------------ + Positioning — centred above selection, flip below if needed + ------------------------------------------------------------------ */ + + function position(rect) { + var pw = popup.offsetWidth; + var ph = popup.offsetHeight; + var GAP = 10; + var sy = window.scrollY; + var sx = window.scrollX; + var vw = window.innerWidth; + + var left = rect.left + sx + rect.width / 2 - pw / 2; + left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP)); + + var top = rect.top + sy - ph - GAP; + if (top < sy + GAP) { + top = rect.bottom + sy + GAP; + popup.classList.add('is-below'); + } else { + popup.classList.remove('is-below'); + } + + popup.style.left = left + 'px'; + popup.style.top = top + 'px'; + } + + /* ------------------------------------------------------------------ + Helpers + ------------------------------------------------------------------ */ + + function isSingleWord(text) { + return !/\s/.test(text); + } + + /* ------------------------------------------------------------------ + HTML builder — context-aware button sets + ------------------------------------------------------------------ */ + + function buildHTML(context, oneWord, codeLang, selLang) { + if (context === 'code') { + var provider = codeLang ? DOC_PROVIDERS[codeLang] : null; + return btn('copy', 'Copy') + + (provider ? docsBtn(provider) : ''); + } + + if (context === 'math') { + /* Alphabetical: Copy · nLab · OEIS · Wolfram */ + return btn('copy', 'Copy') + + btn('nlab', 'nLab') + + btn('oeis', 'OEIS') + + btn('wolfram', 'Wolfram'); + } + + /* Prose: Annotate · BibTeX · Copy · [Define] · DuckDuckGo · Here · [Translate] · Wikipedia */ + return btn('annotate', 'Annotate') + + btn('cite', 'BibTeX') + + btn('copy', 'Copy') + + (oneWord ? btn('define', 'Define') : '') + + btn('search', 'DuckDuckGo') + + btn('here', 'Here') + + (selLang ? translateBtn(selLang) : '') + + btn('wikipedia', 'Wikipedia'); + } + + function btn(action, label, placeholder) { + var cls = 'selection-popup-btn' + (placeholder ? ' selection-popup-btn--placeholder' : ''); + var extra = placeholder ? ' aria-disabled="true" title="Coming soon"' : ''; + return '<button class="' + cls + '" data-action="' + action + '"' + extra + '>' + + label + '</button>'; + } + + + /* Docs button embeds the base URL so dispatch can read it without a lookup. */ + function docsBtn(provider) { + return '<button class="selection-popup-btn" data-action="docs"' + + ' data-docs-url="' + provider.url + '">' + + provider.label + '</button>'; + } + + /* Translate button carries the resolved source lang so dispatch can pass + it to DeepL (or fall through to auto-detect). */ + function translateBtn(lang) { + return '<button class="selection-popup-btn" data-action="translate"' + + ' data-lang="' + lang + '">Translate</button>'; + } + + + /* ------------------------------------------------------------------ + Action bindings + ------------------------------------------------------------------ */ + + function bindActions(text, rect) { + popup.querySelectorAll('[data-action]').forEach(function (el) { + if (el.getAttribute('aria-disabled') === 'true') return; + el.addEventListener('click', function () { + dispatch(el.getAttribute('data-action'), text, el, rect); + hide(); + }); + }); + } + + /* ------------------------------------------------------------------ + BibTeX/BibLaTeX builder for the Cite action + ------------------------------------------------------------------ */ + + /* Escape LaTeX special characters in a BibTeX field value. */ + function escBib(s) { + return String(s) + .replace(/\\/g, '\\textbackslash{}') + .replace(/[#$%&_{}]/g, function (c) { return '\\' + c; }) + .replace(/~/g, '\\textasciitilde{}') + .replace(/\^/g, '\\textasciicircum{}'); + } + + /* "Levi Neuwirth" → "Neuwirth, Levi" */ + function toBibAuthor(name) { + var parts = name.trim().split(/\s+/); + if (parts.length < 2) return name; + return parts[parts.length - 1] + ', ' + parts.slice(0, -1).join(' '); + } + + function buildBibTeX(selectedText) { + /* Title — h1.page-title is most reliable; fall back to document.title */ + var titleEl = document.querySelector('h1.page-title'); + var title = titleEl + ? titleEl.textContent.trim() + : document.title.split(' \u2014 ')[0].trim(); + + /* Author(s) — read from .meta-authors, default to site owner */ + var authorEls = document.querySelectorAll('.meta-authors a'); + var authors = authorEls.length + ? Array.from(authorEls).map(function (a) { + return toBibAuthor(a.textContent.trim()); + }).join(' and ') + : 'Neuwirth, Levi'; + + /* Year — scrape from the first version-history entry ("14 March 2026 · Created"), + fall back to current year. */ + var year = String(new Date().getFullYear()); + var vhEl = document.querySelector('#version-history li'); + if (vhEl) { + var ym = vhEl.textContent.match(/\b(\d{4})\b/); + if (ym) year = ym[1]; + } + + /* Access date */ + var now = new Date(); + var urldate = now.getFullYear() + '-' + + String(now.getMonth() + 1).padStart(2, '0') + '-' + + String(now.getDate()).padStart(2, '0'); + + /* Citation key: lastname + year + first_content_word_of_title */ + var lastName = authors.split(',')[0].toLowerCase().replace(/[^a-z]/g, ''); + var stopwords = /^(the|and|for|with|from|that|this|into|about|over)$/i; + var keyWord = title.split(/\s+/).filter(function (w) { + return w.length > 2 && !stopwords.test(w); + })[0] || 'untitled'; + keyWord = keyWord.toLowerCase().replace(/[^a-z0-9]/g, ''); + var key = lastName + year + keyWord; + + return [ + '@online{' + key + ',', + ' author = {' + escBib(authors) + '},', + ' title = {' + escBib(title) + '},', + ' year = {' + year + '},', + ' url = {' + window.location.href + '},', + ' urldate = {' + urldate + '},', + ' note = {\\enquote{' + escBib(selectedText) + '}},', + '}', + ].join('\n'); + } + + /* ------------------------------------------------------------------ + Action dispatch + ------------------------------------------------------------------ */ + + function dispatch(action, text, el, rect) { + var q = encodeURIComponent(text); + if (action === 'search') { + window.open('https://duckduckgo.com/?q=' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'copy') { + if (navigator.clipboard) navigator.clipboard.writeText(text).catch(function () {}); + + } else if (action === 'docs') { + var base = el.getAttribute('data-docs-url'); + if (base) window.open(base + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'wolfram') { + window.open('https://www.wolframalpha.com/input?i=' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'oeis') { + window.open('https://oeis.org/search?q=' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'nlab') { + window.open('https://ncatlab.org/nlab/search?query=' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'wikipedia') { + /* Always use Special:Search — never jumps to an article directly, + so phrases and ambiguous terms always show the results page. */ + window.open('https://en.wikipedia.org/wiki/Special:Search?search=' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'define') { + /* English Wiktionary — only rendered for single-word selections. */ + window.open('https://en.wiktionary.org/wiki/' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'cite') { + var citation = buildBibTeX(text); + if (navigator.clipboard) navigator.clipboard.writeText(citation).catch(function () {}); + + } else if (action === 'here') { + /* Site search via Pagefind — opens search page with query pre-filled. */ + window.open('/search.html?q=' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'translate') { + /* DeepL web translator. Hash-routed source/target/text triple; + 'auto' is used when the resolved lang is not one DeepL supports. */ + var src = el.getAttribute('data-lang') || ''; + var srcCode = DEEPL_SOURCES[src] ? src : 'auto'; + window.open('https://www.deepl.com/translator#' + srcCode + '/en/' + q, + '_blank', 'noopener,noreferrer'); + + } else if (action === 'annotate') { + showAnnotatePicker(text, rect); + } + } + + /* ------------------------------------------------------------------ + Annotate picker — color swatches + optional note input + ------------------------------------------------------------------ */ + + function showAnnotatePicker(text, selRect) { + if (!picker) { + picker = document.createElement('div'); + picker.className = 'ann-picker'; + picker.setAttribute('role', 'dialog'); + picker.setAttribute('aria-label', 'Annotate selection'); + document.body.appendChild(picker); + } + + pickerColor = 'amber'; + picker.innerHTML = + '<div class="ann-picker-swatches">' + + swatchBtn('amber') + swatchBtn('sage') + swatchBtn('steel') + swatchBtn('rose') + + '</div>' + + '<input class="ann-picker-note" type="text" placeholder="Note (optional)" maxlength="200">' + + '<div class="ann-picker-actions">' + + '<button class="ann-picker-submit">Highlight</button>' + + '</div>'; + + picker.style.visibility = 'hidden'; + picker.classList.add('is-visible'); + positionPicker(selRect); + picker.style.visibility = ''; + + var note = picker.querySelector('.ann-picker-note'); + var submitBtn = picker.querySelector('.ann-picker-submit'); + + picker.querySelector('.ann-picker-swatch[data-color="amber"]').classList.add('is-selected'); + + picker.querySelectorAll('.ann-picker-swatch').forEach(function (sw) { + sw.addEventListener('click', function () { + picker.querySelectorAll('.ann-picker-swatch').forEach(function (s) { + s.classList.remove('is-selected'); + }); + sw.classList.add('is-selected'); + pickerColor = sw.getAttribute('data-color'); + }); + }); + + function commit() { + if (window.Annotations) { + window.Annotations.add(text, pickerColor, note.value.trim()); + } + hidePicker(); + } + + submitBtn.addEventListener('click', commit); + note.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') { hidePicker(); } + }); + + setTimeout(function () { note.focus(); }, 0); + } + + function hidePicker() { + if (picker) picker.classList.remove('is-visible'); + } + + function positionPicker(rect) { + var pw = picker.offsetWidth; + var ph = picker.offsetHeight; + var GAP = 8; + var sy = window.scrollY; + var sx = window.scrollX; + var vw = window.innerWidth; + + var left = rect.left + sx + rect.width / 2 - pw / 2; + left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP)); + + var top = rect.top + sy - ph - GAP; + if (top < sy + GAP) top = rect.bottom + sy + GAP; + + picker.style.left = left + 'px'; + picker.style.top = top + 'px'; + } + + function swatchBtn(color) { + return '<button class="ann-picker-swatch ann-picker-swatch--' + color + + '" data-color="' + color + '" aria-label="' + color + '"></button>'; + } + + document.addEventListener('DOMContentLoaded', init); +}()); diff --git a/static/js/semantic-search.js b/static/js/semantic-search.js new file mode 100644 index 0000000..d82f318 --- /dev/null +++ b/static/js/semantic-search.js @@ -0,0 +1,265 @@ +/* semantic-search.js — Client-side semantic search using paragraph embeddings. + * + * At build time, tools/embed.py produces: + * /data/semantic-index.bin raw Float32Array (N_paragraphs × 384 dims) + * /data/semantic-meta.json [{url, title, heading, excerpt}, ...] + * + * At query time, transformers.js embeds the user's query with all-MiniLM-L6-v2 + * (same model used at build time) and ranks paragraphs by cosine similarity. + * All computation is client-side; no server required. + * + * Model: Xenova/all-MiniLM-L6-v2 (~22 MB quantized, cached by browser after first load) + * Model files served from /models/all-MiniLM-L6-v2/ (same-origin; run tools/download-model.sh) + * Index format: raw little-endian Float32, shape [N, 384], unit-normalized + * + * CSP: requires cdn.jsdelivr.net in script-src (transformers.js library). + * connect-src stays 'self' — model weights are served same-origin. + */ +(function () { + 'use strict'; + + var MODEL = 'all-MiniLM-L6-v2'; /* local name, no Xenova/ prefix */ + var MODEL_PATH = '/models/'; /* served same-origin */ + var DIM = 384; + var TOP_K = 8; + var CDN = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2'; + + var extractor = null; /* loaded lazily on first search */ + var vectors = null; /* Float32Array, shape [N, DIM] */ + var meta = null; /* [{url, title, heading, excerpt}] */ + var indexReady = false; + + var queryEl = document.getElementById('semantic-query'); + var statusEl = document.getElementById('semantic-status'); + var resultsEl = document.getElementById('semantic-results'); + + if (!queryEl) return; /* not on the search page */ + + /* ------------------------------------------------------------------ + Index loading — fetch once, lazily + ------------------------------------------------------------------ */ + + /* In-flight promise so concurrent first searches share a single + index fetch (mirrors loadModelPromise below). Without this guard, + two rapid keystrokes would each fetch semantic-index.bin and + semantic-meta.json before the first resolves. */ + var loadIndexPromise = null; + + function loadIndex() { + if (indexReady) return Promise.resolve(); + if (loadIndexPromise) return loadIndexPromise; + + loadIndexPromise = Promise.all([ + fetch('/data/semantic-index.bin').then(function (r) { + if (!r.ok) throw new Error('semantic-index.bin not found'); + return r.arrayBuffer(); + }), + fetch('/data/semantic-meta.json').then(function (r) { + if (!r.ok) throw new Error('semantic-meta.json not found'); + return r.json(); + }), + ]).then(function (results) { + vectors = new Float32Array(results[0]); + meta = results[1]; + /* Consistency check: a stale CDN-cached bin/json pair would + otherwise produce NaN scores and silently garbage ranking. */ + if (vectors.length !== meta.length * DIM) { + console.error('semantic-search: index/meta size mismatch (' + + vectors.length + ' floats vs ' + meta.length + ' × ' + DIM + ')'); + vectors = null; + meta = null; + throw new Error('semantic index not available: index/meta size mismatch'); + } + indexReady = true; + }).catch(function (err) { + /* Allow a retry on the next call instead of caching the + failed promise forever. */ + loadIndexPromise = null; + throw err; + }); + return loadIndexPromise; + } + + /* ------------------------------------------------------------------ + Model loading — dynamic import from CDN, lazy + ------------------------------------------------------------------ */ + + /* In-flight promise so concurrent searches share a single model + load. Without this guard, two rapid keystrokes would each call + `import(CDN)` and `pipeline(...)`, wasting CPU and memory before + the second resolves. */ + var loadModelPromise = null; + + function loadModel() { + if (extractor) return Promise.resolve(extractor); + if (loadModelPromise) return loadModelPromise; + setStatus('Loading model…'); + loadModelPromise = import(CDN).then(function (mod) { + /* Point transformers.js at our self-hosted model files. */ + mod.env.localModelPath = MODEL_PATH; + mod.env.allowRemoteModels = false; + return mod.pipeline('feature-extraction', MODEL, { quantized: true }); + }).then(function (pipe) { + extractor = pipe; + return extractor; + }).catch(function (err) { + /* Allow a retry on the next call instead of caching the + failed promise forever. */ + loadModelPromise = null; + throw err; + }); + return loadModelPromise; + } + + /* ------------------------------------------------------------------ + Search + ------------------------------------------------------------------ */ + + function cosineSims(queryVec) { + /* queryVec is already unit-normalized; dot product = cosine similarity */ + var N = meta.length; + var scores = new Float32Array(N); + for (var i = 0; i < N; i++) { + var dot = 0; + var off = i * DIM; + for (var d = 0; d < DIM; d++) dot += queryVec[d] * vectors[off + d]; + scores[i] = dot; + } + return scores; + } + + function topK(scores) { + var indices = Array.from({ length: meta.length }, function (_, i) { return i; }); + indices.sort(function (a, b) { return scores[b] - scores[a]; }); + return indices.slice(0, TOP_K).map(function (i) { + return { idx: i, score: scores[i] }; + }); + } + + /* Generation token: each runSearch call invalidates all still-in-flight + predecessors, so a stale (earlier) query's results can never render + after a newer query's. */ + var searchGeneration = 0; + + function runSearch(query) { + var gen = ++searchGeneration; + + query = query.trim(); + if (!query) { clearResults(); return; } + + setStatus('Searching…'); + + var indexPromise = loadIndex().catch(function (err) { + if (gen === searchGeneration) { + setStatus('Semantic index not available — run make build first.'); + } + throw err; + }); + var modelPromise = loadModel(); + + Promise.all([indexPromise, modelPromise]).then(function (results) { + var pipe = results[1]; + return pipe(query, { pooling: 'mean', normalize: true }); + }).then(function (output) { + if (gen !== searchGeneration) return; /* superseded by a newer query */ + var queryVec = output.data; /* Float32Array, length 384 */ + var scores = cosineSims(queryVec); + var hits = topK(scores); + renderResults(hits); + setStatus(hits.length ? '' : 'No results found.'); + }).catch(function (err) { + if (gen !== searchGeneration) return; /* superseded by a newer query */ + if (err.message && err.message.indexOf('not available') === -1) { + setStatus('Search error — see console for details.'); + console.error('semantic-search:', err); + } + }); + } + + /* ------------------------------------------------------------------ + Rendering + ------------------------------------------------------------------ */ + + function renderResults(hits) { + if (!hits.length) { clearResults(); return; } + + var html = '<ol class="semantic-results-list">'; + for (var i = 0; i < hits.length; i++) { + var h = hits[i]; + var m = meta[h.idx]; + var sameHeading = m.heading === m.title; + html += '<li class="semantic-result">' + + '<a class="semantic-result-title" href="' + esc(m.url) + '">' + + esc(m.title) + '</a>'; + if (!sameHeading) { + html += '<span class="semantic-result-heading"> § ' + esc(m.heading) + '</span>'; + } + html += '<p class="semantic-result-excerpt">' + esc(m.excerpt) + '</p>' + + '</li>'; + } + html += '</ol>'; + resultsEl.innerHTML = html; + } + + function clearResults() { + resultsEl.innerHTML = ''; + } + + function setStatus(msg) { + statusEl.textContent = msg; + } + + /* Defer to the shared utility (loaded synchronously from + templates/partials/head.html) so this file cannot drift from + popups.js, annotations.js, or build/Utils.hs. */ + function esc(s) { + return window.lnUtils.escapeHtml(s); + } + + /* ------------------------------------------------------------------ + Tab switching — persists choice in localStorage + ------------------------------------------------------------------ */ + + var STORAGE_KEY = 'search-tab'; + + function activateTab(target) { + document.querySelectorAll('.search-tab').forEach(function (b) { + var active = b.dataset.tab === target; + b.classList.toggle('is-active', active); + b.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + document.querySelectorAll('.search-panel').forEach(function (p) { + p.classList.toggle('is-active', p.dataset.panel === target); + }); + try { localStorage.setItem(STORAGE_KEY, target); } catch (e) {} + } + + document.querySelectorAll('.search-tab').forEach(function (btn) { + btn.addEventListener('click', function () { activateTab(btn.dataset.tab); }); + }); + + /* Restore last-used tab (falls back to keyword if unset or unrecognised) */ + var saved = null; + try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {} + if (saved === 'semantic') activateTab('semantic'); + + /* ------------------------------------------------------------------ + Input handling — debounced, 400 ms + ------------------------------------------------------------------ */ + + var debounceTimer = null; + queryEl.addEventListener('input', function () { + clearTimeout(debounceTimer); + var q = queryEl.value.trim(); + if (!q) { clearResults(); setStatus(''); return; } + debounceTimer = setTimeout(function () { runSearch(q); }, 400); + }); + + /* Pre-fill from ?q= on load — mirror keyword search behaviour */ + var params = new URLSearchParams(window.location.search); + var initial = params.get('q'); + if (initial) { + queryEl.value = initial; + runSearch(initial); + } +}()); diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..f7e9db2 --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,174 @@ +/* settings.js — Settings panel: theme, text size, print. + Must stay in sync with TEXT_SIZES in theme.js. + + All localStorage access routes through window.lnUtils.safeStorage so + Safari private-mode SecurityErrors on writes do not throw uncaught. */ +(function () { + 'use strict'; + + var TEXT_SIZES = [20, 23, 26]; + var TEXT_SIZE_DEFAULT = 1; /* index of 23px */ + var TEXT_SIZE_KEY = 'text-size'; + var store = (window.lnUtils && window.lnUtils.safeStorage) || { + get: function () { return null; }, + set: function () { return false; }, + remove: function () { return false; } + }; + + /* ------------------------------------------------------------------ + Init + ------------------------------------------------------------------ */ + + function init() { + var toggle = document.querySelector('.settings-toggle'); + var panel = document.querySelector('.settings-panel'); + if (!toggle || !panel) return; + + syncThemeButtons(); + syncTextSizeButtons(); + syncToggleButton('focus-mode', 'data-focus-mode'); + syncToggleButton('reduce-motion', 'data-reduce-motion'); + + toggle.addEventListener('click', function (e) { + e.stopPropagation(); + setOpen(toggle.getAttribute('aria-expanded') !== 'true'); + }); + + document.addEventListener('click', function (e) { + if (!panel.contains(e.target) && e.target !== toggle) setOpen(false); + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { setOpen(false); return; } + if (e.key !== 'Tab' || !panel.classList.contains('is-open')) return; + var focusable = Array.from(panel.querySelectorAll( + 'button:not([disabled]), [tabindex]:not([tabindex="-1"])' + )); + if (focusable.length === 0) return; + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }); + + panel.querySelectorAll('[data-action]').forEach(function (btn) { + btn.addEventListener('click', function () { + handle(btn.getAttribute('data-action')); + }); + }); + } + + /* ------------------------------------------------------------------ + Panel open / close + ------------------------------------------------------------------ */ + + function setOpen(open) { + var toggle = document.querySelector('.settings-toggle'); + var panel = document.querySelector('.settings-panel'); + var wasOpen = panel.classList.contains('is-open'); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + panel.setAttribute('aria-hidden', open ? 'false' : 'true'); + panel.classList.toggle('is-open', open); + if (open) { + var first = panel.querySelector('button:not([disabled]), [tabindex]:not([tabindex="-1"])'); + if (first) first.focus(); + } else if (wasOpen && (panel.contains(document.activeElement) || document.activeElement === toggle)) { + /* Only return focus to toggle when the panel was open and focus + was inside the settings area. Clicking outside the panel to + dismiss it should not steal focus from wherever the user clicked. */ + toggle.focus(); + } + } + + /* ------------------------------------------------------------------ + Actions + ------------------------------------------------------------------ */ + + function handle(action) { + if (action === 'theme-light') setTheme('light'); + else if (action === 'theme-dark') setTheme('dark'); + else if (action === 'theme-cappuccino') setTheme('cappuccino'); + else if (action === 'text-smaller') shiftSize(-1); + else if (action === 'text-larger') shiftSize(+1); + else if (action === 'focus-mode') toggleDataAttr('focus-mode', 'data-focus-mode'); + else if (action === 'reduce-motion') toggleDataAttr('reduce-motion', 'data-reduce-motion'); + else if (action === 'print') { setOpen(false); window.print(); } + else if (action === 'clear-annotations') { clearAnnotations(); } + } + + function clearAnnotations() { + if (!confirm('Remove all highlights and annotations across every page?')) return; + if (window.Annotations) window.Annotations.clearAll(); + setOpen(false); + } + + /* Theme ----------------------------------------------------------- */ + + function setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + store.set('theme', theme); + syncThemeButtons(); + } + + function currentTheme() { + var attr = document.documentElement.getAttribute('data-theme'); + if (attr) return attr; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + function syncThemeButtons() { + var active = currentTheme(); + document.querySelectorAll('[data-action^="theme-"]').forEach(function (btn) { + btn.classList.toggle('is-active', btn.getAttribute('data-action') === 'theme-' + active); + }); + } + + /* Text size ------------------------------------------------------- */ + + function getSizeIndex() { + var n = parseInt(store.get(TEXT_SIZE_KEY), 10); + return (isNaN(n) || n < 0 || n >= TEXT_SIZES.length) ? TEXT_SIZE_DEFAULT : n; + } + + function shiftSize(delta) { + var idx = Math.max(0, Math.min(TEXT_SIZES.length - 1, getSizeIndex() + delta)); + store.set(TEXT_SIZE_KEY, idx); + document.documentElement.style.setProperty('--text-size', TEXT_SIZES[idx] + 'px'); + syncTextSizeButtons(); + } + + /* Boolean toggles (focus-mode, reduce-motion) -------------------- */ + + function toggleDataAttr(storageKey, attrName) { + var html = document.documentElement; + var on = html.hasAttribute(attrName); + if (on) { + html.removeAttribute(attrName); + store.remove(storageKey); + } else { + html.setAttribute(attrName, ''); + store.set(storageKey, '1'); + } + syncToggleButton(storageKey, attrName); + } + + function syncToggleButton(storageKey, attrName) { + var btn = document.querySelector('[data-action="' + storageKey + '"]'); + if (btn) btn.classList.toggle('is-active', document.documentElement.hasAttribute(attrName)); + } + + function syncTextSizeButtons() { + var idx = getSizeIndex(); + var smaller = document.querySelector('[data-action="text-smaller"]'); + var larger = document.querySelector('[data-action="text-larger"]'); + if (smaller) smaller.disabled = (idx === 0); + if (larger) larger.disabled = (idx === TEXT_SIZES.length - 1); + } + + document.addEventListener('DOMContentLoaded', init); +}()); diff --git a/static/js/sidenotes.js b/static/js/sidenotes.js new file mode 100644 index 0000000..d2e52df --- /dev/null +++ b/static/js/sidenotes.js @@ -0,0 +1,237 @@ +/* sidenotes.js — Collision avoidance and hover linking for sidenotes. + * + * HTML structure produced by Filters/Sidenotes.hs: + * <sup class="sidenote-ref" id="snref-N"><a href="#sn-N">N</a></sup> + * <span class="sidenote" id="sn-N">…</span> + * + * #markdownBody must be position: relative (layout.css guarantees this). + * The .sidenote spans are position: absolute; left: calc(100% + 2.5rem). + * Without an explicit top they sit at their "hypothetical static position", + * which is fine for isolated notes but causes overlaps when notes are close. + * + * This script: + * 1. Anchors each sidenote's top to its reference's offsetTop so the + * alignment is explicit and stable across all browsers. + * 2. Walks notes top-to-bottom and pushes each one below the previous + * if they would overlap (with a small gap between them). + * 3. Wires bidirectional hover highlights between each ref↔sidenote pair. + */ + +(function () { + 'use strict'; + + const GAP = 12; /* minimum px gap between successive sidenotes */ + + function positionSidenotes() { + const body = document.getElementById('markdownBody'); + if (!body) return; + + const notes = Array.from(body.querySelectorAll('.sidenote')); + if (notes.length === 0) return; + + /* Only run on wide viewports where sidenotes are visible. */ + if (getComputedStyle(notes[0]).display === 'none') return; + + let prevBottom = 0; + + notes.forEach(function (sn) { + /* Reset any prior JS-applied top so offsetTop reads correctly. */ + sn.style.top = ''; + + const id = sn.id; /* "sn-N" */ + const refId = 'snref-' + id.slice(3); /* "snref-N" */ + const ref = document.getElementById(refId); + + /* Preferred top: align with the reference superscript. */ + const preferred = ref ? ref.offsetTop : sn.offsetTop; + const top = Math.max(preferred, prevBottom + GAP); + + sn.style.top = top + 'px'; + prevBottom = top + sn.offsetHeight; + }); + } + + /* ------------------------------------------------------------------ */ + /* Hover + click + keyboard wiring */ + /* ------------------------------------------------------------------ */ + + /* At most one sidenote is "focused" (click-sticky) at a time. */ + let focusedPair = null; + + function wireHover(ref, sn) { + /* Idempotent: skip pairs already wired so 'reinitSidenotes' + after a transclusion injection cannot stack listeners. */ + if (ref.dataset.snBound === '1') return; + ref.dataset.snBound = '1'; + sn.dataset.snBound = '1'; + + let hovering = false; + let focused = false; + + function update() { + const active = focused || hovering; + ref.classList.toggle('is-active', active); + sn.classList.toggle('is-active', active); + } + + function unfocus() { focused = false; update(); } + + function toggleFocus() { + /* Sticky focus only makes sense on wide viewports where the + sidenote is actually visible. On narrow screens there's + nothing to pin. */ + if (getComputedStyle(sn).display === 'none') return; + if (focused) { + focused = false; + focusedPair = null; + } else { + if (focusedPair) focusedPair.unfocus(); + focused = true; + focusedPair = { ref: ref, sn: sn, unfocus: unfocus }; + } + update(); + } + + ref.addEventListener('mouseenter', function () { hovering = true; update(); }); + ref.addEventListener('mouseleave', function () { hovering = false; update(); }); + sn.addEventListener('mouseenter', function () { hovering = true; update(); }); + sn.addEventListener('mouseleave', function () { hovering = false; update(); }); + + /* Click on the superscript link: sticky focus on wide viewports; + mobile popup on narrow viewports (sidenote hidden). */ + const link = ref.querySelector('a'); + if (link) { + link.addEventListener('click', function (e) { + e.preventDefault(); + if (getComputedStyle(sn).display === 'none') { + openMobilePopup(sn); + return; + } + toggleFocus(); + }); + + /* Keyboard activation: Enter follows the link by default (the + browser synthesizes a click), but Space does not. Both are + expected to toggle focus on a focus-activated element, so + we normalize: Enter/Space toggle, Escape clears if focused. + The <a href="#sn-N"> retains its native focusability so + Tab reaches it; we only intercept the key. */ + link.addEventListener('keydown', function (e) { + if (e.key === ' ') { + e.preventDefault(); + toggleFocus(); + } else if (e.key === 'Escape' && focused) { + e.preventDefault(); + focused = false; + focusedPair = null; + update(); + } + }); + } + } + + /* ------------------------------------------------------------------ */ + /* Mobile popup — bottom sheet for narrow viewports where .sidenote */ + /* is display:none. Created lazily on first use. */ + /* ------------------------------------------------------------------ */ + + var mobileOverlay = null; + + function ensureMobileOverlay() { + if (mobileOverlay) return; + + var overlay = document.createElement('div'); + overlay.className = 'sidenote-popup-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Note'); + + var sheet = document.createElement('div'); + sheet.className = 'sidenote-popup'; + sheet.setAttribute('tabindex', '-1'); + + var closeBtn = document.createElement('button'); + closeBtn.className = 'sidenote-popup-close'; + closeBtn.setAttribute('aria-label', 'Close note'); + closeBtn.textContent = '\u00d7'; /* × */ + + var body = document.createElement('div'); + body.className = 'sidenote-popup-body'; + + sheet.appendChild(closeBtn); + sheet.appendChild(body); + overlay.appendChild(sheet); + document.body.appendChild(overlay); + + overlay.addEventListener('click', function (e) { + if (!sheet.contains(e.target)) closeMobilePopup(); + }); + closeBtn.addEventListener('click', closeMobilePopup); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && mobileOverlay && + mobileOverlay.classList.contains('is-open')) { + closeMobilePopup(); + } + }); + + mobileOverlay = overlay; + } + + function openMobilePopup(sn) { + ensureMobileOverlay(); + mobileOverlay.querySelector('.sidenote-popup-body').innerHTML = sn.innerHTML; + mobileOverlay.classList.add('is-open'); + mobileOverlay.querySelector('.sidenote-popup').focus(); + } + + function closeMobilePopup() { + if (mobileOverlay) mobileOverlay.classList.remove('is-open'); + } + + /* Click anywhere outside a focused pair dismisses it. */ + document.addEventListener('click', function (e) { + if (focusedPair && + !focusedPair.ref.contains(e.target) && + !focusedPair.sn.contains(e.target)) { + focusedPair.unfocus(); + focusedPair = null; + } + }); + + /* Global Escape dismisses any sticky-focused sidenote, even if focus + has moved away from the ref link. */ + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && focusedPair) { + focusedPair.unfocus(); + focusedPair = null; + } + }); + + function wireAll(root) { + root.querySelectorAll('.sidenote').forEach(function (sn) { + const refId = 'snref-' + sn.id.slice(3); + const ref = document.getElementById(refId); + if (ref) wireHover(ref, sn); + }); + } + + function init() { + const body = document.getElementById('markdownBody'); + if (!body) return; + + wireAll(body); + positionSidenotes(); + } + + /* Public re-init hook used by transclude.js after it injects new + content. wireAll is idempotent so calling this multiple times is + safe. */ + window.reinitSidenotes = function (container) { + wireAll(container || document.getElementById('markdownBody') || document); + positionSidenotes(); + }; + + document.addEventListener('DOMContentLoaded', init); + window.addEventListener('resize', positionSidenotes); +}()); diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..161045b --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,36 @@ +/* theme.js — Restores theme and text size from localStorage before first paint. + Loaded synchronously (no defer/async) to prevent flash of wrong appearance. + DOM interaction (button wiring) is handled by settings.js (deferred). + + All storage access routes through window.lnUtils.safeStorage (from + utils.js, loaded immediately before this script) so Safari private-mode + SecurityErrors degrade to default appearance rather than blowing up + the synchronous bootstrap. +*/ +(function () { + var TEXT_SIZES = [20, 23, 26]; + var store = window.lnUtils && window.lnUtils.safeStorage; + function safeGet(key) { return store ? store.get(key) : null; } + + /* Theme */ + var storedTheme = safeGet('theme'); + if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'cappuccino') { + document.documentElement.setAttribute('data-theme', storedTheme); + } + + /* Text size */ + var storedSize = parseInt(safeGet('text-size'), 10); + if (!isNaN(storedSize) && storedSize >= 0 && storedSize < TEXT_SIZES.length) { + document.documentElement.style.setProperty('--text-size', TEXT_SIZES[storedSize] + 'px'); + } + + /* Focus mode */ + if (safeGet('focus-mode')) { + document.documentElement.setAttribute('data-focus-mode', ''); + } + + /* Reduce motion */ + if (safeGet('reduce-motion')) { + document.documentElement.setAttribute('data-reduce-motion', ''); + } +})(); diff --git a/static/js/toc.js b/static/js/toc.js new file mode 100644 index 0000000..217787e --- /dev/null +++ b/static/js/toc.js @@ -0,0 +1,129 @@ +/* toc.js — Sticky TOC with IntersectionObserver scroll tracking, + horizontal progress indicator, and expand/collapse. + Loaded with defer. +*/ +(function () { + document.addEventListener('DOMContentLoaded', function () { + const toc = document.getElementById('toc'); + if (!toc) return; + + const links = Array.from(toc.querySelectorAll('a[data-target]')); + if (!links.length) return; + + // Map: heading ID → TOC anchor element. + const linkMap = new Map(links.map(a => [a.dataset.target, a])); + + // All headings in the body that have a matching TOC entry. + const headings = Array.from( + document.querySelectorAll('#markdownBody :is(h1,h2,h3,h4,h5,h6)[id]') + ).filter(h => linkMap.has(h.id)); + + if (!headings.length) return; + + const label = toc.querySelector('.toc-active-label'); + const toggleBtn = toc.querySelector('.toc-toggle'); + const mobileBar = document.getElementById('toc-mobile-bar'); + const mobileLabel = mobileBar && mobileBar.querySelector('.toc-mobile-label'); + + const pageTitleEl = document.querySelector('#markdownBody .page-title'); + const pageTitle = pageTitleEl ? pageTitleEl.textContent.trim() : 'Contents'; + + function activateTitle() { + links.forEach(a => a.classList.remove('is-active')); + if (label) label.textContent = pageTitle; + if (mobileLabel) mobileLabel.textContent = pageTitle; + } + + function activate(id) { + links.forEach(a => a.classList.toggle('is-active', a.dataset.target === id)); + const activeLink = linkMap.get(id); + const text = activeLink ? activeLink.textContent : pageTitle; + if (label) label.textContent = text; + if (mobileLabel) mobileLabel.textContent = text; + } + + // Collapse / expand. The collapsed state is hidden from + // assistive technology (aria-hidden="true") and removed from + // the keyboard tab order (tabindex=-1 on each link), so users + // navigating with a screen reader or only the keyboard cannot + // land on links inside a collapsed TOC. + const tocNav = toc.querySelector('.toc-nav'); + function setExpanded(open) { + if (!toggleBtn) return; + toc.classList.toggle('is-collapsed', !open); + toggleBtn.setAttribute('aria-expanded', String(open)); + if (tocNav) { + tocNav.setAttribute('aria-hidden', String(!open)); + } + links.forEach(function (a) { + if (open) { + a.removeAttribute('tabindex'); + } else { + a.setAttribute('tabindex', '-1'); + } + }); + } + + setExpanded(true); + + if (toggleBtn) { + toggleBtn.addEventListener('click', function () { + setExpanded(toc.classList.contains('is-collapsed')); + }); + } + + + // Progress indicator — drives the horizontal bar under .toc-header + // and the mobile bar's progress line. + function updateProgress() { + const scrollable = document.documentElement.scrollHeight - window.innerHeight; + const progress = scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0; + toc.style.setProperty('--toc-progress', progress); + if (mobileBar) mobileBar.style.setProperty('--toc-progress', progress); + } + window.addEventListener('scroll', updateProgress, { passive: true }); + updateProgress(); + + // The set of headings currently intersecting the trigger band. + const visible = new Set(); + + const observer = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (e.isIntersecting) visible.add(e.target); + else visible.delete(e.target); + }); + + if (visible.size > 0) { + // Activate the topmost visible heading in document order. + const top = headings.find(h => visible.has(h)); + if (top) { activate(top.id); } + } else { + // Nothing in the trigger band: activate the last heading + // whose top edge is above the sticky nav bar. + const navHeight = (document.querySelector('header') || {}).offsetHeight || 0; + let candidate = null; + for (const h of headings) { + if (h.getBoundingClientRect().top < navHeight + 16) candidate = h; + else break; + } + if (candidate) { activate(candidate.id); } + else activateTitle(); + } + }, { + // Trigger band: a strip from 10% to 15% down from the top of the + // viewport. Headings become active when they enter this band. + rootMargin: '-10% 0px -85% 0px', + threshold: 0, + }); + + headings.forEach(h => observer.observe(h)); + + // Set initial active state based on URL hash, else first heading. + const hash = window.location.hash.slice(1); + if (hash && linkMap.has(hash)) { + activate(hash); + } else { + activateTitle(); + } + }); +})(); diff --git a/static/js/transclude.js b/static/js/transclude.js new file mode 100644 index 0000000..6895dc7 --- /dev/null +++ b/static/js/transclude.js @@ -0,0 +1,177 @@ +/* transclude.js — Client-side lazy transclusion. + * + * Authored in Markdown as a standalone line: + * {{slug}} — embed full body of /slug.html + * {{slug#section-id}} — embed one section by heading id + * {{path/to/page}} — sub-path pages work the same way + * + * The Haskell preprocessor (Filters.Transclusion) converts these at build + * time to placeholder divs: + * <div class="transclude" data-src="/slug.html" + * data-section="section-id"></div> + * + * This script finds those divs, fetches the target page, extracts the + * requested content, rewrites cross-page fragment hrefs, injects the + * content inline, and retriggers layout-dependent JS (sidenotes, collapse). + */ + +(function () { + 'use strict'; + + /* Shared fetch cache — one network request per URL regardless of how + * many transclusions reference the same page. */ + var cache = {}; + + function fetchPage(url) { + if (!cache[url]) { + cache[url] = fetch(url).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.text(); + }); + } + return cache[url]; + } + + function parseDoc(html) { + return new DOMParser().parseFromString(html, 'text/html'); + } + + /* Extract a named section: the heading element with id=sectionId plus + * all following siblings until the next heading at the same or higher + * level (lower number), or end of parent. */ + function extractSection(doc, sectionId) { + var anchor = doc.getElementById(sectionId); + if (!anchor) return null; + + var level = parseInt(anchor.tagName[1], 10); + if (!level) return null; + + var nodes = [anchor.cloneNode(true)]; + var el = anchor.nextElementSibling; + + while (el) { + if (/^H[1-6]$/.test(el.tagName) && + parseInt(el.tagName[1], 10) <= level) break; + nodes.push(el.cloneNode(true)); + el = el.nextElementSibling; + } + + return nodes.length ? nodes : null; + } + + /* Extract the full contents of #markdownBody. */ + function extractBody(doc) { + var body = doc.getElementById('markdownBody'); + if (!body) return null; + var nodes = Array.from(body.children).map(function (el) { + return el.cloneNode(true); + }); + return nodes.length ? nodes : null; + } + + /* Rewrite href="#fragment" → href="srcUrl#fragment" so in-page anchor + * links from the source page remain valid when embedded elsewhere. */ + function rewriteFragmentHrefs(nodes, srcUrl) { + nodes.forEach(function (node) { + node.querySelectorAll('a[href^="#"]').forEach(function (a) { + a.setAttribute('href', srcUrl + a.getAttribute('href')); + }); + }); + } + + /* After injection, retrigger layout-dependent subsystems. */ + function reinitFragment(container) { + /* sidenotes.js — wire newly injected sidenote refs/spans and + reposition the column. Falls back to a manual resize event + for older builds that haven't been redeployed yet. */ + if (typeof window.reinitSidenotes === 'function') { + window.reinitSidenotes(container); + } else { + window.dispatchEvent(new Event('resize')); + } + + /* popups.js — bind hover popups for newly injected links so + transcluded content has the same preview behaviour as the + host page. */ + if (typeof window.reinitPopups === 'function') { + window.reinitPopups(container); + } + + /* collapse.js exposes reinitCollapse for newly added headings. */ + if (typeof window.reinitCollapse === 'function') { + window.reinitCollapse(container); + } + + /* gallery.js can expose reinitGallery when needed. */ + if (typeof window.reinitGallery === 'function') { + window.reinitGallery(container); + } + } + + /* Nested transclusion limits: ancestors carries the chain of srcs + * currently being expanded (cycle guard — a self-transcluding page + * must not loop), and MAX_DEPTH caps pathological nesting. */ + var MAX_DEPTH = 3; + + function loadTransclusion(el, depth, ancestors) { + depth = depth || 0; + ancestors = ancestors || []; + + var src = el.dataset.src; + var section = el.dataset.section || null; + if (!src) return; + + if (depth >= MAX_DEPTH || ancestors.indexOf(src) !== -1) { + el.classList.add('transclude--error'); + el.textContent = '[transclusion omitted (cycle or depth limit): ' + + src + (section ? '#' + section : '') + ']'; + return; + } + + el.classList.add('transclude--loading'); + + fetchPage(src) + .then(function (html) { + var doc = parseDoc(html); + var nodes = section + ? extractSection(doc, section) + : extractBody(doc); + + if (!nodes) { + el.classList.replace('transclude--loading', 'transclude--error'); + el.textContent = '[transclusion not found: ' + + src + (section ? '#' + section : '') + ']'; + return; + } + + rewriteFragmentHrefs(nodes, src); + + var wrapper = document.createElement('div'); + wrapper.className = 'transclude--content'; + nodes.forEach(function (n) { wrapper.appendChild(n); }); + + el.classList.replace('transclude--loading', 'transclude--loaded'); + el.appendChild(wrapper); + + /* The fetched page may itself contain transclusion + placeholders — process them too, extending the + ancestor chain for cycle/depth guarding. */ + var chain = ancestors.concat(src); + wrapper.querySelectorAll('div.transclude').forEach(function (nested) { + loadTransclusion(nested, depth + 1, chain); + }); + + reinitFragment(el); + }) + .catch(function (err) { + el.classList.replace('transclude--loading', 'transclude--error'); + console.warn('transclude: failed to load', src, err); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('div.transclude').forEach(function (el) { + loadTransclusion(el); + }); + }); +}()); diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..b55c052 --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,48 @@ +/* utils.js — Tiny shared helpers loaded before any other script. + Loaded synchronously (no `defer`) from templates/partials/head.html so + that defer'd scripts can rely on `window.lnUtils` existing at run time. + + Keep this file dependency-free and minimal. It's the lowest-level + layer in the JS stack — anything heavier should live in a feature + module instead. */ +(function (global) { + 'use strict'; + + var lnUtils = global.lnUtils || {}; + + /* Escape a string for safe interpolation into HTML text content or + double-quoted attribute values. The order of replacements matters: + `&` MUST come first, otherwise the `&` injected by other rules + gets re-escaped to `&amp;`. + + Mirror of `Utils.escapeHtml` in build/Utils.hs. */ + lnUtils.escapeHtml = function (s) { + return String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + /* Safe localStorage wrapper. Safari throws SecurityError in private + browsing mode on every access — including writes — so reads AND + writes need to be guarded. Every consumer that touches storage + should route through this helper so degradation is uniform. */ + lnUtils.safeStorage = { + get: function (key) { + try { return localStorage.getItem(key); } + catch (_) { return null; } + }, + set: function (key, value) { + try { localStorage.setItem(key, value); return true; } + catch (_) { return false; } + }, + remove: function (key) { + try { localStorage.removeItem(key); return true; } + catch (_) { return false; } + } + }; + + global.lnUtils = lnUtils; +})(window); diff --git a/static/js/viz.js b/static/js/viz.js new file mode 100644 index 0000000..3e6c9b8 --- /dev/null +++ b/static/js/viz.js @@ -0,0 +1,161 @@ +/* viz.js — Vega-Lite renderer with monochrome theming and dark-mode support. + * + * Finds every <script type="application/json" class="vega-spec"> embedded by + * Filters.Viz, renders it into the parent .vega-container via Vega-Embed, + * and re-renders whenever the site's light/dark theme changes. + * + * The site-level monochrome config is always applied; scripts should not + * set colour-related Vega-Lite config keys — use encoding shape, strokeDash, + * and opacity to distinguish series instead of hue. + */ +(function () { + 'use strict'; + + // ------------------------------------------------------------------------- + // Monochrome Vega-Lite configs (matched to base.css custom properties) + // ------------------------------------------------------------------------- + + var LIGHT = { + background: null, + font: 'Spectral, Georgia, "Times New Roman", serif', + mark: { color: '#1a1a1a' }, + axis: { + gridColor: '#cccccc', + gridOpacity: 0.6, + domainColor: '#1a1a1a', + tickColor: '#1a1a1a', + labelColor: '#1a1a1a', + titleColor: '#555555', + labelFont: 'Spectral, Georgia, serif', + titleFont: '"Fira Sans", "Helvetica Neue", Arial, sans-serif', + titleFontWeight: 'normal', + }, + legend: { + labelColor: '#1a1a1a', + titleColor: '#555555', + labelFont: 'Spectral, Georgia, serif', + titleFont: '"Fira Sans", "Helvetica Neue", Arial, sans-serif', + titleFontWeight: 'normal', + }, + title: { + color: '#1a1a1a', + subtitleColor: '#555555', + font: '"Fira Sans", "Helvetica Neue", Arial, sans-serif', + fontWeight: 'normal', + }, + range: { + category: ['#1a1a1a', '#555555', '#888888', '#aaaaaa', '#cccccc'], + ordinal: { scheme: 'greys' }, + ramp: { scheme: 'greys' }, + }, + view: { stroke: null }, + }; + + var DARK = { + background: null, + font: 'Spectral, Georgia, "Times New Roman", serif', + mark: { color: '#d4d0c8' }, + axis: { + gridColor: '#333333', + gridOpacity: 0.8, + domainColor: '#d4d0c8', + tickColor: '#d4d0c8', + labelColor: '#d4d0c8', + titleColor: '#8c8881', + labelFont: 'Spectral, Georgia, serif', + titleFont: '"Fira Sans", "Helvetica Neue", Arial, sans-serif', + titleFontWeight: 'normal', + }, + legend: { + labelColor: '#d4d0c8', + titleColor: '#8c8881', + labelFont: 'Spectral, Georgia, serif', + titleFont: '"Fira Sans", "Helvetica Neue", Arial, sans-serif', + titleFontWeight: 'normal', + }, + title: { + color: '#d4d0c8', + subtitleColor: '#8c8881', + font: '"Fira Sans", "Helvetica Neue", Arial, sans-serif', + fontWeight: 'normal', + }, + range: { + category: ['#d4d0c8', '#8c8881', '#6a6660', '#444444', '#333333'], + ordinal: { scheme: 'greys' }, + ramp: { scheme: 'greys' }, + }, + view: { stroke: null }, + }; + + // ------------------------------------------------------------------------- + // Theme detection (matches theme.js logic) + // ------------------------------------------------------------------------- + + function isDark() { + var t = document.documentElement.dataset.theme; + if (t === 'dark') return true; + /* cappuccino is a dark-brown theme (light text on #553a28) — charts + need the dark palette or axis labels become unreadable. */ + if (t === 'cappuccino') return true; + if (t === 'light') return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + function themeConfig() { + return isDark() ? DARK : LIGHT; + } + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + function renderOne(container) { + var spec = container._vegaSpec; + if (!spec) return; + // Always apply site theme; ignore any config baked into the spec. + var mergedSpec = Object.assign({}, spec, { config: themeConfig() }); + vegaEmbed(container, mergedSpec, { actions: false, renderer: 'svg' }) + .catch(function (err) { console.error('[viz]', err); }); + } + + function renderAll() { + var scripts = document.querySelectorAll('script.vega-spec'); + for (var i = 0; i < scripts.length; i++) { + var scriptEl = scripts[i]; + var container = scriptEl.closest('.vega-container'); + if (!container) continue; + try { + // Store the parsed spec on the container before vegaEmbed replaces + // the container's innerHTML (which removes the <script> element). + container._vegaSpec = JSON.parse(scriptEl.textContent); + } catch (e) { + console.error('[viz] Failed to parse Vega-Lite spec:', e); + continue; + } + renderOne(container); + } + } + + function reRenderAll() { + var containers = document.querySelectorAll('.vega-container'); + for (var i = 0; i < containers.length; i++) { + renderOne(containers[i]); + } + } + + // ------------------------------------------------------------------------- + // Initialisation and theme-change listener + // ------------------------------------------------------------------------- + + document.addEventListener('DOMContentLoaded', renderAll); + + new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + if (mutations[i].attributeName === 'data-theme') { + reRenderAll(); + return; + } + } + }).observe(document.documentElement, { attributes: true }); + +}()); diff --git a/static/logo-sprite.svg b/static/logo-sprite.svg new file mode 100644 index 0000000..70383e5 --- /dev/null +++ b/static/logo-sprite.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg"><symbol id="logo-mark" viewBox="0 0 831 832"> + <g transform="translate(0,832) scale(0.1,-0.1)" stroke="none"> + <path d="M6800 8299 c-81 -9 -122 -16 -405 -73 -404 -81 -724 -212 -1012 -413 -44 -30 -27 24 37 120 21 31 30 37 63 37 50 0 60 25 24 56 -24 20 -26 27 -20 73 6 51 6 51 -23 51 -21 0 -35 -8 -49 -30 -18 -26 -23 -29 -62 -23 -34 5 -44 2 -49 -11 -7 -19 37 -72 68 -82 22 -7 23 -32 2 -62 -12 -17 -19 -20 -34 -12 -43 23 -145 4 -134 -24 7 -17 64 -39 91 -34 12 2 17 -2 14 -12 -3 -8 -9 -38 -15 -66 -8 -45 -16 -55 -60 -87 -28 -21 -62 -47 -76 -60 -77 -69 -94 -77 -110 -47 -9 16 -4 26 30 60 55 55 51 100 -9 100 -35 -1 -58 -20 -67 -55 -7 -27 -8 -27 -15 -8 -7 18 12 129 22 136 2 2 16 8 32 15 27 11 37 45 18 57 -5 3 -7 21 -4 40 6 37 -7 43 -106 48 -73 3 -77 -18 -20 -112 26 -44 19 -87 -12 -71 -51 28 -127 -14 -85 -46 11 -8 36 -14 56 -14 35 0 36 -1 32 -29 l-4 -30 -77 0 c-125 0 -160 31 -125 114 29 69 -14 131 -56 80 -7 -8 -27 -15 -44 -15 -70 0 -74 -49 -10 -110 l45 -43 -48 -26 c-64 -35 -72 -68 -16 -73 29 -2 39 -8 41 -23 10 -55 58 -42 74 21 l12 44 71 -1 c52 0 64 -3 47 -9 -23 -10 -50 -55 -39 -66 11 -11 54 5 85 31 40 34 55 32 91 -11 55 -68 54 -72 -65 -198 l-107 -113 -49 36 c-38 29 -46 40 -41 56 18 50 10 80 -22 83 -47 5 -78 -29 -69 -75 l7 -36 -42 6 c-70 9 -77 22 -36 70 38 44 42 59 23 75 -14 11 -65 5 -74 -9 -10 -16 -33 -9 -39 11 -6 20 -45 27 -79 14 -21 -8 -21 -53 0 -83 16 -22 15 -24 -20 -53 -55 -46 -50 -72 15 -66 28 3 57 12 66 21 14 13 20 14 54 2 42 -15 41 -15 66 -15 23 0 -143 -80 -187 -91 -42 -10 -102 6 -127 33 -16 18 -15 19 5 34 27 19 27 28 3 51 -11 10 -21 34 -23 53 -4 43 -38 53 -72 21 -12 -12 -25 -18 -29 -15 -3 4 -18 4 -32 0 -48 -12 -14 -116 39 -116 13 0 56 -57 56 -74 0 -3 -8 -6 -19 -6 -20 0 -71 -36 -71 -51 0 -19 79 -9 121 16 59 34 124 35 115 3 -4 -13 -8 -30 -9 -38 -3 -21 37 -25 53 -5 17 21 30 19 30 -4 0 -26 14 -34 50 -26 34 7 43 29 41 97 -1 48 -1 48 50 73 36 19 62 25 92 23 l42 -4 -30 -12 c-52 -21 -80 -40 -83 -56 -5 -23 49 -21 100 4 42 20 57 16 58 -17 0 -26 -172 -254 -187 -249 -8 3 -21 6 -29 6 -10 0 -14 12 -14 40 0 39 -1 40 -33 40 -21 0 -41 -8 -52 -20 -14 -15 -26 -18 -59 -14 -47 6 -81 -12 -71 -38 7 -17 35 -28 72 -28 20 0 21 -2 11 -25 -7 -16 -8 -29 -2 -35 12 -12 78 5 99 26 18 19 49 15 53 -7 2 -9 -24 -64 -58 -122 -33 -57 -60 -109 -60 -114 0 -18 -36 -43 -63 -43 -38 0 -42 17 -7 35 30 16 41 50 18 57 -9 3 -9 12 0 38 22 63 17 83 -20 76 -29 -6 -42 -16 -51 -40 -2 -5 -13 -2 -26 5 -23 14 -23 20 -5 92 6 26 5 27 -29 27 -19 0 -37 -6 -39 -12 -4 -10 -10 -10 -32 0 -53 24 -93 -40 -51 -82 14 -14 14 -18 0 -40 -29 -44 1 -55 62 -23 42 22 41 23 53 -23 13 -47 7 -60 -24 -60 -25 0 -76 -44 -76 -66 0 -18 16 -18 74 2 44 15 48 15 66 -1 11 -10 37 -18 60 -19 48 -1 48 2 6 -88 -84 -183 -114 -255 -186 -451 -32 -88 -61 -161 -64 -164 -8 -9 -35 16 -65 61 -45 66 -44 69 10 131 28 32 49 65 47 74 -5 23 -61 27 -83 4 -17 -17 -18 -16 -12 17 4 19 10 58 13 87 l7 51 53 -5 c36 -4 59 -2 69 7 19 16 20 66 0 74 -9 3 -15 18 -15 39 0 60 -55 80 -86 31 -20 -30 -28 -21 -39 44 -6 40 -4 47 23 75 35 36 66 102 56 119 -4 6 -23 8 -48 5 -41 -6 -42 -5 -44 25 -6 79 -74 62 -96 -24 -3 -16 -13 -28 -21 -28 -8 0 -18 -12 -21 -27 -7 -28 14 -53 47 -53 14 0 28 -26 29 -52 0 -5 -13 -8 -30 -8 -33 0 -72 -32 -64 -53 2 -7 31 -26 64 -43 l59 -29 0 -60 0 -60 -18 23 c-22 26 -72 30 -91 7 -17 -21 2 -65 50 -113 34 -34 37 -41 31 -76 l-6 -39 -29 37 c-15 21 -46 50 -68 66 l-40 30 -2 72 c-1 83 -8 98 -40 98 -33 0 -56 -40 -56 -95 0 -55 -26 -61 -44 -10 -29 78 -44 95 -76 84 -27 -10 -30 -15 -30 -52 0 -37 -4 -44 -33 -58 -52 -25 -48 -49 8 -49 46 0 46 0 31 -22 -9 -12 -16 -35 -16 -50 0 -55 99 -23 114 37 9 34 49 33 104 -2 50 -33 51 -35 12 -50 -69 -26 -23 -72 59 -59 38 6 40 4 60 -34 12 -22 21 -42 21 -44 0 -2 -21 -7 -47 -11 -118 -17 -181 -95 -78 -95 54 0 55 0 55 -30 0 -36 27 -41 55 -10 12 13 25 18 41 14 17 -5 30 1 46 19 l23 25 42 -44 c52 -53 52 -38 -5 -207 l-47 -138 -76 -3 -77 -3 19 41 c28 62 19 126 -19 141 -23 9 -62 -22 -62 -49 0 -18 -10 -29 -37 -43 -51 -24 -59 -76 -18 -109 l26 -20 -85 -38 c-108 -48 -156 -39 -156 29 0 29 4 34 29 40 32 7 81 50 81 70 0 22 -29 37 -60 33 -19 -3 -30 0 -30 8 0 6 -8 22 -19 35 -17 21 -17 24 -2 51 21 36 6 63 -37 63 -17 0 -34 3 -38 8 -12 11 -59 8 -72 -5 -17 -17 -15 -51 5 -87 17 -29 17 -31 -1 -44 -46 -34 6 -71 65 -46 42 17 45 7 26 -66 -17 -63 -15 -106 6 -132 12 -16 -39 -18 -79 -3 -21 8 -24 14 -16 22 20 20 15 58 -8 58 -13 0 -20 7 -20 19 0 43 -66 44 -77 1 -7 -27 -26 -23 -82 17 -2 1 4 21 14 43 22 48 10 70 -40 70 -36 0 -65 -26 -65 -58 0 -17 -6 -22 -25 -22 -22 0 -24 4 -27 58 -3 62 -22 76 -61 43 -16 -13 -21 -13 -46 2 -65 38 -94 9 -63 -64 21 -50 21 -50 1 -70 -48 -48 -4 -65 87 -33 38 13 70 24 72 24 7 0 1 -29 -7 -32 -5 -1 -13 -16 -17 -32 -7 -28 -6 -29 25 -24 24 4 35 0 42 -14 20 -36 60 -13 85 50 1 1 15 -5 33 -15 31 -18 42 -43 18 -43 -19 0 -57 -43 -57 -63 0 -25 39 -21 95 9 45 23 51 24 97 13 56 -15 55 -12 23 -64 -34 -54 -32 -59 20 -52 28 4 45 3 45 -4 0 -6 16 -9 38 -7 l37 3 0 57 0 56 38 6 c21 4 66 18 99 31 71 29 71 29 58 -16 -6 -18 -10 -51 -10 -72 0 -21 -9 -56 -20 -78 l-21 -39 -29 22 c-24 18 -35 20 -57 12 -36 -12 -42 -42 -12 -65 l23 -17 -25 -12 c-22 -10 -31 -8 -60 9 -66 40 -139 15 -139 -49 0 -24 5 -33 20 -37 14 -4 20 -12 18 -27 -3 -19 1 -21 40 -18 35 2 42 -1 42 -16 0 -10 6 -26 13 -36 29 -39 79 19 79 91 0 26 7 37 39 57 21 14 41 26 44 26 3 0 20 13 38 30 26 24 33 27 35 14 4 -19 61 -12 74 9 16 25 8 66 -18 91 -24 22 -26 28 -19 72 13 90 99 143 178 110 l36 -15 -15 -53 c-9 -29 -42 -142 -75 -252 -32 -109 -61 -214 -65 -232 -3 -19 -10 -36 -14 -39 -16 -10 -306 29 -525 72 -182 35 -316 70 -610 160 -146 44 -448 165 -634 254 -56 27 -105 49 -107 49 -3 0 -85 43 -182 97 -97 53 -195 106 -216 117 -44 24 -121 99 -201 196 -146 178 -255 385 -336 635 -63 195 -106 299 -152 368 -146 220 -404 266 -569 103 -136 -136 -172 -346 -87 -520 39 -80 92 -133 255 -253 16 -12 67 -50 114 -84 140 -103 532 -369 545 -369 2 0 46 -26 97 -58 51 -32 125 -76 163 -97 39 -21 111 -61 160 -88 50 -28 113 -62 140 -75 191 -93 336 -161 440 -204 66 -27 145 -60 175 -73 337 -146 942 -300 1393 -356 82 -10 97 -14 93 -28 -51 -178 -49 -175 -95 -142 -15 11 -39 23 -54 26 -23 6 -25 11 -20 36 10 52 -38 78 -83 45 -14 -10 -23 -11 -32 -4 -9 8 -20 8 -37 0 -32 -15 -35 -64 -5 -85 18 -12 18 -16 5 -33 -37 -48 11 -61 73 -19 41 28 54 30 84 14 19 -10 19 -12 -11 -30 -61 -37 -22 -68 43 -34 23 12 32 13 43 3 18 -15 17 -24 -41 -223 -74 -258 -101 -341 -108 -343 -11 -2 -107 42 -108 49 0 4 11 10 25 13 35 9 65 62 65 113 0 45 -20 66 -52 54 -20 -7 -78 -79 -78 -97 0 -7 -5 -16 -12 -20 -8 -5 -10 12 -5 66 6 68 9 76 38 99 58 45 73 131 23 131 -13 0 -34 -10 -46 -22 -20 -20 -22 -20 -35 -3 -19 26 -16 43 7 50 30 9 70 53 70 75 0 24 -29 40 -72 40 -32 0 -33 1 -34 43 -1 24 -7 53 -13 65 -24 44 -101 17 -101 -36 0 -17 -7 -21 -36 -24 -80 -7 -61 -91 30 -136 34 -17 54 -36 76 -75 45 -79 43 -82 -48 -82 l-77 0 -24 52 c-25 54 -52 70 -94 57 -30 -9 -37 -23 -26 -52 8 -20 6 -27 -11 -39 -14 -10 -20 -24 -18 -41 3 -26 5 -27 78 -30 41 -1 101 3 134 9 48 9 61 8 73 -4 13 -13 10 -17 -24 -33 -46 -23 -73 -67 -44 -72 11 -2 39 6 62 17 24 12 44 21 46 21 2 0 3 -23 3 -51 0 -35 6 -60 20 -79 l20 -28 -23 -6 c-57 -15 -81 -7 -107 34 -25 40 -52 50 -94 34 -18 -7 -21 -45 -5 -61 9 -9 6 -19 -10 -39 -29 -36 -14 -51 34 -37 30 9 37 9 41 -3 2 -8 6 -21 9 -29 11 -35 70 -8 91 41 9 22 21 34 34 34 23 0 25 -17 5 -44 -20 -27 -20 -85 1 -93 22 -8 65 27 92 75 28 50 42 52 66 11 14 -24 17 -43 13 -80 -9 -72 -86 -311 -98 -303 -7 4 -7 10 0 17 6 6 11 26 11 44 0 28 -4 33 -24 33 -26 0 -42 15 -48 45 -10 56 -78 9 -78 -55 0 -52 -13 -51 -30 1 -8 23 -20 43 -27 46 -15 6 -18 33 -3 33 11 0 13 39 4 64 -9 24 -42 19 -70 -9 l-25 -25 -26 20 c-32 25 -84 26 -106 2 -23 -25 -21 -49 5 -63 20 -11 21 -12 4 -25 -41 -29 7 -44 65 -20 27 11 41 11 68 2 48 -16 48 -31 1 -45 -81 -24 -71 -69 13 -57 76 12 235 -39 251 -79 3 -8 -13 -69 -34 -137 -22 -67 -40 -129 -41 -138 0 -10 -5 -7 -17 10 -33 48 -84 90 -130 106 -45 16 -47 19 -44 51 4 31 2 33 -27 33 -22 0 -31 5 -31 16 0 9 -10 34 -22 55 -18 33 -25 38 -47 32 -39 -9 -46 -20 -46 -73 0 -34 -4 -50 -13 -50 -19 0 -85 36 -120 65 l-30 25 29 29 c45 46 37 141 -14 160 -14 5 -27 19 -30 30 -5 22 -49 29 -73 13 -19 -11 -31 -74 -23 -112 7 -34 -1 -49 -27 -50 -7 0 -19 -11 -28 -25 -24 -36 9 -69 62 -63 31 4 42 0 64 -23 15 -16 42 -36 60 -45 l33 -17 -33 -14 c-28 -12 -35 -11 -48 2 -18 18 -44 19 -79 3 -25 -11 -34 -41 -15 -53 6 -4 8 -21 4 -41 -7 -38 3 -43 46 -21 21 11 29 11 47 -1 44 -27 79 6 61 57 -11 32 -5 36 57 36 73 0 87 -16 40 -44 -20 -11 -33 -26 -30 -31 12 -20 81 -11 120 16 40 27 71 26 129 -3 l28 -15 -52 -34 c-29 -19 -62 -46 -73 -61 -20 -25 -21 -25 -28 -4 -10 26 -40 27 -63 2 -15 -16 -22 -18 -41 -9 -81 37 -63 -75 20 -124 40 -23 40 -36 1 -95 -27 -42 -27 -42 -59 22 -27 56 -37 61 -84 49 -53 -14 -9 -134 49 -134 30 0 11 -19 -26 -26 -48 -9 -99 19 -141 76 -20 27 -48 53 -64 58 -24 8 -27 14 -23 40 6 46 -13 74 -51 70 -48 -5 -68 -47 -44 -90 11 -18 14 -34 9 -36 -6 -2 -20 -12 -33 -23 -29 -25 -36 -24 -54 10 -17 34 -42 45 -78 36 -44 -11 -43 -59 1 -98 19 -18 19 -18 -9 -47 -40 -39 -37 -53 8 -48 21 3 46 8 55 12 13 6 19 2 24 -19 5 -21 11 -26 33 -23 24 3 26 7 29 58 3 71 10 100 23 100 6 0 8 -9 4 -20 -9 -27 33 -28 66 -1 l24 18 41 -44 42 -44 -39 -19 c-59 -30 -91 -92 -55 -106 19 -7 70 20 94 51 25 32 42 32 42 0 0 -20 5 -25 24 -25 25 0 36 16 36 55 0 20 52 45 93 45 3 0 8 -17 12 -37 9 -52 31 -83 59 -83 56 0 73 72 32 133 l-25 36 29 33 29 32 6 -27 c10 -40 58 -38 75 3 14 35 6 92 -15 110 -21 17 -19 43 9 98 53 105 117 108 168 8 25 -50 21 -95 -21 -226 -29 -92 -122 -405 -155 -522 -11 -39 -22 -58 -32 -58 -12 0 -14 5 -9 18 4 9 10 30 13 46 5 28 4 29 -32 22 -33 -6 -38 -4 -43 13 -13 52 -86 3 -82 -54 4 -44 -5 -52 -35 -31 -26 16 -46 69 -46 119 0 24 3 27 36 27 87 0 103 74 22 103 -14 5 -18 18 -18 51 0 86 -40 106 -80 41 -17 -27 -28 -35 -51 -35 -40 0 -69 -16 -69 -39 0 -21 53 -71 76 -71 8 0 14 -8 14 -17 0 -10 3 -35 6 -56 l7 -37 -40 0 c-26 0 -50 -8 -67 -21 -46 -36 -25 -95 25 -69 18 10 19 7 19 -40 0 -41 3 -50 18 -50 24 0 62 28 62 45 0 23 18 27 42 11 12 -9 32 -16 44 -16 30 0 35 -9 19 -34 -7 -11 -16 -36 -20 -54 -14 -70 76 -25 111 56 8 17 21 32 29 32 16 0 47 -39 39 -52 -3 -4 -21 -63 -40 -131 -54 -191 -54 -192 -95 -156 -13 12 -40 33 -59 48 -19 14 -37 29 -40 32 -3 3 -43 34 -90 67 -47 34 -89 65 -95 70 -63 52 -297 189 -421 246 -230 105 -416 146 -666 146 -790 0 -1281 -451 -1263 -1160 13 -500 267 -923 679 -1129 239 -119 375 -145 751 -146 423 0 696 37 1085 149 228 65 596 209 630 247 3 3 29 18 59 33 49 25 238 148 301 195 46 35 167 111 176 111 5 0 34 -17 64 -37 56 -37 173 -111 265 -166 399 -237 905 -410 1341 -457 275 -30 735 5 924 70 14 5 63 20 110 35 106 33 289 114 398 177 46 26 86 48 88 48 11 0 235 159 303 215 124 102 331 312 408 414 10 13 30 37 44 55 14 17 41 54 60 81 19 28 36 52 39 55 3 3 26 37 51 75 25 39 79 122 120 185 113 173 156 276 185 442 49 280 -56 450 -269 436 -220 -14 -346 -219 -442 -723 -96 -501 -134 -596 -299 -760 -667 -661 -1489 -842 -2363 -520 -240 89 -873 448 -873 496 0 4 26 28 58 54 117 95 292 274 457 465 86 100 233 308 347 490 55 88 228 402 228 414 0 2 24 55 54 117 218 461 395 1028 597 1914 67 291 117 517 131 587 l11 58 71 27 c72 28 403 191 486 239 64 37 224 137 230 144 3 3 41 30 85 59 262 178 591 491 786 746 297 390 439 754 439 1126 0 453 -208 762 -602 893 -155 52 -414 76 -598 55z m577 -300 c83 -28 164 -90 229 -174 31 -41 63 -184 70 -314 11 -194 -37 -462 -125 -700 -127 -346 -501 -818 -871 -1102 -25 -19 -47 -37 -50 -40 -51 -63 -635 -417 -655 -397 -4 4 18 107 50 230 164 650 327 1120 542 1558 57 118 109 217 114 218 5 2 9 8 9 14 0 13 158 274 217 358 94 136 215 289 280 357 26 27 96 24 190 -8z m-357 -34 c0 -2 -19 -28 -43 -57 -83 -103 -252 -341 -293 -413 -6 -11 -42 -72 -79 -135 -281 -479 -523 -1126 -723 -1937 l-57 -232 -120 -50 c-168 -68 -462 -167 -472 -158 -14 15 242 812 313 972 7 17 26 67 43 110 91 246 258 593 427 885 27 47 52 90 57 95 4 6 54 80 111 167 259 391 457 584 731 714 89 42 105 48 105 39z m-1264 -3076 c-236 -1090 -426 -1774 -613 -2214 -89 -207 -189 -419 -241 -510 -6 -11 -43 -76 -83 -145 -105 -184 -258 -408 -368 -540 -25 -30 -55 -65 -66 -79 -134 -163 -315 -334 -235 -221 61 86 178 354 249 573 110 338 405 1477 581 2247 17 74 68 283 112 463 79 315 83 328 108 333 112 24 318 80 430 117 74 24 136 43 137 42 2 -1 -4 -30 -11 -66z m-3986 -2559 c106 -26 276 -107 412 -196 57 -37 105 -70 108 -74 3 -3 46 -37 95 -75 82 -62 224 -175 251 -200 15 -13 108 -92 136 -115 l22 -17 -28 -74 c-15 -41 -30 -84 -33 -97 -8 -33 -47 -26 -51 9 -8 66 -94 83 -100 20 -2 -18 1 -38 7 -44 17 -17 13 -27 -12 -27 -20 0 -23 4 -19 30 4 25 0 30 -24 36 -15 4 -39 20 -52 36 -52 62 -136 7 -87 -58 19 -26 19 -41 -2 -49 -10 -3 -27 -12 -37 -20 -49 -37 50 -63 102 -27 25 17 85 23 98 9 4 -3 -1 -13 -10 -22 -24 -25 -21 -55 7 -55 27 0 103 61 112 90 8 26 35 26 35 0 0 -23 -123 -309 -188 -434 -26 -50 -45 -95 -44 -99 5 -16 -58 34 -69 54 -18 34 18 93 61 100 70 11 102 46 63 68 -15 8 -16 13 -4 33 36 69 -21 117 -78 65 -14 -13 -34 -27 -44 -30 -26 -10 -32 -35 -12 -57 16 -18 16 -21 -5 -55 -26 -42 -36 -44 -44 -9 -5 17 1 45 16 81 27 66 19 88 -33 88 -26 0 -36 -6 -44 -22 -18 -42 -45 -29 -45 21 0 36 -4 46 -21 51 -22 7 -79 -12 -79 -26 0 -5 -11 -9 -24 -9 -13 0 -29 -7 -37 -16 -18 -22 -4 -42 50 -71 1 -1 -4 -12 -13 -25 -20 -29 -20 -57 0 -65 16 -6 70 28 92 58 20 29 39 12 66 -62 14 -39 26 -72 26 -74 0 -9 -161 8 -207 22 -81 26 -132 -9 -86 -59 9 -10 27 -18 40 -18 22 0 23 -3 17 -35 -6 -34 -5 -35 24 -35 24 0 40 11 74 50 43 49 45 50 76 39 39 -13 39 -16 7 -41 -16 -13 -25 -29 -25 -49 0 -36 18 -38 92 -8 52 20 110 20 123 -1 5 -8 -54 -109 -80 -135 -5 -6 -41 -17 -79 -24 l-68 -14 -30 32 c-50 54 -146 28 -110 -30 11 -18 10 -23 -12 -40 -72 -57 -4 -64 142 -15 60 21 102 30 102 24 0 -17 -168 -179 -224 -215 -118 -78 -249 -107 -491 -107 -194 0 -256 8 -407 54 -205 63 -387 178 -496 314 -69 86 -77 100 -110 202 -147 446 -44 944 249 1208 235 212 643 313 959 236z" fill="var(--logo-ink, currentColor)"/> + <path d="M5613 7535 c0 -22 2 -30 4 -17 2 12 2 30 0 40 -3 9 -5 -1 -4 -23z M4833 7015 c0 -27 2 -38 4 -22 2 15 2 37 0 50 -2 12 -4 0 -4 -28z M4880 6932 c0 -5 14 -7 30 -4 17 2 30 6 30 8 0 2 -13 4 -30 4 -16 0 -30 -4 -30 -8z M4671 6838 c-1 -9 3 -19 9 -23 5 -3 7 -13 4 -23 -5 -14 -4 -14 4 -2 6 8 7 19 3 25 -5 5 -11 17 -14 25 -4 12 -6 12 -6 -2z M4443 6510 c0 -30 2 -43 4 -27 2 15 2 39 0 55 -2 15 -4 2 -4 -28z M4530 6525 c-14 -17 -7 -45 12 -45 9 0 8 3 -2 10 -12 8 -13 13 -2 30 14 23 10 27 -8 5z M4586 6441 c-3 -5 0 -14 7 -19 10 -8 13 -6 14 9 2 19 -11 26 -21 10z M4353 6020 c0 -30 2 -43 4 -27 2 15 2 39 0 55 -2 15 -4 2 -4 -28z M4248 5763 c-2 -5 -1 -37 3 -73 l6 -65 1 73 c2 65 -1 85 -10 65z M3960 5445 c0 -8 2 -15 4 -15 2 0 13 -3 23 -6 17 -5 17 -5 0 6 -9 6 -15 15 -12 21 4 5 1 9 -4 9 -6 0 -11 -7 -11 -15z M4120 4890 c0 -22 3 -40 8 -40 4 0 6 18 4 40 -2 22 -6 40 -8 40 -2 0 -4 -18 -4 -40z M3920 4576 c-6 -8 -24 -17 -38 -19 l-27 -4 26 -2 c15 0 34 8 44 19 10 11 16 20 13 20 -3 0 -11 -6 -18 -14z M4161 4399 c-1 -33 35 -71 45 -46 3 9 4 22 1 29 -3 7 -6 2 -6 -11 -1 -32 -28 -11 -34 27 l-4 27 -2 -26z M3832 4113 c-2 -45 -5 -52 -24 -56 l-23 -4 23 -2 c28 -1 34 14 29 70 l-4 44 -1 -52z M3874 4085 c1 -12 9 -21 21 -22 14 -1 15 0 3 3 -9 3 -19 13 -21 22 -3 12 -4 11 -3 -3z M3654 3876 c-3 -8 -4 -25 -2 -38 3 -17 5 -13 9 15 5 38 2 47 -7 23z M3623 3535 c0 -33 2 -45 4 -27 2 18 2 45 0 60 -2 15 -4 0 -4 -33z M3554 3386 c-3 -8 -4 -25 -2 -38 3 -17 5 -13 9 15 5 38 2 47 -7 23z M3450 3372 c0 -5 14 -7 30 -4 17 2 30 6 30 8 0 2 -13 4 -30 4 -16 0 -30 -4 -30 -8z M3471 3272 c-1 -15 -5 -21 -13 -16 -10 5 -10 4 -1 -6 17 -18 26 -8 20 22 l-4 23 -2 -23z M3638 2783 c12 -2 30 -2 40 0 9 3 -1 5 -23 4 -22 0 -30 -2 -17 -4z M3649 2442 c5 -34 6 -35 9 -8 2 16 0 32 -5 37 -5 5 -7 -7 -4 -29z M3017 2213 c-4 -3 -7 -11 -7 -17 0 -7 3 -7 8 0 4 7 18 14 32 17 l25 4 -26 2 c-14 0 -29 -2 -32 -6z M3132 2191 c-7 -4 -25 -6 -40 -5 l-27 4 25 -9 c22 -7 60 2 60 14 0 7 -3 6 -18 -4z M3511 2156 c-8 -9 -8 -16 -2 -20 5 -4 12 -2 16 4 3 5 3 10 -2 11 -4 1 3 5 17 9 22 7 23 8 4 9 -12 1 -27 -5 -33 -13z M3598 1783 c12 -2 30 -2 40 0 9 3 -1 5 -23 4 -22 0 -30 -2 -17 -4z M3333 1285 c0 -22 2 -30 4 -17 2 12 2 30 0 40 -3 9 -5 -1 -4 -23z M2839 1067 c5 -5 24 -7 42 -5 33 3 33 4 -8 8 -23 3 -39 2 -34 -3z M2607 658 c2 -5 15 -8 29 -8 13 0 24 3 24 8 0 4 -13 7 -29 7 -16 0 -27 -3 -24 -7z" fill="var(--logo-ink, currentColor)"/> + <path d="M5346 7728 c-20 -29 -20 -45 0 -56 13 -7 15 -18 10 -46 -6 -30 -11 -36 -32 -36 -29 0 -58 -14 -97 -46 -31 -26 -75 -31 -109 -13 -25 13 -35 5 -18 -16 10 -12 9 -16 -4 -21 -16 -6 -16 -8 0 -25 18 -19 43 -84 44 -111 0 -12 -8 -18 -24 -18 -48 0 -89 -41 -117 -118 -31 -84 -43 -100 -84 -118 -31 -13 -28 -16 27 -28 19 -4 36 -21 54 -52 23 -39 26 -52 20 -97 -17 -126 -17 -125 -37 -120 -27 7 -68 -23 -84 -60 -7 -18 -18 -68 -25 -112 -13 -86 -26 -119 -59 -149 -19 -18 -20 -20 -4 -32 39 -28 76 24 93 132 15 96 28 135 58 166 34 37 46 25 67 -65 15 -62 41 -114 51 -104 2 2 -5 40 -16 86 -11 45 -20 87 -20 93 0 7 17 9 47 5 35 -4 54 -12 73 -34 14 -15 34 -35 45 -43 17 -14 18 -14 6 8 -17 32 -86 80 -135 94 -23 6 -42 12 -43 13 -2 1 2 17 8 36 14 46 13 123 -2 151 -6 13 -25 37 -41 55 -30 31 -30 31 -10 51 10 10 31 51 46 90 44 115 81 128 51 18 -11 -40 -15 -80 -12 -102 6 -37 44 -86 82 -107 11 -6 6 3 -12 22 -40 40 -49 83 -29 140 9 24 16 54 16 67 0 13 5 24 11 24 5 0 15 14 20 30 6 17 16 30 22 30 7 0 7 4 -1 12 -7 7 -12 21 -12 32 0 12 -9 40 -20 63 -10 24 -15 45 -10 48 8 5 53 -126 53 -155 0 -8 10 -26 22 -40 12 -14 31 -38 41 -54 29 -46 91 -86 134 -86 48 0 72 24 44 45 -16 12 -21 11 -36 -2 -29 -26 -69 -19 -101 17 -33 37 -31 45 7 30 40 -15 22 7 -19 24 -19 8 -41 28 -49 44 -14 28 -14 30 12 54 15 14 37 31 49 37 31 16 94 14 138 -5 56 -23 115 -2 68 24 -11 6 -23 6 -33 -1 -12 -7 -30 -5 -65 7 -64 21 -112 20 -160 -4 -39 -20 -54 -15 -55 16 0 6 -3 25 -6 41 -6 28 -4 31 29 37 19 4 52 20 72 36 21 17 41 30 45 30 4 0 10 -17 14 -38 3 -21 14 -46 24 -55 18 -16 18 -15 14 39 -3 41 -2 55 7 50 6 -4 11 -13 11 -21 0 -14 50 -78 56 -72 2 2 -5 21 -16 42 -22 43 -18 61 6 27 8 -12 20 -22 27 -22 6 0 -3 15 -21 33 -23 24 -32 42 -32 67 0 21 -9 44 -22 59 -13 14 -17 21 -9 14 11 -9 19 -8 37 4 23 14 25 13 61 -27 21 -23 51 -49 66 -57 26 -13 28 -19 24 -51 -3 -20 -8 -47 -12 -58 -5 -15 -2 -23 11 -28 24 -9 34 10 34 70 l0 51 66 12 c37 7 84 21 106 32 29 15 43 17 58 9 16 -8 20 -8 20 4 0 33 -69 47 -106 22 -33 -23 -130 -47 -173 -43 -33 3 -50 13 -92 56 -54 55 -48 61 24 24 43 -22 32 -7 -16 22 -58 33 -129 35 -151 3z M4812 7209 c26 -15 36 -65 30 -156 l-5 -83 -36 0 c-44 0 -95 -30 -119 -69 -15 -25 -19 -27 -32 -13 -11 12 -12 16 -1 20 7 2 28 18 47 34 31 28 33 32 17 43 -14 11 -24 7 -65 -23 -26 -20 -48 -40 -48 -45 0 -4 9 -20 19 -35 11 -15 32 -81 46 -147 23 -105 39 -144 53 -130 4 4 -21 132 -35 179 -4 16 0 15 29 -8 37 -27 110 -37 124 -15 13 22 -13 31 -49 18 -33 -12 -39 -11 -76 14 -23 15 -41 35 -41 43 0 13 3 14 14 5 11 -10 16 -8 21 8 10 32 82 91 111 91 15 0 41 -13 61 -30 18 -16 39 -30 46 -30 7 0 0 10 -15 21 -38 28 -35 39 11 39 45 0 60 15 39 36 -13 13 -18 13 -43 0 -41 -22 -48 -12 -40 58 15 119 15 117 -24 153 -20 18 -40 33 -46 33 -5 0 -2 -5 7 -11z M4502 6659 c-33 -26 -51 -46 -48 -55 7 -18 2 -18 -37 2 l-32 16 33 -30 c29 -28 32 -37 32 -87 0 -48 -5 -63 -30 -95 -16 -20 -34 -49 -40 -64 -13 -35 -13 -113 0 -121 17 -11 2 -24 -64 -54 -86 -41 -90 -47 -82 -131 6 -53 4 -76 -8 -99 -22 -42 -73 -75 -113 -74 -32 1 -33 0 -19 -16 9 -10 16 -23 16 -29 0 -6 15 -23 34 -39 41 -35 47 -52 38 -126 l-7 -58 -58 -24 c-70 -28 -93 -47 -109 -91 -6 -18 -19 -35 -27 -37 -11 -3 -7 -8 14 -18 17 -8 43 -23 59 -33 l28 -20 49 28 c66 37 128 34 147 -6 14 -30 32 -37 32 -13 0 8 -19 28 -42 45 -52 38 -105 40 -166 5 -23 -13 -49 -22 -57 -19 -18 7 -18 9 -1 42 22 41 58 71 113 92 44 17 53 17 69 5 21 -16 64 -20 64 -6 0 5 -7 14 -15 21 -13 11 -13 15 -3 21 23 15 60 10 138 -18 91 -32 112 -29 180 32 54 48 82 55 112 28 10 -9 18 -11 18 -5 0 19 -57 52 -89 52 -23 0 -45 -13 -87 -51 -53 -48 -60 -51 -98 -46 -22 3 -58 12 -79 21 l-39 17 46 41 c40 34 45 43 34 56 -11 13 -11 14 0 8 8 -5 21 -1 32 9 17 15 25 16 67 5 59 -15 114 0 93 25 -6 8 -24 14 -38 14 -61 0 -142 18 -164 36 -13 11 -25 21 -28 23 -2 2 2 7 9 11 8 5 24 3 40 -5 14 -8 37 -14 49 -14 36 0 21 24 -19 30 l-34 6 33 7 c17 4 46 10 63 13 17 3 53 26 81 51 55 49 89 65 115 57 16 -5 15 -3 -1 10 -27 22 -72 20 -112 -5 -34 -21 -34 -21 -34 0 0 11 4 30 9 43 8 21 19 25 100 37 78 11 90 15 80 27 -12 14 -66 13 -124 -3 -20 -6 -20 -5 -1 16 11 11 26 21 33 21 26 0 7 25 -21 28 -22 3 -36 -6 -67 -38 l-39 -41 -43 21 c-23 12 -51 28 -61 36 -19 14 -19 15 9 44 30 31 78 50 130 50 34 0 124 39 143 62 10 13 9 18 -3 28 -12 10 -23 7 -62 -18 -79 -49 -88 -49 -117 1 -14 24 -28 57 -32 73 -5 27 -2 32 25 45 41 20 144 14 195 -11 22 -11 41 -18 43 -17 8 8 -85 57 -120 62 -23 4 -47 11 -53 16 -6 5 -22 8 -35 7 -32 -4 -37 26 -7 46 19 12 32 13 69 4 57 -13 99 -4 99 21 0 31 -24 43 -38 20 -11 -17 -22 -19 -92 -17 -98 4 -105 19 -30 75 38 28 47 40 38 50 -16 20 -29 16 -86 -28z m-22 -308 c6 -16 23 -39 36 -52 28 -26 18 -39 -31 -39 -16 0 -42 -7 -57 -14 -27 -14 -28 -14 -28 7 0 28 46 127 58 127 5 0 15 -13 22 -29z m-20 -239 c50 -29 65 -43 64 -60 -4 -127 -3 -122 -31 -133 -16 -6 -49 -13 -74 -16 -55 -6 -58 -2 -66 102 -4 50 -11 76 -25 92 l-20 21 29 16 c40 22 50 20 123 -22z m-172 -44 c9 -9 12 -9 12 0 0 7 5 12 10 12 6 0 10 -22 10 -50 0 -27 7 -70 16 -96 19 -56 9 -68 -46 -58 -44 7 -44 8 -30 35 9 15 10 46 6 96 -7 72 -2 85 22 61z m5 -215 c3 -5 -4 -24 -14 -43 -11 -19 -19 -27 -19 -19 0 9 -9 28 -21 43 l-20 26 35 0 c19 0 37 -3 39 -7z m-73 -23 c10 -18 8 -19 -20 -14 -18 4 -36 1 -45 -6 -13 -10 -15 -9 -15 8 0 15 8 22 28 25 15 2 30 5 34 6 4 0 12 -8 18 -19z m179 -24 c32 -24 32 -25 12 -36 -12 -6 -21 -15 -21 -20 0 -15 -84 -89 -94 -83 -17 11 1 92 28 128 15 19 30 35 35 35 4 0 22 -11 40 -24z m-151 -177 c-28 -18 -35 -9 -30 41 3 32 0 55 -8 65 -10 13 -9 15 8 15 12 0 24 6 29 13 5 7 9 -17 11 -57 2 -49 -1 -72 -10 -77z M4610 6415 c13 -14 26 -25 28 -25 3 0 -5 11 -18 25 -13 14 -26 25 -28 25 -3 0 5 -11 18 -25z M4492 5455 c-57 -7 -107 -16 -112 -21 -5 -5 -17 -31 -27 -57 l-18 -48 -85 -4 c-47 -3 -104 -7 -126 -10 -39 -6 -43 -4 -63 29 -23 36 -76 86 -92 86 -5 0 -9 -18 -9 -41 0 -22 -5 -61 -12 -86 -10 -41 -9 -47 7 -59 11 -8 38 -14 62 -14 49 0 51 -5 23 -60 -25 -49 -25 -66 -1 -112 19 -35 19 -36 2 -52 -10 -9 -21 -34 -25 -55 -3 -21 -17 -55 -32 -76 -23 -35 -24 -39 -9 -54 15 -15 18 -14 41 14 13 17 26 46 29 65 7 43 17 62 35 68 18 6 7 -146 -14 -187 -12 -22 -13 -37 -5 -65 13 -49 1 -68 -63 -98 -29 -14 -62 -35 -72 -47 -12 -13 -31 -21 -52 -21 -32 0 -32 1 -24 33 6 19 30 51 60 78 47 43 63 80 41 93 -5 3 -36 -20 -69 -52 -62 -59 -81 -103 -69 -154 8 -33 -50 -76 -98 -72 -42 3 -50 -22 -10 -32 14 -3 25 -10 25 -14 0 -5 3 -16 7 -25 5 -15 3 -16 -18 -5 l-24 11 22 -18 c13 -11 24 -36 28 -63 8 -50 25 -83 63 -115 22 -19 25 -30 26 -91 l1 -69 -40 -3 c-50 -4 -55 -9 -80 -80 -15 -44 -25 -58 -43 -60 -17 -3 -22 2 -23 20 -2 40 2 62 16 85 12 18 12 23 -2 34 -13 11 -18 10 -35 -11 -15 -18 -19 -36 -16 -74 l3 -51 -38 -6 c-51 -8 -76 -33 -60 -58 10 -16 9 -18 -8 -14 -32 9 -21 -6 18 -24 43 -20 60 -50 69 -117 6 -43 5 -46 -15 -46 -34 0 -67 -41 -75 -92 -7 -44 -9 -47 -65 -72 -32 -14 -63 -26 -69 -26 -5 0 -14 -6 -20 -13 -8 -9 -2 -30 21 -77 30 -60 32 -71 33 -181 1 -65 5 -123 9 -129 4 -7 -5 -24 -21 -41 -31 -33 -77 -39 -104 -14 -15 14 -18 14 -23 1 -4 -9 4 -21 20 -31 14 -10 22 -12 18 -4 -6 8 -4 10 4 5 7 -5 10 -10 7 -13 -3 -3 4 -23 14 -44 27 -52 25 -110 -6 -180 -16 -36 -25 -69 -22 -88 3 -19 0 -27 -7 -25 -7 3 -19 -20 -30 -55 -15 -49 -16 -63 -5 -76 20 -24 -31 -20 -64 6 l-26 20 -10 -23 c-43 -95 -96 -255 -89 -264 12 -15 1 -24 -28 -24 -25 0 -30 -19 -11 -38 14 -14 32 -16 23 -2 -3 5 5 12 19 16 34 8 75 -23 76 -57 0 -38 36 -79 68 -79 43 0 101 19 190 62 45 22 88 38 96 35 7 -3 26 -37 40 -75 26 -67 62 -112 100 -126 16 -5 17 -4 6 9 -7 8 -18 15 -25 15 -13 0 -25 24 -56 106 -19 49 -75 104 -106 104 -6 0 -49 -18 -95 -40 -45 -22 -104 -46 -130 -54 l-48 -14 0 28 c0 32 -34 80 -58 80 -9 0 6 13 33 28 33 18 57 40 70 65 20 37 21 38 65 32 25 -3 54 -8 65 -11 19 -5 19 -4 2 9 -9 8 -45 20 -80 27 -57 11 -63 10 -72 -7 -16 -28 -84 -93 -99 -93 -10 0 -3 29 25 103 62 160 53 147 90 134 18 -7 67 -46 111 -87 62 -60 78 -81 78 -104 0 -21 11 -40 40 -68 34 -31 48 -38 81 -38 28 0 39 4 36 13 -3 11 -15 13 -83 16 -21 1 -36 26 -48 82 -3 15 -37 53 -81 93 -42 38 -74 71 -71 73 5 5 121 32 139 33 4 0 19 -24 32 -54 13 -30 29 -57 34 -61 18 -11 12 28 -9 63 -11 18 -18 37 -15 42 3 4 -12 18 -33 30 -26 16 -32 24 -22 28 8 3 28 16 43 28 40 33 56 30 75 -16 20 -46 81 -87 100 -68 14 14 15 12 16 -82 1 -52 7 -86 20 -109 18 -35 84 -101 100 -101 5 0 -3 9 -18 21 -47 37 -66 89 -66 179 0 77 1 80 19 70 24 -12 61 -13 61 -1 0 12 -25 21 -56 21 -16 0 -43 17 -73 45 -54 50 -81 61 -36 15 23 -24 25 -30 11 -30 -17 0 -66 45 -66 61 0 11 53 5 123 -14 63 -17 81 -14 130 25 24 20 31 21 59 10 40 -15 48 -8 12 11 l-27 14 29 11 c16 5 37 13 47 17 12 5 17 2 17 -10 0 -28 37 -80 78 -111 l37 -27 -32 34 c-18 19 -38 50 -44 69 -7 19 -18 42 -26 52 -13 14 -10 21 22 57 35 39 38 40 85 34 42 -5 56 -2 97 23 26 16 57 33 67 37 18 7 18 9 4 23 -14 15 -20 14 -59 -5 -24 -11 -61 -30 -81 -42 -33 -18 -42 -19 -74 -9 l-36 12 -57 -60 c-84 -91 -142 -96 -162 -16 -7 27 -22 53 -38 65 -24 19 -35 20 -141 15 -73 -4 -121 -3 -130 3 -12 8 -8 11 17 11 25 0 39 8 60 35 l27 34 78 -6 77 -6 62 64 c80 83 104 85 170 17 49 -49 95 -66 145 -54 21 6 23 10 14 21 -8 10 -26 12 -63 8 l-52 -5 -42 50 -42 50 31 6 c43 9 72 35 57 53 -15 18 -26 17 -56 -7 -18 -14 -41 -20 -73 -20 -46 0 -49 -2 -108 -69 -71 -79 -105 -97 -159 -84 -37 9 -38 10 -36 53 2 32 9 51 30 72 24 25 32 28 66 23 36 -7 41 -4 92 44 29 28 69 58 89 67 34 17 35 18 15 32 -17 12 -25 10 -74 -17 l-55 -31 0 25 c0 16 14 41 38 65 32 34 47 42 112 55 115 23 122 22 189 -39 65 -58 111 -82 137 -72 28 11 -3 36 -36 30 -23 -5 -37 2 -83 44 -31 28 -65 56 -77 62 l-22 12 27 28 c17 17 25 35 22 44 -9 24 -29 19 -61 -14 -38 -41 -75 -59 -144 -71 -31 -6 -69 -19 -84 -29 -26 -19 -27 -19 -53 2 -19 14 -41 21 -70 20 -40 0 -45 2 -54 30 -19 55 -14 77 23 95 34 16 34 16 78 -19 64 -52 79 -56 28 -8 -25 23 -39 38 -32 35 8 -4 36 5 63 20 50 27 111 34 161 20 23 -7 23 -7 -2 11 -15 11 -47 20 -85 22 -50 3 -68 -1 -108 -22 l-48 -26 -9 26 c-11 29 -13 82 -4 136 l7 38 30 -22 c44 -32 108 -28 161 10 45 33 56 56 31 66 -9 3 -34 -8 -61 -29 -72 -55 -124 -58 -147 -8 -9 21 -8 27 6 35 10 5 28 31 42 58 29 58 65 80 130 80 63 0 91 -17 152 -91 57 -71 94 -108 136 -134 15 -10 -31 42 -102 114 -89 90 -136 131 -151 131 -18 0 -21 4 -16 23 3 12 6 28 6 36 0 11 3 11 18 0 15 -13 16 -12 4 3 -9 11 -11 32 -6 66 l6 50 44 7 c24 3 48 4 53 1 4 -3 14 -32 20 -65 13 -67 38 -104 84 -127 l32 -15 -32 28 c-36 31 -47 56 -57 126 -5 32 -17 59 -36 81 -29 32 -29 34 -11 50 10 9 49 24 86 34 73 18 98 40 76 66 -12 14 -16 13 -42 -9 -16 -13 -46 -27 -67 -30 -85 -14 -127 -49 -104 -86 9 -15 8 -19 -9 -24 -29 -9 -36 2 -41 63 -4 40 -2 55 10 61 12 7 11 10 -6 22 -11 8 -27 31 -35 51 l-15 36 38 6 c22 3 51 11 66 16 25 10 26 12 11 27 -14 14 -19 14 -56 -1 -74 -29 -105 -16 -114 49 -1 11 -6 25 -10 31 -4 7 2 14 16 18 12 3 46 23 75 46 58 43 72 44 72 4 0 -55 -71 -90 -123 -62 -20 10 -21 10 -9 -5 18 -22 78 -40 107 -33 40 10 55 33 55 87 0 58 15 77 86 108 26 12 48 20 49 19 13 -16 78 -177 82 -203 3 -19 12 -40 21 -47 28 -23 7 69 -37 163 -31 65 -42 104 -46 152 l-5 65 37 7 c34 6 38 4 97 -59 34 -36 68 -66 76 -66 9 0 4 8 -12 18 -15 10 -40 36 -57 59 -16 22 -46 50 -66 63 l-37 23 30 29 c25 25 33 28 50 19 28 -15 42 -14 42 4 0 14 -4 17 -32 19 -7 1 -21 9 -31 19 -18 18 -19 18 -41 -2 -12 -12 -25 -21 -28 -21 -4 0 -8 26 -11 58 -3 31 -6 66 -8 77 -3 16 5 23 46 36 39 12 51 21 53 39 4 22 4 21 23 -8 19 -33 94 -92 113 -91 6 0 2 6 -8 12 -56 35 -77 63 -85 115 -9 61 -27 84 -98 124 -29 17 -53 36 -53 43 0 7 -2 19 -5 27 -4 10 21 13 119 15 116 2 125 1 131 -17 14 -43 97 -80 135 -60 l22 12 -22 15 c-16 11 -25 12 -33 4 -18 -18 -55 -13 -67 9 -15 28 14 126 43 144 12 8 49 17 82 21 126 13 137 16 124 31 -6 8 -17 13 -23 13 -6 -1 -57 -7 -114 -14z m-452 -117 c40 -71 40 -92 -1 -84 -17 3 -38 6 -46 6 -9 0 -13 7 -10 18 3 9 8 38 12 65 3 26 8 47 10 47 3 0 18 -24 35 -52z m130 -148 c26 -13 40 -27 40 -40 0 -12 -5 -17 -14 -14 -9 4 -15 -7 -20 -36 -7 -43 -9 -45 -69 -55 -34 -5 -38 -3 -47 21 -9 22 -6 34 15 70 14 23 25 50 25 58 0 22 23 20 70 -4z m-360 -926 c7 -19 9 -38 6 -41 -6 -6 -36 58 -36 77 0 22 19 -1 30 -36z m20 -274 c0 -33 -5 -35 -46 -20 -26 10 -26 11 -9 30 28 31 55 26 55 -10z m-15 -49 c3 -5 -2 -25 -11 -44 -12 -26 -25 -36 -45 -39 -22 -4 -29 -1 -29 13 0 9 7 19 15 23 11 4 14 13 9 31 -6 23 -3 25 24 25 17 0 33 -4 37 -9z m-210 -107 c-12 -19 -21 -23 -38 -18 -32 9 -32 12 -11 35 44 50 84 36 49 -17z m75 8 c-4 -20 -15 -52 -25 -71 -16 -33 -18 -34 -31 -16 -12 16 -11 20 6 32 14 10 20 26 20 52 0 42 6 52 25 45 8 -4 10 -16 5 -42z m-90 -244 c0 -14 -30 -48 -42 -48 -5 0 -2 14 7 30 16 30 35 40 35 18z m9 -129 c1 -9 -7 -19 -16 -22 -10 -2 -26 -8 -36 -12 -16 -7 -17 -4 -11 21 3 16 16 37 28 46 20 16 21 16 28 0 4 -9 7 -24 7 -33z m-109 -16 c0 -12 -33 -43 -47 -43 -21 0 -15 26 10 38 26 13 37 14 37 5z m-65 -94 c8 -29 13 -55 10 -57 -6 -6 -45 89 -45 111 0 35 20 5 35 -54z m106 -34 c24 -62 24 -60 -6 -73 -33 -15 -62 -15 -71 0 -5 7 -3 8 6 3 10 -6 12 2 7 41 -3 27 -9 57 -13 67 -6 16 -2 18 26 15 28 -3 34 -9 51 -53z m156 -85 c14 -21 5 -58 -19 -78 -12 -9 -19 -8 -34 3 -29 22 -57 18 -103 -15 -23 -17 -53 -35 -66 -40 -23 -9 -23 -9 -22 59 l2 68 33 -15 c27 -13 38 -14 67 -3 26 9 40 9 57 0 19 -9 21 -9 11 2 -18 18 -16 27 5 32 37 10 58 6 69 -13z m-19 -110 c28 -26 28 -55 1 -74 -16 -11 -28 -12 -47 -5 -53 20 -89 -13 -102 -97 -11 -68 -37 -99 -91 -108 -35 -6 -36 -6 -67 54 l-31 60 34 0 c21 0 44 9 63 25 18 15 42 25 62 25 28 0 31 2 20 15 -7 8 -18 15 -25 15 -24 0 -4 38 23 45 14 3 41 19 61 36 42 34 69 37 99 9z m-109 -382 c47 1 102 3 121 5 32 2 35 -1 45 -37 6 -21 25 -51 42 -66 30 -27 31 -28 9 -39 -11 -6 -33 -20 -48 -32 l-27 -22 -83 24 c-46 13 -90 31 -99 40 -16 16 -21 15 -67 -8 -36 -18 -59 -23 -80 -18 -41 9 -46 38 -17 105 13 30 26 67 29 83 l7 28 40 -33 c40 -32 43 -33 128 -30z m-189 -203 l44 -45 -29 0 c-17 0 -45 -4 -63 -9 -21 -6 -32 -6 -32 0 0 22 23 99 29 99 4 0 27 -20 51 -45z M3011 2121 c3 -32 11 -66 17 -77 8 -15 6 -30 -10 -65 -28 -60 -28 -133 0 -187 12 -24 20 -48 16 -53 -3 -5 -28 -22 -55 -38 l-49 -28 38 -53 c20 -29 43 -56 51 -58 7 -3 0 16 -17 41 l-32 45 41 20 c36 18 43 19 70 5 36 -18 51 -45 47 -86 -3 -31 -4 -32 -50 -32 -35 0 -53 -6 -71 -23 l-24 -22 -25 20 c-42 32 -62 25 -117 -41 -37 -45 -50 -67 -45 -81 3 -11 -1 -27 -9 -36 -51 -59 -65 -153 -30 -200 19 -25 19 -31 7 -55 -8 -14 -15 -39 -16 -54 -1 -15 -12 -36 -24 -45 -12 -10 -26 -37 -33 -61 -14 -57 -30 -69 -103 -78 -75 -9 -89 -21 -60 -52 38 -40 29 -62 -44 -111 -37 -25 -61 -47 -55 -51 18 -10 12 -25 -6 -18 -16 6 -16 5 -4 -10 7 -9 20 -17 27 -17 8 0 14 -9 14 -20 0 -33 35 -93 69 -117 38 -28 53 -30 18 -3 -27 22 -63 102 -53 119 4 5 -4 18 -17 28 l-24 18 51 31 51 31 28 -24 c33 -27 78 -33 57 -8 -7 9 -19 14 -26 11 -20 -8 -40 34 -23 46 30 20 115 58 132 58 11 0 15 5 11 11 -7 11 -86 8 -129 -6 -15 -4 -23 1 -31 19 -15 32 -10 36 42 36 67 0 91 14 103 57 16 55 27 71 42 56 9 -9 17 -8 35 4 36 23 51 10 73 -65 23 -76 45 -109 94 -144 l32 -22 -37 41 c-25 28 -43 64 -57 110 -21 71 -58 113 -98 113 -30 0 -1 18 47 29 34 7 75 6 163 -6 98 -14 126 -15 165 -4 57 15 60 17 40 37 -14 14 -18 14 -46 0 -18 -9 -55 -16 -89 -16 -106 0 -122 14 -84 76 23 38 91 73 160 81 44 5 49 8 39 21 -9 11 -25 13 -66 8 -68 -8 -119 -29 -157 -65 -28 -27 -32 -28 -62 -15 -29 12 -33 19 -33 53 0 23 8 48 20 64 18 22 25 25 46 17 43 -17 60 -12 130 36 82 56 131 67 162 34 14 -15 27 -20 41 -15 19 6 21 2 21 -35 0 -47 28 -82 84 -105 19 -8 41 -27 48 -42 13 -26 38 -43 38 -25 0 20 -42 61 -84 82 -49 23 -56 34 -56 91 0 27 -6 42 -19 51 -18 11 -18 12 5 31 13 10 31 31 38 45 8 15 26 40 41 56 25 27 25 30 10 45 -17 17 -23 12 -109 -94 -28 -35 -29 -36 -91 -31 -75 4 -156 30 -179 55 -16 18 -15 20 10 40 44 35 71 37 94 8 30 -38 166 -101 145 -67 -3 6 -26 15 -50 21 -61 15 -78 36 -63 77 8 20 23 35 42 42 37 13 117 13 152 0 39 -15 41 -4 2 17 -23 13 -56 18 -113 18 -75 1 -82 3 -110 31 -16 16 -41 37 -55 47 -14 10 -17 16 -8 13 11 -3 17 1 16 12 0 9 9 39 20 66 l19 49 31 -45 c31 -45 62 -77 62 -63 0 4 -22 37 -49 74 -27 37 -52 82 -56 99 -6 32 -6 32 32 32 21 0 44 4 52 9 14 9 98 -12 116 -30 5 -5 10 -36 10 -67 0 -65 15 -90 87 -148 56 -44 89 -45 123 0 51 67 158 84 171 28 10 -41 32 -60 89 -77 29 -8 57 -15 62 -15 14 1 -31 25 -67 35 -22 7 -40 21 -48 37 -24 51 -68 78 -126 78 -55 0 -84 -14 -124 -62 -33 -39 -64 -36 -101 9 -27 33 -31 45 -31 99 0 68 -5 76 -79 117 l-44 25 31 16 c39 20 71 20 100 0 13 -9 25 -14 27 -12 3 2 -15 16 -38 32 -52 34 -101 31 -161 -12 -53 -38 -86 -48 -97 -31 -4 7 -3 21 2 31 7 13 6 20 -3 23 -8 3 -13 22 -13 49 0 34 -6 51 -25 71 -49 53 -57 50 -49 -14z m36 -43 c-3 -7 -5 -2 -5 12 0 14 2 19 5 13 2 -7 2 -19 0 -25z m27 -170 c21 -29 20 -54 -3 -103 l-19 -40 -11 28 c-18 45 -11 137 10 137 4 0 15 -10 23 -22z m-144 -450 c-22 -21 -49 -38 -61 -38 -22 0 -22 0 -3 23 10 12 32 36 48 54 27 30 30 31 42 15 12 -15 8 -22 -26 -54z m118 -25 c54 -39 58 -43 42 -43 -16 1 -101 70 -87 70 5 0 25 -12 45 -27z m-3 -30 l34 -27 -52 -38 c-73 -55 -110 -50 -132 16 -12 34 -12 36 10 36 13 0 33 9 46 20 32 26 54 25 94 -7z m-181 -36 c8 -21 2 -48 -24 -127 -18 -55 -36 -100 -40 -100 -12 0 -30 50 -30 81 0 32 20 78 52 118 11 15 16 32 12 44 -10 30 19 15 30 -16z m61 -247 c7 0 15 -14 17 -30 l5 -31 -56 3 c-53 3 -56 4 -53 27 2 14 8 35 13 49 l9 23 26 -20 c15 -12 32 -21 39 -21z" fill="var(--logo-bg, #16140f)"/> + </g> +</symbol></svg> diff --git a/static/og-image.png b/static/og-image.png new file mode 100644 index 0000000..3af582f Binary files /dev/null and b/static/og-image.png differ diff --git a/static/papers/ball-occupation-paper.pdf b/static/papers/ball-occupation-paper.pdf new file mode 100644 index 0000000..5b926f2 Binary files /dev/null and b/static/papers/ball-occupation-paper.pdf differ diff --git a/static/papers/ball-occupation-paper.thumb.png b/static/papers/ball-occupation-paper.thumb.png new file mode 100644 index 0000000..0207f1f Binary files /dev/null and b/static/papers/ball-occupation-paper.thumb.png differ diff --git a/static/papers/branch-capture-paper.pdf b/static/papers/branch-capture-paper.pdf new file mode 100644 index 0000000..3dcaf08 Binary files /dev/null and b/static/papers/branch-capture-paper.pdf differ diff --git a/static/papers/branch-capture-paper.thumb.png b/static/papers/branch-capture-paper.thumb.png new file mode 100644 index 0000000..8a45944 Binary files /dev/null and b/static/papers/branch-capture-paper.thumb.png differ diff --git a/static/papers/growing-radius-domination-demo-output.csv b/static/papers/growing-radius-domination-demo-output.csv new file mode 100644 index 0000000..ca113ed --- /dev/null +++ b/static/papers/growing-radius-domination-demo-output.csv @@ -0,0 +1,8 @@ +d,h,dps,B_h,C,alpha_B_h,terminal_t,activity_ratio,minus_psi_over_exp_minus_C,psi_root,psi_direct,route_relative_disagreement,stationarity_residual,telescoping_residual,compact_residual,source_sha256 +3,12,90,12286.0,2.68891585501040013976745,2.68891585501040013976745,1.5649521817236902514912410604,0.43761910441011800655,0.38704929760879828092,-0.0263017693847861766925997,-0.0263017693847861766925997,8.69648997003e-88,8.28042160528e-171,5.58680599144e-250,1.49001860314e-84,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f +3,20,110,3145726.0,6.84510347814699949522998,6.84510347814699949522998,2.59233190755103757476376681408,0.58731966449413053705,0.64837167469537249632,-0.000690292834108247212221875,-0.000690292834108247212221875,2.66900925683e-105,1.14656253968e-538,5.53263399978e-965,1.17925528381e-108,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f +3,30,130,3221225470.0,12.6345230553746044619009,12.6345230553746044619009,4.24843522695731804549429347768,0.95204332641006628599,0.94176771565242100741,-0.00000306789261548327935842162,-0.00000306789261548327935842162,5.64116276476e-123,6.75776572086e-2301,4.05185949322e-4470,1.76048737805e-128,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f +3,40,160,3298534883326.0,18.7408224021000065352103,18.7408224021000065352103,6.25102199356755672760671381765,0.99562972566514808396,0.97573044303281005292,-7.08425360319143688355618e-9,-7.08425360319143688355618e-9,2.42607964143e-149,6.8404849326e-5339,-1.97357088308e-10516,1.14143107304e-157,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f +4,12,100,1062881.0,5.98590477925353559863557,5.98590477925353559863557,3.27430811180715697841994080308,0.68162323087349072876,0.72854684100301358049,-0.00183152168317342646402893,-0.00183152168317342646402893,6.57339657635e-97,5.62858267109e-629,-5.58589608171e-892,4.82863310106e-95,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f +4,20,130,6973568801.0,13.3028752886383745882347,13.3028752886383745882347,6.65792202872351594182004835315,0.99149489768252470055,0.97063642934364656966,-0.00000162065754589300041933,-0.00000162065754589300041933,4.39042055762e-122,1.83300664684e-3752,-4.99348900811e-5145,3.38121775554e-130,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f +4,28,160,45753584909921.0,21.1086850002637408299362,21.1086850002637408299362,10.5544288265371556225644335766,0.99990750838490069309,0.97999453596106802522,-6.66558450055970876728766e-10,-6.66558450055970876728766e-10,1.6950057681e-148,9.00394642419e-9922,2.57879588178e-13675,7.14127931035e-158,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f diff --git a/static/papers/growing-radius-domination-demo.py b/static/papers/growing-radius-domination-demo.py new file mode 100755 index 0000000..296611a --- /dev/null +++ b/static/papers/growing-radius-domination-demo.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +"""Reader-facing computations for growing-radius domination. + +This script accompanies the essay version of the preprint + + Near-Critical First-Moment Lower Bounds for + Growing-Radius Domination in Random Regular Graphs. + +It deliberately keeps the mathematical structure visible. The core steps are: + +1. compute the tree-ball volume B_h; +2. evaluate the conditioned local entropy s_d(a); +3. evaluate the exact compact microcanonical functional; +4. solve the unique stationary orbit by reverse transfer; +5. reconstruct density, activity, and free energy at the root; +6. target the near-critical coordinate C = alpha B_h; +7. gate every reported row by independent residual checks. + +Only ``mpmath`` is required for the main calculations. ``matplotlib`` is +optional and is used only by ``--plot``. + +The code is expository, not optimized for maximum throughput. Numerical +calculations illustrate the theorem and audit the identities; they are not +used in its proof. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import math +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +import mpmath as mp + + +# --------------------------------------------------------------------------- +# 1. Geometry and the expected scale +# --------------------------------------------------------------------------- + + +def tree_ball_volume(d: int, h: int) -> mp.mpf: + """Radius-h volume of the infinite d-regular tree.""" + if d < 3: + raise ValueError("d must be at least 3") + if h < 0: + raise ValueError("h must be nonnegative") + b = d - 1 + return mp.mpf(1) + d * (mp.power(b, h) - 1) / (d - 2) + + +def near_critical_coordinate(d: int, h: int, W: mp.mpf | None = None) -> mp.mpf: + """Return C = log B_h - 2 log log B_h - W. + + The demonstration default W=log log B_h stays a diverging distance below + the bounded critical window while remaining close enough to show the + predicted second-order term. + """ + B = tree_ball_volume(d, h) + L = mp.log(B) + if W is None: + W = mp.log(L) + return L - 2 * mp.log(L) - W + + +# --------------------------------------------------------------------------- +# 2. Exact local entropy and compact functional +# --------------------------------------------------------------------------- + + +def _conditioned_marginal(d: int, lam: mp.mpf) -> mp.mpf: + """Marginal of one coordinate in a nonempty tilted subset of [d].""" + log1p_lam = mp.log1p(lam) + numerator = lam * mp.exp((d - 1) * log1p_lam) + denominator = mp.expm1(d * log1p_lam) + return numerator / denominator + + +def lambda_from_marginal(d: int, a: mp.mpf) -> mp.mpf: + """Solve a = lambda(1+lambda)^(d-1)/((1+lambda)^d-1). + + A precision-scaled lower endpoint is important near the Moore corner, + where a-1/d can be exponentially small. + """ + a = mp.mpf(a) + lower = mp.mpf(1) / d + if a < lower or a > 1: + raise ValueError("a must lie in [1/d, 1]") + tol = mp.power(10, -(mp.mp.dps - 12)) + if abs(a - lower) <= tol: + return mp.mpf(0) + if abs(a - 1) <= tol: + return mp.inf + + # Bisection in log lambda avoids an enormous dynamic range. + lo = -mp.mpf(2) * mp.mp.dps * mp.log(10) + hi = mp.mpf(2) * mp.mp.dps * mp.log(10) + for _ in range(max(160, 3 * mp.mp.dps)): + mid = (lo + hi) / 2 + lam = mp.exp(mid) + if _conditioned_marginal(d, lam) < a: + lo = mid + else: + hi = mid + return mp.exp((lo + hi) / 2) + + +def conditioned_subset_entropy(d: int, a: mp.mpf) -> mp.mpf: + r"""Maximum entropy s_d(a) on nonempty subsets with marginal a. + + s_d(a) = inf_{lambda>0} [log((1+lambda)^d-1)-da log lambda]. + """ + a = mp.mpf(a) + lower = mp.mpf(1) / d + tol = mp.power(10, -(mp.mp.dps - 12)) + if abs(a - lower) <= tol: + return mp.log(d) + if abs(a - 1) <= tol: + return mp.mpf(0) + lam = lambda_from_marginal(d, a) + log_partition = mp.log(mp.expm1(d * mp.log1p(lam))) + return log_partition - d * a * mp.log(lam) + + +def _xlogx(x: mp.mpf) -> mp.mpf: + return mp.mpf(0) if x == 0 else x * mp.log(x) + + +def compact_microcanonical_value( + d: int, + h: int, + x: Sequence[mp.mpf], + ell: Sequence[mp.mpf], +) -> tuple[mp.mpf, mp.mpf]: + r"""Evaluate the exact compact functional. + + Coordinates: + x_i = q_{i-1,i}, i=1,...,h, + ell_i = q_{i,i}, i=0,...,h. + + The layer masses are p_i = ell_i + x_i + x_{i+1}, with x_0=x_{h+1}=0. + Returns (F, alpha=p_0). + """ + if len(x) != h or len(ell) != h + 1: + raise ValueError("wrong coordinate lengths") + xx = [mp.mpf(0)] + [mp.mpf(v) for v in x] + [mp.mpf(0)] + ll = [mp.mpf(v) for v in ell] + if any(v < 0 for v in xx) or any(v < 0 for v in ll): + raise ValueError("coordinates must be nonnegative") + + p = [ll[i] + xx[i] + xx[i + 1] for i in range(h + 1)] + if abs(sum(p) - 1) > mp.power(10, -(mp.mp.dps // 2)): + raise ValueError("profile is not normalized") + + alpha = p[0] + value = (d - 1) * _xlogx(alpha) + for i in range(1, h + 1): + if p[i] == 0: + continue + a_i = xx[i] / p[i] + lower = mp.mpf(1) / d + numerical_tol = mp.power(10, -(mp.mp.dps // 3)) + if a_i < lower - numerical_tol or a_i > 1 + numerical_tol: + raise ValueError("descent marginal is infeasible") + a_i = min(mp.mpf(1), max(lower, a_i)) + y_i = p[i] - xx[i] + value += -_xlogx(p[i]) + value += p[i] * conditioned_subset_entropy(d, a_i) + value += d * _xlogx(y_i) + value -= mp.mpf(d) / 2 * sum(_xlogx(v) for v in ll) + return value, alpha + + +# --------------------------------------------------------------------------- +# 3. Reverse transfer and stationary reconstruction +# --------------------------------------------------------------------------- + + +@dataclass +class StationaryPoint: + d: int + h: int + terminal_t: mp.mpf + terminal: mp.mpf + z: mp.mpf + alpha: mp.mpf + phi_direct: mp.mpf + psi_direct: mp.mpf + phi_root: mp.mpf + psi_root: mp.mpf + kappa: mp.mpf + r0: mp.mpf + Zv: mp.mpf + Ze: mp.mpf + telescoping_residual: mp.mpf + root_pressure_residual: mp.mpf + root_micro_residual: mp.mpf + stationarity_residual: mp.mpf + compact_residual: mp.mpf + rho: list[mp.mpf] + u: list[mp.mpf] + A: list[mp.mpf] + Bmsg: list[mp.mpf] + + +def _w_e(rho: mp.mpf, b: int) -> tuple[mp.mpf, mp.mpf]: + """Return w=(1-rho)^(1/b) and e=1-w without cancellation.""" + log_w = mp.log1p(-rho) / b + return mp.exp(log_w), -mp.expm1(log_w) + + +def reverse_step(rho_next: mp.mpf, v_next: mp.mpf, b: int) -> tuple[mp.mpf, mp.mpf]: + r"""Invert one stationary transfer step. + + Input lies in D={(rho,v): 0<rho<=v<=1}. The output is the unique + predecessor in D. + """ + w, e = _w_e(rho_next, b) + R = v_next * e / w + M = mp.power(e + w / v_next, b) + denominator = R + M + rho = R / denominator + v = (1 + R) / denominator + return rho, v + + +def _stable_power_difference(S: mp.mpf, B: mp.mpf, power: int) -> mp.mpf: + """Compute S^power-(S-B)^power stably when B/S is tiny.""" + ratio = B / S + return mp.power(S, power) * (-mp.expm1(power * mp.log1p(-ratio))) + + +def stationary_from_terminal_t( + d: int, h: int, terminal_t: mp.mpf, *, validate_compact: bool = True +) -> StationaryPoint: + r"""Construct the unique positive stationary orbit. + + We use t=-log(1-s) rather than s directly. At high density the terminal + parameter s is extraordinarily close to one, while t remains numerically + well scaled. + """ + if d < 3 or h < 1: + raise ValueError("require d>=3 and h>=1") + b = d - 1 + t = mp.mpf(terminal_t) + if t <= 0: + raise ValueError("terminal_t must be positive") + terminal = -mp.expm1(-t) + + rho = [mp.mpf(0)] * (h + 1) + v = [mp.mpf(0)] * (h + 1) + u = [mp.mpf(0)] * (h + 1) + rho[h] = v[h] = terminal + for i in range(h - 1, 0, -1): + rho[i], v[i] = reverse_step(rho[i + 1], v[i + 1], b) + u[i] = v[i] - rho[i] + u[h] = mp.mpf(0) + + w1, e1 = _w_e(rho[1], b) + if e1 == 0: + raise ArithmeticError("root coordinate under-resolved; increase mp.dps") + r0 = v[1] * e1 / w1 + kappa = mp.power(v[1] / w1, b) + z = r0 * kappa / mp.power(1 + r0, b) + + # Normalize A_1=1 and reconstruct the messages. + A = [mp.mpf(0)] * (h + 2) + Bmsg = [mp.mpf(0)] * (h + 1) + A[1] = mp.mpf(1) + for i in range(1, h): + A[i + 1] = u[i] * A[i] + Bmsg[0] = r0 + for i in range(1, h + 1): + Bmsg[i] = rho[i] * A[i] + + S = [mp.mpf(0)] * (h + 1) + S[0] = Bmsg[0] + A[1] + for i in range(1, h): + S[i] = Bmsg[i - 1] + Bmsg[i] + A[i + 1] + S[h] = Bmsg[h - 1] + Bmsg[h] + + vertex_terms = [z * mp.power(S[0], d)] + for i in range(1, h + 1): + vertex_terms.append(_stable_power_difference(S[i], Bmsg[i - 1], d)) + Zv = mp.fsum(vertex_terms) + Ze = mp.fsum(v * v for v in Bmsg) + 2 * mp.fsum( + Bmsg[i] * A[i + 1] for i in range(h) + ) + + alpha = Bmsg[0] * (Bmsg[0] + A[1]) / Ze + phi_direct = mp.log(Zv) - mp.mpf(d) / 2 * mp.log(Ze) + psi_direct = phi_direct - alpha * mp.log(z) + + # Correct, well-conditioned root-only formulas. + phi_root = ( + mp.log(z) + + mp.mpf(d) / 2 * mp.log((1 + r0) / r0) + + mp.mpf(d - 2) / 2 * mp.log(alpha) + ) + psi_root = ( + (1 - alpha) * mp.log(kappa) + - (mp.mpf(d - 2) / 2 + alpha) * mp.log(r0) + + mp.mpf(d - 2) / 2 * mp.log(alpha) + + (mp.mpf(d - 1) * alpha - mp.mpf(d - 2) / 2) * mp.log(1 + r0) + ) + + stationarity = [abs(kappa * Bmsg[0] - z * mp.power(S[0], b))] + for i in range(1, h + 1): + stationarity.append(abs(kappa * A[i] - mp.power(S[i], b))) + stationarity.append( + abs( + kappa * Bmsg[i] + - _stable_power_difference(S[i], Bmsg[i - 1], b) + ) + ) + + # Reconstruct the compact profile only for final diagnostic points. + if validate_compact: + x = [Bmsg[i - 1] * A[i] / Ze for i in range(1, h + 1)] + ell = [Bmsg[i] * Bmsg[i] / Ze for i in range(h + 1)] + compact_value, compact_alpha = compact_microcanonical_value(d, h, x, ell) + compact_residual = max(abs(compact_value - psi_root), abs(compact_alpha - alpha)) + else: + compact_residual = mp.nan + + return StationaryPoint( + d=d, + h=h, + terminal_t=t, + terminal=terminal, + z=z, + alpha=alpha, + phi_direct=phi_direct, + psi_direct=psi_direct, + phi_root=phi_root, + psi_root=psi_root, + kappa=kappa, + r0=r0, + Zv=Zv, + Ze=Ze, + telescoping_residual=Zv - kappa * Ze, + root_pressure_residual=phi_direct - phi_root, + root_micro_residual=psi_direct - psi_root, + stationarity_residual=max(stationarity), + compact_residual=compact_residual, + rho=rho, + u=u, + A=A, + Bmsg=Bmsg, + ) + + +# --------------------------------------------------------------------------- +# 4. Target-density solving and residual gates +# --------------------------------------------------------------------------- + + +def solve_for_alpha_B( + d: int, + h: int, + C: mp.mpf, + *, + iterations: int | None = None, +) -> StationaryPoint: + """Solve alpha B_h = C by bisection in terminal_t.""" + B = tree_ball_volume(d, h) + target_alpha = mp.mpf(C) / B + if not 0 < target_alpha < 1: + raise ValueError("target density must lie in (0,1)") + if iterations is None: + iterations = max(140, 2 * mp.mp.dps) + + D = mp.mpf(d) / (d - 2) + lo = mp.mpf("0.05") * C / D + hi = mp.mpf("3.0") * C / D + 1 + while stationary_from_terminal_t(d, h, lo, validate_compact=False).alpha > target_alpha: + lo /= 2 + while stationary_from_terminal_t(d, h, hi, validate_compact=False).alpha < target_alpha: + hi *= 2 + + for _ in range(iterations): + mid = (lo + hi) / 2 + point = stationary_from_terminal_t(d, h, mid, validate_compact=False) + if point.alpha < target_alpha: + lo = mid + else: + hi = mid + return stationary_from_terminal_t(d, h, (lo + hi) / 2, validate_compact=True) + + +def relative_residual(residual: mp.mpf, reference: mp.mpf) -> mp.mpf: + return abs(residual) / max(abs(reference), mp.mpf(1)) + + +def gate_point(point: StationaryPoint, route_tolerance: str = "1e-6") -> None: + """Reject a row if an internal cross-check is too large to ignore. + + The direct partition-function route can be ill conditioned near criticality. + The root-only formula is the reported value, but the direct route must still + agree to the requested relative tolerance at the working precision. + """ + tol = mp.mpf(route_tolerance) + if point.psi_root == 0: + raise RuntimeError("zero root-formula microcanonical exponent") + route_disagreement = abs(point.root_micro_residual / point.psi_root) + if route_disagreement > tol: + raise RuntimeError( + "microcanonical routes disagree: " + f"relative discrepancy={route_disagreement}; increase mp.dps" + ) + if point.compact_residual > mp.sqrt(tol): + raise RuntimeError( + f"compact-functional reconstruction failed: {point.compact_residual}" + ) + + +def diagnostic_row(d: int, h: int, W: mp.mpf | None = None) -> dict[str, str | int]: + """Solve one near-critical case and return an audit-friendly record.""" + B = tree_ball_volume(d, h) + L = mp.log(B) + if W is None: + W = mp.log(L) + C = near_critical_coordinate(d, h, W) + point = solve_for_alpha_B(d, h, C) + gate_point(point) + + coupon = B * mp.power(1 - point.alpha, B) + activity_ratio = (-mp.log(point.z) - mp.log(1 / point.alpha)) / coupon + scale = mp.exp(-C) + route_disagreement = abs(point.root_micro_residual / point.psi_root) + + return { + "d": d, + "h": h, + "dps": mp.mp.dps, + "B_h": mp.nstr(B, 30), + "C": mp.nstr(C, 24), + "alpha_B_h": mp.nstr(point.alpha * B, 24), + "terminal_t": mp.nstr(point.terminal_t, 30), + "activity_ratio": mp.nstr(activity_ratio, 20), + "minus_psi_over_exp_minus_C": mp.nstr(-point.psi_root / scale, 20), + "psi_root": mp.nstr(point.psi_root, 24), + "psi_direct": mp.nstr(point.psi_direct, 24), + "route_relative_disagreement": mp.nstr(route_disagreement, 12), + "stationarity_residual": mp.nstr(point.stationarity_residual, 12), + "telescoping_residual": mp.nstr(point.telescoping_residual, 12), + "compact_residual": mp.nstr(point.compact_residual, 12), + "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + } + + +# --------------------------------------------------------------------------- +# 5. Tables and optional plotting +# --------------------------------------------------------------------------- + + +def default_cases() -> list[tuple[int, int, int]]: + """(d,h,dps) cases chosen to keep a demonstration run manageable.""" + return [ + (3, 12, 90), + (3, 20, 110), + (3, 30, 130), + (3, 40, 160), + (4, 12, 100), + (4, 20, 130), + (4, 28, 160), + ] + + +def make_table( + cases: Iterable[tuple[int, int, int]], + *, + csv_path: str | None = None, +) -> list[dict[str, str | int]]: + rows: list[dict[str, str | int]] = [] + for d, h, dps in cases: + mp.mp.dps = dps + row = diagnostic_row(d, h) + rows.append(row) + print( + f"d={d} h={h:>2} C={row['C']} " + f"activity={row['activity_ratio']} " + f"-Psi/e^-C={row['minus_psi_over_exp_minus_C']}" + ) + if csv_path: + with open(csv_path, "w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + return rows + + +def plot_rows(rows: Sequence[dict[str, str | int]], output: str) -> None: + """Plot the two diagnostic ratios; matplotlib is optional.""" + try: + import matplotlib.pyplot as plt + except ImportError as exc: + raise RuntimeError("install matplotlib to use --plot") from exc + + by_d: dict[int, list[dict[str, str | int]]] = {} + for row in rows: + by_d.setdefault(int(row["d"]), []).append(row) + + # Separate figures keep the diagnostics readable. + for field, ylabel, suffix in [ + ("activity_ratio", "activity-law ratio", "activity"), + ("minus_psi_over_exp_minus_C", r"$-\Psi/e^{-C}$", "free_energy"), + ]: + plt.figure() + for d, group in sorted(by_d.items()): + group = sorted(group, key=lambda r: int(r["h"])) + plt.plot( + [int(r["h"]) for r in group], + [float(r[field]) for r in group], + marker="o", + label=f"d={d}", + ) + plt.axhline(1.0, linestyle="--") + plt.xlabel("radius h") + plt.ylabel(ylabel) + plt.legend() + plt.tight_layout() + path = str(Path(output).with_name(Path(output).stem + f"_{suffix}.png")) + plt.savefig(path, dpi=180) + plt.close() + print(path) + + +# --------------------------------------------------------------------------- +# 6. Command line interface +# --------------------------------------------------------------------------- + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--d", type=int, default=3) + parser.add_argument("--h", type=int, default=20) + parser.add_argument("--dps", type=int, default=110) + parser.add_argument( + "--W", + type=str, + default=None, + help="additive gap W; default is log log B_h", + ) + parser.add_argument("--table", action="store_true", help="run the curated table") + parser.add_argument("--csv", type=str, default=None, help="optional CSV output") + parser.add_argument("--plot", type=str, default=None, help="plot prefix for table output") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.table: + rows = make_table(default_cases(), csv_path=args.csv) + if args.plot: + plot_rows(rows, args.plot) + return + + mp.mp.dps = args.dps + W = None if args.W is None else mp.mpf(args.W) + row = diagnostic_row(args.d, args.h, W) + for key, value in row.items(): + print(f"{key}: {value}") + + +if __name__ == "__main__": + main() diff --git a/static/papers/growing-radius-domination-paper.pdf b/static/papers/growing-radius-domination-paper.pdf new file mode 100644 index 0000000..91fa57b Binary files /dev/null and b/static/papers/growing-radius-domination-paper.pdf differ diff --git a/static/papers/growing-radius-domination-paper.thumb.png b/static/papers/growing-radius-domination-paper.thumb.png new file mode 100644 index 0000000..ae21c1c Binary files /dev/null and b/static/papers/growing-radius-domination-paper.thumb.png differ diff --git a/static/papers/near-critical-growing-radius-domination-paper.pdf b/static/papers/near-critical-growing-radius-domination-paper.pdf new file mode 100644 index 0000000..095e5e6 Binary files /dev/null and b/static/papers/near-critical-growing-radius-domination-paper.pdf differ diff --git a/static/papers/near-critical-growing-radius-domination-paper.thumb.png b/static/papers/near-critical-growing-radius-domination-paper.thumb.png new file mode 100644 index 0000000..aa6e2ea Binary files /dev/null and b/static/papers/near-critical-growing-radius-domination-paper.thumb.png differ diff --git a/static/resume.pdf b/static/resume.pdf new file mode 100644 index 0000000..b8ad75d Binary files /dev/null and b/static/resume.pdf differ diff --git a/static/resume.thumb.png b/static/resume.thumb.png new file mode 100644 index 0000000..04d8122 Binary files /dev/null and b/static/resume.thumb.png differ diff --git a/static/site.webmanifest b/static/site.webmanifest new file mode 100644 index 0000000..8b20b3a --- /dev/null +++ b/static/site.webmanifest @@ -0,0 +1,36 @@ +{ + "name": "Levi Neuwirth", + "short_name": "ln", + "description": "Personal site of Levi Neuwirth — essays, research, music, and photography.", + "start_url": "/", + "scope": "/", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#16140f", + "background_color": "#16140f", + "display": "standalone" +} diff --git a/static/templates/epistemic-figure-reference.svg b/static/templates/epistemic-figure-reference.svg new file mode 100644 index 0000000..dbdadc9 --- /dev/null +++ b/static/templates/epistemic-figure-reference.svg @@ -0,0 +1,42 @@ +<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Epistemic figure reference: skeleton (no field data)"> + <!-- Reference skeleton for the epistemic figure generator + (build/Marks.hs). Renders the parts that are present whether + or not frontmatter fields are set: the outer roundel, the + four guide circles, the six radial axes, and the stability- + tick scale at 12 o'clock (all five ticks at "inactive" + opacity 0.4). The polygon, vertex marks, trust label, and + result-shape glyph are absent from this skeleton; they + appear only when frontmatter supplies values. + + Used as a visual-regression baseline: compare against + generator output stripped of data-bearing elements. The + coordinate values mirror MARKS.md §3.3 exactly. --> + + <!-- Outer roundel: r=88 inner, r=90 outer, stroke 0.5 --> + <circle cx="100.00" cy="100.00" r="88.00" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.7"/> + <circle cx="100.00" cy="100.00" r="90.00" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.7"/> + + <!-- Inner guide circles at 0.2, 0.4, 0.6, 0.8 of axis radius (R=80) --> + <circle cx="100.00" cy="100.00" r="16.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/> + <circle cx="100.00" cy="100.00" r="32.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/> + <circle cx="100.00" cy="100.00" r="48.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/> + <circle cx="100.00" cy="100.00" r="64.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/> + + <!-- Six radial axes: 0° confidence, 60° novelty, 120° practicality, + 180° scope, 240° evidence, 300° importance --> + <line x1="100.00" y1="100.00" x2="100.00" y2="20.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/> + <line x1="100.00" y1="100.00" x2="169.28" y2="60.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/> + <line x1="100.00" y1="100.00" x2="169.28" y2="140.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/> + <line x1="100.00" y1="100.00" x2="100.00" y2="180.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/> + <line x1="100.00" y1="100.00" x2="30.72" y2="140.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/> + <line x1="100.00" y1="100.00" x2="30.72" y2="60.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/> + + <!-- Stability tick scale at 12 o'clock; all five at inactive + opacity 0.4 in this skeleton (no frontmatter → no live + stability label). --> + <line x1="100.00" y1="10.00" x2="100.00" y2="8.50" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/> + <line x1="76.71" y1="11.55" x2="75.32" y2="10.13" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/> + <line x1="123.29" y1="11.55" x2="124.68" y2="10.13" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/> + <line x1="55.00" y1="22.06" x2="53.70" y2="19.81" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/> + <line x1="145.00" y1="22.06" x2="146.30" y2="19.81" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/> +</svg> diff --git a/static/templates/mark-template.svg b/static/templates/mark-template.svg new file mode 100644 index 0000000..c9cc042 --- /dev/null +++ b/static/templates/mark-template.svg @@ -0,0 +1,26 @@ +<svg viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mark-template-title"> + <title id="mark-template-title">Frontmatter monogram template + A reference monogram demonstrating the §2.2 visual contract: outer roundel, currentColor strokes, no fills except small filled point-marks, round line caps. Authors copy this file to content/{section}/{slug}/mark.svg (or content/{section}/{slug}.mark.svg for flat-form pieces) and replace the inner geometry with a glyph abstracted from their work's central concept. + + + + + + + + + + + + + + + + diff --git a/static/web-app-manifest-192x192.png b/static/web-app-manifest-192x192.png new file mode 100644 index 0000000..a146b54 Binary files /dev/null and b/static/web-app-manifest-192x192.png differ diff --git a/static/web-app-manifest-512x512.png b/static/web-app-manifest-512x512.png new file mode 100644 index 0000000..c634c9d Binary files /dev/null and b/static/web-app-manifest-512x512.png differ diff --git a/systemd/archive-check.service b/systemd/archive-check.service new file mode 100644 index 0000000..6f9d401 --- /dev/null +++ b/systemd/archive-check.service @@ -0,0 +1,20 @@ +# levineuwirth.org link-rot scan — runs `make archive-check` in the repo. +# +# Installed as a symlink into this repo (so edits here take effect after +# `systemctl --user daemon-reload`): +# +# systemctl --user link ~/Repos/personal/levineuwirth.org/systemd/archive-check.service +# systemctl --user enable --now ~/Repos/personal/levineuwirth.org/systemd/archive-check.timer +# +# The scan only updates the gitignored data/archive-state.json; the next +# `make deploy` consumes it. See ARCHIVE.md — Link-rot detection. + +[Unit] +Description=levineuwirth.org link-rot scan (archive.py check) +Documentation=file://%h/Repos/personal/levineuwirth.org/ARCHIVE.md + +[Service] +Type=oneshot +WorkingDirectory=%h/Repos/personal/levineuwirth.org +ExecStart=/usr/bin/make archive-check +Nice=10 diff --git a/systemd/archive-check.timer b/systemd/archive-check.timer new file mode 100644 index 0000000..93d2a40 --- /dev/null +++ b/systemd/archive-check.timer @@ -0,0 +1,18 @@ +# Daily trigger for archive-check.service. See that unit's header for +# the install commands. + +[Unit] +Description=Daily levineuwirth.org link-rot scan + +[Timer] +# Daily: rot needs 3 consecutive fails spanning >=14 days, so a daily +# scan gives the design's minimum detection latency (14 days) at one +# polite HEAD per cited host per day. Persistent=true fires a missed +# scan at the next login; the canary guard in archive.py makes a fire +# while offline harmless (scan skipped, state untouched). +OnCalendar=*-*-* 12:00 +RandomizedDelaySec=30min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/systemd/forgejo-backup.service b/systemd/forgejo-backup.service new file mode 100644 index 0000000..6a1df18 --- /dev/null +++ b/systemd/forgejo-backup.service @@ -0,0 +1,26 @@ +# Nightly Forgejo backup on the VPS. Unlike archive-check.service, which is +# a --user unit on the laptop, this is a system unit on the server: +# +# scp tools/forgejo-backup.sh root@:/usr/local/bin/forgejo-backup.sh +# ssh root@ chmod 755 /usr/local/bin/forgejo-backup.sh +# scp systemd/forgejo-backup.{service,timer} root@:/etc/systemd/system/ +# ssh root@ systemctl daemon-reload +# ssh root@ systemctl enable --now forgejo-backup.timer +# +# The script aborts non-zero on a failed integrity check or an unreadable +# archive, so a bad run shows up as a failed unit rather than as a corrupt +# file that quietly displaces a good one. + +[Unit] +Description=Forgejo backup (consistent SQLite snapshot + data tarball) +Documentation=https://git.levineuwirth.org/neuwirth/levineuwirth.org +# The script checks for the container itself and fails loudly if it is +# absent, but ordering after docker avoids a guaranteed-failed run at boot. +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/forgejo-backup.sh +Nice=15 +IOSchedulingClass=idle diff --git a/systemd/forgejo-backup.timer b/systemd/forgejo-backup.timer new file mode 100644 index 0000000..209e0e3 --- /dev/null +++ b/systemd/forgejo-backup.timer @@ -0,0 +1,20 @@ +# Nightly trigger for forgejo-backup.service. See that unit's header for +# the install commands. + +[Unit] +Description=Nightly Forgejo backup + +[Timer] +# 03:30 UTC: after any realistic evening of work, before the morning. The +# backup does not stop the service, so the hour matters less than being +# consistently outside the window where a push might be mid-flight. +OnCalendar=*-*-* 03:30 +RandomizedDelaySec=20min +# The VPS is not always up (it rebooted this morning). Persistent=true runs +# a missed backup shortly after the next boot instead of silently skipping +# the night — the failure mode that turns "we have nightly backups" into +# "we had nightly backups until the reboot". +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/templates/archive-index.html b/templates/archive-index.html new file mode 100644 index 0000000..0e6aba8 --- /dev/null +++ b/templates/archive-index.html @@ -0,0 +1,23 @@ +
        +
        +
        +

        $title$

        +

        Local snapshots of works referenced across the site, preserved against link rot. Each is an archived copy; the original is linked prominently from its page.

        +
        + + $if(has-entries)$ +
          + $for(entries)$ +
        • + $entry-title$ + $entry-type$ · archived $entry-archived$$if(entry-degraded)$ · $entry-quality$ capture$endif$$if(entry-private)$ · private$endif$$if(entry-rotted)$ · link rotted$endif$ +
        • + $endfor$ +
        + $else$ +

        Nothing archived yet.

        + $endif$ + + $partial("templates/partials/archive-removal-notice.html")$ +
        +
        diff --git a/templates/archive.html b/templates/archive.html new file mode 100644 index 0000000..d2e1fd5 --- /dev/null +++ b/templates/archive.html @@ -0,0 +1,109 @@ +
        +
        +
        +
        +

        $title$

        + $partial("templates/partials/archive-banner.html")$ + $if(status-note)$ +

        + $status-note$ +

        + $endif$ + $if(degraded)$ +

        + Capture: $snapshot-quality$ + Some of the original's content (images, scripted elements) + may be missing or incomplete in this snapshot. The original + is linked above. +

        + $endif$ +
        + +
        +

        Provenance

        +
        +
        Original
        +
        $original-url$
        +
        Link status
        +
        $status$
        +
        Archived
        +
        $archived$
        +
        Type
        +
        $archive-type$
        +
        Snapshot quality
        +
        $snapshot-quality$
        +
        Size
        +
        $size$
        +
        SHA-256
        +
        $sha-short$…
        + $if(wayback)$ +
        Wayback
        +
        web.archive.org copy
        + $endif$ + $if(paywalled)$ +
        Access
        +
        The original sits behind a paywall.
        + $endif$ + $if(private)$ +
        Visibility
        +
        private — held offline
        + $endif$ +
        +
        + + $if(note)$

        $note$

        $endif$ + + $if(private)$ +

        + This work is archived privately: a local + preservation copy is kept against link rot, but the artifact + is not published here. Use the original link above to read it. +

        + $else$ +
        +
        + $artifact-name$ + Open raw ↗ +
        + $if(is-pdf)$ + + $endif$ + $if(is-html)$ + + $endif$ +
        + $endif$ + + $if(fulltext)$ + $if(is-pdf)$ +
        + Full text (extracted) + $fulltext$ +
        + $endif$ + $if(is-html)$ +
        +

        Readable text (extracted)

        + $fulltext$ +
        + $endif$ + $endif$ + + $if(referenced-by)$ + + $endif$ + + $if(similar-links)$ + + $endif$ + + $partial("templates/partials/archive-removal-notice.html")$ +
        +
        +
        diff --git a/templates/author-index.html b/templates/author-index.html new file mode 100644 index 0000000..a635a44 --- /dev/null +++ b/templates/author-index.html @@ -0,0 +1,26 @@ +
        +

        $author$

        + $if(items)$ +
          + $for(items)$ +
        • + $title$ + $if(date)$ + $date$ + $endif$ + $if(abstract)$ +

          $abstract$

          + $endif$ + $if(item-tags)$ +
          + $for(item-tags)$$tag-name$$endfor$ +
          + $endif$ +
        • + $endfor$ +
        + $else$ +

        No items yet.

        + $endif$ + $partial("templates/partials/paginate-nav.html")$ +
        diff --git a/templates/bibliography-index.html b/templates/bibliography-index.html new file mode 100644 index 0000000..2f16e13 --- /dev/null +++ b/templates/bibliography-index.html @@ -0,0 +1,9 @@ +
        +

        Bibliography

        + + + +$bibliography-entries$ +
        diff --git a/templates/bibliography-keyword.html b/templates/bibliography-keyword.html new file mode 100644 index 0000000..649835d --- /dev/null +++ b/templates/bibliography-keyword.html @@ -0,0 +1,39 @@ +
        +

        $title$

        + + + + + +$if(portal-intro)$ +
        + $portal-intro$ +
        +$endif$ + +$if(has-writings)$ +
        +

        Writings

        +
          +$for(writings)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
        +
        +$endif$ + +$if(references)$ +
        +

        References

        +$references$ +
        +$endif$ +
        diff --git a/templates/blog-index.html b/templates/blog-index.html new file mode 100644 index 0000000..1978852 --- /dev/null +++ b/templates/blog-index.html @@ -0,0 +1,17 @@ +
        +

        $title$

        +
          + $for(posts)$ +
        • + $title$ + $if(date)$ + + $endif$ + $if(abstract)$ +

          $abstract$

          + $endif$ +
        • + $endfor$ +
        + $partial("templates/partials/paginate-nav.html")$ +
        diff --git a/templates/blog-post.html b/templates/blog-post.html new file mode 100644 index 0000000..965c2d0 --- /dev/null +++ b/templates/blog-post.html @@ -0,0 +1,54 @@ +
        +
        $monogramSvg$
        +
        +

        $title$

        + $if(subtitle)$

        $subtitle$

        $endif$ + $if(date)$ + + $endif$ +
        + $if(epistemicSvg)$ + + $endif$ +
        +
        + $body$ + $if(backlinks)$ +
        + $elseif(similar-links)$ +
        + $elseif(status)$ +
        + $endif$ + $if(backlinks)$ + + $endif$ + $if(similar-links)$ + + $endif$ + $if(status)$ + + $endif$ + $if(backlinks)$ +
        + $elseif(similar-links)$ +
        + $elseif(status)$ +
        + $endif$ +
        diff --git a/templates/commonplace.html b/templates/commonplace.html new file mode 100644 index 0000000..c2b9c60 --- /dev/null +++ b/templates/commonplace.html @@ -0,0 +1,35 @@ +
        +

        $title$

        +
        $body$
        + + $cp-themed-html$ + $cp-chrono-html$ +
        +$partial("templates/partials/page-footer.html")$ + diff --git a/templates/composition.html b/templates/composition.html new file mode 100644 index 0000000..4fb442c --- /dev/null +++ b/templates/composition.html @@ -0,0 +1,75 @@ +
        + +
        +

        $title$

        + + $if(recording)$ +
        + +
        + $endif$ + $if(has-movements)$ +
        + $for(movements)$ +
        +
        + $movement-name$ + $movement-duration$ + $if(movement-score-url)$ + Score ↗ + $endif$ +
        + $if(has-audio)$ + + $endif$ +
        + $endfor$ +
        + $endif$ + + $body$ +
        +
        +$partial("templates/partials/page-footer.html")$ diff --git a/templates/current.html b/templates/current.html new file mode 100644 index 0000000..7a366ae --- /dev/null +++ b/templates/current.html @@ -0,0 +1,15 @@ +
        +
        +

        $title$

        +

        + Last updated + + $if(now-last-updated-relative)$$now-last-updated-relative$$endif$ +

        +
        + + $if(body)$
        $body$
        $endif$ + + $now-entries-html$ + $now-shipped-html$ +
        diff --git a/templates/default.html b/templates/default.html new file mode 100644 index 0000000..bfd6e8c --- /dev/null +++ b/templates/default.html @@ -0,0 +1,44 @@ + + + +$partial("templates/partials/head.html")$ + + + +$partial("templates/partials/nav.html")$ +$if(search)$ + + + + +$endif$ +
        +$body$ +$partial("templates/partials/footer.html")$ +
        + + + + + + + + + + + +$if(home)$$endif$ +$if(reading)$$endif$ +$if(now)$$endif$ +$if(photography)$$endif$ +$if(photography)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ +$for(page-scripts)$$endfor$ +$if(math)$ + + +$endif$ + + diff --git a/templates/essay-index.html b/templates/essay-index.html new file mode 100644 index 0000000..8156b1b --- /dev/null +++ b/templates/essay-index.html @@ -0,0 +1,16 @@ +
        +

        $title$

        +
          + $for(essays)$ +
        • + $title$ + $if(date-created)$ + $date-created$ + $endif$ + $if(abstract)$ +

          $abstract$

          + $endif$ +
        • + $endfor$ +
        +
        diff --git a/templates/essay.html b/templates/essay.html new file mode 100644 index 0000000..7191dcf --- /dev/null +++ b/templates/essay.html @@ -0,0 +1,43 @@ +
        +
        $monogramSvg$
        +
        +

        $title$

        + $if(subtitle)$

        $subtitle$

        $endif$ + $partial("templates/partials/metadata-header.html")$ +
        + $if(epistemicSvg)$ + + $endif$ +
        +
        + $partial("templates/partials/metadata-tail.html")$ + $if(summary)$ +
        +
        Summary
        + $summary$ +
        + $endif$ +
        + +
        + +
        + $body$ +
        +
        + +$partial("templates/partials/page-footer.html")$ diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..8acfdb6 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,10 @@ +
        +$body$ + +
        diff --git a/templates/library.html b/templates/library.html new file mode 100644 index 0000000..288abda --- /dev/null +++ b/templates/library.html @@ -0,0 +1,140 @@ +
        +

        Library

        + +$if(library-intro)$ +
        +$library-intro$ +
        +$endif$ + + + +$if(research-entries)$ +
        +

        Research

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

        More on this shelf →

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

        Nonfiction

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

        More on this shelf →

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

        Fiction

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

        More on this shelf →

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

        Poetry

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

        More on this shelf →

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

        Music

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

        More on this shelf →

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

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

        More on this shelf →

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

        Tech

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

        More on this shelf →

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

        Miscellany

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

        More on this shelf →

        +$endif$ +
        +$endif$ + +
        diff --git a/templates/music-catalog.html b/templates/music-catalog.html new file mode 100644 index 0000000..982ab7d --- /dev/null +++ b/templates/music-catalog.html @@ -0,0 +1,20 @@ +
        +

        $title$

        + $if(abstract)$ +

        $abstract$

        + $endif$ + $if(body)$ +
        + $body$ +
        + $endif$ + $if(has-featured)$ + + $endif$ +
        + $catalog-by-category$ +
        +
        diff --git a/templates/new.html b/templates/new.html new file mode 100644 index 0000000..54a414f --- /dev/null +++ b/templates/new.html @@ -0,0 +1,9 @@ +
        +

        New

        + $partial("templates/partials/list-controls.html")$ +
          + $for(recent-items)$ + $partial("templates/partials/item-card.html")$ + $endfor$ +
        +
        diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..b4bc5f3 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,4 @@ +
        +

        $title$

        + $body$ +
        diff --git a/templates/partials/archive-banner.html b/templates/partials/archive-banner.html new file mode 100644 index 0000000..d08b3ec --- /dev/null +++ b/templates/partials/archive-banner.html @@ -0,0 +1,5 @@ +
        +

        Archived copy

        +

        A local preservation snapshot taken $archived$ — this page is not the original.

        + View the original ↗ +
        diff --git a/templates/partials/archive-removal-notice.html b/templates/partials/archive-removal-notice.html new file mode 100644 index 0000000..c34a0f5 --- /dev/null +++ b/templates/partials/archive-removal-notice.html @@ -0,0 +1,5 @@ +

        + This is an archived copy, preserved so that a work cited across the site + survives the original going dark. To request removal, email + ln@levineuwirth.org. +

        diff --git a/templates/partials/footer.html b/templates/partials/footer.html new file mode 100644 index 0000000..ba22913 --- /dev/null +++ b/templates/partials/footer.html @@ -0,0 +1,14 @@ + diff --git a/templates/partials/head.html b/templates/partials/head.html new file mode 100644 index 0000000..e3e2072 --- /dev/null +++ b/templates/partials/head.html @@ -0,0 +1,81 @@ + + +$if(home)$Levi Neuwirth$else$$if(title)$$title$ — Levi Neuwirth$else$Levi Neuwirth$endif$$endif$ +$if(description)$$endif$ +$if(noindex)$$endif$ + + + + + + + +$if(home)$$else$$if(work)$$else$$if(title)$$endif$$endif$$endif$ +$if(description)$$endif$ + +$if(date)$$else$$endif$ + + + + +$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$ +$if(catalog)$$endif$ +$if(commonplace)$$endif$ +$if(now)$$endif$ +$if(now)$$endif$ +$if(vita)$$endif$ +$if(vita)$$endif$ +$if(work)$$endif$ +$if(build)$$endif$ +$if(reading)$$endif$ +$if(composition)$$endif$ +$if(photography)$$endif$ +$if(archive)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ + +$if(math)$ + +$endif$ +$if(search)$ + +$endif$ + + +$if(viz)$ + + + + + +$endif$ +$if(list-page)$$endif$ + + + diff --git a/templates/partials/item-card.html b/templates/partials/item-card.html new file mode 100644 index 0000000..8674281 --- /dev/null +++ b/templates/partials/item-card.html @@ -0,0 +1,19 @@ +
      1. + $item-kind$ + $if(has-monogram)$ + + $endif$ +
        +
        + $title$ + +
        + $if(abstract)$

        $abstract$

        $endif$ + $if(revision-note)$

        $revision-note$

        $endif$ + $if(item-tags)$ +
        + $for(item-tags)$$tag-name$$endfor$ +
        + $endif$ +
        +
      2. diff --git a/templates/partials/list-controls.html b/templates/partials/list-controls.html new file mode 100644 index 0000000..38ad838 --- /dev/null +++ b/templates/partials/list-controls.html @@ -0,0 +1,9 @@ +
        + Show +
        + + + + +
        +
        diff --git a/templates/partials/metadata-header.html b/templates/partials/metadata-header.html new file mode 100644 index 0000000..fc6ddfa --- /dev/null +++ b/templates/partials/metadata-header.html @@ -0,0 +1,23 @@ + diff --git a/templates/partials/metadata-tail.html b/templates/partials/metadata-tail.html new file mode 100644 index 0000000..524f12f --- /dev/null +++ b/templates/partials/metadata-tail.html @@ -0,0 +1,33 @@ + diff --git a/templates/partials/nav.html b/templates/partials/nav.html new file mode 100644 index 0000000..51b2f20 --- /dev/null +++ b/templates/partials/nav.html @@ -0,0 +1,72 @@ +
        + +
        diff --git a/templates/partials/page-footer.html b/templates/partials/page-footer.html new file mode 100644 index 0000000..d66db9b --- /dev/null +++ b/templates/partials/page-footer.html @@ -0,0 +1,115 @@ + + diff --git a/templates/partials/paginate-nav.html b/templates/partials/paginate-nav.html new file mode 100644 index 0000000..9a04a94 --- /dev/null +++ b/templates/partials/paginate-nav.html @@ -0,0 +1,14 @@ +$if(previousPageUrl)$ + +$else$$if(nextPageUrl)$ + +$endif$$endif$ 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 @@ +
      3. + + $if(photo-url)$ + $if(photo-webp-url)$$endif$$title$ + $else$$if(photo)$ + $title$ + $endif$$endif$ +
        + $title$ + $if(captured-iso)$$endif$ +
        +
        +
      4. diff --git a/templates/partials/photography-controls.html b/templates/partials/photography-controls.html new file mode 100644 index 0000000..2180e27 --- /dev/null +++ b/templates/partials/photography-controls.html @@ -0,0 +1,21 @@ + 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..911e79a --- /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..1ae1726 --- /dev/null +++ b/templates/photography-index.html @@ -0,0 +1,22 @@ +
        +
        +
        +

        $title$

        + $if(photos)$ + $partial("templates/partials/photography-controls.html")$ + $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..ec2da7c --- /dev/null +++ b/templates/photography-series.html @@ -0,0 +1,67 @@ +
        + $if(photo-url)$ +
        +
        + $if(photo-webp-url)$$endif$$title$ +
        +
        + $endif$ + +
        +

        $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(series-frame-count)$ +
        +
        +
        Frames
        $series-frame-count$
        + $if(series-bytes)$
        Weight
        $series-bytes$
        $endif$ + $if(series-captured-span)$
        Shot
        $series-captured-span$
        $endif$ + $if(series-cameras)$
        Body
        $series-cameras$
        $endif$ + $if(series-lenses)$
        Glass
        $series-lenses$
        $endif$ + $if(series-focal-span)$
        Focal
        $series-focal-span$
        $endif$ + $if(series-iso-span)$
        ISO
        $series-iso-span$
        $endif$ + $if(series-locations)$
        Where
        $series-locations$
        $endif$ +
        +
        + $endif$ +
        + + + +
        +
        +
        + $if(body)$
        $body$
        $endif$ + + $if(series-photos)$ + $partial("templates/partials/photography-controls.html")$ +
          + $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..6c3d864 --- /dev/null +++ b/templates/photography.html @@ -0,0 +1,74 @@ +
        +
        +
        +
        +

        $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$ + + $if(backlinks)$ +
        + +
        + $endif$ +
        +
        +
        diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..bf1ed48 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,7 @@ +
        +

        $title$

        + +
        $body$
        + + $vita-projects-html$ +
        diff --git a/templates/reading.html b/templates/reading.html new file mode 100644 index 0000000..cfe1969 --- /dev/null +++ b/templates/reading.html @@ -0,0 +1,25 @@ + +
        +
        $monogramSvg$
        +
        +

        $title$

        + $if(subtitle)$

        $subtitle$

        $endif$ + $partial("templates/partials/metadata-header.html")$ +
        +
        +
        + $partial("templates/partials/metadata-tail.html")$ + $if(summary)$ +
        +
        Summary
        + $summary$ +
        + $endif$ +
        + +
        + $body$ +
        +$partial("templates/partials/page-footer.html")$ diff --git a/templates/score-reader-default.html b/templates/score-reader-default.html new file mode 100644 index 0000000..f757f32 --- /dev/null +++ b/templates/score-reader-default.html @@ -0,0 +1,21 @@ + + + + + +$title$ — Score — Levi Neuwirth + + + + + + + + + +$body$ + + + + diff --git a/templates/score-reader.html b/templates/score-reader.html new file mode 100644 index 0000000..fa1ca74 --- /dev/null +++ b/templates/score-reader.html @@ -0,0 +1,56 @@ +
        + + +
        +
        + + + +
        + $if(pdf)$PDF$endif$ +
        + + +
        +
        +
        +
        +
        + + +
        + + + +
        diff --git a/templates/sitemap.xml b/templates/sitemap.xml new file mode 100644 index 0000000..c398f72 --- /dev/null +++ b/templates/sitemap.xml @@ -0,0 +1,12 @@ + + + + $root$/ + +$for(entries)$ + + $root$$url$ +$if(lastmod)$ $lastmod$ +$endif$ +$endfor$ + diff --git a/templates/tag-index.html b/templates/tag-index.html new file mode 100644 index 0000000..25ffbf3 --- /dev/null +++ b/templates/tag-index.html @@ -0,0 +1,40 @@ +
        +

        $title$

        + $if(has-see-also)$ + + $endif$ + $if(portal-intro)$ +
        + $portal-intro$ +
        + $endif$ + $if(items)$ + $partial("templates/partials/list-controls.html")$ +
          + $for(items)$ + $partial("templates/partials/item-card.html")$ + $endfor$ +
        + $else$ +

        No items yet.

        + $endif$ + $partial("templates/partials/paginate-nav.html")$ +
        diff --git a/templates/vita.html b/templates/vita.html new file mode 100644 index 0000000..ab380fc --- /dev/null +++ b/templates/vita.html @@ -0,0 +1,11 @@ +
        +

        $title$

        + +
        $body$
        + + $vita-education-html$ + $vita-experience-html$ + $vita-publications-html$ + $vita-presentations-html$ + $vita-contact-html$ +
        diff --git a/tests/test_import_content.py b/tests/test_import_content.py new file mode 100644 index 0000000..d23e9a0 --- /dev/null +++ b/tests/test_import_content.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import argparse +import contextlib +import importlib.util +import io +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = REPO_ROOT / "tools" / "import-content.py" +SPEC = importlib.util.spec_from_file_location("import_content", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +import_content = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = import_content +SPEC.loader.exec_module(import_content) + + +def args_for(**overrides: object) -> argparse.Namespace: + values: dict[str, object] = { + "type": "page", + "title_prefix": None, + "date": None, + "tags": None, + "author": None, + "field": [], + "collection": None, + "overwrite": False, + } + values.update(overrides) + return argparse.Namespace(**values) + + +class ImportContentTests(unittest.TestCase): + def test_author_override_uses_authors_list(self) -> None: + doc = import_content.Document( + "Title\n\nBody", {"authors": ["Previous Author"]} + ) + + import_content.apply_schema( + [doc], args_for(author="Ada Lovelace") + ) + + self.assertEqual(doc.meta["authors"], ["Ada Lovelace"]) + + def test_duplicate_output_destinations_are_rejected(self) -> None: + docs = [ + import_content.Document("First", {"title": "Same"}), + import_content.Document("Second", {"title": "Same"}), + ] + + with self.assertRaisesRegex( + import_content.ContentImportError, + "duplicate output destination content/same.md", + ): + import_content.assemble_file_entries(docs, args_for()) + + def test_explicit_source_slugs_determine_output_paths(self) -> None: + docs = [ + import_content.Document( + "First", {"title": "Same", "slug": "source-one"} + ), + import_content.Document( + "Second", {"title": "Same", "slug": "source-two"} + ), + ] + + entries = import_content.assemble_file_entries(docs, args_for()) + + self.assertEqual( + {path.as_posix() for path, _content, _label in entries}, + {"content/source-one.md", "content/source-two.md"}, + ) + + def test_collections_support_every_content_type(self) -> None: + expected = { + "essay": ( + "content/essays/cycle", + "/essays/cycle/", + ), + "blog": ( + "content/blog/cycle", + "/blog/cycle/", + ), + "fiction": ( + "content/fiction/cycle", + "/fiction/cycle/", + ), + "poetry": ( + "content/poetry/cycle", + "/poetry/cycle/", + ), + "page": ( + "content/cycle", + "/cycle/", + ), + } + + for content_type, (directory, collection_url) in expected.items(): + with self.subTest(content_type=content_type): + doc = import_content.Document("Body", {"title": "Piece"}) + entries = import_content.assemble_file_entries( + [doc], + args_for(type=content_type, collection="Cycle"), + ) + + self.assertEqual( + {path.as_posix() for path, _content, _label in entries}, + {f"{directory}/piece.md", f"{directory}/index.md"}, + ) + self.assertEqual( + doc.meta["collection-url"], collection_url + ) + + def test_page_collection_rejects_reserved_section_slug(self) -> None: + docs = [import_content.Document("Body", {"title": "Piece"})] + + with self.assertRaisesRegex( + import_content.ContentImportError, + "conflicts with a reserved content directory", + ): + import_content.assemble_file_entries( + docs, args_for(type="page", collection="Fiction") + ) + + def test_writing_types_require_valid_iso_dates(self) -> None: + with self.assertRaisesRegex( + import_content.ContentImportError, + "a date in YYYY-MM-DD format is required", + ): + import_content.apply_schema( + [import_content.Document("Body", {"title": "Story"})], + args_for(type="fiction"), + ) + + with self.assertRaisesRegex( + import_content.ContentImportError, "invalid date" + ): + import_content.apply_schema( + [import_content.Document("Body", {"title": "Story"})], + args_for(type="fiction", date="2026-02-30"), + ) + + def test_opening_prose_is_not_consumed_as_a_title(self) -> None: + body = "This is the opening sentence.\nThe paragraph continues here." + doc = import_content.Document(body) + + import_content.apply_schema([doc], args_for()) + + self.assertEqual(doc.meta["title"], "Untitled") + self.assertEqual(doc.body, body) + self.assertEqual( + doc.meta["abstract"], + "This is the opening sentence. The paragraph continues here.", + ) + + def test_invalid_regex_exits_without_writing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.txt" + source.write_text("Title\n\nBody\n", encoding="utf-8") + stdout = io.StringIO() + stderr = io.StringIO() + + with contextlib.chdir(root), contextlib.redirect_stdout(stdout), \ + contextlib.redirect_stderr(stderr): + status = import_content.main([ + str(source), "--splitter", "regex", "--regex", "(", + ]) + + self.assertEqual(status, 2) + self.assertIn("error: invalid regex pattern", stderr.getvalue()) + self.assertFalse((root / "content").exists()) + + def test_consecutive_headings_keep_the_nonempty_section_title(self) -> None: + docs = import_content.split_heading_1( + import_content.Document("# First\n# Second\nSecond body"), {} + ) + + self.assertEqual(len(docs), 1) + self.assertEqual(docs[0].meta, {"number": 1, "title": "Second"}) + self.assertEqual(docs[0].body, "Second body") + + def test_page_break_handles_form_feed_and_preserves_no_match(self) -> None: + split = import_content.split_page_break( + import_content.Document("first\fsecond"), {} + ) + untouched = import_content.Document("Body only") + + self.assertEqual( + [(doc.meta, doc.body) for doc in split], + [({"number": 1}, "first"), ({"number": 2}, "second")], + ) + self.assertEqual( + import_content.split_page_break(untouched, {}), [untouched] + ) + + def test_frontmatter_must_be_a_yaml_mapping(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.md" + source.write_text( + "---\nOrdinary paragraph.\n---\nThis survives.\n", + encoding="utf-8", + ) + + with contextlib.chdir(root), self.assertRaisesRegex( + import_content.ContentImportError, + "frontmatter must be a YAML mapping", + ): + import_content.read_file_per_document(["source.md"]) + + def test_structured_fields_are_validated(self) -> None: + with self.assertRaisesRegex( + import_content.ContentImportError, + "json entry 0 field 'body' must be a string", + ): + import_content._documents_from_records( + [{"title": "Bad", "body": 42}], Path("source.json"), "json" + ) + + with self.assertRaisesRegex( + import_content.ContentImportError, + "field 'number' must be a positive integer", + ): + import_content.apply_schema( + [ + import_content.Document( + "Body", {"title": "Bad", "number": "1"} + ) + ], + args_for(), + ) + + def test_collection_index_frontmatter_is_valid_yaml(self) -> None: + doc = import_content.Document( + "Body", + { + "title": "Piece", + "date": "2026-07-17", + "tags": ["poetry"], + "poet": "Ada Lovelace", + }, + ) + + rendered = import_content.generate_collection_index( + [doc], "Essays: 2026" + ) + metadata = import_content.yaml.safe_load(rendered.split("---", 2)[1]) + + self.assertEqual(metadata["title"], "Essays: 2026") + self.assertEqual(metadata["tags"], ["poetry"]) + + def test_abstract_respects_maximum_length_without_spaces(self) -> None: + abstract = import_content.auto_abstract("x" * 250, max_chars=200) + + self.assertEqual(len(abstract), 200) + self.assertTrue(abstract.endswith(" …")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/add-popup-source.sh b/tools/add-popup-source.sh new file mode 100755 index 0000000..01dd9ab --- /dev/null +++ b/tools/add-popup-source.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# add-popup-source.sh — Scaffold a new popup provider end-to-end. +# +# Prompts for the handful of facts unique to a new source (name, label, +# a sample URL), then: +# 1. Probes the upstream for CORS support and content-type. +# 2. Prints a PROVIDERS entry stub you paste into static/js/popups.js. +# 3. If CORS-broken, prints + (on confirmation) appends an nginx +# location block to nginx/popup-proxy.conf. +# 4. Prints a remaining-steps checklist (icon SVG, CSS, CSP comment). +# +# The parse() body is left as a TODO — writing it requires eyes on the +# actual API response, which this tool also fetches and pretty-prints so +# you can inspect the response shape without a second terminal. +# +# Never writes to static/js/popups.js automatically — that file has too +# much surrounding structure to edit blindly. Copy-paste is safer. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +POPUPS_JS="$REPO_ROOT/static/js/popups.js" +NGINX_CONF="$REPO_ROOT/nginx/popup-proxy.conf" +ORIGIN="https://levineuwirth.org" + +# ── helpers ────────────────────────────────────────────────────────── + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +dim() { printf '\033[2m%s\033[0m\n' "$*"; } +warn() { printf '\033[33m%s\033[0m\n' "$*" >&2; } +prompt() { local v; read -r -p "$1 " v; printf '%s' "$v"; } + +usage() { + cat <&1 || true) + # grep returns 1 when no match — tolerate that under `set -e` + pipefail. + CORS=$(printf '%s\n' "$HEADERS" | grep -i 'access-control-allow-origin' | head -1 | tr -d '\r' || true) + CT=$(printf '%s\n' "$HEADERS" | grep -i '^content-type' | head -1 | tr -d '\r' | awk '{print tolower($2)}' || true) + + if [[ -n "$CORS" ]]; then + echo " CORS ✓ $CORS" + NEEDS_PROXY=0 + else + warn " CORS ✗ none — upstream needs a reverse proxy" + NEEDS_PROXY=1 + fi + + echo " Content-Type: ${CT:-unknown}" + case "$CT" in + *xml*|*atom*) FETCH_TYPE=xml ;; + *json*) FETCH_TYPE=json ;; + *) FETCH_TYPE=json; warn " (unrecognized type — defaulting to json)" ;; + esac + echo " fetchType → $FETCH_TYPE" +else + warn " no API URL supplied; assuming json + CORS-OK (edit by hand if wrong)" + FETCH_TYPE=json + NEEDS_PROXY=0 +fi + +# ── sample response dump (helps write the parser) ──────────────────── + +if [[ -n "$API_URL" ]]; then + echo + bold "── sample response (first ~40 lines) ──" + RAW=$(curl -sS "$API_URL" 2>&1 || true) + if [[ "$FETCH_TYPE" == json ]] && command -v jq >/dev/null; then + printf '%s\n' "$RAW" | jq -C . 2>/dev/null | head -40 || printf '%s\n' "$RAW" | head -40 + elif [[ "$FETCH_TYPE" == xml ]] && command -v xmllint >/dev/null; then + printf '%s\n' "$RAW" | xmllint --format - 2>/dev/null | head -40 || printf '%s\n' "$RAW" | head -40 + else + printf '%s\n' "$RAW" | head -40 + fi +fi + +# ── proxy prefix + upstream host derivation ────────────────────────── + +# UPSTREAM_HOST is derived unconditionally: the no-proxy (direct CORS +# fetch) case is exactly when the host must be added to connect-src, so +# the checklist's CSP reminder below needs it populated either way. +UPSTREAM_HOST=$(printf '%s' "$API_URL" | awk -F/ '{print $3}') +if [[ "$NEEDS_PROXY" -eq 1 ]]; then + UPSTREAM_PATH=$(printf '%s' "$API_URL" | awk -F/ 'BEGIN{OFS="/"} {$1=""; $2=""; $3=""; print}' | sed 's|^///||') + PROXY_PATH="/proxy/$NAME/" + PROXY_API_URL="$PROXY_PATH${UPSTREAM_PATH%%\?*}" + [[ "$API_URL" == *"?"* ]] && PROXY_API_URL="$PROXY_API_URL?${API_URL#*\?}" +else + PROXY_API_URL="$API_URL" +fi + +# ── PROVIDERS entry stub ───────────────────────────────────────────── + +echo +bold "── paste into static/js/popups.js (PROVIDERS array) ──" +dim " Insertion order = match priority; pick a spot before less-specific entries." +echo +cat <> "$NGINX_CONF" + echo " appended to $NGINX_CONF" + else + echo " skipped — paste manually when ready" + fi +fi + +# ── remaining-steps checklist ──────────────────────────────────────── + +echo +bold "── remaining manual steps ──" +cat </, extracts readable text, +writes the per-entry archive//PROVENANCE.json, and (re)writes the +Hakyll input data/archive-index.json. + +Two artifact types: + * pdf — downloaded directly, stored as document.pdf, text via pdftotext. + * html — snapshotted with `monolith` into a single self-contained + snapshot.html (JavaScript stripped, assets inlined as data + URIs), a restrictive Content-Security-Policy injected, + text extracted with BeautifulSoup. + +Subcommands: + fetch download missing artifacts, (re)generate sidecars + index; + an original already dead at first fetch falls back to its most + recent existing Wayback capture (recorded as `fetched-from`) + refresh deliberately re-snapshot a single entry, recording the prior + SHA in the new PROVENANCE.json's `previous-sha256` + wayback submit archived URLs to the Wayback Machine as a second, + independent copy; backfill the capture URL into PROVENANCE.json + check HEAD/GET-probe every manifest URL for link rot, updating + data/archive-state.json with asymmetric hysteresis + suggest print works cited in data/*.bib (url / doi fields) but not in + the manifest, as manifest-ready lines; never edits the manifest + gc delete archive// directories listed in archive/removed.yaml + +Failure policy: + * Integrity errors — a committed artifact whose SHA-256 no longer + matches PROVENANCE.json, or a slug whose manifest URL has changed — + print loudly and exit non-zero, halting `make build`. + * Transient errors — a network failure, an over-cap download, a missing + `monolith` binary, a manifest entry missing its `url:` — print a + warning, skip that entry, and exit zero so the build proceeds (the + entry is retried on the next build). + +See ARCHIVE.md for the full design. + +Gated on .venv by the Makefile (same convention as embed.py). Non-stdlib +dependencies: PyYAML and beautifulsoup4, both already in pyproject.toml. +External tools: `pdftotext` (poppler) for PDF text, and the `monolith` +binary — vendored at tools/bin/monolith, see tools/monolith-version.txt. +""" + +from __future__ import annotations + +import datetime +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse + +import yaml + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parent.parent +ARCHIVE_DIR = REPO_ROOT / "archive" +MANIFEST = ARCHIVE_DIR / "manifest.yaml" +REMOVED = ARCHIVE_DIR / "removed.yaml" +INDEX_OUT = REPO_ROOT / "data" / "archive-index.json" +STATE_OUT = REPO_ROOT / "data" / "archive-state.json" + +ROT_FAILS = 3 # consecutive failed scans before `rotted` is considered +ROT_DAYS = 14 # ... and the streak must also span at least this many days + +# Probed before a link-rot scan; unreachable -> the scan is inconclusive +# (offline machine, dead DNS) and state is left untouched. Guards the +# unattended timer-run case: three offline scans spanning two weeks would +# otherwise flip every entry to `rotted` at once. +CHECK_CANARY = "https://levineuwirth.org/" + +SIZE_CAP = 25 * 1024 * 1024 # 25 MB per-artifact cap +TIMEOUT = 60 # seconds, per network request +WAYBACK_TIMEOUT = 120 # seconds — Save Page Now is slow +USER_AGENT = ("levineuwirth.org/archive " + "(ln@levineuwirth.org; removal requests honored)") + +# Per-type on-disk names. The artifact is committed; the .txt is generated +# (gitignored) and regenerated whenever the artifact's SHA-256 changes. +ARTIFACT = {"pdf": "document.pdf", "html": "snapshot.html"} +TEXTFILE = {"pdf": "document.txt", "html": "snapshot.txt"} + +# Injected into every HTML snapshot's . Permits exactly what a +# faithful monolith capture needs — inlined images/fonts as data URIs and +# inline styles (as