Files
intotheeast-com/docs/working/plans/2026-07-08-trip-publish-toggle.md
m038andClaude Opus 4.8 3e1ddd8132 test(trip): publish-toggle Playwright specs + plan/spec docs; bump user pin
TP1/TP1b/TP2–TP6 cover the owner gate, coverless drafts, cache-correct
hide/restore, the active-trip confirm, backend authz (401/403/400), and
the home fallback. The suite pins site.owner_username to the authenticated
test user (restore on teardown) and runs serially — it mutates global
config and clears the shared cache, so it collides with parallel readers.

Bumps the user/ pin to the finished trip-publish-toggle content (064f0f0)
and marks the plan Complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 14:50:20 +02:00

267 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Trip Publish/Unpublish Toggle - Plan
type: feat
date: 2026-07-08
origin: docs/working/specs/2026-07-08-trip-publish-toggle-design.md
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: legacy-requirements
execution: code
---
# Trip Publish/Unpublish Toggle - Plan
**Status:** ✅ Complete (2026-07-08)
## Goal Capsule
- **Objective:** Let the logged-in site owner publish/unpublish any trip from the `/trips` listing, with correct page-tree cache invalidation so the change is reflected everywhere on the next load. Anonymous/non-owner visitors see no change.
- **Authority hierarchy:** The design doc (`docs/working/specs/2026-07-08-trip-publish-toggle-design.md`) is authoritative for behavior; this plan is authoritative for sequencing and file-level implementation. Repo conventions (CLAUDE.md) and the cited existing patterns override any incidental detail here.
- **Stop conditions:** Surface a blocker if implementation reveals that Grav 2.0's `$page->save()` does not persist `published` from a mutated header (the pattern KTD1 depends on), or that `$pages->find()` refuses to resolve unpublished trips from the listing context — either contradicts the design doc's cited behavior.
- **Execution profile:** Standard feature — one owner-gated API write, one shared partial, two template edits, one new bundled JS file, CSS, and seven Playwright specs (TP1, TP1b, TP2TP6). Test-after is fine except U7, which is written against the finished surfaces.
- **Tail ownership:** Rebuild theme assets (`make build-assets`) after U6; run the trip Playwright suite after U7.
---
## Product Contract
### Summary
Add an owner-only publish/unpublish switch to each card on the `/trips` listing. The switch POSTs to a new `entry-actions` route that mutates the trip's `trip.md` frontmatter (`published: true|false`), then clears and invalidates Grav's page-tree cache. The trip detail page carries no publish UI — an unpublished trip's detail page 404s for everyone including the owner, so management is listing-only. When the active trip is unpublished, the home page falls back to its between-trips / pre-departure state.
### Problem Frame
Publishing a trip today means editing `trip.md` frontmatter by hand (or via Admin) and manually clearing cache. The owner wants a reversible in-UI toggle. The listing is the only viable surface: it already shows drafts to the owner and is fully reversible, whereas the detail page is unreachable while a trip is unpublished. The change is security-sensitive (an owner-only write) and cache-sensitive (publish state feeds `.published()` collections, routability, nav, and the home render, all keyed through the page-tree index — the exact class of bug fixed in `deleteEntry`).
### Requirements
**Owner gate & authorization**
- R1. The publish write control renders only for the owner: `grav.user.authenticated and grav.user.username == grav.config.site.owner_username`. This is broader than `owner_can_edit` in `trip.html.twig` (which also requires the active trip) — publishing must work on any trip.
- R2. The backend enforces the owner check independently of the UI (defense in depth): anonymous → 401, authenticated non-owner → 403, with frontmatter unchanged on disk.
- R3. The endpoint enforces the same `api.pages.write` scope cap as the stock media/page-write endpoints (owner already holds it).
**Publish write**
- R4. `POST /api/v1/trip/{slug}/publish` with body `{ "published": true|false }` sets the trip's published state and persists it to `trip.md` frontmatter.
- R5. A missing or non-boolean `published` value is rejected with 400 (no silent coercion).
- R6. The slug is validated as a safe single segment; the target must resolve through the page tree to a direct child of `/trips`, else 404.
- R7. `find()` resolves unpublished trips too, so the owner can republish a draft from the listing.
- R8. On success the endpoint clears the cache (`deleteAll()` + `Cache::invalidateCache()`) and returns 204, and writes an audit-log line.
**Listing surface**
- R9. The owner sees unpublished trips in the `/trips` listing (with a `Draft` badge); anonymous/non-owner listings are unchanged (published only).
- R10. Each owner-visible card carries a toggle switch overlaid on the cover image, top-right, that does not sit inside the card's navigating `<a>`. The switch is legible over arbitrary cover photos and carries a ≥44px touch target clear of the anchor hit area.
- R11. The switch is accessible: `role="switch"`, `aria-checked`, and a per-instance accessible name identifying the trip.
**Interaction & feedback**
- R12. Unpublishing the active trip prompts a `window.confirm` warning that the home page loses it; cancelling reverts the switch.
- R13. A toggle in flight is disabled (`aria-busy`, dimmed, wait cursor), ignoring further toggles until success or failure revert.
- R14. On success the UI updates optimistically in place (switch position/label, `Draft` badge, `data-published`) with no full reload; the card stays visible to the owner.
- R15. On failure the switch reverts and an error surfaces via a shared page-level `aria-live` toast (401/403 → "sign in again"; other → "Couldn't update — try again.").
**Home fallback**
- R16. When the resolved active trip is unpublished, `home.html.twig`'s active-trip branch does not render; home falls through to its between-trips / pre-departure state. `site.active_trip` is not modified.
### Scope Boundaries
**Out of scope (v1)**
- Bulk publish/unpublish.
- Scheduling / publish dates.
- Cascading child (dailies/stories) publish state — unpublishing a trip does not change its children.
- Reordering trips by publish state (order stays date desc).
- Any publish/unpublish write control or `Draft` indicator on the trip detail page (`trip.html.twig`) — management is listing-only by design.
**Non-goal clarification**
- This toggle governs only whether a trip appears in the `/trips` listing; it is not a content-privacy control. A story reachable by a direct link stays reachable while its parent trip is unpublished, which is acceptable.
---
## Planning Contract
### Key Technical Decisions
- KTD1. **Persist published state by mutating the page header before save, not `$page->published()`.** In Grav 2.0 `$page->published($v)` sets only the in-memory property (`Page.php:1714`), while `save()` serializes from the header object (`Page.php:1256`) and the flag is read one-way from the header at init (`Page.php:541`). Mirror `cache-on-save`'s header-mutation pattern: `$header = $page->header(); $header->published = $published; $page->save();`. Without this the on-disk `trip.md` is unchanged and the toggle silently no-ops.
- KTD2. **Reject non-boolean `published` explicitly; never `(bool)`-cast.** `array_key_exists('published', $body) && is_bool($body['published'])` or 400. A cast coerces `"false"`, `0`, `""`, or a missing key into a valid boolean and never rejects, contradicting R5.
- KTD3. **Clear the cache with `deleteAll()` + `Cache::invalidateCache()`.** `deleteAll()` alone drops cache stores but does not rebuild the page-tree index (keyed on folderHash under `cache.check.method: folder`), so the listing/nav/home render stale. This is the same fix as `deleteEntry` — see `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`.
- KTD4. **Resolve the trip through `$pages->find()` + a parent-route assertion, never raw path concatenation.** New guard `EntryScopeGuard::resolveTripChild($grav, $slug)` mirrors `resolveActiveDailyChild`: call `enablePages()` (guarded by `method_exists` — the API context lazily disables the tree), `find('/trips/' . $slug)`, then assert the resolved page's parent route is exactly `/trips`. Reuses `isSafeSegment` for traversal safety.
- KTD5. **The JS request must send `Content-Type: application/json`.** The API's `JsonBodyParserMiddleware` (`JsonBodyParserMiddleware.php:16`) only parses the body when that header is present; without it the body decodes to `[]`, the strict `is_bool` guard sees no key, and every toggle 400s. Model the send on `post-form.js`'s `apiSend` (JSON body + both headers + `credentials: 'include'`), **not** `feed-actions.js` (a body-less DELETE with no `Content-Type`).
- KTD6. **The card toggle is an overlay sibling of the cover, not a child of the card `<a>`.** A toggle inside the anchor would navigate on click. Restructure the card so the cover sits in a positioned wrapper and the toggle overlays it as a sibling. Reuse the existing `.journal-draft-badge` styling (Field Notes paper/teal) so the pill stays legible over any cover.
- KTD7. **Gate `home.html.twig`'s active-trip branch on `trip.published` as well as `config.site.travelling`.** `trip` is already resolved at `home.html.twig:10`; adding `and trip.published` to the branch condition is the whole home fallback — no need to touch `site.active_trip`.
- KTD8. **New JS file needs an esbuild build entry.** `js/src/trip-publish.js` does not build automatically — add an esbuild invocation to the theme's `package.json` `build` script (same `--bundle --minify --format=iife` shape as the `feed-actions.js` entry) so `make build-assets` emits `js/trip-publish.js`.
### High-Level Technical Design
Request flow for one toggle:
```mermaid
sequenceDiagram
participant U as Owner (listing card switch)
participant JS as trip-publish.js
participant API as entry-actions route
participant Ctl as setTripPublished
participant G as EntryScopeGuard
participant FS as trip.md + cache
U->>JS: change (with active-trip confirm if applicable)
JS->>JS: disable switch, aria-busy
JS->>API: POST /api/v1/trip/{slug}/publish {published}
API->>Ctl: dispatch
Ctl->>Ctl: getUser (401 anon) + requirePermission(api.pages.write)
Ctl->>G: isOwnerUser (else 403)
Ctl->>Ctl: isSafeSegment(slug) (else 400)
Ctl->>G: resolveTripChild(slug) (else 404)
Ctl->>Ctl: validate published is_bool (else 400)
Ctl->>FS: header.published = v; save(); deleteAll(); invalidateCache()
Ctl-->>JS: 204
JS->>U: optimistic UI (switch, Draft badge, data-published)
```
### Assumptions
- The Playwright harness runs as the owner because the local test setup treats `testrunner` as `owner_username` — the same setup the existing owner-only delete-flow specs rely on. The new specs inherit it rather than introducing a new override mechanism. Verify by mirroring `tests/ui/post/delete-flow.spec.js` (which already exercises the owner API gate).
- `css/style.css` is hand-authored (the theme has no active SCSS pipeline for it), so toggle/badge styling is added there directly, next to the existing `.journal-draft-badge` (line 283) and `.trip-card*` (line 1220+) rules.
---
## Implementation Units
### U1. Guard: resolve a trip as a direct child of `/trips`
- **Goal:** Add `EntryScopeGuard::resolveTripChild($grav, $slug): ?PageInterface`, the trip-scoped analogue of `resolveActiveDailyChild`, so the controller resolves the target safely (R6, R7, KTD4).
- **Requirements:** R6, R7.
- **Dependencies:** none.
- **Files:** `user/plugins/cache-on-save/classes/EntryScopeGuard.php`.
- **Approach:** New static method: reject via `isSafeSegment($slug)` → null; get `$pages = $grav['pages']`; if `method_exists($pages, 'enablePages')` call it; `$page = $pages->find('/trips/' . $slug)`; return null unless `$page !== null` and `$page->parent()?->route() === '/trips'`. No raw path concatenation beyond the `find()` argument, matching the sibling method's style. Do not filter on published state — `find()` returning drafts is required for republish (R7).
- **Patterns to follow:** `EntryScopeGuard::resolveActiveDailyChild` in the same file (lines 104129).
- **Test scenarios:** Covered end-to-end by U7 (TP2/TP3 exercise resolve-and-republish; TP5 exercises the reject paths). No standalone PHP unit-test harness exists in this repo.
- **Verification:** Method exists and returns a `PageInterface` for a real trip slug, `null` for an unsafe segment, a non-existent slug, and a page whose parent is not `/trips`.
### U2. API route + `setTripPublished` controller
- **Goal:** Register `POST /api/v1/trip/{slug}/publish` and implement the owner-gated write that persists published state and invalidates cache (R2R8).
- **Requirements:** R2, R3, R4, R5, R6, R7, R8.
- **Dependencies:** U1.
- **Files:** `user/plugins/entry-actions/entry-actions.php`, `user/plugins/entry-actions/classes/EntryActionsApiController.php`.
- **Approach:** In `onApiRegisterRoutes`, add `$routes->post('/trip/{slug}/publish', [EntryActions\EntryActionsApiController::class, 'setTripPublished'])`. In the controller, mirror `deleteEntry` step-for-step: `getUser` (401), `requirePermission($request, 'api.pages.write')`, `isOwnerUser` (else `ForbiddenException`), `isSafeSegment` (else 400), `resolveTripChild` (else `NotFoundException`). Read body via `getRequestBody`; enforce KTD2 (`array_key_exists` + `is_bool`, else 400); assign the raw boolean. Persist per KTD1 (mutate `$page->header()->published`, then `$page->save()`). Clear cache per KTD3. Log `owner "%s" set trip "%s" published=%s`. Return `ApiResponse::noContent()`.
- **Patterns to follow:** `EntryActionsApiController::deleteEntry` (guard chain, cache calls, audit log) and `reorderPhotos` (JSON body read) in the same file; `cache-on-save` header-mutation for the save.
- **Test scenarios:** Covered by U7 — TP2 (publish→off persists + hides for anon), TP3 (republish), TP5 (401 anon, 403 non-owner, frontmatter unchanged), plus the 400 non-boolean path asserted via a direct API call in TP5.
- **Verification:** `curl` (or the Playwright request context) as owner with `{"published":false}` returns 204 and `trip.md` on disk gains `published: false`; anon → 401; non-owner → 403; missing/`"false"`/`0` body → 400.
### U3. Shared toggle partial + styling
- **Goal:** Create `partials/trip-publish-toggle.html.twig` (the sliding switch + `Draft` badge) and its CSS, so both the markup and its legible-over-cover styling exist as one reusable unit (R10, R11, KTD6).
- **Requirements:** R10, R11.
- **Dependencies:** none (consumed by U4).
- **Files:** `user/themes/intotheeast/templates/partials/trip-publish-toggle.html.twig`, `user/themes/intotheeast/css/style.css`.
- **Approach:** Partial params: `trip` (Page), `is_active` (bool). Render a styled checkbox switch (`role="switch"`, `aria-checked` bound to `trip.published`, `aria-label="Published — {{ trip.title }}"`) plus a `Draft` badge when `not trip.published`. Emit `data-trip-slug`, `data-trip-route`, `data-published`, `data-active` for the JS. Wrap the control class `.trip-publish-toggle`. CSS: a solid pill/chip background reusing `.journal-draft-badge` colors so it stays legible on any cover; absolute positioning is applied by the card container in U4 (which must exist even for a coverless draft — see U4), but the switch's own visual (track/knob, ≥44px hit area, dimmed `[aria-busy]` + wait-cursor pending state per R13, and a legible keyboard focus ring that reads over a busy cover photo) lives here.
- **Visible failure toast (not sr-only):** the design's page-level toast (R15) is meant for the sighted owner, but `feed-actions.js`'s live region is `sr-only` (visually hidden) and the trip card — unlike the delete flow — has no inline message slot, so a straight reuse would leave a sighted owner seeing only a silent switch revert. Add CSS here for a **visible** page-level toast as a **new, separate DOM element and CSS class** (e.g. `#trip-publish-live` / a `.trip-publish-toast` class, with `role="status"`, `aria-live="polite"`, positioned so it does not depend on the cramped card overlay) that U6 populates. This element is distinct from `feed-actions.js`'s `#feed-actions-live` / `.sr-only` region: reuse the *copy* but do **not** modify the shared `.sr-only` utility (still used by `feed-actions.js` on `trip.html.twig`/`home.html.twig`) or make its live region visible. Toast behavior: auto-dismiss after ~5s, include a manual close control, and replace (not queue) the message if a new failure arrives before the previous one dismisses.
- **Patterns to follow:** existing `.journal-draft-badge` (style.css:283) and the `journal-draft-badge` span in `partials/entry-journal.html.twig:7`.
- **Test scenarios:** Rendered presence/absence is asserted by U7 TP1 (owner sees `.trip-publish-toggle`, anon does not); `Draft` badge presence by TP2. Accessible name/`role` are asserted structurally in TP1.
- **Verification:** Partial renders a switch with the correct `data-*` and `aria-*` for a published and an unpublished trip; the pill is legible over a cover image in the browser.
### U4. `/trips` listing — owner-aware collection, card restructure, JS load
- **Goal:** Make the listing owner-aware (drafts for owner), restructure each card so the toggle overlays the cover as a non-anchor sibling, render the toggle for the owner, and load the JS gated on owner (R9, R10, R12R15 wiring).
- **Requirements:** R1, R9, R10.
- **Dependencies:** U3, U6 (built `js/trip-publish.js`).
- **Files:** `user/themes/intotheeast/templates/trips.html.twig`.
- **Approach:** Compute `is_owner` (R1) at the top. Change the collection to `{% set trips = (is_owner ? page.children : page.children.published())|sort(...) %}`. Restructure the card: keep the navigating `<a class="trip-card">` for cover + title + meta, but wrap the cover in a positioned container so `{% if is_owner %}{% include 'partials/trip-publish-toggle.html.twig' with { trip: trip, is_active: is_active } only %}{% endif %}` sits as an overlay sibling outside the click-navigation path. **The positioned container must exist even when the cover macro emits nothing** — see the coverless-draft note below. Gate the asset: `{% if is_owner %}{% do assets.addJs('theme://js/trip-publish.js', {group: 'bottom'}) %}{% endif %}` (mirrors the `feed-actions.js` gate in `home.html.twig:27`). Compute `is_active` robustly — `site.active_trip` may be a full route (`/trips/x`) or a bare slug — by normalizing both sides before comparing, e.g. `{% set active = config.site.active_trip|trim('/') %}` then `{% set is_active = (active == trip.route|trim('/')) or (active == ('trips/' ~ trip.slug)) %}`. Comparing only against `trip.route`/`trip.url` (full-route form) would silently drop the R12 active-trip confirm if the config ever stores a bare slug (both forms are already supported in `helpers.js` and `cache-on-save`).
- **Coverless-draft state (blocks the primary use case):** the shared cover macro emits its wrapper + `<img>` only when a cover exists (an author-set `cover_image` or a published journal image), and **nothing at all** for a freshly-created draft trip with neither — which is exactly the most common publish-toggle target. The positioned container the toggle overlays must therefore be provided by the card itself (a min-height header strip or the card element), not by the cover wrapper, so the toggle has an anchor whether or not `cover.render` emits an image. Enumerate this state in U3's markup and assert it in U7 (a no-cover fixture trip still shows a working toggle).
- **Patterns to follow:** the owner-aware feed collection + gated `addJs` in `home.html.twig:23-27`; the existing card markup in `trips.html.twig:16-34`.
- **Test scenarios:** Covered by U7 — TP1 (owner sees toggle + draft trip; anon does not, and the draft trip is absent for anon), TP2/TP3 (draft badge on listing after toggle).
- **Verification:** Owner load of `/trips` shows `.trip-publish-toggle` on each card and includes unpublished fixture trips; anon load shows neither; clicking a card cover still navigates (toggle click does not).
### U5. Home fallback when the active trip is unpublished
- **Goal:** Gate the home active-trip branch on the active trip being published so an unpublished active trip falls through to the between-trips / pre-departure state (R16, KTD7).
- **Requirements:** R16.
- **Dependencies:** none.
- **Files:** `user/themes/intotheeast/templates/home.html.twig`.
- **Approach:** Change the branch condition at `home.html.twig:12` from `{% if config.site.travelling %}` to `{% if config.site.travelling and trip.published %}`. `trip` is already resolved at line 10. No change to `site.active_trip`.
- **Patterns to follow:** existing branch structure in `home.html.twig`.
- **Test scenarios:** Covered by U7 TP6 (active fixture trip unpublished → home renders between-trips/pre-departure, not the draft active-trip view).
- **Verification:** With `travelling: true` and the active trip unpublished, `/` renders the fallback branch; republishing restores the active-trip view.
### U6. `trip-publish.js` + esbuild build wiring
- **Goal:** Implement the toggle behavior (confirm, pending, POST, optimistic success, failure revert + toast) and wire it into the theme build so `make build-assets` emits `js/trip-publish.js` (R12R15, KTD5, KTD8).
- **Requirements:** R12, R13, R14, R15.
- **Dependencies:** U2 (endpoint), U3 (markup contract).
- **Files:** `user/themes/intotheeast/js/src/trip-publish.js`, `user/themes/intotheeast/package.json`.
- **Approach:** Bind each `.trip-publish-toggle`. On change: if turning **off** and `data-active` is true → `window.confirm('This is your active trip — unpublishing it also removes it from the home page. Unpublish anyway?')`; on cancel revert and stop (R12). Set pending: disable the switch, `aria-busy`, dim + wait cursor, ignore further toggles (R13). Send `POST /api/v1/trip/<slug>/publish` with `headers: { 'Content-Type': 'application/json', Accept: 'application/json' }`, `body: JSON.stringify({ published })`, `credentials: 'include'` — modeled on `post-form.js` `apiSend` (KTD5). Success: flip `data-published`, toggle the `Draft` badge, update switch position/label/`aria-checked` in place; re-enable (R14). Failure: revert switch to prior state, re-enable, surface an error via the **visible** shared page-level toast defined in U3 (`role="status"`, `aria-live="polite"` — reuse the `feed-actions.js` copy but not its `sr-only` region, so a sighted owner actually sees it: 401/403 → "sign in again"; other → "Couldn't update — try again.") (R15). Wire the build: add an esbuild entry for `js/src/trip-publish.js` to `package.json` `build`, same flags as the `feed-actions.js` entry.
- **Patterns to follow:** `post-form.js` `apiSend` (js/src/post-form.js:890) for the request; `feed-actions.js` for the live-region + error copy + double-tap lock; the `feed-actions.js` esbuild entry in `package.json` `build`.
- **Test scenarios:** Covered by U7 — TP2/TP3 (optimistic flip + persistence), TP4 (active-trip confirm dismiss leaves published). Failure/toast copy is exercised where practical in TP5.
- **Verification:** `make build-assets` produces `js/trip-publish.js`; in the browser, toggling a card updates it in place without reload; unpublishing the active trip prompts a confirm.
### U7. Playwright specs (TP1, TP1b, TP2TP6)
- **Goal:** Cover the owner gate, cache-correct hide/restore, active-trip confirm, authz, and home fallback (R1R16 as observable behavior).
- **Requirements:** R1R16.
- **Dependencies:** U1U6.
- **Files:** `user/themes/intotheeast/...` (none); `tests/ui/trip/trip-publish.spec.js`.
- **Approach:** Run as the owner (same setup as the delete-flow specs). **New scaffolding this unit must build (not mirrored from the entry helpers):** the existing `createPhotoEntry`/`cleanupEntry`/`findEntry` helpers create/clean *entry* folders inside the active trip's `dailies` (`TRACKER_DIR`) — none create a *trip*. This unit needs a small on-disk trip-fixture helper that writes `pages/01.trips/<fixture>/` with a `trip.md` (a `date` for the listing sort, `published` set per test), plus `01.dailies/` and `04.stories/` `routable:false` container `.md` files, and cleans it up. TP6 additionally repoints `site.active_trip` to the fixture with `travelling: true` — the cited home-suite specs only patch `travelling`, never override `active_trip`, so this override is also new (restore `site.yaml` on teardown). Use the DEL4 fixture-then-reload assertion shape from `delete-flow.spec.js`.
- **TP1 — gate:** owner load of `/trips` shows `.trip-publish-toggle`; anon (cleared storageState) does not, and an unpublished fixture trip is absent for anon.
- **TP1b — coverless draft:** a fixture trip with no `cover_image` and no published entry image still renders a working `.trip-publish-toggle` for the owner (guards the coverless-container state from U4).
- **TP2 — unpublish hides it (caching):** owner toggles a published fixture off → reload `/trips` as anon → trip absent; owner reload → `Draft` badge present on the listing (the detail page 404s for the owner too). Mirrors DEL4's page-tree-index assertion.
- **TP3 — republish restores it:** owner on `/trips` toggles a Draft fixture back on → anon reload sees it; assert on the listing, not the detail page.
- **TP4 — active-trip confirm:** unpublishing the active trip prompts a confirm; dismissing leaves it published.
- **TP5 — authz:** `POST /api/v1/trip/<slug>/publish` as anon → 401; as an authenticated non-owner → 403; frontmatter unchanged on disk. Include a non-boolean-body → 400 assertion. **The 403 leg needs a second, authenticated non-owner identity** — the harness authenticates only one account (`auth.setup.js` → one `storageState`), so this leg requires either a second account + storageState (e.g. a non-owner login) or an in-test override of `owner_username` to a value the logged-in test user does not match, then a restore on teardown. This is not provided by the delete-flow setup; pick one approach and wire it explicitly.
- **TP6 — active trip unpublished → home falls back:** with the fixture set as `site.active_trip` and `travelling: true`, unpublish it → reload `/` → home renders between-trips/pre-departure, not the draft active-trip view (needs the `active_trip` override on the fixture; mirror the home-suite setup).
- **Patterns to follow:** `tests/ui/post/delete-flow.spec.js` (owner fixture + reload + on-disk assertion), `tests/ui/trip/trips-list.spec.js` (listing selectors), `tests/ui/post/anon-view.spec.js` (anon storageState + draft-visibility).
- **Test scenarios:** the six specs above are the scenarios.
- **Verification:** `npm run test:ui -- tests/ui/trip/trip-publish.spec.js` (from `tests/`) passes all seven (TP1, TP1b, TP2TP6), with fixture folders cleaned up afterward.
---
## Verification Contract
| Gate | Command | Applies to |
|---|---|---|
| Rebuild theme assets | `make build-assets` | U6 (emits `js/trip-publish.js`) |
| Trip publish specs | `npm run test:ui -- tests/ui/trip/trip-publish.spec.js` (run from `tests/`) | U7 |
| Full trip suite (no regressions) | `npm run test:ui -- tests/ui/trip` | U4, U5, U7 |
| Backend contract (manual/spec) | owner POST → 204 + on-disk `published:` change; anon → 401; non-owner → 403; non-boolean → 400 | U2 |
Run the dev stack for tests via the worktree's own container (`docker compose -p itte-<feature> up`) per the worktree dev-server convention. Do **not** flip any dev/prod mode flags to work around caching — the cache-clear is handled in-code (KTD3).
---
## Definition of Done
**Global**
- All seven Playwright specs (TP1, TP1b, TP2TP6) pass; the broader `tests/ui/trip` suite shows no regressions.
- `make build-assets` emits `js/trip-publish.js`; `js/trip-publish.js` and `js/feed-actions.js` are both current (no hand-edits to built files).
- Anonymous and non-owner behavior is unchanged: no toggle rendered, listing shows published trips only, backend rejects with 401/403.
- No abandoned/experimental code left in the diff; the plan status line is updated to `✅ Complete (YYYY-MM-DD)`.
**Per unit**
- U1: `resolveTripChild` returns the trip page for a real slug and `null` for unsafe/nonexistent/wrong-parent inputs.
- U2: endpoint persists `published` to `trip.md`, invalidates cache, returns 204/400/401/403/404 correctly.
- U3: partial renders the accessible switch + `Draft` badge with correct `data-*`, legible over a cover.
- U4: owner listing includes drafts + toggles; anon listing unchanged; card cover still navigates.
- U5: unpublished active trip → home fallback; published → active-trip view.
- U6: JS confirm/pending/optimistic/revert behaviors work in the browser; build entry wired.
- U7: specs implemented, fixtures cleaned up.
---
## Risks & Dependencies
- **Grav 2.0 save semantics (KTD1).** If a mutated-header `save()` does not persist `published`, the toggle no-ops silently. Mitigation: TP2 asserts the on-disk frontmatter change, not just UI; the `cache-on-save` plugin already relies on this pattern.
- **Cache staleness (KTD3).** Omitting `invalidateCache()` reproduces the `deleteEntry` bug (stale listing/nav/home). Mitigation: TP2/TP3 assert visibility after a full reload as a fresh (anon) client.
- **Build step required (KTD8).** Editing `js/src/trip-publish.js` without adding the esbuild entry (or without running `make build-assets`) ships nothing. Mitigation: DoD requires the built file to be current; U6 owns the `package.json` edit.
- **Test-harness owner identity.** The specs assume `testrunner` acts as owner (as the delete-flow specs do). If that assumption is wrong, the owner-gated specs fail fast at the gate; resolve by matching the existing owner-only spec setup rather than inventing a new override.
- **Upstream dependency:** none external; this is self-contained within `user/` (theme + two custom plugins) and the `tests/` harness.
---
## Open Questions
Both are non-blocking (defense-in-depth / UX-copy) and do not hold up implementation, but resolve them before or during U2/U6.
- **CSRF boundary is implicit.** The endpoint is a session-cookie-authenticated write with `credentials: 'include'`. Its only cross-origin protection is incidental: KTD5's required `Content-Type: application/json` plus the strict `is_bool` guard force a CORS-preflighted request an attacker cannot forge — *unless* the `api` plugin emits permissive CORS headers. Verify the `api` plugin sends no `Access-Control-Allow-Origin`/`-Credentials` that would defeat the preflight, and state the preflight as the intended CSRF boundary in U2 (or add an explicit token check if it does).
- **Draft is not a privacy control (owner mental model).** Unpublishing hides the trip from the `/trips` listing but leaves every child URL (stories, dailies, media) publicly served (documented non-goal). An owner clicking a `Draft` switch may reasonably expect the content to go private. Decide whether the unpublish `confirm()` copy (R12) or toggle help text should say child content stays reachable by direct link, so `Draft` is not mistaken for a retract-content action.
### From 2026-07-08 doc review
- **Owner test-identity for the Playwright suite is unspecified and contradicts committed config (adversarial, P1 — blocking for U7).** The Assumptions block asserts the harness treats `testrunner` as `owner_username`, but committed `user/config/site.yaml` sets `owner_username: mischa`, and `EntryScopeGuard::isOwnerUser` is a strict username match with no super-admin bypass. So every owner-gated spec (TP1, TP1b, TP2, TP3, TP4, and TP5's owner leg) depends on untracked local state (a dirty `site.yaml` or a `.env` `GRAV_TEST_USER` override) that the new specs cannot reproducibly "inherit" — and TP5's non-owner override is described in the *inverted* direction (it only makes sense if `testrunner` were owner by default). **Resolve before writing U7:** confirm the worktree container's actual `GRAV_TEST_USER` / `owner_username` binding, then replace the "inherit testrunner-as-owner" assumption with an explicit tracked suite-setup step that pins `site.owner_username` to the authenticated test user (restore on teardown) and derives TP5's 403 leg from a value that user does not match. Do not rely on the committed `owner_username: mischa` or an untracked local `site.yaml`.