The feat/journal-post-form branch is now merged into main; drop the stale "not yet pushed / awaiting owner go-ahead" tail from the Status line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
41 KiB
artifact_contract, artifact_readiness, product_contract_source, title, type, date, execution
| artifact_contract | artifact_readiness | product_contract_source | title | type | date | execution |
|---|---|---|---|---|---|---|
| ce-unified-plan/v1 | implementation-ready | ce-brainstorm | Journal Post Form Improvements - Plan | feat | 2026-07-04 | code |
Journal Post Form Improvements — Plan
Status: ✅ Complete (2026-07-04) — implemented on feat/journal-post-form (U1–U7). U4 changed course during execution: the "plain input + custom uploader" fallback uploaded to Grav's flash but couldn't attach photos to the entry without replicating FilePond's undocumented submit contract, so photos now stay on type:filepond with a beforeAddFile hook that converts HEIC→JPEG then re-adds via pond.addFile() (FilePond owns upload+attach). Verified end-to-end in a browser (HEIC→JPEG attach, corrupt-HEIC fail-closed, disclosure, weather gating, draft restore) and via curl (active-trip parent injection + empty-active_trip fail-closed). Merged into main on 2026-07-08 (outer-repo feat/journal-post-form).
Plan type:
feat· Depth: Deep — feature · Origin:/ce-brainstorm"improve the current php plugin that allows me to add a new journal page to the current active trip" (2026-07-04)
Product Contract preservation: Product Contract unchanged. Planning enriches this artifact in place — Requirements R1–R20, Key Flows, and Acceptance Examples are carried verbatim from the brainstorm.
Goal Capsule
- Objective: Redesign the frontend
/postjournal form so a daily entry can be posted end-to-end from an iPhone — auto-targeting the active trip, exposing every entry field, loading a light markdown editor, converting HEIC photos in the browser, and matching the Field Notes design system. - Authority hierarchy: This plan's Requirements (R1–R20) and the three resolved Key Technical Decisions govern. Where an implementation detail is unspecified, follow existing repo conventions (esbuild bundle, Playwright suite, plugin structure).
CLAUDE.mdproject rules override everything (only write insidetravel-blog-intotheeast/;user/is a standalone repo; never read.env; usemakefor remote ops). - Stop conditions: Stop and surface if (a) intercepting Grav's managed FilePond instance for HEIC conversion proves infeasible without replacing the field type (U4 is the load-bearing risk), or (b) any change would require a server-side image pipeline or Docker rebuild — that path is explicitly deferred.
- Execution profile: Frontend-weighted. One PHP handler (U1); the rest is form blueprint, Twig, an esbuild bundle with two new npm deps, and CSS. Dev server at
http://localhost:8081; rebuild JS withmake build-assets(never hand-editjs/main.js). - Tail ownership: Verify with
make test(config + post + Playwright UI) and manual dev-server walkthrough on a narrow viewport.
Product Contract
Summary
Redesign the frontend /post journal form to auto-target the active trip, expose every entry-blueprint field (core visible, advanced behind "More options"), load a light markdown editor, convert iPhone HEIC photos to JPEG in the browser, and match the site's Field Notes design system — all behind the existing site login, optimised for posting from an iPhone during a trip.
Problem Frame
Posting a daily entry today has five rough edges. The parent trip is hardcoded in user/pages/02.post/post-form.md (pageconfig.parent) and must be kept in sync by hand with site.active_trip; forgetting on a trip switch silently files entries under the wrong trip. The form exposes only a subset of the entry blueprint, so transport_mode, hero_image, force_connect, featured, and a proper weather-condition picker are unreachable without opening Admin2. The content field is a bare <textarea> — the bundled SimpleMDE never loads because add-page-by-form keys its editor on a form named add_page*, and this form is new-entry. Photos come straight off an iPhone, most often as HEIC, which Grav's GD pipeline cannot read at all, so thumbnails break. The form also uses generic Grav markup rather than the site's visual language, and isn't tuned for one-handed mobile use — which is the only way it will be used during the trip.
Key Decisions
- Active trip is resolved dynamically, not hardcoded. The parent is derived from
site.active_tripat submit time instead of from a staticpageconfig.parent.add-page-by-formalready honours a submittedparentvalue (add-page-by-form.php~L521), so a small server-side hook injects<active_trip>/dailies. This removes the manual two-file sync and its silent-misfile failure mode. - HEIC is handled client-side only. Desktop story work sources images from Immich, which already yields JPEG, so HEIC only ever originates from this mobile form — a single path. A browser converter covers it without adding a libheif-enabled ImageMagick to the baked Docker image. Server-side conversion would maintain infrastructure for a case that, by the owner's workflow, never occurs.
- Advanced fields sit behind a "More options" disclosure. Core fields stay visible for fast phone posting;
hero_image,force_connect, andfeaturedare collapsed by default but reachable — nothing is Admin-only anymore. - Editor is EasyMDE. The maintained SimpleMDE successor, with a minimal toolbar (bold, italic, list, link) plus a preview toggle — enough affordance without the mobile clutter of a full toolbar.
- The footprint is mostly frontend, not PHP. Despite the original framing, the only PHP change is the active-trip parent injection. Fields, editor, HEIC conversion, styling, and mobile layout all live in the form blueprint, the Twig template, and its JS/CSS.
Requirements
Active-trip targeting
- R1. Submitting the form stores the new entry under the currently active trip's
dailiesfolder, resolved fromsite.active_tripat submit time. - R2. Switching trips (changing
active_trip) requires no edit to the post form; the hardcodedpageconfig.parentcoupling is removed.
Entry fields
- R3. The form can set the following entry fields: title, date, content, photos, location (city, country, lat, lng), weather (condition, temperature),
transport_mode,hero_image,force_connect,featured. (title/date/content/photos are page-level and media fields supplied by the form; the remaining fields live inentry.yaml.) - R4. Core fields are always visible: title, date, content, photos, location, weather condition, weather temperature, transport mode.
- R5.
hero_image,force_connect, andfeaturedsit behind a "More options" disclosure that is collapsed by default. - R6. Weather condition is a labelled picker matching the blueprint's options; the existing "Get Weather" action pre-fills it, and it stays manually overridable.
Editor
- R7. The content field uses EasyMDE with a minimal toolbar (bold, italic, list, link) and a preview toggle, bound to the underlying content field so both submission and validation read its value.
- R15. EasyMDE syncs its content back to the underlying textarea before the form's custom required-field validation runs (e.g.
editor.codemirror.save()on submit, or bound on change), so a valid entry is never rejected as empty and an empty one never slips past.
Image handling
- R8. HEIC/HEIF photos selected on the device are converted to JPEG in the browser before upload, so only web-renderable images reach the server.
- R9. Conversion is a no-op for photos already in a web format (JPEG/PNG), including HEIC that iOS Safari has already transcoded on file-pick.
- R10. No server-side or Docker image change is required; image handling is entirely client-side. (Client-side conversion is a UX convenience; the trust boundary at the upload endpoint is an accepted, deferred gap — see Open Questions.)
- R16. HEIC/HEIF is detected by content sniffing, not filename or MIME alone. If a photo is HEIC/HEIF and conversion fails, times out, or the file is corrupt/ambiguous, that photo is blocked from upload with an inline error while other selected photos and Submit remain usable; the original HEIC is never posted.
- R17. Each photo still converting shows a per-thumbnail "converting…" indicator, and Submit is disabled until every selected photo has finished converting.
Styling and mobile UX
- R11. The form is styled to the Field Notes design system (teal accent, DM Serif Display + DM Sans, warm paper background), consistent with the rest of the site.
- R12. The form is single-column and mobile-first: large tap targets, native-keyboard-friendly inputs, a comfortable writing area, and smooth "Get Location" / "Get Weather" / photo-capture actions on iPhone.
- R13. Photo input supports selecting or capturing images from an iPhone, up to 4.
- R18. "Get Location" and "Get Weather" each expose idle, loading (spinner on the button), success (fields filled), and error/permission-denied states; on failure an inline message appears and the fields stay manually editable.
- R19. Submit runs blocking inline validation with per-field messages for missing required fields (at minimum title and content, matching the form's current validation), and on a failed save it preserves all entered input and surfaces a retry.
Access
- R14.
/postremains gated by the existing frontend site login; one login persists for the session. No public or unauthenticated posting. - R20. If the site-login session expires while an entry is being composed, submitting does not lose the in-progress text: the entered title, content, location, weather, and other field values are preserved so the owner can re-authenticate and resubmit. Scope limit: selected/converted photos are not preserved across a reload or re-auth —
localStoragecannot holdFile/Blobobjects — so photos must be re-picked after re-authenticating. The form surfaces an inline hint to that effect rather than silently dropping them.
Key Flows
- F1. Post a daily entry from an iPhone.
- Trigger: owner opens
/poston their phone (already logged into the site). - Fills title, date, content (EasyMDE), taps "Get Location" then "Get Weather" to auto-fill coords + weather, sets transport mode, optionally expands "More options".
- Picks up to 4 photos. Any HEIC is converted to JPEG in the browser before upload; already-web-format photos pass through untouched.
- On submit, the entry is written to
<site.active_trip>/dailies(parent injected server-side), media attached, and the page cache cleared so it appears immediately in the feed.
- Trigger: owner opens
Acceptance Examples
- AE1. Covers R8, R9. A HEIC photo is selected → converted to JPEG client-side → the posted entry renders with a working thumbnail and hero. A JPEG photo is selected → uploaded unchanged.
- AE2. Covers R1, R2. With
active_trip: /trips/japan-korea-2026, a new post lands in/trips/japan-korea-2026/dailieswithout any edit to the form definition. - AE3. Covers R4, R5. On load, title/date/content/photos/location/weather/transport are visible;
hero_image,force_connect, andfeaturedare hidden until "More options" is expanded. - AE4. Covers R16, R17. While a photo converts it shows a "converting…" indicator and Submit is disabled. A HEIC photo whose conversion fails (or a corrupt/ambiguous file) is blocked with an inline error while other photos and Submit stay usable; the original HEIC is never posted.
Scope Boundaries
- Deferred: server-side HEIC conversion and a custom libheif-enabled ImageMagick Docker image — revisit only if HEIC begins arriving through a non-Immich path.
- Deferred: server-side upload validation (accept-list + size cap on
/post). The authenticated SVG/HEIC/oversized-payload gap is real but login-gated and low-risk for a solo owner; left in Open Questions rather than pulled into this plan. Client-side conversion is UX, not the security boundary. - Separate brainstorm: moving story authoring into a frontend add-page flow ("capture a story from the road"). Stories remain desktop-authored for now.
- Unchanged: the auth model (no PIN/magic-link), the travel-memories / Immich pipeline, and Admin2 authoring.
Dependencies / Assumptions
- Desktop story images come from Immich as JPEG — this is what makes client-side-only HEIC handling sufficient.
add-page-by-formcontinues to honour a submittedparentvalue that overridespageconfig.parent.- A browser HEIC→JPEG library (heic-to) integrates into the filepond upload step.
- EasyMDE can be bound to the content field so its value syncs to the submitted form data.
- The frontend Login-plugin session persists on iOS for the trip's duration.
Planning Contract
Key Technical Decisions
- KTD1. Parent injection lives in
cache-on-save, as a second handler ononFormValidationProcessed, and is server-authoritative. The plugin gains anonFormValidationProcessedhandler that, for formnew-entry, readssite.active_tripand sets the form'sparentvalue (via$form->value()) to<active_trip>/dailiesbeforeadd-page-by-form'sadd_pageaction reads$form->value()->toArray()['parent']on its ownonFormProcessedhandler (add-page-by-form.php:521).onFormValidationProcessedis chosen deliberately over a higher-priorityonFormProcessed: it is the only pre-write event that can abort the submit by failing validation. The existing cache-clear handler is untouched. Noparentfield is added to the form blueprint — injecting server-side (not via a client-submitted hidden field) keeps the write target out of the client's control.pageconfig.parentis removed frompost-form.mdso nothing can drift out of sync. Empty-active_tripfail-closed: ifactive_tripis missing/empty the handler must fail validation (raise anonFormValidationError/ throw) soadd_pagenever runs — merely leavingparentunset is not enough, because withpageconfig.parentgonegetParentPage('')resolves to the/postpage itself and the entry would silently land under/post(not the site root). Failing validation is what guarantees no misfile. - KTD2. EasyMDE + heic-to ship in a
/post-scoped, code-split bundle, not the globalmain.js. A new entryjs/src/post-form.jsis bundled tojs/post-form.jsand loaded only bypost-form.html.twig— keeping ~1.5 MB of converter + editor off every other page. Unlike the site's other bundles (--format=iife, no splitting), the/postentry is built with--format=esm --splittingso the dynamicimport('heic-to')(KTD4) becomes a separately-fetched chunk rather than being inlined — the HEIC converter's weight stays out of the initial/postdownload and is fetched only when a HEIC is actually picked. This matters because/postis the cold-load-on-cellular surface. Consequences to carry through: the template must load the entry as<script type="module" src="js/post-form.js">(not a classic<script>), esbuild emits shared/dynamic chunks alongside the entry (the whole emitted set must ship, so the build'soutdir/chunk output is committed, not just the single file), and this is the only ESM/split entry inpackage.json'sbuildscript — the existing IIFE entries are untouched. CSS is still extracted tocss-compiled/post-form.css. - KTD3. EasyMDE flushes to the textarea before validation. Init EasyMDE on the content
<textarea>, and calleditor.codemirror.save()onchangeand at the top of the existingsubmithandler, so the customnovalidatevalidator (which reads[name="data[content]"],post-form.html.twig:39–45) sees the live value. This preserves the current validation approach rather than replacing it. - KTD4. HEIC is detected by magic-byte sniffing and the converter is lazy-loaded. Sniff the first bytes for the ISO-BMFF
ftypbox withheic/heif/mif1brands rather than trusting extension or MIME. Only when a HEIC is detected is heic-to dynamically imported (keeps the initial/postpayload small). On success the file is replaced with a JPEG blob (slugified.jpgname); on failure/timeout/corrupt the file is rejected fail-closed with an inline error. Submit is gated on a "conversions in flight" counter. - KTD5. "More options" is an accessible native
<details>/disclosure. Advanced fields (hero_image,force_connect,featured) render inside a<details>collapsed by default, auto-expanded if any advanced field is non-empty on load. Native<details>gives keyboard/AT support without custom ARIA wiring. - KTD6. Submit resilience via a
localStoragedraft. Field values (content especially) are mirrored tolocalStorageon input and restored on load; the draft is cleared on a confirmed successful post. On a failed submit — validation, save error, or a session-expiry response that renders the login form instead of the success message — the draft survives so the owner re-authenticates and resubmits without loss.
High-Level Technical Design
The submit pipeline spans client (conversion, editor sync, validation) and server (parent injection, page write, cache clear). The load-bearing ordering is that parent injection must run before add-page-by-form's add_page action.
flowchart TB
subgraph Client
A[Pick photos] --> B{HEIC?<br/>magic-byte sniff}
B -->|yes| C[Lazy-load heic-to<br/>convert to JPEG]
B -->|no| D[Pass through]
C -->|fail| E[Block photo,<br/>inline error]
C -->|ok| F[Replace with JPEG blob]
D --> F
G[EasyMDE] -->|codemirror.save| H[textarea value]
F --> I{Submit}
H --> I
I -->|conversions in flight| J[Submit disabled]
I -->|required missing| K[Inline validation, preserve draft]
I -->|ok| L[POST /post]
end
subgraph Server
L --> M[onFormValidationProcessed<br/>cache-on-save injects parent<br/>= active_trip + /dailies]
M --> N[add-page-by-form add_page<br/>reads form_data.parent L521]
N --> O[Page written under active trip]
O --> P[cache-on-save clears cache]
P --> Q[Entry appears in feed]
end
Sequencing
U1 (parent injection) and U2 (fields) are independent and can land first in either order. U3 introduces the /post bundle. U4's HEIC logic is independent of U3's editor logic, but U4 depends on that bundle scaffolding — build U3 first so the bundle exists, then U4 adds to it. U5 (styling/disclosure/feedback) depends on U2's field definitions and U3's bundle. U6 (draft resilience) depends on U3 and U5. U7 (tests) comes last and verifies the whole.
Assumptions / Execution-time unknowns
- The exact hook for injecting into Grav's managed FilePond instance (U4) is unresolved and is the plan's chief risk — see Risks. Resolve during implementation by inspecting the rendered filepond field and FilePond's
beforeAddFile/server.processoptions; a fallback is documented in U4. - Whether
onFormValidationProcessedexposes a settableparenton the form in this Grav/add-page-by-form version, or whether a higher-priorityonFormProcessedis needed, is confirmed at implementation time against a live submit.
Implementation Units
U1. Server-authoritative active-trip parent injection
- Goal: New entries land under
<site.active_trip>/dailiesautomatically; the hardcoded parent sync is removed (R1, R2). - Requirements: R1, R2. Covers AE2.
- Dependencies: none.
- Files:
user/plugins/cache-on-save/cache-on-save.php— add a second subscribed event + handler for parent injection.user/pages/02.post/post-form.md— removepageconfig.parent; drop the "keep in sync" comment.
- Approach: Subscribe to
onFormValidationProcessed(keep the existingonFormProcessedcache-clear). In the new handler, guard on$form->getName() === 'new-entry', readactive_tripfrom$this->grav['config']->get('site.active_trip'), and set the form'sparentvalue to<active_trip>/dailiessoadd-page-by-formpicks it up atadd-page-by-form.php:521. Do not add aparentform field. Ifactive_tripis empty, fail validation (raise anonFormValidationError/ throw) so theadd_pageaction never runs — do not just leaveparentunset, which would misfile under/post(see KTD1). Optionally tighten the existingdeleteAll()to run once (minor; only if trivially safe). - Patterns to follow: existing
cache-on-save.phphandler shape andgetSubscribedEvents(). - Test scenarios:
- Covers AE2. With
active_trip: /trips/italy-2026-demo, a form post creates the page under/trips/italy-2026-demo/dailies. - Change
active_tripto another trip → next post lands there with no edit topost-form.md. active_tripempty/unset → validation fails and theadd_pageaction never runs; no page is written under/postor anywhere.- Existing cache-clear behavior still fires (new entry appears immediately in the feed).
- Covers AE2. With
- Verification:
make test-postandmake test-configpass; a manual post athttp://localhost:8081/postlands in the active trip and appears in its dailies feed immediately.
U2. Full entry-field exposure + weather picker
- Goal: The form can set every entry field, with a proper weather-condition picker; core fields visible, advanced fields defined for the U5 disclosure (R3, R4, R6).
- Requirements: R3, R4, R6. Supports R5 (disclosure UI in U5).
- Dependencies: none.
- Files:
user/pages/02.post/post-form.md— field definitions. - Approach: Change
weather_descfromhiddento aselectmirroringentry.yaml's options (the emoji-labelled conditions). Addtransport_mode(select, options fromentry.yaml),hero_image(text),force_connect(toggle),featured(toggle). Keepweather_temp_c(populated by Get Weather; anumberinput so it stays user-editable). Order fields so core (title, date, content, photos, location, weather condition, weather temp, transport) precede the advanced trio; the visual grouping/disclosure is U5. Field names must match theentry.yamlheader keys sopagefrontmatterserialization lands them correctly. Caution: turningweather_descinto a<select>breaks the existing Get Weather handler'sgetField('weather_desc')lookup, which queriesinput[name="data[weather_desc]"](post-form.html.twig:65-67) and will returnnullfor a select — U5 must generalize that selector when it migrates the handler, or Get Weather's condition pre-fill silently no-ops. - Patterns to follow:
entry.yamlfield types and option lists; existing field blocks inpost-form.md. - Test scenarios:
- Covers AE3 (field presence half). A logged-in
/postrender shows title, date, content, photos, location, weather condition (as a select with emoji options), weather temp, and transport mode. - Posting with
transport_mode,hero_image,force_connect,featuredset writes those keys into the entry frontmatter. - Weather condition select round-trips a manually chosen value (not overwritten unless Get Weather runs).
- Covers AE3 (field presence half). A logged-in
- Verification:
make test-configpasses; posted entry frontmatter contains the new fields; entry renders with transport/weather on the trip feed.
U3. EasyMDE editor + validation sync + /post bundle scaffolding
- Goal: Content uses EasyMDE with a minimal toolbar + preview, synced to the textarea before validation; establish the
/post-scoped bundle (R7, R15). - Requirements: R7, R15.
- Dependencies: none (introduces the bundle U4/U5/U6 extend).
- Files:
user/themes/intotheeast/package.json— addeasymdedep; add ajs/src/post-form.jsesbuild entry to thebuildscript built with--format=esm --splitting(per KTD2, so KTD4'simport('heic-to')is a real deferred chunk), CSS extracted tocss-compiled/post-form.css. The existing IIFE entries stay as-is.user/themes/intotheeast/js/src/post-form.js— new bundle entry: init EasyMDE, wire sync.user/themes/intotheeast/templates/post-form.html.twig— load the bundle as<script type="module" src="js/post-form.js">+css-compiled/post-form.css(page-scoped); migrate the inline validation script's content read to use the synced textarea. (Module scripts defer by default — ensure any inline init that depends on globals accounts for that.)
- Approach: In
post-form.js, guard on the presence of the content textarea (no-op otherwise, mirroringinitTripStats). Init EasyMDE withtoolbar: ['bold','italic','unordered-list','link','preview']. Oneditor.codemirrorchangeand at the start of the existing submit handler, calleditor.codemirror.save()so[name="data[content]"]holds the live value for validation and submission. Rebuild withmake build-assets(never hand-editjs/post-form.js). - Patterns to follow:
js/src/main.jsinitTripStatspresence-guard pattern;package.jsonbuildscript esbuild invocation;base.html.twigassets.addJs(..., {group:'bottom'})for the page-scoped adds in the template. - Test scenarios:
- Typing content in EasyMDE, then submitting, posts the entered markdown (content persists).
- Submitting with an empty editor triggers the required-field error (sync makes the empty value visible to the validator).
- Content with markdown (bold, list, link) round-trips into the entry body.
- Preview toggle renders markdown without breaking submit.
- Verification:
make build-assetscompletes clean;make test-uipost spec (U7) passes; manual check that a valid entry is never wrongly rejected as empty.
U4. Client-side HEIC→JPEG conversion with progress + failure states
- Goal: HEIC photos are detected and converted before upload with a converting indicator and fail-closed handling; web-format photos pass through (R8, R9, R16, R17).
- Requirements: R8, R9, R16, R17. Covers AE1, AE4.
- Dependencies: U3 (the
/postbundle). - Files:
user/themes/intotheeast/package.json— addheic-todep (dynamically imported).user/themes/intotheeast/js/src/post-form.js— HEIC detection, conversion, progress/failure UI, Submit gating.user/themes/intotheeast/css/style.css(orpost-form.cssbundle) — converting indicator + inline photo error styles.
- Approach: Hook the filepond field's file intake. Sniff the first bytes for an ISO-BMFF
ftypbox withheic/heif/mif1brands. On a HEIC, dynamicallyimport('heic-to'), convert to a JPEG blob, and substitute it (slugified.jpgname) before it uploads; show a per-thumbnail "converting…" state and increment an in-flight counter that disables Submit. On success decrement; on failure/timeout/corrupt, reject that file with an inline error, leave other files + Submit usable, and never upload the original. Non-HEIC files pass through untouched (R9), including HEIC already transcoded to JPEG by iOS on pick. - Execution note: This is the plan's highest-risk unit — Grav's
filepondfield manages its own FilePond instance. Resolve the exact interception point at implementation time (FilePondbeforeAddFile/server.process, or converting theFilebefore it enters filepond). Fallback if the managed instance can't be hooked cleanly: replace thefilepondfield with a plain multiplefileinput for/postand drive conversion + preview directly. Surface this as a blocker (per Goal Capsule stop condition) before adopting the fallback. - Patterns to follow: none local for filepond interception — see Sources; follow
initTripStatspresence-guard for the init. - Test scenarios:
- Covers AE1. A JPEG uploads unchanged; the posted entry renders a working thumbnail + hero.
- Covers AE4. A HEIC file shows a "converting…" indicator, converts, and posts as JPEG; Submit is disabled until conversion completes.
- A corrupt/ambiguous HEIC (or a conversion that throws) is blocked with an inline error; other selected photos and Submit remain usable; the original HEIC is not posted.
- A HEIC renamed to
.jpg(misleading extension) is still detected by sniffing and converted, not passed through. - Selecting a 5th photo respects the
limit: 4cap.
- Verification:
make build-assetsclean;make test-uiHEIC spec (U7) passes using a real.heicfixture; manual iPhone-Safari check that a camera HEIC posts with a working thumbnail.
U5. Field Notes styling, mobile layout, "More options" disclosure, async feedback
- Goal: The form matches the design system and is mobile-first; advanced fields sit behind an accessible disclosure; Get Location / Get Weather / submit validation expose full feedback states (R5, R11, R12, R13, R18, R19).
- Requirements: R5, R11, R12, R13, R18, R19. Covers AE3 (disclosure half).
- Dependencies: U2 (field definitions), U3 (the
/postbundle + EasyMDE-synced content value). - Files:
user/themes/intotheeast/templates/post-form.html.twig— wrap advanced fields in a<details>"More options"; restructure for single-column mobile; migrate inline scripts into the bundle where practical.user/themes/intotheeast/js/src/post-form.js— Get Location / Get Weather state machine (idle/loading/success/error), Get Weather disabled until coords present, blocking submit validation with per-field messages.user/themes/intotheeast/css/style.cssand/orpost-form.css— Field Notes tokens (tokens.css), large tap targets, disclosure styling,.form-statusstates,.field-error.
- Approach: Use
tokens.cssvariables (teal accent, DM Serif Display + DM Sans, paper background) for a single-column layout with ≥44px tap targets and native-friendly inputs. Advanced fields render inside<details>collapsed by default, auto-openwhen any advanced field is non-empty. Extend the existing Get Location / Get Weather handlers (post-form.html.twig:69–124) with explicit loading (button spinner), success, and error/permission-denied states; disable Get Weather with a hint until lat/lng exist. Keep the fields manually editable on failure. When migrating the Get Weather handler, generalize theweather_desclookup so it matches the U2<select>(notinput[...]). Submit validation stays the customnovalidateapproach (title + content required), now reading the EasyMDE-synced value. Add a save-failure feedback state distinct from field validation: on a failedadd_page/upload— including KTD1's empty-active_tripvalidation error — show an inline error with an explicit retry affordance while the draft (U6) is preserved; specify what the empty-active_tripcase tells the user ("no active trip is set"). - Patterns to follow:
css/tokens.cssvariables; existing.form-status--ok/.form-status--errclasses;.journal-post/ site card styling for visual consistency. - Test scenarios:
- Covers AE3 (disclosure). Advanced trio is hidden until "More options" is expanded; expands automatically when an advanced field has a value.
- Get Location denied → inline error, lat/lng stay manually editable.
- Get Weather tapped before coords exist → disabled/hint, no dead tap.
- Get Weather success fills the weather condition select + temp; failure shows an inline message.
- Submit with empty title → per-field inline error, focus moves to the field, no navigation.
- Save failure (e.g. empty
active_trip) → inline save-error message with a retry affordance; entered content preserved (not reset). - Narrow viewport (~375px) renders single-column with no horizontal scroll.
- Verification:
make test-ui(incl.tests/ui/a11y/accessibility.spec.js) passes; manual dev-server walkthrough at 375px width.
U6. Submit resilience — draft persistence
- Goal: A failed submit or an expired session mid-compose never loses the entry's text (R19 preservation, R20); photos are out of scope for persistence and the form says so.
- Requirements: R19 (preserve-on-failure), R20.
- Dependencies: U3, U5 (bundle + submit handling).
- Files:
user/themes/intotheeast/js/src/post-form.js— draft mirror/restore;user/themes/intotheeast/templates/post-form.html.twig— re-auth hint markup if needed. - Approach: Mirror text field values (content especially — title, date, content, location, weather, transport, advanced fields) to
localStorageon input under anew-entrykey. Photos are explicitly out of scope:File/Blobobjects can't be serialized tolocalStorage, so picked/converted photos are not persisted and must be re-selected after a reload or re-auth — render an inline hint near the photo field on restore ("photos need re-selecting"). On load, restore any text draft into the fields + editor. Clear the draft only after a confirmed successful post (success message present) — and ensure this clear runs before/independently of the form'sprocess.reset: true, so the reset doesn't repopulate blank fields back intolocalStorage. Text-draft survival is guaranteed by this clear-only-on-success invariant, independent of any failure-type detection. The tailored "session expired — log in and resubmit" hint is best-effort on top: verify at implementation time what a multipart POST under an expired session/nonce actually returns (an inline#grav-login, a Grav nonce/validation error, or a 302 redirect) before keying the hint on it — the auth spec's#grav-loginassumption is GET-scoped and may not hold for the POST. - Patterns to follow: the auth spec's assumption that
/postrenders#grav-logininline when unauthenticated (tests/ui/auth/auth.spec.jsA4) — detect that to distinguish session-expiry from other failures. - Test scenarios:
- Type content, reload the page → content is restored from the draft.
- Successful post → draft is cleared (a fresh
/postload is empty). - Failed validation submit → entered values persist (not wiped by reset).
- Simulated session-expiry response (login form) → text draft survives; re-auth + resubmit posts the text without loss.
- After a reload with a photo previously picked → the photo is gone (expected) and the inline "photos need re-selecting" hint is shown; text fields are still restored.
- Verification:
make test-uidraft spec (U7) passes; manual check that a reload mid-compose restores content.
U7. Post-form test coverage
- Goal: Lock the behavior with a Playwright spec and fixtures (verifies AE1–AE4 and the new UX).
- Requirements: verification for R1–R20. Two are preserve/constraint requirements with no new-behavior scenario: R10 (no server-side/Docker change) is enforced by the "Scope discipline" Definition-of-Done line; R14 (login gating, no public posting) is covered by the existing
tests/ui/auth/auth.spec.js(A4). - Dependencies: U1–U6.
- Files:
tests/ui/post/post.spec.jsandtests/ui/post/validation.spec.js— update existing specs: they (andtests/ui/helpers.js) currently filltextarea[name="data[content]"], which EasyMDE hides once U3 lands. Retarget content entry to the CodeMirror instance (type into.CodeMirror textareaor call the EasyMDE API) or the Playwright suite goes red.tests/ui/helpers.js— update the shared content-fill helper for the same reason.tests/ui/post/post.spec.js— extend with the new coverage (or add a focused sibling spec) for disclosure, HEIC conversion + failure, active-trip landing, feedback states, draft restore.tests/fixtures/test-photo.heic— real HEIC fixture for the conversion path.scripts/test-post.sh— extend if the active-trip landing assertion belongs there rather than in Playwright.
- Approach: Follow the existing spec style (
tests/ui/post/post.spec.js,auth.spec.js): use the logged-in storage state, drive/post, and assert field presence (AE3), disclosure behavior, HEIC conversion + failure (AE1/AE4), active-trip landing (AE2), and draft restore. Add the.heicfixture alongsidetest-photo.jpg/test-nonimage.txt. Note the filepond-targeting specs (and helpers) also need updating if U4's plain-input fallback is adopted. - Patterns to follow: existing
tests/ui/**specs;.env.testprovidesGRAV_TEST_USER/GRAV_TEST_PASS/GRAV_BASE_URL. - Test scenarios: the spec is the scenarios — AE1, AE2, AE3, AE4, plus disclosure, feedback states, and draft restore.
- Verification:
make test(config + post + UI) is green.
Verification Contract
| Gate | Command | Proves |
|---|---|---|
| Asset build | make build-assets |
/post bundle compiles as ESM with splitting; js/post-form.js entry + the heic-to dynamic chunk + css-compiled/post-form.css all emitted and committed |
| Form config | make test-config (scripts/test-form-config.sh) |
post-form.md blueprint is valid; new fields parse |
| Post pipeline | make test-post (scripts/test-post.sh) |
A post lands under the active trip and appears in the feed |
| UI suite | make test-ui (npx playwright test) |
AE1–AE4, disclosure, feedback states, draft restore, accessibility |
| Full gate | make test |
All of the above in sequence |
Manual: on http://localhost:8081/post at ~375px width, post a real iPhone HEIC and confirm a working thumbnail; verify the entry lands in the active trip's dailies immediately.
Definition of Done
- Global: All of R1–R20 satisfied;
make testgreen;make build-assetsclean with no hand-edits to generatedjs/*.js; the form is posted successfully end-to-end from a narrow (mobile) viewport including one real HEIC photo. - Per unit: each unit's Test scenarios pass and its Verification holds.
- Scope discipline: no server-side image pipeline, no Docker change, no server-side upload validation added (deferred per decision);
pageconfig.parentremoved and no new client-submittableparentfield introduced. - Cleanup: any exploratory filepond-interception dead-ends removed; if the U4 fallback (plain file input) was adopted, the managed-filepond attempt is not left commented in the bundle.
- Docs: if
active_trip/post-form coupling notes inCLAUDE.mdare now stale (the two-file sync is gone), update them.
Open Questions
Both are deferred (security posture), not launch-blocking. They stay in Open Questions by owner decision:
- HEIC single-path durability. The client-side-only decision assumes HEIC only ever enters via this mobile form, but Admin2 media edits, Immich-served originals, and the same
/postform opened in a desktop browser can each introduce an unconverted HEIC that bypasses the converter. Decide whether to add a cheap server-side HEIC rejection backstop or to explicitly accept (and document) that non-/postHEIC uploads render broken. Reversal cost of the deferred server-side path is a Docker image rebuild. - Server-side upload validation vs. client-only posture. A direct authenticated POST can bypass the browser conversion and the
accept: image/*filter — sending still-HEIC, oversized, non-image, or SVG payloads (media.yamlservessvg, making an uploaded SVG stored XSS). Deferred: login-gated and low-risk for a solo owner. If pulled in later, enforce a server-side accept-list (jpeg/png/webp; reject SVG + HEIC) and per-file size cap in the samecache-on-savehandler added in U1, treating client-side conversion as UX rather than a security control.
Risks & Dependencies
- Filepond interception (U4) is the load-bearing risk. Grav's managed FilePond instance may not expose a clean hook for pre-upload conversion. Mitigation: documented fallback to a plain file input scoped to
/post; surface as a blocker before adopting it. - heic-to browser support. Relies on WASM/libheif in-browser; verify it works in iOS Safari (the only target). Mitigation: the U7 HEIC fixture test plus a manual real-device check.
- EasyMDE ↔ custom validation ordering. If
codemirror.save()doesn't fire before the validator reads the textarea, valid entries get rejected. Mitigated by KTD3 (save on change and at submit-handler top) and a U3 test. onFormValidationProcessedparent settability. The exact event/priority at whichparentis settable beforeadd-page-by-formreads it is confirmed against a live submit in U1; a higher-priorityonFormProcessedis the fallback.
Sources / Research
- Code:
user/pages/02.post/post-form.md,user/plugins/add-page-by-form/add-page-by-form.php(parentoverride L521–523),user/plugins/cache-on-save/cache-on-save.php(onFormProcessedhandler),user/themes/intotheeast/blueprints/entry.yaml(field types/options),user/themes/intotheeast/templates/post-form.html.twig(inline validation + Get Location/Weather),user/themes/intotheeast/package.json(esbuildbuildscript),user/themes/intotheeast/templates/partials/base.html.twig(asset loading),user/config/media.yaml(noheic; servessvg),user/config/site.yaml(active_trip),tests/ui/**(Playwright suite),tests/fixtures/(test-photo.jpg). - External: Grav Media docs (HEIC unsupported; jpg/png/gif/svg) · Grav forum — image upload preprocessing · heic-to · EasyMDE.
- Design:
docs/reference/design-system.md,user/themes/intotheeast/css/tokens.css.