- 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
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
Banners/cards used cropResize (fit-inside), so a portrait fallback
source was handed back as a narrow sliver that object-fit:cover then
upscaled into a blur (reported on us-canada-mex-2024). Switch to
cropZoom (crop-to-fill) so the derivative is a real w×h cover strip.
Emit the 2x srcset descriptor only when the source is genuinely >=2w
wide (cover.width >= 2w), else 1x-only — no upscaling, no odd
intermediate widths. Imported pixelfed photos cap at ~1440px wide, so
auto-picked covers are usually 1x-only (see backlog: full-res reimport).
Also drop the no-photos-demo test fixture — it surfaced as stray demo
content in the trip list; AE4 (no cover + no images -> no banner) is a
trivial else-branch of the shared macro's cover guard, covered by
construction alongside the R7/AE3 fallback tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
The header.cover_image pagemediaselect field had no accept filter, so the
Admin media picker listed every file in the trip page folder — including the
GPX files placed there by the GPX manager. On a typical trip page (photos live
on the journal entries, not the trip page) the picker offered *only* GPX, and
selecting one routed a non-image Medium into cropResize, rendering a broken
<img> on both the trips list and the trip banner.
- Blueprint: add `accept: ['.jpg','.jpeg','.png','.webp','.gif']` so the picker
only offers images (prevention at source).
- Macro: resolve cover_image against `media.images` instead of all media, so a
non-image or unresolvable selection falls through to the entry-photo
auto-pick (defence-in-depth; also hardens the R11 fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
Give trips an optional one-liner (header.tagline) and description (markdown
content), surface them where they help, and fix the soft cover image — all
editable from admin.
- U1: header.cover_image blueprint field → pagemediaselect media picker.
- U2: new macros/cover.html.twig — single source for cover resolution
(author-selected → first journal image → none; missing file falls back)
and retina rendering (1x/2x cropResize + srcset). Merged resolve+render
into one macro since Twig macros can't return a Medium object.
- U3: trip-list card renders the one-liner (when set) and the retina cover.
- U4: trip-page in-column header gains the one-liner, an expandable
description, and a thin banner strip — gated behind a trip_header_extras
partial flag (default off) so the shared home active-trip view is
unchanged (R12/KTD4).
- U5: styles for the card/header one-liner, collapsible description
(max-height preview, not line-clamp, so it holds across paragraphs) and
the banner, with a mobile banner-height reduction.
Covers R1–R15. Verified with new Playwright specs + full trip/home/maps
regression against an isolated worktree dev server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
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
Render on each journal card, gated by owner_can_edit (R1/R3/R5/R6, KTD8):
- Draft badge (amber) when the entry is unpublished.
- Edit link -> /post?edit=<route>&return=<feed-url> (D5: a save from the home
feed returns to home, not always the trip page); feed_return_url threaded from
trip.html.twig/home.html.twig (page.url).
- Delete control with inline Cancel/Confirm button-swap (no browser dialog),
data-entry-route carried for U6's delete JS, plus an aria-live status slot.
- 44x44px min tap targets (D8); titlerow wraps on narrow phones.
Verified on the container: anon sees no controls/badge; owner sees Edit+Delete on
all 13 cards with a Draft badge on the unpublished one; a non-owner authenticated
session (testrunner vs owner mischa) sees none and no drafts (V5). Browser
screenshot verification unavailable (no local Playwright); did a code-level
layout review instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
Owner-aware draft visibility (R5, KTD7/KTD8) on trip.html.twig and home.html.twig
(active-trip branch):
- Compute owner_can_edit = authenticated AND username == site.owner_username AND
(this is the active trip). The super-admin tester authenticates too, so the gate
is owner identity, not mere login.
- The feed list (all_items) uses an owner-aware journal collection: owner sees
drafts, everyone else (and every non-active-trip view) sees published only.
- journal_entries stays published-only — it feeds stats/counts. map_entries now
filters on item.page.published, so drafts get a feed card but no marker and no
stat contribution.
- Thread owner_can_edit into trip-feed-col (defaults false) for the U4 controls.
Between-trips home grid stays published-only. Stories untouched (journal drafts only).
Verified on the container: draft entry with coords shows in the owner's feed but
not the map or count; absent entirely for anonymous (V4).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
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>
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.
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.
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>
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
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
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