Commit Graph

147 Commits

Author SHA1 Message Date
Levi Neuwirth 5d344f940e Last audit stragglers: scaffolder, refreeze safety, atomic-write polish
- add-popup-source.sh: slug validated against ^[a-z0-9-]+$ before nginx
  interpolation; UPSTREAM_HOST derived unconditionally so the CSP
  reminder fires in the no-proxy case — which is exactly when the host
  must be added to connect-src (AUDIT §4.8)
- refreeze.sh: backs up the freeze and restores it on a failed resolve
  instead of leaving the repo with no freeze file (§4.9)
- einops gets the policy-mandated upper bound and a comment naming its
  consumer (nomic's remote modeling code) (§1.5)
- Makefile: pdftoppm failures warn instead of vanishing in the while
  pipeline; .NOTPARALLEL guards deploy's clean->build->sign ordering
  against -j invocations (§8.4)
- Atomic writers (embed, archive, the three sidecar extractors):
  PID-unique temp names so concurrent runs can't interleave, cleanup on
  failure everywhere, fsync where the artifact is not trivially
  regenerable (§4.10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:43:14 -04:00
Levi Neuwirth 23bc2d0dc1 Frontend tail: keyboard access, idempotence, input edge cases
- gallery.js: math/score focus overlays are keyboard-activatable
  (role=button, tabindex, Enter/Space) and focus return on close lands
  on a focusable trigger (AUDIT §5.7)
- annotations.js: marks are focusable; Enter/Space pins the tooltip
  with focus moved to its Delete button, Escape dismisses — the delete
  affordance is finally reachable without a mouse (§5.7)
- transclude.js: nested transclusions resolve (depth-capped at 3, with
  ancestor-chain cycle rejection rendering the existing error style);
  collapse.js reinit is idempotent via data-collapse-bound (§5.7)
- copy.js excludes the button label from code-less <pre> copies;
  score-reader.js stops rewriting plain loads to ?p=1; search-filters
  treats non-numeric threshold input as inactive instead of a
  match-everything >=0 filter; selection-popup no longer re-summons
  the toolbar while typing capitals in the annotation picker (§5.8)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:25:19 -04:00
Levi Neuwirth 9f61ce5949 Tooling, manifest, and content polish
- import-photo.sh deletes the copied JPEG when EXIF stripping fails, so
  the auto-commit can never publish GPS/serial metadata (AUDIT §4.11)
- pre-commit-marks hook: tab-aware path parsing, probes the staged blob
  rather than the working tree (§4.11)
- preset-signing-passphrase uses printf; stamp-build-time writes via
  temp + os.replace; archive.py passes -- to pdftotext and verifies the
  vendored monolith binary against its recorded sha256 (mismatch is
  fatal, consistent with the tool's integrity contract); extract-exif
  ./-prefixes relative paths (§4.11)
- blog-post.html: id="similar-links"/"backlinks" each appear once;
  rendered output unchanged (§6.4)
- site.webmanifest: start_url/scope/description added, maskable icon
  purpose restored alongside any (§9.3)
- Frontmatter cleanup: scaffold comments out of scaling_outage,
  dangling null confidence-history keys removed (populated ones kept),
  dead modified: key dropped from colophon (§6.4)
- canto31.jpg: 4.0 MB -> 1.9 MB (2400px, q80, grayscale — the source
  is a monochrome Doré engraving, so single-channel is colorimetrically
  lossless); webp sidecar regenerated (§6.4, prior-audit §6.1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:13:34 -04:00
Levi Neuwirth 56afdb867a Feature modules: URL normalization, Maybe-trust, proper medians
- Empty/all-comments manifest.yaml is the empty archive, not a fatal
  parse error (AUDIT §3.11)
- Backlinks normaliseUrl strips index.html like SimilarLinks, so links
  to canonical directory URLs invert again; Stats normUrl updated in
  lockstep (§3.12)
- PDF viewer file= query value percent-encoded (hand-rolled RFC 3986
  encoder; network-uri is not a dependency) (§3.13)
- Photography feed thumbnails embed for flat singles and series
  children, not just directory entries (§3.14)
- Marks trust is Maybe Int: missing confidence/evidence collapses the
  figure to the bare frame as documented, instead of a literal
  "0 TRUST"; result-shape glyph centers when no score (§3.15)
- Unknown catalog categories fold into one Other bucket; medians take
  the mean of middle elements; protocol-relative URLs excluded from
  backlinks; @string/@comment/@preamble skipped in BibTeX parsing;
  watch-staleness of the once-per-process archive reads documented;
  stale comments fixed (§3.16, §3.9)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:13:34 -04:00
Levi Neuwirth f254ce866e Filters: fence/code-span awareness, host matching, nested-header skip
- SourceRefs trigger whitelist aligned to the /source/ serving
  whitelist (drops content/, yaml-source/, broad static//tools//data
  prefixes; adds .bib); existsCached no longer memoizes non-existence,
  so files created under make watch are picked up (§2.5, §2.16)
- fill/stroke hex replacement is boundary-aware: #000080 and 8-digit
  RGBA forms can no longer be corrupted into currentColor80 (§2.12)
- Wikilinks/Transclusion/EmbedPdf skip fenced code blocks (shared
  CommonMark fence tracker), and wikilinks additionally skip inline
  code spans — the syntax-documentation essay now renders its own
  examples literally while live wikilinks still convert (verified both
  ways in output) (§2.13)
- domainIcon matches the extracted host by label suffix instead of
  substring-of-URL; extractHost also strips userinfo (§2.14)
- webpSrc escaped in srcset; internal PDF links no longer double-
  classified; Smallcaps/Archive header-skip now holds at every nesting
  depth via protect/restore walks (§2.17)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:13:08 -04:00
Levi Neuwirth c8eeaaa9bc Core build cleanups: guards, pattern unification, noResult hygiene
- Library page no longer hard-depends on content/library.md; deleting
  it degrades to no intro block (AUDIT §2.8)
- primaryPortalOf accepts scalar comma-form tags via getTags, matching
  the tag system (§2.9)
- allContent gains me/ and memento-mori/ so their outgoing links join
  the backlinks graph; photography exclusion now documented (§2.10)
- Paginated tag pages partition AND sort by the same revision-aware
  display date — cross-page order is monotone again (§2.11)
- New stripPrefixRoute replaces gsubRoute at 17 call sites: prefix-only
  stripping, no mid-path mangling; route inventory verified identical
  (§2.15)
- random-pages uses canonical patterns (collection poems randomizable);
  pattern literals replaced with Patterns imports; duplicate local
  poetry patterns deleted; flat/collection poetry rules merged (§2.17)
- noResult instead of empty-list/fail for tagLinksField, dotsField,
  abstract/description/summary/bibliography/further-reading, plus the
  confidence-trend, overall-score, has-score, has-movements, and
  movement-audio fields — no more empty wrappers or [ERROR] log noise
  for legitimately-absent values (§2.17)
- tagItemCtx composes siteCtx, so monograms render on tag pages (§2.17)
- readingTime ceilings (399 words -> 2 min); authorSlugify comment
  fixed to match behavior, code untouched for URL stability; stale
  portal-count comments corrected (§2.17)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:13:08 -04:00
Levi Neuwirth 945086421a embed.py: hash-cache the paragraph pass; drop the dead mtime skip
The 'skip if outputs newer than every HTML' check could never fire:
stamp-build-time.py rewrites every page's footer AFTER embed.py runs,
so the comparison was always false and the full MiniLM paragraph pass
(and model load) ran on every build (AUDIT §4.3). Replaced with the
same content-hash cache the page pass already had — generalized
load/save_vec_cache, keyed by sha256 of the input text, invalidated on
model/revision/dim change. A no-change rerun now does no model loads:
measured 97s cold -> 4.8s warm.

Also strips section.footnotes from extraction: the new no-JS fallback
duplicates each sidenote's text at document end, which would double
footnotes in search results and skew page similarity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:51:01 -04:00
Levi Neuwirth b2951c0c2c Branding diet: logo sprite via <use>, lean favicon.ico, simple mask icon
- The ~33 KB traced logo moves from an inlined-per-page partial to
  /logo-sprite.svg referenced with <use> — cached once instead of
  shipped on every page (homepage HTML: 46 KB -> 13 KB). CSS custom
  properties cascade into the use shadow tree, so the two-tone cutout
  is unchanged (AUDIT §9.1)
- favicon.ico regenerated at 16/32/48 from the 512px master: 71 KB ->
  15 KB; modern browsers take the SVG anyway, the .ico is the legacy
  fallback (§9.2)
- link-icons/internal.svg restored to the simple 4 KB path: it renders
  at 0.7-1.6 rem through a CSS mask, where the 33 KB traced detail
  cannot resolve (§9.2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:43:06 -04:00
Levi Neuwirth aeb2937f7c Drafts are local-only: untrack the four committed ones
.gitignore has declared content/drafts/ local-only working notes since
the rule was added, but four drafts were already tracked — ignore rules
don't untrack, so make build's auto-commit kept staging and deploy kept
pushing them (AUDIT §6.3). Untracked with --cached; the files remain on
disk and still build in dev. Also moved inclusionist-manifesto.md into
drafts/essays/ where the draft rule actually matches it (§6.1), and
un-shadowed the tracked .env.example from the credential patterns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:40:05 -04:00
Levi Neuwirth 8ca22a45d2 Sidenotes: emit the section.footnotes fallback the CSS expects
The filter consumes every Pandoc Note, so the "standard Pandoc-
generated section.footnotes" its doc claimed as the no-JS fallback
never existed — below 1500px with JS disabled, footnote content was
simply invisible (AUDIT §2.3). The filter now collects consumed notes
and appends the section itself: letter labels, jump targets for the
in-text refs (which now point at the visible fallback item), and
doc-backlink returns. sidenotes.js pairs ref/note by element id and
preventDefaults clicks, so behavior with JS is unchanged.

Verified in output: per-page item count matches inline sidenote count;
refs target #fn-<label>; backlinks target #snref-<label>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:37:28 -04:00
Levi Neuwirth 4e28c82e4c Fix SIMD essay repository URL: add missing owner segment
https://git.levineuwirth.org/where-simd-helps returned 404; the
owner-qualified form returns 200 (AUDIT §6.2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:44:26 -04:00
Levi Neuwirth 8040be1aee Docs: align WRITING.md and README with the implementation
- js: page-script paths are site-root-relative, not content-relative
  (AUDIT §7.1)
- directory-form standalone pages need a dedicated Site.hs rule; flat
  content/<page>.md is the generic form (§7.2)
- portal table: add the missing Photography row (§7.3)
- document the implemented-but-undocumented summary:, revised:, and
  keywords: fields, including a Revision dates section (§7.4)
- default citation style is Chicago Notes Bibliography, not
  Author-Date; hover previews come from popups.js, not the deleted
  citations.js (§7.5)
- history: entries may be authored in any order (sorted at build
  time); examples reordered newest-first (§3.5)
- README: make watch runs Hakyll's live-reload preview server (§7.5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:43:25 -04:00
Levi Neuwirth caa113e036 Frontend: search races, lightbox a11y, popup edge cases
- semantic-search.js: generation token prevents stale results from
  rendering over newer queries; in-flight dedup on the index fetch;
  index/meta size consistency check fails loudly instead of NaN
  ranking (AUDIT §5.5)
- lightbox.js: triggers keyboard-activatable (role=button, tabindex,
  Enter/Space); Tab trapped inside the aria-modal overlay, modeled on
  gallery.js (§5.6)
- nav.js: portal toggle persists via guarded safeStorage so
  storage-blocked contexts can't kill the toggle (§5.7)
- popups.js: provider url() throws (malformed percent-encoding) are
  treated as no-popup; future dates render nothing instead of
  "N days ago" (§5.7)
- search.js: missing PagefindUI degrades to a console warning instead
  of aborting the whole handler (§5.7)
- citations.js: deleted — dead code superseded by popups.js (§5.7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:43:25 -04:00
Levi Neuwirth c17c203747 Tooling robustness: atomic writes, verified downloads
- archive.py: PROVENANCE.json / archive-index.json / archive-state.json
  now written atomically (tmp + os.replace) — a truncated integrity
  record is the one thing this tool must never produce (AUDIT §4.4);
  manifest entries validated as mappings up front (§4.7); refresh
  rejects provenance with a missing/empty artifact key instead of
  crashing on IsADirectoryError (§4.7); wayback save URL quotes
  unsafe characters (§4.7)
- download-leaflet.sh: existing files are re-verified before being
  skipped, and downloads land in a .part temp moved into place only
  after checksum verification — a failed verification can no longer
  leave a bad file that the next run silently accepts (§4.5)
- download-model.sh, convert-images.sh: same temp-then-move pattern so
  interrupted downloads/conversions never persist at final paths (§4.6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:43:25 -04:00
Levi Neuwirth c68d03af31 Fix audit MEDs in feature modules
- Backlinks: handle Plain blocks (tight list items) and DefinitionList
  in link extraction — links in ordinary bullet lists were invisible to
  the backlinks system (AUDIT §3.3)
- Sidenotes: render note bodies with a KaTeX writer so footnote math
  reaches the client-side KaTeX pass instead of degrading to italics
  (§2.4)
- Archive: join manifest to provenance on normalised URLs like every
  other comparison in the system — an equivalent-form URL edit silently
  unpublished the page while links kept pointing at it (§3.6)
- Photography: flat singles get their basename as slug and root-level
  asset paths in map.json (§3.7); geo-precision now fails closed — an
  unrecognised value (typo'd "hidden") suppresses the pin instead of
  publishing rounded coordinates (§3.8)
- Stability: age is measured first-commit -> today, not the commit
  span, so quiet time stabilises a piece as documented (§3.4);
  history: entries are sorted newest-first by date regardless of
  authored order (§3.5); pinned pages format last-reviewed like the
  git branch (§3.10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:43:25 -04:00
Levi Neuwirth 902e43ea19 Add /poetry/ and /fiction/ indexes; widen tag-collision guard
Nav, the home portal grid, and the library have linked both URLs since
the portals were added, but no rule generated either index — confirmed
404s in production (AUDIT §2.1). Both rules mirror the essays index;
fiction renders an empty list until content exists.

sectionOwnedTopLevelTags now lists every namespace owning a
<name>/index.html route, not just photography — Hakyll silently
overwrites on duplicate routes, so an essay tagged e.g. "music" would
have clobbered a real section landing (AUDIT §2.2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:25:50 -04:00
Levi Neuwirth f11495ff9a Fix audit tooling/infra findings
- embed.py: pin nomic's auto_map modeling repo via code_revision —
  revision= alone left nomic-bert-2048 unpinned under
  trust_remote_code (AUDIT §1.3; verified loadable with
  HF_HUB_OFFLINE=1). Catch BadZipFile/EOFError when loading the page
  cache so a half-written npz is discarded, not fatal (§4.2), and
  unlink the tmp file on a failed save (§4.1)
- nginx: collapse the CSP to one physical line — nginx has no line
  continuation in quoted strings, so the old value embedded literal
  backslash+LF bytes, illegal in HTTP/2 (§8.1). Add the externals the
  site actually uses: KaTeX webfonts + onnxruntime wasm via jsdelivr,
  and the popup provider APIs popups.js documents (§8.2)
- Makefile: pathspec-limit the auto-commit to content/ so pre-staged
  unrelated work is no longer swept into auto: commits (§8.3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:21:47 -04:00
Levi Neuwirth c64f3d63c0 Fix audit frontend MEDs
- score-reader template: load utils.js before theme.js — without
  lnUtils.safeStorage the saved theme/text-size never restored on
  score pages (AUDIT §5.1)
- search-filters: expand trailing-slash pathnames to .../index.html
  before the epistemicMeta lookup; clean-URL pages were silently
  bypassing every active filter (AUDIT §5.2)
- viz: treat cappuccino as a dark theme so charts stop rendering
  near-black marks on a dark brown background (AUDIT §5.3)
- collapse: namespace section-collapsed keys by pathname (Pandoc
  auto-slugs recur across essays) and go through safeStorage like the
  rest of the site (AUDIT §5.4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:21:47 -04:00
Levi Neuwirth 7ca937d98c Fix audit HIGHs/MEDs in build code
- ArchiveIndex: guard rawIndex/rawState with doesFileExist so a fresh
  clone (gitignored data/ JSONs absent) degrades to empty instead of
  crashing — the behavior the module doc already promised (AUDIT §1.2)
- Commonplace: decode YAML via encodeUtf8, not Char8.pack, which
  truncates codepoints above 0x7F (AUDIT §3.2)
- Stats: DayOfWeek is ISO-numbered (Mon=1..Sun=7); dowOf and weekStart
  assumed Mon=0..Sun=6, clipping every Sunday cell outside the heatmap
  viewBox and starting weeks on Sunday (AUDIT §3.1)
- Site: epistemicEntry now honors the proved/proven confidence sentinel
  like Contexts.overallScoreField (AUDIT §2.6)
- Contexts: affiliationField returns noResult instead of an empty list,
  so essays without affiliation no longer render an empty meta row
  (AUDIT §2.7)

Verified: full site build passes; proved page gets score=100 in
epistemic-meta.json; empty .meta-affiliation gone; heatmap rows
y=22..94 all inside the 104-high viewBox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:21:30 -04:00
Levi Neuwirth 70ad44e9f4 Add 2026-06-09 repository audit findings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:57:43 -04:00
Levi Neuwirth 7c5354efa7 embed.py: split page vs paragraph embedding models
Pages (similar-links.json, build-only) move to nomic-embed-text-v1.5
(768d) with an on-disk npz cache; paragraphs (browser semantic search)
stay on all-MiniLM-L6-v2 (384d), so the client contract is unchanged.
WRITING.md search row updated accordingly. einops added for nomic's
remote modeling code; cache gitignored with a trailing glob so
interrupted-write debris is covered too.

Known follow-ups (AUDIT-2026-06-09.md §1.3, §4): pin the
nomic-bert-2048 remote code, catch BadZipFile in cache loads, fix the
staleness check defeated by stamp-build-time ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:57:43 -04:00
Levi Neuwirth 37665f67db Branding: traced logo mark, regenerated favicons, og-image
New inline logo-mark.svg partial in the nav (two-tone cutout via
--logo-ink/--logo-bg), regenerated favicon set + web-app manifest icons
from the new mark, 1200x630 og-image wired into head.html.

Known follow-ups (AUDIT-2026-06-09.md §9): the traced SVG is ~33 KB
inlined per page, favicon.ico carries 128/256px entries, and the
webmanifest dropped its maskable purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:57:34 -04:00
Levi Neuwirth a7b3b9cd07 Refreeze after system update: distributive 0.6.3 et al.
The pinned distributive 0.6.2.1 conflicted with the pacman package db
(comonad-5.0.10 built against 0.6.3), making a fresh solve impossible —
same failure mode as the 2026-05-07 audit's aeson pin. Regenerated via
tools/refreeze.sh; cabal build --dry-run now resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:57:25 -04:00
Levi Neuwirth 620b974d3f auto: 2026-05-26T15:50:02Z [skip ci] 2026-05-26 11:50:02 -04:00
Levi Neuwirth b83af076e0 Bump cabal.project.freeze: minor patch versions
Patch-level bumps to five transitive dependencies (attoparsec-aeson,
http2, prettyprinter-ansi-terminal, semialign, zlib). index-state is
unchanged; refreezes against the same hackage snapshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:07:02 -04:00
Levi Neuwirth af27479c6e Add now.js: recompute "Last updated" relative phrase client-side
build/Now.hs renders the .now-stamp-relative phrase ("3 days ago") at
build time against the build machine's clock; a page served days later
from cache or a CDN would then read stale. now.js recomputes the
phrase in the browser from the <time datetime> attribute (an
unambiguous YYYY-MM-DD) against the visitor's clock, with bucket
thresholds that mirror Now.hs:relativeTime exactly so the no-JS
fallback and the recomputed value agree.

* static/js/now.js — the recomputation script.
* templates/default.html includes it via $if(now)$ so it only loads
  on the Current page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:06:49 -04:00
Levi Neuwirth 70dda56625 Popups: render the source page's monogram in internal previews
Internal-page hover popups now show the source's monogram alongside
the title / abstract / metadata when one exists. Two-column grid is
gated on .has-monogram so popups for pieces without an authored mark
keep their default single-column body. The serialised SVG comes from
the rendered page's own .frontmatter-mark--monogram figure, excluded
when it is the symmetric-layout placeholder roundel so empty-slot
pieces do not get a fake mark in the preview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:06:35 -04:00
Levi Neuwirth a3b3457803 print.css: refresh page rules and prose treatments
Tightens what gets printed and how. Reading-mode warm tints are
disabled so pages do not repaint cream; the mobile TOC bar's
screen-only body padding is reset; sidenote / footnote treatments
are reworked so the prose flows continuously instead of breaking
into a separate footnotes section; decorative link-icon glyphs
are suppressed while external links keep their underline so a
reader can follow them in the printed copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:06:20 -04:00
Levi Neuwirth 802fc75968 Layout: page-shell wrapper for iOS sticky, scrollable TOC
iOS WebKit silently degrades position: sticky to static on direct
flex/grid children of <body>, so the sticky nav was breaking on
mobile. Wrapping everything below the nav in a .page-shell flex
column keeps the sticky-footer math out of body { } and restores
sticky behaviour across browsers. The essay-frontmatter hoisted in
the Marks II commit becomes a body-level sibling of .page-shell so
its monogram and epistemic-figure columns can span viewport width.

* templates/default.html wraps $body$ + footer in .page-shell.
* static/css/layout.css moves the flex-column + min-height math from
  body to .page-shell; the body > header rule now excludes
  .essay-frontmatter so the essay header does not inherit nav chrome
  (sticky, nav-bg, border-bottom).
* static/css/base.css drops the html/body overflow-x: clip — the
  page-shell wrapper handles horizontal containment and clipping at
  the viewport level was interfering with position: sticky.
* static/css/reading.css updates its #markdownBody centering selector
  to match .page-shell > #markdownBody.
* static/css/components.css makes the TOC outline scrollable when
  it overflows: bounded max-height tied to the sticky budget plus a
  thin themed scrollbar, with overflow: hidden preserved for the
  collapse transition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:06:02 -04:00
Levi Neuwirth fad8719045 Stamp the site-wide build time post-render
Hakyll caches per-page outputs, so pages whose dependencies have not
changed are not recompiled and the rendered $build-time$ in their
footer goes stale relative to a fresh build. The right granularity for
"last built at" is site-wide rather than per-page; wrapping the footer
timestamp in <span data-build-time> and rewriting it after Hakyll lets
every page reflect the current build without paying recompilation cost.

* tools/stamp-build-time.py walks _site/**/*.html after Hakyll runs and
  rewrites each element wrapped in [data-build-time] to the same format
  Contexts.hs:buildTimeField emits, so a fresh render and the post-pass
  agree.
* templates/partials/footer.html wraps $build-time$ in
  <span class="footer-build-time" data-build-time>...</span> so the
  sweep has a stable selector.
* Makefile invokes the sweep between embed.py and compress-assets so
  the .gz/.br sidecars include the fresh stamp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:05:28 -04:00
Levi Neuwirth 154b47a4cb Marks II: broader monogram coverage + audit-marks tool
Extends the Phase-1 monogram mark system to every long-form content
type (essays, blog posts, poems, fiction, music) and introduces a
coverage audit so gaps are visible.

* build/Marks.hs gains hasMonogram (predicate), monogramSvgFieldFor +
  hasMonogramFieldFor (for explicit-path callers like the /build/ and
  /stats/ pages). Contexts.hs exports hasMonogramField as a siteCtx
  boolean so templates can conditionally render the slot without
  emitting an empty <div>.
* essay.html, blog-post.html, reading.html: hoist the frontmatter
  block out of <main id="markdownBody"> so the monogram + epistemic
  marks render as wrapper chrome rather than indexable prose; left
  + right mark slots are now unconditional (CSS handles the empty
  state) so the layout is grid-stable across pieces.
* templates/partials/item-card.html: optional monogram chip on cards
  (item-card--has-monogram modifier), gated on $has-monogram$ so
  monogram-less pieces stay flush.
* build/Stats.hs grows a "Marks coverage" telemetry section: per-type
  pieces / monogram / epistemic-figure counts + a coverage rollup,
  rendered between epistemic and output on /build/.
* tools/audit-marks.py: coverage report (ASCII table) walking
  content/**/*.md, plus a pre-commit hook at
  tools/hooks/pre-commit-marks.sh that runs the same scan against
  newly-staged .md files. New `make audit-marks` runs the report
  manually; the hook gates commits.
* static/css/marks.css: layout for the new frontmatter slots and the
  item-card monogram chip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:05:08 -04:00
Levi Neuwirth 77e31efdae Add link archive system: snapshots, backlinks, link-rot
Preserve external works the site cites against link rot, host them at
permanent /archive/<slug>/ URLs in site chrome, and treat them as
first-class citizens of the backlinks and similar-pages indexes.
Curated, not crawled: the author adds one line to archive/manifest.yaml
and the build fetches, hashes, snapshots, and indexes the work.

* archive/manifest.yaml + tools/archive.py (fetch / refresh / wayback /
  check / gc) — PDFs downloaded directly, HTML pages snapshotted with a
  vendored monolith (tools/bin/monolith @ 2.10.1) into a single
  self-contained file with the archive CSP and a noarchive robots meta
  injected. Per-entry PROVENANCE.json committed; gitignored .txt
  sidecars regenerated from the artifact's SHA-256.
* build/Archive.hs + build/ArchiveIndex.hs + build/Filters/Archive.hs
  — Hakyll rules for /archive/ and /archive/<slug>/, a body Pandoc
  filter that appends an archive affordance to live citations and
  flips dead ones to the local copy on archive.py check's asymmetric
  hysteresis (rotted needs 3 fails over >= 14 days; one ok recovers).
* build/Backlinks.hs — keeps archived external URLs through pass 1 and
  canonicalises them to /archive/<slug>/ in pass 2, producing a
  "Referenced by" section grouped by the fragment each citation
  targets. build/Stats.hs gains a "Link archive" telemetry block on
  /build/ (count, total size, median age, by-status / by-quality /
  by-visibility, orphans).
* Integrity: archive.py fetch and build/Archive.hs (via sha256sum)
  both re-hash every committed artifact, so a tampered file halts the
  build even with cabal invoked directly or no .venv present. refresh
  refuses to replace an uncommitted prior snapshot and rolls back
  atomically on any exit path. removed.yaml is honoured by fetch,
  wayback, and check using canonical-form (tracking-stripped,
  arXiv-canonicalised) comparison.
* visibility: private keeps an entry in-repo but undeployed.
  nginx/archive.conf emits X-Robots-Tag: noindex, noarchive for raw
  artifacts that cannot carry meta directives.

The full design, phase plan (1-5), and three refinement passes live
in ARCHIVE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 10:06:33 -04:00
Levi Neuwirth 14c881b9e4 auto: 2026-05-16T23:42:26Z [skip ci] 2026-05-16 19:42:26 -04:00
Levi Neuwirth e61a9e495c auto: 2026-05-16T23:28:47Z [skip ci] 2026-05-16 19:28:47 -04:00
Levi Neuwirth 711912cdfb auto: 2026-05-16T23:20:57Z [skip ci] 2026-05-16 19:20:57 -04:00
Levi Neuwirth 36748573cd auto: 2026-05-09T01:05:52Z [skip ci] 2026-05-08 21:05:52 -04:00
Levi Neuwirth 7f7c029601 Marks I 2026-05-07 23:51:14 -04:00
Levi Neuwirth 1274b36d42 Internal audit 2026-05-07 17:20:27 -04:00
Levi Neuwirth 41c8033eee Prune stale README.*.md entries from .gitignore
README.profile.md, README.arcana.md, README.simd.md, README.icd.md,
README.neuropose.md never existed in the working tree. Cosmetic
cleanup; the credential-shaped patterns and content/drafts/ entries
are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:09:13 -04:00
Levi Neuwirth 3e5277871a WRITING.md: unquoted dates, document tag-meta sidecar schema
- Update history: examples to show unquoted ISO dates, matching the
  corpus convention (every date: in content/ is unquoted) and drop
  the misleading 'prevent YAML date parsing' comment — the Haskell
  YAML parser keeps the value as a String either way.
- Add a 'Tag-meta sidecars' subsection under Tags explaining that
  content/tag-meta/{portal}.md uses a tooltip-only schema with no
  title: and no body. Documents the one place in content/ where the
  'every file has a title' rule does not apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:09:10 -04:00
Levi Neuwirth 3e76833aac Validate tool inputs and surface tracebacks on errors
- import-photo.sh: validate \$SLUG against ^[a-z0-9-]+\$ before
  writing under content/photography/; rejects '../' and other
  surprises early. Also fail loudly with a clear message if either
  'magick' resize or 'magick mogrify -strip' returns non-zero
  (prevents shipping a file that still carries EXIF when the strip
  silently failed).
- compress-assets.sh: reject non-numeric MIN_SIZE up front (otherwise
  the comparison fails later with a cryptic arithmetic error).
- extract-dimensions.py / extract-exif.py / extract-palette.py:
  add traceback.print_exc() after the broad except so a corrupt
  image produces a stack trace alongside the one-line summary.
- extract-exif.py: switch from Pillow's deprecated _getexif() to
  the public getexif() API; pyproject allows Pillow up to 12 where
  _getexif may be removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:09:02 -04:00
Levi Neuwirth 0379dda908 Degrade gracefully on corrupt backlinks JSON
If data/backlinks.json fails to parse, every page that uses the
backlinks context aborts with 'fail'. The JSON is build-generated;
corruption is unlikely but not impossible (interrupted writer, disk
issue). Switch to noResult so the affected pages render without the
backlinks block instead of failing the whole build. The next clean
build regenerates the JSON.

Note: commonplace.yaml and now.yaml deliberately keep fail-fast —
they're hand-edited and silent fallbacks would mask author typos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:08:54 -04:00
Levi Neuwirth 725fa17f6a Tighten partial patterns and switch to strict file reads in build/
- Stats.hs: median uses (!!) directly after the empty-case equation,
  dropping the unreachable empty-fallback arm.
- Stats.hs + BibExtras.hs: switch lazy readFile to strict readFile'
  (System.IO). Lazy IO leaves handles open until the value is forced;
  errors surface at unpredictable points and the em-dash fallback in
  Stats can hide real I/O failures. Strict reads fail at the read.
- Stability.hs: stabilityFromDates uses 'last dates' directly, since
  the (newest:_) pattern guarantees non-empty input.
  versionHistoryRangeField and versionHistoryRangeEndField bind the
  matched list as 'es' and call 'last es', dropping the
  reconstruction of (newest : more) just to call last on it.
- Tags.hs: parentOf is a 3-arm case (\[\], \[_\], segs) instead of a
  length-based guard around 'init segs'.
- Catalog.hs: renderGroup re-orders so the structurally-guaranteed
  (e:_) arm is matched first; the empty arm stays as a coverage stub
  with a comment noting it's unreachable per groupBy's contract.
- Utils.hs: trim uses dropWhileEnd instead of double-reverse.

All sites were runtime-safe before; the changes make the safety
structural and shorter to read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:08:47 -04:00
Levi Neuwirth a818b7df9b Add robots.txt and sitemap.xml; tidy essay-route prefix-strip
- Emit a minimal robots.txt that points at the sitemap.
- Emit sitemap.xml covering every dated content page (essays, blog,
  fiction, poetry, music) with absolute <loc> and frontmatter-derived
  <lastmod>. 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.
- Replace the magic 'drop 8' offset in essay routing with
  stripPrefix "content/". Same behavior, but reads structurally and
  fails closed if the prefix ever changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:08:33 -04:00
Levi Neuwirth 339433db20 Quote rsync target variables in Makefile deploy
A VPS_PATH containing whitespace or shell metacharacters would split
on the unquoted expansion and hand rsync extra arguments. The
existing VPS_PATH guard rejects obviously dangerous parents (/srv,
/var, etc.) but does not catch this. Quoting fails closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:08:23 -04:00
Levi Neuwirth c7a588d769 Bump requires-python floor to 3.14
Matches .python-version and the actual development environment.
uv.lock already declared >=3.12 but the project has been on 3.14 for
a while; the floor was just stale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:08:20 -04:00
Levi Neuwirth eb7fef30df Pin Hugging Face model revisions for downloader and embed pipeline
- Add tools/model-checksums.sha256 with sha256 hashes for the five
  Xenova/all-MiniLM-L6-v2 files served from static/models/.
  download-model.sh was already plumbed to verify against this file
  when present; the file itself was missing, so downloads were
  unverified. Now every fetch checks against committed hashes and
  fails closed on mismatch.
- Pin embed.py's SentenceTransformer load to a specific HF commit
  (c9745ed1d9f207416be6d2e6f8de32d1f16199bf of
  sentence-transformers/all-MiniLM-L6-v2). A future model bump can no
  longer silently change embedding semantics across builds. Bump
  deliberately when validating; re-run a full embed pass to refresh
  the semantic + similar-links data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:08:14 -04:00
Levi Neuwirth 87819501a5 nginx: ship security baseline, reference vhost, and tighter cache
- Add nginx/security-headers.conf — server_tokens off, HSTS (1y +
  preload), X-Content-Type-Options, X-Frame-Options DENY,
  Referrer-Policy, Permissions-Policy, and a usage-scoped CSP. CSP
  ships in Report-Only mode; promote to enforcing once the report
  stream is clean for a week. CSP allowlists are derived from actual
  usage (cdn.jsdelivr.net for KaTeX/Vega, *.basemaps.cartocdn.com for
  Leaflet tiles); 'unsafe-inline' and 'unsafe-eval' are documented
  inline.
- Add nginx/vhost.conf.example — reference vhost showing the canonical
  include order. The live vhost on the VPS remains the source of
  truth; this file documents the structure so the VPS config can be
  reproduced or audited from the repo.
- Shorten unfingerprinted CSS/JS cache from 24h to 1h. Bug fixes ship
  to warm clients within an hour; if assets are ever fingerprinted,
  this can move to immutable.
- Refresh README repo layout — add nginx/ entry, drop stale paper/
  and spec.md references that never existed in the working tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:08:03 -04:00
Levi Neuwirth fd84b7e6d2 Add AUDIT.md
Comprehensive audit of the repo flagging HIGH/MED/LOW/NIT issues across
the Haskell build, Python and shell tools, content + frontmatter,
templates, static assets, nginx snippets, README, .env, and gitignore.
Records what was fixed, what was deferred, and the recommended fix
order so future me has a paper trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:07:48 -04:00
Levi Neuwirth 670d477cd6 auto: 2026-05-06T16:35:22Z [skip ci] 2026-05-06 12:35:22 -04:00