Operating notes for AI agents (Cline, Hermes, others) working in this repository. Read this first, then .clinerules — the authoritative rulebook for content/template/code conventions.
Pictrace is a Jekyll photo blog — Alexander Dümont’s photography grouped by year and location, with a hash-based lightbox (“popup”) showing EXIF data, and scroll-animated SVG “journey” maps for select trips. Published on GitHub Pages at https://pictrace.de.
LICENSE). Photographs: © Alexander Dümont, all rights reserved (images/LICENSE.photos) — never reuse or re-publish them.C:\Users\James\IdeaProjects\pictrace · WSL /mnt/c/Users/James/IdeaProjects/pictrace.| Area | State |
|---|---|
| Live site | https://pictrace.de — GitHub Pages, publishes from default branch master |
| Repo | github.com/a-d/pictrace (origin). 2026-10-03 (PT29): the 2026-09-20…10-03 history was rewritten — all 98 agent-era commits are attributed to Alexander Dümont <alexander_duemont@web.de>; PT30 (Helsinki stub + stub-sync automation) is on top — remote force-push pending (owner) |
| Branches | master only — redesign / multi-map / namibia were deleted in PT14 (the multi-map WIP is kept as .hermes/plan/PT14-audit/multi-map-wip.patch) |
| Working tree | Clean apart from two untracked entries (below). The ~170 staged Morocco raw photos are gone from the index — PT14 rescued the sources to IdeaProjects/pictrace-morocco-sources/ (md5-verified) and images/_2026-morocco/ no longer exists |
| Untracked | .agentbridge/ (local agent state) · namib1.svg (working file) — leave as-is |
| Task board | KANBAN.md — PT22–PT27 done 2026-09-24; PT28 retro-logged; PT29 (attribution rewrite), PT30 (Helsinki 404 + stub-sync automation), PT31 (lightbox load-ahead) + PT32 (Thessaloniki rotation) done 2026-10-03; PT10 parked; PT13 dormant |
:target, :has(), transitions, media queries); use JavaScript only for non-functional visual/UX improvements (e.g. EXIF display, image fade-ins, progressive batch loading). Everything must degrade gracefully — with scripting disabled the gallery, lightbox and navigation stay fully usable. When a JS enhancement gates something visible, ship a CSS fallback via @media (scripting: none) (see a[rel=prev] / a[rel=next] in main.css)./* … */ only — never //. The compress.html layout collapses the built HTML to single lines; a // comment would comment out the rest of that script. Applies to inline template scripts too.<article>/<section>/<figure>/<aside>/<nav> over <div>/<span>; vanilla JS; CSS custom properties. More in .clinerules.assets/js/main.js is the editable source; after editing run ./minify.sh and commit both files (main.min.js needs git add -f; /assets/ is gitignored). ./minify.sh --check fails when the shipped file is stale; the layout loads main.min.js (falls back to main.js).feat(lightbox): …, fix(gallery): …, docs: …. Commit explicitly named files only (git commit --only -- <paths>); the tree often carries unrelated WIP — never git add -A, never a bare git commit. End every completed task with a commit and report the message back to the owner.Orientation drives display and the lightbox EXIF panel reads them. Every AVIF encode must copy the source metadata (ICC/EXIF/XMP) and must bake a non-normal Orientation into the pixels first (magick … -auto-orient), so derivatives render exactly like the JPG (the copied tag normalises to Horizontal (normal) — no viewer can double-rotate). resize.sh does both automatically; dropping metadata or skipping the orientation step is a regression. Verify per file: derivative dims == magick <jpg> -auto-orient dims, plus a magick compare -metric SSIM display check (~0.98+)._config.yml, templates, CSS and JS are LF in git; KANBAN.md and AGENTS.md are CRLF. The drvfs worktree may report either, so commit through a temp index (GIT_INDEX_FILE=… git read-tree HEAD, git hash-object -w, git update-index --cacheinfo) that stores each file with its existing convention — and never run LF normalisation over JPEG/AVIF binaries (it corrupts them). Finish with git reset -q so the real index is refreshed to the new HEAD.KANBAN.md is the single source of truth for tasks (IDs PT…); move lanes only when actually started/finished; log decisions in its Decisions log.assets/ ignores new files (.gitignore line /assets/): main.css / main.js are tracked and editable; a new file under assets/ will be silently ignored — git add -f it or fix .gitignore (tell the owner first).contact: endpoint in _config.yml is a public Google Apps Script URL by design; no analytics keys configured. Keep it that way._config.yml: image_root: images, image_fulls_loc: fulls, image_thumbs_loc: thumbs, preload_count: 12, exif: display list, baseurl: "", url: "https://pictrace.de" (used by jekyll-sitemap), prerender_locations: 5 (PT16 — how many locations the index and the year pages render inline), locations and years collections (output: true) and an exclude: list for local tooling._layouts/default2.html (page shell — inlines main.css through site.pages, preloads the page’s first thumbnails, loads vendored exifr (assets/js/vendor/exifr-7.1.3.lite.umd.js) + main.min.js (built from main.js via ./minify.sh; falls back to main.js when the min file is absent), both deferred) wrapped by _layouts/compress.html (jekyll-compress-html — the reason for the // ban). _layouts/location.html (PT16) renders a single location page: title, scoped gallery, links block../resize.sh (ImageMagick + avifenc + exiftool) → images/{year}/{NN}_{Location}/fulls (1024px) + /thumbs (512px), JPG + AVIF; ICC/EXIF/XMP are copied into the AVIFs, and a non-normal Orientation is baked into the pixels before encoding — every format renders exactly like the JPG (see the EXIF rule under Conventions). Since PT11 the script runs -j jobs in parallel and has maintenance modes: --backfill (missing AVIF from the published JPGs, thumbs first), --backfill --force (re-encode in scope) and --coverage (report; needs no encoder tools). AVIF coverage: 1907/1907 photos (fulls + thumbs). rename.sh stamps EXIF timestamps into filenames (name~YYYYMMDD_HHMMSS.ext)._includes/iterator.html walks site.static_files under fulls/ (skips .avif), groups year → location (newest first), sorts by ~ sort-key then natural name, and renders one <article> per photo — the lightbox figure lives inside its grid article (PT12 merged the two renders). No structural classes since PT24–PT26 — styles/scripts target .gallery article, article > figure, a[rel=prev]/a[rel=next], section[year]/section[location]. Scope parameters: only_year, only_location, limit_locations (the index prerender) and with_ids (a first pass that collects every slide id so the prev/next chain is precomputed). Slide ids are p-{yy}-{locnum}-{basename}; section anchors are p-{yy} (year) and p-{yy}-{locnum} (location) — scheme documented in .clinerules./{year}/{NN}_{Location}/, generated from front-matter-only stubs in _locations/*.html — regenerate them with ./mklocations.sh after adding or renaming a location (a missing stub = a missing page = a 404 from the links block). The index prerenders the newest prerender_locations (5) locations and lists every location in <nav class="location-links"> (legacy order), which doubles as the id → page manifest for the JS./{year}/ from stubs in _years/*.html (./mklocations.sh writes them together with the location stubs); it prerenders the newest prerender_locations (5) locations of that year and lists the year’s locations in a scoped <nav class="location-links"> — the same batch loader appends the rest on scroll. The Select popover links straight to the pages: locations → /{year}/{NN}_{Location}/, years → /{year}/ (no in-page anchors, no rewiring).main.js fetches the next batch of 5 location pages as the visitor approaches the end (IntersectionObserver on the links nav), injects their galleries, rewires the prev/next chain at both batch boundaries, re-registers injected journey maps from their data-progress attributes; on a year page (PT20) it loads that year’s remaining locations the same way. Since PT31 the lightbox walk drives the loader too: a step that brings the walk within 25 loaded slides of the end prefetches the next batch, and while unloaded locations remain the last loaded slide carries a real forward arrow whose press loads the batch and then follows the rewired href (keys, wheel, swipe and the half-screen zone all press this same arrow; the true end stays arrow-less). A deep link (#p-…) to a non-prerendered photo eagerly loads batches until the anchor exists, then opens the slide (PT27 — Chromium does not re-evaluate :target for elements inserted after the fragment was set, so the hash is re-asserted via location.replace; the location-page hop remains the fallback). Without JS: the prerendered 5 locations plus the links block.:target. The targeted figure (article > figure:target) is the fixed overlay (inside its article); prev/next are ordinary hash links; #p is the empty “closed” state; oversized ::before hit zones make clicks on the left/right half of the screen navigate (≥769 px; at ≤768 px the zones give up their pointer-events and every tap closes — PT17, swipes keep navigating). Pinch-zoom resets when the slide closes (PT18: the close click briefly flips the viewport meta to maximum-scale=1 — the flip must land before the close navigation runs). EXIF is fetched on hashchange in main.js (range request of the first 64 KB of the JPG, parsed by exifr). While a slide is open, the grid item that owns it is raised (z-index: 20011) and made click-transparent — only the prev/next arrows stay hit-testable — so clicks on the image fall through to the .close catcher; see the stacking-context pitfall below. The visible X is painted by the open slide itself (article > figure:target::after) — the .close link must stay below the raised item, so its own glyph would sit behind the backdrop (PT19). The dark backdrop must not fade: a fresh figure becomes :target on every prev/next step, and both the figure and its ::before carried an lbFade that restarted per step (the figure’s is a group opacity — it fades its whole subtree, the backdrop included), so the blur reset on each navigation (PT21). The fade-in lives on the slide image (article > figure img) instead._data/journeys.yml + _includes/journeys/{Name}.svg; travel path animates on scroll via makeProgressMapper (CSS scroll-timeline where supported, JS fallback); map tiles hidden ≤899px. The journey data reaches the page as data-progress attributes on the map <aside> — inline scripts do not run for injected HTML, so main.js initialises injected maps itself.assets/css/main.css, all site JS in assets/js/main.js — keep additions there; the shipped script is the minified main.min.js (see the JS bullet under Conventions). The only inline script is the contact-form handleSubmit in _includes/site-popovers.html. Third-party vendored files (self-hosted, PT9) live in assets/js/vendor/ + assets/fonts/ with their license files.bundle exec jekyll serve --host 0.0.0.0 (deps/Docker one-liner in README).jekyll build -s . -d /tmp/site (Jekyll 3.10 + jekyll-sitemap; ~130 s for ~1 900 photos + 57 location pages).master → GitHub Pages rebuilds. No manual deploy../resize.sh (interactive) or ./resize.sh 2026 "Paris" [-d] [-v] [-j N]; --backfill / --coverage for AVIF maintenance; ./mklocations.sh after adding locations (auto-run by resize.sh since PT30; --check reports drift); ./blur-bg.sh refreshes the baked backdrop (not automatic — commit the result).jekyll serve; check responsive breakpoints (grid 5/4/3/2/1 columns; lightbox mobile rules ≤768px) and cold-cache behavior for image-loading changes. For the PT16 loader, a file:// fixture (index + a few location pages) works: fetch the pages over file:// and stub window.fetch if the browser blocks it.AGENTS.md This orientation doc
_config.yml Jekyll config (image paths, preload_count, EXIF list, contact endpoint, prerender_locations)
_data/journeys.yml Journey metadata + animation curves
_includes/ iterator.html · gallery[.html|_item.html] · popup[.html|_item.html] · site-header.html · site-popovers.html · location-links.html · journeys/*.svg
_layouts/ default2.html (shell) · location.html (PT16 location page) · year.html (PT20 year page) · compress.html (minifier wrapper)
_locations/ Per-location page stubs (front matter only) — generated by mklocations.sh
_years/ Per-year page stubs (front matter only) — generated by mklocations.sh
assets/css/main.css All styles (a Jekyll page: front matter, inlined via site.pages)
assets/js/main.js All JS (journeys, gallery, EXIF, lightbox helpers, PT16 batch loader)
images/ {year}/{NN}_{Location}/{fulls,thumbs} · LICENSE.photos
bg-blurred.jpg Baked lightbox/page backdrop - in the ROOT, not images/ (that dir is the new-originals drop)
index.html Page entry: header, prerendered gallery, popovers, all-locations links
mklocations.sh Regenerates the _locations/ and _years/ stubs from images/
0/ · _p1/ Raw-photo staging dirs (mostly gitignored, never deployed)
resize.sh · blur-bg.sh · rename.sh Image processing / backdrop / renaming helpers
_site/ Generated output (gitignored)
.clinerules Detailed project rulebook — read before editing content/templates
KANBAN.md Task board
./mklocations.sh writes both the _locations/ and _years/ stubs from images/; a missing or uncommitted stub is a 404 (the links block, the year quick-links and the sitemap point at the missing page — PT30, Helsinki). Since PT30 the sync is automated (resize.sh end-of-run; the agent commit helper auto-includes stubs missing from HEAD) and ./mklocations.sh --check reports missing/stale stubs (exit 1).content-visibility: auto implies containment — it re-anchors position: fixed descendants (the lightbox overlay) to their article, shrinking it to the grid cell. The body:has(article > figure:target) guard must reset both contain: none and content-visibility: visible; contain: none alone does not undo it.<article>: items after it painted above the backdrop, and the page-level .close catcher painted above the arrows (every click closed the lightbox). Fixed by raising the item that owns the open slide (z-index: 20011) and making the slide + item click-transparent while open; don’t drop the animation instead — the fade would restart on every close. The ≤899 px filter: brightness(1.1) on .gallery article re-anchors fixed descendants like contain does — it is reset in the same guard..close link (PT19) — the link is the click-anywhere catcher and must stay below the raised slide item (else its full-viewport ::before zone swallows the arrows), but the backdrop lives inside that item, so the link’s own glyph would sit behind the blur. The slide’s ::after paints the X instead (z-index: 20012 in the slide’s stacking context, pointer-events: none; hover/focus feedback mirrored from the link via :has()). Do not “simplify” this by raising the .close z-index — the arrows break.::before (PT21) — a new figure becomes :target on every prev/next step, so an animation on the figure (or on its ::before) restarts per step; the figure’s is a group opacity that fades its whole subtree, the backdrop included, so the dark blur visibly reset on every navigation. The fade-in belongs to the slide image. Do not “fix” the reset with a single page-level backdrop element instead: the raised item (z-index: 20011) paints above it, so the open slide’s own tile shows through undarkened, and hiding the tile exposes the light page backdrop (white flash on open). The backdrop can only live inside the item’s stacking context, i.e. per slide.::before zones swallow touch gestures — they cover the left/right screen halves (the whole viewport on ≤768 px), so any touch guard testing closest('a[rel=prev], a[rel=next]') sees “an arrow” everywhere and PT15’s swipe was dead on phones. Test the arrow’s own rendered box (getBoundingClientRect(); 0×0 on phones, the glyph is hidden) instead — see the PT15 plan. Since PT17 the ≤768 px zone takes no taps at all (pointer-events: none; taps close the lightbox), so that box test is exactly what keeps swipes alive there..close catcher, and it only works while three rules stay in sync: a[rel=next]::before and the slide backdrop lose pointer-events, and .close::before must not be hidden. Break one and taps silently keep advancing (or do nothing). After a swipe exactly one navigation must fire — PT15’s touchend handler calls .click() itself, and a browser-synthesized click landing on the catcher would close the slide right after it opened (assert the click-target log, not just “it navigated”)..gallery’s touch-action: pan-x pan-y also restricts the lightbox — the open slide lives inside the gallery (PT12 merge), so the ancestor walk blocks pinch on the opened image (PT6). The open-state guard releases it (body:has(article > figure:target) .gallery { touch-action: auto; }); closed, the overview gating stays. Pinch can’t be probed with Input.synthesizePinchGesture in headless Chrome — use a manual two-finger touch sequence. A zoom left over after closing is reset by a brief viewport-meta flip on the close click (PT18); the flip must land before the close navigation — verify it with real taps on a fresh load, not only scripted hash changes (a hashchange-time flip is too late for tap closes).:target does not re-evaluate for elements inserted after the fragment was set — Chromium (PT27 finding). The eager deep-link loader must re-assert the hash (location.replace('#p') then the target — keeps the back-button history clean) or the slide never opens; do not build features that rely on :target styling of injected content.contain-intrinsic-size must be auto calc(100vw/N) (N = the column count at that breakpoint) — a fixed placeholder (e.g. auto 512px 384px) inflates the page height by more than 60 %.bg-blurred.jpg in the project ROOT, ~1.7 KB, regenerated by ./blur-bg.sh) — the runtime backdrop-filter was removed because it re-anchored fixed descendants (same class of bug as content-visibility).~ is treated as a sort-key file by iterator.html (key = text after the first ~; see rename.sh).Orientation tag (Rotate 270 CW etc.); encoding such a file without applying the tag renders it sideways (the PT11 backfill did exactly that: 232 files / 116 photos — list in .hermes/plan/PT11-images/orientation-affected.txt). Rule: keep the metadata and auto-orient before encoding (rotation baked into pixels). The earlier “metadata is bloat” cleanup was reverted for this reason — EXIF stays stable at all costs.resize.sh copies only a 9-tag EXIF whitelist and never the Orientation tag (the rotation is baked into the pixels via -auto-orient since ecd2613). Files processed before that fix — most 2026 locations (added 2026-03-16 … 2026-06-26) — have no orientation tag at all, so camera-sourced portraits among them render sideways (the owner’s darktable exports, already rotated, stayed correct — hence a “random” mix within a collection). Morocco’s 7 affected photos were re-derived 2026-09-20 (b24d0e4); the other locations still need their originals — census/suspects/re-derive procedure: .hermes/plan/PT11-images/jpg-orientation-affected.md.<img>s are loading="lazy" inside a display:none container — they may not start loading until their slide becomes :target (relevant to KANBAN PT2).git status is noisy whenever WIP is present — read it carefully before staging anything.