The API plugin's enabled/route/session_enabled/cors/rate_limit config
lived only in the untracked user/plugins/api/api.yaml, so a fresh install
(prod) had no working /api — Admin2, which authenticates via /api/v1,
could not log in. Move the functional config into the tracked override
user/config/plugins/api.yaml (merged over the plugin defaults). Secrets
(JWT) stay in the gitignored api-private.php; the auto-generated
popularity salt is intentionally not committed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
Get Location now reverse-geocodes the captured coordinates into City +
Country via BigDataCloud's free client endpoint, filling only blank fields
(never clobbering a manual entry) and appending the resolved place to the
location status. Best-effort — a failure leaves the coordinates intact.
Removes the redundant hero_image field: journal entries render their hero
from the first uploaded photo (entry-journal uses entry.media.images|first),
so an explicit hero filename served no purpose. Stories still use hero_image
but are not posted through this form.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Raise the FilePond limit from 4 to 6 and enforce a minimum of one photo.
The photo field is first in the form, so initValidation checks it first: an
empty picker blocks submit, reveals the (possibly collapsed) photo section,
and shows "Add at least one photo." under its header. Labels updated to
"Photos (1–6)".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stock Grav `datetime` field template is deprecated and falls back to a
plain text box, so `type: datetime` + `default: now` rendered a raw input
showing the literal word "now" — unusable. Add a theme override at
templates/forms/fields/datetime/datetime.html.twig that renders a native
<input type="datetime-local"> (real calendar+clock, great on mobile), drop
the `default: now`, and prefill the current local time from post-form.js.
Also add `date` to the existing client-side validator. Together with the
picker (which can't hold an invalid value) this stops a bad/empty date from
round-tripping to the server — which was the trigger that made Grav re-render
the managed FilePond field from the session flash as filename-only inputs and
resurrect a photo the user had removed. Grav still reformats the submitted
value to the blueprint `format: 'Y-m-d H:i'` on save, so stored dates and
folder slugs are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Styling: repaint FilePond's default light drop zone/thumbnails/actions with
the Field Notes dark tokens so the picker matches the site palette.
Reordering: enable FilePond drag-reorder (allowReorder, itemInsertLocation
'after'). FilePond does NOT re-sequence its submitted data[photos][] inputs on
reorder, so post-form.js sends the visual order as a top-level `photo_order`
POST key on submit. cache-on-save reads it from $_POST (after add-page-by-form
copies the files, priority -100) and renames them photo-1..N in that order —
which the entry honours since it lists media by filename and treats the first
as hero. The order key is top-level (not data[...]), so it never lands in the
entry frontmatter. Best-effort + self-idempotent: locates the new entry folder
by the uploaded filenames and no-ops if they're already renamed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grav 2.0's schema migration replaces the 1.7-era twig_compat with
twig2_compat/twig3_compat. Matching the repo to what the upgraded server
already generated so a content reset doesn't revert it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
- Reorder blueprint to Photos → Title → Content; drop title autofocus so the
picker leads (it anchors what you write).
- After upload the photo section auto-collapses to a live summary bar
("✓ N photos ready — tap to review") and re-expands on tap; the field's
own .form-label is hidden so the <summary> is the sole header. Trailing
refreshes settle the summary past FilePond's event/DOM timing gap.
- Replace FilePond's murky completed-thumbnail overlay with a clean green ✓
badge (remove action left intact).
- On a successful post, hide the reset form + location/weather controls so
only the "✓ Saved" confirmation + View-journal CTA remain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three field-reported bugs, all root-caused on the isolated test server:
1+2. FilePond's stylesheet never loaded — the filepond field registers it via
assets.addCss() during body render, too late for the theme's head-only
{{ assets.css() }}. Photo tiles rendered as giant unstyled boxes that
stacked and overlapped the rest of the form (Get Location/Weather, Submit),
making it unusable and looking like upload errors. Load filepond.min.css +
image-preview CSS in the head_assets block; hide the PQINA credit.
3. The success notice rendered at the top of a long, reset form (off-screen
after submitting from the bottom) and .notices was unstyled on the dark
theme. Style .notices; on load, scroll the confirmation into view and inject
a 'View your journal' link (to site.active_trip) + 'Post another' CTA.
Verified in a browser: 3 photos render compact without overlap; post-submit
shows the confirmation + working view link.
Runtime verification showed the custom picker uploaded to Grav's flash but
never attached photos to the entry — attachment needs FilePond's exact
(undocumented) submit contract. Reverting to type:filepond and hooking its
beforeAddFile: a HEIC item is rejected, converted to JPEG via the lazy heic-to
chunk, then re-added with pond.addFile() so FilePond owns upload + page-attach
(the proven path). Web-format photos pass through; conversion failures fail
closed (inline status, original never added). Removes the custom photos field
template + AJAX uploader.
Verified end-to-end in a browser: HEIC posts as JPEG, corrupt HEIC is skipped,
Submit gated while converting, draft photos-reselect hint intact.
Refs R8, R9, R16, R17, AE1, AE4, KTD4.
The API plugin (>=1.0.7) relocates jwt_secret from config/plugins/api.yaml
into a generated config/plugins/api-private.php. Ignore it like the other
per-instance secrets (git-sync.yaml, security.yaml) so it is never committed
or pushed by git-sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
Mirror text field values (incl. EasyMDE content) to localStorage on every
edit and restore them on load. Clear the draft only when the server confirms
a successful post (.notices.success) — the invariant that guarantees text
survives validation failures, save errors, and session expiry (login form
shown → form absent → draft left intact for post-reauth restore). Photos are
not persisted (File/Blob can't serialize); on restore an inline hint says
they need re-selecting.
Refs R19 (preserve-on-failure), R20, KTD6.
Migrate Get Location/Weather + required-field validation out of the
template's inline scripts into the bundle: generalize the field lookup so
weather_desc (now a <select>) is found, gate Get Weather until coords exist,
and add idle/loading/success/error states. Build the 'More options' <details>
(native, KTD5) around the advanced trio, auto-open when any has a value.
Add Field Notes CSS: select/number styling, EasyMDE dark theme, disclosure,
button spinner, and server form-message states.
Refs R5, R11, R12, R18, R19, AE3, KTD5.
Replace /post's managed filepond field with a controlled 'photos' field
(theme forms/fields/photos) + picker logic in post-form.js: magic-byte
sniff (ISO-BMFF ftyp brands, not filename/MIME), lazy import('heic-to')
only for real HEIC (deferred 3MB chunk via ESM splitting), per-thumbnail
converting/uploading/done/error states, in-flight counter gating Submit,
and fail-closed skip on conversion failure. Converted JPEGs POST to Grav's
AJAX file-upload route into the form flash (the only path copyFiles reads),
so add-page-by-form attaches them on submit. Web-format photos pass through.
Refs R8, R9, R16, R17, AE1, AE4, KTD4.
Add a page-scoped js/src/post-form.js bundle (built ESM + code-splitting
so U4's heic-to becomes a deferred chunk) loaded only by post-form.html.twig
via a new base head_assets block. EasyMDE replaces the bare textarea with a
minimal toolbar (bold/italic/list/link/preview), FA-free via CSS glyphs, and
syncs codemirror.save() on change + capture-phase submit so the inline
required-field validator and payload see the live value.
Refs R7, R15, KTD2, KTD3.
Derive the entry write target from site.active_trip at submit time in
cache-on-save's onFormValidationProcessed handler, and fail closed
(ValidationException) when no active trip is set. Removes the hardcoded
pageconfig.parent that had to be hand-synced with active_trip.
Refs R1, R2, AE2, KTD1.
The back pills use history.back() when history exists, but fall back to
href=page.parent().url for direct-landing visitors (shared link, new tab).
After Phase 1 that fallback pointed at the retired /dailies (404) and
/stories (redirect) containers. Point it at the trip page (grandparent)
instead, which is where the feed now lives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
trip.html.twig and home.html.twig hand-wrote near-identical map-column
markup + a MapUtils.initEntryMap() invocation. Extract both into
partials/entry-map.html.twig, parameterised (map id, global, entries,
gpx config, story markers). Callers now pass resolved header values.
The map engine (js/map.js initEntryMap) is unchanged; emitted config is
byte-equivalent to the previous inline scripts. Verified: trip-map,
home-map and window.tripMap/homeMap Playwright tests pass; both pages 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the demo's naming (was 'The Journey' on real trips). These
containers are inert folders now; the title just labels what's below.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of the standalone sub-page cleanup. The trip page consolidated
these views (inline map + filter bar + inline stats), leaving the
standalone pages unreachable and the last consumers of the old map
code path (feed-map inline script + map.html renderGpxJourney variant).
- Delete templates: map, stats, dailies, stories + feed-map partial
- Delete 02.map/03.stats page folders across all trips
- Keep 01.dailies/04.stories as inert data containers (routable:false)
- Fix demo source + Makefile demo-load so reload stays consistent
- Drop dead map-page body class in base.html.twig
Site converges on MapUtils.initEntryMap(); child entries/stories remain
reachable (verified: keepers 200, retired 404, no Twig errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the duplicated MapLibre init orchestration (map construction,
marker/popup loop, bounds fit, GPX journey, fullscreen toggle) into one
config-driven MapUtils.initEntryMap(opts) in maplibre-utils.js, bundled
into map.js. Convert trip.html.twig and both home.html.twig branches to
call it; home active gains the flash-highlight + a fullscreen button to
match trip, and home highlights' marker click now navigates to the
article. Adds a markLatest opt (false for highlights) and exposes the
map as window.tripMap/window.homeMap (used by existing Playwright specs).
feed-map.html.twig and map.html.twig left on their inline init (deferred).
Plan: docs/working/plans/2026-06-27-map-init-consolidation.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
Addresses code-review finding: the pre_departure branch made trip-feed-col
dual-purpose. Pre-departure landing now lives in its own partial; home picks
it via {% if all_items|length == 0 %}, and trip-feed-col drops the
pre_departure param to be shared 1:1 by both callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
- Header: offset the 4px green ::before bar so content centres in the
visible dark area, not the geometric box (fixes wordmark/nav/icon
sitting ~2px high — the root cause behind the whole alignment saga)
- Mobile (≤768px): collapse nav into a slide-down hamburger panel
(standalone js/nav.js); inline nav retained on desktop
- Nav: use the display serif in sentence case so it shares the
wordmark's anatomy and aligns naturally (no optical nudge needed)
- Typography tokens: three-tier display tracking (--tracking-display,
--tracking-display-sm), --tracking-caps, --color-error, paper-glass
overlays, --text-story/--leading-story; applied across components
- trip-dates: move inline styles to a class
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
After the CDN elimination refactor, maplibregl moved from an inline head
script to the bottom-group bundle — but home.html.twig still called
new maplibregl.Map() immediately, before map.js had loaded. Wraps both
the active-trip and between-trips script blocks in DOMContentLoaded,
matching the pattern already used in trip/map/feed-map templates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Lowercase all weather_icons map keys to match stored entry values
('sunny', 'partly cloudy', etc.); add |lower filter as safety net
- Remove vertical-align hack from location pin SVG
- Add display:inline-flex + align-items:center to .journal-post-location
and .journal-post-weather so icons and text share a true center axis
- feed-map.html.twig: remove addCss/addJs calls (too late for <head>); merge
two <script> blocks into one wrapped in DOMContentLoaded
- map.html.twig: move {% block map_assets %} above {% block content %} so
map.css reaches <head>; add DOMContentLoaded around map init
- dailies.html.twig: add {% block map_assets %} override so map.css and
map.js are registered for the feed-map partial
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Move stats computation and both panel HTML divs out of trip.html.twig
into dedicated macros/stats.html.twig and macros/cycling.html.twig.
trip.html.twig imports both at the top of {% block content %} and
calls them via stats_m.stats_panel() and cycling_m.cycling_panel().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Move Piran entry out of us-canada-mex-2024 into a new slovenia-2024 trip.
Rename entry folder to match the post title convention.
Fix us-canada-mex-2024 date_start to 2024-07-21 (first actual US entry).
When fullscreen is active, clicking a marker now triggers fsBtn.click()
to exit cleanly (handles class, body overflow, tripMap.resize + icon),
then waits 450ms for the exit animation before scrolling to the entry
and firing the highlight. Also fixes missing icon-swap CSS for
.home-map-col.is-fullscreen (was only targeting .feed-map-wrap).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
grid-template-rows: 0fr fails to fully collapse when the direct grid
child has overflow:hidden (creates a BFC that prevents 0-height).
max-height: 0 → 600px with overflow:hidden is simpler and reliable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Attribution: MapLibre v4 uses <details> and may open it after load
regardless of compact:true — remove the open attribute in the load
handler to guarantee collapsed state.
Button: switch from teal to --color-canvas (#22201B) so it sits quietly
against the dark map; icon reads in --color-ink (warm cream).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Button in bottom-right of #trip-map (z-index:1000), hidden ≥769px.
Attribution moved to bottom-left to free the corner. Clicking toggles
.is-fullscreen on .home-map-col (position:fixed, 100dvh), locks body
scroll, and calls tripMap.resize() for MapLibre to re-render.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Button is back inside #feed-map with z-index:1000 to clear all MapLibre
layers. Attribution control disabled in constructor and re-added to
bottom-left so bottom-right is free for the fullscreen button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
MapLibre's attribution button occupies bottom-right of the container.
Moving our button out of the map div avoids MapLibre's DOM entirely,
and top-right is clear of all default MapLibre controls.
Position anchor moves to feed-map-wrap (position:relative).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Button in the bottom-right corner of the map, hidden ≥769px. Clicking
it toggles .is-fullscreen on .feed-map-wrap (position:fixed, full
viewport), locks body scroll, and calls feedMap.resize() so MapLibre
re-renders at the new size. Icon swaps between expand SVG and ✕.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
22px floor was too close to the preferred at 375px (6vw=22.5px), so
values were pinned near the minimum. 28px floor makes values pop more
on small screens while long values like ~12,366 still wrap gracefully.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Replaces fixed 3rem with clamp(--text-sm, 5.5vw, --text-3xl) so long
values like "4:32:15" scale down on mobile instead of overflowing.
Desktop (≥870px viewport) is unchanged at 3rem.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
7 cycling stat blocks in a 2-col mobile grid leaves a lone card in the
last row's left column with empty space on the right. Using
:last-child:nth-child(odd) + grid-column: 1/-1 spans that card across
both columns. Also minmax(0,1fr) on both grids for strictly equal widths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
- Radius: trip-panel-toggle now uses --radius-full, consistent with filter pills
- Animation: stats/cycling blocks use CSS grid-template-rows 0fr→1fr transition
(inner trip-panel-inner div carries decoration so border/padding don't peek
out when collapsed; display:none removed)
- Close button: ↑ Close stats / ↑ Close cycling at bottom of each panel,
hidden ≥769px, triggers the header toggle via data-toggle attribute
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Border + 4px radius instead of borderless text, matching the visual
weight of the filter pills without the full pill roundness.
Active state gets teal border + accent-light background like other active controls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Filter bar now has one job: content type (All/Journal/Stories) + sort icon (↑/↓).
Stats and Cycling move to a lean text-button row below the bar with a
rotating ▾ caret — CSS handles expand/collapse state via .is-active,
no JS changes needed for the caret animation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Button sits in the right filter group alongside Stats/Cycling.
Default state: ascending (↑ Oldest first, no highlight).
Toggled state: descending (↓ Newest first, is-active pill style).
DOM reversal uses insertBefore against the anchored #feed-filter-empty
so the empty-state message stays last regardless of sort direction.
Interacts safely with the type filter (show/hide by data-type).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
pswp.currSlide is a Slide instance whose DOM element is stored as
.container (.pswp__zoom-wrap). The .el property belongs to the
itemHolder wrapper, not the Slide — so currSlide.el was always
undefined, the null-guard exited early, and no animation played.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Previous approach (CSS transition + reflow trick) is unreliable in
Firefox. New approach: PhotoSwipe emits 'change' synchronously before
painting; we add a direction-aware CSS keyframe animation to the
incoming slide element, with animation-fill-mode:both so there is no
flash before the animation starts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
PhotoSwipe's goTo() moves slides instantly (no spring animation unlike
swipe). Intercepts keydown in capture phase, sets a CSS transition and
forces a reflow before PhotoSwipe moves the container, so the browser
animates from the old position to the new one. Cleans up on close.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Creates partials/entry-journal.html.twig and partials/entry-story.html.twig
so trip, dailies, and home all use the same up-to-date markup. Home page
gains PhotoSwipe, blurred fill, adaptive aspect ratio, and hash-based
marker scroll. Future changes only need to happen in one place.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Removed redundant margin-bottom on .journal-post (feed gap already
separates items). Reduced padding-bottom and gap from 3rem to 2rem,
cutting the between-entry whitespace roughly in half.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Portrait entries (first image taller than wide) get a 4:5 container —
Instagram's proven cap that prevents single photos dominating the screen.
Landscape entries keep 4:3. Aspect-ratio moved from slide to wrap so the
strip inherits it via height:100%.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
- object-fit: contain + ::before blurred background fill on all slides
- dots moved inside photo-wrap, overlaid at bottom with shadow for contrast
- arrows hidden on touch devices via @media (hover: none)
- margin-bottom increased for breathing room below photo block
- trip.html.twig brought up to parity with dailies: same structure,
same PhotoSwipe init, same expand button, same dot overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Move PhotoSwipe CSS from per-entry assets.addCss() (runs after head is
committed) to a single <link> tag at block start. Restore the expand
button as the reliable mobile tap target — it dispatches a synthetic
click on the visible <a> slide, which bubbles to PhotoSwipe's gallery
handler. Merge dot sync into the single module script.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Drops ~80 lines of fragile custom lightbox JS/HTML/CSS in favour of
PhotoSwipe v5 loaded from CDN. Slides are now <a> tags (the pswp-gallery
children) which always fire click events on iOS even inside scroll
containers — the root cause of the mobile tap issue. Pinch-to-zoom and
swipe-to-dismiss come for free. Dot sync kept as a separate vanilla JS
block.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
- Aspect ratio 3:2 → 4:3 (less aggressive crop; closer to phone native)
- Slides become <button> elements with data-full pointing to original image
- Tap/click any photo in the feed opens a full-screen lightbox showing the
uncropped original; prev/next browses all feed photos; Esc/arrows/backdrop
click close
- Dot indicator now syncs with scroll via IntersectionObserver
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr