docs: add journal-post-form implementation plan (ce-plan + reviewed)
Enriched from the requirements-only brainstorm to implementation-ready: 7 units (active-trip parent injection, full field exposure, EasyMDE, client-side HEIC conversion, Field Notes mobile styling, draft resilience, tests). Applied headless doc-review fixes plus two owner decisions: /post bundle built ESM + code-split (defer heic-to for cold-load), and R20 draft persistence narrowed to text (photos re-picked after re-auth). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
product_contract_source: ce-brainstorm
|
||||||
|
title: Journal Post Form Improvements - Plan
|
||||||
|
type: feat
|
||||||
|
date: 2026-07-04
|
||||||
|
execution: code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Journal Post Form Improvements — Plan
|
||||||
|
|
||||||
|
**Status:** 📋 Not started
|
||||||
|
|
||||||
|
> 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 `/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 (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.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](https://github.com/hoppergee/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: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 `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.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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: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 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 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.js` and `tests/ui/post/validation.spec.js` — **update 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`) | 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 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 L521–523), `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](https://learn.getgrav.org/17/content/media) (HEIC unsupported; jpg/png/gif/svg) · [Grav forum — image upload preprocessing](https://getgrav.org/forum/forms-blueprints/image-upload-with-preprocessing-t610) · [heic-to](https://github.com/hoppergee/heic-to) · [EasyMDE](https://github.com/Ionaru/easy-markdown-editor).
|
||||||
|
- **Design:** `docs/reference/design-system.md`, `user/themes/intotheeast/css/tokens.css`.
|
||||||
Reference in New Issue
Block a user