Compare commits
1
Commits
main
..
8202d2a257
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8202d2a257 |
@@ -6,9 +6,10 @@ Rules, gotchas, and entry points — the things that must change what you do *be
|
||||
|---|---|
|
||||
| How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
|
||||
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
|
||||
| Doing something operational — posting, writing stories, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
||||
| Doing something operational — posting, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
||||
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||
| A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/) — grep the `module`/`tags`/`problem_type` frontmatter; check when working in a documented area |
|
||||
| Why an old plan describes something that no longer exists | [`docs/reference/superseded-decisions.md`](docs/reference/superseded-decisions.md) — check before re-creating anything found in `docs/working/` |
|
||||
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
|
||||
|
||||
The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||
@@ -20,13 +21,13 @@ The site is Grav (flat-file PHP CMS, no database) in Docker, with content and th
|
||||
- **Never SSH to a server directly** — use the `make remote-*` targets, since credentials live in `.env`. If no target covers what you need, ask the user to run it or propose a new target.
|
||||
- **Never hand-edit build output** — sources and outputs share folders under `user/themes/intotheeast/` (paths below are relative to it), so know which is which. Run `make build-assets` after editing any source.
|
||||
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
|
||||
- `css-compiled/` and `fonts/` are generated by esbuild from the `js/src/` entrypoints' CSS and font imports (fontsource, photoswipe, maplibre-gl) — **not** from `css/`. `css/style.css` and `css/tokens.css` are hand-authored and served directly (`partials/base.html.twig`), so editing them needs no rebuild.
|
||||
- `templates/partials/weather-icons.html.twig` is generated (source: `scripts/gen-weather-icons.js`).
|
||||
- `css-compiled/` and `fonts/` are **esbuild output from the imports inside `js/src/`** — *not* from `css/`. Everything in `css/` is hand-authored and served directly (`assets.addCss` in `partials/base.html.twig`), never compiled. So `templates/partials/weather-icons.html.twig` is also generated (source: `scripts/gen-weather-icons.js`).
|
||||
- **Never toggle dev↔prod mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than flipping a mode flag — mode switches leave inconsistent state and make bugs harder to reproduce.
|
||||
|
||||
## Dev environment
|
||||
|
||||
- Dev server: **http://localhost:8081** (`make setup` on a first run, `make start` / `make stop` after). A second service, `travel-memories`, runs on :8082. A worktree gets its own container and port `8090+` from its `.worktree-env` — pass `GRAV_BASE_URL` when pointing tests at one.
|
||||
- Dev server: **http://localhost:8081** (`make setup` on a first run, `make start` / `make stop` after). A worktree gets its own container and port `8090+` from its `.worktree-env` — pass `GRAV_BASE_URL` when pointing tests at one.
|
||||
- ⚠️ **`make start` / `make setup` fail on a clean checkout** — `docker compose up -d` still tries to build a `travel-memories` service whose source was moved out of this repo, so the build context is missing. Use **`make start-grav`** (Grav only). Existing containers keep working from a cached image, which is why this hides until a rebuild.
|
||||
- `user/config/system.yaml` is committed with **dev** values (`twig.cache: false`), so templates recompile per request and no cache flush is needed after editing a `.html.twig`. Prod values live in `deploy/env/prod/system.yaml` and **never** in `user/config/`.
|
||||
- ⚠️ **Once `user/env/<hostname>/` exists on a server, 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; (b) when reading or writing server config, check **both** `user/config/…` and `user/env/<host>/config/…` — **env wins**, so look there first. Mechanics: [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md).
|
||||
- The Admin plugin slug is **`admin2`**, not `admin`.
|
||||
@@ -45,8 +46,8 @@ The site is Grav (flat-file PHP CMS, no database) in Docker, with content and th
|
||||
|
||||
Trip and home render the same map and feed chrome through two shared partials, both included `with {…} only`. Parameter contracts: [`docs/reference/architecture.md`](docs/reference/architecture.md) → "Shared partial contracts". What must not break:
|
||||
|
||||
- **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add another display-map implementation; an older three-variant setup was deliberately consolidated away.
|
||||
- **One sanctioned exception: `js/src/location-map.js`**, the `/post` form's pin *editor* (one draggable marker, no popups/GPX/bounds-fitting, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it). It shares exactly one thing with the display path — `MAP_STYLE` from `js/src/map-style.js`, imported by both so the basemap cannot drift. Do not fold it into `initEntryMap`, and do not add a *third* path.
|
||||
- **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add a third; an older three-variant setup was deliberately consolidated away.
|
||||
- **One sanctioned exception:** `js/src/location-map.js` is the `/post` pin *editor* — one draggable marker, no popups/GPX/bounds, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it. It shares exactly one thing with the display path, the style URL in `js/src/map-style.js`. Do not fold them together.
|
||||
- It must keep assigning **`window.tripMap` / `window.homeMap`** — the Playwright map specs assert those globals.
|
||||
- **Keep `trip-feed-col.html.twig` single-purpose.** Its sibling `partials/home-predeparture.html.twig` is the home-only "Coming soon" state — do **not** fold the pre-departure branch back into it.
|
||||
|
||||
|
||||
@@ -54,15 +54,9 @@ $(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,
|
||||
GRAV_TEST_USER ?= testrunner
|
||||
GRAV_TEST_PASS ?= Testpass1234
|
||||
|
||||
# The password is handed to the container through `docker exec -e` (the bare
|
||||
# form, which forwards the already-exported variable) rather than interpolated
|
||||
# into the `sh -c` string. Interpolating it meant any shell-special character in
|
||||
# GRAV_TEST_PASS was re-parsed by the container's shell — a `.env` password
|
||||
# containing one produced `sh: 2: <fragment>: not found` and no test account.
|
||||
# The recipe is now indifferent to the password's contents.
|
||||
test-account:
|
||||
@docker exec -e GRAV_TEST_PASS $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$$GRAV_TEST_PASS" \
|
||||
@docker exec $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
|
||||
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
||||
|
||||
test-config:
|
||||
@@ -71,13 +65,6 @@ test-config:
|
||||
test-post: test-account
|
||||
@bash scripts/test-post.sh
|
||||
|
||||
# Pinned to THIS checkout's port, not playwright.config.js's :8081 default. In a
|
||||
# worktree that default silently pointed the suite at the main checkout's server,
|
||||
# so entries were created in main's user/ while the specs asserted and cleaned up
|
||||
# in the worktree's — leaving ui-test entries behind in real trip content.
|
||||
# tests/global-setup.js now also hard-fails on that mismatch.
|
||||
GRAV_BASE_URL ?= http://localhost:$(GRAV_PORT)
|
||||
|
||||
test-ui: test-account
|
||||
@npx playwright test
|
||||
|
||||
@@ -111,18 +98,8 @@ build-assets:
|
||||
-w /app node:20-alpine \
|
||||
sh -c "npm install && npm run build"
|
||||
|
||||
# In a worktree this degrades to start-grav. The travel-memories service declares
|
||||
# `env_file: .env`, and worktree-new does not create a .env, so a plain
|
||||
# `docker compose up -d` there dies with "env file ... not found" — leaving the
|
||||
# worktree with no server at all, which is how test runs ended up silently
|
||||
# targeting the main checkout.
|
||||
start:
|
||||
@if [ -f .worktree-env ]; then \
|
||||
echo "→ worktree: starting the grav service only (travel-memories needs a .env, which worktrees have none)"; \
|
||||
docker compose up -d grav; \
|
||||
else \
|
||||
docker compose up -d; \
|
||||
fi
|
||||
docker compose up -d
|
||||
|
||||
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
||||
# travel-memories service, and this keeps its footprint minimal).
|
||||
@@ -200,12 +177,6 @@ worktree-rm: guard-name
|
||||
-git -C "$(WT_DIR)" submodule deinit -f user
|
||||
git worktree remove --force "$(WT_DIR)"
|
||||
git worktree prune
|
||||
# The deinit above is required (a populated user/ blocks `worktree remove`),
|
||||
# but worktrees SHARE .git/config — so it also strips submodule.user.url for
|
||||
# the MAIN checkout, leaving `git submodule status` there showing `-` (not
|
||||
# initialised) even though user/ is intact. Re-register it; init is
|
||||
# idempotent and touches config only, never the working tree.
|
||||
git submodule init
|
||||
@echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
|
||||
|
||||
# ── Demo content ──────────────────────────────────────────────────────────────
|
||||
@@ -214,13 +185,6 @@ demo-load:
|
||||
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
||||
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
||||
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
||||
#
|
||||
# ⚠️ A fixture whose folder name matches a REAL trip's slug is copied straight
|
||||
# over that live page — docs/demo/trips/italy-2025/ collides with the real
|
||||
# italy-2025 trip on purpose (the fixture supplies its GPX + dailies). So any
|
||||
# field the fixture's trip.md omits gets silently deleted from real content on
|
||||
# every test run: it had been dropping the trip's tagline that way. Keep a
|
||||
# colliding fixture's trip.md byte-identical to the live page.
|
||||
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
||||
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
||||
|
||||
@@ -27,7 +27,7 @@ Two git repos:
|
||||
| `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||
| `php/` | Local PHP ini overrides |
|
||||
| `docs/` | All project documentation — start at [`docs/README.md`](docs/README.md) |
|
||||
| `docs/guides/` | Operational how-tos (posting, story authoring, GPX, trip switching, setup, deploy cycle) |
|
||||
| `docs/guides/` | Operational how-tos (posting, GPX, trip switching, setup, deploy cycle) |
|
||||
| `docs/reference/` | Stable facts: architecture, design system, testing |
|
||||
| `docs/solutions/` | Write-ups of bugs and workflow traps already hit, with YAML frontmatter (`module`, `tags`, `problem_type`) |
|
||||
| `docs/working/` | Specs, plans, backlog, QA — work in flight |
|
||||
@@ -48,17 +48,23 @@ Two git repos:
|
||||
## Local development setup
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill in your values — never commit this file
|
||||
make setup # start Docker container and install plugins
|
||||
cp .env.example .env # fill in your values — never commit this file
|
||||
git submodule update --init user
|
||||
mkdir -p user/plugins user/data
|
||||
make build && make start-grav && make install-plugins && make fix-perms
|
||||
```
|
||||
|
||||
Site runs at http://localhost:8081.
|
||||
|
||||
Clone the user content repo into `user/` if not already present:
|
||||
`user/` is a **git submodule** — initialise it with `git submodule update --init user`. Do not
|
||||
`git clone` into `user/` by hand; that detaches it from the pin the outer repo tracks.
|
||||
|
||||
```bash
|
||||
git clone $USER_REPO user/
|
||||
```
|
||||
> ⚠️ **Use `make start-grav`, not `make setup`, on a clean checkout.** `make setup` runs `make start`
|
||||
> (`docker compose up -d`), which still tries to build the `travel-memories` service — but its source
|
||||
> was moved to a separate project (`a80b0a9`) and `services/` is gitignored, so the build context is
|
||||
> missing and the command fails. `make start-grav` brings up Grav only. Machines with a cached
|
||||
> `travel-memories` image will not see this until their next rebuild. See
|
||||
> [`docs/reference/superseded-decisions.md`](docs/reference/superseded-decisions.md) → R11.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,12 +75,12 @@ git clone $USER_REPO user/
|
||||
**2. Run the install:**
|
||||
|
||||
```bash
|
||||
make remote-install
|
||||
make remote-install-prod # or -test
|
||||
```
|
||||
|
||||
This SSHes into the server, downloads Grav, clones both repos (user content + this config repo), installs plugins, and prints the server's SSH public key.
|
||||
|
||||
**3. Add the SSH key to Gitea** — copy the printed public key and add it as a read-only deploy key to both Gitea repos. After this, `make remote-fetch` works without credentials.
|
||||
**3. Add the SSH key to Gitea** — copy the printed public key and add it as a read-only deploy key to both Gitea repos. After this, `make remote-fetch-prod` works without credentials.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,8 +107,9 @@ make content-push # push local user/ commits → Gitea
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make setup` | First run: build → start → install plugins → fix perms |
|
||||
| `make start` | Start the local Docker container |
|
||||
| `make setup` | First run: build → start → install plugins → fix perms. ⚠️ Currently fails on a clean checkout — see the setup note above; use the `start-grav` sequence instead |
|
||||
| `make start` | Start **all** compose services. ⚠️ Fails where `services/travel-memories` is absent |
|
||||
| `make start-grav` | Start the Grav service only — the reliable option |
|
||||
| `make stop` | Stop the local Docker container |
|
||||
| `make install-plugins` | (Re)install plugins from plugins.txt, then apply local plugin patches |
|
||||
| `make apply-plugin-patches` | Idempotently re-apply the patches in `deploy/patches/` |
|
||||
@@ -137,35 +144,67 @@ Details and conventions: [`docs/reference/testing.md`](docs/reference/testing.md
|
||||
| `make worktree-new NAME=<feature>` | Create a worktree with its own `user/` checkout and an isolated dev server on port `8090+` |
|
||||
| `make worktree-rm NAME=<feature>` | Tear one down cleanly (compose down → submodule deinit → worktree remove → prune) |
|
||||
|
||||
### Remote credentials
|
||||
### Remote targets — every one needs an environment suffix
|
||||
|
||||
> **All `remote-*` targets require `-test` or `-prod`.** A bare `make remote-fetch` fails via
|
||||
> `guard-env` with *"no environment. Use an env-suffixed target"*. The suffixed variants are generated
|
||||
> by a macro in the `Makefile`, so they will not show up in a grep for literal target names.
|
||||
|
||||
The runbook for shipping a change through test → prod is
|
||||
[`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md). The tables below are the inventory.
|
||||
|
||||
**Credentials** — always run `remote-env-remove-<env>` when done; credentials must not persist on the server.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make remote-env-setup` | Write Gitea credentials to `~/.env-intotheeast` on the server |
|
||||
| `make remote-env-remove` | Delete `~/.env-intotheeast` from the server |
|
||||
| `make remote-env-setup-<env>` | Write Gitea credentials to `~/.env-intotheeast` on the server |
|
||||
| `make remote-env-remove-<env>` | Delete `~/.env-intotheeast` from the server |
|
||||
| `make remote-secrets-audit-<env>` | Check the server for exposed secrets |
|
||||
| `make remote-seed-api-salt-<env>` | Generate the API/CSRF salt on the server |
|
||||
|
||||
Always run `make remote-env-remove` when done. Credentials must not persist on the server.
|
||||
|
||||
### Remote server management
|
||||
**Install and sync**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make remote-install` | First-time install: download Grav, clone both repos, install plugins |
|
||||
| `make remote-fetch` | Pull latest config repo (Makefile, scripts, plugins.txt) on the server |
|
||||
| `make remote-install-plugins` | Install/update plugins from local plugins.txt on the server |
|
||||
| `make remote-upgrade-grav` | Upgrade Grav core on the server |
|
||||
| `make remote-clean` | Clear Grav cache on the server |
|
||||
| `make remote-maintenance-on` | Enable maintenance mode (visitors see offline page) |
|
||||
| `make remote-maintenance-off` | Disable maintenance mode |
|
||||
| `make remote-install-<env>` | First-time install: download Grav, clone both repos, install plugins |
|
||||
| `make remote-fetch-<env>` | Pull latest config repo (Makefile, scripts, plugins.txt) on the server |
|
||||
| `make remote-fetch-content-<env>` | Pull latest `user/` content on the server |
|
||||
| `make remote-content-status-<env>` | Show the server's content-repo state |
|
||||
| `make remote-apply-env-<env>` | Apply `deploy/env/<env>/` config into the server's env tree — **re-run after any fresh install** |
|
||||
| `make remote-apply-plugin-patches-<env>` | Re-apply `deploy/patches/` on the server |
|
||||
|
||||
**Plugins and core**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make remote-install-plugins-<env>` | Install plugins from local plugins.txt on the server |
|
||||
| `make remote-update-plugins-<env>` | Update installed plugins via GPM |
|
||||
| `make remote-gpm-install-<env>` | Install a single plugin via GPM |
|
||||
| `make remote-upgrade-grav-<env>` | Upgrade Grav core on the server (in place — servers have no image) |
|
||||
|
||||
**Operations**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make remote-clean-<env>` | Clear Grav cache on the server |
|
||||
| `make remote-warmup-<env>` | Clear **and warm** the cache after a deploy |
|
||||
| `make remote-maintenance-on-<env>` | Enable maintenance mode (visitors see offline page) |
|
||||
| `make remote-maintenance-off-<env>` | Disable maintenance mode |
|
||||
| `make remote-diag-<env>` | Diagnostics on the server |
|
||||
| `make remote-git-sync-enable-<env>` / `-disable-<env>` | Toggle the remote-only git-sync plugin |
|
||||
| `make remote-wipe-<env>` | ⚠️ Destroy the server install |
|
||||
|
||||
### Typical upgrade workflow
|
||||
|
||||
Run against `test` first — it is a full dress rehearsal of prod.
|
||||
|
||||
```bash
|
||||
make remote-maintenance-on
|
||||
make remote-upgrade-grav
|
||||
make remote-install-plugins
|
||||
make remote-clean
|
||||
make remote-maintenance-off
|
||||
make remote-maintenance-on-prod
|
||||
make remote-upgrade-grav-prod
|
||||
make remote-install-plugins-prod
|
||||
make remote-apply-env-prod # env tree is not restored by anything else
|
||||
make remote-warmup-prod
|
||||
make remote-maintenance-off-prod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+6
-1
@@ -15,9 +15,14 @@
|
||||
|
||||
**Design or architecture decisions?** → [`reference/`](reference/)
|
||||
- [Design system](reference/design-system.md)
|
||||
- [Architecture overview](reference/architecture.md)
|
||||
- [Architecture overview](reference/architecture.md) — the site as it actually is
|
||||
- [Superseded decisions](reference/superseded-decisions.md) — what was planned, then reversed, and why
|
||||
- [Testing](reference/testing.md)
|
||||
|
||||
> Documents under [`working/`](working/) are historical records. If one describes something that no
|
||||
> longer exists, [`reference/superseded-decisions.md`](reference/superseded-decisions.md) says what
|
||||
> replaced it.
|
||||
|
||||
---
|
||||
|
||||
## If you're Claude
|
||||
|
||||
+73
-18
@@ -1,18 +1,23 @@
|
||||
# Posting a Journal Entry
|
||||
|
||||
Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the **Admin panel** at `/admin` (drafts, scheduling, editing).
|
||||
Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the **Admin panel** at `/admin` (scheduling, bulk edits). The `/post` form also **edits** existing entries — see below.
|
||||
|
||||
---
|
||||
|
||||
## Quick start — mobile form
|
||||
|
||||
1. Open `/post` on your phone (login required)
|
||||
2. Fill in **Title** and **Content** (required)
|
||||
3. Tap **Get Location** → fills Lat/Lng automatically
|
||||
4. Tap **Get Weather** → fills weather fields using your coordinates
|
||||
5. Type **City** and **Country** (optional but nice)
|
||||
6. Attach photos (optional) — first photo becomes the hero image
|
||||
7. Tap **Submit** → entry appears in the feed immediately
|
||||
2. **Attach 1–6 photos** — photos come first because they anchor what you write. **At least one is required**; the form collapses them into a summary bar once uploaded
|
||||
3. Fill in **Title** and **Content** (required)
|
||||
4. Tap **Get Location** → fills Lat/Lng, then reverse-geocodes City + Country for you
|
||||
5. Tap **Get Weather** → fills weather fields using those coordinates
|
||||
6. Optional: open **More location details** to search for a place by name, or drag the pin on the map to place it exactly
|
||||
7. Optional: open **More options** for transport mode, publish state, connector and highlight toggles
|
||||
8. Tap **Submit** → entry appears in the feed immediately
|
||||
|
||||
> **Photos are mandatory (1–6).** This changed during the 2026-07 post-form work — an entry with no
|
||||
> photo will not submit. The first photo in the grid is the hero; reorder by dragging to change it.
|
||||
> See [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md) → R7, R8.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,14 +25,20 @@ Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| Photos | ✅ | **1–6 per entry.** HEIC is converted to JPEG in the browser. First photo = hero; drag to reorder |
|
||||
| Title | ✅ | Entry headline |
|
||||
| Content | ✅ | Markdown body |
|
||||
| Date | ✅ | Defaults to now — adjust if posting later |
|
||||
| Lat / Lng | — | Filled by Get Location; used for map marker |
|
||||
| City | — | Shown as `📍 Kyoto, Japan` on feed cards |
|
||||
| Country | — | Combined with City in location badge |
|
||||
| Lat / Lng | — | Filled by Get Location, by place search, or by dragging the map pin |
|
||||
| City | — | Auto-filled by reverse geocoding after Get Location; shown as `📍 Kyoto, Japan` on feed cards |
|
||||
| Country | — | Combined with City in the location badge |
|
||||
| Weather | — | Filled by Get Weather (Open-Meteo, free, no key) |
|
||||
| Photos | — | All uploaded files appear in the gallery; first = hero |
|
||||
| How I got here | — | `transport_mode`: walking · bicycle · bus · train · car · plane |
|
||||
| Published | — | Advanced. Default **Yes**. Set No to keep a draft, or to unpublish on edit |
|
||||
| Force connector line | — | Advanced. Default No. Forces a map connector to this entry even when suppressed |
|
||||
| Featured highlight | — | Advanced. Default No. Opts the entry into the home highlights grid |
|
||||
|
||||
The advanced three sit behind **More options**. There is **no `hero_image` field** — see the note above.
|
||||
|
||||
**Weather descriptions** (must be one of these if entered manually):
|
||||
`Sunny` · `Partly cloudy` · `Cloudy` · `Foggy` · `Drizzle` · `Rain` · `Snow` · `Thunderstorm`
|
||||
@@ -40,8 +51,9 @@ Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the
|
||||
Browser → /post (post-form.md)
|
||||
└─ Grav Form plugin validates fields
|
||||
└─ cache-on-save injects parent from site.active_trip
|
||||
└─ add-page-by-form plugin
|
||||
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
|
||||
└─ and sets overwrite_mode: edit when edit_path is filled, else false
|
||||
└─ add-page-by-form plugin (patched — see deploy/patches/)
|
||||
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>.entry/entry.md
|
||||
└─ moves uploaded photos into the page folder
|
||||
└─ cache-on-save plugin
|
||||
└─ calls $grav['cache']->deleteAll() → entry visible immediately
|
||||
@@ -55,16 +67,22 @@ Example: `2026-07-20-0930-first-day-in-kyoto.entry`
|
||||
```
|
||||
user/pages/01.trips/denmark-2026/01.dailies/
|
||||
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
||||
├─ entry.md ← frontmatter + markdown body
|
||||
├─ temple.jpg ← hero image (or set hero_image in frontmatter)
|
||||
└─ market.jpg ← additional gallery image
|
||||
├─ entry.md ← frontmatter + markdown body
|
||||
├─ photo-01.jpg ← first in order, so this is the hero
|
||||
└─ photo-02.jpg ← additional gallery image
|
||||
```
|
||||
|
||||
Photos are stored as `photo-01…NN` in display order — the numbering *is* the order, so reordering in
|
||||
the form renames files on disk, and `photo-01` is always the hero. Names are **zero-padded** so
|
||||
lexical sort matches numeric order (otherwise `photo-1, photo-10, photo-2…`); the pad width grows for
|
||||
100+ photos. `PhotoRenumberer` in `cache-on-save` is the single source of truth for this invariant and
|
||||
is shared with `entry-actions`.
|
||||
|
||||
---
|
||||
|
||||
## Admin panel — drafts and scheduling
|
||||
|
||||
Use the Admin panel at `/admin` for drafts, scheduled posts, or editing existing entries.
|
||||
Use the Admin panel at `/admin` for **scheduling** (`publish_date`) and bulk or structural edits. For ordinary edits — text, photos, location, publish state — the `/post` form is quicker; see [Editing an entry](#editing-an-entry).
|
||||
|
||||
1. Log in at `/admin`
|
||||
2. **Pages → Add Page**
|
||||
@@ -96,7 +114,36 @@ Every entry supports these frontmatter fields:
|
||||
| `location_country` | string | e.g. `Japan` |
|
||||
| `weather_desc` | string | One of the allowed values above |
|
||||
| `weather_temp_c` | number | Celsius, displayed rounded |
|
||||
| `hero_image` | string | Filename to pin as hero (e.g. `temple.jpg`); auto-selects first image if blank |
|
||||
| `transport_mode` | string | `walking` · `bicycle` · `bus` · `train` · `car` · `plane` |
|
||||
| `force_connect` | bool | Force a map connector line to this entry even where it would be suppressed |
|
||||
| `featured` | bool | Opt into the home page highlights grid |
|
||||
|
||||
> **No `hero_image` on journal entries.** The hero is whichever photo sorts first
|
||||
> (`entry-journal.html.twig` uses `entry.media.images|first`), which the owner controls by
|
||||
> reordering photos. **Stories still use `hero_image`** — they are not posted through this form.
|
||||
> See [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md) → R7.
|
||||
|
||||
---
|
||||
|
||||
## Editing an entry
|
||||
|
||||
The `/post` form doubles as the editor — you do not need Admin for ordinary edits.
|
||||
|
||||
1. Open the entry (or find it in the feed) while logged in
|
||||
2. Use the entry's **Edit** action → `/post` opens pre-filled, with the hidden `edit_path` set to that
|
||||
entry's path
|
||||
3. Existing photos load into the grid. You can **add**, **remove**, and **drag to reorder** them
|
||||
4. Submit → `cache-on-save` sets `overwrite_mode: edit`, so the entry is rewritten **in place**
|
||||
rather than creating a new dated folder
|
||||
|
||||
Photo files on disk are renumbered to `photo-1…N` to match the displayed order, so the first photo is
|
||||
always the hero. Reordering is a real file rename, handled server-side by `PhotoRenumberer` in the
|
||||
`entry-actions` plugin via `POST /api/v1/entry/{slug}/photos/order`.
|
||||
|
||||
To **unpublish** an entry, edit it and set **Published** to No under *More options*.
|
||||
|
||||
Deleting an entry is also an entry action (`DELETE /api/v1/entry/{slug}`), owner-only and scoped to
|
||||
the active trip.
|
||||
|
||||
---
|
||||
|
||||
@@ -111,5 +158,13 @@ Every entry supports these frontmatter fields:
|
||||
**Photos not showing in gallery**
|
||||
→ Verify files were uploaded (check the entry folder in Admin → Media). Only jpg, jpeg, png, webp, gif are rendered.
|
||||
|
||||
**Submit button does nothing**
|
||||
→ Check you have at least one photo attached, and that every upload has finished. The form blocks
|
||||
submit while an upload is still in flight, and requires 1–6 photos.
|
||||
|
||||
**500 error after posting**
|
||||
→ Run `make fix-perms` to restore container file ownership.
|
||||
|
||||
**Edits create a new entry instead of updating**
|
||||
→ The hidden `edit_path` was empty, so `overwrite_mode` fell back to `false`. Re-enter via the entry's
|
||||
Edit action rather than opening `/post` directly.
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
# Writing a Story
|
||||
|
||||
A Story is a long-form, hand-crafted piece with an immersive layout — distinct from an Entry, which is a quick dated post from the road (see [`CONCEPTS.md`](../../CONCEPTS.md)). Stories get a Ken Burns hero, scroll-driven sections, galleries and pull quotes.
|
||||
|
||||
The admin editor gives you a **plain markdown textarea** for the body. There is no block picker — the layout vocabulary is a set of shortcodes you type by hand. This guide is that vocabulary; the story edit form also carries a condensed copy of it on its **Blocks** tab, so you don't need this file open while writing.
|
||||
|
||||
Admin lives at **`/admin`** (the plugin slug is `admin2`, but the route is `/admin`). A story's edit URL looks like `/admin/pages/edit/trips/<trip>/stories/<slug>`.
|
||||
|
||||
Stories live at:
|
||||
|
||||
```
|
||||
user/pages/01.trips/<trip>/04.stories/<slug>/story.md
|
||||
```
|
||||
|
||||
`04.stories/stories.md` is a `routable: false` container — its children are aggregated onto the trip page. There is no standalone `/stories` view; don't create one.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the page
|
||||
|
||||
1. Admin → **Pages** → add a page under the trip's **Stories** folder
|
||||
2. Set page template to **story** — this loads [`user/themes/intotheeast/blueprints/story.yaml`](../../user/themes/intotheeast/blueprints/story.yaml). You get the story-specific **Content / Blocks / Location / Publishing** tabs plus the inherited **Options / Advanced / Security** tabs
|
||||
3. Fill in Title and Start Date (both required)
|
||||
|
||||
---
|
||||
|
||||
## 2. Upload the images first
|
||||
|
||||
Upload every image the story needs via the **Images** field at the bottom of the Content tab, before writing the body.
|
||||
|
||||
> There is no separate *Media* tab — the uploader is a field on the Content tab, labelled **Images**. It arrives via inheritance: `story.yaml` declares `'@extends': {type: default, context: blueprints://pages}`, which merges in Grav's default page form. `story.yaml` and `trip.yaml` did not originally extend it, so neither form could upload anything, which is why the demo story images had to be placed on the filesystem. Both now inherit, matching [`entry.yaml`](../../user/themes/intotheeast/blueprints/entry.yaml). `home.yaml` is still standalone, deliberately — the home page has no per-page media.
|
||||
|
||||
Every shortcode refers to images by **bare filename** — the `story-blocks` plugin prefixes the page URL at render time ([`story-blocks.php:22`](../../user/plugins/story-blocks/story-blocks.php)), so you write:
|
||||
|
||||
```
|
||||
image="photo-1.jpg" ✅
|
||||
image="/images/photo-1.jpg" ❌ don't path it
|
||||
```
|
||||
|
||||
The demo stories use a `hero.jpg` / `photo-1.jpg` / `photo-2.jpg` naming convention. Worth copying — it keeps the shortcodes readable.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontmatter fields
|
||||
|
||||
All of these come from the form tabs, so you rarely type them by hand. Listed here because the body shortcodes are *not* the whole story — the hero in particular is frontmatter, not a tag.
|
||||
|
||||
| Field | Tab | Notes |
|
||||
|---|---|---|
|
||||
| `title` | Content | Required |
|
||||
| `date` | Content | Required. Start date |
|
||||
| `end_date` | Content | Optional — leave blank for a single-day story |
|
||||
| `hero_image` | Content | **The hero. Filename only**, from the Images field. Missing → grey placeholder, story still renders |
|
||||
| `hero_alt` | Content | Falls back to the title if empty |
|
||||
| `location_name`, `location_country` | Location | Shown in the hero meta line and the opener |
|
||||
| `lat`, `lng` | Location | Decimal degrees — places the story marker on the trip map |
|
||||
| `transport_mode` | Location | walking / bicycle / bus / train / car |
|
||||
| `force_connect` | Location | Always draw a connector line from the previous marker |
|
||||
| `published` | Options | Grav's standard toggle, from the inherited form |
|
||||
| `featured` | Publishing | Show as a homepage highlight when not travelling |
|
||||
|
||||
The Ken Burns pan on the hero is automatic — no parameter for it.
|
||||
|
||||
If you hand-write frontmatter, use `date: '2026-09-03'`. Admin2 saves its own serialization (`29-07-2026 19:51`); both parse fine.
|
||||
|
||||
---
|
||||
|
||||
## 4. The body: six shortcodes
|
||||
|
||||
Defined in [`user/plugins/story-blocks/shortcodes/`](../../user/plugins/story-blocks/shortcodes/). Plain prose between them renders as a normal reading column — you don't need a shortcode to write paragraphs.
|
||||
|
||||
### Wrapping tags
|
||||
|
||||
**`scrolly-section`** — text panels scroll over a pinned, slowly panning image. The centrepiece block.
|
||||
|
||||
```
|
||||
[scrolly-section image="hero.jpg" alt="Description of the image" caption="Optional caption"]
|
||||
The first panel. Scrolls into view over the image.
|
||||
|
||||
---
|
||||
|
||||
The second panel. A markdown `---` starts a new panel.
|
||||
[/scrolly-section]
|
||||
```
|
||||
|
||||
Panels are split on the `<hr>` that `---` produces ([`story.html.twig:234`](../../user/themes/intotheeast/templates/story.html.twig)). `caption` is optional. Under `prefers-reduced-motion` all panels render active with no pinning.
|
||||
|
||||
**`pull-quote`** — large extracted quote, optionally over a background image.
|
||||
|
||||
```
|
||||
[pull-quote image="photo-1.jpg" alt="Description of the image"]
|
||||
The quote itself. Markdown works in here.
|
||||
[/pull-quote]
|
||||
```
|
||||
|
||||
Drop `image`/`alt` entirely for the plain no-image variant.
|
||||
|
||||
### Self-closing tags
|
||||
|
||||
Note the ` /]` — these take no content.
|
||||
|
||||
**`chapter-break`** — full-width section transition over a background image.
|
||||
|
||||
```
|
||||
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Description" /]
|
||||
```
|
||||
|
||||
`number` is optional; the demo stories use roman numerals.
|
||||
|
||||
**`snap-gallery`** — swipeable multi-image carousel with dots.
|
||||
|
||||
```
|
||||
[snap-gallery images="hero.jpg,photo-1.jpg" captions="First caption,Second caption" alts="First alt,Second alt" /]
|
||||
```
|
||||
|
||||
⚠️ See the comma gotcha below.
|
||||
|
||||
**`full-bleed`** — single image edge-to-edge, as a visual pause.
|
||||
|
||||
```
|
||||
[full-bleed image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" /]
|
||||
```
|
||||
|
||||
**`image-caption`** — photo at a chosen width with caption beneath.
|
||||
|
||||
```
|
||||
[image-caption image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" width="column" /]
|
||||
```
|
||||
|
||||
`width` accepts `column` (default), `full`, `bleed`. Anything else falls back to `column`.
|
||||
|
||||
`full-bleed` and `image-caption` are implemented but not yet used by any story — the demos only exercise the other four.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
### snap-gallery splits on commas — captions cannot contain them
|
||||
|
||||
`images`, `captions` and `alts` are each split on `,` and zipped by index. **A comma inside a caption shifts every caption after it.**
|
||||
|
||||
This is already live in the demo content. `04.stories/04.florence-without-a-map/story.md` has:
|
||||
|
||||
```
|
||||
captions="The Arno at noon — greener than expected, the bridges older than you remember,Via dei Servi: …"
|
||||
```
|
||||
|
||||
Two images, but three comma-separated pieces — so slide 1 gets "The Arno at noon — greener than expected", slide 2 gets " the bridges older than you remember", and the Via dei Servi text is silently dropped.
|
||||
|
||||
Use em dashes or semicolons in gallery captions. There is no escaping mechanism.
|
||||
|
||||
### Self-closing tags need the space before `/]`
|
||||
|
||||
`[chapter-break … /]` — not `[chapter-break …/]` or `[chapter-break …]`.
|
||||
|
||||
### A typo'd shortcode fails silently
|
||||
|
||||
An unrecognised tag name or a malformed parameter list renders as literal text or vanishes — no error, no warning. Preview the page after every block; there is no in-editor validation.
|
||||
|
||||
### `---` outside a scrolly-section is just a horizontal rule
|
||||
|
||||
The panel-splitting behaviour only applies *inside* `[scrolly-section]`.
|
||||
|
||||
---
|
||||
|
||||
## Worked example
|
||||
|
||||
The best reference to copy from is [`user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`](../../user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md) — it combines `scrolly-section`, `chapter-break` and `pull-quote` in one story.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: 'Sorano: Rock and Time'
|
||||
date: '2026-09-03'
|
||||
location_name: Sorano
|
||||
location_country: Italy
|
||||
lat: 42.683
|
||||
lng: 11.715
|
||||
hero_image: hero.jpg
|
||||
hero_alt: Medieval town of Sorano clinging to pale tufa cliffs at dusk
|
||||
published: true
|
||||
---
|
||||
|
||||
Opening prose. Renders as a normal reading column.
|
||||
|
||||
[scrolly-section image="hero.jpg" alt="Sorano seen from the approach road" caption="Sorano — tufa cliff town"]
|
||||
First panel over the pinned image.
|
||||
|
||||
---
|
||||
|
||||
Second panel.
|
||||
[/scrolly-section]
|
||||
|
||||
More prose between blocks.
|
||||
|
||||
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Narrow medieval alley at dusk" /]
|
||||
|
||||
[pull-quote image="photo-1.jpg" alt="Stone alley lit by a single lantern"]
|
||||
A town built on rock, carved from rock, returning slowly to rock.
|
||||
[/pull-quote]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Publish
|
||||
|
||||
1. Set **Published** on the Publishing tab
|
||||
2. Optionally set **Featured highlight** to surface it on the homepage between trips
|
||||
3. Push the content:
|
||||
|
||||
```bash
|
||||
make content-push
|
||||
```
|
||||
|
||||
That commits and pushes `user/` to Gitea, which triggers the production pull. See [`deploy-cycle.md`](deploy-cycle.md).
|
||||
|
||||
---
|
||||
|
||||
## The Blocks tab
|
||||
|
||||
The story edit form has a **Blocks** tab holding a paste-ready example of each shortcode plus the comma warning. It's built from `type: spacer` fields in `story.yaml`, whose `text` admin2 renders as HTML (confirmed against admin2 2.0.12 — see its CHANGELOG entry for issue #91).
|
||||
|
||||
If you edit those fields, note two things: the `text` values are YAML double-quoted scalars, so HTML attribute quotes must be escaped (`\"`) — and `<h4>` is flattened by admin2's CSS reset, which is why the headings use `<strong>` in a styled `<p>` instead. Long `<pre>` content needs `white-space:pre-wrap`, or it overflows underneath the Page Info sidebar.
|
||||
|
||||
---
|
||||
|
||||
## Why it's a raw textarea
|
||||
|
||||
The admin2 story editor is a plain markdown field by design-so-far, not by limitation. Background and the options considered: [`docs/research/story-editing.md`](../research/story-editing.md). Note that its conclusion that admin2 cannot host a custom editor is **out of date** — admin-next ships a plugin field-component surface (`admin-next/fields/{type}.js` + `onApiBlueprintResolved`) that the installed api 1.0.9 / admin2 2.0.12 support. Nobody has built it yet.
|
||||
@@ -14,7 +14,8 @@ How the intotheeast site hangs together.
|
||||
| Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time |
|
||||
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
|
||||
| Dev URL | http://localhost:8081 | Mapped from container port 80 |
|
||||
| Maps | MapLibre GL JS | Replaced Leaflet; one shared map path (`MapUtils.initEntryMap`) on trip + home |
|
||||
| Maps | MapLibre GL JS | Replaced Leaflet. One shared *display* path (`MapUtils.initEntryMap`) on trip + home, plus one sanctioned *editor* (`js/src/location-map.js`) for the `/post` pin picker |
|
||||
| Basemap | CartoDB dark-matter | Style URL single-sourced as `MAP_STYLE` in `js/src/map-style.js`, imported by both map paths so they cannot drift |
|
||||
| GPX rendering | toGeoJSON (bundled in `js/map.js`) | Parses GPX → GeoJSON route layers client-side; no CDN |
|
||||
|
||||
---
|
||||
@@ -54,7 +55,7 @@ Other notable plugins:
|
||||
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
|
||||
| `admin2` | Admin panel at /admin |
|
||||
| `story-blocks` (custom) | Storytelling shortcode blocks for long-form stories (needs `shortcode-core`) |
|
||||
| `entry-actions` (custom) | Owner-only, active-trip-scoped journal entry actions (delete) via the Grav API |
|
||||
| `entry-actions` (custom) | Owner-only, active-trip-scoped actions via the Grav API. Three routes: `DELETE /entry/{slug}`, `POST /entry/{slug}/photos/order`, `POST /trip/{slug}/publish`. Exists because stock `DELETE /api/v1/pages<route>` checks only write-permission (no trip scoping) and cannot renumber media to the `photo-NN` cover order |
|
||||
|
||||
### Plugin management model
|
||||
|
||||
@@ -76,10 +77,15 @@ Three categories, by how each plugin is installed and maintained:
|
||||
| `js/src/map.js` | `js/map.js` + `css-compiled/map.css` — bundles `maplibre-gl`, `@mapbox/togeojson`, and `js/maplibre-utils.js` |
|
||||
| `js/src/feed-actions.js` | `js/feed-actions.js` |
|
||||
| `js/src/trip-publish.js` | `js/trip-publish.js` |
|
||||
| `js/src/post-form.js` | `js/post/` (ESM + code splitting) + `css-compiled/post-form.css` |
|
||||
| `js/src/post-form.js` | `js/post/` (ESM + code splitting) + `css-compiled/post-form.css` — also pulls in `location-map.js` and `map-style.js` |
|
||||
| `node_modules/maplibre-gl/dist/maplibre-gl.css` | `css-compiled/maplibre-gl.css` — built standalone so `location-map.js` can inject it on demand without a static import defeating its lazy load |
|
||||
| `scripts/gen-weather-icons.js` | `templates/partials/weather-icons.html.twig` (Lucide SVGs inlined into a Twig map) |
|
||||
|
||||
**The trap:** `js/` holds both bundles *and* hand-authored sources. `js/maplibre-utils.js` (the `MapUtils` map engine, a plain IIFE imported by `js/src/map.js`) and `js/nav.js` are sources despite sitting beside the minified bundles. `css/style.css` and `css/tokens.css` are hand-authored too — only `css-compiled/` is generated.
|
||||
The table lists esbuild **entry points**. Other files in `js/src/` (`api-utils.js`, `location-map.js`, `map-style.js`, `post-form.css`) are sources too — they are imported into a bundle rather than being built directly.
|
||||
|
||||
**The trap:** `js/` holds both bundles *and* hand-authored sources. `js/maplibre-utils.js` (the `MapUtils` map engine, a plain IIFE imported by `js/src/map.js`) and `js/nav.js` are sources despite sitting beside the minified bundles.
|
||||
|
||||
**The second trap:** `css/` is **not** the source of `css-compiled/`. `css/style.css` and `css/tokens.css` are hand-authored and served *directly* via `assets.addCss('theme://css/…')` in `partials/base.html.twig` — they are never compiled. `css-compiled/` is esbuild output from the CSS imports inside `js/src/*.js` (fontsource + PhotoSwipe → `main.css`; maplibre → `map.css`) plus the standalone maplibre build above.
|
||||
|
||||
---
|
||||
|
||||
@@ -89,18 +95,24 @@ All page templates extend `base.html.twig`:
|
||||
|
||||
```
|
||||
templates/
|
||||
├─ base.html.twig ← site shell: nav, fonts, CSS tokens
|
||||
├─ default.html.twig ← extends base; generic page
|
||||
├─ home.html.twig ← extends base; context-aware two-column layout
|
||||
├─ trips.html.twig ← extends base; trip list (with the owner's publish toggle)
|
||||
├─ trip.html.twig ← extends base; trip page with filter bar (All/Journal/Stories)
|
||||
├─ entry.html.twig ← extends base; single journal entry (gallery, badges, map)
|
||||
├─ story.html.twig ← extends base; single story (Ken Burns hero, shortcodes)
|
||||
└─ gpx-manager.html.twig ← extends base; admin UI for GPX file management
|
||||
├─ post-form.html.twig ← extends base; the /post journal form
|
||||
├─ gpx-manager.html.twig ← extends base; admin UI for GPX file management
|
||||
├─ forms/ ← field overrides (e.g. forms/fields/datetime/datetime.html.twig)
|
||||
├─ macros/ ← cover, cycling, date-range, stats
|
||||
└─ partials/ ← base.html.twig lives HERE, not at templates/ root
|
||||
```
|
||||
|
||||
**`base.html.twig` is a partial** (`templates/partials/base.html.twig`), despite being the shell every page template extends.
|
||||
|
||||
The standalone `dailies.html.twig`, `map.html.twig`, `stats.html.twig` and `stories.html.twig` view templates were **removed** in the 2026-07-04 standalone-page cleanup — the trip page (`trip.html.twig`) consolidated the feed, inline map, and inline stats.
|
||||
|
||||
Site nav (in `base.html.twig`) is deliberately minimal — **Home + Past Trips only**. It does not link to trip sub-sections, because those standalone views no longer exist.
|
||||
Site nav (in `partials/base.html.twig`) is deliberately minimal — **Home + Trips**, plus **New Post** when `grav.user.authenticated`. It does not link to trip sub-sections, because those standalone views no longer exist.
|
||||
|
||||
Partials live in `templates/partials/` (plus macros in `templates/macros/`). Key partials: `base.html.twig` (site shell extended by all page templates), `entry-map.html.twig` (shared map column + `initEntryMap` call, used by trip + home), `trip-feed-col.html.twig` (feed column chrome, shared by trip + home), `home-predeparture.html.twig`, `entry-journal.html.twig` / `entry-story.html.twig` (feed cards), `trip-publish-toggle.html.twig`, and `weather-icons.html.twig`.
|
||||
|
||||
@@ -147,7 +159,20 @@ The column **beside** the map: date-range header, filter bar, stats/cycling pane
|
||||
|
||||
**Stats/cycling JS glue:** the partial emits an inline `DOMContentLoaded` script calling `window.initTripStats({ gpxUrls, gpsPoints, hasGpx })` — one shared function in `js/src/main.js`. It no-ops when `#stat-distance` is absent, populates exact distance + cycling stats from GPX, and falls back to a `~`-prefixed haversine estimate (or `—` for `<2` points) when there is no GPX. It depends on `window.MapUtils` from `map.js` (loaded in the `bottom` asset group on both pages).
|
||||
|
||||
> History: the map setup replaced an older three-variant arrangement (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`), deleted in the 2026-07-04 standalone-page cleanup.
|
||||
> History: the map setup replaced an older three-variant arrangement (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`), deleted in the 2026-07-04 standalone-page cleanup. See [`superseded-decisions.md`](superseded-decisions.md) → R12.
|
||||
|
||||
#### The one non-`entry-map` map: the `/post` pin editor
|
||||
|
||||
`js/src/location-map.js` (`getOrCreateLocationMap()`) is a deliberately separate, minimal engine for the post form's "More location details" panel — **an editor, not a display map**, so it shares none of `initEntryMap`'s concerns:
|
||||
|
||||
| | `initEntryMap` (display) | `location-map.js` (editor) |
|
||||
|---|---|---|
|
||||
| Markers | many, from entries | exactly one, **draggable** |
|
||||
| Popups / GPX / bounds-fitting | yes | none |
|
||||
| `maplibre-gl` | bundled into `js/map.js` | **lazy-imported** on first open, so a GPS-only submit never fetches it |
|
||||
| Stylesheet | via `js/src/map.js`'s CSS import | injects `css-compiled/maplibre-gl.css` on demand (a static import would defeat the lazy load) |
|
||||
|
||||
The two share exactly one thing: `MAP_STYLE` from `js/src/map-style.js`. Adding a *third* map path is forbidden — see `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
# Design System — Light Mode Color Palette
|
||||
|
||||
Light-mode counterpart to `design-system.md`. Only color tokens differ between themes — typography, spacing, radius, shadows, and layout are identical.
|
||||
> **Superseded — light mode is not implemented, and this palette is not in the code.**
|
||||
>
|
||||
> The site is **dark only**. `css/tokens.css` has a single `:root` block; there is no
|
||||
> `prefers-color-scheme` query, no `data-theme` switch, and none of the light hex values below appear
|
||||
> anywhere in `css/`. Dark mode shipped as *the* theme rather than as one of two
|
||||
> (`../working/plans/2026-06-19-dark-mode.md`, 2026-06-20).
|
||||
>
|
||||
> Keep this file as the record of the pre-dark-mode palette and as the starting point if a light
|
||||
> theme is ever built — but do not read the "Light" column as describing the running site. See
|
||||
> [`superseded-decisions.md`](superseded-decisions.md) → R9.
|
||||
|
||||
Light-mode counterpart to `design-system.md`, as originally specified. Only color tokens were intended to differ between themes — typography, spacing, radius, shadows, and layout are identical.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
|
||||
### Palette (dark theme — as implemented)
|
||||
|
||||
**Dark is the only theme.** `css/tokens.css` has a single `:root` block; there is no
|
||||
`prefers-color-scheme` query and no `data-theme` switch. `design-system-light.md` records the
|
||||
pre-dark-mode palette, which was never implemented as a switchable theme — see
|
||||
[`superseded-decisions.md`](superseded-decisions.md) → R9.
|
||||
|
||||
The authoritative list is `user/themes/intotheeast/css/tokens.css`.
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `--color-paper` | `#1A1814` | Page background — warm near-black |
|
||||
@@ -46,6 +53,20 @@
|
||||
| `--color-accent-on` | `#FFFFFF` | Text on accent surfaces |
|
||||
| `--color-surface-raised` | `#2A2720` | Elevated surfaces: tooltips, hover |
|
||||
| `--color-ink-inverse` | `#17171A` | Text on accent-coloured buttons |
|
||||
| `--color-error` | `#c0392b` | Validation errors, form error status |
|
||||
| `--color-draft-accent` | `#E0A458` | Warm amber — draft/unpublished badges |
|
||||
|
||||
#### Glass overlays
|
||||
|
||||
Paper colour at opacity, used by the story components. Computed with `color-mix()` rather than fixed
|
||||
hex, so they track `--color-paper` automatically.
|
||||
|
||||
| Token | Value | Usage |
|
||||
|---|---|---|
|
||||
| `--color-paper-glass-low` | `color-mix(in srgb, var(--color-paper) 8%, transparent)` | Faintest scrim |
|
||||
| `--color-paper-glass-mid` | `color-mix(in srgb, var(--color-paper) 25%, transparent)` | Standard overlay |
|
||||
| `--color-paper-glass-high` | `color-mix(in srgb, var(--color-paper) 55%, transparent)` | Heavy scrim over imagery |
|
||||
| `--color-paper-glass-hover` | `color-mix(in srgb, var(--color-paper) 80%, transparent)` | Hover state on a glass surface |
|
||||
|
||||
### Rationale for accent color
|
||||
|
||||
@@ -150,7 +171,7 @@ DM Serif Display has a calligraphic quality — slightly editorial, authoritativ
|
||||
- Nav links: DM Sans, `--text-sm`, weight 500, `--color-ink-2`
|
||||
- Active nav link: `--color-accent`, weight 600
|
||||
- Mobile: same layout, title slightly smaller, nav links compact
|
||||
- Background: `--color-canvas` (white), bottom border `1px solid var(--color-border)`
|
||||
- Background: `--color-canvas` (`#22201B` in the dark theme), bottom border `1px solid var(--color-border)`
|
||||
|
||||
### 5.2 Entry Feed Card — With Photo
|
||||
|
||||
@@ -342,7 +363,7 @@ Minimal changes — the map itself is good. Style improvements:
|
||||
| JS | Vanilla JS — unchanged | Current JS is well-structured, scope doesn't justify a framework |
|
||||
| Icons | Unicode + emoji (current) | No dependency, works everywhere |
|
||||
| Fonts | Google Fonts via CDN | Two fonts, display-swap, negligible impact |
|
||||
| Maps | MapLibre GL JS | Replaced Leaflet; all 3 map templates use it |
|
||||
| Maps | MapLibre GL JS | Replaced Leaflet. One shared display-map partial (`partials/entry-map.html.twig`), not three templates — see [`superseded-decisions.md`](superseded-decisions.md) → R12 |
|
||||
| Build | None — no build pipeline | Grav's asset pipeline handles minification if needed |
|
||||
|
||||
**No Alpine.js, no TypeScript, no Tailwind.** The site has clean vanilla JS and CSS today; a redesign is about visual quality, not framework migration. Introducing a build pipeline on a 3-week timeline is a distraction.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Superseded decisions
|
||||
|
||||
Things this project planned, built, and then deliberately reversed. One row per reversal.
|
||||
|
||||
**Why this file exists.** The plans and milestones under `docs/working/` are historical records — they
|
||||
say what was decided *then*, and they are correct as records. But a reader who opens
|
||||
`milestones/milestone-2.md` finds a confident present-tense description of a Leaflet `/map` page that
|
||||
has not existed since 2026-07-04. This file is the changelog of "what did we change our mind about",
|
||||
so that question has one answer instead of requiring a re-derivation from the code.
|
||||
|
||||
**How to use it.** Each superseded section in the old docs carries a `> **Superseded …**` note
|
||||
pointing back here. If you are about to re-create something you found in an old plan, check here
|
||||
first — the reversal is usually deliberate, and several are load-bearing rules in
|
||||
[`CLAUDE.md`](../../CLAUDE.md).
|
||||
|
||||
**Keep it current.** When a decision is reversed, add a row *in the same commit as the reversal*. A
|
||||
ledger that lags is worse than no ledger, because it is trusted.
|
||||
|
||||
---
|
||||
|
||||
## The reversals
|
||||
|
||||
| # | Originally planned | Planned in | True now | Changed | Why |
|
||||
|---|---|---|---|---|---|
|
||||
| R1 | A standalone `/map` page — full-height Leaflet map, marker per entry, popups | `milestones/milestone-2.md` (whole doc); `summary.md` | No `/map` route. The map renders **inline on the trip page** through the single shared `partials/entry-map.html.twig` | 2026-07-04 | `plans/2026-07-04-standalone-page-cleanup.md`. One consolidated trip page beat four thin views; a separate map page meant a second map implementation to keep in sync |
|
||||
| R2 | A standalone `/stats` page — days on the road, entries, countries, distance | `milestones/milestone-3.md` (whole doc); `summary.md` | No `/stats` route. Stats render **inline on the trip page** behind a toggle, via `initTripStats()` | 2026-07-04 | Same cleanup. The numbers are trip context, not a destination |
|
||||
| R3 | A `/tracker` feed route as the entry list | `milestones/milestone-1.md` §1.6; `milestone-2.md`; `milestone-3.md`; `summary.md` | No `/tracker`. The feed is the **trip page** plus the home active-trip view, sharing `partials/trip-feed-col.html.twig` | Restructured 2026-06-19 (`plans/2026-06-19-trip-entity.md`), fully retired 2026-07-04 | The Trip entity became the organising unit, so a global tracker had nothing to track |
|
||||
| R4 | Leaflet.js with OpenStreetMap tiles | `milestone-2.md`; `milestone-4.md`; `summary.md`; `pm-analysis.md` | **MapLibre GL JS**, CartoDB dark-matter basemap. Style URL is single-sourced as `MAP_STYLE` in `js/src/map-style.js` | 2026-06-20 | `plans/2026-06-19-maplibre-migration.md`. Vector tiles, GPU rendering, and a dark basemap that suits the dark theme |
|
||||
| R5 | A standalone `/dailies` journal view and `/stories` story view | `plans/2026-06-19-trip-entity.md` era | Both routes retired. `01.dailies/` and `04.stories/` survive as `routable:false` **data containers** whose children the trip page aggregates | 2026-07-04 | Same cleanup. **The folders are load-bearing** — retiring a view must never delete its container (see [`CONCEPTS.md`](../../CONCEPTS.md) → Container) |
|
||||
| R6 | Site nav "Journal · Map · Stats" | `summary.md` | **Home · Trips**, plus **New Post** when authenticated (`partials/base.html.twig:27-31`) | Sub-views retired 2026-07-04; "Past Trips" renamed "Trips" 2026-07 (`6cf5092`) | Nav should not link to views that no longer exist |
|
||||
| R7 | `hero_image` frontmatter on entries, to pin a feed-card hero | `milestone-1.md` §1.6; `summary.md`; `pm-analysis.md` | **No `hero_image` field on journal entries.** The hero is the first uploaded photo (`entry-journal.html.twig` uses `entry.media.images\|first`). Photo order is owner-controlled, so an explicit filename was redundant. **Stories still use `hero_image`** | 2026-07 | `plans/2026-07-05-photo-editor-media-api.md` gave the owner drag-reorder over photos, which made "first photo" a deliberate choice rather than an accident |
|
||||
| R8 | Photos optional on an entry | `milestone-1.md` §1.5; `guides/posting.md` (pre-2026-07-25) | Photos are **required — minimum 1, maximum 6** (`post-form.md:35-46`, enforced in `post-form.js` `initValidation`) | 2026-07 | `plans/2026-07-04-journal-post-form.md`. Photos come first in the form because they anchor what you write |
|
||||
| R9 | A light-mode colour palette alongside dark | `reference/design-system-light.md` (whole doc); `plans/2026-06-19-dark-mode.md` | **Dark only.** `css/tokens.css` has a single `:root` block; there is no `prefers-color-scheme` or `data-theme` switch, and no light-palette hex appears in `css/` | 2026-06-20 | Dark mode shipped as *the* theme, not as one of two. The light palette was the pre-dark-mode original and was never re-implemented as a switchable theme |
|
||||
| R10 | `shortcode-gallery-plusplus` as the entry photo gallery | `pm-analysis.md` | Galleries are **PhotoSwipe**, wired in `js/src/main.js` against `.pswp-gallery` markup emitted by `partials/entry-journal.html.twig`. No `[gallery]` shortcode is used anywhere in `templates/` or `pages/` | 2026-06-21 (`30c8937`, "replace custom lightbox with PhotoSwipe v5") | A lightbox the theme controls beat a plugin's markup. ⚠️ The plugin is **still listed in `plugins.txt`** with no consumer — see recommendations |
|
||||
| R11 | `travel-memories` as an in-repo service on :8082, built from `./services/travel-memories` | `plans/2026-06-21-travel-memories.md`; `specs/2026-06-21-travel-memories-design.md`; `working/2026-06-21-travel-memories-handover.md` | **Extracted to a separate project.** `services/` is gitignored and the source is absent from this repo | `a80b0a9` — "remove travel-memories service from repo (moved to separate project)" | It was an independent Flask app with its own lifecycle. ⚠️ `docker-compose.yml` **still declares the service**, so `make start` fails on a clean checkout — see recommendations |
|
||||
| R12 | Three map template variants (`feed-map.html.twig` partial with inline init, plus full-page `map.html.twig`) | pre-2026-06-27 templates | **One display map path** — `MapUtils.initEntryMap()` in `js/maplibre-utils.js`, invoked through `partials/entry-map.html.twig` | Consolidated 2026-06-27, variants deleted 2026-07-04 | `plans/2026-06-27-map-init-consolidation.md`. Three implementations drifted apart |
|
||||
| R13 | A single map code path, no exceptions | `CLAUDE.md` (pre-2026-07-25 wording) | One **display** path (R12) **plus one sanctioned editor** — `js/src/location-map.js` for the `/post` pin picker: one draggable marker, no popups/GPX/bounds, `maplibre-gl` lazy-imported. Shares only `MAP_STYLE` with the display path | 2026-07-24 (`dd19995`) | `plans/2026-07-23-post-form-location-override.md`. An editor map has none of a display map's concerns; folding them together would have compromised both |
|
||||
| R14 | `post-form.md` carries a static `pageconfig.parent` naming the write target | pre-2026-07 form config | **No `parent` in `post-form.md`.** `cache-on-save` derives it from `site.active_trip` at submit time | 2026-07 | The two settings silently desynced. **Never re-add it** — this is a hard rule in [`CLAUDE.md`](../../CLAUDE.md) |
|
||||
|
||||
---
|
||||
|
||||
## Decisions that were *not* reversed
|
||||
|
||||
Worth stating, because their planning docs are old enough to look suspect:
|
||||
|
||||
- **The SKIP list in [`pm-analysis.md`](../working/pm-analysis.md) still holds.** Background GPS
|
||||
tracking, followers, comments, social discovery, reactions, trip reels, 3D flyover, printed books,
|
||||
and AI itinerary building were all deliberately rejected for a solo flat-file blog. That reasoning
|
||||
has not changed — only some of the *BUILD* items' delivery mechanisms did (R1, R2, R4, R7, R10).
|
||||
- **Weather via Open-Meteo**, no API key, with the eight allowed `weather_desc` values — still exactly
|
||||
as planned in `milestone-1.md` §1.2, and still matching the blueprint and the post form.
|
||||
- **Location badge** (`📍 City, Country`) on cards and entry pages — as planned.
|
||||
- **Distance/stats computation from frontmatter and GPX** — the numbers survived; only their
|
||||
*location* moved from a `/stats` page to the trip page (R2).
|
||||
@@ -0,0 +1,149 @@
|
||||
# Recommendations from the 2026-07-25 documentation reconciliation
|
||||
|
||||
**Status:** 📋 Proposed — nothing here has been acted on. Decide per item.
|
||||
|
||||
The reconciliation pass (see [`specs/2026-07-25-docs-reconciliation-design.md`](specs/2026-07-25-docs-reconciliation-design.md))
|
||||
corrected the documentation. It also surfaced problems that are **not** documentation problems, plus
|
||||
process changes that would stop this drift recurring. Those are collected here rather than mixed into
|
||||
a docs diff.
|
||||
|
||||
Ordered by what I would do first.
|
||||
|
||||
---
|
||||
|
||||
## P1 — `make start` and `make setup` are broken on any clean checkout
|
||||
|
||||
**What.** `docker-compose.yml` still declares a `travel-memories` service with
|
||||
`build: ./services/travel-memories`. That source was removed in `a80b0a9` ("moved to separate
|
||||
project") and `services/` is gitignored, so the build context does not exist. `make start` is
|
||||
`docker compose up -d` (all services), and `make setup` calls it.
|
||||
|
||||
**Proof.**
|
||||
```
|
||||
$ docker compose build travel-memories
|
||||
unable to prepare context: path ".../services/travel-memories" not found
|
||||
```
|
||||
|
||||
**Why it has stayed hidden.** A machine that built the image before `a80b0a9` still has
|
||||
`travel-blog-intotheeast-travel-memories:latest` cached, so `docker compose up -d` reuses it and never
|
||||
rebuilds. It breaks for a fresh clone, for every new worktree (different `COMPOSE_PROJECT_NAME` →
|
||||
different image name → forced rebuild), and on the main checkout after any `docker image prune`. This
|
||||
is why `make worktree-new` calls `start-grav`, not `start`.
|
||||
|
||||
**Options.**
|
||||
1. **Delete the service from `docker-compose.yml`** (recommended). It lives in another project now. If
|
||||
that project needs to run alongside Grav, it can carry its own compose file.
|
||||
2. Move it into a compose profile (`profiles: [tools]`) so `docker compose up -d` skips it by default.
|
||||
3. Keep it and point `build` at the new location — only if you actually want the two coupled again.
|
||||
|
||||
Until this is decided, `CLAUDE.md` and `README.md` now warn to use `make start-grav`. That is a
|
||||
signpost around a bug, not a fix.
|
||||
|
||||
---
|
||||
|
||||
## P2 — A repeatable drift check
|
||||
|
||||
Deliberately out of scope for the one-time pass; this is the item that stops the whole problem
|
||||
recurring. Every defect found was mechanically checkable — a route, a path, a token name, a make
|
||||
target, a field rule.
|
||||
|
||||
**Proposal.** A `make docs-check` target that fails loudly when the present-tense docs assert
|
||||
something the code contradicts:
|
||||
|
||||
- Grep `CLAUDE.md`, `docs/reference/`, `docs/guides/`, `README.md`, `CONCEPTS.md` for references to
|
||||
retired routes (`/map`, `/stats`, `/tracker`, `/dailies`, `/stories`) and dead tech (`Leaflet`).
|
||||
These are already forbidden by `CLAUDE.md`, so any hit is a defect.
|
||||
- Assert every `templates/*.html.twig` and `templates/partials/*.html.twig` named in
|
||||
`architecture.md` exists, and flag templates that exist but are undocumented. Both directions of
|
||||
drift were present this pass.
|
||||
- Diff the `--color-*` token names in `design-system.md` against `css/tokens.css`. Six were missing.
|
||||
- Assert every `make <target>` mentioned in `README.md` is a real target, **and** that no bare
|
||||
`remote-*` target is documented without an env suffix. This alone would have caught P4.
|
||||
- Assert file paths cited in `CLAUDE.md` exist. A prior pass shipped a path to
|
||||
`js/src/maplibre-utils.js`, which never existed.
|
||||
|
||||
Deliberately **excluded**: `docs/working/`. Those documents are records and are supposed to drift;
|
||||
scanning them would produce permanent noise.
|
||||
|
||||
Sequence this after P1 — otherwise the first thing the check reports is P1.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Plan status can silently lag a merge
|
||||
|
||||
`plans/2026-07-23-post-form-location-override.md` read `📋 Not started` while the feature was merged
|
||||
in `user/` as `dd19995`. Nothing connects a plan's status line to the commit that lands it, so the
|
||||
convention depends entirely on remembering.
|
||||
|
||||
**Options.**
|
||||
1. **Add the plan path to the feature's commit or PR body**, so `git log --grep` can find plans whose
|
||||
work landed but whose status never moved. Cheapest, no tooling.
|
||||
2. Extend the P2 check: for each plan not `✅ Complete`/`❌ Abandoned`, look for a merged branch whose
|
||||
name matches the plan slug and warn. Catches it automatically; some false positives.
|
||||
3. Accept it and rely on the convention. Reasonable — this was one miss across 41 plans.
|
||||
|
||||
---
|
||||
|
||||
## P4 — `README.md` was designated authoritative for a list it did not hold
|
||||
|
||||
`CLAUDE.md`'s entry-point table sends readers to `README.md` for "the full `make` command list".
|
||||
Before this pass, README documented 7 of ~20 `remote-*` targets, and documented all of them **without
|
||||
the `-test`/`-prod` suffix that `guard-env` requires** — so its server runbook was not executable.
|
||||
|
||||
Corrected now, but the structural point stands: **a doc promoted to "the authoritative list of X"
|
||||
acquires a completeness obligation it did not have as prose.** The `Makefile` is the real source of
|
||||
truth. Consider either generating the command tables from `Makefile` comments, or softening the
|
||||
CLAUDE.md pointer to "common commands" and letting `make help` be authoritative.
|
||||
|
||||
Related: `docs/guides/deploy-cycle.md` had the env-suffix rule right the whole time. The defect was
|
||||
README duplicating the same knowledge and drifting. Fewer copies would have prevented it.
|
||||
|
||||
---
|
||||
|
||||
## P5 — `shortcode-gallery-plusplus` is installed with no consumer
|
||||
|
||||
`plugins.txt` lists it, but there is no `[gallery]` shortcode anywhere in `templates/` or `pages/`.
|
||||
Entry galleries are PhotoSwipe, wired in `js/src/main.js` against `.pswp-gallery` markup from
|
||||
`partials/entry-journal.html.twig`.
|
||||
|
||||
**Careful before removing it.** `plugins.txt` does **not** list `shortcode-core`, which is present as
|
||||
a GPM dependency — and `story-blocks` needs `shortcode-core`. Dropping
|
||||
`shortcode-gallery-plusplus` could take `shortcode-core` with it and break stories.
|
||||
|
||||
**Recommendation.** Add `shortcode-core` to `plugins.txt` as an explicit, first-class dependency
|
||||
*first*, then remove `shortcode-gallery-plusplus` and verify a story page still renders. Do not do
|
||||
these in one step.
|
||||
|
||||
---
|
||||
|
||||
## P6 — Demo fixtures still contain retired views
|
||||
|
||||
`user/docs/demo/trips/italy-2025/` ships `map.md`, `stats.md` and `stories.md` — pages for views
|
||||
retired on 2026-07-04. The newer `italy-2026-demo` fixture has no `map.md`/`stats.md`, so the fixtures
|
||||
disagree with each other.
|
||||
|
||||
Low impact (demo trips are gitignored in the pages tree and loaded on demand), but `make demo-load`
|
||||
copies them in, so a demo trip can materialise pages for views that no longer exist. Delete
|
||||
`map.md` and `stats.md` from `italy-2025`; keep `stories.md` only if the container is still needed.
|
||||
|
||||
This is a `user/` submodule change, which is why it was left out of this pass.
|
||||
|
||||
---
|
||||
|
||||
## P7 — Structural notes worth a decision
|
||||
|
||||
**`design-system-light.md` is a record, not a reference.** It documents an unimplemented palette and
|
||||
now carries a banner saying so, but it still sits in `reference/` — the "stable facts" tier. Moving it
|
||||
to `docs/working/` would make its status structural rather than dependent on a reader seeing the
|
||||
banner. Counter-argument: it is the natural starting point if a light theme is ever built, and
|
||||
`reference/` is where someone would look. Either is defensible; the banner makes it safe for now.
|
||||
|
||||
**`milestone2-template-refactor-brief.md` sits loose in `docs/working/`** while the milestone docs live
|
||||
in `working/milestones/`. Cosmetic, but it is the kind of thing that makes a folder stop being
|
||||
self-explanatory.
|
||||
|
||||
**The `summary.md` lesson generalises.** The single most misleading line in the tree was
|
||||
`working/README.md` advertising `summary.md` as "current state". A stale document is survivable; an
|
||||
*index* that points at a stale document as authoritative is not, because it defeats the reader's
|
||||
judgement. Worth remembering the next time an index gets written: **describing a document's role is
|
||||
itself a factual claim that can rot.**
|
||||
+12
-1
@@ -4,6 +4,17 @@ Everything here is a live working document: specs being built from, plans being
|
||||
|
||||
Stable facts belong in [`../reference/`](../reference/); how-to procedures in [`../guides/`](../guides/); write-ups of bugs already solved in [`../solutions/`](../solutions/).
|
||||
|
||||
> ⚠️ **Everything here is written in the past tense, even when it reads present-tense.** A completed
|
||||
> plan describes the code *as it was when the plan landed* — that is what makes it a useful record,
|
||||
> and it is not a defect when it no longer matches. Several documents here describe features that were
|
||||
> later deliberately reversed: there is no `/map` page, no `/stats` page, no `/tracker`, no Leaflet, no
|
||||
> light theme, and no `hero_image` on entries.
|
||||
>
|
||||
> **Before re-creating anything you find in this folder, check
|
||||
> [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md).** Superseded sections
|
||||
> also carry an inline `> **Superseded …**` note pointing there. For the site as it is, read
|
||||
> [`../reference/architecture.md`](../reference/architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## What's in here
|
||||
@@ -18,7 +29,7 @@ Stable facts belong in [`../reference/`](../reference/); how-to procedures in [`
|
||||
| `learnings/` | Retrospective notes worth keeping but not yet promoted to `../solutions/` |
|
||||
| `backlog.md` | Unscheduled ideas and wishes |
|
||||
| `bugs-and-fixes.md` | Running log of bugs found and what fixed them |
|
||||
| `summary.md` | Project summary / current state |
|
||||
| `summary.md` | **Historical** wrap-up of the original four-milestone branch (2026-06-21). *Not* the current state — for that read [`../reference/architecture.md`](../reference/architecture.md) |
|
||||
| `pm-analysis.md`, `git-sync-notes.md`, dated one-offs | Standalone notes, kept for reference |
|
||||
|
||||
---
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
**Goal:** Every entry is richer out of the box — location name shown, weather auto-captured, photos in a proper gallery, hero image visible on the feed.
|
||||
|
||||
> **Historical — written 2026-06-21. Mostly shipped as specified; three details reversed.**
|
||||
>
|
||||
> Still true: the location badge, Open-Meteo weather auto-fetch with its eight `weather_desc` values,
|
||||
> and the entry photo gallery. Reversed since:
|
||||
> - **§1.5 gallery** is PhotoSwipe, not `shortcode-gallery-plusplus` (R10).
|
||||
> - **§1.6 `hero_image`** no longer exists on entries — the hero is the first uploaded photo, and the
|
||||
> owner controls photo order by drag-reorder (R7). Stories still use `hero_image`.
|
||||
> - **Photos are now required** (1–6 per entry), not optional (R8).
|
||||
> - **"Tracker feed"** is the trip page and the home active-trip view; there is no `/tracker` (R3).
|
||||
>
|
||||
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
**Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, connected by a chronological route line, with popups linking to entries.
|
||||
|
||||
> **Superseded — written 2026-06-21. Neither the `/map` page nor Leaflet exists.**
|
||||
>
|
||||
> - **No `/map` route.** The map renders inline on the trip page via the single shared partial
|
||||
> `templates/partials/entry-map.html.twig` (R1, retired 2026-07-04). `CLAUDE.md` forbids
|
||||
> re-creating it or linking to it.
|
||||
> - **Leaflet + OpenStreetMap tiles → MapLibre GL JS** with a CartoDB dark-matter basemap (R4,
|
||||
> 2026-06-20).
|
||||
> - **§2.6 nav link** is gone with the page (R6).
|
||||
>
|
||||
> The *substance* of this spec survived — markers per entry, chronological route line, popups linking
|
||||
> to entries, bounds fitting, mobile touch handling — it all lives in `MapUtils.initEntryMap()` in
|
||||
> `user/themes/intotheeast/js/maplibre-utils.js`. Only the page and the library changed.
|
||||
>
|
||||
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
**Goal:** A `/stats` page showing key trip numbers: days on the road, entries posted, countries visited, and approximate distance traveled.
|
||||
|
||||
> **Superseded — written 2026-06-21. There is no `/stats` page.**
|
||||
>
|
||||
> The stats themselves shipped and still work — days on the road, entries posted, countries visited,
|
||||
> distance (exact from GPX, or a `~`-prefixed haversine estimate without it). They render **inline on
|
||||
> the trip page** behind a toggle, computed by `window.initTripStats()` in `js/src/main.js`
|
||||
> (R2, retired 2026-07-04). `CLAUDE.md` forbids re-creating the standalone view.
|
||||
>
|
||||
> Also reversed: **§3.7 nav link** (R6), and the `/tracker` references (R3).
|
||||
>
|
||||
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
**Goal:** Embed a compact interactive map above the entry feed on the tracker page, showing recent entry positions and the current location, giving readers immediate spatial context.
|
||||
|
||||
> **Superseded — written 2026-06-21. The idea won; this implementation did not.**
|
||||
>
|
||||
> A map beside the feed is exactly what the site does now — but not as a separate "mini-map":
|
||||
> - **No `/tracker` page** to embed it above (R3). The map sits in a column on the trip page and the
|
||||
> home active-trip view.
|
||||
> - **No second map implementation.** This spec's `feed-map` variant with its own inline init was
|
||||
> deleted; everything goes through the one shared `partials/entry-map.html.twig` +
|
||||
> `MapUtils.initEntryMap()` path (R12, consolidated 2026-06-27). Adding a second display map is
|
||||
> forbidden by `CLAUDE.md`.
|
||||
> - **Leaflet → MapLibre GL JS** (R4), so §4.1's `if (typeof L === 'undefined')` guard is obsolete.
|
||||
> - **No "View full map →" link** — there is no full map page to link to (R1).
|
||||
>
|
||||
> Details: [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md).
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
@@ -11,22 +11,7 @@ execution: code
|
||||
|
||||
# Post Form Location Override - Plan
|
||||
|
||||
**Status:** ✅ Complete (2026-07-24) — U1–U6 shipped, then hardened by a multi-agent code review the same day. The review found the design's stated server-side safety net (`cleanCoordinate()`) had never been committed, so it landed here; replaced a prefix-parsing coordinate check that accepted `48abc` / `48,85` / `35.0116S` (hemisphere silently flipped); closed three paths that bypassed the submit gate (draft restore, edit-mode prefill, map-load failure) because the gate read a CSS class no code set at init; added pin removal on blanked fields; made the geocode failure visible; and rewrote the U5 guard spec, which asserted only instantly-passing conditions and so could not fail. R8 and R13 above are revised accordingly.
|
||||
|
||||
**Verified by a green run (2026-07-24).** The suite now executes end-to-end: `test-config` 22/22, `test-post` 6/6 (the `scripts/test-post.sh` shell suite — *not* the Playwright specs under `tests/ui/post/`, which is a separate set), and `location-override.spec.js` **20/20** — so the verifications below are no longer by inspection alone. Reaching that took fixing `make test-account` (the password was interpolated into an `sh -c` string, so a shell metacharacter in it killed every UI run), pinning `test-ui` to this checkout's own port, and repairing test cleanup, which had never been able to delete the root-owned entries Grav's Apache creates. See the commit `fix(test): close the test-entry leak into real trip content`.
|
||||
|
||||
Also landed after the review: maplibre's stylesheet is now lazy-`<link>`ed at panel-open instead of statically bundled, cutting `post-form.css` from 92,244 to 26,784 raw bytes (14,528 → 5,631 gzip) on every `/post` load, with a new spec asserting both halves of that boundary.
|
||||
|
||||
**Merged to `main` 2026-07-24** — `user/` at `dd19995`, outer at `4450bd6`, pin bumped. On merged `main`: `test-config` **22/22** and `tests/ui/post/` + `tests/ui/map` **69 passed / 1 failed** (DEL4 only, a pre-existing regression unrelated to this feature — see below). `user/` is still **unpushed by choice**; push `user/` first, then the outer repo.
|
||||
|
||||
**Notes carried forward:**
|
||||
- The `user/` submodule commits remain **unpushed by choice** (git-sync would deploy to prod). Merged to `main` locally on 2026-07-24 and the pin bumped; pushing `user/` — then the outer repo, in that order — is the remaining step and is deliberately left to the user to time.
|
||||
- **DEL4 is a real, pre-existing regression and the one thing still red on `main`** (`tests/ui/post/delete-flow.spec.js:44`, reproducible in isolation). Deleting an entry works: the card leaves the DOM and the folder leaves disk (both asserted and both pass). But a fresh load of the trip page makes the server re-emit the card — an image-less ghost of a page whose content is gone. That is precisely the bug the spec's own header says was already fixed once, so the invalidation has regressed. `cache-on-save` clears the page-tree cache on form *submit*; the delete path evidently does not do the equivalent. Practical impact: delete a bad post from the road, reload, and it is back. Worth its own branch.
|
||||
- ~~This worktree's `user/` branch has diverged from `user/`'s `main`~~ **Done** — `user/main` merged in (`7903432`). It was ahead on both content and theme fixes; `denmark-2026 published: true` came with it, so the local testing flip is gone. The one conflict was `js/post/post-form.js`, a generated bundle, resolved by rebuilding rather than hand-merging minified output.
|
||||
- ~~The `~/Projects` clone's `user/` carries two commits this clone cannot see~~ **Done** — merged in (`8a5cc52`). There is no second clone: `~/Projects` is a symlink to `~/Nextcloud/Projects`. What differs is the **submodule git dir** — a worktree gets `.git/worktrees/<name>/modules/user`, not the checkout's `.git/modules/user` — so `user/main` read `4721af6` here while the checkout's read `285ae37`, and the leg-connection map fix and U+200E strip were unreachable until a local `git fetch` between the two paths. Worth remembering: submodule commits made from the main checkout do not appear in a worktree until fetched, and a local fetch carries them without a push, so git-sync never fires.
|
||||
- **Retracted: the "`owner_username` cluster" diagnosis was wrong.** The worktree showed 6 failures (AN2, DEL1–4, ES1) and they were attributed to `site.yaml` pinning `owner_username: mischa` while the suite authenticates as `testrunner`. On merged `main` only DEL4 fails, with byte-identical `site.yaml` and content — so auth was not the cause. The difference is environmental: the isolated worktree's `user/plugins/` was incomplete (missing `admin`, `markdown-notices`, `migrate-grav`, since `plugins/` is git-ignored and populated per-checkout by `make install-plugins`). Lesson: treat a worktree's UI failures as suspect until reproduced in the main checkout, because the worktree's plugin set is not guaranteed to match.
|
||||
- ~~Every `make` target aborts with `.env:6: *** missing separator`~~ **Fixed by the user (2026-07-24)** — `make` now parses in the checkout. Worth keeping in mind: the env layering is intentional (`.env` global, `-include .env.$(ENV)` per-environment, `ENV` set by the generated env-suffixed remote targets like `make remote-install-prod`), but because `.env` is pulled in with `-include` it must be valid **makefile** syntax as well as valid dotenv — so a leading tab, a multi-line value, or a line without `=` takes down every target at once. Worktrees mask it, since `worktree-new` creates no `.env` and the include silently skips.
|
||||
- **UG1, UG2 and LD1 under `tests/ui/post/` now pass** — they had been failing only because this branch predated `e17a5dc` ("block submit on unfinished photo uploads; un-squeeze EXIF portraits in lightbox"). Merging `user/main` in brought the upload gate and the oriented-derivative slide dims those specs assert, and all three went green with no product change. A first pass mistook them for live defects; the lesson is to check the submodule branch point before reading a red spec on a feature branch as a real bug.
|
||||
**Status:** ✅ Complete (2026-07-24) — merged to `user/` `main` as `dd19995` ("Merge feat/post-location-override into main"). Delivered `js/src/location-map.js` (the lazy-imported single-draggable-marker pin editor) and `js/src/map-style.js` (`MAP_STYLE`, now the single source of the basemap URL for both map paths), plus the "More location details" disclosure with city/country search-by-lookup in `js/src/post-form.js`. The status line lagged the merge and was corrected during the 2026-07-25 documentation reconciliation; the second map engine is now recorded as the one sanctioned exception to the single-map-path rule in `CLAUDE.md` and in [`../../reference/superseded-decisions.md`](../../reference/superseded-decisions.md) → R13.
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
@@ -60,7 +45,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
|
||||
- R5. Lookup is explicit-click only. While in flight, the button shows a disabled "Searching…" state that always re-enables on response, no-match, or network failure.
|
||||
- R6. Clicking with both City and Country empty is treated as a no-match: an inline hint asks for a city or country first, and no request is sent.
|
||||
- R7. Multiple matches render as a clickable list (place name, admin region, country), built via `document.createElement` + `.textContent` (no `innerHTML`), matching every other dynamic-content construction already in `post-form.js`. Clicking an entry sets `lat`/`lng` and the pin only — it never writes back to City/Country. The list hides again until the next lookup.
|
||||
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure (or a non-2xx response) leaves the fields untouched and renders a *distinct* inline hint naming the connection as the problem. **Revised in code review 2026-07-24** from "degrades silently" — silence was indistinguishable from a broken button, and the two failure modes need different messages.
|
||||
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure degrades silently (fields untouched), consistent with the existing reverse-geocode/weather error handling in `post-form.js`.
|
||||
|
||||
**Map preview & sync**
|
||||
- R9. A single MapLibre GL map with one draggable marker (≥44×44px touch target) renders in the panel, reusing the site's existing style URL (`MAP_STYLE`, extracted to a shared `user/themes/intotheeast/js/src/map-style.js` module per KTD1). The map instance is created once, on the panel's first open, held in module scope, and reused (with an explicit `.resize()` call) on every subsequent open — the container sits under `display:none` while closed, so the first paint would otherwise get a zero-size canvas.
|
||||
@@ -69,7 +54,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
|
||||
- R12. No pin is shown until one of the four paths above sets a value for the first time.
|
||||
|
||||
**Error handling & validation boundary**
|
||||
- R13. Invalid manual `lat`/`lng` text raises the visual mismatch flag (R11), **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** from "never client-blocked". The original wording deferred all enforcement to a server-side `cleanCoordinate()` described as already shipped — it was not committed anywhere, so no layer validated coordinates. It now ships in `cache-on-save.php` (both the `/post` form and the Admin2/API save paths) and the client gate stays, giving real defence in depth. The client parse is intentionally stricter than the server's `is_numeric` (whole-value decimals only, so `48,85` / `35.0116S` / `48abc` are rejected rather than prefix-parsed).
|
||||
- R13. Invalid manual `lat`/`lng` text is never client-blocked — the visual mismatch flag (R11) is the only feedback. Final enforcement stays server-side in `cleanCoordinate()`, which already throws on a non-blank, still-invalid value after cleaning.
|
||||
- R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
*Role: Senior Product Manager. Audience: one solo traveler (Mischa), platform: Grav CMS flat-file PHP, no native app.*
|
||||
|
||||
> **Historical — written 2026-06-21. The verdicts still hold; some delivery mechanisms do not.**
|
||||
>
|
||||
> The **SKIP** column is still the standing decision and has not been revisited — background GPS,
|
||||
> followers, comments, social discovery, reactions, reels, 3D flyover, print, and AI itineraries
|
||||
> remain deliberately out of scope. What changed is *how* some **BUILD** items shipped: the map and
|
||||
> stats render inline on the trip page rather than as `/map` and `/stats`, MapLibre replaced Leaflet,
|
||||
> galleries use PhotoSwipe rather than `shortcode-gallery-plusplus`, and `hero_image` was dropped for
|
||||
> entries. See [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md).
|
||||
|
||||
---
|
||||
|
||||
## Starting position
|
||||
|
||||
@@ -64,8 +64,8 @@ Backend sanitization has already been added (`user/plugins/cache-on-save/cache-o
|
||||
### Error handling
|
||||
|
||||
- No search results: inline message under the search box, map/pin untouched.
|
||||
- Search network failure: fields untouched, and an inline hint says the lookup service could not be reached (distinct from the no-results message, which means the service answered). **Revised in code review 2026-07-24** — this originally said "silent-ish degrade", which in practice left the DOM byte-identical to the pre-click state, so a traveller on flaky mobile data could not tell a failed lookup from a broken button. A non-2xx response is also now treated as a failure rather than parsed as an empty result set.
|
||||
- Invalid manual `lat`/`lng` text: the visual mismatch flag is the primary feedback, **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** — this originally said "no client-side hard block", on the stated grounds that server-side `cleanCoordinate()` was already the safety net. It was not: `cleanCoordinate()` had never been committed, so nothing validated coordinates anywhere. It now ships (`cache-on-save.php`, both the `/post` and Admin2 paths), so the two are genuine defence in depth rather than one imaginary net. Client-side parsing is deliberately *stricter* than the server's `is_numeric` (whole-value decimals only), which is the safe direction for a mismatch.
|
||||
- Search network failure: silent-ish degrade (consistent with existing weather/reverse-geocode error handling in `post-form.js`), fields untouched.
|
||||
- Invalid manual `lat`/`lng` text: no client-side hard block (the map preview and eventual server-side `cleanCoordinate()` are the safety nets); this UI's whole point is to make that failure mode rare in practice, not to duplicate the backend validator client-side.
|
||||
- Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
|
||||
|
||||
## Out of scope / explicitly deferred
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Docs Reconciliation — Design
|
||||
|
||||
**Date:** 2026-07-25
|
||||
**Status:** Implemented
|
||||
|
||||
Reconcile the documentation against the code after five weeks of undocumented evolution, so that a
|
||||
repeat review returns "ok".
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Documentation for this project began as thoughts and plans. The app then changed — features were
|
||||
built differently, some were dropped, and the owner changed his mind about what he needed. Those
|
||||
decisions were recorded ad hoc or not at all. The result is a tree where some docs describe a site
|
||||
that no longer exists, and nothing marks them as historical.
|
||||
|
||||
Concretely, before this pass:
|
||||
|
||||
- `docs/working/README.md` advertised `summary.md` as the project's **current state**, while
|
||||
`summary.md` described Leaflet, a `/tracker` feed, a `/map` page, a `/stats` page, and a
|
||||
"Journal · Map · Stats" nav — none of which exist.
|
||||
- `docs/reference/design-system-light.md` documented a light-mode palette in present tense. No light
|
||||
mode is implemented anywhere: `tokens.css` has a single `:root` block and no
|
||||
`prefers-color-scheme` / `data-theme` mechanism.
|
||||
- `CLAUDE.md` — the always-loaded file — asserted a source relationship that does not exist
|
||||
(`css-compiled/` generated from `css/style.css` + `css/tokens.css`).
|
||||
- `README.md`'s server runbook documented every `make remote-*` command without the `-test`/`-prod`
|
||||
suffix that `guard-env` requires, so the documented commands cannot run.
|
||||
- `docker-compose.yml` still defines a `travel-memories` service whose source was deleted in
|
||||
`a80b0a9` ("moved to separate project"), so `make start` fails on any clean checkout.
|
||||
|
||||
## Root cause
|
||||
|
||||
Per [`docs/solutions/conventions/claude-md-content-tiering.md`](../../solutions/conventions/claude-md-content-tiering.md),
|
||||
descriptions drift because the code moves and the prose does not; rules do not drift, because they
|
||||
encode intent rather than state. This pass confirms that finding again: every defect found was a
|
||||
description of code, config, or a command — not one was a rule that had become wrong on its own.
|
||||
|
||||
The compounding factor is **tense**. The tree mixes two kinds of document with no marker
|
||||
distinguishing them:
|
||||
|
||||
| Kind | Files | Staleness is |
|
||||
|---|---|---|
|
||||
| Present-tense — "this is how it is" | `CLAUDE.md`, `reference/`, `guides/`, `README.md`, `CONCEPTS.md` | a defect |
|
||||
| Past-tense — "this is what we decided then" | `working/plans/`, `working/specs/`, `working/milestones/`, `summary.md`, `pm-analysis.md` | correct and expected |
|
||||
|
||||
A completed plan *should* be stale — it is a record. It only becomes a problem when nothing tells a
|
||||
reader it is a record. `milestones/milestone-2.md` opens by describing a Leaflet `/map` page in
|
||||
confident present tense with no date qualifier.
|
||||
|
||||
## Approach
|
||||
|
||||
Two mechanisms, combined:
|
||||
|
||||
**A — one authoritative supersession ledger.** `docs/reference/superseded-decisions.md` records every
|
||||
reversal in one table: what was planned, where it was planned, what is true now, when it changed, and
|
||||
why. This answers "what did I change my mind about?" in a single place, which is the question a
|
||||
review actually asks.
|
||||
|
||||
**B — inline notes at the point of staleness.** Every superseded section carries a
|
||||
`> **Superseded …**` blockquote where the stale claim sits, so the claim can never be read
|
||||
un-corrected. This pattern is not invented here — `docs/reference/architecture.md` and
|
||||
`docs/guides/trip-switching.md` already use `> History:` and `> **Changed 2026-07:**` notes.
|
||||
|
||||
A alone has an indirection problem (a pointer you may not follow). B alone has a completeness problem
|
||||
(no changelog view, and coverage is only as good as the annotation pass). Together each covers the
|
||||
other's gap.
|
||||
|
||||
### Scope, split by tense
|
||||
|
||||
- **Present-tense docs are corrected against the code.** The code is the source of truth. Every
|
||||
factual claim was verified by reading the code, config, or `Makefile` — not inferred.
|
||||
- **Past-tense docs are annotated only, never rewritten.** 41 plans and 25 specs, ~30k lines. Their
|
||||
`✅ Complete` trailing notes are good records; rewriting them would destroy the audit trail and is
|
||||
unbounded work.
|
||||
- **Code-side inconsistencies are logged, not fixed.** Mixing behaviour changes into a documentation
|
||||
diff would make it unreviewable. They go to
|
||||
`docs/working/2026-07-25-doc-drift-recommendations.md` for a separate decision.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- A repeatable drift check (script with an exit code). Deliberately deferred — the owner asked for the
|
||||
one-time reconciliation first. It is the lead recommendation in the recommendations doc.
|
||||
- Fixing the `travel-memories` / `docker-compose.yml` breakage, the unused
|
||||
`shortcode-gallery-plusplus`, and the `italy-2025` demo fixtures. All logged as recommendations.
|
||||
|
||||
## Verification
|
||||
|
||||
Claims were checked against, not assumed from:
|
||||
|
||||
| Claim area | Verified against |
|
||||
|---|---|
|
||||
| Nav labels | `templates/partials/base.html.twig:27-31` |
|
||||
| Template + partial inventory | `ls templates/`, `ls templates/partials/` |
|
||||
| Asset sources → outputs | `user/themes/intotheeast/package.json` build script |
|
||||
| `css-compiled/` provenance | CSS imports in `js/src/*.js`; `assets.addCss` in `base.html.twig:7-8` |
|
||||
| Design tokens | `css/tokens.css` |
|
||||
| Light mode | absence of `prefers-color-scheme` / `data-theme` and of light hex values in `css/` |
|
||||
| Photo field rules | `user/pages/02.post/post-form.md:35-46` |
|
||||
| `hero_image` removal | `post-form.md:149-151` |
|
||||
| `entry-actions` routes | `user/plugins/entry-actions/entry-actions.php:63-73` |
|
||||
| `make` targets + env guard | `Makefile` (`guard-env:41-43`, `make-env-target:45-46`) |
|
||||
| `travel-memories` removal | `git log -- services/` → `a80b0a9`; `docker compose build` failure |
|
||||
| Plan status vs reality | `user/` HEAD `dd19995` |
|
||||
|
||||
The `user/` submodule was moved off the outer repo's pin to its real HEAD (`dd19995`) before
|
||||
auditing, because the pin lagged and would have produced findings against a state that is no longer
|
||||
current.
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
*Branch: `experimental-polar-steps`. Ready for morning review.*
|
||||
|
||||
> **Historical — written 2026-06-21. This is not the current state of the site.**
|
||||
>
|
||||
> This was the wrap-up of the four-milestone experimental branch. Much of what it describes has since
|
||||
> been deliberately reversed: there is no `/map` page, no `/stats` page, no `/tracker` feed, no
|
||||
> Leaflet, and the nav is not "Journal · Map · Stats". Every reversal is listed in
|
||||
> [`../reference/superseded-decisions.md`](../reference/superseded-decisions.md).
|
||||
>
|
||||
> For the site as it actually is, read
|
||||
> [`../reference/architecture.md`](../reference/architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## What Was Done
|
||||
|
||||
@@ -59,10 +59,7 @@ check_grep "location_country field present" "name: location_country"
|
||||
check_grep "weather_desc field present" "name: weather_desc"
|
||||
check_grep "weather_temp_c field present" "name: weather_temp_c"
|
||||
check_grep "transport_mode field present" "name: transport_mode"
|
||||
# No hero_image assertion: the field was deliberately dropped in 8cf1145 —
|
||||
# entries render their hero from the first photo, so an explicit filename was
|
||||
# redundant (see the comment at that spot in post-form.md). This check outlived
|
||||
# the field and had been failing ever since.
|
||||
check_grep "hero_image field present" "name: hero_image"
|
||||
check_grep "force_connect field present" "name: force_connect"
|
||||
check_grep "featured field present" "name: featured"
|
||||
|
||||
|
||||
@@ -2,58 +2,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* Fail fast if the server under test does not serve the `user/` tree the specs
|
||||
* read from disk.
|
||||
*
|
||||
* This mismatch is silent and destructive. Every post spec submits through the
|
||||
* live form (the write target is derived server-side from site.yaml
|
||||
* `active_trip`, so there is no per-request override), then asserts and cleans up
|
||||
* on disk via helpers' USER_DIR. Run the specs from a worktree whose own
|
||||
* container is down and baseURL falls back to localhost:8081 — the MAIN
|
||||
* checkout — so entries get created in one content tree while cleanup deletes
|
||||
* from another. The entries are then left behind in real trip content, which is
|
||||
* exactly what happened on 2026-07-24.
|
||||
*
|
||||
* Docker is the only thing that knows the mapping, so this is best-effort: if we
|
||||
* cannot determine it we warn and continue rather than blocking non-Docker runs.
|
||||
* But when we CAN determine it and it disagrees, that is always a bug.
|
||||
*/
|
||||
function assertServerServesUserDir(baseURL, userDir) {
|
||||
const port = new URL(baseURL).port || '80';
|
||||
let mountedUserDir;
|
||||
try {
|
||||
const container = execSync("docker ps --format '{{.Names}}\t{{.Ports}}'", { encoding: 'utf-8' })
|
||||
.split('\n').filter(Boolean)
|
||||
.find(l => l.includes(`:${port}->`));
|
||||
if (!container) {
|
||||
console.warn(`[setup] no running container publishes port ${port} — is the dev server up? (make start)`);
|
||||
return;
|
||||
}
|
||||
const name = container.split('\t')[0];
|
||||
mountedUserDir = execSync(
|
||||
`docker inspect ${name} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
if (!mountedUserDir) return; // no bind mount to compare against
|
||||
} catch (_) {
|
||||
return; // docker unavailable — nothing to check
|
||||
}
|
||||
|
||||
const served = fs.realpathSync(mountedUserDir);
|
||||
const asserted = fs.realpathSync(userDir);
|
||||
if (served !== asserted) {
|
||||
throw new Error(
|
||||
`Test target mismatch — refusing to run.\n` +
|
||||
` baseURL ${baseURL} is served from: ${served}\n` +
|
||||
` but the specs read/clean up: ${asserted}\n` +
|
||||
`Entries would be created in one tree and cleanup would miss them, leaving\n` +
|
||||
`test entries behind in real content. Start this checkout's own server\n` +
|
||||
`(make start) and point the run at it, e.g. GRAV_BASE_URL=http://localhost:<port>.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async function globalSetup() {
|
||||
const envFile = path.join(__dirname, '../.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
@@ -75,9 +23,4 @@ module.exports = async function globalSetup() {
|
||||
|
||||
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
|
||||
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
||||
|
||||
// Required last: helpers.js resolves USER_DIR at require time, and the .env
|
||||
// load above can supply GRAV_USER_DIR.
|
||||
const { USER_DIR } = require('./ui/helpers');
|
||||
assertServerServesUserDir(process.env.GRAV_BASE_URL || 'http://localhost:8081', USER_DIR);
|
||||
};
|
||||
|
||||
+45
-32
@@ -1,44 +1,57 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Reuse the specs' own resolution rather than reimplementing it. The previous
|
||||
// version of this file derived the dailies directory from a `parent:` key in
|
||||
// pages/02.post/post-form.md — a key that was deliberately removed (the write
|
||||
// target is injected server-side from site.yaml `active_trip`, and CLAUDE.md
|
||||
// forbids re-adding a static parent). The regex therefore never matched,
|
||||
// dailiesDir was always null, and the dailies sweep below silently did nothing.
|
||||
// That is how ui-test entries survived into the active trip's content.
|
||||
// removeEntryDir handles the root-owned case by deleting through the container —
|
||||
// see its comment. Plain fs.rmSync cannot remove what Grav's Apache wrote.
|
||||
const { USER_DIR, TRACKER_DIR, removeEntryDir } = require('./ui/helpers');
|
||||
function resolveUserDir() {
|
||||
if (process.env.GRAV_USER_DIR) return process.env.GRAV_USER_DIR;
|
||||
try {
|
||||
const raw = execSync(
|
||||
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
if (raw) return raw;
|
||||
} catch (_) {}
|
||||
return path.join(__dirname, '../user');
|
||||
}
|
||||
|
||||
function sweepUiTestEntries(dir) {
|
||||
if (!dir || !fs.existsSync(dir)) return 0;
|
||||
const found = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
||||
let removed = 0;
|
||||
found.forEach(e => {
|
||||
const target = path.join(dir, e);
|
||||
try {
|
||||
removeEntryDir(target);
|
||||
removed++;
|
||||
} catch (err) {
|
||||
// Loud, not silent — a swallowed failure here is exactly what let a
|
||||
// ui-test entry survive into the active trip's content.
|
||||
console.error(`[teardown] COULD NOT REMOVE ${target}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
const entries = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
||||
entries.forEach(e => fs.rmSync(path.join(dir, e), { recursive: true, force: true }));
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
module.exports = async function globalTeardown() {
|
||||
// Sweep both the post inbox and the active trip's dailies.
|
||||
const n1 = sweepUiTestEntries(path.join(USER_DIR, 'pages/02.post'));
|
||||
const n2 = sweepUiTestEntries(TRACKER_DIR);
|
||||
const userDir = resolveUserDir();
|
||||
|
||||
// Read active trip slug from post-form.md
|
||||
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md');
|
||||
let dailiesDir = null;
|
||||
if (fs.existsSync(postFormPath)) {
|
||||
const content = fs.readFileSync(postFormPath, 'utf-8');
|
||||
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
|
||||
if (m) {
|
||||
const tripSlug = m[1];
|
||||
const tripsBase = path.join(userDir, 'pages/01.trips');
|
||||
const tripFolder = fs.readdirSync(tripsBase).find(
|
||||
f => f === tripSlug || f.endsWith('.' + tripSlug) || f.includes(tripSlug)
|
||||
);
|
||||
if (tripFolder) {
|
||||
const dailiesBase = path.join(tripsBase, tripFolder);
|
||||
const dailiesFolder = fs.readdirSync(dailiesBase).find(
|
||||
f => f === 'dailies' || f === '01.dailies' || f.endsWith('.dailies')
|
||||
);
|
||||
if (dailiesFolder) dailiesDir = path.join(dailiesBase, dailiesFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep both the post inbox and the active trip's dailies
|
||||
const postInbox = path.join(userDir, 'pages/02.post');
|
||||
const n1 = sweepUiTestEntries(postInbox);
|
||||
const n2 = dailiesDir ? sweepUiTestEntries(dailiesDir) : 0;
|
||||
|
||||
if (n1 + n2 > 0) {
|
||||
console.log(
|
||||
`[teardown] removed ${n1} ui-test entries from 02.post, ` +
|
||||
`${n2} from ${path.relative(USER_DIR, TRACKER_DIR)}`
|
||||
);
|
||||
console.log(`[teardown] removed ${n1} ui-test entries from 02.post, ${n2} from dailies`);
|
||||
}
|
||||
};
|
||||
|
||||
+2
-59
@@ -170,60 +170,6 @@ async function createPhotoEntry(page, tag, { content, publish = true, created }
|
||||
'Entry posted successfully!', { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Grav container that serves USER_DIR, so cleanup can delete as root.
|
||||
* Prefers GRAV_CONTAINER (set by .worktree-env / .env), else matches on the bind
|
||||
* mount so a worktree never picks the main checkout's container.
|
||||
*/
|
||||
function resolveGravContainer() {
|
||||
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
|
||||
try {
|
||||
const want = fs.realpathSync(USER_DIR);
|
||||
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] })
|
||||
.split('\n').filter(Boolean);
|
||||
return names.find((n) => {
|
||||
const src = execSync(
|
||||
`docker inspect ${n} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
return src && fs.realpathSync(src) === want;
|
||||
}) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entry directory, falling back to the container when the host cannot.
|
||||
*
|
||||
* Grav's Apache workers run as root, so every entry the form creates is
|
||||
* root-owned. Removing one recursively needs write permission on that directory,
|
||||
* which the host user does not have — so a plain fs.rmSync throws EACCES and the
|
||||
* entry survives. That is how a ui-test entry ended up committed-adjacent in the
|
||||
* active trip's content on 2026-07-24: cleanup had never actually worked for
|
||||
* form-created entries, it just failed inside a path nothing checked.
|
||||
*
|
||||
* `docker exec … rm -rf` runs as root in the container, which can remove them.
|
||||
*/
|
||||
function removeEntryDir(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code !== 'EACCES' && err.code !== 'EPERM') throw err;
|
||||
}
|
||||
const container = resolveGravContainer();
|
||||
if (!container) {
|
||||
throw new Error(
|
||||
`Cannot remove ${dir}: it is root-owned (written by Grav in the container) and no ` +
|
||||
`matching container was found to delete it as root. Set GRAV_CONTAINER or remove it manually.`
|
||||
);
|
||||
}
|
||||
execSync(`docker exec ${container} rm -rf '/var/www/html/user/${path.relative(USER_DIR, dir)}'`,
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tracker entry folder by a unique slug fragment, then delete it.
|
||||
*/
|
||||
@@ -233,7 +179,7 @@ function cleanupEntry(slugFragment) {
|
||||
const entries = fs.readdirSync(TRACKER_DIR);
|
||||
const match = entries.find(e => e.includes(slugFragment));
|
||||
if (match) {
|
||||
removeEntryDir(path.join(TRACKER_DIR, match));
|
||||
fs.rmSync(path.join(TRACKER_DIR, match), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +202,4 @@ function readEntryMd(entryDir) {
|
||||
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
||||
}
|
||||
|
||||
// USER_DIR is exported so global-setup/global-teardown resolve the same tree the
|
||||
// specs assert against, instead of keeping their own (previously divergent) copy
|
||||
// of this logic.
|
||||
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, removeEntryDir, findEntry, readEntryMd, TEST_PHOTO, USER_DIR, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said
|
||||
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them.
|
||||
//
|
||||
// Fixed in e17a5dc: slides now link a 2000px fit-within derivative and measure
|
||||
// THAT file, and derivatives are re-encoded upright, so the attrs and the
|
||||
// rendered pixels agree.
|
||||
//
|
||||
// The invariant tested here is environment-proof: whatever file the slide
|
||||
// links to, its browser-rendered natural size must equal the data-pswp-*
|
||||
// attrs. (Whether the photo ALSO displays upright depends on the server's
|
||||
@@ -28,14 +24,10 @@ const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
// USER_DIR comes from helpers so GRAV_USER_DIR is honoured — without it a run
|
||||
// against a checkout detached from the served tree plants the fixture in a
|
||||
// different user/ than Grav renders, and LD1 fails as an opaque "card never
|
||||
// appeared" timeout.
|
||||
const { USER_DIR } = require('../helpers');
|
||||
|
||||
// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
|
||||
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
|
||||
const USER_DIR = path.join(__dirname, '../../../user');
|
||||
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
|
||||
const DEMO_TRIP_URL = '/trips/italy-2026-demo';
|
||||
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
// @ts-check
|
||||
// Tests: post form "More location details" — search-by-city lookup + draggable
|
||||
// map pin preview for setting an entry's coordinates without live GPS.
|
||||
// Covers R4-R14. The Open-Meteo geocoding endpoint is mocked via page.route()
|
||||
// so this suite is hermetic (no live third-party call, no rate-limit flakiness).
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO } = require('../helpers');
|
||||
|
||||
const GEOCODE_URL = '**/geocoding-api.open-meteo.com/v1/search**';
|
||||
|
||||
const created = [];
|
||||
test.afterAll(() => { created.forEach(cleanupEntry); });
|
||||
|
||||
// Real-API-shaped fixtures (verified live against geocoding-api.open-meteo.com).
|
||||
const KYOTO_RESULTS = {
|
||||
results: [
|
||||
{ name: 'Kyoto', latitude: 35.0116, longitude: 135.7681, admin1: 'Kyoto Prefecture', country: 'Japan' }
|
||||
]
|
||||
};
|
||||
|
||||
// Mirrors the design doc's verified live Paris query: Île-de-France (France)
|
||||
// first from the API, then five US states — Texas among them, in admin1 (the
|
||||
// API's `country` field is "United States" for all of the US matches, so the
|
||||
// ranking must also check admin1 to disambiguate on a US state name).
|
||||
const PARIS_RESULTS = {
|
||||
results: [
|
||||
{ name: 'Paris', latitude: 48.85341, longitude: 2.3488, admin1: 'Île-de-France Region', country: 'France' },
|
||||
{ name: 'Paris', latitude: 33.66094, longitude: -95.55551, admin1: 'Texas', country: 'United States' },
|
||||
{ name: 'Paris', latitude: 36.302, longitude: -88.32671, admin1: 'Tennessee', country: 'United States' },
|
||||
{ name: 'Paris', latitude: 38.2098, longitude: -84.2529, admin1: 'Kentucky', country: 'United States' },
|
||||
{ name: 'Paris', latitude: 39.6112, longitude: -87.6961, admin1: 'Illinois', country: 'United States' }
|
||||
]
|
||||
};
|
||||
|
||||
function mockGeocode(page, body) {
|
||||
return page.route(GEOCODE_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}));
|
||||
}
|
||||
|
||||
async function openLocationDetails(page) {
|
||||
await page.locator('.location-details__summary').click();
|
||||
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||
}
|
||||
|
||||
// ── Panel closed by default (R1) ────────────────────────────────────────────
|
||||
test('More location details is closed by default and holds the relocated lat/lng fields', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
const details = page.locator('.location-details');
|
||||
await expect(details).toBeAttached();
|
||||
await expect(details).toHaveJSProperty('open', false);
|
||||
await expect(page.locator('.location-details input[name="data[lat]"]')).toBeAttached();
|
||||
await expect(page.locator('.location-details input[name="data[lng]"]')).toBeAttached();
|
||||
});
|
||||
|
||||
// ── R6: empty City + Country sends no request ───────────────────────────────
|
||||
test('R6: clicking lookup with City and Country both empty sends no request', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
let requested = false;
|
||||
await page.route(GEOCODE_URL, (route) => { requested = true; route.abort(); });
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await expect(page.locator('#location-search-hint')).toContainText(/city or country/i);
|
||||
expect(requested).toBe(false);
|
||||
});
|
||||
|
||||
// ── R7: a search result sets lat/lng only, never City/Country ──────────────
|
||||
test('R7: clicking a search result sets lat/lng and leaves City/Country untouched', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const results = page.locator('.location-search-results li button');
|
||||
await expect(results).toHaveCount(1);
|
||||
await results.first().click();
|
||||
|
||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('35.011600');
|
||||
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('135.768100');
|
||||
await expect(page.locator('input[name="data[location_city]"]')).toHaveValue('Kyoto');
|
||||
await expect(page.locator('input[name="data[location_country]"]')).toHaveValue('');
|
||||
// R7: the list hides again until the next lookup.
|
||||
await expect(page.locator('.location-search-results li')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── R4/KTD2: Paris/Texas disambiguation ranks the Texas match first ─────────
|
||||
test('disambiguation: City "Paris" + Country "Texas" ranks the Texas match first', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
let requestedUrl = null;
|
||||
await page.route(GEOCODE_URL, (route) => {
|
||||
requestedUrl = route.request().url();
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARIS_RESULTS) });
|
||||
});
|
||||
await page.fill('input[name="data[location_city]"]', 'Paris');
|
||||
await page.fill('input[name="data[location_country]"]', 'Texas');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const results = page.locator('.location-search-results li button');
|
||||
await expect(results).toHaveCount(5);
|
||||
await expect(results.first()).toContainText('Texas');
|
||||
|
||||
// R4: Country is never concatenated into the query string.
|
||||
expect(requestedUrl).toContain('name=Paris');
|
||||
expect(requestedUrl).not.toContain('Texas');
|
||||
});
|
||||
|
||||
// ── R8: no matches shows the inline hint, fields untouched ─────────────────
|
||||
test('R8: no matches shows the no-match hint and leaves fields untouched', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, { results: [] });
|
||||
await page.fill('input[name="data[location_city]"]', 'Nowheresville');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
await expect(page.locator('#location-search-hint')).toContainText(/no matches/i);
|
||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||
});
|
||||
|
||||
// ── R5: in-flight state shows "Searching…" and always re-enables ───────────
|
||||
test('R5: the lookup button shows a disabled "Searching…" state while in flight', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await page.route(GEOCODE_URL, async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(KYOTO_RESULTS) });
|
||||
});
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const btn = page.locator('#lookup-coords');
|
||||
await expect(btn).toBeDisabled();
|
||||
await expect(btn).toHaveText('Searching…');
|
||||
await expect(btn).toBeEnabled({ timeout: 5_000 });
|
||||
await expect(btn).toContainText('Look up coordinates');
|
||||
});
|
||||
|
||||
// ── R8: a network failure degrades silently and re-enables the button ──────
|
||||
test('a network failure degrades silently, leaves fields untouched, and re-enables the button', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await page.route(GEOCODE_URL, (route) => route.abort('failed'));
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
await expect(page.locator('#lookup-coords')).toBeEnabled();
|
||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||
});
|
||||
|
||||
// ── XSS safety: an API-sourced name containing markup renders as literal text ──
|
||||
test('a result name containing markup renders as literal text, not executed', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, {
|
||||
results: [{ name: '<img src=x onerror="window.__xss=true">', latitude: 1, longitude: 2, country: 'Nowhere' }]
|
||||
});
|
||||
await page.fill('input[name="data[location_city]"]', 'Test');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
|
||||
const btn = page.locator('.location-search-results li button').first();
|
||||
await expect(btn).toContainText('<img src=x onerror="window.__xss=true">');
|
||||
expect(await btn.evaluate((el) => el.querySelector('img'))).toBeNull();
|
||||
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── U4: map renders exactly one canvas, no pin until a coordinate is set ───
|
||||
test('opening the panel renders exactly one map canvas with no initial pin', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── U4: reopening does not duplicate the canvas; resize keeps it non-zero ──
|
||||
test('reopening the panel a second time leaves exactly one canvas with non-zero size', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
await page.locator('.location-details__summary').click(); // close
|
||||
await expect(page.locator('.location-details')).toHaveJSProperty('open', false);
|
||||
await openLocationDetails(page); // reopen
|
||||
|
||||
const canvases = page.locator('#location-map canvas.maplibregl-canvas');
|
||||
await expect(canvases).toHaveCount(1, { timeout: 10_000 });
|
||||
const box = await canvases.first().boundingBox();
|
||||
expect(box && box.width).toBeGreaterThan(0);
|
||||
expect(box && box.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── R11: a search pick shows a pin on the map ───────────────────────────────
|
||||
test('a search-result pick renders a pin on the map', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await page.locator('.location-search-results li button').first().click();
|
||||
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── R11: dragging the marker updates lat/lng (rounded to 6dp) ──────────────
|
||||
test('dragging the pin updates lat/lng to the drop location', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await page.locator('.location-search-results li button').first().click();
|
||||
|
||||
const marker = page.locator('#location-map .maplibregl-marker');
|
||||
await expect(marker).toHaveCount(1, { timeout: 10_000 });
|
||||
const before = await page.locator('input[name="data[lat]"]').inputValue();
|
||||
|
||||
// setPin()'s map.panTo() animates the marker into view — wait for it to
|
||||
// settle so the bounding box grabbed below matches where the marker will
|
||||
// actually be when the mouse events land.
|
||||
await page.waitForTimeout(800);
|
||||
const box = await marker.boundingBox();
|
||||
if (!box) throw new Error('marker has no bounding box');
|
||||
const startX = box.x + box.width / 2;
|
||||
const startY = box.y + box.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 40, startY + 30, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(async () => {
|
||||
const after = await page.locator('input[name="data[lat]"]').inputValue();
|
||||
expect(after).not.toBe(before);
|
||||
expect(after).toMatch(/^-?\d+\.\d{6}$/);
|
||||
}).toPass({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ── R11/R13: typing an invalid value flags the field without crashing ──────
|
||||
test('typing an invalid lat value shows the mismatch flag and clears once fixed', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
|
||||
const latEl = page.locator('input[name="data[lat]"]');
|
||||
const lngEl = page.locator('input[name="data[lng]"]');
|
||||
await latEl.fill('not-a-number');
|
||||
await lngEl.fill('135.7681');
|
||||
await lngEl.blur();
|
||||
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
await expect(latEl).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||
|
||||
await latEl.fill('35.0116');
|
||||
await latEl.blur();
|
||||
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1);
|
||||
});
|
||||
|
||||
// ── U4: rapid close/reopen while the maplibre-gl chunk is still in flight must
|
||||
// not build two Map instances against the same container (code-review fix) ──
|
||||
test('rapid close/reopen before the maplibre-gl chunk resolves still leaves exactly one canvas', async ({ page }) => {
|
||||
await page.route('**/*maplibre-gl*.js', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto('/post');
|
||||
|
||||
// Open, then immediately close and reopen — both toggles land while the
|
||||
// delayed chunk request above is still pending.
|
||||
await page.locator('.location-details__summary').click();
|
||||
await page.locator('.location-details__summary').click();
|
||||
await page.locator('.location-details__summary').click();
|
||||
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||
|
||||
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ── U5: blanking both fields after a mismatch was flagged clears the flag ──
|
||||
test('blanking both lat/lng fields after a mismatch clears the flag', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await openLocationDetails(page);
|
||||
|
||||
const latEl = page.locator('input[name="data[lat]"]');
|
||||
const lngEl = page.locator('input[name="data[lng]"]');
|
||||
await latEl.fill('not-a-number');
|
||||
await lngEl.blur();
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
|
||||
await latEl.fill('');
|
||||
await lngEl.fill('');
|
||||
await lngEl.blur();
|
||||
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||
await expect(lngEl).not.toHaveClass(/location-field--mismatch/);
|
||||
});
|
||||
|
||||
// ── U5: a flagged, unresolved lat/lng must block submit (code-review fix) ──
|
||||
test('submitting with an unresolved lat/lng mismatch is blocked', async ({ page }) => {
|
||||
const tag = `loc-mismatch-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, 'Location-override mismatch-blocks-submit guard. Safe to delete.');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
|
||||
await openLocationDetails(page);
|
||||
const latEl = page.locator('input[name="data[lat]"]');
|
||||
const lngEl = page.locator('input[name="data[lng]"]');
|
||||
await latEl.fill('999');
|
||||
await lngEl.fill('999');
|
||||
await lngEl.blur();
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
|
||||
// Register for cleanup BEFORE the click: if the gate ever regresses, the
|
||||
// entry lands on disk and the afterAll hook must still see the tag.
|
||||
created.push(tag);
|
||||
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||
|
||||
// `.notices` toHaveCount(0) and toHaveURL(/\/post/) both pass instantly and
|
||||
// both also hold for a SUCCESSFUL submit (the form posts to /post and only
|
||||
// renders its notice after the round trip), so neither can distinguish a
|
||||
// working gate from a regressed one. Prove the negative on disk instead,
|
||||
// after giving a regressed submit time to actually write.
|
||||
await page.waitForTimeout(2000);
|
||||
expect(findEntry(tag), 'a flagged coordinate must never reach the server').toBeFalsy();
|
||||
// And prove the block was the gate's doing: still flagged, value untouched.
|
||||
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||
await expect(latEl).toHaveValue('999');
|
||||
});
|
||||
|
||||
// ── U4: lazy-load boundary — an ordinary GPS-only submit never fetches maplibre-gl ──
|
||||
// The URL pattern deliberately covers BOTH halves of the lazy boundary: the JS
|
||||
// chunk (js/post/maplibre-gl-*.js) and the stylesheet
|
||||
// (css-compiled/maplibre-gl.css, <link>ed by location-map.js at panel-open —
|
||||
// see its ensureMaplibreCss). Neither may be requested when the panel stays shut.
|
||||
test('an ordinary submit without opening the panel never fetches the maplibre-gl chunk', async ({ page }) => {
|
||||
const chunkRequests = [];
|
||||
page.on('request', (req) => {
|
||||
if (/maplibre-gl/.test(req.url())) chunkRequests.push(req.url());
|
||||
});
|
||||
|
||||
const tag = `loc-nomap-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, 'Location-override lazy-load guard. Safe to delete.');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||
created.push(tag);
|
||||
|
||||
expect(chunkRequests, 'neither the maplibre-gl chunk nor its stylesheet may be fetched when the panel is never opened').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── The other half of that boundary: opening the panel DOES apply the vendor CSS ──
|
||||
// Without this, the guard above could keep passing while the stylesheet silently
|
||||
// stopped loading at all (a broken href, a missed build step), leaving the map
|
||||
// unstyled with nothing to catch it. Asserts the <link> exists AND parsed —
|
||||
// link.sheet is null until the browser has actually applied it.
|
||||
test('opening the panel lazily links maplibre\'s stylesheet and applies it', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
|
||||
const hrefBefore = await page.evaluate(() => Array.from(document.styleSheets)
|
||||
.map((s) => s.href || '').filter((h) => /maplibre-gl\.css/.test(h)));
|
||||
expect(hrefBefore, 'the vendor stylesheet must not be present before the panel opens').toHaveLength(0);
|
||||
|
||||
await openLocationDetails(page);
|
||||
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||
|
||||
await expect.poll(
|
||||
() => page.evaluate(() => {
|
||||
const link = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
||||
.find((l) => /maplibre-gl\.css/.test(l.href));
|
||||
return link ? link.sheet !== null : false;
|
||||
}),
|
||||
{ message: 'maplibre\'s stylesheet must be linked and applied once the panel opens', timeout: 10_000 }
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── Full submit: a search-picked location round-trips into the frontmatter ──
|
||||
test('a full submit with a search-picked location saves the expected lat/lng', async ({ page }) => {
|
||||
const tag = `loc-submit-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await mockGeocode(page, KYOTO_RESULTS);
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, 'Location-override submit test. Safe to delete.');
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await openLocationDetails(page);
|
||||
await page.click('#lookup-coords');
|
||||
await page.locator('.location-search-results li button').first().click();
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||
created.push(tag);
|
||||
|
||||
const entryDir = findEntry(tag);
|
||||
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
|
||||
const md = readEntryMd(entryDir);
|
||||
expect(md).toContain('35.0116');
|
||||
expect(md).toContain('135.7681');
|
||||
});
|
||||
@@ -12,12 +12,6 @@
|
||||
// silent-data-loss path.
|
||||
// post-form.js owns the complete gate (theme code; the form plugin is
|
||||
// GPM-managed and not patchable in-repo).
|
||||
//
|
||||
// The gate lives in e17a5dc: submit is blocked unless EVERY FilePond item is
|
||||
// processing-complete, with distinct messages for the failed and still-uploading
|
||||
// cases. Both assert on .photo-convert-status, which post-form.js's setStatus()
|
||||
// creates via photoStatusEl() — so a passing expectation here proves the THEME
|
||||
// gate fired, not the form plugin's, whose own guard only raises alert().
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-check
|
||||
// Tests: S1–S9 — story mode rendering and navigation
|
||||
// Tests: S1–S7 — story mode rendering and navigation
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
@@ -85,74 +85,3 @@ test('S7: story body back link has back-pill class', async ({ page }) => {
|
||||
await expect(bodyBack).toBeAttached();
|
||||
await expect(bodyBack).toHaveText(/← Back/);
|
||||
});
|
||||
|
||||
// ── S8: Scrolly-section text panels actually render beside the pinned image ───
|
||||
// The server ships the panel text inside .scrolly__steps-content, which CSS hides
|
||||
// (style.css: `display: none`). Only the inline Scrollama block in story.html.twig
|
||||
// splits it into visible .scrolly-step divs — and it early-returns silently if the
|
||||
// main.js bundle (which sets window.scrollama) hasn't executed yet. S3 asserted the
|
||||
// image column exists; nothing asserted the text column was non-empty.
|
||||
test('S8: scrolly-section builds visible step panels from its slot content', async ({ page }) => {
|
||||
await page.goto(STORY_SCROLLY);
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
|
||||
// The bundle must have published scrollama before the inline block ran
|
||||
expect(
|
||||
await page.evaluate(() => typeof window.scrollama !== 'undefined'),
|
||||
'window.scrollama published by main.js bundle'
|
||||
).toBe(true);
|
||||
|
||||
// Every scrolly-section must have produced at least one step
|
||||
const sections = page.locator('.scrolly');
|
||||
const sectionCount = await sections.count();
|
||||
expect(sectionCount, 'Two scrolly-sections').toBe(2);
|
||||
|
||||
for (let i = 0; i < sectionCount; i++) {
|
||||
const section = sections.nth(i);
|
||||
const steps = section.locator('.scrolly-step');
|
||||
expect(
|
||||
await steps.count(),
|
||||
`scrolly-section ${i} split its slot content into steps`
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// …and the text must be readable, not left hidden in the raw slot.
|
||||
// Scroll each step into view so its reveal transition completes.
|
||||
const firstStep = page.locator('.scrolly').first().locator('.scrolly-step').first();
|
||||
await firstStep.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(800);
|
||||
await expect(firstStep.locator('.scrolly-step__inner')).toBeVisible();
|
||||
const text = (await firstStep.innerText()).trim();
|
||||
expect(text.length, 'First step panel renders non-empty text').toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
// ── S9: Back-to-top is wired once, by main.js, and pushes a history entry ─────
|
||||
// The inline duplicate in story.html.twig was removed; initBackToTop() in
|
||||
// js/src/main.js now solely owns #story-totop. That makes the button depend on
|
||||
// the bundle having loaded, so assert the observable behaviour end to end.
|
||||
test('S9: story back-to-top reveals on scroll, returns to top, and pushes history', async ({ page }) => {
|
||||
await page.goto(STORY_SCROLLY);
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
|
||||
const btn = page.locator('#story-totop');
|
||||
await expect(btn).toBeAttached();
|
||||
|
||||
// Hidden until scrolled past the 0.8 * viewport threshold
|
||||
await expect(btn).not.toHaveClass(/is-visible/);
|
||||
|
||||
const historyBefore = await page.evaluate(() => history.length);
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, window.innerHeight * 2));
|
||||
await expect(btn).toHaveClass(/is-visible/, { timeout: 3000 });
|
||||
|
||||
await btn.click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.scrollY), { timeout: 3000 })
|
||||
.toBeLessThan(10);
|
||||
|
||||
// main.js's variant pushes a history entry; the removed inline copy did not
|
||||
expect(
|
||||
await page.evaluate(() => history.length),
|
||||
'Back-to-top pushed a history entry'
|
||||
).toBeGreaterThan(historyBefore);
|
||||
});
|
||||
|
||||
+1
-1
Submodule user updated: 1b9e51baf7...02fa4e94a7
Reference in New Issue
Block a user