The engine was already a lazy import(), but its CSS was a static one — the
usual workaround for esbuild never emitting a <link> for a code-split chunk's
stylesheet (R10). That folded the whole vendor sheet into post-form.css, so
every /post load paid for a panel most submits never open, and ~78% of those
bytes were rules for controls this map never creates (popups, geolocate, zoom,
compass, fullscreen, terrain, scale).
Build the vendor sheet as its own css-compiled/maplibre-gl.css and have
location-map.js <link> it at panel-open, concurrently with the engine's
import(). Keeping the file intact rather than hand-picking the ~16 selectors
in use means a maplibre upgrade can't silently un-style the map.
post-form.css 92,244 → 26,784 raw (14,528 → 5,631 gzip)
The href resolves from import.meta.url, so it is correct under any Grav base
path without threading a URL through the template — the panel is built entirely
in JS, so there is no element to hang a data-attr on. A failed stylesheet load
resolves rather than rejects: the map degrades to unstyled, never to absent.
The existing lazy-load guard spec already matched every maplibre-gl URL, so it
covers the stylesheet too; its message now says so, and a new test asserts the
other half — that opening the panel does link and apply the sheet.
Two code-review follow-ups.
cleanCoordinate() — the server-side coordinate sanitizer the design doc and
plan both describe as already shipped — had never actually been committed; it
existed only as uncommitted work in another checkout, so this branch had no
server-side validation of lat/lng at all (the blueprint fields are plain
`type: text` with no `validate:` key). Landing it here makes the spec's stated
safety net real. Also corrected its onAdminSave comment, which justified that
hook by saying the public form's lat/lng inputs are CSS-hidden and GPS-filled
— true before this feature, inverted by it. Both hooks are needed: this branch
makes /post the primary hand-entry path, not Admin2.
The geocode lookup swallowed every failure and reset the button, leaving the
DOM byte-identical to the pre-click state — a traveller on flaky mobile data
could not distinguish a failed lookup from a broken button. It now shows a
distinct hint, and checks r.ok first so a 4xx/5xx body no longer parses as
"no results" and tells the traveller their city does not exist. R8's actual
guarantee (fields untouched on failure) is preserved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code-review fixes to the location-override panel.
parseFloat is a prefix parser, so '48abc', '48,85' (comma-decimal paste) and
'35.0116S' (hemisphere suffix, silently flipped to the wrong side of the
equator) all passed the isFinite + range check, cleared the mismatch flag and
were POSTed verbatim — the same silent-corruption class this feature exists to
end. Replaced with a whole-value decimal check, and the valid branch now
normalises to the 6dp the GPS handler and onDragEnd already write, so the pin
and the submitted value cannot disagree.
The submit gate keyed on the .location-field--mismatch class, which was only
ever set by syncFields() on blur / debounced input / toggle-open-success / GPS
— never at init. Three paths therefore reached the server unflagged: a draft
restored by initDraft() (which runs before initLocationDetails() and assigns
.value directly, firing no events), an edit-mode async prefill, and an open
panel whose maplibre chunk failed to load. syncFields() now runs once at the
end of initLocationDetails(), in the toggle handler's catch arm, and after the
edit prefill writes lat/lng.
Blanking both fields left the marker behind, presenting a stale coordinate as
if it were still the entry's — added clearPin() to the map handle and call it.
Removed hasPin(), which had no caller. Corrected two comments that named the
wrong stylesheet and claimed .field-invalid reuse the code does not do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code review (4 independent reviewers) converged on the same bug: the
maplibre-gl singleton cached its handle only after import() resolved,
so a fast close/reopen of the "More location details" panel could
race two Map instances onto one container. Cache the in-flight promise
synchronously instead, and propagate/handle import rejection so a
failed map load surfaces a hint instead of hanging silently.
Also closes a submit-time hole the adversarial pass found: the
mismatch flag on lat/lng was purely cosmetic and never blocked
form submission, so out-of-range coordinates could be saved. The
flag now gates submit like the other required fields, and clears
itself when both fields are blanked back out instead of sticking.
The geocode fetch gets a 10s timeout via AbortController so a hung
response can't leave the lookup button disabled forever.
Also moves the location-details CSS out of the site-wide style.css
into post-form's own code-split stylesheet, since none of it is used
outside the post form.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closed-by-default disclosure below City/Country holding a city/country
geocode lookup (Open-Meteo, city-only query with client-side country
ranking), a single-marker draggable MapLibre preview (lazy-loaded via
location-map.js), and the relocated lat/lng fields. GPS button, search
pick, pin drag, and typed values all stay in sync via syncPinFromFields(),
with a visual mismatch flag for unparseable typed input.
Built entirely in JS (mirrors the existing 'More options' disclosure
pattern) so post-form.html.twig needs no template change.
Single source of truth for the MapLibre style URL, shared between
maplibre-utils.js (multi-marker/GPX maps) and the new location-map.js
preview module — no behavior change.
- resolveTripChild now asserts the resolved page uses the trip template, so a
non-trip direct child of /trips could never be toggled through this endpoint
(P3 adversarial).
- apiSend gains an optional timeoutMs (AbortController); trip-publish passes 10s
so a hung toggle can't leave the switch stuck aria-busy. post-form omits it,
keeping media uploads unbounded (P2 reliability).
- trips.html.twig reuses trip.html.twig's one-line active-trip slug match
instead of a bespoke 3-branch OR (P2 maintainability).
- Draft-badge amber is now a --color-draft-accent token shared by the trip and
journal badges instead of a twice-hardcoded #E0A458 literal (P3).
Rebuilt js/trip-publish.js and js/post/post-form.js (shared api-utils change).
PHP lint clean; trip-publish suite 10/10; post suite unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
Address the P1 maintainability finding that trip-publish.js reimplemented
post-form.js's apiSend + login-expired error copy verbatim. Extract both into
js/src/api-utils.js and import from both entry points; esbuild inlines the
module into each bundle so there is no runtime coupling. Also drops the stale
data-trip-route reference from trip-publish.js's markup-contract comment.
Rebuilt js/trip-publish.js and js/post/post-form.js.
Verified: trip-publish suite 8/8; post suite unchanged (34 pass, same 6
owner-gate environmental fails as baseline — photo-editor specs that exercise
post-form's apiSend/apiErrorMsg all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
Add an owner-only publish switch to each /trips card. It POSTs to a new
entry-actions route that mutates trip.md `published` and invalidates the
page-tree cache so the listing, nav and home reflect the change on the
next load. Owner sees drafts (Draft badge); anon/non-owner unchanged.
- U1 EntryScopeGuard::resolveTripChild — resolve a slug to a direct child
of /trips (drafts included, for republish)
- U2 POST /api/v1/trip/{slug}/publish (setTripPublished) — owner-gated
write, strict is_bool body, header-mutation save, audit log
- U3 trip-publish-toggle partial + CSS (role=switch, Draft badge, visible
failure toast, ≥44px target)
- U4 owner-aware /trips listing + card restructure (toggle overlays cover
as a non-anchor sibling; works for coverless drafts)
- U5 home active-trip branch falls back when the active trip is unpublished
- U6 trip-publish.js (confirm/pending/optimistic/revert) + esbuild wiring
Cache note: an in-place frontmatter edit keeps the folder-check cache id,
and driver:auto uses APCu in web memory, so deleteAll()+invalidateCache()
is insufficient — the endpoint also calls apcu_clear_cache().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
Renders FilePond items as a 3-up square-thumbnail grid on create+edit,
disables native image-drag so FilePond owns reorder, and hides the
file-info overlay on already-uploaded (idle) items. This is the styling
QA'd and approved on the worktree dev server; it was previously left
uncommitted (mis-tagged as unrelated WIP in earlier handovers).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
initDisclosure hardcoded blueprint defaults into a field-name regex
(/\[published\]$/) to decide which toggle state counts as a deviation worth
auto-expanding "More options". Read each toggle's default from the HTML
`checked` attribute instead — Grav's toggle template stamps it on the default
option, and prefill/edit only ever set the live `.checked` property — so a
future default-ON advanced toggle Just Works. Rebuilt bundle.
Code review F4 (maintainability).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
initDisclosure auto-opened the More-options panel whenever any advanced field
'had a value', but the published toggle defaults ON, so a plain create form
tripped it every load. A toggle now counts only when it deviates from its
blueprint default (published: OFF is notable; force_connect/featured: ON is),
so the panel stays collapsed on create. Edit mode still force-opens it
separately. Rebuilt bundle via make build-assets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
Follow-up to the ce-code-review deferred items on the photo editor:
- Photo editor fetches now REJECT with a status-bearing error (apiSend)
instead of the boolean apiOk that swallowed the HTTP code. Reorder,
delete and add paths tell a lapsed login (401/403) apart from a generic
failure and prompt the owner to sign in again rather than "try again".
- Add-batch rollback: the per-file cleanup DELETEs no longer swallow
individual failures. If any rollback DELETE doesn't land (a stray
stock-named file could steal the lexicographic cover slot), the owner is
told cleanup was incomplete and to reload — instead of a false "rolled
back cleanly".
- entry-actions delete + reorder routes now emit an owner-attributed audit
log line, so a destructive mutation is traceable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses ce-code-review findings on the photo-editor media-API work:
- P0 (#1): PhotoRenumberer now renumbers EVERY on-disk image, using the
client manifest only as preferred ORDER and appending any omitted image
at the end. A stale/incomplete `order` (e.g. a second browser tab)
previously left an unlisted photo at a target slot for phase-2's
rename() to silently overwrite — verified data loss, now impossible.
The reorder route inherits the guard; create/reconcile is unchanged.
- P2 (#3): unique per-call token in the .reorder-tmp-* name so two
concurrent renumbers on one folder can't collide and clobber bytes.
- P3 (#7): de-duplicate the manifest so a repeated name can't shift/drop
a photo.
- P2 (#2): applyReorder + doDelete split the two failure stages — a failed
refresh AFTER a committed reorder/delete no longer reverts to a stale or
ghost state, it reconciles to disk. A DELETE 404 is treated as success
so a retried ghost cell converges.
- P2 (#4): both custom routes call requirePermission('api.pages.write')
so the GHSA-x7hm API-key scope cap applies (owner already holds it, so
the owner-only behaviour is unchanged).
- P3 (#8): refresh stale comments (photo-01..NN; drop editLoadPhotos ref).
PhotoRenumberer's 7-case unit suite still passes and the data-loss repro
now preserves all bytes. Assets rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the FilePond photo path in edit mode with our own thumbnail grid that
talks straight to the media API (the gpx-manager pattern). Add/delete/reorder
each persist immediately, decoupled from the form's text-field Save:
- Add: HEIC->JPEG client-side, stock POST .../media per file, then ONE reorder
after the batch (renumber photo-01..NN). On a failed reorder: auto-retry
(idempotent), else roll the just-uploaded files back so no orphan stock-named
image breaks cover=first. Upload progress shown per file.
- Delete: inline 'Delete? [Confirm] [Cancel]' (Confirm disabled in flight),
stock DELETE, then renumber the survivors.
- Reorder: SortableJS drag -> POST /entry/<slug>/photos/order. On failure the
move reverts to last-known-good; the shown grid never disagrees with disk
without an inline error.
- Loading + empty states; first cell badged Cover; photo-NN URLs cache-busted
since reorder reuses them for different bytes.
FilePond is fully decommissioned in edit mode (initPhotoConversion early-returns
under EDIT_MODE): no stale photo_order manifest is posted on text Save, so
cache-on-save can't delete a live-added photo. Create-mode FilePond is untouched.
Adds sortablejs (bundled into js/post via the post-form entry). SVG excluded in
the file-input accept; the server-side SVG block is a documented fast-follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
QA feedback fixes for the front-end journal edit form:
- Photos on edit showed a filename with no thumbnail. addFile(url,{type:'local'})
routed through the form plugin's FilePond server.load, which returned HTML (not
the image bytes), so image-preview had nothing to render. Fetch each image as a
Blob and add it as a File (ordered) — the thumbnail renders, and type:'local'
still means it is never re-uploaded and its filename rides the photo_order
manifest. Verified: fileType image/jpeg, previews render, reorder/remove unchanged.
- Distinguish a deleted/missing entry (API 404 → "this entry no longer exists")
from a transient load failure ("check your connection") in the prefill catch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-enable the FilePond browse/drop affordance in edit mode. On submit, existing
locals + new uploads ride the photo_order manifest and cache-on-save reconciles
the folder (delete dropped, renumber survivors photo-1..N, first = cover), so an
entry's photos can now be added, removed and reordered from the front-end edit
form. Verified end-to-end: add, remove, reorder, and all three in one save, plus
create-with-photos and edit remove/reorder regressions.
Depends on a local fix to add-page-by-form (its Grav-2.0 edit-mode header cast
fatals on a new upload); that plugin is git-ignored, so the fix ships as a
tracked patch in the superproject (deploy/patches/) rather than here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Editing an entry now loads its existing photos into FilePond so the owner can
remove and reorder them; the first photo is the cover. Adding NEW photos on edit
is intentionally suppressed (see below).
post-form.js (U7):
- On ?edit=, load the entry's current images into FilePond as LOCAL items (via
the session media API, gpx-manager pattern). They display for remove/reorder
and ride the existing photo_order manifest on submit, but are never re-uploaded.
- Exclude the FilePond field from the D1 prefill disable-sweep — FilePond reads
its input's disabled state at init and never re-enables, which had removed its
controls in edit mode.
- Suppress the add affordance in edit mode (allowBrowse/allowDrop off): a new
upload on edit hits add-page-by-form's Grav-2.0 edit-merge fatal
((array)$page->header() yields mangled protected keys → array_merge(null,…)).
That plugin is stock/GPM/git-ignored (no fork), so adding photos on edit is
deferred to the form-to-page/image-upload rework.
cache-on-save.php (U8):
- reconcilePhotos(): on edit, resolve the entry folder via the shared scope guard
(not the fuzzy create-path finder), delete any image dropped from the manifest,
then renumber survivors photo-1..N in the submitted order (cover = first).
- Run reconciliation ONCE per submit: onFormProcessed fires per process action
(4×); a 2nd pass deleted the just-renamed photo-N files as "unlisted".
- Empty manifest reconciles nothing (fail-safe: never wipes photos on a missing
photo_order).
Verified on the container: existing photos load (V9); remove + reorder persist to
disk with cover=first (V10); reconcile helpers covered by a reflection unit test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New custom-in-repo plugin entry-actions (un-ignored in .gitignore, NOT in
plugins.txt) registers DELETE /api/v1/entry/{slug} via onApiRegisterRoutes
(KTD5). The handler requires the authenticated site OWNER (not any login/admin),
rejects unsafe slugs (400), resolves the target through the page tree, asserts it
is a direct child of the active trip's dailies container, deletes the folder and
clears the cache — sharing EntryScopeGuard with the save path so R6 can't diverge.
A lazy per-namespace autoloader loads the controller on cached-route requests
(the router dispatches from route.cache without re-firing onApiRegisterRoutes).
EntryScopeGuard gains isOwnerUser() (API user comes from the request, not
$grav['user']) and enablePages() before find() (pages are lazily disabled in the
API context).
feed-actions.js (new, built via make build-assets; loaded on the trip/home feed
only when owner_can_edit) wires the inline Delete → Cancel/Confirm swap: on
Confirm it locks both buttons (D2, no double-DELETE), fetches the route
(credentials:include), removes the card, moves focus to the next card, and
announces via a page-level aria-live region (D4); on failure it restores the
control with an inline message (D7). Adds .sr-only + .entry-action[hidden] CSS.
Verified on the 2.0.4 container — API matrix 8/8 (anon 401, non-owner 403, bad
slug 400, out-of-scope 404, owner 204 + folder removed; V3/V5) and the delete UI
in a headless browser (confirm swap, card removal, disk deletion, live announce).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
The card Edit link opens /post?edit=<route>. post-form.js now (KTD4/KTD9, D1/D6/D7):
- On ?edit=, disables the form and shows 'Loading entry…' before the fetch (D1),
so slow-connection typing can't be overwritten by the incoming prefill.
- GETs /api/v1/pages<route> (credentials:include, the gpx-manager session pattern)
and populates every field from data.header.* / data.content: title, date
(space→T for datetime-local), content (EasyMDE), lat, lng, city, country,
weather select, temp, transport, featured, force_connect, published toggle.
- Sets the hidden edit_path to <route>/entry.md so cache-on-save toggles
overwrite_mode:edit and the save writes back in place (stable URL).
- Hides the photos section and skips the ≥1-photo rule (photos untouched in M1).
- Switches chrome to 'Edit entry' / 'Save changes' (D6); reveals More options.
- On fetch failure, shows an inline banner and keeps the form disabled (D7).
- Skips draft restore in edit mode; carries edit+return on the form action for a
future re-render (D3/D5). Rebuilt via make build-assets.
Verified in a headless browser: all fields prefill correctly incl. edit_path and
the published toggle, photos hidden, chrome correct (V6); fetch-failure banner
shown with fields left disabled (D7).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
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>
- 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.
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.
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
- 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
Replaces the boolean toggle with a select field offering:
on — connect all consecutive entries (chronological line)
manual — force_connect entries only (user-controlled connections)
intelligent_gpx — suppress connectors where GPX covers both endpoints;
force_connect overrides (original smart logic, restored)
off — no connectors at all; force_connect also ignored
buildJourneySegments gains an optional trackpointsPerFile param used
only by intelligent_gpx mode. renderGpxJourney extracts trackpoints
only when connectMode is intelligent_gpx. dailies.html.twig falls
back from intelligent_gpx → on (mini-map has no GPX tracks to
suppress against).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
autoconnect:true now connects every consecutive entry pair in
chronological order; the old proximity check (suppress where GPX
covers the route) is removed entirely.
- buildJourneySegments: drops allTrackpoints/thresholdKm params;
logic is now force_connect || autoconnect (binary, no GPX math)
- renderGpxJourney: no longer extracts trackpoints; just renders
visual GPX layers then calls buildJourneySegments
- dailies.html.twig: removes GPX URL collection, toGeoJSON CDN load,
and the Promise.all — connectors are now synchronous
- extractTrackpoints/isNearTrack/haversineKm removed (dead code)
- blueprint help text updated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Adds two configurable toggles to the trip blueprint (Admin2 Trip tab):
- use_gpx: show/hide GPX tracks on all maps (default: enabled)
- autoconnect: draw connector lines between markers (default: enabled)
When use_gpx is off, GPX files are not fetched or rendered on any map
(home, map, trip, dailies). The stats panel in trip.html.twig still
reads GPX_URLS directly and is unaffected.
When autoconnect is off, buildJourneySegments suppresses all
auto-connectors; only entries with force_connect:true still draw a
line — making force_connect behaviour independent of both settings.
Also refactors the inline Promise.all in trip.html.twig to use the
shared renderGpxJourney utility (reducing duplication).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
The home map was drawing an initial addJourneyLine, then trying to remove
layer 'home-journey' in the Promise.all callback — but addJourneyLine names
the layer 'home-journey-line', so removeLayer was a no-op and removeSource
failed (layer still referencing the source), leaving a ghost line on top of
the GPX tracks.
Extract the Promise.all → GPX tracks → buildJourneySegments → addJourneySegments
pattern into MapUtils.renderGpxJourney() and replace both map.html.twig and
home.html.twig with the shared call. No upfront journey line is drawn — the
function handles the no-GPX case correctly via Promise.all([]).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
Adds haversineKm, extractTrackpoints, isNearTrack, buildJourneySegments, and
addJourneySegments to the shared MapLibre GL IIFE. Updates MapUtils export to
expose the new functions. ES5-only; no arrow functions, const/let, or modules.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM