Files
intotheeast-com/docs/working/plans/2026-07-04-journal-post-form.md
m038andClaude Opus 4.8 20df900188 docs(plans): refresh journal-post-form status — merged to main
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
2026-07-08 10:56:07 +02:00

41 KiB
Raw Permalink Blame History

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 (U1U7). 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 R1R20, Key Flows, and Acceptance Examples are carried verbatim from the brainstorm.


Goal Capsule

  • Objective: Redesign the frontend /post journal 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 (R1R20) 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.md project rules override everything (only write inside travel-blog-intotheeast/; user/ is a standalone repo; never read .env; use make for 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 with make build-assets (never hand-edit js/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_trip at submit time instead of from a static pageconfig.parent. add-page-by-form already honours a submitted parent value (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, and featured are 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 dailies folder, resolved from site.active_trip at submit time.
  • R2. Switching trips (changing active_trip) requires no edit to the post form; the hardcoded pageconfig.parent coupling 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 in entry.yaml.)
  • R4. Core fields are always visible: title, date, content, photos, location, weather condition, weather temperature, transport mode.
  • R5. hero_image, force_connect, and featured sit 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. /post remains 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 — localStorage cannot hold File/Blob objects — 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 /post on 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.

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/dailies without 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, and featured are 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-form continues to honour a submitted parent value that overrides pageconfig.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 on onFormValidationProcessed, and is server-authoritative. The plugin gains an onFormValidationProcessed handler that, for form new-entry, reads site.active_trip and sets the form's parent value (via $form->value()) to <active_trip>/dailies before add-page-by-form's add_page action reads $form->value()->toArray()['parent'] on its own onFormProcessed handler (add-page-by-form.php:521). onFormValidationProcessed is chosen deliberately over a higher-priority onFormProcessed: it is the only pre-write event that can abort the submit by failing validation. The existing cache-clear handler is untouched. No parent field 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.parent is removed from post-form.md so nothing can drift out of sync. Empty-active_trip fail-closed: if active_trip is missing/empty the handler must fail validation (raise an onFormValidationError / throw) so add_page never runs — merely leaving parent unset is not enough, because with pageconfig.parent gone getParentPage('') resolves to the /post page 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 global main.js. A new entry js/src/post-form.js is bundled to js/post-form.js and loaded only by post-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 /post entry is built with --format=esm --splitting so the dynamic import('heic-to') (KTD4) becomes a separately-fetched chunk rather than being inlined — the HEIC converter's weight stays out of the initial /post download and is fetched only when a HEIC is actually picked. This matters because /post is 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's outdir/chunk output is committed, not just the single file), and this is the only ESM/split entry in package.json's build script — the existing IIFE entries are untouched. CSS is still extracted to css-compiled/post-form.css.
  • KTD3. EasyMDE flushes to the textarea before validation. Init EasyMDE on the content <textarea>, and call editor.codemirror.save() on change and at the top of the existing submit handler, so the custom novalidate validator (which reads [name="data[content]"], post-form.html.twig:3945) 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 ftyp box with heic/heif/mif1 brands rather than trusting extension or MIME. Only when a HEIC is detected is heic-to dynamically imported (keeps the initial /post payload small). On success the file is replaced with a JPEG blob (slugified .jpg name); 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 localStorage draft. Field values (content especially) are mirrored to localStorage on 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.process options; a fallback is documented in U4.
  • Whether onFormValidationProcessed exposes a settable parent on the form in this Grav/add-page-by-form version, or whether a higher-priority onFormProcessed is 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>/dailies automatically; 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 — remove pageconfig.parent; drop the "keep in sync" comment.
  • Approach: Subscribe to onFormValidationProcessed (keep the existing onFormProcessed cache-clear). In the new handler, guard on $form->getName() === 'new-entry', read active_trip from $this->grav['config']->get('site.active_trip'), and set the form's parent value to <active_trip>/dailies so add-page-by-form picks it up at add-page-by-form.php:521. Do not add a parent form field. If active_trip is empty, fail validation (raise an onFormValidationError / throw) so the add_page action never runs — do not just leave parent unset, which would misfile under /post (see KTD1). Optionally tighten the existing deleteAll() to run once (minor; only if trivially safe).
  • Patterns to follow: existing cache-on-save.php handler shape and getSubscribedEvents().
  • 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_trip to another trip → next post lands there with no edit to post-form.md.
    • active_trip empty/unset → validation fails and the add_page action never runs; no page is written under /post or anywhere.
    • Existing cache-clear behavior still fires (new entry appears immediately in the feed).
  • Verification: make test-post and make test-config pass; a manual post at http://localhost:8081/post lands 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_desc from hidden to a select mirroring entry.yaml's options (the emoji-labelled conditions). Add transport_mode (select, options from entry.yaml), hero_image (text), force_connect (toggle), featured (toggle). Keep weather_temp_c (populated by Get Weather; a number input 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 the entry.yaml header keys so pagefrontmatter serialization lands them correctly. Caution: turning weather_desc into a <select> breaks the existing Get Weather handler's getField('weather_desc') lookup, which queries input[name="data[weather_desc]"] (post-form.html.twig:65-67) and will return null for 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.yaml field types and option lists; existing field blocks in post-form.md.
  • Test scenarios:
    • Covers AE3 (field presence half). A logged-in /post render 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, featured set writes those keys into the entry frontmatter.
    • Weather condition select round-trips a manually chosen value (not overwritten unless Get Weather runs).
  • Verification: make test-config passes; 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 — add easymde dep; add a js/src/post-form.js esbuild entry to the build script built with --format=esm --splitting (per KTD2, so KTD4's import('heic-to') is a real deferred chunk), CSS extracted to css-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, mirroring initTripStats). Init EasyMDE with toolbar: ['bold','italic','unordered-list','link','preview']. On editor.codemirror change and at the start of the existing submit handler, call editor.codemirror.save() so [name="data[content]"] holds the live value for validation and submission. Rebuild with make build-assets (never hand-edit js/post-form.js).
  • Patterns to follow: js/src/main.js initTripStats presence-guard pattern; package.json build script esbuild invocation; base.html.twig assets.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-assets completes clean; make test-ui post 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 /post bundle).
  • Files:
    • user/themes/intotheeast/package.json — add heic-to dep (dynamically imported).
    • user/themes/intotheeast/js/src/post-form.js — HEIC detection, conversion, progress/failure UI, Submit gating.
    • user/themes/intotheeast/css/style.css (or post-form.css bundle) — converting indicator + inline photo error styles.
  • Approach: Hook the filepond field's file intake. Sniff the first bytes for an ISO-BMFF ftyp box with heic/heif/mif1 brands. On a HEIC, dynamically import('heic-to'), convert to a JPEG blob, and substitute it (slugified .jpg name) 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 filepond field manages its own FilePond instance. Resolve the exact interception point at implementation time (FilePond beforeAddFile / server.process, or converting the File before it enters filepond). Fallback if the managed instance can't be hooked cleanly: replace the filepond field with a plain multiple file input for /post and 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 initTripStats presence-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: 4 cap.
  • Verification: make build-assets clean; make test-ui HEIC spec (U7) passes using a real .heic fixture; 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 /post bundle + 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.css and/or post-form.css — Field Notes tokens (tokens.css), large tap targets, disclosure styling, .form-status states, .field-error.
  • Approach: Use tokens.css variables (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-open when any advanced field is non-empty. Extend the existing Get Location / Get Weather handlers (post-form.html.twig:69124) 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 the weather_desc lookup so it matches the U2 <select> (not input[...]). Submit validation stays the custom novalidate approach (title + content required), now reading the EasyMDE-synced value. Add a save-failure feedback state distinct from field validation: on a failed add_page/upload — including KTD1's empty-active_trip validation error — show an inline error with an explicit retry affordance while the draft (U6) is preserved; specify what the empty-active_trip case tells the user ("no active trip is set").
  • Patterns to follow: css/tokens.css variables; existing .form-status--ok / .form-status--err classes; .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 localStorage on input under a new-entry key. Photos are explicitly out of scope: File/Blob objects can't be serialized to localStorage, 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's process.reset: true, so the reset doesn't repopulate blank fields back into localStorage. 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-login assumption is GET-scoped and may not hold for the POST.
  • Patterns to follow: the auth spec's assumption that /post renders #grav-login inline when unauthenticated (tests/ui/auth/auth.spec.js A4) — 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 /post load 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-ui draft 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 AE1AE4 and the new UX).
  • Requirements: verification for R1R20. 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: U1U6.
  • Files:
    • tests/ui/post/post.spec.js and tests/ui/post/validation.spec.jsupdate existing specs: they (and tests/ui/helpers.js) currently fill textarea[name="data[content]"], which EasyMDE hides once U3 lands. Retarget content entry to the CodeMirror instance (type into .CodeMirror textarea or 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 .heic fixture alongside test-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.test provides GRAV_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) AE1AE4, 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 R1R20 satisfied; make test green; make build-assets clean with no hand-edits to generated js/*.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.parent removed and no new client-submittable parent field 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 in CLAUDE.md are 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 /post form 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-/post HEIC 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.yaml serves svg, 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 same cache-on-save handler 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.
  • onFormValidationProcessed parent settability. The exact event/priority at which parent is settable before add-page-by-form reads it is confirmed against a live submit in U1; a higher-priority onFormProcessed is the fallback.

Sources / Research

  • Code: user/pages/02.post/post-form.md, user/plugins/add-page-by-form/add-page-by-form.php (parent override L521523), user/plugins/cache-on-save/cache-on-save.php (onFormProcessed handler), 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 (esbuild build script), user/themes/intotheeast/templates/partials/base.html.twig (asset loading), user/config/media.yaml (no heic; serves svg), 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.