# CLAUDE.md ## 0. Project specifics **Only ever write changes in this folder (travel-blog-intotheeast/) or its subfolders.** ### Folder explanation - **./**: Grav CMS dev environment for intotheeast travel blog - **scripts/**: Server install and maintenance scripts - **user/**: Site content, config, pages, and theme — its own git repo (`intotheeast-com-content.git`), tracked by the outer repo as a **git submodule** (pinned commit). See "Dual-repo submodule structure" below and `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` - **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19) - **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area - **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts ### Current stack - **Grav:** 2.0.7 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`) - **Admin:** Admin2 v2.0.12 (plugin slug: `admin2`, NOT `admin`) - **GPM channel:** `stable` — set in `user/config/system.yaml` → `gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only - **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt` - **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production` - **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini` ### Dev server The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`). A second service, `travel-memories`, runs at **http://localhost:8082**. Both ports and the container name are overridable via `GRAV_PORT` / `TM_PORT` / `GRAV_CONTAINER` — a worktree's `.worktree-env` sets these so isolated servers never collide (see "Dual-repo submodule structure"). ### Local dev commands `make setup` for a first run (build → start → install-plugins → fix-perms); `make start` / `make stop` thereafter. Other targets are self-describing in the `Makefile`. **`make build-assets` is mandatory after editing anything in `user/themes/intotheeast/js/src/`** — esbuild writes the *committed* bundles `js/main.js`, `js/map.js`, `js/feed-actions.js`, `js/trip-publish.js`, `js/post/`, and `css-compiled/`. **Never hand-edit those.** By contrast `css/style.css` and `css/tokens.css` are hand-authored sources. ### Custom plugins Three plugins are site-owned and tracked in the `user/` repo (everything else under `user/plugins/` is GPM-managed and git-ignored): **`cache-on-save`** (clears page-tree cache on `new-entry` submits + injects the write target from `site.active_trip`), **`story-blocks`** (story shortcodes), **`entry-actions`** (owner-only entry delete via the API). ### Local plugin patches Third-party plugins live in the **git-ignored** `user/plugins/`, so local fixes to them do not travel with the content repo and are **overwritten by `make install-plugins`** or a fresh image build. Keep the fix as a tracked patch in `deploy/patches/` instead: - `make apply-plugin-patches` — idempotent `git apply` (skips already-applied patches). `make install-plugins` runs it automatically as its last step - `make remote-apply-plugin-patches-test` / `-prod` — piped over SSH into `patch -p1 --forward`; also runs automatically after a remote plugin install - Details and the current patch list: `deploy/patches/README.md` ### Trip entity architecture The site is structured around Trip entities. Key facts: - Active trip is set in `user/config/site.yaml` → `active_trip` (currently `/trips/denmark-2026`). The value is a **route**, not a bare slug - Trip pages live at `user/pages/01.trips//` - Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page - `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips//dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page - Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections - New journal entries are written to the active trip's `dailies` — the write target is derived from `site.active_trip` at submit time by the `cache-on-save` plugin (post-form.md no longer hardcodes `pageconfig.parent`) - The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories). The standalone `/dailies`, `/map`, `/stats`, `/stories` view pages no longer exist — do NOT try to re-create them or link to them. This filter bar + stats chrome is shared with the home active-trip view via the `trip-feed-col` partial (see "Two shared partials" below) - Stats are shown inline on the trip page via a toggle (the standalone `/stats` view was removed) - GPX route files live as media on the trip page itself, parsed client-side via toGeoJSON (bundled into `js/map.js`) and drawn on the trip/home map ### Two shared partials — the rules Trip and home render the same map and feed chrome through **two** shared partials, both included with `{% include … with {…} only %}`. Full parameter contracts: [`docs/reference/architecture.md`](docs/reference/architecture.md) → "Shared partial contracts". The rules that must not be broken: - **`partials/entry-map.html.twig` is the only map path.** The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js`. Do not add a second map implementation — an older three-variant setup (`feed-map.html.twig`, full-page `map.html.twig`) was consolidated away on 2026-07-04. - **It must keep assigning `window.tripMap` / `window.homeMap`** — the Playwright map specs assert these globals. - **`partials/trip-feed-col.html.twig` is the feed column beside the map** (date-range header, filter bar, stats/cycling panels, feed loop). Its sibling `partials/home-predeparture.html.twig` is the home-only "Coming soon" state, selected by `home.html.twig` when `all_items` is empty. **Keep `trip-feed-col` single-purpose — do NOT fold the pre-departure branch back into it.** - **Stats glue:** `trip-feed-col` calls `window.initTripStats({…})`, one shared function in `js/src/main.js` that depends on `window.MapUtils` from `map.js`. Both load in the `bottom` asset group. ### GPX file management GPX files are page media on the trip page (`user/pages/01.trips//`), auto-detected via `trip_page.media.all` filtered to `.gpx` and passed to the `entry-map` partial — no manual linking. `.gpx` is registered in `user/config/media.yaml`. Manage them at `/gpx-manager` (admin login required; filenames auto-slugified on upload), or drop files into the trip folder and `make content-push`. Wiring details — API routes, session-cookie auth, the `Blob`/`FormData` upload gotcha — are in [`docs/guides/gpx-manager.md`](docs/guides/gpx-manager.md). ### Switching to a new trip The active trip lives in **one** place: `user/config/site.yaml` → `active_trip`. **Never re-add a `pageconfig.parent` to `post-form.md`** — `cache-on-save` derives the write target from `site.active_trip` at submit time, and a static parent would override it and reintroduce the old silent-desync bug. Procedure and the new-trip page tree: [`docs/guides/trip-switching.md`](docs/guides/trip-switching.md). ### Environment **Never read `.env`, `.env.prod`, or `.env.test`** — they contain sensitive credentials. You may pass them to commands (e.g. `docker compose`, `make`) but never read their contents directly. Ask the user if you need environment-specific information. ### Remote operations Always use `make` commands for anything on the production server (`make remote-install-plugins`, `make remote-clean`, etc.) — never SSH directly since credentials live in `.env`. If a remote operation isn't covered by an existing `make` command, either ask the user to run it manually or suggest adding a new `make` command if it seems reusable. For a full upgrade/deploy through local → test → prod (ordered steps, smoke checklist, rollback), follow the runbook at [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md). ### Content sync - `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook) - `make content-pull` — pull latest from Gitea to local - `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it - `make demo-load` — load **every** fixture trip under `user/docs/demo/trips/` into the pages tree (currently `italy-2026-demo` and `no-photos-demo`). Add a new fixture by dropping a trip folder there; no Makefile edit needed - `make demo-reset` — remove the demo trips' pages folders and clear cache (full reset; re-run `demo-load` to restore) - `make pixelfed-import` — import posts from Pixelfed via `scripts/pixelfed-import.py` ### User repo gitignore Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded — **except** the three site-owned plugins, which are un-ignored explicitly (see "Custom plugins" below). Also ignored: the test accounts, `italy-2026-demo` pages, secrets (`config/plugins/git-sync.yaml`, `config/security.yaml`, `api-private.php`), and the whole `env/` override tree. ### Dual-repo submodule structure `user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials: - **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink. - **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently. - **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`. - **Worktrees for parallel work — use the make targets, don't do it by hand.** `make worktree-new NAME=` (from the main checkout) creates the outer worktree off `main`, initialises its own `user/` submodule, branches both, and starts an **isolated** dev server (own container name + auto-assigned port `8090+`, persisted in a git-ignored `.worktree-env` so every `make`/compose command in that worktree targets its own server). `make worktree-rm NAME=` tears it down cleanly (compose down → `submodule deinit` → `worktree remove` → `prune`) — skipping the deinit is what leaves orphaned `.worktrees/` dirs. Worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). A fresh worktree's `user/` is empty until the submodule init runs, and `M user`/`m user` is normal (see above) — do not "fix" either. To add a commit to `main` while the main checkout is on another branch, use a throwaway `main` worktree rather than `git checkout main`. ## 1. Environment modes ### Rule: do not switch modes during development **Never toggle between development and production mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than temporarily flipping a mode flag to work around it. Mode switches introduce inconsistent state and make bugs harder to reproduce. ### Development mode (current) Active settings in `user/config/system.yaml`: | Setting | Dev value | Why | |---|---|---| | `twig.cache` | `false` | Theme file edits take effect immediately; no stale compile errors | With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file. ### Production mode (per-environment override) Prod needs different Twig settings than dev, but they are **never** committed to `user/config/system.yaml` — `twig.cache: false` and `debug`/`auto_reload: true` there are the *intended dev values*, and committing prod values breaks local development for everyone. Prod values live in the version-controlled `deploy/env/prod/system.yaml` and deploy to the server's `user/env//` tree via `make remote-apply-env-`. Mechanics, the settings table, and the re-apply-after-install rule: [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md) → "The env override tree". Two things to carry in your head: > **⚠️ Once `user/env//` exists, Grav's Admin saves ALL config there** — system *and* plugin. So (a) config edited via Admin **on the server is server-only** and silently never reaches Gitea or local (good for secrets, invisible to the repo); (b) when reading or writing server config, check **both** `user/config/…` and `user/env//config/…` — **env wins**, so tooling must look there first. **Pre-launch smoke test:** with the prod override applied, submit one post via `/post` and confirm it appears in the trip feed immediately — this proves `cache-on-save` works with caching on. ## 2. Local development setup Full setup guide: [`docs/guides/local-setup.md`](docs/guides/local-setup.md) ### Superpowers skill paths Specs: `docs/working/specs/YYYY-MM-DD--design.md` Plans: `docs/working/plans/YYYY-MM-DD-.md` The brainstorming and writing-plans skills default to `docs/superpowers/`; these lines override that default. ### Plan status convention Every plan in `docs/working/plans/` must have a `**Status:**` line immediately after the title heading: | Status | Meaning | |---|---| | `📋 Not started` | Plan written; work not yet begun | | `🔄 In progress — ` | Actively being worked on | | `⏸️ Deferred — ` | Intentionally postponed | | `✅ Complete (YYYY-MM-DD)` | Done | | `❌ Abandoned — ` | Won't implement | **When asked what's open:** surface `Not started` and `In progress` plans. Show `Deferred` plans but label them clearly. Omit `Complete` and `Abandoned` unless explicitly asked. **When finishing a plan:** update the `**Status:**` field in the plan file to `✅ Complete (YYYY-MM-DD)` before closing the session. This applies whether execution was done by Claude directly, via the superpowers:executing-plans skill, or via superpowers:subagent-driven-development. ## 3. Testing **The dev server must be running** (`make start`) — every suite drives the live site over HTTP. | Command | Scope | |---|---| | `make test` | Everything: `test-config` → `test-post` → `test-ui` | | `make test-config` | Form/config sanity via `scripts/test-form-config.sh` | | `make test-post` | End-to-end post submission via `scripts/test-post.sh` | | `make test-ui` | Playwright suite (`npx playwright test`) | - **Test account is automatic.** `test-post` and `test-ui` depend on `test-account`, which creates a `testrunner` admin (password `Testpass1234`) inside the container if absent. It is git-ignored — never commit it, and keep the password free of shell/Make/URL-special characters since several consumers interpolate it. - **Playwright layout:** config at `playwright.config.js`, specs under `tests/ui/` (`a11y`, `auth`, `dailies`, `gpx`, `home`, `maps`, `nav`, `post`, `stories`, `trip`), shared helpers in `tests/ui/helpers.js`, global setup/teardown in `tests/`. - **Auth is a dependency project.** `auth.setup.js` runs first and writes `tests/.auth/user.json`; the `chromium` project reuses it as `storageState`. Don't add per-test logins. - **Base URL:** defaults to `http://localhost:8081`; override with `GRAV_BASE_URL` (required when testing a worktree's isolated server on `8090+`). - Single spec / focused run: `npx playwright test tests/ui/maps` (add `--headed` to watch). `retries: 0` and screenshots-on-failure only, so a failure is a real failure.