Compare commits
307
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
086c36d157 | ||
|
|
7c9c140a1b | ||
|
|
d946eaaa7e | ||
|
|
8202d2a257 | ||
|
|
cfe070efec | ||
|
|
3250ad366a | ||
|
|
4450bd6eec | ||
|
|
28bbd41868 | ||
|
|
d57041d316 | ||
|
|
b02f27f559 | ||
|
|
6398542845 | ||
|
|
e79275a3ab | ||
|
|
f9ab3b1561 | ||
|
|
1f4e2aeba5 | ||
|
|
cdae34a706 | ||
|
|
285e61573e | ||
|
|
5edaf3ee1e | ||
|
|
9ec2349cd6 | ||
|
|
829325c9c7 | ||
|
|
839a4d0e69 | ||
|
|
ed6e43ae51 | ||
|
|
2fbfc884b9 | ||
|
|
a517331d1b | ||
|
|
01c3e72c8f | ||
|
|
641b0c376e | ||
|
|
2ab6575e4b | ||
|
|
94bfc53b90 | ||
|
|
0defa85f58 | ||
|
|
9ffeb4d2d8 | ||
|
|
6cf50920df | ||
|
|
60e80c3e72 | ||
|
|
24867524a1 | ||
|
|
f3816bfc3e | ||
|
|
084f683e19 | ||
|
|
62f940f6ef | ||
|
|
bc15f0b07d | ||
|
|
b205db0ea9 | ||
|
|
bb2b64bd78 | ||
|
|
2cdb435182 | ||
|
|
500d59bdae | ||
|
|
3e1ddd8132 | ||
|
|
28e57f62c2 | ||
|
|
3d9d3ecb85 | ||
|
|
209b804423 | ||
|
|
20df900188 | ||
|
|
c4891d8f60 | ||
|
|
793ca10d4d | ||
|
|
2e96106c84 | ||
|
|
ceb0570c86 | ||
|
|
877d29b2b4 | ||
|
|
fa6550a232 | ||
|
|
838f237ef5 | ||
|
|
5276768e12 | ||
|
|
44c3a1c32e | ||
|
|
b752178eb4 | ||
|
|
af07ef403c | ||
|
|
06f4c25631 | ||
|
|
6a73be3e49 | ||
|
|
e10496afe7 | ||
|
|
d576487886 | ||
|
|
86f9018f73 | ||
|
|
7ea90de12b | ||
|
|
f4dbac6fc2 | ||
|
|
d3c17791b7 | ||
|
|
7534d7d178 | ||
|
|
9295914238 | ||
|
|
8441ce392d | ||
|
|
3cb7dfbd8b | ||
|
|
bb7f4a02ef | ||
|
|
1cf2d12bc7 | ||
|
|
0f6b1e69cd | ||
|
|
10f990e0e7 | ||
|
|
fec6a475a2 | ||
|
|
7329852497 | ||
|
|
c56265824b | ||
|
|
58a8504a47 | ||
|
|
0a7997c92d | ||
|
|
cf21e199bc | ||
|
|
b5fc43d208 | ||
|
|
6dc6af6359 | ||
|
|
06d9629075 | ||
|
|
7d7346305d | ||
|
|
fb7b6db1b1 | ||
|
|
f0a8895b78 | ||
|
|
e0e2e1e7b5 | ||
|
|
39d42119b2 | ||
|
|
66438836de | ||
|
|
3ad055d4a8 | ||
|
|
dcf9c13455 | ||
|
|
425c7b8e20 | ||
|
|
d61de6f3f7 | ||
|
|
cc40c23ea8 | ||
|
|
4aeff39756 | ||
|
|
0e597c5329 | ||
|
|
4428ef6c42 | ||
|
|
41e61fc148 | ||
|
|
b0cb67a079 | ||
|
|
553d9e4759 | ||
|
|
c4bee49fc3 | ||
|
|
c76c16b06d | ||
|
|
45c2d54d2b | ||
|
|
edb1c7659c | ||
|
|
a35eb4f288 | ||
|
|
0f88ec4694 | ||
|
|
db50b84bfd | ||
|
|
9440bdc29d | ||
|
|
d19a5802ae | ||
|
|
fb9a47ea0c | ||
|
|
58d2d70c13 | ||
|
|
ab5db71f35 | ||
|
|
b3d3a8e8b8 | ||
|
|
27a35a1db8 | ||
|
|
725131e128 | ||
|
|
421c21345e | ||
|
|
0f9a3b86b8 | ||
|
|
a639dc6e41 | ||
|
|
3fffa02bec | ||
|
|
6ad62360c6 | ||
|
|
a28ef8f8d7 | ||
|
|
1710ad8612 | ||
|
|
52010c9733 | ||
|
|
b47b1e9657 | ||
|
|
3085cede28 | ||
|
|
2f733f668d | ||
|
|
99f290fbca | ||
|
|
b1b7f64996 | ||
|
|
2695bce835 | ||
|
|
7984b3a75e | ||
|
|
4dc5bf6812 | ||
|
|
a1425e851b | ||
|
|
7407129812 | ||
|
|
a2457d6402 | ||
|
|
2729a8c14c | ||
|
|
aeea6744bd | ||
|
|
a9ca68d790 | ||
|
|
9fbc61ee6e | ||
|
|
abab85ca5c | ||
|
|
0427b75b6e | ||
|
|
4665e014be | ||
|
|
567ea7bb89 | ||
|
|
dd3c89e88a | ||
|
|
301de51add | ||
|
|
119e8e5a35 | ||
|
|
532fbda801 | ||
|
|
fcd8ed13ce | ||
|
|
a80b0a90fc | ||
|
|
4df191b9f4 | ||
|
|
0d3a3451f3 | ||
|
|
65e18be92a | ||
|
|
13fe204d30 | ||
|
|
ae33c256a7 | ||
|
|
ef215cb4fc | ||
|
|
ac80a9ab18 | ||
|
|
82d6da48e1 | ||
|
|
7b93fbc5a3 | ||
|
|
41e303e6bc | ||
|
|
b2e9dcadb9 | ||
|
|
aab783384f | ||
|
|
5cf7e15219 | ||
|
|
bd906005e4 | ||
|
|
dc01d943f3 | ||
|
|
be673b2135 | ||
|
|
7d1bab89b9 | ||
|
|
00d6bb0e37 | ||
|
|
b9f9f4ce9c | ||
|
|
be063ad5b4 | ||
|
|
fac3b18201 | ||
|
|
7b4a4d2b9c | ||
|
|
35a9393537 | ||
|
|
a265b08ca0 | ||
|
|
3fd1e8ae96 | ||
|
|
a8804547e7 | ||
|
|
1c5526c56c | ||
|
|
14845f47ac | ||
|
|
e9fffa36ce | ||
|
|
f260e2ff76 | ||
|
|
1159b9cba6 | ||
|
|
ab159d3a93 | ||
|
|
1d29c30900 | ||
|
|
7dc7caee26 | ||
|
|
69cc29b5e5 | ||
|
|
8c32ac707e | ||
|
|
db7c102da1 | ||
|
|
5160368407 | ||
|
|
b79c0da808 | ||
|
|
fade38e7a0 | ||
|
|
f22d32f056 | ||
|
|
02c772f321 | ||
|
|
1b319ca8ae | ||
|
|
b5c90a1e81 | ||
|
|
596db0442f | ||
|
|
23b68d845b | ||
|
|
2c8d676e25 | ||
|
|
851df070e4 | ||
|
|
a6a2b31c43 | ||
|
|
7b7810cc59 | ||
|
|
32775ef83f | ||
|
|
2ab0b13eb6 | ||
|
|
fec536ef16 | ||
|
|
39d19cf2f8 | ||
|
|
f00f48c40c | ||
|
|
508fcbdbe8 | ||
|
|
c9c1a50103 | ||
|
|
e4e4de319d | ||
|
|
bcfee45bd7 | ||
|
|
203737cc3f | ||
|
|
102ad7b77b | ||
|
|
7ce02d642a | ||
|
|
e2497adf0a | ||
|
|
d507d04825 | ||
|
|
4fe8d2b72b | ||
|
|
c703a09967 | ||
|
|
29e046f7f7 | ||
|
|
611c4a2949 | ||
|
|
0b6f4b3b9e | ||
|
|
e108887c4d | ||
|
|
11167e9a65 | ||
|
|
4be7a52fd8 | ||
|
|
c8ee4d1521 | ||
|
|
8b5f418ffc | ||
|
|
72afc73065 | ||
|
|
7c63e98f5a | ||
|
|
5eb3e971bb | ||
|
|
7d96450bc0 | ||
|
|
85c3595cce | ||
|
|
bf3377e7e0 | ||
|
|
f4542c73e3 | ||
|
|
d884f80e19 | ||
|
|
0eb6254085 | ||
|
|
b1efa699f0 | ||
|
|
a2a1ab7e11 | ||
|
|
562de15429 | ||
|
|
6d43c65dc6 | ||
|
|
c91eb2f644 | ||
|
|
ddbaf7d44f | ||
|
|
c2dfad5160 | ||
|
|
968cda9b27 | ||
|
|
dbf645ebc4 | ||
|
|
65597de00d | ||
|
|
93aa6d9b42 | ||
|
|
05d65652bd | ||
|
|
5aad6a3760 | ||
|
|
28008da922 | ||
|
|
647f76333d | ||
|
|
58b41f5d36 | ||
|
|
046a505615 | ||
|
|
e7de57623c | ||
|
|
157a558bbd | ||
|
|
6d2723e6f2 | ||
|
|
461df550a1 | ||
|
|
0ba479c7c9 | ||
|
|
c862827ac2 | ||
|
|
0c924139b1 | ||
|
|
e9cd9c5946 | ||
|
|
3d9aa306dc | ||
|
|
a1dbc2ea34 | ||
|
|
b9e0e39402 | ||
|
|
9c2177600c | ||
|
|
1588902dd3 | ||
|
|
26c91fcc38 | ||
|
|
cdd9e0c8b3 | ||
|
|
acdf3edb3d | ||
|
|
d13e4dffb8 | ||
|
|
5cfd3a8d85 | ||
|
|
d9f99af1cb | ||
|
|
c973bf0ab3 | ||
|
|
49e983e804 | ||
|
|
6d771855ee | ||
|
|
54180321be | ||
|
|
0db4ea9496 | ||
|
|
1e28081b31 | ||
|
|
f63912d874 | ||
|
|
6135a680fe | ||
|
|
cf03eebb72 | ||
|
|
d6a7a8c3df | ||
|
|
8f5ad0dae9 | ||
|
|
3f8004da48 | ||
|
|
9e55925169 | ||
|
|
9e1950c960 | ||
|
|
b5e27e68e6 | ||
|
|
069d6d05a2 | ||
|
|
75dd3ff970 | ||
|
|
0729e4ea1d | ||
|
|
9cb1b3cb3a | ||
|
|
36817676ea | ||
|
|
2a8781d970 | ||
|
|
ed005bae14 | ||
|
|
3c4ec0b79b | ||
|
|
0339529f44 | ||
|
|
fb3a656db5 | ||
|
|
9402594eb8 | ||
|
|
da1b9f0e93 | ||
|
|
b3ceb4a8f7 | ||
|
|
69820fe1cb | ||
|
|
f4a38c23f6 | ||
|
|
c0c4fe2622 | ||
|
|
55bfec30f5 | ||
|
|
e7b60c0c4c | ||
|
|
208cd224ad | ||
|
|
baeca605f6 | ||
|
|
c2ea985546 | ||
|
|
4d87f8fef2 | ||
|
|
58e84afebd | ||
|
|
ab85ce2f79 | ||
|
|
41dc3dbeea | ||
|
|
ce7549cef1 | ||
|
|
f0c8ce3137 |
+57
-12
@@ -1,26 +1,71 @@
|
|||||||
# SSH connection
|
# .env.example — template for the project's environment files.
|
||||||
REMOTE_USER=root
|
#
|
||||||
|
# There are TWO kinds of env file, loaded by the Makefile in this order:
|
||||||
|
#
|
||||||
|
# .env → LOCAL / shared config. ALWAYS loaded. NO remote credentials.
|
||||||
|
# Used by local targets (docker compose ${UID}/${GID} + the
|
||||||
|
# travel-memories env_file, and `make test-post` / `make test`).
|
||||||
|
# Copy the LOCAL section below into it.
|
||||||
|
#
|
||||||
|
# .env.test → REMOTE config for the test environment.
|
||||||
|
# .env.prod → REMOTE config for production.
|
||||||
|
# Loaded only when a remote target sets ENV, e.g.
|
||||||
|
# `make remote-install-prod`. Copy the REMOTE section below into
|
||||||
|
# each, with the values for that environment.
|
||||||
|
#
|
||||||
|
# .env, .env.test and .env.prod are all gitignored — never commit real values.
|
||||||
|
# This .example file is the only one that IS committed; keep its values as
|
||||||
|
# placeholders.
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# LOCAL → copy into .env
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Host user/group id for container file ownership (docker-compose ${UID}:${GID}).
|
||||||
|
# Match your local user: run `id -u` / `id -g` (usually 1000 on a single-user box).
|
||||||
|
UID=1000
|
||||||
|
GID=1000
|
||||||
|
|
||||||
|
# Local Grav dev server. GRAV_BASE_URL is used by the Playwright suite and
|
||||||
|
# scripts/test-post.sh.
|
||||||
|
GRAV_BASE_URL=http://localhost:8081
|
||||||
|
# Test login for `make test` — OPTIONAL. If unset, the suite auto-creates and
|
||||||
|
# uses a dedicated local-only account (testrunner / Testpass1234), gitignored so
|
||||||
|
# it is never pushed to prod (see `make test-account`). Override only to test as
|
||||||
|
# a different account; keep the password free of shell/Make/URL-special chars.
|
||||||
|
# GRAV_TEST_USER=testrunner
|
||||||
|
# GRAV_TEST_PASS=Testpass1234
|
||||||
|
GRAV_USER_DIR=/absolute/path/to/travel-blog-intotheeast/user
|
||||||
|
|
||||||
|
# travel-memories service (docker-compose `env_file: .env`). Fill in whatever
|
||||||
|
# that Flask app needs — e.g. its Immich connection. Leave commented until set.
|
||||||
|
# IMMICH_URL=
|
||||||
|
# IMMICH_API_KEY=
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# REMOTE → copy into .env.test AND .env.prod (with per-env values)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# SSH connection to the target server.
|
||||||
|
REMOTE_USER=deploy
|
||||||
REMOTE_HOST=example.com
|
REMOTE_HOST=example.com
|
||||||
REMOTE_PORT=22
|
REMOTE_PORT=22
|
||||||
REMOTE_HOME=/home/example.com
|
REMOTE_HOME=/home/example.com
|
||||||
|
|
||||||
# Server paths (override here if your setup differs from the Makefile defaults)
|
# Server paths. Optional — default to $(REMOTE_HOME)/public_html and
|
||||||
|
# $(REMOTE_HOME)/site-config. Set explicitly only if the layout differs
|
||||||
|
# (e.g. a per-domain webroot like /home/deploy/domains/test.example.com/public_html).
|
||||||
WEBROOT=/home/example.com/public_html
|
WEBROOT=/home/example.com/public_html
|
||||||
SITE_CONFIG_DIR=/home/example.com/site-config
|
SITE_CONFIG_DIR=/home/example.com/site-config
|
||||||
|
|
||||||
# Grav
|
# Grav version installed by scripts/server-install.sh (remote-install).
|
||||||
GRAV_VERSION=1.7.53
|
GRAV_VERSION=2.0.7
|
||||||
|
|
||||||
# Repos
|
# Repos cloned/pulled on the server.
|
||||||
USER_REPO=https://gitea.example.com/org/intotheeast-user.git
|
USER_REPO=https://gitea.example.com/org/intotheeast-user.git
|
||||||
MAIN_REPO=https://gitea.example.com/org/travel-blog-intotheeast.git
|
MAIN_REPO=https://gitea.example.com/org/travel-blog-intotheeast.git
|
||||||
|
|
||||||
# Gitea credentials — never commit these; only ever in .env (local) or ~/.env-project (server, temporary)
|
# Gitea credentials used by remote-install / remote-env-setup.
|
||||||
GITEA_HOST=gitea.example.com
|
GITEA_HOST=gitea.example.com
|
||||||
GITEA_USER=deploy-user
|
GITEA_USER=deploy-user
|
||||||
GITEA_TOKEN=your-gitea-personal-access-token
|
GITEA_TOKEN=your-gitea-personal-access-token
|
||||||
|
|
||||||
# Test credentials — used by 'make test-post' (must be a valid Grav site login user)
|
|
||||||
GRAV_TEST_USER=mischa
|
|
||||||
GRAV_TEST_PASS=your-grav-password
|
|
||||||
GRAV_BASE_URL=http://localhost:8081
|
|
||||||
|
|||||||
+12
@@ -1,5 +1,9 @@
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
|
.env.test
|
||||||
|
# Per-worktree dev-server identity, written by `make worktree-new`
|
||||||
|
.worktree-env
|
||||||
|
|
||||||
# Grav CMS
|
# Grav CMS
|
||||||
/user/
|
/user/
|
||||||
@@ -18,6 +22,14 @@ node_modules/
|
|||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
tests/.auth/
|
tests/.auth/
|
||||||
|
user/pages/**/ui-test-trip/
|
||||||
|
|
||||||
|
# Services (moved to separate projects)
|
||||||
|
services/
|
||||||
|
|
||||||
|
# travel-memories state
|
||||||
|
docs/immich-workflow/*.json
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
tests/ui/.auth/
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "user"]
|
||||||
|
path = user
|
||||||
|
url = ssh://git@m038-nas.tail63ee39.ts.net:222/m038/intotheeast-com-content.git
|
||||||
|
branch = main
|
||||||
@@ -1,198 +1,77 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## 0. Project specifics
|
Rules, gotchas, and entry points — the things that must change what you do *before* you open a file. Everything descriptive lives next to the code:
|
||||||
|
|
||||||
**Only ever write changes in this folder (travel-blog-intotheeast/) or its subfolders.**
|
| Need | Read |
|
||||||
|
|---|---|
|
||||||
|
| 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, 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) |
|
||||||
|
|
||||||
### Folder explanation
|
The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||||
|
|
||||||
- **./**: Grav CMS dev environment for intotheeast travel blog
|
## Hard rules
|
||||||
- **scripts/**: Server install and maintenance scripts
|
|
||||||
- **user/**: Site content, config, pages, and theme (standalone git repo — do not modify from here)
|
|
||||||
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
|
|
||||||
|
|
||||||
### Current stack
|
- **Only ever write inside `travel-blog-intotheeast/`** or its subfolders.
|
||||||
|
- **Never read `.env`, `.env.prod`, `.env.test`** — they hold credentials. Pass them to commands (`make`, `docker compose`) but never read them; ask the user if you need a value.
|
||||||
|
- **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 **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.
|
||||||
|
|
||||||
- **Grav:** 2.0.0-rc.9 (installed manually — see §3 below)
|
## Dev environment
|
||||||
- **Admin:** Admin2 v2.0.0-rc.15 (plugin slug: `admin2`, NOT `admin`)
|
|
||||||
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=beta`
|
|
||||||
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
|
|
||||||
|
|
||||||
### Dev server
|
- 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`.
|
||||||
|
- `plugins.txt` is maintained by hand — installing a plugin via Admin does **not** update it. `git-sync` is **remote-only** and must never appear in it.
|
||||||
|
- Everything under `user/plugins/` is git-ignored and gets overwritten by `make install-plugins` — **except** the three site-owned plugins (`cache-on-save`, `story-blocks`, `entry-actions`). So a fix to a third-party plugin must be a tracked patch in `deploy/patches/`, never an in-place edit: [`deploy/patches/README.md`](deploy/patches/README.md).
|
||||||
|
|
||||||
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
|
## Content and trips
|
||||||
|
|
||||||
### Trip entity architecture
|
- The active trip lives in **one** place: `user/config/site.yaml` → `active_trip`, and its value is a **route** (`/trips/denmark-2026`), not a bare slug.
|
||||||
|
- `cache-on-save` derives the post write target from `active_trip` at submit time. **Never re-add a `pageconfig.parent` to `post-form.md`** — a static parent would override it and reintroduce the old silent-desync bug. Switching trips: [`docs/guides/trip-switching.md`](docs/guides/trip-switching.md).
|
||||||
|
- The standalone `/dailies`, `/map`, `/stats` and `/stories` trip views were **deleted** (2026-07-04) — map, stats, and filtering all render inline on the trip page. Do not re-create them or link to them. `01.dailies/` and `04.stories/` are `routable:false` data containers whose children are aggregated by the trip page.
|
||||||
|
- GPX routes are page media on the trip page, auto-detected — no manual linking. Manage them at `/gpx-manager` (admin login): [`docs/guides/gpx-manager.md`](docs/guides/gpx-manager.md).
|
||||||
|
- `make content-push` commits and pushes `user/` to Gitea, which triggers the production pull; `make content-pull` is the reverse.
|
||||||
|
|
||||||
The site is structured around Trip entities. Key facts:
|
## Two shared partials — the rules
|
||||||
- Active trip is set in `user/config/site.yaml` → `active_trip: japan-korea-2026`
|
|
||||||
- Trip pages live at `user/pages/01.trips/<slug>/`
|
|
||||||
- Each trip has: `01.dailies/`, `02.map/`, `03.stats/`, `04.stories/`
|
|
||||||
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
|
|
||||||
- Post form parent (`post-form.md` → `pageconfig.parent`) **must be kept in sync** with `active_trip`
|
|
||||||
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories) — do NOT add nav links back to `/dailies`, `/stats`, `/stories` on the trip page
|
|
||||||
- Stats are shown inline on the trip page via a toggle; the standalone `/stats` sub-page still exists as a URL but is not linked from the trip page
|
|
||||||
- GPX route files live as media on the trip page itself, served via leaflet-gpx CDN
|
|
||||||
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
|
|
||||||
|
|
||||||
### GPX file management
|
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:
|
||||||
|
|
||||||
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `map.html.twig` via `trip_page.media.all`.
|
- **`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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
|
## Dual-repo submodule structure
|
||||||
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
|
|
||||||
- **Template:** `user/themes/intotheeast/templates/gpx-manager.html.twig`
|
|
||||||
- **API:** uses Grav API v1 with session cookie auth (`session_enabled: true` in `user/plugins/api/api.yaml`)
|
|
||||||
- List: `GET /api/v1/pages{route}/media`
|
|
||||||
- Upload: `POST /api/v1/pages{route}/media` (multipart)
|
|
||||||
- Delete: `DELETE /api/v1/pages{route}/media/{filename}`
|
|
||||||
- **Slugification:** filenames are slugified client-side before upload (spaces/special chars → hyphens, lowercase); the file is sliced to a plain `Blob` so the third argument to `FormData.append` is always used as the filename
|
|
||||||
- **Media type:** `.gpx` is registered in `user/config/media.yaml` so Grav serves and tracks these files
|
|
||||||
|
|
||||||
To add GPX files without the browser UI, drop them directly into `user/pages/01.trips/<slug>/` and run `make content-push`.
|
`user/` is a git submodule with its own Gitea remote and its own cadence; the outer repo pins an exact commit. Full workflow, worktree mechanics, teardown: [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
|
||||||
|
|
||||||
### Switching to a new trip
|
- **`M user` / `m user` is normal, not an error.** `M` = the pin differs from `user/` HEAD; `m` = the submodule working tree is dirty (e.g. a local-testing `site.yaml`). Do not "fix" either by committing the gitlink or that `site.yaml`.
|
||||||
|
- **Don't bump the pin for routine content changes.** Bump it once at the end of a cross-repo feature, to a commit reachable from `user/`'s published `main`, and **push `user/` before the outer repo**.
|
||||||
|
- **Use `make worktree-new NAME=<x>` / `make worktree-rm NAME=<x>`** — never a hand-rolled `git worktree add`. The targets initialise the submodule and an isolated dev server; skipping the deinit on teardown is what leaves orphaned `.worktrees/` dirs.
|
||||||
|
|
||||||
Two places hardcode the active trip slug. Grav's config and page frontmatter are static YAML — no variable substitution is possible, so these cannot read from `site.yaml` automatically. **Both must be updated together** when starting a new trip, or entries will be posted to the wrong folder.
|
## Testing
|
||||||
|
|
||||||
| File | Key | Example value |
|
`make test` runs everything (`test-config` → `test-post` → `test-ui`). **The dev server must be running** — every suite drives the live site over HTTP. Layout, helpers, and per-suite commands: [`docs/reference/testing.md`](docs/reference/testing.md).
|
||||||
|---|---|---|
|
|
||||||
| `user/config/site.yaml` | `active_trip` | `italy-2027` |
|
|
||||||
| `user/pages/02.post/post-form.md` | `pageconfig.parent` | `/trips/italy-2027/dailies` |
|
|
||||||
|
|
||||||
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
|
- **Auth is a dependency project.** `auth.setup.js` writes `tests/.auth/user.json`, which the `chromium` project reuses as `storageState`. Never add per-test logins.
|
||||||
|
- The `testrunner` admin account is created automatically and is git-ignored — never commit it, and keep its password free of shell/Make/URL-special characters, since several consumers interpolate it.
|
||||||
|
- `retries: 0`, so a failing test is a real failure, not flake.
|
||||||
|
|
||||||
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the standard four subfolders.
|
## Working docs
|
||||||
|
|
||||||
### Environment
|
Specs go in `docs/working/specs/YYYY-MM-DD-<topic>-design.md`, plans in `docs/working/plans/YYYY-MM-DD-<topic>.md`. These paths override the `docs/superpowers/` default used by the brainstorming and writing-plans skills.
|
||||||
|
|
||||||
**Never read `.env`** — it contains sensitive credentials. You may pass it to commands (e.g. `docker compose`, `make`) but never read its contents directly. Ask the user if you need environment-specific information.
|
Every plan needs a `**Status:**` line immediately after its title heading: `📋 Not started` · `🔄 In progress — <note>` · `⏸️ Deferred — <reason>` · `✅ Complete (YYYY-MM-DD)` · `❌ Abandoned — <reason>`.
|
||||||
|
|
||||||
### Remote operations
|
- **When asked what's open:** surface `Not started` and `In progress`; show `Deferred` but label it clearly; omit `Complete` and `Abandoned` unless explicitly asked.
|
||||||
|
- **When finishing a plan:** set its status to `✅ Complete (YYYY-MM-DD)` before closing the session — whether you executed it directly or via the executing-plans / subagent-driven-development skills.
|
||||||
Always use `make` commands for anything on the production server (`make remote-install-plugins`, `make remote-clean`, etc.) — never SSH directly since credentials live in `.env`. If a remote operation isn't covered by an existing `make` command, either ask the user to run it manually or suggest adding a new `make` command if it seems reusable.
|
|
||||||
|
|
||||||
### Content sync
|
|
||||||
|
|
||||||
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
|
|
||||||
- `make content-pull` — pull latest from Gitea to local
|
|
||||||
- `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it
|
|
||||||
- `make demo-load` — load demo entries for both trips (Japan/Korea 2026 + Italy 2025 with real GPX)
|
|
||||||
- `make demo-reset` — remove demo entries (keeps trip page structure, removes entries only)
|
|
||||||
|
|
||||||
### User repo gitignore
|
|
||||||
|
|
||||||
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
|
|
||||||
|
|
||||||
## 1. Environment modes
|
|
||||||
|
|
||||||
### Rule: do not switch modes during development
|
|
||||||
|
|
||||||
**Never toggle between development and production mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than temporarily flipping a mode flag to work around it. Mode switches introduce inconsistent state and make bugs harder to reproduce.
|
|
||||||
|
|
||||||
### Development mode (current)
|
|
||||||
|
|
||||||
Active settings in `user/config/system.yaml`:
|
|
||||||
|
|
||||||
| Setting | Dev value | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| `twig.cache` | `false` | Theme file edits take effect immediately; no stale compile errors |
|
|
||||||
|
|
||||||
With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file.
|
|
||||||
|
|
||||||
### Production mode (not yet configured)
|
|
||||||
|
|
||||||
Before going live, change in `user/config/system.yaml`:
|
|
||||||
|
|
||||||
| Setting | Prod value | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
|
|
||||||
|
|
||||||
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/trips/japan-korea-2026/dailies` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
|
|
||||||
|
|
||||||
### What the cache-on-save plugin handles
|
|
||||||
|
|
||||||
The custom plugin at `user/plugins/cache-on-save/` clears Grav's page-tree cache on every `new-entry` form submission. This ensures new posts appear in the tracker feed immediately in both modes — it does not depend on whether Twig caching is on or off.
|
|
||||||
|
|
||||||
## 2. Local development setup
|
|
||||||
|
|
||||||
### First-time setup after cloning
|
|
||||||
|
|
||||||
`user/plugins/` and `user/data/` are excluded from git but Grav requires them to exist. Create them once after cloning:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p user/plugins user/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run `make setup` (starts Docker + installs plugins).
|
|
||||||
|
|
||||||
### After make install-plugins: fix cache permissions
|
|
||||||
|
|
||||||
If the site returns a 500 error after plugin installation or after recreating the container,
|
|
||||||
run `make fix-perms`. This creates uid 1000 in the container, chowns `/var/www/html` to 1000:1000,
|
|
||||||
and reloads Apache. Always run `make setup` (not just `make start`) after `docker compose down && up`
|
|
||||||
to ensure permissions are correct.
|
|
||||||
|
|
||||||
### Grav 2.0 upgrade (local)
|
|
||||||
|
|
||||||
GPM (`php bin/gpm selfupgrade`) does **not** serve Grav 2.0 RC — it still reports 1.7.x as latest even on the `testing` channel. To upgrade locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download grav-admin bundle (includes Grav core + admin2 plugin)
|
|
||||||
docker exec -w /tmp intotheeast_grav bash -c "
|
|
||||||
curl -sL 'https://getgrav.org/download/core/grav-admin/2.0.0-rc.9?testing' -o grav-admin.zip && \
|
|
||||||
unzip -q grav-admin.zip
|
|
||||||
"
|
|
||||||
# Copy core files only (not user/)
|
|
||||||
docker exec -w /tmp intotheeast_grav bash -c "
|
|
||||||
cp -rf grav-admin/{assets,bin,system,vendor,webserver-configs,index.php,composer.json,composer.lock,robots.txt,CHANGELOG.md,LICENSE.txt} /var/www/html/
|
|
||||||
"
|
|
||||||
# Install Admin2 from the bundle (it's named admin2, not admin)
|
|
||||||
docker exec -w /tmp intotheeast_grav bash -c "
|
|
||||||
cp -rf grav-admin/user/plugins/admin2 /var/www/html/user/plugins/admin2
|
|
||||||
"
|
|
||||||
make fix-perms
|
|
||||||
docker exec -w /var/www/html intotheeast_grav php bin/grav cache --all
|
|
||||||
# Cleanup
|
|
||||||
docker exec intotheeast_grav rm -rf /tmp/grav-admin /tmp/grav-admin.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
After upgrading, ensure these settings in `user/config/system.yaml`:
|
|
||||||
```yaml
|
|
||||||
accounts:
|
|
||||||
type: flex # required for Admin2 API
|
|
||||||
pages:
|
|
||||||
type: flex # required for Admin2 pages API
|
|
||||||
```
|
|
||||||
|
|
||||||
And ensure the admin user account has `api.*` permissions (Admin2 uses a new permission namespace):
|
|
||||||
```yaml
|
|
||||||
# user/accounts/<username>.yaml
|
|
||||||
access:
|
|
||||||
admin:
|
|
||||||
login: true
|
|
||||||
super: true
|
|
||||||
api:
|
|
||||||
super: true
|
|
||||||
access: true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Disable the old `admin` plugin** once `admin2` is installed — both route to `/admin` and conflict:
|
|
||||||
```bash
|
|
||||||
# In user/plugins/admin/admin.yaml:
|
|
||||||
enabled: false
|
|
||||||
```
|
|
||||||
|
|
||||||
**JWT secret:** Leave `jwt_secret: ''` in `user/plugins/api/api.yaml` — it works for local dev and production installs generate a secure secret automatically.
|
|
||||||
|
|
||||||
### Language URL prefix
|
|
||||||
|
|
||||||
If Grav redirects to `/en/...` URLs, ensure `user/config/system.yaml` contains:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
languages:
|
|
||||||
supported: [en]
|
|
||||||
include_default_lang: false
|
|
||||||
```
|
|
||||||
|
|
||||||
Without `include_default_lang: false`, Grav adds a language prefix to all URLs even for single-language sites.
|
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# Concepts
|
||||||
|
|
||||||
|
Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
A **Trip** owns its **Entries** and **Stories**. Exactly one Trip is the **Active Trip** at a time; it is the one surfaced on the home page and the target for new posts. Entries and Stories are always scoped to a Trip — they do not exist independently.
|
||||||
|
|
||||||
|
## Trip
|
||||||
|
|
||||||
|
### Trip
|
||||||
|
A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages.
|
||||||
|
|
||||||
|
### Active Trip
|
||||||
|
The one Trip currently featured — set in a single site-config value and read by the home page and the posting pipeline, which derives the write target for new Entries from it at submit time. Switching the Active Trip is that one setting; there is no separate post-form target to keep in sync.
|
||||||
|
|
||||||
|
### Published / Draft
|
||||||
|
A Trip's visibility state. A **Published** Trip is listed publicly and reachable by anyone; a **Draft** Trip is hidden from anonymous visitors in the public trip list, while the signed-in owner still sees it (marked "Draft") and can flip it back. The owner toggles this per Trip from the trip list.
|
||||||
|
|
||||||
|
Unpublishing the **Active Trip** additionally drops it from the public home page, which falls back to its between-trips landing. The toggle is owner-only; a Draft is a visibility control, not privacy — a Draft Trip's Entries, Stories, and media stay reachable by direct link.
|
||||||
|
|
||||||
|
### Entry
|
||||||
|
A single dated journal post within a Trip — the atomic unit of the day-to-day travel log.
|
||||||
|
*Avoid:* daily, journal post
|
||||||
|
|
||||||
|
The Trip's journal section is labelled "Journal" and lives in the Trip's `dailies` container, so an Entry is colloquially "a daily"; in templates and page metadata the same thing is called an `entry`. Entries carry a date, optional location and coordinates, weather, and photos, and are ordered by date within a Trip.
|
||||||
|
|
||||||
|
### Story
|
||||||
|
A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log.
|
||||||
|
|
||||||
|
### Container
|
||||||
|
A Trip's non-routable holder of child pages — one for Entries, one for Stories. A Container's own URL is deliberately inert (it renders no page of its own), while its children stay individually reachable and are aggregated onto the Trip page. Retiring a view must never delete its Container: the folder half is load-bearing data even when the page half is gone.
|
||||||
|
|
||||||
|
## Repos & deployment
|
||||||
|
|
||||||
|
### Content repo
|
||||||
|
The repository holding everything the site serves — pages, configuration, accounts, the theme. It has its own remote and its own release cadence: pushing it triggers production to pull via webhook, independent of the Outer repo.
|
||||||
|
|
||||||
|
### Outer repo
|
||||||
|
The dev-environment repository — tests, docs, scripts, container build — that nests the Content repo and records a Pin to an exact Content-repo commit, expressing "this dev-env state expects this content/theme state."
|
||||||
|
|
||||||
|
### Pin
|
||||||
|
The Outer repo's recorded Content-repo commit (also "pointer bump" for the act of updating it). Routine content churn never moves it; it is bumped once at the end of a cross-repo feature, to a commit already published on the Content repo's main branch. A stale Pin during normal work is expected, not an error.
|
||||||
|
|
||||||
|
### Env tree
|
||||||
|
A server's per-host configuration overlay. Once it exists, Grav's Admin writes **all** config edits there rather than into the shared configuration — so server-side Admin edits are server-only, invisible to content sync, and can hold live secrets. Diagnosing config on a server means checking both the shared configuration and the Env tree, with the Env tree winning at runtime.
|
||||||
|
|
||||||
|
### Remote-only plugin
|
||||||
|
One of the project's three plugin-management categories, alongside GPM-managed (declared in the shared install list and restored by the standard install flow) and custom-in-repo (code tracked in the Content repo). A Remote-only plugin is installed explicitly on servers and restored by **no** standard flow — if its code goes missing it stays missing until someone reinstalls it deliberately, even while its configuration persists in the Env tree.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### Historical record
|
||||||
|
A document that states what was decided or built at a past moment, not what is true now — plans, specs, milestone scopes, and session write-ups. Its going out of date is expected and is what makes it a record; it is corrected only by annotation, never by rewriting, because the value is the reasoning at the time.
|
||||||
|
|
||||||
|
Distinguished from *current documentation*, which asserts how the system is today and is simply wrong when it drifts. A Historical record often reads in present tense, so the distinction is carried by an explicit marker rather than by tone.
|
||||||
|
|
||||||
|
### Superseded decision
|
||||||
|
Something the project planned or built and then deliberately reversed, recorded so the reversal is discoverable from the document that still describes the original. Each one names what was planned, what replaced it, when, and why.
|
||||||
|
|
||||||
|
The record exists because a reversal is otherwise invisible: the old document keeps asserting the old thing, and the reasoning that killed it lives only in whoever remembers. A Superseded decision is the standing answer to "may I re-create this?" — usually no, and often the prohibition is also a hard rule.
|
||||||
|
|
||||||
|
### Plan status
|
||||||
|
The single recorded state of a plan, carried on the plan itself rather than in a separate tracker. **Deferred** and **Abandoned** are deliberately distinct: Deferred means still wanted but not now, Abandoned means decided against, kept so the decision is not re-litigated.
|
||||||
|
|
||||||
|
A status that lags reality is worse than no status, because it is trusted — so it moves when the work lands, not later.
|
||||||
|
|
||||||
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
- "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity.
|
||||||
|
- A **Historical record** written in present tense is **not** a claim about the current system. Staleness there is correct; staleness in current documentation is a defect. When the two disagree, the code decides, and the gap is recorded as a **Superseded decision**.
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM getgrav/grav
|
||||||
|
|
||||||
|
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.7/grav-admin-v2.0.7.zip' \
|
||||||
|
-o /tmp/grav-admin.zip \
|
||||||
|
&& unzip -q /tmp/grav-admin.zip -d /tmp \
|
||||||
|
&& cp -rf /tmp/grav-admin/assets /var/www/html/ \
|
||||||
|
&& cp -rf /tmp/grav-admin/bin /var/www/html/ \
|
||||||
|
&& cp -rf /tmp/grav-admin/system /var/www/html/ \
|
||||||
|
&& cp -rf /tmp/grav-admin/vendor /var/www/html/ \
|
||||||
|
&& cp -rf /tmp/grav-admin/webserver-configs /var/www/html/ \
|
||||||
|
&& cp -f /tmp/grav-admin/index.php /var/www/html/ \
|
||||||
|
&& cp -f /tmp/grav-admin/composer.json /var/www/html/ \
|
||||||
|
&& cp -f /tmp/grav-admin/composer.lock /var/www/html/ \
|
||||||
|
&& cp -f /tmp/grav-admin/CHANGELOG.md /var/www/html/ \
|
||||||
|
&& cp -f /tmp/grav-admin/LICENSE.txt /var/www/html/ \
|
||||||
|
&& cp -f /tmp/grav-admin/webserver-configs/htaccess.txt /var/www/html/.htaccess \
|
||||||
|
&& rm -rf /tmp/grav-admin /tmp/grav-admin.zip \
|
||||||
|
&& mkdir -p /var/www/html/logs /var/www/html/images /var/www/html/backup
|
||||||
@@ -1,67 +1,247 @@
|
|||||||
|
# Local/shared config — always loaded. Keep remote credentials OUT of here;
|
||||||
|
# those live in .env.test / .env.prod. (docker compose also reads .env directly
|
||||||
|
# for ${UID}/${GID} substitution and the travel-memories env_file.)
|
||||||
-include .env
|
-include .env
|
||||||
|
|
||||||
|
# Per-worktree dev-server identity, written by `make worktree-new` into the new
|
||||||
|
# worktree only (git-ignored). Absent in the main checkout, so the defaults below
|
||||||
|
# apply there. Loaded here so every local target + compose call in a worktree
|
||||||
|
# targets that worktree's own container and ports.
|
||||||
|
-include .worktree-env
|
||||||
|
|
||||||
|
# Remote config — loaded only when targeting an environment. ENV is set
|
||||||
|
# automatically by the env-suffixed remote targets (e.g. `make remote-install-prod`);
|
||||||
|
# each .env.<ENV> holds a full, self-contained set of remote vars.
|
||||||
|
ENV ?=
|
||||||
|
-include .env.$(ENV)
|
||||||
export
|
export
|
||||||
|
|
||||||
REMOTE_PORT ?= 22
|
REMOTE_PORT ?= 22
|
||||||
SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST)
|
SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST)
|
||||||
WEBROOT ?= $(REMOTE_HOME)/public_html
|
WEBROOT ?= $(REMOTE_HOME)/public_html
|
||||||
SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
|
SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
|
||||||
|
# Hostname Grav uses to pick its per-environment config (user/env/<host>/).
|
||||||
|
# Defaults to the SSH host; override in .env.<ENV> only if the web hostname
|
||||||
|
# Grav sees differs from the SSH host (e.g. an addon domain on a shared box).
|
||||||
|
WEB_HOST ?= $(REMOTE_HOST)
|
||||||
|
|
||||||
|
# ── Environment guard + generated per-env remote targets ──────────────────────
|
||||||
|
# Every remote-* target below gains `-test` / `-prod` variants, e.g.
|
||||||
|
# make remote-install-prod → runs remote-install with ENV=prod
|
||||||
|
# Calling a bare remote target (no ENV) fails via guard-env.
|
||||||
|
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
|
||||||
|
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
|
||||||
|
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
|
||||||
|
remote-content-status remote-clean remote-warmup remote-diag remote-apply-env \
|
||||||
|
remote-seed-api-salt remote-secrets-audit \
|
||||||
|
remote-gpm-install remote-maintenance-on remote-maintenance-off \
|
||||||
|
remote-apply-plugin-patches
|
||||||
|
ENVS := test prod
|
||||||
|
|
||||||
|
guard-env:
|
||||||
|
@test -n "$(ENV)" || { echo "ERROR: no environment. Use an env-suffixed target, e.g. 'make remote-install-prod'."; exit 1; }
|
||||||
|
@test -f ".env.$(ENV)" || { echo "ERROR: missing .env.$(ENV)"; exit 1; }
|
||||||
|
|
||||||
|
define make-env-target
|
||||||
|
$(1)-$(2): ; @$$(MAKE) --no-print-directory $(1) ENV=$(2)
|
||||||
|
endef
|
||||||
|
$(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,$(t),$(e)))))
|
||||||
|
|
||||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Local test account — auto-created, never committed (see user/.gitignore).
|
||||||
|
# Keep the password free of shell/Make/URL-special chars so every consumer agrees.
|
||||||
|
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" \
|
||||||
|
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
||||||
|
|
||||||
test-config:
|
test-config:
|
||||||
@bash scripts/test-form-config.sh
|
@bash scripts/test-form-config.sh
|
||||||
|
|
||||||
test-post:
|
test-post: test-account
|
||||||
@bash scripts/test-post.sh
|
@bash scripts/test-post.sh
|
||||||
|
|
||||||
test-ui:
|
# 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
|
@npx playwright test
|
||||||
|
|
||||||
test: test-config test-post test-ui
|
test: test-config test-post test-ui
|
||||||
|
|
||||||
# ── Local dev ──────────────────────────────────────────────────────────────────
|
# ── Local dev ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Dev-server identity. Defaults are the main checkout's canonical values; a
|
||||||
|
# worktree's .worktree-env (above) overrides them so servers never collide.
|
||||||
|
# Exported (via the top-of-file `export`) so `docker compose` picks them up.
|
||||||
|
GRAV_CONTAINER ?= intotheeast_grav
|
||||||
|
GRAV_PORT ?= 8081
|
||||||
|
TM_PORT ?= 8082
|
||||||
|
|
||||||
|
# The container boots as root (the base image entrypoint needs it to bind :80
|
||||||
|
# and set up cron), so a bare `docker exec` runs as root and any file it writes
|
||||||
|
# into the ./user bind mount is root-owned on the host. Run the file-CREATING
|
||||||
|
# CLI commands as the host user instead, so their output belongs to you.
|
||||||
|
HOST_UID := $(shell id -u)
|
||||||
|
HOST_GID := $(shell id -g)
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
build-assets:
|
||||||
|
# --user: outputs (node_modules, js/ bundles, css-compiled/) land in the
|
||||||
|
# tracked theme tree owned by the host user, not root. HOME=/tmp gives npm
|
||||||
|
# a writable cache when running as a non-root uid.
|
||||||
|
docker run --rm --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
|
||||||
|
-v $(PWD)/user/themes/intotheeast:/app \
|
||||||
|
-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:
|
start:
|
||||||
docker compose up -d
|
@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
|
||||||
|
|
||||||
|
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
||||||
|
# travel-memories service, and this keeps its footprint minimal).
|
||||||
|
start-grav:
|
||||||
|
docker compose up -d grav
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
setup: start install-plugins fix-perms
|
setup: build start install-plugins fix-perms
|
||||||
|
|
||||||
fix-perms:
|
fix-perms:
|
||||||
docker exec intotheeast_grav bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser"
|
docker exec $(GRAV_CONTAINER) bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser"
|
||||||
docker exec intotheeast_grav chown -R 1000:1000 /var/www/html
|
docker exec $(GRAV_CONTAINER) chown -R 1000:1000 /var/www/html
|
||||||
docker exec intotheeast_grav apachectl graceful
|
docker exec $(GRAV_CONTAINER) apachectl graceful
|
||||||
|
|
||||||
|
|
||||||
install-plugins:
|
install-plugins:
|
||||||
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
# cache/ and tmp/ are root-owned in the image, so make them writable first
|
||||||
|
# (container-internal chown — never touches the host) so gpm can run AS YOU.
|
||||||
|
docker exec $(GRAV_CONTAINER) chown -R $(HOST_UID):$(HOST_GID) /var/www/html/cache /var/www/html/tmp
|
||||||
|
# gpm runs as the host user, so the plugins it writes into ./user/plugins are
|
||||||
|
# owned by you, not root — no post-hoc chown, no root files to clean up later.
|
||||||
|
docker exec -u $(HOST_UID):$(HOST_GID) -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||||
|
$(MAKE) apply-plugin-patches
|
||||||
|
|
||||||
|
# Re-apply local fixes to git-ignored, GPM-managed third-party plugins. Run this
|
||||||
|
# AFTER install-plugins (which overwrites them). See deploy/patches/README.md.
|
||||||
|
apply-plugin-patches:
|
||||||
|
@for p in deploy/patches/*.patch; do \
|
||||||
|
[ -f "$$p" ] || continue; \
|
||||||
|
if git apply --check "$$p" >/dev/null 2>&1; then \
|
||||||
|
git apply "$$p" && echo "applied $$p"; \
|
||||||
|
else \
|
||||||
|
echo "skipped $$p (already applied or does not match)"; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Worktrees ─────────────────────────────────────────────────────────────────
|
||||||
|
# Isolated outer-repo worktree + its own user/ submodule checkout + its own dev
|
||||||
|
# server (distinct container name & ports), for long-running feature work that
|
||||||
|
# runs in parallel with the main checkout without collisions. Encodes the full
|
||||||
|
# SOP from docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md
|
||||||
|
# so no step (submodule init, per-server isolation, clean teardown) is skipped.
|
||||||
|
#
|
||||||
|
# make worktree-new NAME=my-feature [PORT=8090] # create branch + start server
|
||||||
|
# make worktree-rm NAME=my-feature # tear down cleanly
|
||||||
|
#
|
||||||
|
# Run both from the MAIN checkout. After worktree-new, `cd .worktrees/<name>`
|
||||||
|
# and use make as normal — it targets that worktree's own server automatically.
|
||||||
|
|
||||||
|
WT_DIR = .worktrees/$(NAME)
|
||||||
|
|
||||||
|
guard-name:
|
||||||
|
@test -n "$(NAME)" || { echo "ERROR: set NAME=, e.g. 'make worktree-new NAME=my-feature'."; exit 1; }
|
||||||
|
|
||||||
|
worktree-new: guard-name
|
||||||
|
@test ! -e "$(WT_DIR)" || { echo "ERROR: $(WT_DIR) already exists."; exit 1; }
|
||||||
|
git worktree add "$(WT_DIR)" -b feat/$(NAME) main
|
||||||
|
git -C "$(WT_DIR)" submodule update --init user
|
||||||
|
git -C "$(WT_DIR)/user" checkout -b feat/$(NAME)
|
||||||
|
@port=$${PORT:-$$(for p in $$(seq 8090 8099); do \
|
||||||
|
docker ps --format '{{.Ports}}' | grep -q ":$$p->" || { echo $$p; break; }; \
|
||||||
|
done)}; \
|
||||||
|
test -n "$$port" || { echo "ERROR: no free port in 8090-8099; pass PORT= explicitly."; exit 1; }; \
|
||||||
|
printf 'COMPOSE_PROJECT_NAME=itte-%s\nGRAV_CONTAINER=itte_%s_grav\nGRAV_PORT=%s\nTM_PORT=%s\n' \
|
||||||
|
"$(NAME)" "$(NAME)" "$$port" "$$((port + 100))" > "$(WT_DIR)/.worktree-env"; \
|
||||||
|
echo "→ starting this worktree's Grav dev server on http://localhost:$$port"; \
|
||||||
|
$(MAKE) -C "$(WT_DIR)" start-grav
|
||||||
|
@echo "Worktree ready: $(WT_DIR) (outer + user/ on branch feat/$(NAME))"
|
||||||
|
|
||||||
|
worktree-rm: guard-name
|
||||||
|
@test -e "$(WT_DIR)" || { echo "ERROR: $(WT_DIR) does not exist."; exit 1; }
|
||||||
|
-$(MAKE) -C "$(WT_DIR)" stop
|
||||||
|
-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 ──────────────────────────────────────────────────────────────
|
# ── Demo content ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
demo-load:
|
demo-load:
|
||||||
# Load japan-korea-2026 dailies
|
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
||||||
cp -r user/docs/demo/trips/japan-korea-2026/dailies/. user/pages/01.trips/japan-korea-2026/01.dailies/
|
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
||||||
cp -r user/docs/demo/trips/japan-korea-2026/04.stories user/pages/01.trips/japan-korea-2026/ 2>/dev/null || true
|
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
||||||
# Load italy-2025 trip (create pages if absent)
|
#
|
||||||
mkdir -p user/pages/01.trips/italy-2025/01.dailies user/pages/01.trips/italy-2025/02.map user/pages/01.trips/italy-2025/03.stats user/pages/01.trips/italy-2025/04.stories
|
# ⚠️ A fixture whose folder name matches a REAL trip's slug is copied straight
|
||||||
cp user/docs/demo/trips/italy-2025/trip.md user/pages/01.trips/italy-2025/trip.md 2>/dev/null || true
|
# over that live page — docs/demo/trips/italy-2025/ collides with the real
|
||||||
cp user/docs/demo/trips/italy-2025/map.md user/pages/01.trips/italy-2025/02.map/map.md 2>/dev/null || true
|
# italy-2025 trip on purpose (the fixture supplies its GPX + dailies). So any
|
||||||
cp user/docs/demo/trips/italy-2025/stats.md user/pages/01.trips/italy-2025/03.stats/stats.md 2>/dev/null || true
|
# field the fixture's trip.md omits gets silently deleted from real content on
|
||||||
cp user/docs/demo/trips/italy-2025/stories.md user/pages/01.trips/italy-2025/04.stories/stories.md 2>/dev/null || true
|
# every test run: it had been dropping the trip's tagline that way. Keep a
|
||||||
cp -r user/docs/demo/trips/italy-2025/04.stories/. user/pages/01.trips/italy-2025/04.stories/ 2>/dev/null || true
|
# colliding fixture's trip.md byte-identical to the live page.
|
||||||
cp -r user/docs/demo/trips/italy-2025/dailies/. user/pages/01.trips/italy-2025/01.dailies/
|
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||||
cp user/docs/demo/trips/italy-2025/*.gpx user/pages/01.trips/italy-2025/ 2>/dev/null || true
|
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
||||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
||||||
|
cp "$$src/trip.md" "$$dst/trip.md" 2>/dev/null || true; \
|
||||||
|
cp "$$src/stories.md" "$$dst/04.stories/stories.md" 2>/dev/null || true; \
|
||||||
|
cp -r "$$src/04.stories/." "$$dst/04.stories/" 2>/dev/null || true; \
|
||||||
|
cp -r "$$src/dailies/." "$$dst/01.dailies/" 2>/dev/null || true; \
|
||||||
|
cp "$$src"/*.gpx "$$dst/" 2>/dev/null || true; \
|
||||||
|
chown -R 1000:1000 "$$dst"; \
|
||||||
|
done; cd /var/www/html && php bin/grav clearcache'
|
||||||
|
|
||||||
demo-reset:
|
demo-reset:
|
||||||
@for dir in user/docs/demo/trips/japan-korea-2026/dailies/*/; do \
|
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||||
folder=$$(basename "$$dir"); \
|
rm -rf /var/www/html/user/pages/01.trips/$$(basename "$$src"); \
|
||||||
rm -rf "user/pages/01.trips/japan-korea-2026/01.dailies/$$folder"; \
|
done; cd /var/www/html && php bin/grav clearcache'
|
||||||
done
|
|
||||||
rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates
|
pixelfed-import:
|
||||||
rm -rf user/pages/01.trips/italy-2025
|
docker exec $(GRAV_CONTAINER) bash -c "which python3 || apt-get install -y python3 --no-install-recommends -q"
|
||||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
docker cp /home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json $(GRAV_CONTAINER):/tmp/pixelfed-statuses.json
|
||||||
|
docker cp scripts/pixelfed-import.py $(GRAV_CONTAINER):/tmp/pixelfed-import.py
|
||||||
|
docker exec -w /var/www/html $(GRAV_CONTAINER) python3 /tmp/pixelfed-import.py
|
||||||
|
|
||||||
# ── Content sync (user repo ↔ Gitea) ──────────────────────────────────────────
|
# ── Content sync (user repo ↔ Gitea) ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -73,21 +253,21 @@ content-pull:
|
|||||||
|
|
||||||
# ── Remote credentials ─────────────────────────────────────────────────────────
|
# ── Remote credentials ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
remote-env-setup:
|
remote-env-setup: guard-env
|
||||||
@$(SSH) "printf 'GITEA_HOST=%s\nGITEA_USER=%s\nGITEA_TOKEN=%s\n' \
|
@$(SSH) "printf 'GITEA_HOST=%s\nGITEA_USER=%s\nGITEA_TOKEN=%s\n' \
|
||||||
'$(GITEA_HOST)' '$(GITEA_USER)' '$(GITEA_TOKEN)' > ~/.env-intotheeast && chmod 600 ~/.env-intotheeast"
|
'$(GITEA_HOST)' '$(GITEA_USER)' '$(GITEA_TOKEN)' > ~/.env-intotheeast && chmod 600 ~/.env-intotheeast"
|
||||||
@echo "Credentials written to server. Run 'make remote-env-remove' when done."
|
@echo "Credentials written to server. Run 'make remote-env-remove' when done."
|
||||||
|
|
||||||
remote-env-remove:
|
remote-env-remove: guard-env
|
||||||
@$(SSH) "rm -f ~/.env-intotheeast"
|
@$(SSH) "rm -f ~/.env-intotheeast"
|
||||||
@echo "Credentials removed from server."
|
@echo "Credentials removed from server."
|
||||||
|
|
||||||
# ── Remote: initial install ────────────────────────────────────────────────────
|
# ── Remote: initial install ────────────────────────────────────────────────────
|
||||||
|
|
||||||
remote-wipe:
|
remote-wipe: guard-env
|
||||||
$(SSH) "cd $(WEBROOT) && rm -rf assets backup bin cache images logs system tmp vendor webserver-configs index.php .htaccess CHANGELOG.md LICENSE.txt README.md"
|
$(SSH) "cd $(WEBROOT) && rm -rf assets backup bin cache images logs system tmp vendor webserver-configs index.php .htaccess CHANGELOG.md LICENSE.txt README.md"
|
||||||
|
|
||||||
remote-install:
|
remote-install: guard-env
|
||||||
$(SSH) "WEBROOT=$(WEBROOT) \
|
$(SSH) "WEBROOT=$(WEBROOT) \
|
||||||
SITE_CONFIG_DIR=$(SITE_CONFIG_DIR) \
|
SITE_CONFIG_DIR=$(SITE_CONFIG_DIR) \
|
||||||
USER_REPO=$(USER_REPO) \
|
USER_REPO=$(USER_REPO) \
|
||||||
@@ -101,20 +281,137 @@ remote-install:
|
|||||||
|
|
||||||
# ── Remote: ongoing maintenance ────────────────────────────────────────────────
|
# ── Remote: ongoing maintenance ────────────────────────────────────────────────
|
||||||
|
|
||||||
remote-fetch:
|
remote-fetch: guard-env
|
||||||
$(SSH) "git -C $(SITE_CONFIG_DIR) pull"
|
$(SSH) "git -C $(SITE_CONFIG_DIR) checkout main && git -C $(SITE_CONFIG_DIR) pull"
|
||||||
|
|
||||||
remote-install-plugins:
|
remote-fetch-content: guard-env
|
||||||
$(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
|
$(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main"
|
||||||
|
|
||||||
remote-upgrade-grav:
|
remote-install-plugins: guard-env
|
||||||
$(SSH) "cd $(WEBROOT) && php bin/grav upgrade"
|
$(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
|
||||||
|
$(MAKE) remote-apply-plugin-patches
|
||||||
|
|
||||||
remote-clean:
|
remote-update-plugins: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
|
||||||
|
$(MAKE) remote-apply-plugin-patches
|
||||||
|
|
||||||
|
# Re-apply local fixes to git-ignored, GPM-managed third-party plugins on the
|
||||||
|
# remote (pristine after a GPM install/update). Piped over SSH like the git-sync
|
||||||
|
# scripts — no scp. `--forward` makes it a no-op when already applied. Runs
|
||||||
|
# automatically after remote-install-plugins / remote-update-plugins; safe to run
|
||||||
|
# standalone. See deploy/patches/README.md.
|
||||||
|
remote-apply-plugin-patches: guard-env
|
||||||
|
@for p in deploy/patches/*.patch; do \
|
||||||
|
[ -f "$$p" ] || continue; \
|
||||||
|
echo "remote-apply $$p"; \
|
||||||
|
$(SSH) "cd $(WEBROOT) && patch -p1 --forward -r - --no-backup-if-mismatch" < "$$p" || echo " (already applied or no-op)"; \
|
||||||
|
done
|
||||||
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
||||||
|
|
||||||
remote-maintenance-on:
|
remote-upgrade-grav: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
|
||||||
|
|
||||||
|
remote-git-sync-disable: guard-env
|
||||||
|
$(SSH) "bash -s -- '$(WEBROOT)' false" < scripts/git-sync-toggle.sh
|
||||||
|
|
||||||
|
remote-git-sync-enable: guard-env
|
||||||
|
$(SSH) "bash -s -- '$(WEBROOT)' true" < scripts/git-sync-toggle.sh
|
||||||
|
|
||||||
|
remote-content-status: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT)/user && echo '--- HEAD ---' && git log -1 --oneline && echo '--- working tree ---' && git status --short && echo '--- config diff ---' && git diff -- config/ && echo '--- .gitignore diff ---' && git diff -- .gitignore"
|
||||||
|
|
||||||
|
remote-clean: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
||||||
|
|
||||||
|
# Post-deploy cache refresh: clear, then WARM. A `reset --hard` content deploy
|
||||||
|
# leaves Grav's compiled-Twig/page cache stale, and the first real visitor pays
|
||||||
|
# the recompile cost — so clear it and pre-render the public pages ourselves.
|
||||||
|
# Grav has no native warmup command, so this is an HTTP crawl of the live site:
|
||||||
|
# homepage + trips listing + every trip page linked from it (no sitemap plugin
|
||||||
|
# installed, so we scrape the listing instead of /sitemap.xml). The crawl runs
|
||||||
|
# from here over public HTTPS, so it also doubles as a smoke test — a non-200 on
|
||||||
|
# `/` is surfaced loudly. Run after every content deploy: `make remote-warmup-prod`.
|
||||||
|
remote-warmup: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache" >/dev/null
|
||||||
|
@base="https://$${WEB_HOST:-$(REMOTE_HOST)}"; \
|
||||||
|
echo "warming $$base (clear done) ..."; \
|
||||||
|
trip_urls=$$(curl -s "$$base/trips" | grep -oE '/trips/[a-z0-9][a-z0-9-]*' | sort -u); \
|
||||||
|
fail=0; \
|
||||||
|
for u in / /trips $$trip_urls; do \
|
||||||
|
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$base$$u"); \
|
||||||
|
printf ' %-40s %s\n' "$$u" "$$code"; \
|
||||||
|
case "$$code" in 2*|3*) ;; *) fail=1;; esac; \
|
||||||
|
done; \
|
||||||
|
if [ "$$fail" = 1 ]; then echo "WARNING: one or more pages returned a non-2xx/3xx status"; else echo "warmup OK — all pages 2xx/3xx"; fi
|
||||||
|
|
||||||
|
# Install a single GPM package on the server (e.g. git-sync, which is
|
||||||
|
# intentionally NOT in plugins.txt — it is remote-only).
|
||||||
|
# Usage: make remote-gpm-install-prod PKG=git-sync
|
||||||
|
remote-gpm-install: guard-env
|
||||||
|
@test -n "$(PKG)" || { echo "ERROR: set PKG=<plugin-slug>"; exit 1; }
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(PKG) -y && php bin/grav clearcache"
|
||||||
|
|
||||||
|
# Deploy per-environment Grav config overrides to the server's
|
||||||
|
# user/env/<WEB_HOST>/config/ tree (deep-merged over the committed config).
|
||||||
|
# Source of truth: deploy/env/$(ENV)/system.yaml (version-controlled). This
|
||||||
|
# tree is outside the content repo, so it is NOT restored by content sync —
|
||||||
|
# re-run after any fresh install.
|
||||||
|
remote-apply-env: guard-env
|
||||||
|
@test -f deploy/env/$(ENV)/system.yaml || { echo "ERROR: missing deploy/env/$(ENV)/system.yaml"; exit 1; }
|
||||||
|
@host="$${WEB_HOST:-$(REMOTE_HOST)}"; \
|
||||||
|
test -n "$$host" || { echo "ERROR: WEB_HOST/REMOTE_HOST unresolved"; exit 1; }; \
|
||||||
|
$(SSH) "mkdir -p $(WEBROOT)/user/env/$$host/config && cat > $(WEBROOT)/user/env/$$host/config/system.yaml && cd $(WEBROOT) && php bin/grav clearcache" < deploy/env/$(ENV)/system.yaml; \
|
||||||
|
echo "Applied deploy/env/$(ENV)/system.yaml -> $(WEBROOT)/user/env/$$host/config/system.yaml"
|
||||||
|
|
||||||
|
# Seed a per-host popularity salt into the env override tree so the api plugin
|
||||||
|
# reads it there instead of appending one to the git-tracked config/plugins/
|
||||||
|
# api.yaml. That appended salt kept the content working tree dirty, which broke
|
||||||
|
# git-sync's auto-merge on webhook. Salt is generated server-side and never
|
||||||
|
# committed (a committed salt would be globally known). Idempotent: an existing
|
||||||
|
# salt is kept, so re-running never rotates it.
|
||||||
|
remote-seed-api-salt: guard-env
|
||||||
|
@host="$${WEB_HOST:-$(REMOTE_HOST)}"; \
|
||||||
|
test -n "$$host" || { echo "ERROR: WEB_HOST/REMOTE_HOST unresolved"; exit 1; }; \
|
||||||
|
$(SSH) "set -e; \
|
||||||
|
envfile=$(WEBROOT)/user/env/$$host/config/plugins/api.yaml; \
|
||||||
|
mkdir -p \$$(dirname \"\$$envfile\"); \
|
||||||
|
if grep -qE '^[[:space:]]*salt:' \"\$$envfile\" 2>/dev/null; then \
|
||||||
|
echo \"salt already present in \$$envfile — keeping it\"; \
|
||||||
|
else \
|
||||||
|
salt=\$$(openssl rand -hex 32); \
|
||||||
|
printf 'popularity:\n salt: %s\n' \"\$$salt\" > \"\$$envfile\"; \
|
||||||
|
echo \"seeded new per-host salt into \$$envfile\"; \
|
||||||
|
fi; \
|
||||||
|
git -C $(WEBROOT)/user checkout -- config/plugins/api.yaml 2>/dev/null || true; \
|
||||||
|
cd $(WEBROOT) && php bin/grav clearcache >/dev/null 2>&1 || true; \
|
||||||
|
echo '--- base api.yaml status (expect clean) ---'; \
|
||||||
|
git -C $(WEBROOT)/user status --short config/plugins/api.yaml; \
|
||||||
|
echo '(if the line above is empty, the tree is clean)'"
|
||||||
|
|
||||||
|
# Read-only health check: plugin install state, versions, key config, log tail.
|
||||||
|
remote-diag: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && \
|
||||||
|
echo '=== Grav version ==='; php bin/grav --version 2>/dev/null; \
|
||||||
|
echo '=== installed plugin versions ==='; for p in login admin2 flex-objects form api; do printf '%s: ' \"\$$p\"; grep -m1 '^version:' user/plugins/\$$p/blueprints.yaml 2>/dev/null || echo '(NOT installed)'; done; \
|
||||||
|
echo '=== what does GPM say about api? ==='; php bin/gpm info api 2>&1 | head -12; \
|
||||||
|
echo '=== api override (enabled/route/session) ==='; grep -nE '^enabled:|^route:|session_enabled:' user/config/plugins/api.yaml 2>&1; \
|
||||||
|
echo '=== per-env override present? ==='; for f in user/env/*/config/system.yaml; do echo \"\$$f:\"; cat \"\$$f\" 2>/dev/null | grep -E 'cache:|debug:|auto_reload:'; done; \
|
||||||
|
echo '=== twig cache populating? (non-empty => cache on) ==='; ls cache/twig/ 2>/dev/null | head -1 || echo '(empty)'; \
|
||||||
|
echo '=== git-sync config (secrets redacted) ==='; grep -vaiE 'password|token|secret' user/config/plugins/git-sync.yaml user/env/*/config/plugins/git-sync.yaml 2>/dev/null; \
|
||||||
|
echo '=== grav.log tail ==='; tail -8 logs/grav.log 2>/dev/null"
|
||||||
|
|
||||||
|
# Secret-safe audit: lists WHERE per-host secret/config files live (config/ vs
|
||||||
|
# env/<host>/config/) and their sizes — never prints contents. Used to decide
|
||||||
|
# whether a `reset --hard` would clobber a live runtime secret.
|
||||||
|
remote-secrets-audit: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT)/user && \
|
||||||
|
echo '=== tracked in git? (git ls-files) ==='; git ls-files config/security-private.php config/security.yaml config/versions.yaml config/plugins/api-private.php config/plugins/git-sync.yaml; \
|
||||||
|
echo '=== config/ copies (size only) ==='; ls -la config/security.yaml config/security-private.php config/versions.yaml config/plugins/api-private.php config/plugins/git-sync.yaml 2>&1; \
|
||||||
|
echo '=== env/<host>/config copies (size only) ==='; ls -la env/*/config/security.yaml env/*/config/security-private.php env/*/config/plugins/api-private.php env/*/config/plugins/git-sync.yaml 2>&1; \
|
||||||
|
echo '=== does security.yaml reference the private php? (key names only) ==='; grep -aoE '^[a-z_]+:' config/security.yaml 2>/dev/null; for f in env/*/config/security.yaml; do echo \"\$$f:\"; grep -aoE '^[a-z_]+:' \"\$$f\" 2>/dev/null; done; true"
|
||||||
|
|
||||||
|
remote-maintenance-on: guard-env
|
||||||
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
|
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
|
||||||
|
|
||||||
remote-maintenance-off:
|
remote-maintenance-off: guard-env
|
||||||
$(SSH) "bash -s off $(WEBROOT)" < scripts/server-maintenance.sh
|
$(SSH) "bash -s off $(WEBROOT)" < scripts/server-maintenance.sh
|
||||||
|
|||||||
@@ -10,10 +10,29 @@ Two git repos:
|
|||||||
|
|
||||||
| Repo | Contents | Location |
|
| Repo | Contents | Location |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, plugins.txt | `./` |
|
| `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, tests, docs, plugins.txt | `./` |
|
||||||
| `intotheeast.com-content` | Site config, pages, theme | `user/` (standalone git repo) |
|
| `intotheeast.com-content` | Site config, pages, theme | `user/` (git submodule) |
|
||||||
|
|
||||||
The `user/` directory is a standalone git repo — its changes are pushed/pulled independently to Gitea. The Grav Sync plugin on the server automatically pulls from Gitea when content is pushed.
|
`user/` is tracked by this repo as a **git submodule** — it has its own Gitea remote and its own push/pull cadence (`make content-push` / `make content-pull`), and this repo pins an exact `user/` commit. The Git Sync plugin on the server pulls from Gitea automatically when content is pushed. A persistent `M user` / `m user` in `git status` is normal, not a problem; see [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
|
||||||
|
|
||||||
|
### Folder map
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `user/` | Site content, config, pages, theme (the content submodule) |
|
||||||
|
| `user/themes/intotheeast/js/src/` | JS sources — esbuild inputs; run `make build-assets` after editing. Note `js/maplibre-utils.js` and `js/nav.js` are *also* sources, despite sitting beside the generated bundles |
|
||||||
|
| `deploy/env/` | Per-environment Grav config overrides (e.g. prod Twig settings) |
|
||||||
|
| `deploy/patches/` | Tracked patches for third-party plugins, which are otherwise git-ignored |
|
||||||
|
| `scripts/` | Server install and maintenance scripts |
|
||||||
|
| `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, 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 |
|
||||||
|
| `CONCEPTS.md` | Shared domain vocabulary (Trip, Entry, Story, Active Trip) |
|
||||||
|
| `CLAUDE.md` | Rules and gotchas loaded into every Claude Code session |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,16 +49,22 @@ The `user/` directory is a standalone git repo — its changes are pushed/pulled
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # fill in your values — never commit this file
|
cp .env.example .env # fill in your values — never commit this file
|
||||||
make setup # start Docker container and install plugins
|
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.
|
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
|
> ⚠️ **Use `make start-grav`, not `make setup`, on a clean checkout.** `make setup` runs `make start`
|
||||||
git clone $USER_REPO user/
|
> (`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,12 +75,12 @@ git clone $USER_REPO user/
|
|||||||
**2. Run the install:**
|
**2. Run the install:**
|
||||||
|
|
||||||
```bash
|
```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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,42 +107,104 @@ make content-push # push local user/ commits → Gitea
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `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 stop` | Stop the local Docker container |
|
||||||
| `make setup` | Start container and install all plugins from plugins.txt |
|
| `make install-plugins` | (Re)install plugins from plugins.txt, then apply local plugin patches |
|
||||||
| `make install-plugins` | (Re)install plugins from plugins.txt in the local container |
|
| `make apply-plugin-patches` | Idempotently re-apply the patches in `deploy/patches/` |
|
||||||
| `make content-push` | Push local `user/` commits to Gitea |
|
| `make fix-perms` | Reset file ownership inside the container |
|
||||||
|
| `make build-assets` | Run esbuild over `user/themes/intotheeast/js/src/` — **required** after editing any JS source |
|
||||||
|
| `make content-push` | Push local `user/` commits to Gitea (triggers the production pull) |
|
||||||
| `make content-pull` | Pull latest `user/` content from Gitea |
|
| `make content-pull` | Pull latest `user/` content from Gitea |
|
||||||
|
|
||||||
### Remote credentials
|
### Testing
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `make remote-env-setup` | Write Gitea credentials to `~/.env-intotheeast` on the server |
|
| `make test` | Everything: `test-config` → `test-post` → `test-ui` |
|
||||||
| `make remote-env-remove` | Delete `~/.env-intotheeast` from the server |
|
| `make test-config` | Form/config sanity checks |
|
||||||
|
| `make test-post` | End-to-end post submission |
|
||||||
|
| `make test-ui` | Playwright suite |
|
||||||
|
|
||||||
Always run `make remote-env-remove` when done. Credentials must not persist on the server.
|
Details and conventions: [`docs/reference/testing.md`](docs/reference/testing.md).
|
||||||
|
|
||||||
### Remote server management
|
### Demo content and imports
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `make remote-install` | First-time install: download Grav, clone both repos, install plugins |
|
| `make demo-load` | Copy every fixture trip under `user/docs/demo/trips/` into the pages tree (add a fixture by dropping a folder there — no Makefile edit needed) |
|
||||||
| `make remote-fetch` | Pull latest config repo (Makefile, scripts, plugins.txt) on the server |
|
| `make demo-reset` | Remove those demo trips from the pages tree and clear cache |
|
||||||
| `make remote-install-plugins` | Install/update plugins from local plugins.txt on the server |
|
| `make pixelfed-import` | Import posts from Pixelfed via `scripts/pixelfed-import.py` |
|
||||||
| `make remote-upgrade-grav` | Upgrade Grav core on the server |
|
|
||||||
| `make remote-clean` | Clear Grav cache on the server |
|
### Parallel work
|
||||||
| `make remote-maintenance-on` | Enable maintenance mode (visitors see offline page) |
|
|
||||||
| `make remote-maintenance-off` | Disable maintenance mode |
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `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 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-<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 |
|
||||||
|
|
||||||
|
**Install and sync**
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `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
|
### Typical upgrade workflow
|
||||||
|
|
||||||
|
Run against `test` first — it is a full dress rehearsal of prod.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make remote-maintenance-on
|
make remote-maintenance-on-prod
|
||||||
make remote-upgrade-grav
|
make remote-upgrade-grav-prod
|
||||||
make remote-install-plugins
|
make remote-install-plugins-prod
|
||||||
make remote-clean
|
make remote-apply-env-prod # env tree is not restored by anything else
|
||||||
make remote-maintenance-off
|
make remote-warmup-prod
|
||||||
|
make remote-maintenance-off-prod
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -131,6 +218,44 @@ Plugins are not committed to git. The full list is in `plugins.txt` — one plug
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Template behaviour
|
||||||
|
|
||||||
|
Key design decisions that affect how pages render:
|
||||||
|
|
||||||
|
| Context | Sort order | Reason |
|
||||||
|
|---------|------------|--------|
|
||||||
|
| Trip page (`trip.html.twig`) | Ascending (oldest first) | Trip reads as a narrative from start to finish |
|
||||||
|
| Homepage active-trip feed (`home.html.twig`) | Descending (newest first) | Visitors want to see what's happening right now |
|
||||||
|
|
||||||
|
**Homepage modes** — controlled by `travelling` in `user/config/site.yaml`:
|
||||||
|
|
||||||
|
| `travelling` | Homepage shows |
|
||||||
|
|---|---|
|
||||||
|
| `true` | Active trip map + chronological feed (newest first) |
|
||||||
|
| `false` | Map with highlight markers + curated highlights grid (max 6, 1 per trip, random) |
|
||||||
|
|
||||||
|
Entries and stories opt into the highlights grid via `featured: true` in their frontmatter. The `active_trip` field stores a full page route (e.g. `/trips/italy-2026-demo`), not a bare slug.
|
||||||
|
|
||||||
|
**Per-trip map settings** — configurable in Admin2 under the Trip tab:
|
||||||
|
|
||||||
|
| Setting | Values | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `use_gpx` | Yes / No | Yes | Draws uploaded GPX files as route lines on the map |
|
||||||
|
| `autoconnect` | off / on / manual / intelligent_gpx | on | Controls connector lines between location markers |
|
||||||
|
|
||||||
|
Connect markers behaviour:
|
||||||
|
|
||||||
|
| Value | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `off` | No connector lines; `force_connect` on entries is also ignored |
|
||||||
|
| `on` | Dashed connector between every entry in date order |
|
||||||
|
| `manual` | No automatic lines; only entries with `force_connect: true` are linked |
|
||||||
|
| `intelligent_gpx` | Suppresses the connector where a GPX track covers the route; `force_connect` overrides. Requires `use_gpx` enabled — falls back to `on` if GPX is off or no files are present |
|
||||||
|
|
||||||
|
`use_gpx` and `autoconnect` are independent: you can show GPX tracks without connector lines or vice versa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- `.env` is gitignored. Never commit it — it contains your server credentials and Gitea token.
|
- `.env` is gitignored. Never commit it — it contains your server credentials and Gitea token.
|
||||||
|
|||||||
Vendored
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Deployed-environment Grav config overrides (test AND prod).
|
||||||
|
#
|
||||||
|
# Both server environments share this one file so test stays a faithful dress
|
||||||
|
# rehearsal of prod: deploy/env/test/system.yaml is a symlink to this file.
|
||||||
|
# Edit here and both environments move together — never let them drift.
|
||||||
|
#
|
||||||
|
# Deep-merged OVER the committed user/config/system.yaml via Grav's
|
||||||
|
# per-environment config mechanism: on the server this file is deployed to
|
||||||
|
# <webroot>/user/env/<hostname>/config/system.yaml
|
||||||
|
# and Grav's `environment://config` stream (keyed on the request hostname)
|
||||||
|
# layers it on top of `user://config`.
|
||||||
|
#
|
||||||
|
# These values are deliberately NOT in the committed system.yaml because they
|
||||||
|
# would break local development (see CLAUDE.md §1 — dev keeps twig.cache:false
|
||||||
|
# so theme edits take effect immediately). They apply only on the deployed
|
||||||
|
# hosts, never on a local dev checkout.
|
||||||
|
#
|
||||||
|
# Deploy with: make remote-apply-env-test / make remote-apply-env-prod
|
||||||
|
# The user/env/ tree is outside the content repo's tracked folders, so it is
|
||||||
|
# NOT restored by content-push / git-sync / remote-fetch-content — re-run the
|
||||||
|
# target above after any fresh install.
|
||||||
|
twig:
|
||||||
|
cache: true
|
||||||
|
debug: false
|
||||||
|
auto_reload: false
|
||||||
|
|
||||||
|
# Compression / connection handling.
|
||||||
|
#
|
||||||
|
# This host is not FastCGI (no fastcgi_finish_request()), so Grav's shutdown
|
||||||
|
# "early connection close" falls back to emitting `Content-Encoding: identity`
|
||||||
|
# to ask the webserver not to compress. But Apache's mod_deflate compresses
|
||||||
|
# anyway and adds `Content-Encoding: gzip`, giving TWO conflicting headers —
|
||||||
|
# the browser can't decode the body and renders raw gzip bytes (a garbage
|
||||||
|
# page). Note: allow_webserver_gzip:true takes the SAME identity branch, so it
|
||||||
|
# does not help. The real fix is to disable the early-close path, so Grav never
|
||||||
|
# emits the bogus header and mod_deflate compresses cleanly (single header).
|
||||||
|
debugger:
|
||||||
|
shutdown:
|
||||||
|
close_connection: false
|
||||||
|
# Let the webserver own gzip; Grav does not compress or double-label.
|
||||||
|
cache:
|
||||||
|
gzip: false
|
||||||
|
allow_webserver_gzip: false
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../prod/system.yaml
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Local plugin patches
|
||||||
|
|
||||||
|
Patches for **third-party, GPM-managed plugins** that live under
|
||||||
|
`user/plugins/` — which is **git-ignored** (see `user/.gitignore`), so these
|
||||||
|
edits do **not** travel with the content repo and are **overwritten by
|
||||||
|
`make install-plugins`** / a fresh image build. Keep the fix here (tracked) and
|
||||||
|
re-apply it after any plugin (re)install, until the plugin is forked upstream.
|
||||||
|
|
||||||
|
### Local (dev)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make apply-plugin-patches # git apply, idempotent (skips if applied)
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install-plugins` runs this automatically as its last step.
|
||||||
|
|
||||||
|
### Remote (test / prod)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make remote-apply-plugin-patches-test
|
||||||
|
make remote-apply-plugin-patches-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Each patch is piped over SSH into `patch -p1 --forward` at the webroot (no scp),
|
||||||
|
so it is a no-op when already applied. **Runs automatically** as the last step of
|
||||||
|
`remote-install-plugins-*` and `remote-update-plugins-*` — GPM lays down pristine
|
||||||
|
plugins, so the patch must follow every GPM install/update. Content pulls
|
||||||
|
(git-sync / `remote-fetch-content`) do **not** touch `user/plugins/`, so the patch
|
||||||
|
survives ordinary content syncs. Requires the `patch` tool on the server.
|
||||||
|
|
||||||
|
Verify a patch is live on a server:
|
||||||
|
`grep -c toArray user/plugins/add-page-by-form/add-page-by-form.php` (≥1 = applied).
|
||||||
|
|
||||||
|
## add-page-by-form-grav2-header.patch
|
||||||
|
|
||||||
|
Fixes a fatal when **adding a new photo while editing an entry** (front-end
|
||||||
|
journal edit, milestone M2 / R9).
|
||||||
|
|
||||||
|
- **Plugin:** `add-page-by-form` 3.3.0 (abandoned upstream — last release Sept 2023).
|
||||||
|
- **Bug:** the edit-mode branch reads existing frontmatter with
|
||||||
|
`(array)$pages->get($folder)->header()`. On Grav 2.0 `header()` returns a
|
||||||
|
`Grav\Common\Page\Header` object whose data sits in a **protected** `items`
|
||||||
|
property, so the `(array)` cast produces mangled keys (`\0*\0items`) and
|
||||||
|
`$original_frontmatter['photos']` is never set → `array_merge(null, …)`
|
||||||
|
throws a `TypeError` (PHP 8) on any edit that uploads a new file.
|
||||||
|
- **Fix:** use `Header::toArray()` (clean keys) with a fallback to the cast for
|
||||||
|
classic stdClass headers, and guard the per-field merge against a
|
||||||
|
missing/non-array original.
|
||||||
|
|
||||||
|
Remove this patch once `add-page-by-form` is forked and the fix lands in the
|
||||||
|
fork (then pin the fork instead of the GPM package).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
--- a/b/user/plugins/add-page-by-form/add-page-by-form.php 2026-07-05 12:03:55.849015242 +0200
|
||||||
|
+++ b/user/plugins/add-page-by-form/add-page-by-form.php 2026-07-05 11:55:06.175609339 +0200
|
||||||
|
@@ -619,7 +619,19 @@
|
||||||
|
if ($overwrite_mode !== 'false') {
|
||||||
|
if (file_exists($new_page_folder)) {
|
||||||
|
if ($overwrite_mode === 'edit') {
|
||||||
|
- $original_frontmatter = (array)$pages->get($new_page_folder)->header();
|
||||||
|
+ // intotheeast patch (temporary, pending upstream fork):
|
||||||
|
+ // On Grav 2.0 header() returns a Grav\Common\Page\Header
|
||||||
|
+ // object whose data sits in a PROTECTED `items` property,
|
||||||
|
+ // so the original `(array)$header` yields mangled keys
|
||||||
|
+ // (\0*\0items) and every frontmatter lookup below misses —
|
||||||
|
+ // `array_merge($original_frontmatter['photos'], …)` then
|
||||||
|
+ // fatals under PHP 8. Use toArray() (clean keys) when the
|
||||||
|
+ // Header exposes it; fall back to the cast for a plain
|
||||||
|
+ // stdClass (classic pages).
|
||||||
|
+ $__header = $pages->get($new_page_folder)->header();
|
||||||
|
+ $original_frontmatter = (is_object($__header) && method_exists($__header, 'toArray'))
|
||||||
|
+ ? $__header->toArray()
|
||||||
|
+ : (array)$__header;
|
||||||
|
} else {
|
||||||
|
Folder::delete($new_page_folder);
|
||||||
|
}
|
||||||
|
@@ -708,7 +720,13 @@
|
||||||
|
|
||||||
|
$file_fields_updated = array();
|
||||||
|
foreach ($file_fields as $file_field => $uploads) {
|
||||||
|
- $file_fields_updated[$file_field] = array_merge($original_frontmatter[$file_field], $uploads);
|
||||||
|
+ // intotheeast patch: entries that render from folder-scanned
|
||||||
|
+ // media carry no matching frontmatter key, so fall back to []
|
||||||
|
+ // rather than fatal array_merge() on a missing/null original.
|
||||||
|
+ $existing = (isset($original_frontmatter[$file_field]) && is_array($original_frontmatter[$file_field]))
|
||||||
|
+ ? $original_frontmatter[$file_field]
|
||||||
|
+ : array();
|
||||||
|
+ $file_fields_updated[$file_field] = array_merge($existing, $uploads);
|
||||||
|
|
||||||
|
// Get any (uploaded and then) deleted files
|
||||||
|
foreach ($copy_files['deleted'] as $file_to_delete) {
|
||||||
+26
-4
@@ -1,14 +1,36 @@
|
|||||||
services:
|
services:
|
||||||
grav:
|
grav:
|
||||||
image: getgrav/grav
|
build: .
|
||||||
container_name: intotheeast_grav
|
# Overridable so a git worktree can run its own isolated dev server (see
|
||||||
|
# `make worktree-new`); unset → the canonical main-checkout values below.
|
||||||
|
container_name: ${GRAV_CONTAINER:-intotheeast_grav}
|
||||||
environment:
|
environment:
|
||||||
- GRAV_CHANNEL=beta
|
- GRAV_CHANNEL=production
|
||||||
- APACHE_RUN_USER=#1000
|
- APACHE_RUN_USER=#1000
|
||||||
- APACHE_RUN_GROUP=#1000
|
- APACHE_RUN_GROUP=#1000
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "${GRAV_PORT:-8081}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./user:/var/www/html/user
|
- ./user:/var/www/html/user
|
||||||
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
||||||
|
# Grav stages form uploads in tmp/forms/<session>/ before the submit moves
|
||||||
|
# them into the page folder. The image declares /var/www/html as a VOLUME,
|
||||||
|
# so without this it lives in an ANONYMOUS volume that is discarded on any
|
||||||
|
# `docker compose up` that recreates the container — dropping the photos of
|
||||||
|
# a post that was filled in but not yet submitted. Naming it gives the
|
||||||
|
# staging area its own lifecycle.
|
||||||
|
- grav_tmp:/var/www/html/tmp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
travel-memories:
|
||||||
|
build: ./services/travel-memories
|
||||||
|
ports:
|
||||||
|
- "${TM_PORT:-8082}:8082"
|
||||||
|
volumes:
|
||||||
|
- ./docs/immich-workflow:/app/state
|
||||||
|
- ./user/pages:/app/pages
|
||||||
|
env_file: .env
|
||||||
|
user: "${UID}:${GID}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
grav_tmp:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# docs/
|
||||||
|
|
||||||
|
## If you're Mischa
|
||||||
|
|
||||||
|
**Doing something operational?** → [`guides/`](guides/)
|
||||||
|
- [Posting a journal entry](guides/posting.md)
|
||||||
|
- [Managing GPX files](guides/gpx-manager.md)
|
||||||
|
- [Switching to a new trip](guides/trip-switching.md)
|
||||||
|
- [Rebuilding local dev from scratch](guides/local-setup.md)
|
||||||
|
|
||||||
|
**Checking project status?** → [`working/`](working/) — [what's in there + the plan status convention](working/README.md)
|
||||||
|
- [Backlog](working/backlog.md)
|
||||||
|
- [Bugs and fixes](working/bugs-and-fixes.md)
|
||||||
|
- [QA results](working/qa/results.md)
|
||||||
|
|
||||||
|
**Design or architecture decisions?** → [`reference/`](reference/)
|
||||||
|
- [Design system](reference/design-system.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
|
||||||
|
|
||||||
|
**Always-loaded project rules** → [`CLAUDE.md`](../CLAUDE.md) (repo root)
|
||||||
|
|
||||||
|
**Active specs and plans** → [`working/specs/`](working/specs/) and [`working/plans/`](working/plans/)
|
||||||
|
|
||||||
|
**Stable facts** → [`reference/`](reference/)
|
||||||
|
|
||||||
|
**Raw research input** → [`research/`](research/)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# central-asia-2023 Enrichment Review
|
||||||
|
|
||||||
|
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
|
||||||
|
|
||||||
|
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 2023-08-28-pixelfed-1.entry | 2023-08-28 | Welcome to My Central Asian Picture Diary | Berlin | Germany | https://www.openstreetmap.org/#map=15/52.5200/13.4050 | 24 | sunny |
|
||||||
|
| 2023-08-29-pixelfed-2.entry | 2023-08-29 | Last Beer Before the Foreign Land | Berlin | Germany | https://www.openstreetmap.org/#map=16/52.36402/13.50745 | 24 | sunny |
|
||||||
|
| 2023-08-30-pixelfed-3.entry | 2023-08-30 | The UAZ Buchanka Counter Begins | Astana | Kazakhstan | https://www.openstreetmap.org/#map=17/51.140108/71.429747 | 15 | rainy |
|
||||||
|
| 2023-08-31-pixelfed-4.entry | 2023-08-31 | Baiterek: Bird of Happiness in Astana | Astana | Kazakhstan | https://www.openstreetmap.org/#map=17/51.128246/71.430466 | 15 | cloudy |
|
||||||
|
| 2023-09-02-pixelfed-5.entry | 2023-09-02 | Doshirak and Politics on the Night Train | Almaty | Kazakhstan | https://www.openstreetmap.org/#map=15/43.2220/76.8512 | 25 | sunny |
|
||||||
|
| 2023-09-03-pixelfed-6.entry | 2023-09-03 | Plov and Street Art in Almaty | Almaty | Kazakhstan | https://www.openstreetmap.org/#map=15/43.2220/76.8512 | 24 | sunny |
|
||||||
|
| 2023-09-04-pixelfed-7.entry | 2023-09-04 | Rain in Charyn Canyon, Manti for Dinner | Charyn Canyon | Kazakhstan | https://www.openstreetmap.org/#map=16/43.35102/79.08010 | 18 | cloudy with showers |
|
||||||
|
| 2023-09-05-pixelfed-8.entry | 2023-09-05 | Kurt, Kumis and a UAZ Dream Ride | Kaindy / Kolsai | Kazakhstan | https://www.openstreetmap.org/#map=17/43.068370/78.412857 | 20 | sunny |
|
||||||
|
| 2023-09-07-pixelfed-9.entry | 2023-09-07 | First Hike Up Toward Ala Kol | Karakol | Kyrgyzstan | https://www.openstreetmap.org/#map=15/42.4900/78.3936 | 0 | partly cloudy |
|
||||||
|
| 2023-09-10-pixelfed-10.entry | 2023-09-10 | Tea Trails and No Seatbelts in Kyrgyzstan | Bishkek | Kyrgyzstan | https://www.openstreetmap.org/#map=18/42.876640/74.603745 | 16 | partly cloudy |
|
||||||
|
| 2023-09-18-pixelfed-11.entry | 2023-09-18 | Stuck in No Man's Land at 4655m | Akbaital Pass | Tajikistan | https://www.openstreetmap.org/#map=18/39.384414/73.322529 | 16 | partly cloudy |
|
||||||
|
| 2023-09-19-pixelfed-12.entry | 2023-09-19 | Black Water Lake on the Pamir Highway | Karakul | Tajikistan | https://www.openstreetmap.org/#map=16/39.01250/73.55978 | 15 | windy |
|
||||||
|
| 2023-09-19-pixelfed-13.entry | 2023-09-19 | Warm Soup in a Village of Hundreds | Alichur | Tajikistan | https://www.openstreetmap.org/#map=18/37.755579/73.271513 | 15 | windy |
|
||||||
|
| 2023-09-20-pixelfed-14.entry | 2023-09-20 | Farewell Vodka Under the World's Tallest Flag | Dushanbe | Tajikistan | https://www.openstreetmap.org/#map=15/38.5598/68.7870 | 28 | sunny |
|
||||||
|
| 2023-10-02-pixelfed-15.entry | 2023-10-02 | Millionaires and Minarets in Bukhara | Bukhara | Uzbekistan | https://www.openstreetmap.org/#map=17/39.775957/64.416693 | 25 | sunny |
|
||||||
|
| 2023-09-23-pixelfed-16.entry | 2023-09-23 | The Night the Beer Finally Arrived | Alichur | Tajikistan | https://www.openstreetmap.org/#map=19/37.755660/73.271591 | 15 | windy |
|
||||||
|
| 2023-09-23-pixelfed-17.entry | 2023-09-23 | Afghanistan Just Across the Wakhan River | Zong | Tajikistan | https://www.openstreetmap.org/#map=19/37.032071/72.630602 | 28 | sunny |
|
||||||
|
| 2023-10-01-pixelfed-18.entry | 2023-10-01 | Hot Springs and a Pamiri Homestay | Ishkashim / Bibi Fatima | Tajikistan | https://www.openstreetmap.org/#map=19/36.983527/72.264250 | 18 | sunny |
|
||||||
|
| 2023-10-01-pixelfed-19.entry | 2023-10-01 | What Is Normal? Reflections from Khorog | Khorog | Tajikistan | https://www.openstreetmap.org/#map=16/37.49046/71.53921 | 25 | sunny |
|
||||||
|
| 2023-10-03-pixelfed-20.entry | 2023-10-03 | Timur's Samarkand and a Badly Parked Truck | Samarkand | Uzbekistan | https://www.openstreetmap.org/#map=16/39.65841/66.98542 | 25 | sunny |
|
||||||
|
| 2023-10-03-pixelfed-21.entry | 2023-10-03 | Four Weeks in Central Asia, Barely Enough | Baku | Azerbaijan |https://www.openstreetmap.org/#map=16/40.37660/49.84752 | 24 | sunny |
|
||||||
|
| 2023-10-18-pixelfed-22.entry | 2023-10-18 | Hunting the Mother of Georgia from Above | Tbilisi | Georgia | https://www.openstreetmap.org/#map=15/41.6938/44.8015 | 20 | partly cloudy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Notes for reviewer:**
|
||||||
|
|
||||||
|
- **Entry 3 (Aug 30, UAZ Buchanka):** City inferred as Astana — the trip narrative shows flight to Kazakhstan on Aug 29, and entry 4 is explicitly Astana on Aug 31. Frontmatter has blank city/country; confirm this is correct.
|
||||||
|
- **Entry 7 (Sep 4, Charyn Canyon):** Day tour from Almaty. Charyn Canyon coordinates not in reference table — Map Link left as `?`. Correct city if this was based in Almaty rather than the canyon itself.
|
||||||
|
- **Entry 8 (Sep 5, Kaindy/Kolsai lakes):** Day tour from Almaty area. Coordinates not in reference table — Map Link left as `?`.
|
||||||
|
- **Entry 10 (Sep 10, Tea Trails):** Body mentions Karakol → Bishkek → Osh (OSU airport code). Entry ends in Osh; Osh not in reference table — Map Link left as `?`. Weather estimated using Karakol September values as nearest reference.
|
||||||
|
- **Entry 11 (Sep 18, No Man's Land):** Crossing from Osh (KG) to Karakul (TJ) via Akbaital Pass (4655m). Location ambiguous — border crossing, no single city. Map Link left as `?`. Dates in content say "10-09" (September 10), but file date is 2023-09-18 (post was written later as a recap).
|
||||||
|
- **Entry 12 (Sep 19, Black Water Lake):** Karakul, Tajikistan (on Pamir Highway). Not in reference table. Weather estimated using Dushanbe September values.
|
||||||
|
- **Entry 13 (Sep 19, Warm Soup):** Alichur village, Tajikistan. Content date says "11-09". Not in reference table. Weather estimated using Dushanbe September values.
|
||||||
|
- **Entry 15 (Sep 23, Bukhara):** Bukhara mentioned explicitly in body. Not in reference table — Map Link left as `?`. Weather estimated using Samarkand September values (nearest Uzbek city in table).
|
||||||
|
- **Entry 16 (Sep 23, Beer Finally Arrived):** Alichur, Tajikistan. Content date says "PMU13-9". Chronology note: this entry was posted on Sep 23 but describes events from Sep 13. Weather estimated using Dushanbe September values.
|
||||||
|
- **Entry 17 (Sep 23, Afghanistan):** Zong village, Wakhan valley, Tajikistan. Content date says "PMU14-9". Not in reference table. Weather estimated using Dushanbe September values.
|
||||||
|
- **Entry 18 (Oct 1, Hot Springs):** Bibi Fatima hot springs in Ishkashim area, Wakhan corridor, Tajikistan. Content date says "PMU15-9". Not in reference table. Weather estimated using Dushanbe October values.
|
||||||
|
- **Entry 19 (Oct 1, Khorog):** Khorog, Tajikistan explicitly mentioned. Not in reference table. Weather estimated using Dushanbe October values.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# italy-2025 Enrichment Review
|
||||||
|
|
||||||
|
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
|
||||||
|
|
||||||
|
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 2025-10-11-pixelfed-1.entry | 2025-10-11 | 600km of Tuscany Begins with an Aperitif | Venturina Terme | Italy | https://www.openstreetmap.org/#map=15/43.0183/10.6059 | 18 | partly cloudy |
|
||||||
|
| 2025-10-16-pixelfed-2.entry | 2025-10-16 | Twelve Hundred Meters of Hills in Tuscany | Pienza | Italy | https://www.openstreetmap.org/#map=15/43.0765/11.6786 | 18 | partly cloudy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Notes for reviewer:**
|
||||||
|
|
||||||
|
- **Entry 1 (Oct 11, 600km of Tuscany):** Venturina Terme is confirmed as the start location from the Day 1 GPX filename. The frontmatter has empty city/country fields. Coordinates placed at Venturina Terme town center (43.0183, 10.6059).
|
||||||
|
- **Entry 2 (Oct 16, Twelve Hundred Meters):** This entry was posted on Oct 16 but describes Day 3 of the tour (actual event date 2025-10-13, based on the Day 3 GPX file). The entry title and body mention "about 1200 height meters" and hilly terrain. Based on the route (Venturina Terme → Orbetello → Sorano → Pienza → Siena → Florence → Volterra), Day 3 arrives in the Pienza/Montalcino area (Val d'Orcia region). The Day 3 GPX endpoint coordinates (42.682770, 11.714815) confirm this region. City set to Pienza as a representative location for this day. Weather estimated using October Tuscany values (18°C, partly cloudy — typical autumn conditions).
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# slovenia-2024 Enrichment Review
|
||||||
|
|
||||||
|
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
|
||||||
|
|
||||||
|
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 2024-05-28-pixelfed-1.entry | 2024-05-28 | Ice Cream and Old Walls in Piran | Piran | Slovenia | https://www.openstreetmap.org/#map=15/45.5285/13.5680 | 21 | sunny |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- This entry was originally routed into `us-canada-mex-2024` because Pixelfed import bucketed all 2024 posts together. It belongs to a separate Slovenia 2024 trip.
|
||||||
|
- The entry currently lives at `user/pages/01.trips/us-canada-mex-2024/01.dailies/2024-05-28-pixelfed-1.entry/` — it will need to be moved to a new `slovenia-2024` trip page tree when that trip is set up on the site.
|
||||||
|
- Content will be added later. Enrichment can be applied once the trip page exists.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# us-canada-mex-2024 Enrichment Review
|
||||||
|
|
||||||
|
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
|
||||||
|
|
||||||
|
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 2024-07-21-pixelfed-2.entry | 2024-07-21 | A Warm Welcome and Peanut Butter Pretzels | San Francisco | USA | https://www.openstreetmap.org/#map=16/37.76384/-122.47812 | 18 | partly cloudy |
|
||||||
|
| 2024-07-21-pixelfed-3.entry | 2024-07-21 | Windmills, Craft Beer and Twin Peaks at Dusk | San Francisco | USA | https://www.openstreetmap.org/#map=17/37.765049/-122.508320 | 18 | partly cloudy |
|
||||||
|
| 2024-07-22-pixelfed-4.entry | 2024-07-22 | Breakfast Burrito and an Illegal Beach Beer | Santa Cruz | USA | https://www.openstreetmap.org/#map=16/36.96356/-122.01813 | 18 | partly cloudy |
|
||||||
|
| 2024-07-25-pixelfed-5.entry | 2024-07-25 | Cruising Highway 1 into the California Sunset | Big Sur | USA | https://www.openstreetmap.org/#map=13/37.23744/-122.41499 | 18 | partly cloudy |
|
||||||
|
| 2024-07-29-pixelfed-6.entry | 2024-07-29 | The Near-Perfect Burrito and Fog on the Gate | San Francisco | USA | https://www.openstreetmap.org/#map=17/37.806046/-122.451843 | 18 | partly cloudy |
|
||||||
|
| 2024-07-29-pixelfed-7.entry | 2024-07-29 | Eighteen Hours on Amtrak Through Epic Nature | Sacramento Valley | USA | https://www.openstreetmap.org/#map=13/40.75701/-122.31903 | 23 | sunny |
|
||||||
|
| 2024-07-29-pixelfed-8.entry | 2024-07-29 | Portland: Seventy Breweries and the Hippest Streets | Portland | USA | https://www.openstreetmap.org/#map=15/45.52508/-122.67759 | 27 | sunny |
|
||||||
|
| 2024-08-05-pixelfed-9.entry | 2024-08-05 | Wedding Days in Diverse and Vibrant Toronto | Toronto | Canada | https://www.openstreetmap.org/#map=18/43.683760/-79.322147 | 27 | sunny |
|
||||||
|
| 2024-08-05-pixelfed-10.entry | 2024-08-05 | Red Ponchos and Mist at Niagara Falls | Niagara Falls | Canada | https://www.openstreetmap.org/#map=16/43.08677/-79.07249 | 26 | sunny |
|
||||||
|
| 2024-08-05-pixelfed-11.entry | 2024-08-05 | Poutine and French Echoes in Old Montreal | Montreal | Canada | https://www.openstreetmap.org/#map=15/45.5017/-73.5673 | 26 | sunny |
|
||||||
|
| 2024-08-07-pixelfed-12.entry | 2024-08-07 | Cocoa Beans and Aztec Gold in Mexico City | Mexico City | Mexico | https://www.openstreetmap.org/#map=15/19.4326/-99.1332 | 22 | partly cloudy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Notes for reviewer:**
|
||||||
|
|
||||||
|
- **Entry 1 (Piran / Slovenia):** Moved to `docs/enrichment/slovenia-2024.md` — this is a separate trip. Entry will be relocated on the site when the `slovenia-2024` trip page is created.
|
||||||
|
- **Entry 1 (Jul 21, Warm Welcome):** Body text reads "SF here we come!" and describes arrival by plane; city inferred as San Francisco. Content internal date "18.7" (July 18) but file date is 2024-07-21.
|
||||||
|
- **Entry 3 (Jul 21, Windmills/Twin Peaks):** "SF Sunset district", "Golden Gate park windmills", "David Lynch's Twin Peaks" — all confirmed San Francisco. Internal date "19.7".
|
||||||
|
- **Entry 4 (Jul 22, Breakfast Burrito):** Starts with a burrito in SF, then drives Highway 1 south to Santa Cruz. Base city assigned as San Francisco per reference table; if you prefer Santa Cruz as the destination, update to City=Santa Cruz, Lat=36.9741, Lng=-122.0308.
|
||||||
|
- **Entry 5 (Jul 25, Highway 1):** Very short body — only mentions Highway 1 and California sunset. Assigned to San Francisco as the SF-area base per reference table. If this was shot further south (e.g. Big Sur), update accordingly.
|
||||||
|
- **Entry 6 (Jul 29, Near-Perfect Burrito):** Body explicitly mentions "downtown SF" and "Golden Gate bridge" and "San Franciscan skies". Internal date "21.7". Confirmed San Francisco.
|
||||||
|
- **Entry 7 (Jul 29, Amtrak 18 Hours):** Train journey from SF to Portland; assigned Portland (destination) per task brief. Internal date "22/23.7".
|
||||||
|
- **Entry 8 (Jul 29, Portland Breweries):** Portland explicitly named throughout body. Internal date "24.7". Confirmed Portland.
|
||||||
|
- **Entries 9–11 (Aug 5, three entries):** All three share the same file date (2024-08-05) but describe different cities visited at different points of the trip. Chronological order inferred from pixelfed sequence numbers and internal dates: pixelfed-9 = Toronto (25/29.7), pixelfed-10 = Niagara Falls (28.7), pixelfed-11 = Montreal (30.7). The Aug 5 file date appears to be when the posts were batch-uploaded rather than the visit dates.
|
||||||
|
- **Entry 10 (Aug 5, Niagara Falls):** Body says "Even though the falls are in the US the best views are from the Canadian side" — assigned Canadian side (Niagara Falls, Canada).
|
||||||
|
- **Entry 12 (Aug 7, Mexico City):** Body mentions "Museo de Chocolate" and Nahuatl/Aztec references. Internal date "1.8". Confirmed Mexico City.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# View unpublished trips (drafts) on the frontend when logged in
|
||||||
|
|
||||||
|
**Status:** 📋 Not started
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
An unpublished trip (`published: false`) currently returns a hard **404** on its own
|
||||||
|
route, even for the logged-in owner. Example: `http://localhost:8081/trips/denmark-2026`
|
||||||
|
→ `HTTP 404` (verified 2026-07-08, anonymous *and* authenticated). The owner should be
|
||||||
|
able to preview a draft trip page at its real URL before publishing, while the public
|
||||||
|
still gets a 404.
|
||||||
|
|
||||||
|
The rest of the site is **already owner-aware** — the trip template, the trips listing,
|
||||||
|
and the home page all render drafts to `grav.user.authenticated` (via `.published()`
|
||||||
|
filters + `is-draft`/Draft badges). The only missing piece is the **direct route** to a
|
||||||
|
draft's own page.
|
||||||
|
|
||||||
|
## Current behaviour — verified mechanism
|
||||||
|
|
||||||
|
Traced through the Grav core running in the container (Grav 2.0.x):
|
||||||
|
|
||||||
|
- `Page::routable()` (`system/src/Grav/Common/Page/Page.php`) returns:
|
||||||
|
```php
|
||||||
|
return $this->routable && $this->published();
|
||||||
|
```
|
||||||
|
So `published: false` ⇒ `routable()` is `false`, regardless of the `routable` flag.
|
||||||
|
|
||||||
|
- `PagesProcessor.php:67` gates the request on exactly that:
|
||||||
|
```php
|
||||||
|
if (!$page->routable()) {
|
||||||
|
// build 404, fire onPageNotFound...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `PagesProcessor.php` ~line 80: after firing `onPageNotFound`, if a listener set
|
||||||
|
`$event->page`, Grav serves **that** page directly with no further routable check:
|
||||||
|
```php
|
||||||
|
if (isset($event->page)) {
|
||||||
|
unset($this->container['page']);
|
||||||
|
$this->container['page'] = $page = $event->page;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException('Page Not Found', 404);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That last hook is the clean insertion point.
|
||||||
|
|
||||||
|
## Proposed approach — small custom plugin (~40 lines)
|
||||||
|
|
||||||
|
Mirror the existing `user/plugins/cache-on-save/` custom-plugin pattern. Subscribe to
|
||||||
|
`onPageNotFound` and, for authenticated users only, resolve the requested route including
|
||||||
|
unpublished pages and hand it back:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function onPageNotFound(Event $e) {
|
||||||
|
$user = $this->grav['user'];
|
||||||
|
if (!$user->authenticated) {
|
||||||
|
return; // owners only — public still 404s
|
||||||
|
}
|
||||||
|
$route = $this->grav['uri']->path();
|
||||||
|
$page = $this->grav['pages']->find($route, true); // include unpublished
|
||||||
|
if ($page && !$page->published()) {
|
||||||
|
$e->page = $page; // serve the draft → 200
|
||||||
|
$e->stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The page then renders with its normal template. Because the theme is already owner-aware,
|
||||||
|
the trip page will display correctly for the logged-in owner.
|
||||||
|
|
||||||
|
## Decisions to make before building (brainstorm first)
|
||||||
|
|
||||||
|
1. **Scope of page types.** All unpublished pages, or just the trip tree
|
||||||
|
(`/trips/*`)? Entries and stories already show inline as drafts on the owner's trip
|
||||||
|
feed; do they also need standalone-route preview? Leaning: gate to trip/entry/story
|
||||||
|
templates to avoid unintentionally exposing every draft everywhere.
|
||||||
|
2. **Draft banner.** Add a trip-level "Draft — not published" banner when viewing an
|
||||||
|
unpublished trip (entry-level draft badges already exist; this is the trip equivalent).
|
||||||
|
3. **Non-existent vs. unpublished.** Ensure a genuinely missing route still 404s — the
|
||||||
|
`find(..., true)` + `!published()` check already distinguishes them, but cover it in a test.
|
||||||
|
|
||||||
|
## The one real risk — page-cache leak to the public
|
||||||
|
|
||||||
|
If Grav caches the 200 we serve to the owner and later hands it to an anonymous visitor,
|
||||||
|
the "owners only" gate is defeated. **Verify, don't assume:**
|
||||||
|
|
||||||
|
- Grav's Login plugin disables page caching for authenticated sessions by default.
|
||||||
|
- The theme already serves owner-only draft *content* inline today, so this exposure is
|
||||||
|
presumably mitigated somewhere already.
|
||||||
|
|
||||||
|
Add an explicit **anonymous-request assertion** (draft route → 404 for anon, even
|
||||||
|
after an authenticated hit warmed any cache).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Playwright spec:
|
||||||
|
- Authenticated owner → `GET /trips/<draft-slug>` returns 200 and renders the trip page.
|
||||||
|
- Anonymous → same route returns 404.
|
||||||
|
- Anonymous after an authenticated hit → still 404 (cache-leak guard).
|
||||||
|
- Genuinely missing route → 404 for everyone.
|
||||||
|
|
||||||
|
## Effort
|
||||||
|
|
||||||
|
**Low** — roughly half a day including the Playwright spec. Single custom plugin plus an
|
||||||
|
optional small theme partial for the draft banner.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Investigation session: 2026-07-08 ("hotfixes").
|
||||||
|
- Pattern to copy: `user/plugins/cache-on-save/`.
|
||||||
|
- Related owner-aware theme logic: `templates/trip.html.twig` (`owner_can_edit`),
|
||||||
|
`templates/trips.html.twig` (`is_owner`), `templates/home.html.twig`.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Blueprint Vetting — Research & Recommendation
|
||||||
|
|
||||||
|
**Status:** 📋 Not started
|
||||||
|
**Date:** 2026-07-08
|
||||||
|
**Scope:** All custom Grav blueprints (intotheeast theme page blueprints, theme blueprint, site-config extension). Stock Quark blueprints excluded.
|
||||||
|
|
||||||
|
## Files reviewed
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `user/themes/intotheeast/blueprints/entry.yaml` | Daily journal entry (Admin form) |
|
||||||
|
| `user/themes/intotheeast/blueprints/story.yaml` | Story pages |
|
||||||
|
| `user/themes/intotheeast/blueprints/trip.yaml` | Trip pages |
|
||||||
|
| `user/themes/intotheeast/blueprints/home.yaml` | Home page |
|
||||||
|
| `user/themes/intotheeast/blueprints.yaml` | Theme blueprint (identity only) |
|
||||||
|
| `user/blueprints/config/site.yaml` | Site-config extension (`active_trip`, `travelling`) |
|
||||||
|
|
||||||
|
## What's already good
|
||||||
|
|
||||||
|
- Toggle idiom is correct and consistent everywhere: `options: {1: Yes, 0: No}` + `validate: type: bool`.
|
||||||
|
- `trip.yaml` `autoconnect` keys `'on'`/`'off'` are properly quoted — avoids the YAML 1.1 boolean footgun (`on:` parsing as `true:`). `default: 'on'` is quoted too.
|
||||||
|
- `user/blueprints/config/site.yaml` follows the standard Grav pattern for extending system site config (fields merge into Admin → Configuration → Site); `validation: loose` present; the `pages` field options (`start_route`, `show_root`, `show_slug`) are all real options.
|
||||||
|
- `entry.yaml` correctly uses `@extends: {type: default, context: blueprints://pages}` and adds its fields as a new tab, so entries keep the full standard Admin UI.
|
||||||
|
- Required-field validation on story/home titles and story content is in place.
|
||||||
|
- `weather_temp_c` has sensible min/max bounds (−60…60).
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### F1 — Only `entry.yaml` extends the default page blueprint (structural)
|
||||||
|
|
||||||
|
`story.yaml`, `trip.yaml`, and `home.yaml` define `form.fields.tabs` from scratch (no `@extends`). In Admin2 those page types show **only** the declared fields — no Options/Advanced tabs, so no slug rename, no ordering, no visibility, no publish dates, no taxonomy from Admin. The custom `header.published` toggles in story/trip partially compensate.
|
||||||
|
|
||||||
|
If the locked-down UI is deliberate, entry is the inconsistent one; if not, story/trip lose real capabilities (they're created repeatedly and may need slug/ordering control).
|
||||||
|
|
||||||
|
**Implementation note if extending:** story/trip use a tab key `content`, which collides with the default blueprint's Content tab — fields merge by key, so the duplicate `header.title`/`content` definitions override rather than duplicate, but the merged result needs a visual check in Admin. Their custom `header.published` toggle also becomes redundant with the default Options-tab toggle — keep one.
|
||||||
|
|
||||||
|
### F2 — `lat`/`lng` are free-text with no validation (data integrity)
|
||||||
|
|
||||||
|
`entry.yaml:27-35` and `story.yaml:64-74` declare latitude/longitude as plain `type: text`. Templates pipe the values straight into `number_format(6, …)` (`user/themes/intotheeast/templates/trip.html.twig:62`, `templates/home.html.twig:56`). PHP casts silently:
|
||||||
|
|
||||||
|
- European decimal comma `"35,0116"` → `35.000000` (marker subtly wrong)
|
||||||
|
- non-numeric garbage → `0.000000` (marker in the Gulf of Guinea)
|
||||||
|
|
||||||
|
No error surfaces anywhere. Fix: `validate: { type: float, min: -90, max: 90 }` for lat, `±180` for lng.
|
||||||
|
|
||||||
|
### F3 — `transport_mode` option drift (copy-paste divergence)
|
||||||
|
|
||||||
|
Entry offers `plane` (`entry.yaml:77`); story doesn't (`story.yaml:80-86`). The field — along with lat/lng, location, `force_connect` — is duplicated between the two blueprints, which is how drift happens. Grav supports shared partials via `import@`; in-repo example: `user/themes/quark/blueprints/blog.yaml:90` importing `partials/blog-bits.yaml`.
|
||||||
|
|
||||||
|
### F4 — `hero_image` UX inconsistency
|
||||||
|
|
||||||
|
Trip uses `pagemediaselect` (dropdown of uploaded media, `trip.yaml:40-44`); entry and story use free-text filename fields (`entry.yaml:60-64`, `story.yaml:33-37`) where a typo silently breaks the hero. `pagemediaselect` keeps the "blank = first image" fallback while removing typo risk.
|
||||||
|
|
||||||
|
### F5 — Minor items
|
||||||
|
|
||||||
|
| Item | Location | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| `travelling` default mismatch | `user/blueprints/config/site.yaml:15` | `default: false` vs option keys `1`/`0`; works via loose comparison, but `default: 0` matches every other toggle |
|
||||||
|
| Date type drift | `story.yaml:20-31` vs `trip.yaml:28-38` | story: `datetime` + `format: 'Y-m-d'` (the deliberate Admin2 datepicker fix); trip: plain `date`. Pick one convention |
|
||||||
|
| `<br>` in help text | `trip.yaml:61,73` | If Admin2 escapes HTML in help tooltips, users see literal `<br>` tags |
|
||||||
|
| `weather_temp_c` step | `entry.yaml:52-58` | HTML number inputs default to step 1 → `19.5` may be rejected client-side; fine if whole degrees are intended |
|
||||||
|
| `pagemediaselect` accept filter | `trip.yaml:42` | Extension-style `accept: ['.jpg', …]` is the filepicker convention; unverified against Admin2's SPA implementation |
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Treat as one small milestone in three parts, in this order:
|
||||||
|
|
||||||
|
### Phase 1 — Data-integrity + drift fixes (no decisions needed, low risk)
|
||||||
|
|
||||||
|
1. **F2:** add `validate: { type: float, min/max }` to all four lat/lng fields (entry + story).
|
||||||
|
2. **F3:** extract a shared theme partial `user/themes/intotheeast/blueprints/partials/` (e.g. `location-bits.yaml`) holding location name/country, lat/lng (with the new validation), `transport_mode` (superset incl. `plane`), and `force_connect`; `import@` it from entry and story. Follow the Quark example.
|
||||||
|
3. **F5 quick fixes:** `travelling` default → `0`; standardize date fields on `datetime` + `format: 'Y-m-d'` (matches the established Admin2 datepicker fix).
|
||||||
|
|
||||||
|
### Phase 2 — Structural decision (needs Mischa's call)
|
||||||
|
|
||||||
|
4. **F1:** recommended: add `@extends: default` to **story and trip** (repeatedly-created content pages that benefit from slug/ordering/options control); leave **home** minimal (singleton whose slug must never change). Resolve the Content-tab merge and duplicate-published-toggle notes above. Verify each Admin form visually after the change.
|
||||||
|
5. **F4:** switch entry + story `hero_image` to `pagemediaselect` (naturally bundles with the Phase 2 Admin verification pass).
|
||||||
|
|
||||||
|
### Verify-once checklist (manual, 5 minutes in Admin2)
|
||||||
|
|
||||||
|
- [ ] Trip page → Cover Image dropdown: do `.gpx` files appear? (If yes, the `accept` filter isn't applying — F5.)
|
||||||
|
- [ ] `use_gpx` / `autoconnect` help tooltips: rendered line breaks or literal `<br>`?
|
||||||
|
- [ ] Decide: whole-degree temperatures OK, or add `step` to `weather_temp_c`?
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Post form (`/post`) field parity — separate surface, not touched by this vetting.
|
||||||
|
- Theme blueprint (`blueprints.yaml`) — minimal but valid; no theme options exist yet, nothing to add.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **F1:** Is the locked-down Admin UI for story/trip/home deliberate? (Recommendation above assumes it isn't for story/trip.)
|
||||||
|
2. Should `transport_mode` for stories include `plane` (superset) or stay intentionally narrower?
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Upgrade & Deploy Cycle: local → test → prod
|
||||||
|
|
||||||
|
This runbook is the repeatable procedure for shipping a Grav upgrade or any
|
||||||
|
server-affecting change (core version, plugins, config, theme) through the three
|
||||||
|
environments. It was distilled from the 2026-07 Grav 2.0.4→2.0.7 cutover, where
|
||||||
|
every production surprise traced back to one of the desyncs this procedure now
|
||||||
|
forces you to check.
|
||||||
|
|
||||||
|
**Governing principle:** `test` is a **full dress rehearsal of `prod`** — same
|
||||||
|
config, same `-test`/`-prod` make targets, same order. A gotcha only gets caught
|
||||||
|
on test if test is a faithful mirror of prod. Do not shortcut test.
|
||||||
|
|
||||||
|
All server operations go through `make remote-*` targets (never raw SSH — the
|
||||||
|
targets build the SSH connection from `.env.<env>`, which must never be read
|
||||||
|
directly). Every `remote-*` target has `-test` and `-prod` variants; a bare
|
||||||
|
target fails via `guard-env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The mental model: three places state lives
|
||||||
|
|
||||||
|
Every failure in the reference cutover was a desync between these three layers.
|
||||||
|
Before and after each deploy step, ask: *are they in sync?*
|
||||||
|
|
||||||
|
| Layer | Location | Synced by | Failure mode |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Plugin **code** | `user/plugins/<name>/` | GPM only (gitignored `/plugins/*`) | can vanish while config remains → plugin won't enable |
|
||||||
|
| **Repo config** | `user/config/…` | `content-push` / git-sync | holds GPM channel + is where the version floor bites |
|
||||||
|
| **Host config** | `user/env/<host>/config/…` | nothing — server-only | not restored on fresh install; must be re-applied; **must be gitignored** |
|
||||||
|
|
||||||
|
Referenced gotcha docs:
|
||||||
|
- `docs/solutions/integration-issues/grav-plugin-config-without-code-wont-enable.md` — code-vs-config desync.
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — stale `GRAV_VERSION` / version floor.
|
||||||
|
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — gitignore is the sync boundary; env-tree leak.
|
||||||
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — plugin config must live in the tracked override.
|
||||||
|
|
||||||
|
These three layers describe the **servers**. Locally there is a fourth: the Grav
|
||||||
|
**core** is baked into the Docker **image** (`Dockerfile`), not in any layer above —
|
||||||
|
so the local core upgrades by an image rebuild, never by the `gpm self-upgrade` the
|
||||||
|
servers use. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The env override tree (`user/env/<host>/`)
|
||||||
|
|
||||||
|
Prod needs different Twig settings than dev. These are **never** committed to
|
||||||
|
`user/config/system.yaml` — `twig.cache: false` and `debug`/`auto_reload: true`
|
||||||
|
are the *intended dev values*, and committing prod values there breaks local
|
||||||
|
development for everyone. Instead they ship as a per-environment override via
|
||||||
|
Grav's `environment://config`, keyed on the request hostname.
|
||||||
|
|
||||||
|
| Setting | Dev (committed) | Prod (override) | Why prod differs |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `twig.cache` | `false` | `true` | Compile templates once and reuse |
|
||||||
|
| `twig.debug` | `true` | `false` | No debug functions in prod |
|
||||||
|
| `twig.auto_reload` | `true` | `false` | Don't stat templates every request |
|
||||||
|
|
||||||
|
- **Source of truth:** `deploy/env/prod/system.yaml` (version-controlled).
|
||||||
|
- **Deploy:** `make remote-apply-env-prod` — writes it to
|
||||||
|
`<webroot>/user/env/<hostname>/config/system.yaml` and clears cache. It
|
||||||
|
deep-merges over the committed `system.yaml`.
|
||||||
|
- **Hostname segment** defaults to `REMOTE_HOST`; override with `WEB_HOST` in
|
||||||
|
`.env.<env>` if Grav sees a different host than the SSH host.
|
||||||
|
- **Not restored by anything.** `user/env/` is outside the content repo's tracked
|
||||||
|
folders, so `content-push` / git-sync / `remote-fetch-content` do **not** bring
|
||||||
|
it back. **Re-run `make remote-apply-env-<env>` after any fresh install.**
|
||||||
|
|
||||||
|
### Side effect: Admin writes ALL config into the env tree
|
||||||
|
|
||||||
|
Once `user/env/<hostname>/` exists, Grav's Admin saves **every** config change
|
||||||
|
(system *and* plugin) there — e.g. editing a plugin on prod writes
|
||||||
|
`user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
|
||||||
|
`user/config/plugins/<name>.yaml`. Consequences:
|
||||||
|
|
||||||
|
- Config edited via **Admin on the server is server-only**: the env tree is not
|
||||||
|
committed and not synced by git-sync (which syncs only `pages`/`config`/
|
||||||
|
`themes`), so prod Admin edits silently never reach Gitea or local. This is
|
||||||
|
*good* for secrets — `git-sync.yaml` (token), the JWT and CSRF salt safely
|
||||||
|
live there — but it means config drift is invisible to the repo.
|
||||||
|
- When reading or writing server config, check **both** `user/config/…` and
|
||||||
|
`user/env/<host>/config/…` (env wins). Server tooling must search the env path
|
||||||
|
first — see `scripts/git-sync-toggle.sh` and `make remote-diag`.
|
||||||
|
- Repo-authored config (`user/config/…` via `make content-push`) still applies
|
||||||
|
everywhere; the env tree holds only per-host overrides + Admin-on-server edits.
|
||||||
|
|
||||||
|
Full details: `docs/working/git-sync-notes.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Local (author + prove the change)
|
||||||
|
|
||||||
|
1. Make the change in the repo:
|
||||||
|
- GPM channel: `gpm.releases: stable` in `user/config/system.yaml` (authoritative; reaches servers via content pull, so it must be right **before** any server GPM op).
|
||||||
|
- `plugins.txt` — the GPM-managed set only. **Never** add `git-sync` (it is remote-only).
|
||||||
|
- Prod-only overrides (Twig cache/debug, `debugger.shutdown.close_connection: false`) in `deploy/env/prod/system.yaml` — **never** commit prod values into `user/config/system.yaml`.
|
||||||
|
- **Bump `GRAV_VERSION` in `.env.test` and `.env.prod`** to the target version. A stale value here installs the wrong core (an rc), which then blocks the `api` plugin and 404s admin. This governs fresh **remote** installs only.
|
||||||
|
- **If the core version is changing, upgrade the local dev core too** so you prove the change against the target version — bump the hardcoded `grav-admin-v<ver>.zip` URL in `Dockerfile`, `docker compose build grav`, then `docker rm -f intotheeast_grav && docker compose up -d grav`. The local core is baked into the image, so `.env GRAV_VERSION` does *not* touch it and an in-container `gpm self-upgrade` is non-durable. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
|
2. `make build-assets` if you touched `js/src/*` (never hand-edit the bundled `js/*.js`).
|
||||||
|
3. Run the dev server (`docker compose … up`) and the Playwright suite.
|
||||||
|
4. Pre-flight assertions:
|
||||||
|
- `gpm.releases` is `stable`.
|
||||||
|
- `plugins.txt` is correct and does **not** contain `git-sync`.
|
||||||
|
- No prod Twig values leaked into the committed `system.yaml`.
|
||||||
|
5. Commit. `make content-push`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Test (the rehearsal — catch things here)
|
||||||
|
|
||||||
|
### Pre-flight
|
||||||
|
|
||||||
|
- `make remote-git-sync-disable-test` **before any content reset.** This is the safety catch for the whole window: it stops a half-migrated state (e.g. a fresh install-time `versions.yaml`) from auto-committing and pushing on the first sync.
|
||||||
|
|
||||||
|
### Apply — in this fixed order
|
||||||
|
|
||||||
|
```
|
||||||
|
make remote-fetch-content-test # 1. clean-reset synced folders to repo state
|
||||||
|
make remote-upgrade-grav-test # 2. gpm self-upgrade (rewrites schema — expect drift)
|
||||||
|
make remote-update-plugins-test # 3. gpm update the plugins.txt set (auto-applies deploy/patches/)
|
||||||
|
make remote-gpm-install-test PKG=git-sync # 4. EXPLICITLY (re)install each remote-only plugin
|
||||||
|
make remote-apply-env-test # 5. re-deploy the env override (not synced; gone after install)
|
||||||
|
make remote-warmup-test # 6. clear + warm cache — a reset deploy leaves it stale
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Always finish a deploy with `remote-warmup-<env>`** — even a content-only
|
||||||
|
> deploy. A `reset --hard` (step 1) changes files under Grav without going
|
||||||
|
> through it, so the compiled-Twig/page cache is stale and the first visitor
|
||||||
|
> eats the recompile. `remote-warmup` clears the cache, then crawls the public
|
||||||
|
> pages (homepage + trips listing + every trip page linked from it) to
|
||||||
|
> pre-render them. Grav has no native warmup command — this is an HTTP crawl, so
|
||||||
|
> it also doubles as a smoke test (a non-2xx on any page is flagged loudly).
|
||||||
|
|
||||||
|
Why each matters:
|
||||||
|
- **Step 3** re-applies `deploy/patches/*.patch` automatically (it chains `remote-apply-plugin-patches`). GPM install/update lays down **pristine** third-party plugins, wiping local fixes to git-ignored `user/plugins/` — the patch step restores them. Content pulls (step 1) do **not** touch `plugins/`, so the patch only needs re-applying after a GPM op, not after every sync. Run `make remote-apply-plugin-patches-test` standalone if you ever GPM-install outside this sequence. Requires the `patch` tool on the server. See `deploy/patches/README.md`.
|
||||||
|
- **Step 4** is non-optional even if git-sync "was already there" — remote-only plugins are not in `plugins.txt`, so nothing in steps 1–3 restores them. If the code is missing, the plugin is inert despite valid config.
|
||||||
|
- **Step 5** re-writes `user/env/<host>/config/…` from `deploy/env/<env>/`. The env tree is not synced by anything, so a fresh install loses it until you re-apply.
|
||||||
|
|
||||||
|
### Verify (smoke checklist — this is the payoff)
|
||||||
|
|
||||||
|
- **Code present, not just config:** `ls user/plugins/<name>/` for every expected plugin (especially `git-sync`). An empty/absent dir = reinstall (step 4). *(Do this via an ssh one-liner you run, or `make remote-diag-test`.)*
|
||||||
|
- **Plugin patches applied:** confirm the add-page-by-form fix survived the GPM op — `grep -c toArray user/plugins/add-page-by-form/add-page-by-form.php` should be ≥1 (0 = pristine, re-run `make remote-apply-plugin-patches-test`). Functional check: edit a journal entry and add a photo — a pristine plugin 500s on save.
|
||||||
|
- **HTTP:** `/` → 200, `/admin` → 200, `/api/v1/pages` → 401, `/gpx-manager` → 200. Watch for the double-`Content-Encoding` garbage page (fix: `debugger.shutdown.close_connection: false` in the env override — already in `deploy/env/prod/system.yaml`).
|
||||||
|
- **Post smoke test:** submit one entry via `/post` and confirm it appears in the trip feed immediately. This proves the `cache-on-save` plugin works with prod caching on.
|
||||||
|
- **Config drift:** `make remote-diag-test` — diff server config against the repo. Fold any *intended* schema migration (e.g. the Twig-3 `strict_mode` flags a `self-upgrade` writes) back into `user/config/system.yaml`, or the next `fetch-content` reverts it.
|
||||||
|
|
||||||
|
### Re-enable + prove sync
|
||||||
|
|
||||||
|
- `make remote-git-sync-enable-test`.
|
||||||
|
- Confirm a content push round-trips to the server, **and** that no secret/boomerang commit lands on Gitea. Verify `/env/` is gitignored so the env tree (which holds the token, JWT, CSRF salt) can never enter the sync add-set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Prod (repeat identically — should be mechanical)
|
||||||
|
|
||||||
|
Run the **exact same sequence** with `-prod` targets. Because test rehearsed it,
|
||||||
|
prod holds no surprises. Differences to layer on:
|
||||||
|
|
||||||
|
- Optional: `make remote-maintenance-on-prod` at the start, `remote-maintenance-off-prod` at the end, for a clean window.
|
||||||
|
- Confirm secrets are valid/rotated and `/env/` is gitignored **before** `remote-git-sync-enable-prod`. Re-enable git-sync **last**.
|
||||||
|
- After a clean cutover, bump the outer-repo submodule pin to the finished `user/` commit — and **push `user/` before the outer repo** (the superproject references a child SHA that must already exist upstream).
|
||||||
|
|
||||||
|
```
|
||||||
|
make remote-git-sync-disable-prod
|
||||||
|
make remote-fetch-content-prod
|
||||||
|
make remote-upgrade-grav-prod
|
||||||
|
make remote-update-plugins-prod
|
||||||
|
make remote-gpm-install-prod PKG=git-sync
|
||||||
|
make remote-apply-env-prod
|
||||||
|
make remote-warmup-prod # clear + warm cache; also HTTP-smokes public pages
|
||||||
|
# ── smoke checklist (same as test) ──
|
||||||
|
make remote-git-sync-enable-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
For a first-time / from-scratch prod bring-up, `make remote-install-prod` does the
|
||||||
|
full install; then still run `remote-apply-env-prod` and the smoke checklist, and
|
||||||
|
reinstall remote-only plugins explicitly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback & safety
|
||||||
|
|
||||||
|
- **git-sync stays disabled through the whole apply window** on each host — it is the catch that prevents a half-migrated state from auto-pushing.
|
||||||
|
- **Content** is a git repo: a bad content deploy is recoverable with `make remote-fetch-content-<env>` back to a known commit.
|
||||||
|
- **Core + plugins** are GPM-reinstallable (`remote-upgrade-grav`, `remote-update-plugins`, `remote-gpm-install PKG=…`).
|
||||||
|
- The one thing tooling cannot regenerate is the un-synced `user/env/<host>/` tree — its source of truth is `deploy/env/<env>/`, so keep that current and re-apply with `remote-apply-env-<env>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-line invariants (the through-line)
|
||||||
|
|
||||||
|
1. `test` is config-identical to `prod`, run with the same targets in the same order.
|
||||||
|
2. Verify the **code layer** (`ls user/plugins/<name>/`), not just config, on every deploy.
|
||||||
|
3. Reinstall **remote-only** plugins (git-sync) explicitly — nothing else restores them.
|
||||||
|
4. `GRAV_VERSION` in `.env.<env>` and `gpm.releases: stable` are correct **before** any server GPM op.
|
||||||
|
5. Re-apply the **env override** after every install; keep `/env/` **gitignored**.
|
||||||
|
6. git-sync **off** during the window, **on** last; confirm the round-trip carries no secrets.
|
||||||
|
7. Diagnose actual state before changing config — an `ls` or `remote-diag` beats a guess.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Managing GPX Files
|
||||||
|
|
||||||
|
GPX route files live as media on the active trip page. The map picks them up automatically — any `.gpx` file in `user/pages/01.trips/<active_trip>/` appears on the trip map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser UI — /gpx-manager
|
||||||
|
|
||||||
|
The GPX manager at `/gpx-manager` requires admin login (redirects to login form if not authenticated).
|
||||||
|
|
||||||
|
### Upload a file
|
||||||
|
|
||||||
|
1. Open `/gpx-manager` (login required)
|
||||||
|
2. Click **Choose file** → select your `.gpx` file
|
||||||
|
3. Click **Upload**
|
||||||
|
4. The filename is auto-slugified before upload: spaces and special characters become hyphens, everything becomes lowercase.
|
||||||
|
- Example: `Day 1 — Arrival (Kyoto).gpx` → `day-1-arrival-kyoto.gpx`
|
||||||
|
5. The file appears in the list immediately
|
||||||
|
|
||||||
|
### Delete a file
|
||||||
|
|
||||||
|
1. Find the file in the list at `/gpx-manager`
|
||||||
|
2. Click **Delete** next to it
|
||||||
|
3. Confirm — the file is removed from the trip media and will no longer appear on the map
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Without the browser UI
|
||||||
|
|
||||||
|
Drop the file directly into the trip folder and push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp your-route.gpx /path/to/user/pages/01.trips/denmark-2026/
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
|
|
||||||
|
`make content-push` commits and pushes the `user/` repo to Gitea, which triggers a production pull via webhook.
|
||||||
|
|
||||||
|
**Filename tip:** slug your filename before dropping it — lowercase, hyphens only:
|
||||||
|
```
|
||||||
|
day-1-kyoto.gpx ✅
|
||||||
|
Day 1 Kyoto.gpx ⚠️ works but slugified on upload; skip this if dropping manually
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filename slugification rules
|
||||||
|
|
||||||
|
The browser UI slugifies client-side before upload. Manually placed files are used as-is, so name them cleanly.
|
||||||
|
|
||||||
|
Rules applied by the UI:
|
||||||
|
- Lowercase everything
|
||||||
|
- Replace spaces with hyphens
|
||||||
|
- Replace non-alphanumeric characters (except `.`) with hyphens
|
||||||
|
- Collapse multiple consecutive hyphens to one
|
||||||
|
- Strip leading/trailing hyphens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Komoot workflow (no API integration yet)
|
||||||
|
|
||||||
|
Komoot doesn't offer GPX export via API without authentication. Current workaround:
|
||||||
|
|
||||||
|
1. Open your tour in the Komoot app or website
|
||||||
|
2. **More → Export → GPX track** (available on Komoot Premium; free users get a limited version)
|
||||||
|
3. Save the `.gpx` file to your phone or laptop
|
||||||
|
4. Upload via `/gpx-manager` or drop into the trip folder
|
||||||
|
|
||||||
|
Future: a Komoot integration field in the GPX manager (paste tour URL → server fetches GPX) is in the backlog at [`working/backlog.md`](../working/backlog.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How files are served
|
||||||
|
|
||||||
|
GPX files are registered as a valid media type in `user/config/media.yaml`, so Grav stores and serves them alongside images. The map template picks them up via:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% for file in trip_page.media.all %}
|
||||||
|
{% if file.filename ends with '.gpx' %}
|
||||||
|
{# add to map source list #}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
No manual linking is needed — upload and it appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the manager is wired
|
||||||
|
|
||||||
|
| Piece | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Page | `user/pages/03.gpx-manager/` |
|
||||||
|
| Template | `user/themes/intotheeast/templates/gpx-manager.html.twig` |
|
||||||
|
| Auth | Login plugin, via `access.admin.login: true` in the page frontmatter — renders the login form when unauthenticated |
|
||||||
|
| API | Grav API v1 with **session cookie** auth (`session_enabled: true` in `user/plugins/api/api.yaml`) |
|
||||||
|
|
||||||
|
API calls the page makes:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/pages{route}/media # list
|
||||||
|
POST /api/v1/pages{route}/media # upload (multipart)
|
||||||
|
DELETE /api/v1/pages{route}/media/{filename} # delete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload gotcha:** the selected file is sliced into a plain `Blob` before `FormData.append`, so the third argument is always honoured as the filename. Appending the original `File` lets the browser keep the unslugified name and the slugification is silently ignored.
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Local Development Setup
|
||||||
|
|
||||||
|
This guide covers setting up the dev environment from scratch after cloning the repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
`user/plugins/` and `user/data/` are excluded from git but Grav requires them to exist. Create them once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/plugins user/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup
|
||||||
|
```
|
||||||
|
|
||||||
|
`make setup` = `build → start → install-plugins → fix-perms`. This builds the Docker image (Grav 2.0 baked in), starts the container, installs all plugins from `plugins.txt`, and fixes file ownership.
|
||||||
|
|
||||||
|
The dev server runs at **http://localhost:8081**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After any docker compose down
|
||||||
|
|
||||||
|
Always run `make setup` — not just `make start` — to ensure permissions are correct.
|
||||||
|
|
||||||
|
`docker compose restart` (soft restart) preserves the image and is fine for quick restarts. Only `make setup` is needed after `docker compose down`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 500 errors after plugin install
|
||||||
|
|
||||||
|
If the site returns a 500 error after plugin installation or after recreating the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fix-perms
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates uid 1000 in the container, chowns `/var/www/html` to 1000:1000, and reloads Apache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading the Grav core
|
||||||
|
|
||||||
|
The Grav core is baked into the custom Docker image via `Dockerfile`. The base
|
||||||
|
`getgrav/grav` image ships 1.7 — the `Dockerfile` downloads the pinned stable bundle
|
||||||
|
(`grav-admin-v<version>.zip`) from GitHub and overwrites the core files at build time.
|
||||||
|
`docker-compose.yml` volume-mounts only `./user`, so the core lives in the **image
|
||||||
|
layer**. That means you upgrade the core by rebuilding the image, **not** by running
|
||||||
|
`gpm self-upgrade` inside the container — an in-container self-upgrade is lost on the
|
||||||
|
next rebuild. (The servers are the opposite: no image, so they self-upgrade in place.)
|
||||||
|
|
||||||
|
To upgrade:
|
||||||
|
1. Bump **both** occurrences of the version in the `Dockerfile` release URL (the
|
||||||
|
`/download/<ver>/` path and the `grav-admin-v<ver>.zip` filename). Note: the
|
||||||
|
`GRAV_VERSION` in `.env*` does **not** drive this build — it only pins fresh
|
||||||
|
*remote* installs.
|
||||||
|
2. Rebuild: `docker compose build grav`.
|
||||||
|
3. Recreate the container. `docker compose up -d` won't replace an already-running
|
||||||
|
container with a fixed `container_name` (it errors `Conflict … name … already in
|
||||||
|
use`), so remove it first — safe because `./user` is a bind mount:
|
||||||
|
```bash
|
||||||
|
docker rm -f intotheeast_grav && docker compose up -d grav
|
||||||
|
```
|
||||||
|
4. Verify: `docker exec -w /var/www/html intotheeast_grav php bin/grav --version`.
|
||||||
|
5. Refresh plugins and clear cache: `make install-plugins` then
|
||||||
|
`docker exec -w /var/www/html intotheeast_grav php bin/grav cache`.
|
||||||
|
|
||||||
|
Full rationale and the server-vs-local contrast:
|
||||||
|
`docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required system.yaml settings (Grav 2.0)
|
||||||
|
|
||||||
|
After upgrading, verify these are set in `user/config/system.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
accounts:
|
||||||
|
type: flex # required for Admin2 API
|
||||||
|
pages:
|
||||||
|
type: flex # required for Admin2 pages API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin user API permissions
|
||||||
|
|
||||||
|
The admin user account needs `api.*` permissions for Admin2. In `user/accounts/<username>.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
access:
|
||||||
|
admin:
|
||||||
|
login: true
|
||||||
|
super: true
|
||||||
|
api:
|
||||||
|
super: true
|
||||||
|
access: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disable the old admin plugin
|
||||||
|
|
||||||
|
Both `admin` and `admin2` route to `/admin` and conflict. After installing `admin2`, disable the old one:
|
||||||
|
|
||||||
|
In `user/plugins/admin/admin.yaml`:
|
||||||
|
```yaml
|
||||||
|
enabled: false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JWT secret
|
||||||
|
|
||||||
|
Leave `jwt_secret: ''` in `user/plugins/api/api.yaml`. It works for local dev; production installs generate a secure secret automatically during `make remote-install`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Language URL prefix
|
||||||
|
|
||||||
|
If Grav redirects to `/en/...` URLs, ensure `user/config/system.yaml` contains:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
languages:
|
||||||
|
supported: [en]
|
||||||
|
include_default_lang: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `include_default_lang: false`, Grav adds a language prefix to all URLs even for single-language sites.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Posting a Journal Entry
|
||||||
|
|
||||||
|
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. **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Form fields reference
|
||||||
|
|
||||||
|
| 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, 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) |
|
||||||
|
| 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works (for debugging)
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → /post (post-form.md)
|
||||||
|
└─ Grav Form plugin validates fields
|
||||||
|
└─ cache-on-save injects parent from site.active_trip
|
||||||
|
└─ 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
|
||||||
|
└─ form shows success message
|
||||||
|
```
|
||||||
|
|
||||||
|
**Slug format:** `<YYYY-MM-DD-HHmm>-<slugified-title>.entry`
|
||||||
|
Example: `2026-07-20-0930-first-day-in-kyoto.entry`
|
||||||
|
|
||||||
|
**Entry folder structure:**
|
||||||
|
```
|
||||||
|
user/pages/01.trips/denmark-2026/01.dailies/
|
||||||
|
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
||||||
|
├─ 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 **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**
|
||||||
|
3. Set **Parent Page** to `/trips/<active_trip>/dailies` and **Template** to `entry`
|
||||||
|
4. Fill in the **Entry** tab (city, country, lat/lng, weather)
|
||||||
|
5. Write content in the **Content** tab
|
||||||
|
6. Upload photos in the **Media** tab
|
||||||
|
7. **Drafts:** set `published: false` — won't appear until you flip it to `true`
|
||||||
|
8. **Scheduling:** set `publish_date` in **Options → Scheduling**
|
||||||
|
9. Save
|
||||||
|
|
||||||
|
The Admin form fields are defined by `user/themes/intotheeast/blueprints/entry.yaml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontmatter reference
|
||||||
|
|
||||||
|
Every entry supports these frontmatter fields:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | string | Required |
|
||||||
|
| `date` | datetime | Format: `Y-m-d H:i` (e.g. `2026-06-17 10:00`) |
|
||||||
|
| `template` | string | Always `entry` |
|
||||||
|
| `published` | bool | `true` to show in feed |
|
||||||
|
| `lat` | string | Decimal degrees (e.g. `52.3676`) |
|
||||||
|
| `lng` | string | Decimal degrees (e.g. `4.9041`) |
|
||||||
|
| `location_city` | string | e.g. `Kyoto` |
|
||||||
|
| `location_country` | string | e.g. `Japan` |
|
||||||
|
| `weather_desc` | string | One of the allowed values above |
|
||||||
|
| `weather_temp_c` | number | Celsius, displayed rounded |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Entry doesn't appear in feed after submit**
|
||||||
|
→ Check `active_trip` in `user/config/site.yaml` — the write target is derived from it at submit time, so a wrong value sends entries to the wrong trip's dailies. See [trip switching guide](trip-switching.md).
|
||||||
|
|
||||||
|
**Get Weather button shows an error**
|
||||||
|
→ Fill in Lat/Lng first (tap Get Location or enter manually). Open-Meteo requires coordinates.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Switching to a New Trip
|
||||||
|
|
||||||
|
The active trip lives in **one** place: `user/config/site.yaml` → `active_trip`. Set it, create the new page tree, push.
|
||||||
|
|
||||||
|
> **Changed 2026-07:** this used to require editing two files in lockstep (`site.yaml` **and** `post-form.md` → `pageconfig.parent`), and they silently desynced. The `cache-on-save` plugin now derives the write target from `site.active_trip` at submit time (`onFormValidationProcessed` → `setData('parent', …)`), so `post-form.md` no longer carries a `parent` at all. **Do not re-add one** — it would override the derived target and reintroduce the desync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Update `user/config/site.yaml` → `active_trip`
|
||||||
|
- [ ] Create the new trip page tree (see below)
|
||||||
|
- [ ] Run `make content-push` to push the changes to production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Update site.yaml
|
||||||
|
|
||||||
|
In `user/config/site.yaml`, set `active_trip` to the new trip's **route**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
active_trip: /trips/denmark-2026 # ← change this
|
||||||
|
```
|
||||||
|
|
||||||
|
The final segment must exactly match the folder name under `user/pages/01.trips/`.
|
||||||
|
|
||||||
|
You can also set this from Admin → Configuration → Site → **Active Trip** (a page-picker rooted at `/trips`; blueprint at `user/blueprints/config/site.yaml`).
|
||||||
|
|
||||||
|
> `system.yaml` → `home.alias` is permanently `/home` (the real home page) and does **not** change when switching trips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Create the new trip page tree
|
||||||
|
|
||||||
|
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
user/pages/01.trips/denmark-2026/
|
||||||
|
├─ trip.md ← title, date_start, date_end, cover_image, album_url
|
||||||
|
├─ *.gpx ← route files (optional; page media, auto-detected)
|
||||||
|
├─ 01.dailies/
|
||||||
|
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false
|
||||||
|
└─ 04.stories/
|
||||||
|
└─ stories.md ← inert container: template: default, routable: false, visible: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
|
||||||
|
|
||||||
|
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable. Do **not** recreate `02.map/` or `03.stats/`.
|
||||||
|
|
||||||
|
Fields in `trip.md` to update:
|
||||||
|
|
||||||
|
| Field | Example | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | `Denmark 2026` | Displayed in nav and trip header |
|
||||||
|
| `date_start` | `2026-07-15` | Used for "X days on the road" stat |
|
||||||
|
| `date_end` | *(leave blank while travelling)* | Set when you return |
|
||||||
|
| `cover_image` | `cover.jpg` | Shown on the trips listing page |
|
||||||
|
| `album_url` | `https://...` | Optional external album link |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
|
|
||||||
|
This commits and pushes the `user/` repo to Gitea. The webhook triggers a production pull automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
After pushing, check:
|
||||||
|
1. Home page shows the new trip (title and date)
|
||||||
|
2. Submit a test entry via `/post` — verify it lands under `user/pages/01.trips/<new-slug>/01.dailies/` and appears in the feed at `/trips/<new-slug>`
|
||||||
|
3. The inline map on `/trips/<new-slug>` shows the correct (empty or GPX-only) state
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# Daily Entry Posting Pipeline
|
|
||||||
|
|
||||||
Two ways to create a daily entry: the mobile frontend form at `/post`, or directly from the Grav Admin2 panel. Both produce the same page structure under `user/pages/01.trips/<active_trip>/01.dailies/`.
|
|
||||||
|
|
||||||
The active trip is set in `user/config/site.yaml` → `active_trip`. The post form's `pageconfig.parent` in `post-form.md` must be kept in sync with this value.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontmatter Reference
|
|
||||||
|
|
||||||
Every entry page (`template: entry`) supports these frontmatter fields:
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `title` | string | ✅ | Entry headline |
|
|
||||||
| `date` | datetime | ✅ | Format: `Y-m-d H:i` (e.g. `2026-06-17 10:00`) |
|
|
||||||
| `template` | string | ✅ | Always `entry` |
|
|
||||||
| `published` | bool | ✅ | `true` to show in tracker feed |
|
|
||||||
| `lat` | string | — | Latitude decimal degrees (e.g. `52.3676`) |
|
|
||||||
| `lng` | string | — | Longitude decimal degrees (e.g. `4.9041`) |
|
|
||||||
| `location_city` | string | — | City name shown under the title (e.g. `Kyoto`) |
|
|
||||||
| `location_country` | string | — | Country name shown under the title (e.g. `Japan`) |
|
|
||||||
| `weather_desc` | string | — | Condition label — must be one of the values below |
|
|
||||||
| `weather_temp_c` | number | — | Temperature in Celsius (displayed rounded, e.g. `19`) |
|
|
||||||
| `hero_image` | string | — | Filename of the hero image (e.g. `photo.jpg`). Leave blank to auto-select the first uploaded image. |
|
|
||||||
|
|
||||||
**`weather_desc` allowed values** (matched to emoji icons in `entry.html.twig`):
|
|
||||||
`Sunny` · `Partly cloudy` · `Cloudy` · `Foggy` · `Drizzle` · `Rain` · `Snow` · `Thunderstorm`
|
|
||||||
|
|
||||||
**Page media (photos):** images are stored as files in the page folder (`user/pages/01.tracker/<slug>/`). All images in the folder are shown in the gallery. `hero_image` pins one as the full-width header.
|
|
||||||
|
|
||||||
**Example complete frontmatter:**
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
title: 'First Day in Kyoto'
|
|
||||||
date: '2026-07-20 09:30'
|
|
||||||
template: entry
|
|
||||||
published: true
|
|
||||||
lat: '35.0116'
|
|
||||||
lng: '135.7681'
|
|
||||||
location_city: 'Kyoto'
|
|
||||||
location_country: 'Japan'
|
|
||||||
weather_desc: 'Sunny'
|
|
||||||
weather_temp_c: 28
|
|
||||||
hero_image: 'temple.jpg'
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flow 1 — Mobile Frontend Form (`/post`)
|
|
||||||
|
|
||||||
This is the primary posting flow, designed for one-handed phone use.
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser → /post (post-form.md)
|
|
||||||
└─ Grav Form plugin validates fields
|
|
||||||
└─ add-page-by-form plugin (onFormProcessed)
|
|
||||||
├─ reads pageconfig.parent (/trips/japan-korea-2026/dailies) and pageconfig.slug_field (date + title)
|
|
||||||
├─ reads pagefrontmatter (template: entry, published: true)
|
|
||||||
├─ merges form field values into new page frontmatter
|
|
||||||
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
|
|
||||||
└─ moves uploaded photos into the page folder
|
|
||||||
└─ cache-on-save plugin (onFormProcessed)
|
|
||||||
└─ calls $grav['cache']->deleteAll() so tracker feed shows the entry immediately
|
|
||||||
└─ form shows success message, resets fields
|
|
||||||
```
|
|
||||||
|
|
||||||
**The form fields and their mapping to frontmatter:**
|
|
||||||
|
|
||||||
| Form field | Frontmatter key | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `title` | `title` | Required |
|
|
||||||
| `date` | `date` | Defaults to current datetime |
|
|
||||||
| `content` | page body (markdown) | Required |
|
|
||||||
| `photos` | page media files | Uploaded to page folder |
|
|
||||||
| `lat` | `lat` | Filled via "Get Location" button |
|
|
||||||
| `lng` | `lng` | Filled via "Get Location" button |
|
|
||||||
| `location_city` | `location_city` | Manual text entry |
|
|
||||||
| `location_country` | `location_country` | Manual text entry |
|
|
||||||
| `weather_temp_c` | `weather_temp_c` | Hidden — set by weather JS widget |
|
|
||||||
| `weather_desc` | `weather_desc` | Hidden — set by weather JS widget |
|
|
||||||
|
|
||||||
**Slug format:** `<YYYY-MM-DD>.<slugified-title>` (controlled by `slug_field: 'date,title'` in `post-form.md`).
|
|
||||||
|
|
||||||
**Security:** the `/post` page requires `access: site.login: true` — anonymous visitors get redirected to login.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flow 2 — Admin Panel (sit-down workflow)
|
|
||||||
|
|
||||||
Use this for drafts, scheduled posts, or editing existing entries.
|
|
||||||
|
|
||||||
1. Log in at `/admin`
|
|
||||||
2. Go to **Pages** → **Add Page**
|
|
||||||
3. Set:
|
|
||||||
- **Page Title:** your entry title
|
|
||||||
- **Parent Page:** `/trips/japan-korea-2026/dailies` (adjust to active trip)
|
|
||||||
- **Page Template:** `entry`
|
|
||||||
4. Fill in the **Entry** tab fields (city, country, lat/lng, weather)
|
|
||||||
5. Write content in the **Content** tab
|
|
||||||
6. Upload photos via the **Media** tab
|
|
||||||
7. Set `published: true` (or leave `false` for a draft)
|
|
||||||
8. For scheduling: set `publish_date` in **Options** → **Scheduling**
|
|
||||||
9. Save
|
|
||||||
|
|
||||||
The Admin form fields are defined by `user/themes/intotheeast/blueprints/entry.yaml`.
|
|
||||||
|
|
||||||
**Drafts:** set `published: false` — the entry won't appear in the tracker feed until you flip it to `true`. Useful for writing ahead of time on the road.
|
|
||||||
|
|
||||||
**Scheduling:** Grav supports `publish_date` and `unpublish_date` in page frontmatter. Set them in the Admin Options tab. Requires `pages.publish_dates: true` in `system.yaml` (already enabled).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page folder structure
|
|
||||||
|
|
||||||
```
|
|
||||||
user/pages/01.trips/
|
|
||||||
└─ japan-korea-2026/ ← trip entity (active_trip in site.yaml)
|
|
||||||
├─ trip.md ← trip page (title, date_start, date_end, cover_image, album_url)
|
|
||||||
├─ *.gpx ← GPX route files (served as media, rendered on map)
|
|
||||||
├─ 01.dailies/
|
|
||||||
│ └─ 2026-07-20-1430-first-day-in-kyoto.entry/
|
|
||||||
│ ├─ entry.md ← frontmatter + markdown body
|
|
||||||
│ ├─ temple.jpg ← hero image (referenced by hero_image)
|
|
||||||
│ └─ market.jpg ← additional gallery image
|
|
||||||
├─ 02.map/map.md
|
|
||||||
├─ 03.stats/stats.md
|
|
||||||
└─ 04.stories/stories.md
|
|
||||||
```
|
|
||||||
|
|
||||||
The entry folder name follows `<YYYY-MM-DD-HHmm>-<slug>.entry`. Grav uses this for ordering and routing. The `.entry` suffix enables the `entry` template.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Production Todo
|
|
||||||
|
|
||||||
Fresh server — no Grav installed yet. Work through these sections in order.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Pre-install: fix server-install.sh for Grav 2.0
|
|
||||||
|
|
||||||
`server-install.sh` has a gap: it copies the `grav-admin` bundle (which includes `user/plugins/admin2/`) but then immediately does `rm -rf user && git clone ...`, which wipes admin2. It never gets reinstalled because GPM doesn't carry Admin2.
|
|
||||||
|
|
||||||
- [ ] Update `server-install.sh` to stash admin2 before wiping user/, then restore it after:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# After "cp -rf grav-admin/. ." and before "rm -rf user":
|
|
||||||
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
|
|
||||||
|
|
||||||
# After "git clone $USER_REPO user" and "mkdir -p user/plugins ...":
|
|
||||||
cp -rf /tmp/admin2-plugin user/plugins/admin2
|
|
||||||
rm -rf /tmp/admin2-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Remove `admin` from `plugins.txt` if it's there — Admin2 replaces it and both conflict on `/admin`
|
|
||||||
|
|
||||||
## 2. Pre-install: configure .env
|
|
||||||
|
|
||||||
- [ ] Set `GRAV_VERSION=2.0.0-rc.9` in `.env`
|
|
||||||
- [ ] Set `GRAV_CHANNEL_SUFFIX=?testing` in `.env` (makes the download URL resolve to the RC)
|
|
||||||
- [ ] Set `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PORT`, `REMOTE_HOME` for the production server
|
|
||||||
- [ ] Set `USER_REPO` and `MAIN_REPO` (Gitea URLs)
|
|
||||||
- [ ] Set `GITEA_HOST`, `GITEA_USER`, `GITEA_TOKEN` for the install-time clone
|
|
||||||
|
|
||||||
## 3. Run the install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make remote-env-setup # writes Gitea token to server temporarily
|
|
||||||
make remote-install # downloads Grav, clones repos, installs plugins
|
|
||||||
make remote-env-remove # removes token from server
|
|
||||||
```
|
|
||||||
|
|
||||||
After install, the script prints the server's SSH public key. Add it as a deploy key to both Gitea repos so `make remote-fetch` works going forward.
|
|
||||||
|
|
||||||
## 4. Post-install: config
|
|
||||||
|
|
||||||
These are already committed to the `user/` repo so they'll be present after the clone — just verify:
|
|
||||||
|
|
||||||
- [ ] `user/config/system.yaml` has `accounts.type: flex` and `pages.type: flex`
|
|
||||||
- [ ] `user/config/system.yaml` `custom_base_url` is set to the production domain (currently set to the local dev IP — update before deploy)
|
|
||||||
- [ ] `user/accounts/mischa.yaml` has `api.super: true` and `api.access: true`
|
|
||||||
- [ ] Disable old admin plugin: set `enabled: false` in `user/plugins/admin/admin.yaml` on production (or ensure it's not in `plugins.txt`)
|
|
||||||
|
|
||||||
## 5. Post-install: switch to production mode
|
|
||||||
|
|
||||||
- [ ] Set `twig.cache: true` in `user/config/system.yaml`
|
|
||||||
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in `/trips/japan-korea-2026/dailies` immediately (verifies cache-on-save plugin works with Twig cache on)
|
|
||||||
|
|
||||||
## 6. Security
|
|
||||||
|
|
||||||
- [ ] Change admin password to a strong production password
|
|
||||||
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
|
|
||||||
|
|
||||||
## 7. Map tiles
|
|
||||||
|
|
||||||
- [ ] Register at [carto.com](https://carto.com) and review terms for production traffic (CartoDB dark tiles are free but registration is expected for production use)
|
|
||||||
|
|
||||||
## 8. Content
|
|
||||||
|
|
||||||
- [ ] Set `date_start` on the Japan & Korea 2026 trip page (`user/pages/01.trips/japan-korea-2026/trip.md`)
|
|
||||||
- [ ] Upload actual GPX route file(s) to the trip page media — currently no GPX files, so the map shows no route
|
|
||||||
- [ ] Add `cover_image` to the trip page (used on the trips listing)
|
|
||||||
- [ ] Run `make content-push` to push any local content changes to Gitea before going live
|
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
How the intotheeast site hangs together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| CMS | Grav 2.0.7 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
|
||||||
|
| Admin | Admin2 v2.0.12 | Plugin slug: `admin2` (not `admin`) |
|
||||||
|
| GPM channel | `stable` | Authoritative in `user/config/system.yaml` → `gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic |
|
||||||
|
| 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 *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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin roles
|
||||||
|
|
||||||
|
The posting pipeline is a chain of three plugins:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser POST /post
|
||||||
|
│
|
||||||
|
├─ Grav Form plugin (built-in)
|
||||||
|
│ └─ validates required fields; handles file uploads
|
||||||
|
│
|
||||||
|
├─ cache-on-save (custom) — onFormValidationProcessed, runs BEFORE the write
|
||||||
|
│ ├─ setData('parent', …) ← derived from site.active_trip
|
||||||
|
│ └─ sets pageconfig.overwrite_mode: edit when the hidden edit_path is filled,
|
||||||
|
│ false when empty (create a fresh dated folder)
|
||||||
|
│
|
||||||
|
├─ add-page-by-form (third-party, patched — see deploy/patches/)
|
||||||
|
│ └─ reads post-form.md config:
|
||||||
|
│ ├─ pageconfig.slug_field → slug from date + title
|
||||||
|
│ └─ pagefrontmatter → template: entry
|
||||||
|
│ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/
|
||||||
|
│ └─ moves uploaded photos into the page folder
|
||||||
|
│
|
||||||
|
└─ cache-on-save (again, post-write)
|
||||||
|
└─ calls $grav['cache']->deleteAll() on every new-entry form submission
|
||||||
|
└─ ensures entries appear in feed immediately in both dev and prod mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Other notable plugins:
|
||||||
|
|
||||||
|
| Plugin | Role |
|
||||||
|
|---|---|
|
||||||
|
| `login` | Auth for /post and /gpx-manager |
|
||||||
|
| `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 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
|
||||||
|
|
||||||
|
Three categories, by how each plugin is installed and maintained:
|
||||||
|
|
||||||
|
1. **GPM-managed** (`plugins.txt` → `make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
|
||||||
|
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`, `entry-actions`. Versioned in the user repo.
|
||||||
|
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Asset pipeline
|
||||||
|
|
||||||
|
`make build-assets` runs the theme's `npm run build` (esbuild) in a throwaway `node:20-alpine` container, as the host uid so outputs land in the tracked theme tree owned by you rather than root.
|
||||||
|
|
||||||
|
| Source | → Output |
|
||||||
|
|---|---|
|
||||||
|
| `js/src/main.js` | `js/main.js` + `css-compiled/main.css` + `fonts/` (font files via the `woff2` loader) |
|
||||||
|
| `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` — 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 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template hierarchy
|
||||||
|
|
||||||
|
All page templates extend `base.html.twig`:
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/
|
||||||
|
├─ 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)
|
||||||
|
├─ 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 `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`.
|
||||||
|
|
||||||
|
### Shared partial contracts
|
||||||
|
|
||||||
|
Two partials are included by **both** `trip.html.twig` and the active branch of `home.html.twig`, via `{% include … with {…} only %}`. The `only` keyword means every value must be passed explicitly — the tables below are the contracts. The rules that govern them (single map path, required map globals, never hand-edit bundles) live in `CLAUDE.md`; these are the parameter details.
|
||||||
|
|
||||||
|
#### `entry-map.html.twig`
|
||||||
|
|
||||||
|
Renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` assigning `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
|
||||||
|
|
||||||
|
| Parameter | Type | Trip passes | Home passes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `map_id` | string | `'trip-map'` | `'home-map'` |
|
||||||
|
| `map_global` | string | `'tripMap'` | `'homeMap'` |
|
||||||
|
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
|
||||||
|
| `card_prefix` | string | `'entry-'` | `'entry-'` |
|
||||||
|
| `story_markers` | bool | `true` (diamond markers) | `false` |
|
||||||
|
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||||
|
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
|
||||||
|
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
|
||||||
|
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
|
||||||
|
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
|
||||||
|
|
||||||
|
#### `trip-feed-col.html.twig`
|
||||||
|
|
||||||
|
The column **beside** the map: date-range header, filter bar, stats/cycling panels, feed loop.
|
||||||
|
|
||||||
|
| Parameter | Type | Trip passes | Home-active passes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `trip_page` | Page | `page` | `trip` |
|
||||||
|
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
|
||||||
|
| `journal_entries` | array | dailies children | dailies children |
|
||||||
|
| `journal_count` / `story_count` | int | counts | counts |
|
||||||
|
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
|
||||||
|
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||||
|
| `gps_points` | array | `gps_points` | `gps_points` |
|
||||||
|
| `show_sort` | bool | `true` | `false` (home keeps its own feed order) |
|
||||||
|
| `trip_header_extras` | bool | `true` | not passed (defaults `false`) |
|
||||||
|
|
||||||
|
`trip_header_extras` gates the trip-page-only header block (one-liner `.home-trip-tagline`, expandable `.trip-header-desc`, `.trip-header-banner` cover strip) rendered between the counts and the filter bar. `home.html.twig` omits it so those extras never leak onto the home route.
|
||||||
|
|
||||||
|
**Sibling:** `home-predeparture.html.twig` is the home-only "Coming soon" landing state, taking only `trip_page`. `home.html.twig` picks it with `{% if all_items|length == 0 %}` → `home-predeparture` `{% else %}` → `trip-feed-col`. Keep `trip-feed-col` single-purpose — do **not** fold the pre-departure branch back into it.
|
||||||
|
|
||||||
|
**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. 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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trip entity structure
|
||||||
|
|
||||||
|
The site is organized around Trip entities. The active trip is set in `user/config/site.yaml` → `active_trip`, as a **route** (e.g. `/trips/denmark-2026`), not a bare slug.
|
||||||
|
|
||||||
|
```
|
||||||
|
user/pages/01.trips/
|
||||||
|
└─ denmark-2026/
|
||||||
|
├─ trip.md ← template: trip; title, date_start, cover_image, album_url
|
||||||
|
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
|
||||||
|
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
|
||||||
|
└─ 04.stories/ ← story children (container .md is routable:false)
|
||||||
|
```
|
||||||
|
|
||||||
|
`01.dailies/` and `04.stories/` are inert data containers — the trip page aggregates their children; visiting the container routes directly 404s/redirects. (The former `02.map/` and `03.stats/` folders were removed with their view templates.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPX data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
GPX file uploaded to trip page media
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
user/pages/01.trips/<slug>/*.gpx
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
trip.html.twig / home.html.twig: trip_page.media.all → filter .gpx → entry-map partial
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MapLibre source: each GPX file parsed by toGeoJSON (bundled in js/map.js) → GeoJSON source
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Connector suppression: same-file 10km proximity check prevents spurious inter-track segments
|
||||||
|
│ (override with force_connect: true in trip frontmatter)
|
||||||
|
▼
|
||||||
|
Rendered as route polyline on map
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow for a post submission
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User fills /post form and taps Submit
|
||||||
|
2. Grav Form plugin validates: title and content required
|
||||||
|
3. cache-on-save (onFormValidationProcessed) injects the write target:
|
||||||
|
parent ← derived from site.active_trip (e.g. /trips/denmark-2026/dailies)
|
||||||
|
overwrite_mode ← edit if edit_path filled, else false
|
||||||
|
4. add-page-by-form reads post-form.md:
|
||||||
|
pageconfig.slug_field: date,title
|
||||||
|
pagefrontmatter: template: entry
|
||||||
|
5. New page written to:
|
||||||
|
user/pages/01.trips/denmark-2026/01.dailies/
|
||||||
|
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
||||||
|
└─ entry.md
|
||||||
|
6. Photos moved into the same folder
|
||||||
|
7. cache-on-save calls $grav['cache']->deleteAll()
|
||||||
|
8. Browser: form shows success message
|
||||||
|
9. Feed at /trips/denmark-2026 immediately shows new entry
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key config files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `user/config/site.yaml` | `active_trip` route; site title/description |
|
||||||
|
| `user/config/system.yaml` | Twig cache, flex accounts/pages, language prefix |
|
||||||
|
| `user/config/media.yaml` | Registers `.gpx` as a valid media type |
|
||||||
|
| `user/plugins/api/api.yaml` | `session_enabled: true` for GPX manager auth |
|
||||||
|
| `user/themes/intotheeast/css/tokens.css` | Design tokens (colors, fonts, spacing) |
|
||||||
|
| `CLAUDE.md` | Project rules and always-loaded context for Claude |
|
||||||
|
|
||||||
|
### What the `user/` repo tracks
|
||||||
|
|
||||||
|
Only `pages/`, `config/`, `accounts/`, and `themes/` are versioned in the content repo. `plugins/` and `data/` are ignored — **except** the three custom plugins, un-ignored explicitly in `user/.gitignore`. Also ignored: the test accounts, the demo-trip pages, secrets (`config/plugins/git-sync.yaml`, `config/security.yaml`, `api-private.php`), and the whole `env/` override tree. Read `user/.gitignore` for the authoritative list.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Design System — Light Mode Color Palette
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color palette
|
||||||
|
|
||||||
|
| Token | Light | Dark | Usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--color-paper` | `#F7F5F2` | `#1A1814` | Page background |
|
||||||
|
| `--color-canvas` | `#FFFFFF` | `#22201B` | Card surfaces, form backgrounds |
|
||||||
|
| `--color-ink` | `#17171A` | `#EDE8DF` | Primary text |
|
||||||
|
| `--color-ink-2` | `#4A4850` | `#B8B0A4` | Body text — muted |
|
||||||
|
| `--color-ink-muted` | `#9896A0` | `#90887E` | Labels, timestamps, captions |
|
||||||
|
| `--color-border` | `#E8E6E3` | `#2E2B25` | Standard dividers |
|
||||||
|
| `--color-border-soft` | `#F0EDEA` | `#252219` | Subtle dividers |
|
||||||
|
| `--color-accent` | `#1F6B5A` | `#2E9880` | Teal — brand color, links, CTAs |
|
||||||
|
| `--color-accent-hover` | `#185647` | `#287A68` | Hover/pressed teal |
|
||||||
|
| `--color-accent-light` | `#EBF5F2` | `#1A2E29` | Pale teal tint backgrounds |
|
||||||
|
| `--color-accent-on` | `#FFFFFF` | `#FFFFFF` | Text on accent surfaces |
|
||||||
|
| `--color-surface-raised` | `#F0EDE9` | `#2A2720` | Elevated surfaces: tooltips, hover |
|
||||||
|
| `--color-ink-inverse` | `#FFFFFF` | `#17171A` | Text on accent-coloured buttons |
|
||||||
|
|
||||||
|
### Notes on accent values
|
||||||
|
|
||||||
|
The dark accent is `#2E9880` — a lightened version of the original `#1F6B5A` to maintain contrast against near-black backgrounds.
|
||||||
|
|
||||||
|
### Notes on the two added tokens
|
||||||
|
|
||||||
|
- **`--color-surface-raised`** (`#F0EDE9`): warm off-white, slightly darker than `--color-canvas` (`#FFFFFF`) to suggest elevation — mirrors the dark mode pattern of `#2A2720` sitting just above `#22201B`.
|
||||||
|
- **`--color-ink-inverse`** (`#FFFFFF`): white text on the dark teal accent (`#1F6B5A`). Inverse of dark mode where the lightened accent (`#2E9880`) is bright enough to carry dark text (`#17171A`).
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# Into the East — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-06-18
|
||||||
|
**Status:** Implemented — dark theme (see `design-system-light.md` for the original light-mode palette)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Direction
|
||||||
|
|
||||||
|
**The brief:** A personal travel journal, sole author, trip to East Asia. Three weeks to implement before departure. Audience is both friends/family and the occasional curious stranger.
|
||||||
|
|
||||||
|
**The position:** Neither Polarsteps nor FindPenguins. Both optimize for social sharing of travel data. This site optimizes for **the story** — and should feel like reading a well-edited travel journal, not using an app.
|
||||||
|
|
||||||
|
**What we steal from each:**
|
||||||
|
- Polarsteps: photography-first hierarchy, airy whitespace, map as the emotional spine of the trip
|
||||||
|
- FindPenguins: typography as brand identity, stats as trophy case, hierarchical trip → entry structure
|
||||||
|
|
||||||
|
**What we do better than both:**
|
||||||
|
- Web-native: fast, linkable, no install, works on any browser
|
||||||
|
- Single author = pure editorial voice, no social noise
|
||||||
|
- Full CSS control = real typographic identity, not generic app chrome
|
||||||
|
- Editorial feel: more travel magazine, less productivity dashboard
|
||||||
|
|
||||||
|
**Aesthetic direction:** Field notes. The kind of journal a thoughtful traveler would carry — clean, direct, lets the photography speak. Sophisticated without effort.
|
||||||
|
|
||||||
|
**The one aesthetic risk:** Full-bleed hero photography with a translucent date+location overlay at the bottom of each card. The photo IS the entry card — not a thumbnail beside text. This is the single element that distinguishes this design from both reference apps and from typical blog layouts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Color System
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
| `--color-canvas` | `#22201B` | Card surfaces, form backgrounds |
|
||||||
|
| `--color-ink` | `#EDE8DF` | Primary text — warm cream |
|
||||||
|
| `--color-ink-2` | `#B8B0A4` | Body text — muted warm |
|
||||||
|
| `--color-ink-muted` | `#90887E` | Labels, timestamps, captions |
|
||||||
|
| `--color-border` | `#2E2B25` | Standard dividers |
|
||||||
|
| `--color-border-soft` | `#252219` | Subtle dividers |
|
||||||
|
| `--color-accent` | `#2E9880` | Teal — lightened for dark-background contrast |
|
||||||
|
| `--color-accent-hover` | `#287A68` | Hover/pressed teal |
|
||||||
|
| `--color-accent-light` | `#1A2E29` | Pale teal tint backgrounds |
|
||||||
|
| `--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
|
||||||
|
|
||||||
|
Teal was chosen for its associations with bamboo, celadon porcelain, ancient jade, and temple gardens — without being literal or kitsch. On the dark palette, the original `#1F6B5A` was too low-contrast; it was lightened to `#2E9880` to maintain readable contrast against the warm near-black backgrounds. See `design-system-light.md` for the original light-palette values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
### Fonts
|
||||||
|
|
||||||
|
| Role | Family | Fallback | Source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Display / Headings | DM Serif Display | Georgia, serif | Google Fonts |
|
||||||
|
| UI / Body / Labels | DM Sans | -apple-system, BlinkMacSystemFont, sans-serif | Google Fonts |
|
||||||
|
|
||||||
|
**Google Fonts URL:**
|
||||||
|
```
|
||||||
|
https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Serif+Display:ital@0;1&display=swap
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this pairing:**
|
||||||
|
DM Serif Display has a calligraphic quality — slightly editorial, authoritative but not stiff. Paired with DM Sans (its designed companion) the system is cohesive. DM Sans is neutral and highly legible at all sizes. Both are under-used relative to Inter/Lato/Playfair, so the combination has a distinctive voice without being trendy.
|
||||||
|
|
||||||
|
### Type Scale
|
||||||
|
|
||||||
|
| Token | Size | Line Height | Usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--text-xs` | 0.75rem (12px) | 1.5 | Badges, captions |
|
||||||
|
| `--text-sm` | 0.875rem (14px) | 1.5 | Meta, timestamps, labels |
|
||||||
|
| `--text-base` | 1rem (16px) | 1.65 | Body paragraphs |
|
||||||
|
| `--text-md` | 1.125rem (18px) | 1.55 | Lead text, intro paragraphs |
|
||||||
|
| `--text-lg` | 1.375rem (22px) | 1.35 | Subheadings, card titles (mobile) |
|
||||||
|
| `--text-xl` | 1.75rem (28px) | 1.25 | Entry card titles |
|
||||||
|
| `--text-2xl` | 2.25rem (36px) | 1.2 | Page headings, entry titles (desktop) |
|
||||||
|
| `--text-3xl` | 3rem (48px) | 1.1 | Hero entry title |
|
||||||
|
|
||||||
|
### Usage rules
|
||||||
|
|
||||||
|
- Entry titles: `--font-display`, `--text-xl` (mobile) / `--text-2xl` (desktop)
|
||||||
|
- Site title in header: `--font-display`, `--text-lg`
|
||||||
|
- All other UI text: `--font-ui`
|
||||||
|
- Body paragraphs: `--font-ui`, `--text-base`, `--leading-normal`
|
||||||
|
- Timestamps/badges: `--font-ui`, `--text-xs`, uppercase, `letter-spacing: 0.07em`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Spacing & Layout
|
||||||
|
|
||||||
|
### Spacing scale (4px base unit)
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| `--space-1` | 0.25rem (4px) |
|
||||||
|
| `--space-2` | 0.5rem (8px) |
|
||||||
|
| `--space-3` | 0.75rem (12px) |
|
||||||
|
| `--space-4` | 1rem (16px) |
|
||||||
|
| `--space-5` | 1.25rem (20px) |
|
||||||
|
| `--space-6` | 1.5rem (24px) |
|
||||||
|
| `--space-8` | 2rem (32px) |
|
||||||
|
| `--space-10` | 2.5rem (40px) |
|
||||||
|
| `--space-12` | 3rem (48px) |
|
||||||
|
| `--space-16` | 4rem (64px) |
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- Content max-width: `720px` (comfortable reading at any font size)
|
||||||
|
- Page horizontal padding: `1.25rem` (mobile), `1.5rem` (desktop ≥520px)
|
||||||
|
- Header height: `60px` (fixed, for JS offset calculations)
|
||||||
|
- Map page: full viewport, no content max-width constraint
|
||||||
|
|
||||||
|
### Border radius
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `--radius-sm` | 4px | Photo corners, small chips |
|
||||||
|
| `--radius-md` | 8px | Cards, buttons, inputs |
|
||||||
|
| `--radius-lg` | 12px | Large cards, modals |
|
||||||
|
| `--radius-full` | 9999px | Pills, badges |
|
||||||
|
|
||||||
|
### Shadows
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `--shadow-sm` | `0 1px 3px rgba(0,0,0,0.08)` | Stat blocks, subtle elevation |
|
||||||
|
| `--shadow-md` | `0 4px 12px rgba(0,0,0,0.10)` | Cards on hover, dropdowns |
|
||||||
|
| `--shadow-lg` | `0 8px 24px rgba(0,0,0,0.14)` | Lightbox, modals |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Component Inventory
|
||||||
|
|
||||||
|
### 5.1 Site Header
|
||||||
|
|
||||||
|
```
|
||||||
|
[ into the east ] [ Journal Map Stats ]
|
||||||
|
← accent bar across top (3px) ───────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
- Top border: `3px solid var(--color-accent)` — thin accent bar signals the brand color without decorating
|
||||||
|
- Site title: DM Serif Display, `--text-lg`, no decoration
|
||||||
|
- 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` (`#22201B` in the dark theme), bottom border `1px solid var(--color-border)`
|
||||||
|
|
||||||
|
### 5.2 Entry Feed Card — With Photo
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [photo] │ ← full-width, 16:9, rounded corners
|
||||||
|
│ │
|
||||||
|
│ 18 JUN · 📍 Kyoto, Japan │ ← overlaid at bottom, gradient mask
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
Arrived in Tokyo ← DM Serif Display, --text-xl
|
||||||
|
After 14 hours of flying I finally ← body excerpt, --color-ink-2
|
||||||
|
set foot on Japanese soil...
|
||||||
|
Read entry → ← --color-accent, --text-sm
|
||||||
|
```
|
||||||
|
|
||||||
|
- Photo: `aspect-ratio: 16/9`, `object-fit: cover`, `border-radius: var(--radius-md)`
|
||||||
|
- Photo has a `linear-gradient(to top, rgba(0,0,0,0.55), transparent)` overlay at the bottom 40%
|
||||||
|
- Date + location sit on top of gradient in white text (`rgba(255,255,255,0.92)`)
|
||||||
|
- On hover: photo scales to 1.03 (subtle zoom, 0.4s ease)
|
||||||
|
- Title below photo: DM Serif Display, hover turns `--color-accent`
|
||||||
|
- Card separation: `padding-bottom: var(--space-12)` + `border-bottom: 1px solid var(--color-border)`
|
||||||
|
|
||||||
|
### 5.3 Entry Feed Card — No Photo
|
||||||
|
|
||||||
|
When no photo is available, fall back to a text-only layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
18 JUN 2026 · 📍 Kyoto, Japan ← meta row, --text-sm, --color-ink-muted
|
||||||
|
|
||||||
|
Arrived in Tokyo ← DM Serif Display, --text-xl
|
||||||
|
After 14 hours of flying...
|
||||||
|
Read entry →
|
||||||
|
```
|
||||||
|
|
||||||
|
- No photo container
|
||||||
|
- Meta (date + location) on one line above title, small + muted
|
||||||
|
|
||||||
|
### 5.4 Single Entry Page
|
||||||
|
|
||||||
|
```
|
||||||
|
Wednesday, 18 June 2026 ← --text-sm, --color-ink-muted, uppercase
|
||||||
|
📍 Kyoto, Japan · ⛅ Partly cloudy · 22°C
|
||||||
|
|
||||||
|
Arrived in Tokyo ← DM Serif Display, --text-2xl / --text-3xl
|
||||||
|
─────────────────────────────────────
|
||||||
|
Body text content... ← --font-ui, --text-base/md
|
||||||
|
|
||||||
|
[Photo gallery — 2 or 3 col grid]
|
||||||
|
|
||||||
|
← Back to journal
|
||||||
|
```
|
||||||
|
|
||||||
|
- The entry title uses `--font-display` at largest scale
|
||||||
|
- A thin `--color-border` rule separates the header from the body
|
||||||
|
- Body text is `--text-md` (18px) for comfortable long-form reading
|
||||||
|
- Full-bleed hero option: if a `hero_image` is set, it spans the full content width with a bottom margin
|
||||||
|
|
||||||
|
### 5.5 Post Form (Author View)
|
||||||
|
|
||||||
|
```
|
||||||
|
New Entry
|
||||||
|
|
||||||
|
Title * [________________________]
|
||||||
|
Date & Time [2026-06-18 14:30 ]
|
||||||
|
What happened [ ]
|
||||||
|
today? [ ]
|
||||||
|
[ ]
|
||||||
|
|
||||||
|
Photos [ + Add photos (max 4) ]
|
||||||
|
|
||||||
|
City [________________________]
|
||||||
|
Country [________________________]
|
||||||
|
|
||||||
|
[ 📍 Get Location ] [ 🌤 Get Weather ]
|
||||||
|
✓ Location captured: Kyoto, Japan ← status line
|
||||||
|
|
||||||
|
[ Post Entry ]
|
||||||
|
```
|
||||||
|
|
||||||
|
UX changes from current:
|
||||||
|
- Lat/lng inputs **hidden from the UI** (remain in the form as `display:none` for data capture, filled by JS)
|
||||||
|
- Location status shows captured city/country + coordinates in a single line (not separate status paragraphs)
|
||||||
|
- Photo upload area: larger touch target, visual indication of count
|
||||||
|
- "Post Entry" button: `--color-accent` background, full-width on mobile, `min-height: 52px`
|
||||||
|
- Form fields: `--radius-md` corners, `--color-border` border, focus ring in `--color-accent`
|
||||||
|
- Section spacing: generous vertical rhythm on mobile
|
||||||
|
|
||||||
|
### 5.6 Stats Page
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┐ ┌────────────┐
|
||||||
|
│ 42 │ │ 18 │
|
||||||
|
│ days on │ │ entries │
|
||||||
|
│ the road │ │ posted │
|
||||||
|
└────────────┘ └────────────┘
|
||||||
|
┌────────────┐ ┌────────────┐
|
||||||
|
│ 6 │ │ ~14,200 │
|
||||||
|
│ countries │ │ km │
|
||||||
|
│ visited │ │ traveled │
|
||||||
|
└────────────┘ └────────────┘
|
||||||
|
|
||||||
|
Countries visited
|
||||||
|
Japan · South Korea · Mongolia · Russia · Finland · Estonia
|
||||||
|
```
|
||||||
|
|
||||||
|
- Numbers: `--font-display`, `--text-3xl`, `--color-accent`
|
||||||
|
- Labels: `--font-ui`, `--text-xs`, uppercase, `--color-ink-muted`
|
||||||
|
- Cards: white, `--shadow-sm`, `--radius-md`, centered
|
||||||
|
|
||||||
|
### 5.7 Map Page
|
||||||
|
|
||||||
|
Minimal changes — the map itself is good. Style improvements:
|
||||||
|
- MapLibre popups: match the new design (DM Sans, `--radius-md`, `--shadow-md`)
|
||||||
|
- Markers: keep current circle style, update color to `--color-accent`
|
||||||
|
- Feed mini-map wrapper: match `--radius-md`, `--border`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UX Flows
|
||||||
|
|
||||||
|
### 6.1 Reader — First Visit
|
||||||
|
|
||||||
|
1. Land on `/dailies` (journal feed)
|
||||||
|
2. See mini-map above fold (if entries exist) — route tells the geographic story at a glance
|
||||||
|
3. First entry card: full-bleed hero photo with date/location overlay — immediate emotional pull
|
||||||
|
4. Scroll through chronological entries
|
||||||
|
5. Tap/click entry → entry detail page
|
||||||
|
6. Navigate back via "← Back to journal"
|
||||||
|
|
||||||
|
**Key principle:** The reader should understand the journey spatially (mini-map) and emotionally (hero photo) before reading a single word.
|
||||||
|
|
||||||
|
### 6.2 Reader — Navigation
|
||||||
|
|
||||||
|
- Journal: primary destination, the feed
|
||||||
|
- Map: geographic exploration mode
|
||||||
|
- Stats: quick numbers, satisfying progress indicator
|
||||||
|
- No account required, no social friction, no login prompt for readers
|
||||||
|
|
||||||
|
### 6.3 Author — Posting from Mobile
|
||||||
|
|
||||||
|
1. Navigate to `/post` (bookmark on home screen)
|
||||||
|
2. Already logged in (Grav session persists) — form loads directly
|
||||||
|
3. **Title**: tap → type (autofocused)
|
||||||
|
4. **Date & Time**: auto-filled to now, adjust if needed
|
||||||
|
5. **Content**: write what happened
|
||||||
|
6. **Photos**: tap "Add photos" → camera or gallery → select up to 4
|
||||||
|
7. **Location**: tap "📍 Get Location" → GPS fires → status shows "Kyoto, Japan · 34.985, 135.758" in one line
|
||||||
|
8. **Weather**: tap "🌤 Get Weather" (works only if location was captured) → status shows "Partly cloudy · 22°C"
|
||||||
|
9. **City/Country**: auto-populated from GPS is a nice-to-have for v2; in v1 type manually if needed
|
||||||
|
10. Tap "Post Entry" → success message → 2-second pause → redirect to /dailies (new entry visible at top)
|
||||||
|
|
||||||
|
**Key principles:**
|
||||||
|
- One-thumb operation for all critical actions on mobile
|
||||||
|
- Location/weather are conveniences, not blockers — can skip both
|
||||||
|
- Visual feedback is immediate (status line updates on GPS response)
|
||||||
|
- After submit: don't leave author on a success message page; redirect to see their new post
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Mobile Specifics
|
||||||
|
|
||||||
|
### Touch targets
|
||||||
|
- All interactive elements: `min-height: 44px`, `min-width: 44px` (Apple HIG standard)
|
||||||
|
- Form buttons: `min-height: 52px` on the post form (primary CTA)
|
||||||
|
- Nav links: `padding: 0.5rem 0.75rem`
|
||||||
|
|
||||||
|
### Viewport concerns
|
||||||
|
- Map page: `height: calc(100vh - 60px)`, `touch-action: none` on map container — prevents scroll trap
|
||||||
|
- Photo lightbox: full viewport overlay, swipe-friendly (keyboard + click already implemented)
|
||||||
|
- Form on mobile: single-column, generous input padding `0.875rem 1rem`, `font-size: 1rem` (prevents iOS zoom on focus)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Google Fonts: loaded with `preconnect` hints
|
||||||
|
- Images: `loading="lazy"` on all non-above-fold images (already in place)
|
||||||
|
- MapLibre: loaded from CDN, only on pages that need it
|
||||||
|
- No new JS frameworks — vanilla JS throughout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tech Stack Decision
|
||||||
|
|
||||||
|
**Keep Grav CMS.** With a 3-week timeline, replacing it would consume all available time on migration rather than design improvements.
|
||||||
|
|
||||||
|
| Layer | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend | Grav CMS (PHP, Twig) — unchanged | Works, flat-file, no DB |
|
||||||
|
| CSS | Vanilla CSS + custom properties (design tokens) | No build step, full control, ships as one file |
|
||||||
|
| 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. 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What Changes From Current Design
|
||||||
|
|
||||||
|
| Area | Current | New |
|
||||||
|
|---|---|---|
|
||||||
|
| Typography | System sans-serif only | DM Serif Display for headings + DM Sans for UI |
|
||||||
|
| Accent color | `#0066cc` (generic blue) | `#1F6B5A` (deep teal) |
|
||||||
|
| Background | `#ffffff` (pure white) | `#F7F5F2` (warm paper) |
|
||||||
|
| Entry cards | Thumbnail + text below | Full-bleed 16:9 photo with overlay |
|
||||||
|
| Header | No visual identity | Accent top-border, typographic title |
|
||||||
|
| Design tokens | Hardcoded values throughout | CSS custom properties throughout |
|
||||||
|
| Post form | Lat/lng visible inputs | Lat/lng hidden, single status line |
|
||||||
|
| Font loading | None | Google Fonts DM pairing |
|
||||||
|
| Hover states | Minimal | Photo zoom, title color change |
|
||||||
|
| Stat numbers | `#0066cc` | `--color-accent` (#1F6B5A) |
|
||||||
@@ -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-24 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 — `user/` `dd19995`, outer `4450bd6`; the rule was carved out in `829325c` | `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,67 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Every suite drives the **live site over HTTP**, so the dev server must be running (`make start`) before any of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Scope |
|
||||||
|
|---|---|
|
||||||
|
| `make test` | Everything: `test-config` → `test-post` → `test-ui` |
|
||||||
|
| `make test-config` | Form/config sanity via `scripts/test-form-config.sh` |
|
||||||
|
| `make test-post` | End-to-end post submission via `scripts/test-post.sh` |
|
||||||
|
| `make test-ui` | Playwright suite (`npx playwright test`) |
|
||||||
|
| `make test-account` | Creates the `testrunner` admin if absent (a dependency of `test-post` and `test-ui`) |
|
||||||
|
|
||||||
|
Focused runs bypass `make`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps # one suite
|
||||||
|
npx playwright test tests/ui/maps --headed # watch it
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
playwright.config.js ← config (testDir: ./tests/ui)
|
||||||
|
tests/
|
||||||
|
├─ global-setup.js ← runs once before all projects
|
||||||
|
├─ global-teardown.js ← runs once after
|
||||||
|
├─ fixtures/
|
||||||
|
└─ ui/
|
||||||
|
├─ helpers.js ← shared helpers; import from here rather than re-rolling
|
||||||
|
├─ auth/ ← includes auth.setup.js (see below)
|
||||||
|
├─ a11y/ dailies/ gpx/ home/
|
||||||
|
├─ maps/ nav/ post/ stories/ trip/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config facts
|
||||||
|
|
||||||
|
| Setting | Value | Why it matters |
|
||||||
|
|---|---|---|
|
||||||
|
| `baseURL` | `process.env.GRAV_BASE_URL \|\| 'http://localhost:8081'` | Set `GRAV_BASE_URL` to test a worktree's isolated server on `8090+` |
|
||||||
|
| `retries` | `0` | A failing test is a real failure, not flake — do not paper over it with retries |
|
||||||
|
| `timeout` | `30_000` | Per test |
|
||||||
|
| `screenshot` | `only-on-failure` | Video off; artifacts stay small |
|
||||||
|
| `reporter` | `line` | |
|
||||||
|
|
||||||
|
### Auth is a dependency project
|
||||||
|
|
||||||
|
Two Playwright projects, in order:
|
||||||
|
|
||||||
|
1. **`setup`** — matches `auth.setup.js`, logs in once, writes `tests/.auth/user.json`.
|
||||||
|
2. **`chromium`** — `dependencies: ['setup']`, consumes that file as `storageState`.
|
||||||
|
|
||||||
|
So every test in `chromium` starts already authenticated. **Never add a per-test login** — it duplicates the setup project and slows the suite.
|
||||||
|
|
||||||
|
### The test account
|
||||||
|
|
||||||
|
`make test-account` creates a `testrunner` admin (via `bin/plugin login new-user`, admin type `both`) inside the container if `user/accounts/testrunner.yaml` is missing. It is git-ignored.
|
||||||
|
|
||||||
|
- Never commit it.
|
||||||
|
- Keep the password free of shell/Make/URL-special characters — it is interpolated by the Makefile, `scripts/test-post.sh`, and the Playwright setup, and a special character breaks at least one of them.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# FindPenguins — Feature Research
|
||||||
|
|
||||||
|
*Researched June 2026. Source: findpenguins.com, App Store, support docs, reviews.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FindPenguins is a German travel tracking and community app. Core features are free; premium subscription ($4.99/month or $32.99/year) unlocks more photos per post and ebook exports. Revenue comes from subscriptions and printed photo books ($40–240). It leans more social than Polarsteps — discovery, community, and inspiring other travelers are central to its identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core User Flow
|
||||||
|
|
||||||
|
1. User creates a **Trip** (title, dates, cover)
|
||||||
|
2. App runs in background with **automatic GPS + flight detection tracking**
|
||||||
|
3. User creates **Footprints** — individual journal entries tied to a location and time
|
||||||
|
4. Each Footprint can contain: location, title, date, text story, photos, video, weather
|
||||||
|
5. Footprints appear in a **chronological timeline** per trip
|
||||||
|
6. Trip is shareable; social followers can view, comment, react
|
||||||
|
7. At the end, optionally order a printed **photo book**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Map Features
|
||||||
|
|
||||||
|
- **Automatic route tracking**: GPS + flight detection, works offline
|
||||||
|
- **Interactive world map**: route lines drawn between footprints
|
||||||
|
- **3D flyover video**: auto-generated cinematic route visualization, free
|
||||||
|
- **Countries/continents highlighted**: on personal map
|
||||||
|
- **Visited places completion**: stats on what % of a country/region visited
|
||||||
|
- Battery usage: ~4% per day (comparable to Polarsteps)
|
||||||
|
- Route visualized as path on map, not just pins
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Footprints (Journal Entries)
|
||||||
|
|
||||||
|
Each "Footprint" is the core content unit:
|
||||||
|
|
||||||
|
- **Location**: GPS-detected, shown as city/country; uses reverse geocoding (LocationIQ)
|
||||||
|
- **Title**: required, user-set
|
||||||
|
- **Date**: required, defaults to current time
|
||||||
|
- **Text story**: freeform journal text
|
||||||
|
- **Photos**: 6 (free) / 10 (premium) per footprint
|
||||||
|
- **Videos**: 1 (free) / 2 (premium) per footprint
|
||||||
|
- **Weather**: auto-populated at location + time; manually editable
|
||||||
|
- **Place name**: auto-detected city/neighborhood/country, editable
|
||||||
|
- **Selective sharing**: each footprint can be public, friends-only, or private
|
||||||
|
- **Delayed posting**: option to share location with a time delay (privacy feature)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Photo Handling
|
||||||
|
|
||||||
|
- Up to 6 photos per footprint (free), 10 (premium)
|
||||||
|
- 1 video per footprint (free), 2 (premium)
|
||||||
|
- Photos displayed in carousel/grid within footprint
|
||||||
|
- High-res stored for photobook printing
|
||||||
|
- Cover photo selectable per trip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
- Countries visited (count + list + % world)
|
||||||
|
- Continents visited
|
||||||
|
- Total distance traveled
|
||||||
|
- Number of footprints / trips
|
||||||
|
- Days on the road
|
||||||
|
- World coverage percentage
|
||||||
|
- Shown on profile and within photo books
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Social & Discovery Features
|
||||||
|
|
||||||
|
- **Follower system**: follow other travelers, see their public footprints
|
||||||
|
- **Comments**: friends/followers can comment on individual footprints
|
||||||
|
- **Reactions**: like/react to footprints
|
||||||
|
- **Discovery**: browse 10M+ travel experiences from other users by destination
|
||||||
|
- **Group trips**: invite co-travelers to add footprints to a shared trip (with known bug: co-travelers can delete each other's content)
|
||||||
|
- **Travel inspiration**: browse community trips to plan your own
|
||||||
|
- **Explore by destination**: search real traveler experiences for any city/country
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy Controls
|
||||||
|
|
||||||
|
- Per-footprint visibility: public / friends / private
|
||||||
|
- **Delayed sharing**: share location with a configurable time delay (safety feature for solo travelers)
|
||||||
|
- Trip-level privacy: whole trip can be private or public
|
||||||
|
- Can hide real-time location from followers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Photo Book (Premium)
|
||||||
|
|
||||||
|
- Printed book with maps, photos, text, statistics, and friend comments
|
||||||
|
- €40–€240 depending on size/format (hardcover or layflat)
|
||||||
|
- Free ebook version for premium subscribers
|
||||||
|
- 5% discount on books with premium
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3D Flyover Video
|
||||||
|
|
||||||
|
- Free feature: auto-generates a cinematic 3D video of your route
|
||||||
|
- Shareable directly from the app
|
||||||
|
- No native app required for viewing (shareable link)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offline Capability
|
||||||
|
|
||||||
|
- Tracker works fully offline (GPS, flight detection)
|
||||||
|
- Footprints can be created and edited offline
|
||||||
|
- Syncs when connected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Makes FindPenguins Distinctive
|
||||||
|
|
||||||
|
1. **Flight detection**: auto-detects flights and logs them on the route
|
||||||
|
2. **3D flyover video**: compelling visual output, free
|
||||||
|
3. **Delayed sharing**: useful for solo travelers worried about broadcasting real-time location
|
||||||
|
4. **Richer social layer**: comments on individual footprints, community discovery
|
||||||
|
5. **Destination exploration**: browse real traveler posts for any place (like a user-generated travel guide)
|
||||||
|
6. **Premium photo books**: more polished physical product with friend comments included
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations (relevant to our context)
|
||||||
|
|
||||||
|
- Requires native app for GPS/flight tracking — not reproducible in a web CMS
|
||||||
|
- Social discovery features irrelevant for a solo personal blog
|
||||||
|
- Group trip feature has a bug (co-travelers can delete your content)
|
||||||
|
- Premium paywall for basic things like more than 6 photos per post
|
||||||
|
- Community/social focus means the UX is designed around a social graph we don't have
|
||||||
|
- 3D flyover video requires proprietary rendering pipeline
|
||||||
|
- Real-time delayed sharing is a privacy feature for apps broadcasting live location — moot for a blog that posts after the fact
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Polarsteps — Feature Research
|
||||||
|
|
||||||
|
*Researched June 2026. Source: polarsteps.com, App Store, support docs, reviews.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Polarsteps is a travel tracking and journaling app used by 20M+ travelers. It is ad-free, primarily free to use, with paid travel books as the main revenue stream. It positions itself as "by travelers, for travelers" — clean, minimal, focused on personal memory-keeping and sharing with close friends/family rather than a social discovery platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core User Flow
|
||||||
|
|
||||||
|
1. User creates a **Trip** (name, start/end dates, cover photo)
|
||||||
|
2. App runs in background and **auto-tracks GPS route** continuously (dots on map)
|
||||||
|
3. App auto-generates **Step Suggestions** when you stay somewhere — a notification asks "Are you in [City]? Add a step?"
|
||||||
|
4. User accepts or manually creates a **Step**: a journal entry tied to a location
|
||||||
|
5. Each Step gets: title, text, photos/videos, date, and auto-populated metadata
|
||||||
|
6. Steps appear in a **timeline feed** ordered chronologically
|
||||||
|
7. Trip is shareable via link; friends/family can follow in real time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Map Features
|
||||||
|
|
||||||
|
- **Route tracking**: GPS + WiFi + cell towers → white dots plotted on world map as you move
|
||||||
|
- **Offline tracking**: stores locally, syncs when connected
|
||||||
|
- **Travel Tracker steps**: actual route taken (not straight lines), with transport mode tagging (car, bus, train, taxi, walk, fly)
|
||||||
|
- **Route visualization**: colored line on map connecting all steps
|
||||||
|
- **Countries/continents visited**: highlighted on world map
|
||||||
|
- **Battery usage**: ~4% per day (very efficient)
|
||||||
|
- **World completion %**: gamified stat showing % of the globe visited
|
||||||
|
- Tracks distance, speed, and estimated travel time between steps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps (Journal Entries)
|
||||||
|
|
||||||
|
Each "Step" is the core content unit:
|
||||||
|
|
||||||
|
- **Location**: auto-detected city/country, adjustable
|
||||||
|
- **Title**: auto-suggested from location, editable
|
||||||
|
- **Date/time**: auto from GPS
|
||||||
|
- **Text**: rich freeform journal text
|
||||||
|
- **Photos**: unlimited (mobile app), displayed in a grid/carousel
|
||||||
|
- **Videos**: supported on mobile only, excluded from printed books
|
||||||
|
- **Weather**: auto-populated (temperature, conditions) at time of step
|
||||||
|
- **Altitude**: recorded from GPS
|
||||||
|
- **GPS coordinates**: stored and displayed
|
||||||
|
- **Transport**: mode of travel to reach this step (car/train/fly/etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Photo Handling
|
||||||
|
|
||||||
|
- Add photos directly from camera roll per step
|
||||||
|
- Choose cover photo for the trip
|
||||||
|
- Photos displayed in gallery within each step
|
||||||
|
- High-resolution stored for travel book printing
|
||||||
|
- No hard per-step photo limit mentioned (effectively unlimited)
|
||||||
|
- Videos supported on mobile, excluded from print
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
Displayed on trip and profile level:
|
||||||
|
- Total km/miles traveled
|
||||||
|
- Countries visited (count + list)
|
||||||
|
- Continents visited
|
||||||
|
- Number of steps/entries
|
||||||
|
- Days on the road
|
||||||
|
- World completion percentage
|
||||||
|
- Furthest point from home
|
||||||
|
- Number of followers / following
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing & Social Features
|
||||||
|
|
||||||
|
- **Privacy**: "Only me", "Followers only", or "Public"
|
||||||
|
- **Shareable link**: send a URL to anyone to follow the trip live
|
||||||
|
- **Followers**: people can follow your profile and see all public trips
|
||||||
|
- **Reactions/comments**: followers can react and comment on steps
|
||||||
|
- **Social media sharing**: export to Facebook, Instagram, etc.
|
||||||
|
- **Travel Buddy**: invite friends to join and co-document a trip together
|
||||||
|
- **Editors' Choice**: curated featured trips for discovery (like a magazine)
|
||||||
|
- **Trip Reels**: auto-generated short video from photos/videos + visited places, shareable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Features (2025 addition)
|
||||||
|
|
||||||
|
- **AI Itinerary Builder**: generates multi-stop travel plan on the map, with transport modes
|
||||||
|
- **Accommodation import**: forward booking confirmation emails to plan@polarsteps.app → appears on map
|
||||||
|
- **Activity planning**: add stays, restaurants, activities to itinerary
|
||||||
|
- **Travel DNA**: personality-based personalization for AI suggestions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Travel Book
|
||||||
|
|
||||||
|
- Print a hardback book of your trip (€30–80, 24–300 pages)
|
||||||
|
- Each step on its own page: photo, text, map thumbnail, metadata
|
||||||
|
- Statistics page at the end
|
||||||
|
- Designed, high-quality output — main revenue for Polarsteps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offline Capability
|
||||||
|
|
||||||
|
- Full offline posting (text, photos)
|
||||||
|
- GPS route tracking continues offline
|
||||||
|
- All data syncs when back online
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Makes Polarsteps Distinctive
|
||||||
|
|
||||||
|
1. **Simplicity** — minimal UI, auto-everything, almost no friction to log a day
|
||||||
|
2. **Route tracking** — actually shows where you walked/drove, not just pins
|
||||||
|
3. **"Step suggestions"** — proactive nudges to journal without opening the app
|
||||||
|
4. **Printed book** — the premium product, excellent quality
|
||||||
|
5. **Ad-free** — rare among free travel apps
|
||||||
|
6. **Battery efficiency** — 4% per day, usable on long trips
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations (relevant to our context)
|
||||||
|
|
||||||
|
- Requires native mobile app for GPS tracking (cannot do in browser)
|
||||||
|
- Videos excluded from print
|
||||||
|
- Social/discovery features add little value for a solo personal blog
|
||||||
|
- AI itinerary builder overkill for one-person blog
|
||||||
|
- Travel Buddy / follower system assumes a social graph we don't have
|
||||||
|
- Reels require the native app video processing pipeline
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Story Editing Research
|
||||||
|
|
||||||
|
Brainstorming session — 2026-06-20. Notes on options for improving the story editing
|
||||||
|
experience in Admin2. Not a plan — a reference to revisit once real story writing reveals
|
||||||
|
what actually matters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The core problem
|
||||||
|
|
||||||
|
Stories use shortcode syntax (`[scrolly-section image="x.jpg"]…[/scrolly-section]`) authored
|
||||||
|
in a single big markdown textarea in Admin2. Three pain points, roughly equal weight:
|
||||||
|
|
||||||
|
1. **Fragile syntax** — easy to typo a shortcode and get no useful error
|
||||||
|
2. **Writing blind** — no preview while editing; you don't see the result until you view the page
|
||||||
|
3. **Mobile unusable** — Admin2 is desktop-focused; the markdown textarea on a phone is painful
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Keystatic / Sanity / Shorthand taught us
|
||||||
|
|
||||||
|
All three tools represent content as a **typed, ordered list of blocks** — not a text string.
|
||||||
|
|
||||||
|
In Keystatic the editing flow is: write prose normally → click "+" → pick a block type from a
|
||||||
|
list → fill in a form with labelled fields → the block appears as an opaque card in the editor.
|
||||||
|
Authors never see markup. Each block type (hero, gallery, scrolly section) has a typed schema
|
||||||
|
(image picker, text fields, selects). Sanity's Portable Text uses the same model. Shorthand
|
||||||
|
(used by BBC, Reuters, National Geographic) is a purpose-built CMS for exactly this kind of
|
||||||
|
immersive storytelling — their section vocabulary is the best reference for what block types
|
||||||
|
matter in practice.
|
||||||
|
|
||||||
|
**Key insight:** the gap between Grav and these tools is entirely on the authoring side.
|
||||||
|
Grav's rendering (Twig templates, shortcodes, parallax effects) is perfectly capable.
|
||||||
|
The problem is that Admin2 was not designed for structured block content authoring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Snow Fall block vocabulary
|
||||||
|
|
||||||
|
The canonical block types that appear across all immersive storytelling platforms:
|
||||||
|
|
||||||
|
| Block | What it does | Fields |
|
||||||
|
|---|---|---|
|
||||||
|
| **Hero** | Full-bleed opening image/video + title. Chapter opener. | image, headline, subtitle, text position, animation (static / ken-burns) |
|
||||||
|
| **Narrative text** | Prose reading column. The writing block. | body (markdown) |
|
||||||
|
| **Full-bleed media** | Single image/video edge-to-edge, no text. Visual pause. | image, caption, credit |
|
||||||
|
| **Image + caption** | Photo at configurable width with caption below. | image, caption, credit, width (column / full / bleed) |
|
||||||
|
| **Scrollytelling** | Text panels scroll over a fixed or animated background. | background image, panels (each: headline + body) |
|
||||||
|
| **Photo gallery** | Multi-image carousel/grid → lightbox. | images (each: file + caption + credit) |
|
||||||
|
| **Pull quote** | Typographically large extracted quote. | quote text, attribution, optional background |
|
||||||
|
| **Chapter break** | Major section transition with background image. | image, title, chapter number |
|
||||||
|
| **Grid / side-by-side** | 2–3 column photo+text pairs. | columns array |
|
||||||
|
| **Embed** | YouTube, Vimeo, audio, map. | URL, caption |
|
||||||
|
|
||||||
|
Current Grav shortcodes cover: hero (ken-burns), scrollytelling (scrolly-section), gallery
|
||||||
|
(snap-gallery), pull-quote, chapter-break. Missing from the vocabulary: narrative text as an
|
||||||
|
explicit block, full-bleed media, image+caption, grid, embed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The two fundamental approaches
|
||||||
|
|
||||||
|
### A — Blueprint-as-blocks (no shortcodes)
|
||||||
|
|
||||||
|
Replace the markdown body with a `list` field in the story blueprint. Each list item is a
|
||||||
|
block with a `type` selector + type-specific sub-fields. Content lives in frontmatter YAML;
|
||||||
|
the Twig template loops over blocks and renders each one with the right partial. No shortcode
|
||||||
|
syntax at all.
|
||||||
|
|
||||||
|
**Solves all three pain points.** Admin2 form fields are mobile-reasonable. Structure is
|
||||||
|
explicit and impossible to mis-type.
|
||||||
|
|
||||||
|
**One limitation:** Grav's native `list` field doesn't hide/show fields based on the selected
|
||||||
|
type. Every block card shows ALL fields for ALL block types; the template ignores the unused
|
||||||
|
ones. It's visually cluttered but functionally correct. This is a solvable UX problem (Grav
|
||||||
|
roadmap, or a future Admin2 extension) — the data model stays the same when it improves.
|
||||||
|
|
||||||
|
### B — Enhanced markdown (keep shortcodes, improve authoring UX)
|
||||||
|
|
||||||
|
Keep the markdown textarea; add tooling on top to reduce friction. Multiple options here
|
||||||
|
(see section below), but all hit an architectural constraint: Admin2 serves its SPA via
|
||||||
|
`echo $html; exit` which bypasses Grav's entire output pipeline. Standard plugin hooks
|
||||||
|
for injecting assets don't fire in Admin2. Any JS-based editor enhancement requires either
|
||||||
|
a fragile output-buffering hack or patching Admin2's pre-built `app/index.html` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15 options researched
|
||||||
|
|
||||||
|
### Options that work natively (no Admin2 hacking required)
|
||||||
|
|
||||||
|
**1. Blueprint-as-blocks** — structured YAML fields per block, native Admin2 form rendering.
|
||||||
|
Best long-term solution for non-technical story editing and mobile. Medium effort (blueprint
|
||||||
|
YAML + Twig template rework). The field-clutter limitation is real but acceptable.
|
||||||
|
|
||||||
|
**2. page-inject sub-pages** — each story block is a standalone Grav sub-page; the parent
|
||||||
|
story injects them in sequence via `[plugin:page-inject]`. Editing = opening sub-pages
|
||||||
|
individually. More flexible than blueprint-as-blocks for reusable content but creates
|
||||||
|
more pages to manage and Admin2 navigation friction.
|
||||||
|
|
||||||
|
**3. New shortcodes via YAML + Twig** — shortcode-core supports YAML-configured Twig
|
||||||
|
shortcodes out of the box (`shortcode-core.yaml` + a Twig file in the theme). No plugin
|
||||||
|
needed. Used to add `full-bleed` and `image-caption` to the story-blocks plugin.
|
||||||
|
|
||||||
|
**4. Obsidian + Gitea git sync** — write stories in Obsidian on desktop or mobile with
|
||||||
|
snippet templates for shortcodes. Push to Gitea; webhook deploys to production. Zero dev
|
||||||
|
work. Good mobile writing experience. No image upload from mobile; requires comfort with git.
|
||||||
|
|
||||||
|
**5. `/story-editor` custom page** — a purpose-built page (like `/gpx-manager`) that presents
|
||||||
|
story blocks as drag-reorderable cards with typed forms, talks to the Grav API, designed
|
||||||
|
mobile-first. Highest effort; best mobile result. Would use the "one custom plugin" slot.
|
||||||
|
|
||||||
|
### Options blocked by Admin2's architecture
|
||||||
|
|
||||||
|
All of these require injecting JavaScript or CSS into Admin2 pages, which is architecturally
|
||||||
|
blocked (Admin2 bypasses Grav's output pipeline with `echo $html; exit`):
|
||||||
|
|
||||||
|
- EasyMDE / SimpleMDE drop-in (split-pane markdown preview)
|
||||||
|
- CodeMirror 6 autocomplete + syntax highlighting for shortcodes
|
||||||
|
- Toast UI Editor (WYSIWYG ↔ markdown toggle)
|
||||||
|
- Tiptap custom shortcode nodes
|
||||||
|
- Milkdown
|
||||||
|
- Slash-command / shortcode palette
|
||||||
|
- Toolbar-at-bottom CSS (mobile improvement)
|
||||||
|
- Web Speech API dictation button
|
||||||
|
|
||||||
|
**Workaround:** patch Admin2's pre-built `app/index.html` directly. Survives until the next
|
||||||
|
Admin2 update; a `make patch-admin2` command would re-apply it. Viable for a personal blog
|
||||||
|
but adds a maintenance step.
|
||||||
|
|
||||||
|
### Paid option
|
||||||
|
|
||||||
|
**Grav Editor Pro ($75)** — TipTap/ProseMirror-based WYSIWYM editor, confirmed Admin2-
|
||||||
|
compatible (listed as an optional dependency in the Admin2 README ≥ v2.0.1). When paired
|
||||||
|
with shortcode-core, shortcodes appear as visual green blocks with a modal form per type.
|
||||||
|
This is the cleanest story editing experience available without building it yourself. Ruled
|
||||||
|
out based on preference to avoid paid tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Honest CMS comparison: Grav vs Keystatic for storytelling
|
||||||
|
|
||||||
|
**Wrong conclusion:** Grav can't do Snow Fall storytelling.
|
||||||
|
|
||||||
|
**Right conclusion:** Grav's rendering side (parallax, scrollytelling, ken-burns, galleries)
|
||||||
|
is fully capable. The authoring experience for structured block content is the genuine weak
|
||||||
|
point — and the options to improve it cleanly within Admin2 are limited.
|
||||||
|
|
||||||
|
Keystatic + a modern frontend (Astro, Next.js) would give a better editorial experience for
|
||||||
|
story authoring, but at the cost of migrating away from Grav and building a separate frontend.
|
||||||
|
For a solo travel blogger who is the only author, Grav with an improved shortcode setup or
|
||||||
|
blueprint-as-blocks is good enough. Keystatic's advantage is significant for publications with
|
||||||
|
multiple non-technical editors writing daily.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was actually done (2026-06-20)
|
||||||
|
|
||||||
|
- Added `full-bleed` shortcode to `story-blocks` plugin (PHP class + CSS)
|
||||||
|
- Added `image-caption` shortcode to `story-blocks` plugin (PHP class + CSS), with
|
||||||
|
`width` parameter: `column` (default) / `full` / `bleed`
|
||||||
|
- Next step when ready to revisit: write some actual stories, then decide whether
|
||||||
|
blueprint-as-blocks or the app/index.html patch route is worth pursuing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Keystatic content components](https://keystatic.com/docs/content-components)
|
||||||
|
- [Sanity Portable Text](https://www.sanity.io/docs/portable-text)
|
||||||
|
- [Shorthand storytelling sections](https://shorthand.com/features/sections/)
|
||||||
|
- [Grav Editor Pro](https://getgrav.org/premium/editor-pro)
|
||||||
|
- [Admin2 GitHub](https://github.com/getgrav/grav-plugin-admin2)
|
||||||
|
- [shortcode-core](https://github.com/getgrav/grav-plugin-shortcode-core)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
title: Dual-repo submodule workflow — outer dev-env repo + user/ content submodule
|
||||||
|
date: 2026-07-04
|
||||||
|
category: architecture-patterns
|
||||||
|
module: Repo structure — outer repo + user/ content repo
|
||||||
|
problem_type: architecture_pattern
|
||||||
|
component: git
|
||||||
|
severity: medium
|
||||||
|
applies_when:
|
||||||
|
- Starting a feature that touches both the outer repo and user/ (theme, plugins, pages)
|
||||||
|
- Setting up a git worktree for long-running work while doing other work in parallel
|
||||||
|
- Deciding when to bump the user/ submodule pointer in the outer repo
|
||||||
|
- A git worktree of the outer repo shows an empty or broken user/ directory
|
||||||
|
- Seeing a persistent "M user" / "m user" dirty state in the outer repo
|
||||||
|
tags: [git, submodule, worktree, dual-repo, user-repo, docker, content-sync, pointer-bump]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dual-repo submodule workflow
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This project is **two independent git repositories** that happen to be nested:
|
||||||
|
|
||||||
|
- **Outer repo** (`intotheeast-com.git`) — the Grav dev environment: `tests/`, `scripts/`, `docs/`, `docker-compose.yml`, `Dockerfile`, `Makefile`, `CLAUDE.md`.
|
||||||
|
- **`user/` repo** (`intotheeast-com-content.git`) — all site content and the theme: `pages/`, `config/`, `accounts/`, `themes/`. It has its own remote (the Gitea content mirror) and its own release cadence (`make content-push` → webhook → production pull).
|
||||||
|
|
||||||
|
As of 2026-07-04, the outer repo tracks `user/` as a **proper git submodule** (`.gitmodules` at the outer root, git dir absorbed into `.git/modules/user`). Before that it was an *orphaned gitlink* — a `160000` tree entry with no `.gitmodules`, so git had no URL to populate or update it. That broke worktrees (a fresh outer worktree got an empty `user/`) and offered no supported sync path.
|
||||||
|
|
||||||
|
## Why a submodule (and not untracking)
|
||||||
|
|
||||||
|
Two options were weighed: make it a real submodule, or stop tracking `user/` in the outer repo entirely (gitignore it, symlink the real checkout in).
|
||||||
|
|
||||||
|
The submodule was chosen deliberately, for one reason that outweighs its ceremony:
|
||||||
|
|
||||||
|
- **Routine content churn is benign** — day-to-day entries/stories change `user/` constantly and never break the dev environment. Those changes do **not** need to be reflected in the outer repo.
|
||||||
|
- **Cross-repo *features* must be tracked together.** A feature like the journal post-form touches both repos (a plugin + theme JS/CSS in `user/`, and tests/docs in the outer repo). The outer repo pinning an exact `user/` commit records *"this dev-env state expects this content/theme state"* — so checking out the outer feature also gets the matching `user/` code. That coupling is real and worth having.
|
||||||
|
- It enables **per-worktree `user/` checkouts**, which is what makes true parallel work across both repos possible (see below). This was a hard requirement.
|
||||||
|
|
||||||
|
The cost accepted: a persistent `M user` dirty signal (intrinsic to submodules under active development) and the possibility of gitlink merge conflicts between outer branches. Neither is removed by the submodule; they are the price of version pinning.
|
||||||
|
|
||||||
|
## The pointer-bump convention
|
||||||
|
|
||||||
|
The outer repo's `user` gitlink stores an exact `user/` commit SHA. **When to bump it:**
|
||||||
|
|
||||||
|
- **Routine content changes → do not bump.** Push content with `make content-push` and leave the outer pin where it is. A stale pin during normal content work is expected and harmless.
|
||||||
|
- **At the end of a cross-repo feature → bump once.** When the feature's `user/` work is finalized, update the outer pin to the finished `user/` commit, as the final step of the feature (its own `chore: bump user pointer to <sha>` commit, or folded into the final integration commit).
|
||||||
|
|
||||||
|
Two rules keep the pin from dangling for other machines/clones:
|
||||||
|
|
||||||
|
1. **Pin a commit reachable from `user/`'s published `main`.** Prefer the **merge-to-main commit**. Pinning a feature-branch tip is safe *only* if that exact commit survives onto `main` (fast-forward / no-squash merge); a squashed-away tip becomes an orphaned SHA and `git submodule update` fails elsewhere.
|
||||||
|
2. **Push `user/` before the outer repo.** The submodule golden rule: the superproject references a child SHA, so the child must already be pushed. `make content-push` handles the `user/` push — just do it before pushing the outer branch.
|
||||||
|
|
||||||
|
Production is unaffected either way: prod pulls `user/` directly via the content-remote webhook, independent of the outer repo's pin. The pin is **dev-side coordination only**.
|
||||||
|
|
||||||
|
## Parallel work: worktree + its own dev server
|
||||||
|
|
||||||
|
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
|
||||||
|
|
||||||
|
**Use the make targets — don't do the steps by hand.** From the main checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make worktree-new NAME=<feature> # create + start its own dev server
|
||||||
|
make worktree-rm NAME=<feature> # tear down cleanly
|
||||||
|
```
|
||||||
|
|
||||||
|
`worktree-new` does, in order: `git worktree add .worktrees/<feature> -b feat/<feature> main`, `git submodule update --init user`, branches `user/` onto `feat/<feature>`, writes a git-ignored `.worktree-env` (own compose project name, container name, auto-assigned port `8090+`) so every `make`/compose command run inside that worktree targets its own server, and starts the Grav service. The manual equivalent misses `.worktree-env` — without it, make commands in the worktree hit the main checkout's container on `:8081`.
|
||||||
|
|
||||||
|
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
|
||||||
|
|
||||||
|
### Teardown
|
||||||
|
|
||||||
|
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so teardown needs a submodule-deinit step before the worktree can be removed — skipping it is what leaves orphaned `.worktrees/` dirs. `make worktree-rm NAME=<feature>` runs the full sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# what worktree-rm does internally
|
||||||
|
make -C .worktrees/<feature> stop # compose down (its own server)
|
||||||
|
git -C .worktrees/<feature> submodule deinit -f user # detach the submodule worktree
|
||||||
|
git worktree remove --force .worktrees/<feature>
|
||||||
|
git worktree prune
|
||||||
|
git branch -d feat/<feature> # manual, if merged
|
||||||
|
```
|
||||||
|
|
||||||
|
### Landing a commit on main without disturbing the main checkout
|
||||||
|
|
||||||
|
When the main checkout is mid-work on another branch, add a commit to `main` through a throwaway worktree instead of `git checkout main` (which would yank branches out from under an open IDE):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree add .worktrees/main-tmp main
|
||||||
|
git -C .worktrees/main-tmp cherry-pick <sha> # or edit + commit
|
||||||
|
git worktree remove .worktrees/main-tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **`M user` / `m user` is normal.** Uppercase `M` = the pin differs from `user/` HEAD (bump pending or intentional). Lowercase `m` = the submodule working tree is dirty (e.g. an uncommitted `config/site.yaml` used for local testing). Neither is an error.
|
||||||
|
- **Gitlink merge conflicts still happen.** If two outer branches pin different `user/` SHAs, merging them conflicts on the `user` entry. Resolve by choosing the correct (usually newer, merged) SHA, then `git add user`.
|
||||||
|
- **Worktrees need `submodule update --init`.** A fresh outer worktree has an empty `user/` until you run it — it is not automatic.
|
||||||
|
- **The submodule git dir was absorbed** (`git submodule absorbgitdirs user`) so all worktrees share `.git/modules/user`. `user/.git` is now a gitfile (`gitdir: ../.git/modules/user`), not a directory. `make content-push`/`content-pull` still operate on `user/` normally.
|
||||||
|
- **Access requires the content remote** (SSH over Tailscale). A machine that cannot reach it cannot `submodule update` — but it could never clone `user/` anyway, so this is not a regression.
|
||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
---
|
||||||
|
title: "Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)"
|
||||||
|
date: 2026-07-05
|
||||||
|
last_updated: 2026-07-05
|
||||||
|
module: git-sync
|
||||||
|
problem_type: architecture_pattern
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
category: architecture-patterns
|
||||||
|
applies_when:
|
||||||
|
- "Enabling bidirectional Grav git-sync (direction: both, on_save: true) so prod can push content back to Gitea"
|
||||||
|
- "Auditing whether the server can leak secrets (tokens, password hashes, signing salts) off the production host"
|
||||||
|
- "A per-install runtime-generated value is being persisted into a tracked config file that also carries functional config"
|
||||||
|
- "Deciding where a per-install secret must live so it never round-trips"
|
||||||
|
- "A config value keeps ping-ponging or re-committing itself across environments after each sync"
|
||||||
|
tags:
|
||||||
|
- git-sync
|
||||||
|
- secret-exposure
|
||||||
|
- gitignore
|
||||||
|
- grav
|
||||||
|
- popularity-salt
|
||||||
|
- config-boundary
|
||||||
|
- bidirectional-sync
|
||||||
|
- per-install-secret
|
||||||
|
- env-tree-leak
|
||||||
|
---
|
||||||
|
|
||||||
|
# Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)
|
||||||
|
|
||||||
|
> **Correction (2026-07-05):** An earlier version of this doc claimed the sync
|
||||||
|
> add-set was *scoped to the configured `folders`*, and concluded that
|
||||||
|
> `accounts/` and `user/env/` were "safe by construction" because they sit
|
||||||
|
> outside `pages/config/themes`. **That model is wrong and caused a live secret
|
||||||
|
> leak.** git-sync's auto-commit stages files **outside** the configured folders;
|
||||||
|
> the only reliable exclusion is `.gitignore`. The corrected model is below.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The intotheeast.com Grav site runs the `git-sync` plugin on production in
|
||||||
|
**bidirectional** mode: `direction: both`, `on_save: true`, a webhook at
|
||||||
|
`/_git-sync`, and configured `folders: pages, config, themes`. Bidirectional
|
||||||
|
means the server both *pulls* content authored elsewhere **and** *pushes*
|
||||||
|
content authored on the server (Admin edits, `/post` submissions, uploads) back
|
||||||
|
to the shared Gitea repo.
|
||||||
|
|
||||||
|
The operator's question was: **"Can I safely enable bidirectional prod → Gitea
|
||||||
|
sync without leaking secrets?"** Production holds things that must never reach a
|
||||||
|
shared repo — API tokens, JWT signing secrets, CSRF salts, the git-sync token
|
||||||
|
itself.
|
||||||
|
|
||||||
|
The dangerous, tempting answer is "the plugin only syncs `pages/config/themes`,
|
||||||
|
so anything outside those folders is safe." **This is false, and acting on it
|
||||||
|
leaked secrets.** See the incident below.
|
||||||
|
|
||||||
|
## The incident (what actually happened)
|
||||||
|
|
||||||
|
Production's git-sync auto-commit `9337003` ("(Grav GitSync) Automatic Commit
|
||||||
|
...") pushed the **entire `user/env/intotheeast.com/config/` tree** to Gitea —
|
||||||
|
including `api-private.php` (JWT secret), `security-private.php` (CSRF salt), and
|
||||||
|
`git-sync.yaml` (the sync **token + webhook secret**) — plus `accounts/mischa.yaml`
|
||||||
|
(password hash), `system.yaml`, and `post-form.md`.
|
||||||
|
|
||||||
|
**None of those paths is under the configured `folders: pages, config, themes`.**
|
||||||
|
`user/env/` is a sibling of `user/config/`, not a subfolder of it. Yet git-sync
|
||||||
|
staged and pushed them anyway. That single fact refutes the "folders scope the
|
||||||
|
add-set" model empirically: **the `folders` setting does not scope what the
|
||||||
|
auto-commit stages.** Whatever git-sync's exact `git add` invocation, the
|
||||||
|
operational truth is that its commit sweeps the whole `user/` working tree.
|
||||||
|
|
||||||
|
Remediation: disable sync → `.gitignore` `/env/` and `git rm --cached` it →
|
||||||
|
`reset --hard` prod to the gitignored state → regenerate the leaked JWT/CSRF
|
||||||
|
salts (delete the `*-private.php` files; Grav regenerates them) → **rotate the
|
||||||
|
Gitea token and webhook secret** (they were exposed in `git-sync.yaml`). Rotation
|
||||||
|
is what actually neutralizes the leak; the history rewrite is optional for a
|
||||||
|
private repo.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
The safety question reduces to one predicate — but **not** the one the original
|
||||||
|
doc used:
|
||||||
|
|
||||||
|
> **A file round-trips to the shared repo if and only if it is NOT gitignored.**
|
||||||
|
> The configured `folders` setting does **not** narrow this. Treat the
|
||||||
|
> round-trippable set as *everything under `user/` that git will track* — i.e.
|
||||||
|
> everything not matched by `user/.gitignore`.
|
||||||
|
|
||||||
|
Consequences, corrected:
|
||||||
|
|
||||||
|
1. **`.gitignore` is the only reliable boundary.** Do not rely on a file being
|
||||||
|
"outside the synced folders." If it is under `user/` and not gitignored, a
|
||||||
|
bidirectional sync can push it. Design exclusions with `.gitignore`, and
|
||||||
|
verify with `git -C user status` / `git -C user check-ignore <path>`.
|
||||||
|
|
||||||
|
2. **`accounts/` is NOT structurally excluded.** `user/accounts/*.yaml` (bcrypt
|
||||||
|
password hashes) is a *tracked* content folder and is not gitignored, so it
|
||||||
|
**does** round-trip — `accounts/mischa.yaml` was in the leak commit. If you
|
||||||
|
need an account file to stay server-local, it must be gitignored explicitly
|
||||||
|
(as `accounts/testrunner.yaml` already is). The earlier "password hashes never
|
||||||
|
leave the server" claim was wrong.
|
||||||
|
|
||||||
|
3. **The per-environment tree `user/env/<host>/` MUST be gitignored** — it is
|
||||||
|
**not** inherently safe. It holds the live git-sync token, JWT secret, and
|
||||||
|
CSRF salt (Grav writes all server-side Admin config there once the env dir
|
||||||
|
exists). Because it is not under `user/config/` people assumed it was outside
|
||||||
|
the sync scope; the incident proved it is not. It is now gitignored
|
||||||
|
(`/env/` in `user/.gitignore`, commit `6e8eadb`). Keep it that way.
|
||||||
|
|
||||||
|
> Side effect worth remembering: once `user/env/<host>/` exists, Grav's Admin
|
||||||
|
> writes **all** config changes there (system and plugin), not into
|
||||||
|
> `user/config/`. So server-side Admin edits are server-only — but "server-only"
|
||||||
|
> now depends entirely on `/env/` being gitignored, not on folder scope. When
|
||||||
|
> auditing, check **both** `user/config/...` and `user/env/<host>/config/...`
|
||||||
|
> (env wins at runtime). See `docs/working/git-sync-notes.md`.
|
||||||
|
|
||||||
|
4. **Per-install secrets go in gitignored companion files.** Grav's convention
|
||||||
|
splits a per-install secret out of the functional YAML into a sibling that is
|
||||||
|
gitignored: the JWT secret in `api-private.php`, the CSRF/nonce + rate-limit
|
||||||
|
salt in `security-private.php`, plus `security.yaml` and `versions.yaml`.
|
||||||
|
These are safe **because they are gitignored**, not because of where they sit.
|
||||||
|
|
||||||
|
Run every secret through predicate #1 (is it gitignored?) and the answer falls
|
||||||
|
out — but you must actually enumerate what is *not* gitignored, not what is
|
||||||
|
"outside the folders."
|
||||||
|
|
||||||
|
### The tracked-file boomerang (a separate trap)
|
||||||
|
|
||||||
|
Independently of the folder-scope error above, there is a second trap that the
|
||||||
|
original doc got right and that still holds: **a per-install value that a plugin
|
||||||
|
regenerates at runtime and persists into a tracked, functional config file.**
|
||||||
|
|
||||||
|
The concrete case: the `api` plugin's *popularity* feature generates
|
||||||
|
`popularity.salt` and writes it **into `user/config/plugins/api.yaml`** — a file
|
||||||
|
that also carries must-be-shared functional config. That file is tracked, so on
|
||||||
|
prod the popularity feature regenerates the salt, git-sync stages the change,
|
||||||
|
commits, and **pushes prod's salt back to the shared repo**. Another environment
|
||||||
|
pulls it, regenerates *its own* salt, pushes again. The value **ping-pongs across
|
||||||
|
installs**, producing endless noise commits.
|
||||||
|
|
||||||
|
The critical realization: **you cannot gitignore a single key inside a file that
|
||||||
|
also carries functional config.** `.gitignore` operates on whole files. `api.yaml`
|
||||||
|
must be tracked because the rest of it must be shared; therefore the salt inside
|
||||||
|
it is tracked too; therefore it boomerangs.
|
||||||
|
|
||||||
|
### The rules
|
||||||
|
|
||||||
|
For any per-install runtime-generated value, pick one of exactly three
|
||||||
|
resolutions — and do **not** reach for the fourth (stripping the line), which
|
||||||
|
cannot work under sync:
|
||||||
|
|
||||||
|
- **Isolate the value into a gitignored companion `<name>-private.php`** — the
|
||||||
|
pattern Grav uses for the JWT secret via `api-private.php`.
|
||||||
|
- **Disable the feature that generates it** (e.g. `popularity.enabled: false`).
|
||||||
|
- **Consciously accept the churn** when the value is genuinely low-stakes
|
||||||
|
(`popularity.salt` is an IP-hashing salt, not a credential).
|
||||||
|
|
||||||
|
Do **not** keep stripping the value from the tracked file — bidirectional sync
|
||||||
|
brings it right back on the next save.
|
||||||
|
|
||||||
|
### Before / after: what sticks and what doesn't
|
||||||
|
|
||||||
|
**A standalone file gitignored + untracked sticks.** For `security-private.php`
|
||||||
|
(a file that contains *only* the secret) or the whole `user/env/` tree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user rm --cached -r config/security-private.php # or: rm --cached -r env
|
||||||
|
printf '/config/security-private.php\n/env/\n' >> user/.gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
This works permanently. The path is no longer tracked, so the sync's add-set
|
||||||
|
skips it forever. The fix sticks because the secret owns its own gitignored path.
|
||||||
|
|
||||||
|
**A key inside a tracked functional file — stripping the line does NOT stick.**
|
||||||
|
For `popularity.salt` inside `api.yaml`, deleting just the `salt:` line and
|
||||||
|
committing looks clean locally, but Grav re-appends it at runtime and the next
|
||||||
|
sync re-commits and re-pushes it. The only durable fixes are the
|
||||||
|
companion-private-file pattern or disabling the feature.
|
||||||
|
|
||||||
|
### Untracking an already-committed secret under a live sync — freeze every server first
|
||||||
|
|
||||||
|
`.gitignore` (and git-sync's `ignore` field) only affects **untracked** files.
|
||||||
|
Once a secret has actually been *committed* to the shared repo, ignoring it does
|
||||||
|
nothing — removing it means rewriting history. Doing that while a bidirectional
|
||||||
|
sync is live has its own trap.
|
||||||
|
|
||||||
|
**A `direction: both` server silently reverts your force-push.** Concrete
|
||||||
|
incident (2026-07-05, a second occurrence of this doc's trap): `config/security-private.php`
|
||||||
|
and `config/versions.yaml` had been committed to Gitea `main` (auto-commit
|
||||||
|
`d1643a7`, merged at `f8c45fc`). Force-pushing `main` back to the clean commit
|
||||||
|
`32c3d8c` *looked* successful — and within seconds Gitea was back at `f8c45fc`.
|
||||||
|
Cause: prod's git-sync is `direction: both` and its local `HEAD` was still
|
||||||
|
`f8c45fc`; on its next sync it re-pushed the stale, secret-bearing commit and
|
||||||
|
undid the rewrite. The pull-only test server never fought back — **only
|
||||||
|
push-enabled servers do.**
|
||||||
|
|
||||||
|
**The rule: freeze git-sync on _every_ server (push *and* pull) before rewriting
|
||||||
|
shared history.** A pull server mid-rewrite can also resurrect a half-removed
|
||||||
|
state. The safe sequence that worked:
|
||||||
|
|
||||||
|
1. **Freeze all sync.** `make remote-git-sync-disable-{test,prod}` (flips
|
||||||
|
`enabled: false` in the env-path `git-sync.yaml`).
|
||||||
|
2. **Audit where the live secret actually lives — before any `reset --hard`.** A
|
||||||
|
destructive reset *deletes* working-tree files tracked now but absent in the
|
||||||
|
target commit. `config/security-private.php` was such a file — but it was a
|
||||||
|
**stray duplicate**; the authoritative 290-byte copy lives at
|
||||||
|
`env/intotheeast.com/config/security-private.php` (mode 600), which git-sync
|
||||||
|
had copied into `config/`. Because `env/` is gitignored and outside every
|
||||||
|
tracked folder, it survives the reset and *wins* Grav's config merge — so
|
||||||
|
dropping the `config/` copy is safe. **Verify this first** with a secret-safe
|
||||||
|
audit that lists existence + size + `git ls-files` tracking and **never prints
|
||||||
|
contents** (added as `make remote-secrets-audit`; it `ls` / `git ls-files`,
|
||||||
|
never `cat`).
|
||||||
|
3. **Force-push `main` to the clean commit.** It sticks now — no server is pushing.
|
||||||
|
4. **Reset each server** with `make remote-fetch-content-{test,prod}`
|
||||||
|
(`fetch` → `sparse-checkout disable` → `reset --hard origin/main`). This
|
||||||
|
deletes the stray tracked `config/` copies; the `env/` originals remain.
|
||||||
|
5. **Verify** `git ls-files` shows no secret tracked and the `env/` copy is intact
|
||||||
|
on every host.
|
||||||
|
6. **Re-enable sync** (`make remote-git-sync-enable-*`), preserving each server's
|
||||||
|
`direction`. Local `HEAD` now equals Gitea `main`, so there is nothing bad to
|
||||||
|
push.
|
||||||
|
|
||||||
|
Two gotchas inside step 4:
|
||||||
|
|
||||||
|
- **Stale remote-tracking ref.** `reset --hard origin/main` resets to the
|
||||||
|
server's *cached* `refs/remotes/origin/main`, not to Gitea directly. If that ref
|
||||||
|
is stale the reset lands on the wrong commit — confirm the `fetch` force-updated
|
||||||
|
it (`+ f8c45fc...32c3d8c main -> origin/main (forced update)`) before trusting
|
||||||
|
the reset.
|
||||||
|
- **`sparse-checkout disable` before `reset --hard`** — otherwise the reset only
|
||||||
|
touches paths inside the sparse pattern and can skip/wipe directories outside it.
|
||||||
|
|
||||||
|
**Durable exclusion goes in git-sync's `ignore:` config field, never a
|
||||||
|
hand-edited `.gitignore`.** git-sync owns `.gitignore`: on load it regenerates it
|
||||||
|
from `folders` (`/*`, `!/pages`, `!/config`, `!/themes`) and **appends** the
|
||||||
|
`ignore:` entries. Hand edits are clobbered on the next sync; `ignore:` entries
|
||||||
|
persist because git-sync writes them back every time. So the secret paths belong
|
||||||
|
in `ignore:` — but that only prevents *future* tracking. The history rewrite
|
||||||
|
(steps 1–5) is still required *in addition to* the ignore entries to remove a
|
||||||
|
secret that is already committed, not instead of them.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Two quiet, cross-environmental failure modes:
|
||||||
|
|
||||||
|
1. **The folder-scope illusion.** Assuming "only `pages/config/themes` sync" is a
|
||||||
|
security control leads you to leave secrets in `env/` or `accounts/` unignored
|
||||||
|
— and a single Admin save on prod pushes them to a shared repo. This actually
|
||||||
|
happened here. The only defensible mental model is *gitignore is the boundary*;
|
||||||
|
enumerate the un-ignored set, not the "un-foldered" set.
|
||||||
|
|
||||||
|
2. **The boomerang.** A "cleanup" commit that strips a secret from a *tracked*
|
||||||
|
file looks done locally but silently reappears upstream on the next content
|
||||||
|
save, because the plugin regenerates it and the sync re-commits it.
|
||||||
|
|
||||||
|
Getting both right is what lets you answer "is bidirectional sync safe?" honestly.
|
||||||
|
The answer is **yes, once `user/.gitignore` actually excludes every sensitive
|
||||||
|
path** — `env/`, the per-install `*-private.php` files, `security.yaml`,
|
||||||
|
`versions.yaml`, and any account file that must stay server-local — and once every
|
||||||
|
runtime-regenerated value either lives in its own gitignored file or is a
|
||||||
|
consciously-accepted low-stakes churn. It is emphatically **not** safe on the
|
||||||
|
strength of folder scoping alone.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- **Enabling or auditing bidirectional git-sync** on a server that authors
|
||||||
|
content. Enumerate the round-trippable set as *everything under `user/` not
|
||||||
|
matched by `.gitignore`* — then confirm no secret is in it.
|
||||||
|
- **Deciding where a new secret or per-install generated value should live.**
|
||||||
|
Standalone gitignored file for anything sensitive; never a key inside a shared
|
||||||
|
functional YAML; never "outside the folders" as the sole justification.
|
||||||
|
- **Reviewing a "stop tracking this secret" cleanup** for whether it will stick:
|
||||||
|
is the secret in its own gitignored path (holds) or a line inside a tracked
|
||||||
|
functional file that something regenerates (boomerangs)?
|
||||||
|
- **Rewriting shared history (force-push, `filter-repo`, `reset --hard`) on a
|
||||||
|
git-sync-managed repo** — freeze sync on every server first, audit where the
|
||||||
|
live secret authoritatively lives before any destructive reset, then re-enable.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**The leak (what "folder scope is safe" cost).** With `folders: pages, config,
|
||||||
|
themes` and `/env/` **not** gitignored, prod's auto-commit `9337003` pushed
|
||||||
|
`user/env/intotheeast.com/config/**` (JWT, CSRF salt, git-sync token + webhook
|
||||||
|
secret), `accounts/mischa.yaml`, `system.yaml`, and `post-form.md` to Gitea —
|
||||||
|
all outside the configured folders. Fix: gitignore + untrack `/env/`, regenerate
|
||||||
|
the JWT/CSRF salts, **rotate the token and webhook secret**.
|
||||||
|
|
||||||
|
**Safe after remediation.** Same bidirectional config, but now `user/.gitignore`
|
||||||
|
excludes `/env/`, `config/plugins/git-sync.yaml`, `config/plugins/api-private.php`,
|
||||||
|
`config/security.yaml`, `config/security-private.php`, `config/versions.yaml`.
|
||||||
|
Running each secret through *is-it-gitignored*: all sensitive paths are excluded →
|
||||||
|
none is in the round-trippable set. Verified: prod's `git status` shows only the
|
||||||
|
intended tracked content, and no boomerang/secret commit lands on the remote.
|
||||||
|
|
||||||
|
**Boomerang example.** `popularity.salt` in the tracked `api.yaml` regenerates
|
||||||
|
per-install and re-commits under sync. The fix that *sticks* is the
|
||||||
|
companion-private-file pattern or `popularity.enabled: false` — **not** stripping
|
||||||
|
the `salt:` line.
|
||||||
|
|
||||||
|
**Force-push revert example.** With `config/security-private.php` +
|
||||||
|
`config/versions.yaml` already committed to Gitea `main` (`f8c45fc`), a
|
||||||
|
`git push --force origin main` back to the clean `32c3d8c` was undone within
|
||||||
|
seconds — prod's `direction: both` git-sync re-pushed its stale `f8c45fc` `HEAD`.
|
||||||
|
The rewrite only held after `make remote-git-sync-disable-{test,prod}` froze both
|
||||||
|
servers first; then force-push → `make remote-fetch-content-{test,prod}` →
|
||||||
|
re-enable. Verified afterward: `git ls-files` on every host lists no secret, and
|
||||||
|
each host's `env/…/security-private.php` is intact.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the
|
||||||
|
origin of the "functional plugin config in tracked `user/config/plugins/`,
|
||||||
|
secrets/per-install values in gitignored `*-private.php`" rule. That doc covers
|
||||||
|
*where config must live to deploy*; this doc covers *why gitignore — not folder
|
||||||
|
scope — is the sync boundary, and why a runtime-written tracked value boomerangs*.
|
||||||
|
- `docs/working/git-sync-notes.md` — operational notes on git-sync's synced
|
||||||
|
folders and the per-environment tree. Corrected in the same 2026-07-05 pass to
|
||||||
|
drop the "env/ is outside the sync scope so it's safe" claim.
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`
|
||||||
|
— adjacent context from the same Grav production cutover, different failure mode.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
title: Retiring a standalone Grav sub-page that was consolidated onto another page
|
||||||
|
date: 2026-07-04
|
||||||
|
category: architecture-patterns
|
||||||
|
module: Grav theme — trip pages / templates
|
||||||
|
problem_type: architecture_pattern
|
||||||
|
component: rails_view
|
||||||
|
severity: medium
|
||||||
|
applies_when:
|
||||||
|
- Deleting a standalone Grav view whose content now renders inside another page
|
||||||
|
- A page folder holds child pages that other templates fetch via grav.pages.find(route).children
|
||||||
|
- Detail pages have a Back link that falls back to the deleted page
|
||||||
|
- The affected trip is demo content regenerated by make demo-load
|
||||||
|
tags: [grav, twig, page-tree, routable, back-link, demo-content, page-retirement]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Retiring a standalone Grav sub-page that was consolidated onto another page
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The site consolidated four standalone trip views — `/map`, `/stats`, `/dailies`, `/stories` — onto a single trip page (inline map + filter bar + inline stats). Removing the now-redundant view pages looks like a simple `git rm`, but a Grav "page" is a **folder + a `.md` that names a template**, and several things quietly depend on both halves. Missing any of them ships a broken site or a broken `make demo-load`. This captures the safe procedure and the three traps that are not obvious from the file listing.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
Treat a page as two separable roles: a **routable view** (the `.md`'s template renders a URL) and a **data container** (the folder holds child pages other code reads). Retiring the view must preserve the container.
|
||||||
|
|
||||||
|
**1. Keep the folder; neutralise the view — do not delete the container.**
|
||||||
|
`01.dailies/` and `04.stories/` hold the journal/story children, and `trip.html.twig` / `home.html.twig` reach them via `grav.pages.find(route ~ '/dailies').children`. Deleting the folder (or its `.md`) breaks that lookup and the entries vanish from the feed. Instead, keep the folder and repoint its `.md` to an inert container:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 01.dailies/dailies.md
|
||||||
|
---
|
||||||
|
title: Journal
|
||||||
|
template: default # was: dailies (dailies.html.twig is deleted)
|
||||||
|
routable: false # the container's own URL 404s
|
||||||
|
visible: false # not enumerated in nav
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
`routable: false` makes the container URL inert **without** unrouting its children — individual entries stay reachable at `/trips/<slug>/dailies/<entry>`, and `find(...).children` still resolves because the page remains in the tree. (Folders that were pure views with no children — `02.map/`, `03.stats/` — can be deleted outright.)
|
||||||
|
|
||||||
|
**2. Repoint Back-link fallbacks to the surviving surface (grandparent), not the retired parent.**
|
||||||
|
Detail templates used `href="{{ page.parent().url }}"` with an `onclick` that runs `history.back()` when history exists. `history.back()` covers in-app navigation, but the `href` is the fallback for **direct-landing visitors** (shared link, new browser tab, search result) — and it pointed at the now-inert container:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{# entry.html.twig / story.html.twig — BEFORE (breaks on direct landing) #}
|
||||||
|
<a class="back-pill" href="{{ page.parent().url }}"
|
||||||
|
onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||||
|
|
||||||
|
{# AFTER — fall back to the trip page (grandparent), the surface that survived #}
|
||||||
|
<a class="back-pill" href="{{ page.parent().parent().url }}"
|
||||||
|
onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a **silent** regression: the detail page renders fine (200, no Twig error), so curl/CI-of-the-page all pass. It only breaks when a cold visitor clicks Back — the exact path a shared link takes.
|
||||||
|
|
||||||
|
**3. Sync the gitignored demo source AND the Makefile, or the next reload undoes the cleanup.**
|
||||||
|
The `italy-2026-demo` trip pages are **gitignored**; they are regenerated by `make demo-load` from `user/docs/demo/trips/italy-2026-demo/` (which **is** tracked in the user repo). Deleting pages from the live tree is not enough — you must also:
|
||||||
|
- delete/repoint the demo **source** files (`map.md`, `stats.md`, and the `dailies/`/`stories` container `.md`s), and
|
||||||
|
- update the `demo-load` Makefile target so it no longer `mkdir`s `02.map`/`03.stats` or copies the deleted `.md`s.
|
||||||
|
|
||||||
|
Otherwise the next `make demo-load` recreates the deleted pages pointing at deleted templates. This matters doubly because Playwright's `global-setup` runs `make demo-load` before the suite — stale demo source breaks tests, not just a manual reload.
|
||||||
|
|
||||||
|
**4. Sweep the test suite for the deleted routes.** Delete tests that target the gone pages; re-point tests whose behaviour moved to the consolidation surface (e.g. the feed sort toggle is now `#trip-sort-toggle` on the trip page). Note the consolidation surface may sort differently (the trip page is oldest-first; the old `/dailies` view was newest-first) — re-pointed ordering assertions may need to invert.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
The two halves of a Grav page (routable view vs. data container) are invisible in a file listing but load-bearing. The failure modes are asymmetric and sneaky: deleting a container **loudly** empties a feed (easy to catch), but the back-link fallback fails **silently** for only a subset of visitors, and the demo-source drift fails **later** — on the next reload or CI run, not during the change. A curl/HTTP smoke test passes all three while two are broken. Getting the procedure right the first time avoids a shipped regression and a red suite that looks unrelated to the change.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Deleting any Grav view page whose feed/map/list now renders inside another page.
|
||||||
|
- Any time a page folder is a parent of child pages that templates fetch via `find(route).children` — keep it as a `routable:false` container.
|
||||||
|
- Whenever a detail page's Back link (or any `page.parent()` reference) could resolve to the page being retired.
|
||||||
|
- Whenever the affected trip is `italy-2026-demo` (or any gitignored, `demo-load`-regenerated content).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**Verification that proves the container split worked** (children of a `routable:false` parent stay reachable):
|
||||||
|
|
||||||
|
```
|
||||||
|
# keepers
|
||||||
|
/ → 200
|
||||||
|
/trips/italy-2026-demo → 200 (feed still populated)
|
||||||
|
/trips/italy-2026-demo/dailies/<entry> → 200 (child of routable:false container)
|
||||||
|
/trips/italy-2026-demo/stories/<story> → 200
|
||||||
|
# retired views
|
||||||
|
/trips/italy-2026-demo/map → 404
|
||||||
|
/trips/italy-2026-demo/stats → 404
|
||||||
|
/trips/italy-2026-demo/dailies → 404 (container inert; children still route)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grep gate before declaring done** — no `include`/`import`/link references to the deleted templates remain (a lone `macros/stats.html.twig` hit is the shared macro, a keeper — not the deleted `stats.html.twig` page):
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -rnE "include .*(feed-map|dailies|stories|map|stats)\.html|~ '/map'|~ '/stats'" templates/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/working/plans/2026-07-04-standalone-page-cleanup.md` — the plan this learning came from
|
||||||
|
- `docs/reference/architecture.md` — page-tree structure and the single `MapUtils.initEntryMap` map path
|
||||||
|
- `docs/guides/trip-switching.md` — new trips now scaffold only `01.dailies/` + `04.stories/` (inert containers)
|
||||||
|
- `CLAUDE.md` → "Trip entity architecture" and "One map path" — the current-state contract
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
---
|
||||||
|
title: CLAUDE.md content tiering — rules stay, descriptions move out
|
||||||
|
date: 2026-07-24
|
||||||
|
category: conventions
|
||||||
|
module: documentation
|
||||||
|
problem_type: convention
|
||||||
|
component: documentation
|
||||||
|
severity: medium
|
||||||
|
applies_when:
|
||||||
|
- "Deciding whether new content belongs in CLAUDE.md or a docs/ subfolder"
|
||||||
|
- "CLAUDE.md has grown and needs a reduction pass"
|
||||||
|
- "Writing a rule that references specific file paths, bundle names, or other enumerable facts"
|
||||||
|
- "Extracting descriptive content out of CLAUDE.md into docs/reference or docs/guides"
|
||||||
|
tags: [claude-md, documentation-conventions, context-management, staleness, tiering, agent-instructions]
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLAUDE.md content tiering — rules stay, descriptions move out
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`CLAUDE.md` at the root of this repo is loaded into every single session, before any file is opened. It had grown to 255 lines of mixed content: rules, stack version numbers, plugin role tables, `make` command tables, folder maps, template hierarchies, and descriptions of how the asset pipeline worked. Nobody had ever asked whether a line earned its place in permanent context.
|
||||||
|
|
||||||
|
Four rounds of work over one session took it to 74 lines. The interesting part was not the size reduction — it was what the audits revealed about *which kinds of sentences go stale*, and the fact that the first honest audit made the file **bigger**.
|
||||||
|
|
||||||
|
| Round | Commit | Lines | What happened |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `2fbfc88` | 255 → **305** | Audit scored the file 76/100, fixed 4 stale facts, and *added* genuinely missing sections (testing, dev commands, plugin patches) |
|
||||||
|
| 2 | `ed6e43a` | 305 → **179** | Descriptive content extracted to `docs/` |
|
||||||
|
| 3 | `839a4d0` | 179 → **74** (17,057 → 8,544 chars) | Rules-only cut; created `docs/reference/testing.md`, grew `README.md` |
|
||||||
|
| — | `9ec2349` | +52 | `docs/working/README.md` added; the plan-status *rule* stayed in CLAUDE.md, the *explanation* moved out |
|
||||||
|
| 4 | `285e615` | 74 → **74** | Build-output rule restated as an invariant. 3 lines → 3 lines, 156 chars saved. Not a size change — a staleness fix |
|
||||||
|
|
||||||
|
The four stale facts from round 1, verbatim from `2fbfc88`'s commit body:
|
||||||
|
|
||||||
|
- `active_trip: japan-korea-2026` — the committed value was `/trips/denmark-2026` and **no `japan-korea` trip folder existed**
|
||||||
|
- `Admin2 v2.0.10` — installed version was `v2.0.12`
|
||||||
|
- `make demo-load` described as italy-only — the Makefile loops over every fixture under `user/docs/demo/trips/`
|
||||||
|
- the `user/` gitignore claim omitted the three un-ignored site-owned plugins and the secret/`env/` exclusions
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
### 1. Apply the operational test to every line
|
||||||
|
|
||||||
|
> **Does this line change what Claude does on a task where it wouldn't otherwise open the relevant file?**
|
||||||
|
|
||||||
|
If no, it is a *description* — move it to `docs/`. Claude reads the code anyway; prose about the code just drifts alongside it.
|
||||||
|
|
||||||
|
Corollary: **version numbers are pure drift with no behavioral payload.** `Grav 2.0.7`, `Admin2 v2.0.12`, and the GPM-channel paragraph were all dropped. What survived is version-free:
|
||||||
|
|
||||||
|
> The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||||
|
|
||||||
|
"No database" stays because it *does* change behavior — an agent that believes there is a database goes looking for migrations, an ORM, and a query layer that do not exist.
|
||||||
|
|
||||||
|
### 2. Tier content by when it gets read
|
||||||
|
|
||||||
|
| Content | Home | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Rules, gotchas, invariants | `CLAUDE.md` | Worthless unless already in context |
|
||||||
|
| How the code works | `docs/reference/` | Claude reads the code anyway; prose drifts |
|
||||||
|
| How to do a task | `docs/guides/` | Read at task start, on demand |
|
||||||
|
| A trap already hit, with symptoms | `docs/solutions/` | Retrieved by symptom, indexed by frontmatter |
|
||||||
|
| Setup, folder map, commands | `README.md` | For humans; Claude has the Makefile |
|
||||||
|
|
||||||
|
CLAUDE.md keeps a six-row entry-point table pointing at each destination — the routing is a rule, the content behind it is not.
|
||||||
|
|
||||||
|
### 3. Gotchas are the one category that cannot be extracted
|
||||||
|
|
||||||
|
Every other content type has a natural trigger that opens the file:
|
||||||
|
|
||||||
|
| Type | Trigger that gets it read |
|
||||||
|
|---|---|
|
||||||
|
| Description | Agent opens the code |
|
||||||
|
| Procedure | Agent starts the task |
|
||||||
|
| Incident write-up | Agent recognizes a symptom |
|
||||||
|
| **Gotcha / exception** | **none — it must already be in context** |
|
||||||
|
|
||||||
|
A file you only open once you suspect an exception exists is a file you open **too late**. A proposed `docs/exceptions/` directory was therefore recommended against. Supporting arithmetic: the whole rules surface is ~40 lines / ~2,200 tokens, so a second file saves ~1k tokens while adding a lookup step, and `docs/solutions/` (indexed by `module` / `tags` / `problem_type`) already fills the read-on-demand role for "have we hit this before?".
|
||||||
|
|
||||||
|
### 4. State invariants, not enumerations
|
||||||
|
|
||||||
|
An enumerated list is falsified by the next addition, silently. An inverted statement of the same fact survives it. This is what `285e615` did — same three lines, no size change, but now staleness-proof.
|
||||||
|
|
||||||
|
### 5. Verify the destination before extracting
|
||||||
|
|
||||||
|
Every extraction target was confirmed to already exist and already cover the topic:
|
||||||
|
|
||||||
|
- pointer bumps and worktree mechanics → `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` (already covered them)
|
||||||
|
- the `user/env/<host>/` override tree → `docs/guides/deploy-cycle.md` (already covered it)
|
||||||
|
- source→output asset table → `docs/reference/architecture.md` → "Asset pipeline" (section added to receive it, lines 69-82)
|
||||||
|
- test-suite descriptions → `docs/reference/testing.md` (**created**, 67 lines — no destination existed)
|
||||||
|
- folder map + `make` tables → `README.md` (179 → 227 lines)
|
||||||
|
|
||||||
|
Nothing extracted became homeless. Related fix in the same pass: `docs/working/git-sync-notes.md` pointed at "CLAUDE.md §1", a section number that no longer existed after renumbering — **cross-references into an instruction file must point at stable headings, never numbers.**
|
||||||
|
|
||||||
|
### 6. Know when to stop
|
||||||
|
|
||||||
|
At 74 lines the section sizes were even — Hard rules 9, Dev environment 8, Content and trips 7, Two shared partials 7, Dual-repo submodule 7, Testing 7, Working docs 7, intro + entry-point table 15. No fat pocket remained. Roughly 8 more lines *could* have gone (the `travel-memories` :8082 port, a parenthetical Twig-recompile aside, tightening two bullets) for ~250 tokens out of ~2,200 — while deleting actual rules.
|
||||||
|
|
||||||
|
**The trim is strongly positive while what leaves is descriptions, and turns negative once only rules remain.** Round 3 therefore ended with a "we're at the floor" verdict plus one robustness fix (`285e615`), not another cut.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**Every stale fact found across all four rounds was a description of code or config. Not one was a rule.** Two of them had been written by Claude itself days earlier. Descriptions drift because the code moves and the prose does not; rules do not drift because they encode intent rather than state. The tiering above is not an aesthetic preference — it is the only conclusion the evidence supports.
|
||||||
|
|
||||||
|
**A wrong path in an always-loaded file is worse than an absent one.** CLAUDE.md claimed the map engine lived at `js/src/maplibre-utils.js`. That file does not exist. The real path is `user/themes/intotheeast/js/maplibre-utils.js` — a hand-authored source sitting *next to* the generated bundles in `js/`, imported by `js/src/map.js` as `../maplibre-utils.js`. The wrong path survived rounds 1 and 2 (`2fbfc88` line 76, `ed6e43a` line 64) and was only fixed in `839a4d0`.
|
||||||
|
|
||||||
|
An absent fact makes an agent go look. A wrong fact makes it act confidently in the wrong place. Here the wrong place was `js/map.js` — a minified esbuild bundle. The failure mode is a hand-edit that survives until the next `make build-assets` silently reverts it.
|
||||||
|
|
||||||
|
This is also the decisive argument against `docs/exceptions/`: **the maplibre-utils mistake happened because the path was wrong, not because it was missing.** Had that rule lived in `docs/exceptions/assets.md`, the bundle would have been hand-edited with the agent never knowing the file existed.
|
||||||
|
|
||||||
|
**What survived the cut is the sanity check on the criterion.** A rule stays when being wrong about it is expensive *and* the correct behavior is not derivable from reading a file:
|
||||||
|
|
||||||
|
- the Admin plugin slug is `admin2`, not `admin` — nothing in the tree announces this before you've already guessed wrong
|
||||||
|
- `plugins.txt` is hand-maintained; installing a plugin via Admin does **not** update it
|
||||||
|
- once `user/env/<hostname>/` exists on a server, Grav's Admin writes **all** config there — system *and* plugin — and env wins, so server config must be read from both trees
|
||||||
|
- `active_trip` is a **route** (`/trips/denmark-2026`), not a bare slug
|
||||||
|
- never re-add a `pageconfig.parent` to `post-form.md` — a static parent overrides the `active_trip`-derived write target and reintroduces a silent-desync bug
|
||||||
|
- the standalone `/dailies`, `/map`, `/stats`, `/stories` trip views were deleted 2026-07-04 and must not be re-created or linked
|
||||||
|
|
||||||
|
Each of those is a landmine an agent steps on *before* it has cause to open the relevant file.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Auditing or editing any always-loaded instruction file — `CLAUDE.md`, `AGENTS.md`, system prompts, agent definitions
|
||||||
|
- When a stale fact is found in an instruction file: fix it, then ask why that *category* of sentence was there at all
|
||||||
|
- Before adding a line to `CLAUDE.md` — run the operational test first, and route to the tiering table if it fails
|
||||||
|
- Before writing an enumerated list of files, paths, plugins, or bundles into an instruction file — try inverting it into an invariant and verify the inverted form against the actual directory listing
|
||||||
|
- Before extracting content out of an instruction file — confirm the destination exists and covers the topic, or create it in the same commit
|
||||||
|
- When tempted to create a new read-on-demand directory for exceptions or gotchas — don't; they only work in-context
|
||||||
|
- When a reduction pass stops finding descriptions and starts deleting rules — stop and record a floor verdict instead of cutting further
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Enumerated list → invariant (`285e615`)
|
||||||
|
|
||||||
|
**Before** — 3 lines, falsified by adding a fifth bundle:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Never hand-edit build output**, and know which files those are — sources and outputs
|
||||||
|
share folders under `user/themes/intotheeast/` (all paths below are relative to it).
|
||||||
|
`make build-assets` is mandatory after editing any source, and it writes:
|
||||||
|
- **Generated (never edit):** `js/main.js`, `js/map.js`, `js/feed-actions.js`,
|
||||||
|
`js/trip-publish.js`, `js/post/`, `css-compiled/`, `fonts/`, and
|
||||||
|
`templates/partials/weather-icons.html.twig`.
|
||||||
|
- **Hand-authored sources:** everything in `js/src/`, plus `js/maplibre-utils.js` and
|
||||||
|
`js/nav.js` (which sit *next to* the bundles in `js/`), `css/style.css`,
|
||||||
|
`css/tokens.css`, and `scripts/gen-weather-icons.js`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** — 3 lines, 156 chars shorter, still true after the next bundle is added:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **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 (sources: `css/style.css`, `css/tokens.css`);
|
||||||
|
so is `templates/partials/weather-icons.html.twig` (source: `scripts/gen-weather-icons.js`).
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification that made this safe: `ls js/` returns exactly the 4 bundles + `post/` + `maplibre-utils.js` + `nav.js` + `src/`. The inverted form is exactly true today and stays true as bundles are added. The full enumerated source→output table now lives in `docs/reference/architecture.md` → "Asset pipeline", where drift is cheap because the table is read next to the code it describes.
|
||||||
|
|
||||||
|
### Description → extracted; rule → kept
|
||||||
|
|
||||||
|
**Before** (round 1 addition, later cut) — a description of the build, in permanent context:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**`make build-assets` is mandatory after editing anything in
|
||||||
|
`user/themes/intotheeast/js/src/`.** Sources live in `js/src/`; esbuild writes the
|
||||||
|
committed bundles — `js/main.js`, `js/map.js`, `js/feed-actions.js`,
|
||||||
|
`js/trip-publish.js`, `js/post/`, and the CSS extracted into `css-compiled/`.
|
||||||
|
**Never hand-edit those.** By contrast `css/style.css` and `css/tokens.css` are
|
||||||
|
hand-authored sources, not build outputs. `build-assets` runs as your host UID
|
||||||
|
(`--user`) so the outputs in the bind-mounted `user/` tree are not root-owned.
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** — the `--user` mechanism and the esbuild pipeline moved to `docs/reference/architecture.md` line 71; only the never-edit rule and the source/output discriminator remain in `CLAUDE.md`.
|
||||||
|
|
||||||
|
### Wrong path → right path (`839a4d0`)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js`.
|
||||||
|
+the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js`
|
||||||
|
+(a hand-authored file, imported by `js/src/map.js`)
|
||||||
|
```
|
||||||
|
|
||||||
|
`js/src/maplibre-utils.js` never existed. The parenthetical is not padding — it is the whole reason the rule is in an always-loaded file: `js/` is the bundle directory, so a hand-authored source living there is exactly the fact an agent cannot infer.
|
||||||
|
|
||||||
|
### Rule stays, explanation leaves (`9ec2349`)
|
||||||
|
|
||||||
|
The plan-status convention needed both a machine-actionable rule and a human-readable explanation of the five states. They went to different files:
|
||||||
|
|
||||||
|
- `CLAUDE.md` keeps the one-line rule — every plan needs a `**Status:**` line immediately after its title, plus what to surface when asked what's open, plus set `✅ Complete (YYYY-MM-DD)` before closing a session
|
||||||
|
- `docs/working/README.md` (52 lines) holds the explanation of the states, the directory layout, and the human-facing reference
|
||||||
|
|
||||||
|
Same convention, split by *when each half needs to be in context*.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [`docs/README.md`](../../README.md) — the existing "always-loaded rules → CLAUDE.md" vs "stable facts → reference/" split that this learning sharpens into an actionable test
|
||||||
|
- [`docs/working/plans/2026-06-21-documentation-restructure.md`](../../working/plans/2026-06-21-documentation-restructure.md) — the prior restructure that created the extraction destinations (`reference/architecture.md` and siblings) this pass relied on and re-applied
|
||||||
|
- [`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md) — sibling instance of version numbers rotting, in the deploy-config domain rather than the instruction-file domain
|
||||||
|
- [`docs/reference/architecture.md`](../../reference/architecture.md) → "Asset pipeline" — where the enumerated source→output table now lives
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
title: "Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder"
|
||||||
|
date: 2026-07-04
|
||||||
|
category: docs/solutions/conventions
|
||||||
|
module: "grav / plugin configuration"
|
||||||
|
problem_type: convention
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
applies_when:
|
||||||
|
- "Editing functional config for any GPM-managed Grav plugin"
|
||||||
|
- "user/plugins/ is gitignored and only pages/config/accounts/themes are tracked"
|
||||||
|
- "Preparing a fresh install or production cutover"
|
||||||
|
- "A plugin behaves correctly locally but ships with only default config on deploy"
|
||||||
|
related_components:
|
||||||
|
- "grav"
|
||||||
|
- "gpm"
|
||||||
|
- "api plugin"
|
||||||
|
- "content repo"
|
||||||
|
- "deployment"
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- plugin-config
|
||||||
|
- gpm
|
||||||
|
- config-override
|
||||||
|
- gitignore
|
||||||
|
- deployment
|
||||||
|
- api-plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Grav resolves a plugin's config by deep-merging two layers: the plugin's own shipped file `user/plugins/<name>/<name>.yaml` (installed by GPM, part of the package) and the tracked override `user/config/plugins/<name>.yaml` (which wins). In this project the content repo tracks only `pages/`, `config/`, `accounts/`, `themes/`; `user/plugins/` and `user/data/` are gitignored (GPM manages plugin *code*). So any functional config a developer edits into a plugin's own `user/plugins/<name>/<name>.yaml` is invisible to version control.
|
||||||
|
|
||||||
|
It was this gap that left the `api` plugin unconfigured on the fresh prod install. Its `enabled`/`route`/`session_enabled`/cors/rate_limit config existed only in the untracked plugin folder locally, while the committed `user/config/plugins/api.yaml` held only a runtime `popularity.salt`. The local machine worked because the plugin folder had been hand-edited; every fresh environment got only the plugin's shipped defaults.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
Put **functional** plugin configuration in the TRACKED override `user/config/plugins/<name>.yaml`. Grav deep-merges it over the plugin's shipped defaults, so it need only carry the keys that must differ (or the full config, for clarity). Keep **secrets and per-install generated values** OUT of the tracked file — JWT secrets, salts, encrypted tokens belong in gitignored `*-private.php` companion files (e.g. `api-private.php`, `security-private.php`) or should be regenerated per-install.
|
||||||
|
|
||||||
|
Never rely on edits to the plugin's own `user/plugins/<name>/<name>.yaml`: it is gitignored (won't deploy) and is overwritten on the next `php bin/gpm update`.
|
||||||
|
|
||||||
|
Concrete before/after, using the `api` plugin:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# WRONG: user/plugins/api/api.yaml (gitignored, GPM-managed, wiped on update)
|
||||||
|
enabled: true
|
||||||
|
route: /api
|
||||||
|
auth:
|
||||||
|
session_enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# RIGHT: user/config/plugins/api.yaml (tracked, deploys, survives gpm update)
|
||||||
|
enabled: true
|
||||||
|
route: /api
|
||||||
|
version_prefix: v1
|
||||||
|
auth:
|
||||||
|
session_enabled: true
|
||||||
|
# JWT secret intentionally NOT here — it lives in the gitignored api-private.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Reproducible deploys: a fresh clone or `make remote-install-<env>` must produce a working site from the repo alone. Config stranded in the gitignored plugin folder silently yields a plugin with only its shipped defaults on every new environment — which, for a plugin whose behavior depends on non-default config, means it's misconfigured or effectively off. On prod the `api` plugin's route/auth simply didn't work.
|
||||||
|
|
||||||
|
The failure is silent and per-environment: it works on the developer's machine (where the plugin folder was hand-edited) and breaks everywhere else. `gpm update` compounds it by wiping the folder edit even locally, so the "working" state is not just unshared — it is also unstable on the one machine that had it.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Any time you configure a Grav plugin whose non-default settings must work on a server (prod/test) or survive a plugin update.
|
||||||
|
- Especially for plugins whose function depends on config: `api` (route/auth/cors), `admin2`, `flex-objects`, form/media settings, etc.
|
||||||
|
- When auditing a fresh-install failure: check whether the "working" local config actually lives in a tracked path (`git ls-files user/config/plugins/<name>.yaml`) or was stranded in `user/plugins/<name>/`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- **api plugin (this project):** the functional config was moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`, then `make content-push` + `make remote-fetch-content-<env>` deployed it. The JWT secret stayed in the gitignored `api-private.php`.
|
||||||
|
- **Quick audit command:** `git -C user ls-files config/plugins/` shows exactly which plugin configs are tracked/deployable; anything you rely on that isn't listed is a latent fresh-install failure.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the config gap documented here was the *other* latent problem surfaced in that same investigation: the `api` plugin also had to be *installed* first before any config could take effect. The install gap (GPM version floor) and this config-tracking gap compounded each other on the fresh prod environment.
|
||||||
|
- `docs/working/git-sync-notes.md` — the related third config location: on prod, Grav Admin saves config into the per-environment tree `user/env/<host>/config/`, which is *also* untracked. Same "config that doesn't reach the repo" family.
|
||||||
|
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — the sync-boomerang consequence of this rule: a per-install value that a plugin regenerates into a *tracked* functional config file (e.g. `popularity.salt` in `api.yaml`) re-commits itself and ping-pongs across environments under bidirectional git-sync. The `*-private.php` companion pattern this doc establishes is exactly the durable fix.
|
||||||
|
- **CLAUDE.md §0 (plugin-management model):** only `pages/`, `config/`, `accounts/`, `themes/` are tracked in the `user/` repo; `plugins/` and `data/` are gitignored and GPM-managed. That tracking boundary is exactly why functional config must live under `config/plugins/`, not in the plugin's own folder.
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
---
|
||||||
|
title: Reconciling drifted docs — tier by tense, and record reversals in a ledger
|
||||||
|
date: 2026-07-25
|
||||||
|
category: conventions
|
||||||
|
module: documentation
|
||||||
|
problem_type: convention
|
||||||
|
component: documentation
|
||||||
|
severity: high
|
||||||
|
applies_when:
|
||||||
|
- Auditing documentation against the code after a period of undocumented change
|
||||||
|
- Deciding whether a stale document should be corrected, annotated, or deleted
|
||||||
|
- A plan or milestone describes a feature that was later dropped or replaced
|
||||||
|
- Writing or reviewing an index that describes what another document is for
|
||||||
|
- Asked whether the docs would pass a review, or to make them pass one
|
||||||
|
- A decision is being reversed and the old rationale needs to survive the reversal
|
||||||
|
tags: [documentation-conventions, staleness, tiering, drift, supersession, decision-log, audit, verification, indexes]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Reconciling drifted docs — tier by tense, and record reversals in a ledger
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Five weeks after the last documentation pass, this repo's docs described a site that partly no longer
|
||||||
|
existed. `/map`, `/stats`, `/tracker`, Leaflet, a light theme, and `hero_image` on entries had all been
|
||||||
|
deliberately removed — but several documents still presented them in confident present tense, and
|
||||||
|
nothing marked those documents as historical.
|
||||||
|
|
||||||
|
The trigger question was *"straighten this out so a repeat review returns ok."* The answer depended on
|
||||||
|
a distinction the tree did not encode.
|
||||||
|
|
||||||
|
[`claude-md-content-tiering.md`](claude-md-content-tiering.md) established that **descriptions drift
|
||||||
|
and rules do not**, and tiered content by *type* (rules stay in `CLAUDE.md`, descriptions move to
|
||||||
|
`docs/`). This pass confirmed that thesis again — every one of 20 verified defects was a description
|
||||||
|
of code, config, or a command; not one was a rule that had gone wrong on its own. But content-type
|
||||||
|
tiering alone did not answer what to *do* with 41 completed plans and 4 milestone specs, because those
|
||||||
|
are neither rules nor current descriptions.
|
||||||
|
|
||||||
|
The missing axis was **tense**.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
### 1. Tier by tense, then treat the halves oppositely
|
||||||
|
|
||||||
|
| Kind | Files here | Claims | Staleness is | Treatment |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Present-tense** | `CLAUDE.md`, `docs/reference/`, `docs/guides/`, `README.md`, `CONCEPTS.md` | "this is how it *is*" | a **defect** | correct against the code |
|
||||||
|
| **Past-tense** | `docs/working/plans/`, `specs/`, `milestones/`, `summary.md`, `pm-analysis.md` | "this is what we decided *then*" | **correct and expected** | annotate only, never rewrite |
|
||||||
|
|
||||||
|
A completed plan *should* be stale — that is what makes it a record. Rewriting 41 plans to match
|
||||||
|
today's code would destroy the audit trail of *why* each thing changed, and the work is unbounded.
|
||||||
|
The defect was never their staleness; it was that nothing told a reader they were history.
|
||||||
|
|
||||||
|
`docs/solutions/` straddles the split deliberately: past-tense incident, present-tense guidance. That
|
||||||
|
is why its `applies_when` frontmatter matters more than its narrative — the frontmatter is the part
|
||||||
|
that must stay true.
|
||||||
|
|
||||||
|
### 2. Ledger plus inline notes — neither alone is enough
|
||||||
|
|
||||||
|
Two mechanisms, because each covers the other's failure:
|
||||||
|
|
||||||
|
- **A supersession ledger** (`docs/reference/superseded-decisions.md`) — one table: what was planned,
|
||||||
|
where it was planned, what is true now, when it changed, why. This is the only thing that answers
|
||||||
|
*"what did I change my mind about?"* in one place, which is the question a review actually asks.
|
||||||
|
Alone, it has an indirection problem: a pointer you might not follow.
|
||||||
|
- **Inline `> **Superseded …**` notes** at each stale claim, so the claim cannot be read
|
||||||
|
un-corrected. Alone, it has a completeness problem: no changelog view, and coverage is only as good
|
||||||
|
as the annotation pass was.
|
||||||
|
|
||||||
|
**Prefer the annotation patterns the repo already uses.** Here, `architecture.md` already carried
|
||||||
|
`> History:` notes and `trip-switching.md` already carried `> **Changed 2026-07:**`. Inventing a third
|
||||||
|
convention would have been worse than adopting either.
|
||||||
|
|
||||||
|
**Add the ledger row in the same commit as the reversal.** A ledger that lags is worse than no ledger,
|
||||||
|
because it is trusted — the same failure mode as a lagging plan `Status:` line.
|
||||||
|
|
||||||
|
### 3. Also record what was *not* reversed
|
||||||
|
|
||||||
|
A ledger of only reversals makes every old document look suspect. This one ends with a short
|
||||||
|
"decisions that were *not* reversed" section — the `pm-analysis.md` SKIP list still stands, the
|
||||||
|
weather integration shipped exactly as specified, the stats computation survived and only *moved*.
|
||||||
|
Without it, a future reader re-litigates settled decisions because the surrounding docs looked old.
|
||||||
|
|
||||||
|
### 4. Separate "the docs are wrong" from "the code is wrong"
|
||||||
|
|
||||||
|
An audit against code finds both. Mixing them makes the diff unreviewable and stalls the documentation
|
||||||
|
fix behind a behaviour decision. Route code-side findings to a separate recommendations document and
|
||||||
|
**explicitly do not act on them**. Here that kept a 300-line docs diff clean while still capturing that
|
||||||
|
`make start` is broken on any clean checkout.
|
||||||
|
|
||||||
|
Documenting a trap is not the same as fixing it — and is the right move when the fix is someone else's
|
||||||
|
call. Per the tiering doc, a gotcha has no natural trigger that opens a file, so a live trap belongs in
|
||||||
|
`CLAUDE.md` even while its fix stays unscheduled.
|
||||||
|
|
||||||
|
### 5. Verify against the artifact that decides behaviour, not the prose about it
|
||||||
|
|
||||||
|
Every finding must come from the thing that actually determines behaviour:
|
||||||
|
|
||||||
|
| To check | Read |
|
||||||
|
|---|---|
|
||||||
|
| What a command does | the `Makefile` — including macro-generated targets, which a grep for literal target names will miss |
|
||||||
|
| What a build produces | the build script (`package.json`), not a prose asset table |
|
||||||
|
| Whether a file is a source or an output | which file *imports* it, and how it reaches the page |
|
||||||
|
| Whether a feature exists | the absence of its mechanism, not the absence of a mention |
|
||||||
|
| Whether a plan shipped | the branch history, not the plan's own `Status:` line |
|
||||||
|
|
||||||
|
This is also where an audit catches *itself*. One draft finding here claimed the asset table was
|
||||||
|
missing four source files; reading `package.json` showed the table lists esbuild **entry points**, so
|
||||||
|
imported-only sources were correctly absent. The finding was withdrawn. **An audit that never
|
||||||
|
withdraws a finding has not been checking itself.**
|
||||||
|
|
||||||
|
### 6. Re-check the baseline before publishing, not only before starting
|
||||||
|
|
||||||
|
A long audit **races the work it is auditing**. This one had its baseline move twice, and each time the
|
||||||
|
convenient state was the wrong one:
|
||||||
|
|
||||||
|
- **The submodule pin lagged.** A fresh worktree checks out the commit the outer repo pins, not the
|
||||||
|
submodule's real HEAD. Auditing the pin would have reported a shipped feature as unbuilt. Move to the
|
||||||
|
real HEAD first, and keep the gitlink out of the commit (see
|
||||||
|
[`dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) —
|
||||||
|
`M user` is normal and must not be "fixed").
|
||||||
|
- **The base branch advanced 13 commits mid-audit**, independently fixing two findings. Merging the
|
||||||
|
base branch in before opening the PR is what surfaced that. Without it, the branch would have
|
||||||
|
**reverted** work that was already correct — the worst possible outcome for a cleanup pass, because it
|
||||||
|
arrives disguised as an improvement.
|
||||||
|
|
||||||
|
Two habits fall out of this. **Merge the base branch in before publishing, and read the conflicts as
|
||||||
|
findings rather than chores** — each conflict is the codebase telling you someone else already reasoned
|
||||||
|
about this line. And **when the incoming version is better, take it wholesale**: here the base branch's
|
||||||
|
map-doctrine wording and plan status were both more informed than the replacements drafted during the
|
||||||
|
audit, so they were kept in full and the audit's own notes were corrected to match. An audit has no
|
||||||
|
special authority over the work it audits.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**An index describing another document's role makes a factual claim that can rot — and it is worse
|
||||||
|
than the stale document itself.** The single most misleading line in this tree was
|
||||||
|
`docs/working/README.md` advertising `summary.md` as *"Project summary / current state"*, while
|
||||||
|
`summary.md` described Leaflet, `/tracker`, `/map` and `/stats`. A stale document is survivable — a
|
||||||
|
reader may notice the date, the tone, the odd claim. An index that vouches for it as authoritative
|
||||||
|
**defeats that judgement before it engages.** When writing an index, treat every "what this file is
|
||||||
|
for" phrase as an assertion with an expiry date.
|
||||||
|
|
||||||
|
**Wrong beats absent, again — now for commands.** The tiering doc found this for paths: an absent fact
|
||||||
|
makes an agent go look; a wrong one makes it act confidently in the wrong place. The same held for
|
||||||
|
`README.md`'s server runbook, where every `remote-*` command was documented without the `-test`/`-prod`
|
||||||
|
suffix `guard-env` requires. Every documented command failed on the first line. `deploy-cycle.md` had
|
||||||
|
the rule right the whole time — the defect was a **second copy** of the knowledge drifting from the
|
||||||
|
first. Fewer copies would have prevented it outright.
|
||||||
|
|
||||||
|
**Promoting a doc to "the authoritative list of X" creates a completeness obligation it did not have
|
||||||
|
as prose.** `CLAUDE.md` pointed at `README.md` for "the full `make` command list"; README then held 7
|
||||||
|
of ~20 `remote-*` targets. The pointer was added by a well-intentioned earlier tiering pass. Routing
|
||||||
|
content out of an always-loaded file is right, but **the destination inherits a duty to be complete**,
|
||||||
|
and nothing enforces that.
|
||||||
|
|
||||||
|
**Deliberate removals leak.** `travel-memories` was extracted to its own project, its source deleted
|
||||||
|
and `services/` gitignored — but `docker-compose.yml` still declared the service, and `CLAUDE.md` still
|
||||||
|
claimed it ran on :8082. `make start` has therefore been broken on every clean checkout since, hidden
|
||||||
|
only because a pre-removal Docker image stayed cached locally. **A removal is not finished when the
|
||||||
|
code is gone; it is finished when every consumer and every description of it is gone too.** The cached
|
||||||
|
image is the general lesson: local state can mask a breakage indefinitely, so "it works here" is not
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- After any stretch of change that outpaced its documentation, or when asked whether the docs would
|
||||||
|
survive a review
|
||||||
|
- Before rewriting a stale plan, spec, or milestone — annotate it instead; the record is the value
|
||||||
|
- When reversing a decision: add the ledger row and the inline note in the reversal's own commit
|
||||||
|
- When writing an index, a folder README, or any "read X for Y" pointer — that pointer is a claim
|
||||||
|
- When removing a service, route, feature, or dependency: sweep for consumers *and* for prose that
|
||||||
|
describes it, including compose files, always-loaded instruction files, and demo fixtures
|
||||||
|
- When promoting any document to authoritative for a list — decide who keeps it complete
|
||||||
|
- Before auditing a repo with submodules: confirm you are on the state that actually runs
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Tense-marking a historical spec, without rewriting it
|
||||||
|
|
||||||
|
`milestones/milestone-2.md` still opens with its original goal — that is the record. The banner sits
|
||||||
|
directly beneath it, so the stale claim cannot be read alone:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Goal:** A `/map` page shows all entries as markers on an interactive Leaflet.js map, …
|
||||||
|
|
||||||
|
> **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).
|
||||||
|
> - **Leaflet + OpenStreetMap tiles → MapLibre GL JS** (R4, 2026-06-20).
|
||||||
|
>
|
||||||
|
> The *substance* of this spec survived — markers per entry, chronological route line, popups,
|
||||||
|
> bounds fitting — it all lives in `MapUtils.initEntryMap()`. Only the page and the library changed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Separating "the idea won" from "this implementation lost" is what stops a future reader concluding the
|
||||||
|
whole spec was a dead end.
|
||||||
|
|
||||||
|
### An index that vouched for a stale document
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-| `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) |
|
||||||
|
```
|
||||||
|
|
||||||
|
### A source relationship that never existed
|
||||||
|
|
||||||
|
`CLAUDE.md` asserted a build dependency between two unrelated things. `css/` is hand-authored and
|
||||||
|
served *directly*; `css-compiled/` is esbuild output from the CSS imports inside `js/src/*.js`:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`)
|
||||||
|
+- `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.
|
||||||
|
```
|
||||||
|
|
||||||
|
The failure this invited: an agent wanting to change a font edits `css-compiled/main.css` — a
|
||||||
|
generated bundle — because the rule named `css/style.css` as its source and that file does not contain
|
||||||
|
it. The next `make build-assets` silently reverts the edit.
|
||||||
|
|
||||||
|
### Proving a breakage instead of inferring it
|
||||||
|
|
||||||
|
Reasoning that a missing directory *would* break a build is not evidence. Running it is:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ docker compose build travel-memories
|
||||||
|
unable to prepare context: path ".../services/travel-memories" not found
|
||||||
|
```
|
||||||
|
|
||||||
|
The follow-up mattered more than the failure: a cached `travel-blog-intotheeast-travel-memories:latest`
|
||||||
|
image explained why `make start` still worked on the main checkout but failed in every new worktree.
|
||||||
|
Without that check the finding would have been reported as "broken everywhere" and been wrong.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [`claude-md-content-tiering.md`](claude-md-content-tiering.md) — the content-type tiering axis and
|
||||||
|
the "descriptions drift, rules don't" thesis this learning extends with a tense axis. **Consolidation
|
||||||
|
candidate:** the two overlap on root cause and on the files they touch; if a third documentation
|
||||||
|
learning appears, consider merging all three into one documentation-maintenance doc.
|
||||||
|
- [`../architecture-patterns/retiring-a-consolidated-grav-sub-page.md`](../architecture-patterns/retiring-a-consolidated-grav-sub-page.md)
|
||||||
|
— the mechanics of the retirement that produced ledger rows R1, R2 and R5. That doc covers removing
|
||||||
|
the *page*; this one covers removing the *claims about* the page.
|
||||||
|
- [`../architecture-patterns/dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md)
|
||||||
|
— why a fresh worktree's `user/` sits at the pin rather than at HEAD, which is the audit-baseline trap
|
||||||
|
in §6.
|
||||||
|
- [`../integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md)
|
||||||
|
— the same rot in the deploy-config domain: a version number that went stale and broke an install.
|
||||||
|
- `docs/working/specs/2026-07-25-docs-reconciliation-design.md` — the design and the verification
|
||||||
|
table for this pass.
|
||||||
|
- `docs/working/2026-07-25-doc-drift-recommendations.md` — the code-side findings deliberately not
|
||||||
|
acted on, including the compose breakage and a proposed repeatable `make docs-check`.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
title: docker exec/run defaults to root, writing root-owned files into the host bind mount
|
||||||
|
date: 2026-07-08
|
||||||
|
last_updated: 2026-07-08
|
||||||
|
problem_type: integration_issue
|
||||||
|
category: integration-issues
|
||||||
|
module: docker-dev-environment
|
||||||
|
component: development_workflow
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "11,624 root-owned (uid 0) files accumulated under the host ./user bind mount"
|
||||||
|
- "make worktree-rm fails: cannot rm root-owned plugin files without sudo"
|
||||||
|
- "files stay root-owned even though UID/GID env vars were set to the host user"
|
||||||
|
- "install-plugins writes the entire plugin tree as root via php bin/gpm install"
|
||||||
|
- "build-assets (docker run node:20-alpine, no --user) writes root-owned node_modules + esbuild bundles into user/themes/intotheeast/, blocking git worktree remove and git merge"
|
||||||
|
root_cause: config_error
|
||||||
|
resolution_type: config_change
|
||||||
|
related_components:
|
||||||
|
- tooling
|
||||||
|
- docker-compose
|
||||||
|
- grav-cms
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
- docker-exec
|
||||||
|
- docker-run
|
||||||
|
- bind-mount
|
||||||
|
- file-permissions
|
||||||
|
- uid-gid
|
||||||
|
- makefile
|
||||||
|
- grav
|
||||||
|
- gpm
|
||||||
|
- build-assets
|
||||||
|
- esbuild
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
In the Grav CMS travel-blog Docker dev environment, `make` targets that shelled into the `grav` container were silently creating **root-owned (uid 0)** files inside the host `./user` bind mount. The `grav` service (based on `getgrav/grav`) bind-mounts host `./user` → `/var/www/html/user`, so anything the container writes there lands on the host filesystem with whatever ownership the writing process had.
|
||||||
|
|
||||||
|
Over time this accumulated **11,624** root-owned files under `./user`. The immediate breakage: `make worktree-rm` could no longer delete a worktree's plugin tree, because a non-root host user cannot remove root-owned files without `sudo`. The working tree became unmanageable, and the per-worktree isolated-container workflow (which is what surfaced the accumulation) left root-owned debris behind on every teardown.
|
||||||
|
|
||||||
|
The root of the surprise: the developer had already set `UID`/`GID` env vars to their own user and reasonably assumed that covered container file ownership. It did not — those vars never reached the `grav` service.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- `ls -la user/plugins/...` shows files owned by `root root` instead of the host user.
|
||||||
|
- `make worktree-rm` (and a plain `rm -rf` on a worktree) fails with `Permission denied` on plugin files.
|
||||||
|
- Thousands of root-owned files pile up under `./user` — `find ./user -uid 0` counted **11,624**.
|
||||||
|
- Confusing because `UID`/`GID` were already set to the developer's own user, yet ownership was still root.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
Several plausible fixes were tried or considered and rejected:
|
||||||
|
|
||||||
|
- **Setting `UID`/`GID` env vars.** These only reached the `travel-memories` service, which consumes them via its compose `user: "${UID}:${GID}"` directive. The `grav` service has no such directive, so it never consumed them.
|
||||||
|
- **`APACHE_RUN_USER=#1000` / `APACHE_RUN_GROUP=#1000` on the grav service.** These only affect the Apache **worker** processes. They do nothing for `docker exec` CLI invocations or for the entrypoint — which are what the make targets actually run.
|
||||||
|
- **Adding `user: "${UID}:${GID}"` to the grav service in compose.** Not viable. The `getgrav/grav` base-image entrypoint must boot as root to bind port `:80` and set up cron. Pinning the whole container to a non-root user breaks boot.
|
||||||
|
- **Hardening `worktree-rm` to delete root files via a throwaway root container.** Rejected by the user: no make command should require or use root privileges. The correct fix is to stop *creating* root-owned files, not to add a privileged cleanup step.
|
||||||
|
|
||||||
|
**The symptom was noticed for weeks before it was diagnosed.** (session history) During the earlier Grav 2.0.4/2.0.7 upgrade work, container-written files repeatedly surfaced as root-owned — the API plugin's generated `config/plugins/api-private.php` was flagged as "owned by the container, permission-denied to me", and worktree teardown already required `git worktree remove --force` to get past files it couldn't cleanly remove. Each instance was treated as a one-off annoyance rather than traced to `docker exec` defaulting to uid 0. Consolidating plugin management onto `make install-plugins` / `gpm install` during that upgrade actually *enlarged* the problem surface, because it increased how often the container writes into the host mount as root.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
Both `docker exec` **and** `docker run` default to running as root (uid 0). Because the grav container must boot as root, and neither inherits a non-root default unless `-u` / `--user` is passed explicitly, every make target that shelled into (or spun up) a container without dropping privileges wrote root-owned files into whatever host path it bind-mounted.
|
||||||
|
|
||||||
|
There are **two** offenders, on two different bind mounts:
|
||||||
|
|
||||||
|
- **`install-plugins`** — `docker exec … php bin/gpm install`, writing the entire plugin tree into `./user/plugins` as root. The worst by file count (11,624).
|
||||||
|
- **`build-assets`** — `docker run --rm node:20-alpine … "npm install && npm run build"`, bind-mounting `./user/themes/intotheeast` → `/app`, writing root-owned `node_modules/` and esbuild bundle outputs (`js/…`, `css-compiled/`) into the tracked theme tree. This one uses **`docker run`**, not `docker exec`, and has **no `--user`** — so the `install-plugins` fix below does *not* cover it.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Derive the host identity once in the Makefile and drop privileges on the specific exec that writes to the bind mount (commit `209b804`).
|
||||||
|
|
||||||
|
Add host-user vars:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
HOST_UID := $(shell id -u)
|
||||||
|
HOST_GID := $(shell id -g)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rewrite `install-plugins`.
|
||||||
|
|
||||||
|
**Before** (wrote root-owned plugins):
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
install-plugins:
|
||||||
|
docker exec -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||||
|
$(MAKE) apply-plugin-patches
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (plugins owned by host user):
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
install-plugins:
|
||||||
|
# cache/ and tmp/ are root-owned in the image, so make them writable first
|
||||||
|
# (container-internal chown — never touches the host) so gpm can run AS YOU.
|
||||||
|
docker exec $(GRAV_CONTAINER) chown -R $(HOST_UID):$(HOST_GID) /var/www/html/cache /var/www/html/tmp
|
||||||
|
# gpm runs as the host user, so the plugins it writes into ./user/plugins are
|
||||||
|
# owned by you, not root — no post-hoc chown, no root files to clean up later.
|
||||||
|
docker exec -u $(HOST_UID):$(HOST_GID) -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||||
|
$(MAKE) apply-plugin-patches
|
||||||
|
```
|
||||||
|
|
||||||
|
### The `build-assets` vector (same principle, `docker run`) — fixed 2026-07-08
|
||||||
|
|
||||||
|
The first 2026-07-08 fix (`209b804`) hardened `install-plugins` only. `build-assets` remained a root-writing target and surfaced later: `git worktree remove` aborted with `Permission denied` on root-owned esbuild bundles under `user/themes/intotheeast/js/post/`, and earlier a `build-assets` run had produced a root-owned `css-compiled/` dir that blocked a `git merge` on the main checkout. (session history)
|
||||||
|
|
||||||
|
The same drop-privileges principle applies — with `--user` on `docker run` (fixed later the same day):
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
# Before — writes root-owned node_modules + bundles into the tracked theme tree
|
||||||
|
build-assets:
|
||||||
|
docker run --rm \
|
||||||
|
-v $(PWD)/user/themes/intotheeast:/app \
|
||||||
|
-w /app node:20-alpine \
|
||||||
|
sh -c "npm install && npm run build"
|
||||||
|
|
||||||
|
# After — outputs owned by the host user; HOME=/tmp gives npm a writable
|
||||||
|
# cache when running as a non-root uid
|
||||||
|
build-assets:
|
||||||
|
docker run --rm --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
|
||||||
|
-v $(PWD)/user/themes/intotheeast:/app \
|
||||||
|
-w /app node:20-alpine \
|
||||||
|
sh -c "npm install && npm run build"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified: `make build-assets` with the fix completes clean (esbuild bundles emitted), `find user/themes/intotheeast -uid 0` counts zero, and the output bundles are byte-identical to the previously committed ones. Recovery for any pre-existing root-owned output is the same as anywhere else — `chown -R $(HOST_UID):$(HOST_GID)` from a container that already has root, then `rm`.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
The container still *boots* as root — which it needs, to bind `:80` and set up cron. But the individual `docker exec` that writes into the bind mount now runs as the host uid/gid via `-u $(HOST_UID):$(HOST_GID)`. Files that exec creates on the host are therefore owned by the developer, not root. No post-hoc chown, no cleanup debt.
|
||||||
|
|
||||||
|
The preliminary chown of `cache/` and `tmp/` is container-internal: those paths are root-owned in the base image and are not host-managed content in the same way. gpm needs them writable to run as a non-root user; without making them writable first, gpm exits 1. Chowning them inside the container never touches the host filesystem.
|
||||||
|
|
||||||
|
**Empirical validation.** A minimal touch/stat test isolates the mechanism: `docker exec -u 1000:1000 <container> touch /var/www/html/user/probe` produces a host file owned by `1000`, while the same command without `-u` produces one owned by `0`. After applying the fix, `make fix-perms` cleared the backlog (11,624 → 0) and a real `make install-plugins` ran clean: gpm exit 0, zero root-owned files created, `api`/`admin2` plugins owned by the host user, and the site healthy (`/` and `/admin` → 200).
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
The reusable principle, worth internalizing beyond this one repo:
|
||||||
|
|
||||||
|
- **Any make/CI target that writes files into a host bind mount must drop privileges — whether it uses `docker exec` (`-u $(HOST_UID):$(HOST_GID)`) or `docker run` (`--user $(HOST_UID):$(HOST_GID)`).** A container booting as root does *not* mean the commands you run in it must write as root. `build-assets` (a `docker run`) was the easy one to miss, because the original fix only patched the `docker exec` targets — so audit `docker run` invocations too, not just `docker exec`.
|
||||||
|
- **Derive host identity once in the Makefile and reuse it:** `HOST_UID := $(shell id -u)` / `HOST_GID := $(shell id -g)`.
|
||||||
|
- **Don't rely on `APACHE_RUN_USER` or compose-level `UID`/`GID` env vars to fix exec ownership** — they don't apply to `docker exec`. `APACHE_RUN_USER` only affects Apache workers; compose `user:`/env vars only affect services wired to consume them.
|
||||||
|
- **You can't just add `user:` to a service whose entrypoint needs root** (to bind privileged ports, set up cron, etc.). Drop privileges per-exec instead of per-container.
|
||||||
|
- **If a tool run as non-root needs writable scratch dirs that are root-owned in the image, chown them container-internally first.** That doesn't touch the host.
|
||||||
|
- **Root-owned files accumulate invisibly.** (session history) Plugin code under `user/plugins/<name>/` is gitignored by project convention (only `cache-on-save`, `story-blocks`, and `entry-actions` are tracked), so root-owned files pile up in the bind mount without ever appearing in `git status` — they only bite at worktree-removal time. Don't wait for `git status` to reveal them; `find ./user -uid 0 | wc -l` is the real detector.
|
||||||
|
- **Keep a `make fix-perms` escape hatch** (container-internal `chown -R 1000:1000 /var/www/html`) for residual root files — notably first-boot files the base-image entrypoint writes as root (`config/security.yaml`, `data/api-keys.yaml`), which no `-u` on a make target can reach. After this fix it's a rare mop-up, not a routine step.
|
||||||
|
- **Verification recipe:** `docker exec -u 1000:1000 <container> touch /mnt/f && stat -c '%u' host/f` should print your uid, not `0`.
|
||||||
|
|
||||||
|
This lives in the Makefile because make targets are the only sanctioned container interface in this project — the fix belongs there, not in ad-hoc docker commands.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [`tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`](../tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md) — the sibling docker-dev-env doc. It documents `make install-plugins` → `docker exec … php bin/gpm install` as a routine local step but never addresses *who* those execs run as. This doc is its complement: it explains why the exec must drop to the host user.
|
||||||
|
- [`architecture-patterns/dual-repo-submodule-workflow.md`](../architecture-patterns/dual-repo-submodule-workflow.md) — worktrees + the `./user` submodule/bind mount, including the persistent `M user` dirty-state warning. Root-owned files landing in `./user` from root-default execs are a concrete cause of unexpected permission/dirty state in worktree dev servers.
|
||||||
|
- `docs/guides/deploy-cycle.md` — the three-layer state model (plugin code / repo config / host env tree); the host env tree is the layer across which these root-owned files land.
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
title: "cache.deleteAll() doesn't rebuild the page-tree index — a freshly-posted entry 404s when opened for editing"
|
||||||
|
date: 2026-07-07
|
||||||
|
category: integration-issues
|
||||||
|
module: cache-on-save
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: plugin
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "A just-posted journal entry is written to disk but the API 404s on it (GET /api/v1/pages{route})"
|
||||||
|
- "Opening the entry you just created for editing shows 'This entry no longer exists — it may have been deleted'"
|
||||||
|
- "The entry DOES appear in the trip feed, but the edit prefill fetch can't find it until the next unrelated cache bump"
|
||||||
|
- "Intermittent — only bites when the page-tree index survives the create"
|
||||||
|
root_cause: incomplete_setup
|
||||||
|
resolution_type: code_fix
|
||||||
|
related_components:
|
||||||
|
- documentation
|
||||||
|
- development_workflow
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- cache
|
||||||
|
- forms
|
||||||
|
- page-tree
|
||||||
|
---
|
||||||
|
|
||||||
|
# `cache.deleteAll()` doesn't rebuild the page-tree index
|
||||||
|
|
||||||
|
## Context — this is BUG-001 Part 2
|
||||||
|
|
||||||
|
[BUG-001](../../working/bugs-and-fixes.md) ("new entry not visible after form
|
||||||
|
submission") was fixed by wiring `$this->grav['cache']->deleteAll()` into the
|
||||||
|
`cache-on-save` plugin's `onFormProcessed` hook. That made new entries appear in
|
||||||
|
the trip feed immediately. It was **not the whole story**: `deleteAll()` drops
|
||||||
|
the Doctrine store (rendered-page cache, feed HTML, etc.) but does **not** force
|
||||||
|
Grav to rebuild its **regular-pages index**.
|
||||||
|
|
||||||
|
The gap only surfaced once the shared `/post` form gained an **edit mode**
|
||||||
|
(`?edit=<route>`), whose prefill does `GET /api/v1/pages{route}`. On a fresh
|
||||||
|
create that request would 404 — so the owner opening the entry they had *just*
|
||||||
|
posted saw "This entry no longer exists."
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
Grav's regular-pages index is keyed on:
|
||||||
|
|
||||||
|
```
|
||||||
|
md5(dirs + folderHash + config->checksum() + lang) // Pages::buildRegularPages
|
||||||
|
```
|
||||||
|
|
||||||
|
With `cache.check.method: folder` (our setting), the `folderHash` component does
|
||||||
|
not necessarily change when a new child folder is added inside an existing
|
||||||
|
tree — so the **index key stays the same** and the stale index (missing the new
|
||||||
|
entry) is reused. `deleteAll()` clears cache *stores* but does not change any of
|
||||||
|
the inputs to that key, so the tree is not rebuilt. The new page is on disk and
|
||||||
|
in the feed (which re-reads children), but the **API lookup by route** resolves
|
||||||
|
through the cached index and 404s.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Add a second invalidation step alongside `deleteAll()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Grav\Common\Cache;
|
||||||
|
// ...
|
||||||
|
$this->grav['cache']->deleteAll();
|
||||||
|
Cache::invalidateCache(); // touch(system.yaml) → bumps config->checksum()
|
||||||
|
```
|
||||||
|
|
||||||
|
`Cache::invalidateCache()` is lightweight and idempotent — it `touch()`es
|
||||||
|
`system.yaml`, calls `clearstatcache()` and `opcache_reset()` (verified in Grav
|
||||||
|
core `Cache.php`). Touching `system.yaml` bumps `config->checksum()`, which
|
||||||
|
changes the index key, so the tree rebuilds on the next request and the new
|
||||||
|
entry becomes resolvable by route.
|
||||||
|
|
||||||
|
### Latch it — the hook fires 4× per submit
|
||||||
|
|
||||||
|
`onFormProcessed` fires once per `process:` action, and `post-form.md` has four
|
||||||
|
(`add_page`, `upload`, `message`, `reset`). Without a guard the
|
||||||
|
`deleteAll()` + `invalidateCache()` pair runs four times per post (a full store
|
||||||
|
wipe + `system.yaml` touch each time). Gate it with a once-per-request latch
|
||||||
|
(`$cacheInvalidated`), the same pattern already used for photo reconciliation
|
||||||
|
(`$photosReconciled`). See `user/plugins/cache-on-save/cache-on-save.php`.
|
||||||
|
|
||||||
|
## How to verify
|
||||||
|
|
||||||
|
1. Post a new entry via `/post`.
|
||||||
|
2. From the trip feed, click the new card's **Edit** link.
|
||||||
|
3. The form prefills with the entry's title/body — no "no longer exists" banner.
|
||||||
|
|
||||||
|
Regression test: `tests/ui/post/edit-mode.spec.js` **ES1** (create → open the
|
||||||
|
feed card's Edit link → change title + body → Save → assert on disk).
|
||||||
|
|
||||||
|
## Residual coverage gap (tracked, not fixed here)
|
||||||
|
|
||||||
|
`tests/ui/home/home.spec.js` **H1** and `tests/ui/maps/maps.spec.js` **M8**
|
||||||
|
require `site.travelling: true` to exercise the active-trip home feed + home GPX
|
||||||
|
map. The committed local `site.yaml` runs `travelling: false` (owner's testing
|
||||||
|
config, intentionally not committed as `true`), so both specs **skip loudly**
|
||||||
|
with a reason rather than fail misleadingly. They validate whenever the site is
|
||||||
|
in travelling mode. This is a known gap in this environment, not a silent hole —
|
||||||
|
provisioning `travelling: true` in a dedicated test config would close it.
|
||||||
|
|
||||||
|
## Related — Part 3: in-place edits + APCu
|
||||||
|
|
||||||
|
The `Cache::invalidateCache()` fix above completes `deleteAll()` for the
|
||||||
|
**create/delete** case, because a new or removed child folder advances
|
||||||
|
`folderHash` and the `system.yaml` touch bumps `config->checksum()`. It is
|
||||||
|
**necessary but not sufficient** for an **in-place frontmatter edit** (e.g. a
|
||||||
|
trip publish toggle) under `cache.driver: auto` (APCu): the folder structure is
|
||||||
|
unchanged, and APCu lives in web-server shared memory that a CLI `bin/grav
|
||||||
|
clearcache` cannot reach. That case additionally requires `apcu_clear_cache()`
|
||||||
|
called from the web request. See
|
||||||
|
[`grav-in-place-header-edit-apcu-cache-stale.md`](grav-in-place-header-edit-apcu-cache-stale.md).
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
title: "Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)"
|
||||||
|
date: 2026-07-04
|
||||||
|
category: docs/solutions/integration-issues
|
||||||
|
module: "grav / production deploy"
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "Homepage and every dynamic Grav page render as full-screen binary/mojibake garbage in the browser"
|
||||||
|
- "curl without Accept-Encoding returns clean HTML, so the page looks fine from a naive curl"
|
||||||
|
- "curl -H \"Accept-Encoding: gzip\" (a browser-style request) returns raw gzip bytes"
|
||||||
|
- "Response carries two conflicting headers: content-encoding: identity AND content-encoding: gzip"
|
||||||
|
- "Only appeared after switching prod to production Twig mode (twig.debug: false)"
|
||||||
|
root_cause: config_error
|
||||||
|
resolution_type: config_change
|
||||||
|
related_components:
|
||||||
|
- "Apache mod_deflate"
|
||||||
|
- "DirectAdmin shared host"
|
||||||
|
- "Grav shutdown handler (system/src/Grav/Common/Grav.php)"
|
||||||
|
- "deploy/env/prod/system.yaml"
|
||||||
|
- "make remote-apply-env-prod"
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- content-encoding
|
||||||
|
- gzip
|
||||||
|
- mod-deflate
|
||||||
|
- fastcgi-finish-request
|
||||||
|
- apache
|
||||||
|
- production-deploy
|
||||||
|
- twig-debug
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Grav renders every dynamic page as binary garbage in the browser because it emits two conflicting `Content-Encoding` headers. The response body is valid gzip, but because the server advertises both `content-encoding: identity` and `content-encoding: gzip`, the browser cannot decide how (or whether) to inflate it, and paints the raw compressed bytes to screen. Static assets are unaffected — only Grav's own dynamically generated pages are broken. The problem surfaced only after switching the site to production Twig mode (`twig.debug: false`).
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- The homepage and all dynamic Grav pages show a full screen of binary/mojibake characters in the browser (completely unreadable). The surrounding HTML shell and static assets are fine.
|
||||||
|
- A naive `curl https://site/` (with **no** `Accept-Encoding` header) returns clean, correct HTML — so a quick curl sanity check looks perfectly healthy and completely hides the bug.
|
||||||
|
- A browser-style request exposes it. `curl -H "Accept-Encoding: gzip" -D - -o /dev/null https://site/` shows **two** `Content-Encoding` response headers:
|
||||||
|
```
|
||||||
|
content-encoding: identity
|
||||||
|
content-encoding: gzip
|
||||||
|
```
|
||||||
|
The body is valid gzip and `gunzip`s to the correct HTML.
|
||||||
|
- A static asset served by the webserver alone (e.g. a CSS/JS file) shows a **single** clean `content-encoding: gzip` under the same request — confirming the webserver's gzip is fine and the duplication is Grav-originated.
|
||||||
|
- The bug only appeared after switching the site to production Twig mode (`twig.debug: false`), which activates Grav's full shutdown/output path.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
- **First fix attempt: `cache.gzip: false` + `allow_webserver_gzip: true`.** This was the key dead end. It had **no effect** — the duplicated headers were unchanged. Reading Grav's source explained why: the branch that emits the bogus header fires on `if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip'))`. Setting `allow_webserver_gzip: true` satisfies the **same** `||` condition, so Grav still takes the identical `header('Content-Encoding: identity')` code path. The two knobs that *look* like they control this are both on the wrong side of the problem.
|
||||||
|
- **Verifying with a naive `curl` (no `Accept-Encoding: gzip`).** This hid the problem entirely, because the server only compresses when the client advertises gzip support. Any healthcheck that omits `Accept-Encoding: gzip` reports a false "all clear." Reproduce it the way a browser does — send `Accept-Encoding: gzip`, or take a real headless-browser (Playwright) screenshot.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Root the fix in Grav's shutdown handler, `system/src/Grav/Common/Grav.php` (~lines 615–631):
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($config->get('system.debugger.shutdown.close_connection', true)) {
|
||||||
|
$success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;
|
||||||
|
if (!$success) {
|
||||||
|
if (!ini_get('zlib.output_compression')) {
|
||||||
|
if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')) {
|
||||||
|
header('Content-Encoding: identity'); // <-- the bogus header
|
||||||
|
} elseif (function_exists('apache_setenv')) {
|
||||||
|
@apache_setenv('no-gzip', '1');
|
||||||
|
} else {
|
||||||
|
header('Content-Encoding: none');
|
||||||
|
}
|
||||||
|
header('Content-Length: ' . ob_get_length());
|
||||||
|
}
|
||||||
|
header('Connection: close');
|
||||||
|
ob_end_flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The entire problematic block is gated by `system.debugger.shutdown.close_connection` (default `true`). Disable it so the whole branch is skipped and Grav never touches `Content-Encoding` at all.
|
||||||
|
|
||||||
|
In this project it is applied as a **per-environment (prod-only) override** so local dev is untouched — `deploy/env/prod/system.yaml`, deployed via `make remote-apply-env-prod`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
debugger:
|
||||||
|
shutdown:
|
||||||
|
close_connection: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify — the response must show **exactly one** `content-encoding`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" https://site/ | grep -i content-encoding
|
||||||
|
# content-encoding: gzip
|
||||||
|
```
|
||||||
|
|
||||||
|
Then take a screenshot of the rendered page to confirm it displays correctly. A `curl`-without-gzip check is **not** sufficient proof — it would have passed even while the bug was live.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
`debugger.shutdown.close_connection` (default `true`) makes Grav flush the full response and close the connection to the browser **early**, so slow shutdown tasks (logging, debugger teardown) don't keep the visitor waiting. On a FastCGI/PHP-FPM host, Grav does this cleanly via `fastcgi_finish_request()` and never manipulates headers — which is why the bug is invisible on most stacks.
|
||||||
|
|
||||||
|
On a **non-FastCGI** host (LiteSpeed, suPHP, plain CGI), `fastcgi_finish_request()` does not exist, so `$success` is `false` and Grav falls back to closing the connection *manually*. To do that it must set an explicit `Content-Length`, and to keep that length honest it tries to tell the webserver "do not compress this body" — which, on the `cache.gzip`/`allow_webserver_gzip` branch, it expresses as `header('Content-Encoding: identity')`.
|
||||||
|
|
||||||
|
But `identity` is not a real content transformation and is **not** a recognized "suppress compression" signal to Apache `mod_deflate`. `mod_deflate` ignores it, compresses the body anyway, and appends its **own** `Content-Encoding: gzip`. The response now carries two contradictory `Content-Encoding` headers (`identity` and `gzip`). Browsers cannot reconcile the contradiction, fail to inflate the gzip stream, and render the raw compressed bytes — the "binary garbage" screen.
|
||||||
|
|
||||||
|
Setting `close_connection: false` means Grav never enters the manual connection-close path, never emits `Content-Encoding: identity`, and leaves the webserver as the **sole** authority on compression. The webserver then sends a single, correct `Content-Encoding: gzip`, and the browser inflates and renders normally.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- On **non-FastCGI PHP hosts with server-side gzip** (Apache `mod_deflate`, LiteSpeed), set `debugger.shutdown.close_connection: false` for that environment. Deliver it as a **per-environment override**, never by editing the committed `system.yaml` (which would silently change dev behavior too).
|
||||||
|
- **Reproduce compression bugs the way a browser sees them.** Always test with `curl -H "Accept-Encoding: gzip" -D -` and/or a headless-browser screenshot. A plain `curl` negotiates no compression and silently masks encoding bugs.
|
||||||
|
- **Health check:** dynamic pages must return **exactly one** `Content-Encoding` header. Two of them (`identity` + `gzip`) is the unambiguous signature of this bug. Add this assertion to any smoke test.
|
||||||
|
- **Know the trigger.** This can stay completely hidden in development mode and only appear once a site is switched to production mode (`twig.debug: false`), which activates Grav's full shutdown/output path. Re-run the browser-style compression check as part of any production cutover.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- **Grav per-environment override mechanism** — `deploy/env/prod/system.yaml` applied via `make remote-apply-env-prod`, described in `CLAUDE.md` §1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting like `debugger.shutdown.close_connection: false` ship without mutating the committed dev `system.yaml`.
|
||||||
|
- **`docs/working/git-sync-notes.md`** — documents the `user/env/<hostname>/config/` override tree (where this fix physically lives on the server) and the caveat that config saved via Admin on the server stays server-only.
|
||||||
|
- **`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`** — sibling from the same cutover: Admin2 login failed because a stale `GRAV_VERSION` installed an rc core and GPM wouldn't serve the `api` plugin. Different root cause, same deploy.
|
||||||
|
- **`docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md`** — a sibling gotcha from the same 2026-07-04 Grav 2.0.4 production cutover (account permission provisioning); different root cause, same deploy.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
title: "In-place frontmatter edit stays stale under Grav folder-check + APCu cache"
|
||||||
|
date: 2026-07-08
|
||||||
|
category: integration-issues
|
||||||
|
module: entry-actions
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: plugin
|
||||||
|
symptoms:
|
||||||
|
- "After unpublishing a trip via the API, anonymous visitors still saw it on /trips, home, and nav"
|
||||||
|
- "Playwright test TP2 failed: an unpublished trip stayed visible to logged-out users"
|
||||||
|
- "A prior owner-authenticated GET poisoned the page-tree cache before the toggle, making staleness sticky"
|
||||||
|
- "\"bin/grav clearcache\" from the CLI did not bust the stale index at all"
|
||||||
|
- "\"deleteAll()\" and \"pages->reset() + clearCache('standard')\" alone both left the listing stale"
|
||||||
|
root_cause: incomplete_setup
|
||||||
|
resolution_type: code_fix
|
||||||
|
related_components:
|
||||||
|
- cache-on-save
|
||||||
|
- testing_framework
|
||||||
|
- documentation
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- apcu
|
||||||
|
- cache-invalidation
|
||||||
|
- folder-check
|
||||||
|
- in-place-edit
|
||||||
|
- publish-toggle
|
||||||
|
- page-tree-cache
|
||||||
|
- api-endpoint
|
||||||
|
severity: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# In-place frontmatter edit stays stale under Grav folder-check + APCu cache
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
An owner-only endpoint `POST /api/v1/trip/{slug}/publish` toggles a trip's visibility by mutating `trip.md`'s `published:` frontmatter **in place** — same folder, no folder create or delete — via a header mutation plus `$page->save()`. After saving it must invalidate Grav's page-tree cache so the `/trips` listing, the home render, and the nav all reflect the new visibility on the next load.
|
||||||
|
|
||||||
|
They don't. After the owner unpublishes a trip, anonymous visitors still see it in the `/trips` listing and it stays reachable. None of the usual cache-invalidation idioms fix it, and — critically — a CLI `bin/grav clearcache` cannot bust it at all.
|
||||||
|
|
||||||
|
The failure is the interaction of two facts specific to this project's Grav 2.0.4 setup:
|
||||||
|
|
||||||
|
- `cache.check.method: folder` derives the regular-pages index cache id from a **folder-structure** checksum. An in-place frontmatter edit leaves the folder structure identical, so the cache id is unchanged and the stale index is reused.
|
||||||
|
- `cache.driver: auto` resolves to **APCu** (baked into the project's Docker image). APCu lives in the **web server's** shared memory, so any process outside that web worker — a CLI, a cron job, a `docker exec` — flushes a *different* memory segment and cannot reach it.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Owner unpublishes a trip → reload the `/trips` listing as an anonymous visitor → the trip is **still present** and still reachable.
|
||||||
|
- The Playwright spec `tests/ui/trip/trip-publish.spec.js` (TP2) catches it: unpublish → reload as anon → trip still listed.
|
||||||
|
- Staleness is sticky, and worst after a preceding **owner-authenticated GET** has populated the cache.
|
||||||
|
- Running the test harness's `docker exec <container> php bin/grav clearcache` does **not** clear it — the trip stays visible.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
The investigation chain, in order:
|
||||||
|
|
||||||
|
1. **`$this->grav['cache']->deleteAll()` alone** (the first half of the sibling create/delete fix). Still stale.
|
||||||
|
2. **`Cache::clearCache()`, then `$this->grav['pages']->reset()` + `$this->grav['cache']->clearCache('standard')`.** Still stale.
|
||||||
|
3. **CLI `bin/grav clearcache`** (via `docker exec`, root, a separate PHP process). Could not bust it *at all* — this was the discriminator that pointed straight at APCu: a separate process owns a separate APCu segment.
|
||||||
|
|
||||||
|
Note the documented idiom `deleteAll() + Cache::invalidateCache()` — which touches `system.yaml` to bump `config->checksum()` and thus change the index key — is the correct fix for the **create/delete** case. It is not enough here: the deciding failure is that the cache **store** is APCu in web shared memory, unreachable by the CLI, so a key-bump alone leaves the poisoned store in play across the same web worker.
|
||||||
|
|
||||||
|
Prior create/delete work on this branch had already climbed most of an escalation ladder and stopped one rung short of this case *(session history)*:
|
||||||
|
|
||||||
|
- `deleteAll()` was found to clear only the Doctrine cache store, never rebuilding the compiled page-tree index — the original root cause for both the create (BUG-001) and delete flows.
|
||||||
|
- `touch`ing the `dailies/` folder mtime did **not** flip the stale lookup (suspected Docker bind-mount mtime not propagating), so the pure folder-mtime theory was dropped.
|
||||||
|
- A "clear only `cache/compiled/pages/`" hypothesis was a red herring: **there is no such directory** — the regular-pages index lives in the Doctrine cache keyed by `md5(json_encode(dirs) + folderHash + config->checksum() + lang)` (`Pages.php`).
|
||||||
|
- That work standardized on `deleteAll() + Cache::invalidateCache()` (i.e. `touch(system.yaml)` + opcache reset) as the canonical pattern — and it was **sufficient there because folder-level create/delete advances `folderHash`**. Those sessions never touched APCu at all; the in-place-edit + APCu escalation below is genuinely new.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Flush APCu **from within the web request** that performed the edit, in `EntryActionsApiController::setTripPublished`, right after `$page->save()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$header = $page->header();
|
||||||
|
$header->published = $published; // KTD1: mutate the HEADER, not $page->published($v) —
|
||||||
|
// save() serializes from the header
|
||||||
|
$page->save();
|
||||||
|
|
||||||
|
$this->grav['cache']->deleteAll();
|
||||||
|
if (function_exists('apcu_clear_cache')) {
|
||||||
|
apcu_clear_cache(); // flush the WEB server's APCu store directly —
|
||||||
|
// a CLI clearcache cannot reach it
|
||||||
|
}
|
||||||
|
$this->grav['pages']->reset(); // drop the in-memory tree so the next request
|
||||||
|
// rebuilds from disk
|
||||||
|
$this->grav['cache']->clearCache('standard');
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified via curl against the running dev container: unpublish → anon listing count drops to 0; republish → back to 1.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
- `apcu_clear_cache()` runs inside the **same PHP web process** that owns the APCu segment, so it actually empties the store the frontend reads. This is the piece a CLI clearcache structurally cannot do.
|
||||||
|
- `deleteAll()` + `clearCache('standard')` drop the Doctrine/compiled stores.
|
||||||
|
- `$this->grav['pages']->reset()` forces a **rebuild from disk** on the next request, which re-reads the mutated `published` flag.
|
||||||
|
|
||||||
|
Because the mutation is **in place**, none of Grav's folder-checksum-based self-healing applies (a folder create/delete would change the checksum and self-heal — which is why new-post and delete flows never hit this). The invalidation must therefore be **explicit** *and* must **target the web APCu**. The earlier `Cache::invalidateCache()` fix leaned entirely on the `config->checksum()` term of the index key changing; that still leaves the poisoned APCu store live for the current web worker when the edit is in place.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- When an endpoint mutates page frontmatter **in place** (publish toggles, metadata edits) under `cache.check.method: folder`, do **not** rely on `deleteAll()` or on a folder-checksum bump. Explicitly flush APCu from the web request, guarded with `function_exists('apcu_clear_cache')`.
|
||||||
|
- **Never** invalidate web APCu from a CLI/cron/`docker exec` process — it hits a different memory segment. If a CLI must trigger invalidation, it has to go through a web request (curl the endpoint) or a shared driver (file/redis), not APCu.
|
||||||
|
- **Test-harness corollary:** a Playwright helper that clears cache via `docker exec ... bin/grav clearcache` will **not** flush web APCu. Fixture *folders* still appear (folder create bumps the checksum), but in-place/config changes may read stale. Prefer driving the real web endpoint. Cache-mutating E2E specs must run serially (`--workers=1`); mutating global config (`owner_username`, `active_trip`) also collides with parallel readers. See `tests/ui/trip/trip-publish.spec.js`.
|
||||||
|
- **On the divergence from the house idiom:** two independent code reviewers (reliability, maintainability) flagged that this 4-call sequence diverges from the codebase's documented `deleteAll() + Cache::invalidateCache()` idiom. The divergence is **intentional** and specific to in-place-edit + APCu. Pick by case:
|
||||||
|
- create/delete → `Cache::invalidateCache()` (bumps the folder checksum / index key)
|
||||||
|
- in-place edit under APCu → `apcu_clear_cache()` from the web request
|
||||||
|
|
||||||
|
A future improvement is to fold both into one documented helper so future call sites have a single idiom to copy.
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md` — the sibling **CREATE** case: `deleteAll()` doesn't rebuild the index; fix = `Cache::invalidateCache()`. This doc is its **in-place-edit + APCu** counterpart — effectively "Part 3" of that page-tree-cache thread, adding the APCu shared-memory dimension the folder-touch fix did not cover.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
title: "Grav plugin won't enable because its code is missing while its config persists in the env tree"
|
||||||
|
date: 2026-07-05
|
||||||
|
category: integration-issues
|
||||||
|
module: git-sync
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "git-sync plugin will not enable on prod despite enabled: true in its config"
|
||||||
|
- "Plugin does not appear / cannot be toggled on in the Grav Admin UI"
|
||||||
|
- "Config-level fix attempts (editing plugin YAML) have no effect"
|
||||||
|
- "make remote-gpm-install-prod PKG=git-sync reports a FRESH install, not 'already installed'"
|
||||||
|
root_cause: incomplete_setup
|
||||||
|
resolution_type: dependency_update
|
||||||
|
related_components:
|
||||||
|
- documentation
|
||||||
|
- development_workflow
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- git-sync
|
||||||
|
- gpm
|
||||||
|
- plugin-management
|
||||||
|
- env-config
|
||||||
|
- config-without-code
|
||||||
|
- troubleshooting-order
|
||||||
|
- remote-only-plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav plugin won't enable because its code is missing while its config persists in the env tree
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
After remediating an unrelated git-sync secret leak on prod (intotheeast.com, Grav 2.0.7 on a DirectAdmin/Apache shared host), the operator went to re-enable the `git-sync` plugin. Setting `enabled: true` in its config had no effect: the plugin would not appear as enabled in the Grav Admin plugins UI, and toggling it on manually in the browser did not take either. It looked fully "configured" — the config file was right there — but the plugin was inert and sync never ran.
|
||||||
|
|
||||||
|
The trap is that the plugin's config file existed (in the per-host env tree at `user/env/intotheeast.com/config/plugins/git-sync.yaml`), so any inspection that reads only config concluded the plugin was present and just needed enabling. The actual problem was one layer down: the plugin's **code** was missing from `user/plugins/git-sync/`. A Grav plugin cannot load or enable without its code on disk, no matter what its config says.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- The git-sync plugin does not appear as enabled, and cannot be enabled, in the Grav Admin UI — despite `enabled: true` being present in its config.
|
||||||
|
- Toggling `enabled` in config, or flipping the toggle in the Admin UI, produces no working plugin. Sync does not run.
|
||||||
|
- The plugin's config file DOES exist (in the per-environment tree `user/env/intotheeast.com/config/plugins/git-sync.yaml`), so the plugin appears "present" whenever only the config is inspected — masking the real state.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
1. **Setting / ensuring `enabled: true` in the git-sync config.** No effect. Config was never the problem.
|
||||||
|
2. **Enabling the plugin manually in the Admin UI.** The toggle wouldn't take.
|
||||||
|
|
||||||
|
Both failed attempts operate on the **config** layer. But the plugin's **code** was absent from `user/plugins/git-sync/`, and Grav can't load a plugin without its code. Grav (and any tooling that reads the config tree) reports a plugin as "configured" purely from the presence of its config file, which masks the absence of code. Diagnosing and poking at the config layer could never fix a missing-code problem — and guessing at config changes before running a simple `ls` on the plugin directory cost real time here.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
First, run the decisive diagnostic on the server — confirm whether the plugin code actually exists *before* touching config:
|
||||||
|
|
||||||
|
```
|
||||||
|
ls -la $WEBROOT/user/plugins/git-sync/ # empty/absent => missing code, reinstall
|
||||||
|
```
|
||||||
|
|
||||||
|
(In this project, do that via a make target or an ssh one-liner the user runs — never raw SSH by the assistant. All server ops go through `make remote-*`.)
|
||||||
|
|
||||||
|
With the directory confirmed empty/absent, reinstall the plugin's code via GPM:
|
||||||
|
|
||||||
|
```
|
||||||
|
make remote-gpm-install-prod PKG=git-sync # GPM fresh-installs Git Sync v3.4.4
|
||||||
|
```
|
||||||
|
|
||||||
|
That make target runs, on the server:
|
||||||
|
|
||||||
|
```
|
||||||
|
php bin/gpm index -f && php bin/gpm install git-sync -y && php bin/grav clearcache
|
||||||
|
```
|
||||||
|
|
||||||
|
The install output read **"Preparing to install Git Sync [v3.4.4] ... Success!"** — a **fresh** install, not "already installed." That fresh-install line is exactly what confirmed the code had been absent all along. After the reinstall plus cache clear, the plugin enabled and sync worked.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
Grav resolves a plugin from **two independent locations**:
|
||||||
|
|
||||||
|
- **Code** at `user/plugins/<name>/` — installed by GPM. Note `user/plugins/` is gitignored (`/plugins/*`) and is NOT tracked by the content repo.
|
||||||
|
- **Config** — the tracked `user/config/plugins/<name>.yaml` and/or the per-host `user/env/<host>/config/plugins/<name>.yaml`.
|
||||||
|
|
||||||
|
These two can **desync**: config can exist with no code behind it. Config alone makes the plugin look present to any tool that only reads config, but the plugin stays inert until its code is on disk. GPM reinstall restores the code; `clearcache` makes Grav re-scan and pick it up.
|
||||||
|
|
||||||
|
A project-specific amplifier made this worse: `git-sync` is a **remote-only, GPM-managed** plugin. It is deliberately NOT in `plugins.txt`, so `make install-plugins` and the normal `make remote-install` flow do **not** restore it. Only an explicit `php bin/gpm install git-sync` (via `make remote-gpm-install-prod PKG=git-sync`) does. So when its code goes missing, it does not self-heal through the standard install path — you must reinstall it explicitly.
|
||||||
|
|
||||||
|
How the code went missing here is **unconfirmed**. It happened around the git-sync secret-leak remediation, but the exact step that wiped `user/plugins/git-sync/` was not established — don't assume a specific cause.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- **Check the code layer before the config layer.** When a Grav plugin "won't enable" and config toggles do nothing, FIRST verify the code exists: `ls user/plugins/<name>/` on the server. Config-without-code is the failure class; the empty directory is the tell.
|
||||||
|
- **Enumerate both layers in all locations when diagnosing.** Plugins have a code layer (`user/plugins/<name>/`) and a config layer, and on prod the config can live in the env tree (`user/env/<host>/config/plugins/<name>.yaml`) and persist completely independently of the code. Remember: once `user/env/<host>/` exists, Grav Admin writes ALL config there, so always check both `user/config/...` and the env path (env wins).
|
||||||
|
- **Know which plugins are remote-only.** The 3-category model: GPM-via-`plugins.txt` (admin2 / api / flex-objects), custom-in-repo (cache-on-save / story-blocks / entry-actions), and remote-only (git-sync — never in `plugins.txt`). Remote-only plugins are NOT restored by the standard install/content flows, so reinstall them explicitly via GPM after any operation that could have wiped `user/plugins/`.
|
||||||
|
- **Diagnose actual state before proposing config fixes.** An `ls` is cheaper than a guess. Establishing that the code was missing would have pointed straight at the reinstall instead of a round of config poking.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — **closest sibling.** Same family (a plugin non-functional on prod, resolved by a GPM install + cache clear), same 2026-07-04/05 cutover context, same `plugins.txt` / `make remote-*` / GPM machinery. **Distinct trigger:** there, GPM refuses to *offer* the plugin because the installed core is below the version floor; here, the plugin's *code folder is simply missing* while its config persists (config-without-code desync). Two different ways a plugin ends up absent/inert on prod.
|
||||||
|
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — same module (git-sync) and explains **why the config survived without code**: Grav Admin writes `git-sync.yaml` into the per-environment tree `user/env/<host>/config/plugins/`, which is untracked/gitignored and not part of the plugin package. The orphaned config here is the flip side of that env-tree behavior.
|
||||||
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — establishes the plugin **code (GPM/gitignored) vs config (tracked override / env tree)** split that this bug exploits. This doc is a concrete failure of that split going the other way: config present (in the env tree), code absent.
|
||||||
|
- `docs/working/git-sync-notes.md` — operational notes on git-sync's per-environment tree, where `git-sync.yaml` lives server-only. Context for where the orphaned config resided.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
title: "Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor"
|
||||||
|
date: 2026-07-04
|
||||||
|
category: docs/solutions/integration-issues
|
||||||
|
module: "grav / production deploy / plugin install"
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: authentication
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "Admin2 login at /admin silently fails: button disables then re-enables, no visible error, nothing written to grav.log"
|
||||||
|
- "admin2 SPA background login POST to /api/... returns 404"
|
||||||
|
- "/api/v1/pages returns 404 on prod but 401 locally (api plugin route not registered)"
|
||||||
|
- "user/plugins/api directory does not exist on prod (plugin never installed)"
|
||||||
|
- "gpm install api reports 'These packages were not found on Grav: api' even after gpm index -f"
|
||||||
|
root_cause: config_error
|
||||||
|
resolution_type: environment_setup
|
||||||
|
related_components:
|
||||||
|
- "gpm"
|
||||||
|
- "admin2 plugin"
|
||||||
|
- "api plugin"
|
||||||
|
- "scripts/server-install.sh"
|
||||||
|
- "Makefile remote targets"
|
||||||
|
- ".env.prod"
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- gpm
|
||||||
|
- admin2
|
||||||
|
- api-plugin
|
||||||
|
- plugin-dependency
|
||||||
|
- version-compatibility
|
||||||
|
- production-deploy
|
||||||
|
- env-config
|
||||||
|
---
|
||||||
|
|
||||||
|
# Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
On a fresh Grav production install, Admin2 login fails silently because the `api` plugin — which Admin2 authenticates through — never installed. GPM refused to serve it: a stale `GRAV_VERSION` in `.env.prod` had installed Grav `2.0.0-rc.10`, and the `api` plugin requires Grav core `>=2.0.4`. GPM filters offered packages by the installed core version, so on an rc.10 core the `api` plugin was excluded from results entirely and reported as "not found." Admin2 was present (and depends on `api`), but its login POST hit an `/api/...` route that was never registered, so authentication silently 404'd before it ever reached Grav's auth layer.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Admin login at `/admin` silently fails: the login button disables briefly, re-enables, and shows no error. **Nothing appears in `logs/grav.log`** — a wrong password *would* log a failed-attempt warning, so its absence means auth was never reached.
|
||||||
|
- The Admin2 SPA's background login request (to an `/api/...` endpoint) returns **HTTP 404** with `content-type: application/json`.
|
||||||
|
- `GET /api/v1/pages` returns **404** on prod, but **401 Unauthorized** on the working local install — i.e. the api route isn't registered on prod at all.
|
||||||
|
- `ls user/plugins/api` on the server: **No such file or directory** — the plugin was never installed, even though `admin2` (which depends on it) was.
|
||||||
|
- `php bin/gpm install ... api -y` → `"These packages were not found on Grav: api"`, even after `php bin/gpm index -f`.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
- **Committing/deploying the api plugin config** (`enabled` / `route` / `session_enabled`, moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`). This was a real, necessary fix for a *different* latent problem, but it did not fix login: you cannot configure a plugin that isn't installed. Still 404.
|
||||||
|
- **Forcing a GPM index refresh** (`php bin/gpm index -f`). No effect. "Package not found" here is not a stale-index problem — GPM filters the packages it offers by the installed Grav **core** version, and rc.10 is below the api plugin's `>=2.0.4` requirement, so `api` is excluded from results entirely.
|
||||||
|
- **Assuming "same channel = same availability."** Local (Grav 2.0.4, `stable` channel) found `api` via `gpm info api`; prod (also `stable`) reported it "not found." The channel was identical — the difference was the Grav **core** version, which silently filtered the plugin out.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
The real cause is that prod was running the wrong Grav core. `scripts/server-install.sh` downloads `grav-admin-v${GRAV_VERSION}.zip`, and `.env.prod` still carried the stale pre-upgrade `GRAV_VERSION=2.0.0-rc.10`.
|
||||||
|
|
||||||
|
1. Upgrade the Grav core in place to stable (rc.10 → 2.0.7):
|
||||||
|
```bash
|
||||||
|
make remote-upgrade-grav-prod # php bin/gpm self-upgrade -y && php bin/grav cache
|
||||||
|
```
|
||||||
|
2. Install the plugins now that a compatible core is present (the api plugin resolves):
|
||||||
|
```bash
|
||||||
|
make remote-install-plugins-prod # php bin/gpm index -f && php bin/gpm install <plugins.txt> -y
|
||||||
|
# => "Preparing to install API [v1.0.8] ... Success!"
|
||||||
|
```
|
||||||
|
3. Clear cache, then verify the api route is live and login works:
|
||||||
|
```bash
|
||||||
|
make remote-clean-prod
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://site/api/v1/pages
|
||||||
|
# 401 (was 404) => plugin installed + routed
|
||||||
|
```
|
||||||
|
4. **Prevent recurrence:** update `.env.prod` to `GRAV_VERSION=2.0.4` so a future *fresh* install doesn't reinstall rc.10 (self-upgrade fixed the running server, not the env file). Keep the api plugin's functional config in the tracked `user/config/plugins/api.yaml` so it deploys on a clean clone.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
GPM (Grav Package Manager) only offers a plugin version whose declared Grav requirement is satisfied by the **installed core**. The `api` plugin requires Grav `>=2.0.4`; on a `2.0.0-rc.10` core there is no compatible version, so GPM reports the package as "not found" rather than a version conflict. Admin2 declares `api` as a hard dependency and performs all authentication over the api plugin's `/api/v1` JWT endpoints, so with `api` absent the login POST hits a route that doesn't exist (404) and never reaches Grav's auth layer — hence the silent failure with no `grav.log` entry. Upgrading the core to a stable `>=2.0.4` build makes GPM offer `api` again; installing it registers `/api/v1`, and Admin2's login flow succeeds.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- **Keep `.env.<env>` `GRAV_VERSION` current.** It is the version a *fresh* `make remote-install-<env>` bakes in; a stale value silently installs an old core. After any core upgrade, bump the env file too — self-upgrade only moves the running server. Note this is a *third* version-authority surface alongside `user/config/system.yaml` `gpm.releases` (channel) and `plugins.txt` — they must stay in sync. A **fourth** surface governs the *local Docker* core: the hardcoded `grav-admin-v<ver>.zip` URL in `Dockerfile`. `.env.<env> GRAV_VERSION` governs fresh **remote** installs only — it never touches the local Docker core (which upgrades by an image rebuild, not self-upgrade). See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md`.
|
||||||
|
- **When GPM says "package not found" for a package you know is on your channel, check the target's Grav core version first** (`php bin/grav --version` on the server, or `make remote-diag-<env>`). GPM filters by core compatibility; "not found" often means "no version compatible with your core," not "missing from the index." `gpm index -f` will not help.
|
||||||
|
- **Don't trust a top-level install "Success" to mean dependencies installed.** A fresh install can leave a plugin's declared dependency unsatisfied (here `admin2` installed but its `api` dependency didn't). Verify with `ls user/plugins/<dependency>`. The same `ls` guards a *second*, distinct way a plugin ends up non-functional: its **code folder can be missing while its config persists** (e.g. in the per-host env tree), so it looks configured but never loads. Checking `ls user/plugins/<name>` catches both the missing-dependency and the config-without-code cases — see `grav-plugin-config-without-code-wont-enable.md`.
|
||||||
|
- **Know the Admin2 ⇄ api coupling.** Admin2 authenticates via the api plugin's `/api/v1` endpoints; a missing or unrouted api plugin makes admin login fail *silently* (login POST 404s, nothing logged). A quick `curl /api/v1/pages` expecting `401` (not `404`) is a good post-deploy smoke check.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
This is one of three independent gotchas from the same **2026-07-04 Grav 2.0.4 production cutover**:
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` — sibling: garbage-rendered pages from a double `Content-Encoding` header on a non-FastCGI host. Different root cause (HTTP compression), same deploy.
|
||||||
|
- `docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md` — sibling: an authenticated account is denied an admin-gated page because `login new-user` auto-detect granted `api.*` but not `admin.*`. Different root cause (permission provisioning), same admin2/api area.
|
||||||
|
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the upgrade plan whose Global Constraints spell out the GPM version floors (`grav >=2.0.4`, `api >=1.0.6`) that cause the "package not found" on an rc core.
|
||||||
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the *other* latent problem from this same investigation: the api plugin's functional config (`enabled` / `route` / `session_enabled`) must live in the tracked `user/config/plugins/api.yaml` to deploy at all. Necessary but not sufficient here (the plugin must be installed first), but a durable convention in its own right.
|
||||||
|
|
||||||
|
A closely-related **sibling in the "plugin absent/non-functional on prod" family** (from the 2026-07-05 follow-up, not one of the three cutover gotchas above):
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/grav-plugin-config-without-code-wont-enable.md` — same outcome (a plugin inert on prod, fixed by a GPM install + cache clear), **different trigger**: there, GPM won't *offer* the plugin because the core is below the version floor; there, the plugin's *code folder is simply missing* while its config persists in the env tree (config-without-code desync). Same `ls user/plugins/<name>` smoke check flushes both out.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
title: "Grav login new-user grants api.* but not admin.* on Admin2-only installs"
|
||||||
|
date: 2026-07-04
|
||||||
|
category: docs/solutions/test-failures
|
||||||
|
module: testing / account provisioning
|
||||||
|
problem_type: test_failure
|
||||||
|
component: authentication
|
||||||
|
symptoms:
|
||||||
|
- "gpx-manager Playwright specs fail (401 / login form shown) after switching the suite onto a dedicated test account"
|
||||||
|
- "an authenticated account still sees the login form at /gpx-manager instead of the manager UI"
|
||||||
|
- "the generated accounts/*.yaml has an access.api block but no access.admin block"
|
||||||
|
root_cause: missing_permission
|
||||||
|
resolution_type: tooling_addition
|
||||||
|
severity: medium
|
||||||
|
related_components:
|
||||||
|
- testing_framework
|
||||||
|
- tooling
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- login-plugin
|
||||||
|
- admin2
|
||||||
|
- permissions
|
||||||
|
- playwright
|
||||||
|
- test-account
|
||||||
|
- gpx-manager
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav login new-user grants api.* but not admin.* on Admin2-only installs
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
When the Playwright suite was moved onto a dedicated local `testrunner` account, every `/gpx-manager` spec started failing — the account could authenticate but was treated as unauthorized for the manager page. The account had been created with `bin/plugin login new-user ... -P b` (Admin + Site access) but **without** `--admin-type`, and on this Admin2-only install that grants `api.*` permissions and no `admin.*` permissions.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
- The `/gpx-manager` Playwright specs fail after switching from the real user to the `testrunner` account (they passed as the real user).
|
||||||
|
- An authenticated `testrunner` still gets the Login plugin's login form at `/gpx-manager` instead of the manager UI.
|
||||||
|
- The generated `user/accounts/testrunner.yaml` contains an `access.api` block (`login: true`, `super: true`) but **no** `access.admin` block.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
- **Assuming `-P b` was enough.** `-P/--permissions b` selects the *category* of access (Admin + Site), but the *type* of admin permission — classic `admin.*` vs Admin2 `api.*` — is a separate axis controlled by `--admin-type`, which defaults to auto-detect. `-P b` alone does not guarantee `admin.login`.
|
||||||
|
- **Blaming the wrong specs.** In the same push, the home `H1`/map specs were also red, which looked like it might be the same auth problem. It was not — those were gated by `site.yaml` `travelling: false` hiding the active-trip view, a completely separate cause. Conflating the two delayed pinning the permission root cause.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Create the account with an explicit `--admin-type both`, and bake it into the idempotent `make test-account` target so every recreation is faithful:
|
||||||
|
|
||||||
|
```make
|
||||||
|
test-account:
|
||||||
|
@docker exec intotheeast_grav 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'
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the resulting permissions actually include `admin.login`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec intotheeast_grav rm -f /var/www/html/user/accounts/testrunner.yaml
|
||||||
|
make test-account
|
||||||
|
docker exec intotheeast_grav sh -c 'grep -A6 "^access:" /var/www/html/user/accounts/testrunner.yaml'
|
||||||
|
# access:
|
||||||
|
# admin:
|
||||||
|
# login: true
|
||||||
|
# super: true
|
||||||
|
# api:
|
||||||
|
# login: true
|
||||||
|
# super: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
The `login new-user` help text spells out the axis:
|
||||||
|
|
||||||
|
> `--admin-type` — Which admin permission type to grant when permissions include Admin: `admin` (classic Admin plugin, `admin.*`), `api` (Admin2, `api.*`), or `both`. **If omitted, auto-detects from which admin plugin is installed.**
|
||||||
|
|
||||||
|
This site runs **Admin2 only** (the classic `admin` plugin is disabled), so auto-detect resolves to `api` and emits `api.*` alone. `/gpx-manager` is gated by `access.admin.login: true` in its page frontmatter (enforced by the Login plugin), and that check looks specifically for the `admin.login` permission — `api.login` does not satisfy it. Passing `--admin-type both` forces both namespaces into the account, so the admin-gated page accepts the session.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
- **On Admin2-only Grav installs, always pass `--admin-type both` (or `admin`) to `login new-user`** when the account must reach any page gated by `access.admin.login` (e.g. `/gpx-manager`). Auto-detect will otherwise silently give you api-only.
|
||||||
|
- **Assert the permission, not the exit code.** After provisioning an account for admin-gated pages, check that `access.admin.login` exists in the generated YAML rather than trusting that account creation "succeeded."
|
||||||
|
- **Keep provisioning in one idempotent place.** The `make test-account` target is the single source of truth; `tests/global-setup.js` calls it, so `make test` and a bare `npx playwright test` both get identical permissions. Don't hand-create the account out-of-band with different flags — that reintroduces the drift this fix removed.
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade.
|
||||||
|
- Sibling gotchas from the same 2026-07-04 Grav 2.0.4 production cutover (all surface around admin2/api but with distinct root causes): `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` (stale `GRAV_VERSION` → rc core → GPM won't serve the `api` plugin → login 404s) and `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` (double `Content-Encoding` header → garbage page).
|
||||||
|
- `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production.
|
||||||
|
- GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
title: Upgrade the local Grav core by rebuilding the Docker image, not gpm self-upgrade
|
||||||
|
date: 2026-07-05
|
||||||
|
category: docs/solutions/tooling-decisions/
|
||||||
|
module: docker-dev-env
|
||||||
|
problem_type: tooling_decision
|
||||||
|
component: tooling
|
||||||
|
severity: medium
|
||||||
|
applies_when:
|
||||||
|
- Upgrading the Grav core in the local Docker dev environment
|
||||||
|
- App core is baked into the image while only user content is bind-mounted
|
||||||
|
- Deciding between an image rebuild and an in-container package upgrade
|
||||||
|
- "docker compose up refuses to recreate a fixed container_name"
|
||||||
|
tags: [grav, docker, dockerfile, image-rebuild, gpm, upgrade, container-recreate]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Upgrade the local Grav core by rebuilding the Docker image, not gpm self-upgrade
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The test and prod servers had already self-upgraded to Grav 2.0.7 in place, but the local Docker dev environment was still on 2.0.4. The question was how to bring local to 2.0.7 **durably** — in a way that survives the next image rebuild and keeps local reproducible from the repo.
|
||||||
|
|
||||||
|
The instinct is to do what the servers do: `php bin/gpm self-upgrade` inside the running container. That is the wrong tool for the local box, and understanding why is the whole point of this note.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
**The local Grav core is baked into the Docker image, so you upgrade it by editing the `Dockerfile` and rebuilding — never by upgrading inside a running container.**
|
||||||
|
|
||||||
|
The dev image (`Dockerfile`) `curl`s a specific release zip and copies its `system/`, `vendor/`, `bin/`, `index.php`, etc. into the image at build time:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.7/grav-admin-v2.0.7.zip' ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The version is **hardcoded in the URL** — there is no `ARG`, so the `GRAV_VERSION` variable in `.env*` does **not** feed the local build (it only pins the base zip for a *fresh remote install*). `docker-compose.yml` volume-mounts **only** `./user:/var/www/html/user` (plus a php.ini). Everything else — the entire core — lives in the immutable image layer.
|
||||||
|
|
||||||
|
The durable local upgrade sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bump BOTH occurrences of the version in the Dockerfile release URL
|
||||||
|
# (the /download/<ver>/ path and the grav-admin-v<ver>.zip filename)
|
||||||
|
|
||||||
|
# 2. Rebuild the image (the FROM getgrav/grav layer is cached; the RUN
|
||||||
|
# layer re-fetches the new zip in a few seconds)
|
||||||
|
docker compose build grav
|
||||||
|
|
||||||
|
# 3. Recreate the container. `up -d` may refuse (see gotcha below); if so:
|
||||||
|
docker rm -f intotheeast_grav && docker compose up -d grav
|
||||||
|
|
||||||
|
# 4. Verify the core version
|
||||||
|
docker exec -w /var/www/html intotheeast_grav php bin/grav --version # -> Grav CLI Application 2.0.7
|
||||||
|
|
||||||
|
# 5. Refresh plugins to match the servers, then clear cache
|
||||||
|
make install-plugins # docker exec ... php bin/gpm install <plugins.txt> -y
|
||||||
|
docker exec -w /var/www/html intotheeast_grav php bin/grav cache
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha — `docker compose up` won't replace a running fixed-name container.** The service pins `container_name: intotheeast_grav`, so `docker compose up -d` (and even `--force-recreate`) fails with `Conflict. The container name "/intotheeast_grav" is already in use`. Remove the old container first: `docker rm -f intotheeast_grav`, then `up -d`. This is **data-safe** because all persistent content lives in the `./user` bind mount, which is untouched by removing/recreating the container. (This same singleton collision bit an earlier upgrade session when the running container from the main checkout held the name+port. — session history)
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**An in-container `gpm self-upgrade` is non-durable locally.** It writes into the image's filesystem layer, not the `./user` volume, so the upgraded core evaporates on the next `docker compose build` / container recreate. The image, not the running container, is the source of truth for the core — so the core version must be baked into the `Dockerfile` to persist and to stay reproducible from the repo.
|
||||||
|
|
||||||
|
**Local and server upgrade by deliberately different mechanisms:**
|
||||||
|
|
||||||
|
- **Servers** are native webroot installs with no image, so `bin/gpm self-upgrade` mutates the install in place and *is* durable there. (Note: `bin/grav upgrade` does **not** exist — the correct verb is `bin/gpm self-upgrade`. — session history)
|
||||||
|
- **Local** is rebuilt from an image, so only a `Dockerfile` bump persists.
|
||||||
|
|
||||||
|
A consequence worth remembering (accepted risk, flagged in the original upgrade session): the local gate never exercises the server's in-place `self-upgrade` path — a fresh image bakes a clean core and reinstalls plugins clean, whereas the server mutates an existing core in place. A green local build proves the clean-install path, not the in-place upgrade path; the remote upgrade is the first real test of that. (session history)
|
||||||
|
|
||||||
|
**Same mental model applies beyond version upgrades.** Because the core/runtime is baked and only `./user` is mounted, *any* runtime capability lives in the image. Adding server-side HEIC support (ImageMagick/libheif) would likewise require a custom image rebuild — which is why HEIC was handled client-side instead. "The core is in the image; only `./user` is a volume" is the reusable principle. (session history)
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Any time the **local** Grav core version needs to change (upgrade or, rarely, a pinned downgrade — note `gpm self-upgrade` is forward-only and cannot downgrade).
|
||||||
|
- Whenever you catch yourself about to run `gpm self-upgrade` inside the dev container "to match the server" — stop and bump the `Dockerfile` instead.
|
||||||
|
- When `docker compose up`/`--force-recreate` reports a container-name conflict for a service with a fixed `container_name`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Concrete run from the 2.0.4 → 2.0.7 local upgrade (2026-07-05):
|
||||||
|
|
||||||
|
```
|
||||||
|
# Dockerfile line 3: .../download/2.0.4/grav-admin-v2.0.4.zip
|
||||||
|
# -> .../download/2.0.7/grav-admin-v2.0.7.zip
|
||||||
|
|
||||||
|
$ docker compose build grav
|
||||||
|
=> CACHED [1/2] FROM docker.io/getgrav/grav:latest
|
||||||
|
=> [2/2] RUN curl -sL '.../2.0.7/grav-admin-v2.0.7.zip' ... 3.4s
|
||||||
|
|
||||||
|
$ docker compose up -d grav
|
||||||
|
Error response from daemon: Conflict. The container name
|
||||||
|
"/intotheeast_grav" is already in use ...
|
||||||
|
|
||||||
|
$ docker rm -f intotheeast_grav && docker compose up -d grav
|
||||||
|
Container intotheeast_grav Started
|
||||||
|
|
||||||
|
$ docker exec -w /var/www/html intotheeast_grav php bin/grav --version
|
||||||
|
Grav CLI Application 2.0.7
|
||||||
|
|
||||||
|
# Smoke test from INSIDE the container (the host has no curl):
|
||||||
|
$ docker exec intotheeast_grav sh -c \
|
||||||
|
'for p in / /admin /gpx-manager; do curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:80$p"; done'
|
||||||
|
200
|
||||||
|
200
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config caveat:** a server-side `gpm self-upgrade` runs Grav's schema migration and rewrites `system.yaml` `strict_mode` flags (`twig_compat` → `twig2_compat`/`twig3_compat`). A fresh-image rebuild does **not** trigger that migration, so `user/config/system.yaml` in the repo must already carry the intended Twig-3 flags (it does, from an earlier reconciliation). If it didn't, local and server config would silently drift. This is another reason the image-rebuild path depends on the repo config being the source of truth.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/guides/local-setup.md` — "Upgrading to a newer Grav" section documents the same bump-and-rebuild procedure, but with stale "RC bundle" wording and without the `docker rm -f`, version-verify, plugin-refresh, or non-durability details. **Refresh candidate** — fold these operational steps in and drop the "RC" language (`Dockerfile` now pins stable `grav-admin-v2.0.7.zip`).
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the server/`.env`/fresh-install counterpart. That doc frames `GRAV_VERSION` as a version-authority surface for *remote* installs; this doc adds the **fourth** authority surface (the hardcoded release-zip URL in `Dockerfile`) and clarifies that `.env* GRAV_VERSION` never touches the local Docker core. Servers correctly self-upgrade because they have no image; local Docker cannot.
|
||||||
|
- `docs/guides/deploy-cycle.md` — the local→test→prod runbook. Its Phase 0 (Local) covers bumping `GRAV_VERSION` for remote installs but not upgrading the local Docker core; its "where state lives" table omits that the local core lives in the Docker image.
|
||||||
|
- `docs/solutions/integration-issues/docker-exec-root-owned-bind-mount-files.md` — the ownership counterpart to the `make install-plugins` step above (line 54). That `docker exec … php bin/gpm install` writes the plugin tree into the `./user` bind mount **as root** unless `-u $(HOST_UID):$(HOST_GID)` is passed; that doc explains the fix and why the container still boots as root.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
title: "Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images"
|
||||||
|
date: 2026-07-07
|
||||||
|
category: ui-bugs
|
||||||
|
module: intotheeast-theme
|
||||||
|
problem_type: ui_bug
|
||||||
|
component: rails_view
|
||||||
|
symptoms:
|
||||||
|
- "Trip banner/cover renders blurry and badly cropped even though the source photo looks high-res in the post"
|
||||||
|
- "A portrait phone photo appears as a thin, upscaled horizontal sliver in a wide banner strip"
|
||||||
|
- "Cover derivative comes back at the source aspect ratio (e.g. 165x220 from a 1013x1350 portrait) instead of the requested strip"
|
||||||
|
root_cause: wrong_api
|
||||||
|
resolution_type: code_fix
|
||||||
|
severity: medium
|
||||||
|
tags: [grav, twig, medium, cropresize, cropzoom, srcset, retina, cover-image, object-fit]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The shared trip-cover macro produced a blurry, badly-composed banner/card image
|
||||||
|
for any trip whose cover fell back to a portrait journal photo. It looked like a
|
||||||
|
low-quality source, but the source was fine — the wrong Grav Medium operation was
|
||||||
|
turning it into a tiny sliver that CSS then upscaled.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Trip banner on `/trips/us-canada-mex-2024` looked "horrendous" — soft and
|
||||||
|
zoomed — while the same photo looked sharp inside the journal post.
|
||||||
|
- The rendered `<img>` derivative came back at the *source* aspect ratio, not the
|
||||||
|
requested strip: `cropResize(720, 220)` on a 1013×1350 portrait produced a
|
||||||
|
**165×220** image (0.75 ratio, matching the source), not a 720×220 strip.
|
||||||
|
- The banner box (`.trip-header-banner img { object-fit: cover; height: 200px }`)
|
||||||
|
then upscaled that ~165px-wide sliver ~4× to fill the column → blur.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
- **Assuming it was source/image quality.** The imported photos are only
|
||||||
|
~700–1200px wide (pixelfed served downscaled web renditions), but that alone
|
||||||
|
did not explain the blur — the same file was sharp in the post.
|
||||||
|
- **Capping the derivative width to avoid upscaling (`min(w, source_width)`), as
|
||||||
|
a first pass.** This stopped Grav from re-encoding an upscaled JPEG, but the
|
||||||
|
derivative was *still* a portrait sliver because `cropResize` was still the
|
||||||
|
wrong operation — it emitted odd intermediate `srcset` widths (`1013w`,
|
||||||
|
`1200w`) without fixing the composition. It was treating a symptom.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Switch the cover operation from `cropResize` (fit-inside) to `cropZoom`
|
||||||
|
(crop-to-fill / cover), and make retina all-or-nothing so a narrow source is
|
||||||
|
never upscaled.
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{# BEFORE — cropResize fits the source INSIDE the box, preserving its aspect
|
||||||
|
ratio, so a portrait comes back as a narrow sliver #}
|
||||||
|
<img src="{{ cover.cropResize(w, h).url }}"
|
||||||
|
srcset="{{ cover.cropResize(w, h).url }} {{ w }}w,
|
||||||
|
{{ cover.cropResize(w * 2, h * 2).url }} {{ (w * 2) }}w">
|
||||||
|
|
||||||
|
{# AFTER — cropZoom crops-to-fill, returning an actual w×h cover strip; the 2x
|
||||||
|
descriptor is emitted only when the source is genuinely >= 2w wide #}
|
||||||
|
<img src="{{ cover.cropZoom(w, h).url }}"
|
||||||
|
srcset="{{ cover.cropZoom(w, h).url }} {{ w }}w{% if cover.width >= (w * 2) %}, {{ cover.cropZoom(w * 2, h * 2).url }} {{ (w * 2) }}w{% endif %}">
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified empirically against the running container (do not trust the method
|
||||||
|
names from memory — Grav's op semantics are non-obvious):
|
||||||
|
|
||||||
|
| op | on a 1013×1350 portrait, target 720×220 | shape |
|
||||||
|
|----|------------------------------------------|-------|
|
||||||
|
| `cropResize(720, 220)` | **165×220** | fit-inside (source aspect kept) |
|
||||||
|
| `cropZoom(720, 220)` | **720×220** | crop-to-fill (cover) ✅ |
|
||||||
|
| `resize(720, 220)` | 720×220 | stretched/distorted ✗ |
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
Grav's `Medium::cropResize($w, $h)` scales the image to **fit inside** the
|
||||||
|
`$w × $h` box while preserving the source aspect ratio — for a tall portrait it
|
||||||
|
is bound by height, yielding a narrow image far smaller than `$w`. `cropZoom`
|
||||||
|
instead scales to **cover** the box and crops the overflow, so it always returns
|
||||||
|
exactly `$w × $h` with no distortion. A banner/card strip wants cover behavior,
|
||||||
|
so `cropZoom` is correct. Capping widths at `cover.width` prevents Grav from
|
||||||
|
re-encoding an upscaled derivative; combined with `object-fit: cover` on the
|
||||||
|
element, the browser gets a sharp strip at (or below) native resolution.
|
||||||
|
|
||||||
|
Note the imported photos cap at ~1440px wide, so `cover.width >= 2w` is usually
|
||||||
|
false for the wide banner — auto-picked covers render 1x-only (sharp on standard
|
||||||
|
displays; retina only engages for an explicitly-set wide landscape `cover_image`).
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- **Choose the Grav Medium op by intent, and verify the output dimensions.**
|
||||||
|
For a fixed-shape strip/thumbnail (banner, card, avatar) use `cropZoom`
|
||||||
|
(crop-to-fill). Use `cropResize` only when you actually want the whole image
|
||||||
|
fit inside a bounding box (aspect preserved, letterbox-friendly).
|
||||||
|
- **Confirm Medium API behavior empirically before shipping** rather than trusting
|
||||||
|
method names — a quick `php bin/grav` script that runs the op and calls
|
||||||
|
`getimagesize()` on the derivative catches fit-vs-fill surprises. (auto memory
|
||||||
|
[claude]: this repo's standing guidance is to look up / verify Grav + plugin
|
||||||
|
API behavior, never guess it.)
|
||||||
|
- **Guard retina descriptors against upscaling:** only add the 2x `srcset`
|
||||||
|
candidate when `cover.width >= 2 * targetWidth`; never emit a derivative wider
|
||||||
|
than the source.
|
||||||
|
- **Regression test the composition, not just the URL.** Assert the loaded
|
||||||
|
banner image's natural aspect ratio is the wide strip ratio (e.g. `nw/nh > 3`),
|
||||||
|
which fails if a future edit reverts to a fit-inside sliver. See
|
||||||
|
`tests/ui/trip/trip-header.spec.js` (portrait-source regression on
|
||||||
|
`us-canada-mex-2024`).
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- Feature that introduced the macro: `docs/working/plans/2026-07-05-trip-description-and-hero.md`
|
||||||
|
(see the 2026-07-07 follow-up note). Session history shows the retina cover
|
||||||
|
macro was built entirely with `cropResize` across the feature sessions and
|
||||||
|
`cropZoom` was never evaluated, so the bug was latent from inception and only
|
||||||
|
surfaced when real portrait content hit the banner. (session history)
|
||||||
|
- Backlog: full-resolution re-import of pixelfed photos — `docs/working/backlog.md`
|
||||||
|
(Content quality — luxury). The ~1440px source ceiling is why auto covers are
|
||||||
|
1x-only.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# travel-memories — Session Handover (2026-06-21)
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
`services/travel-memories/` is a local Flask app that turns Immich photo albums into Grav CMS journal entries and story pages. It runs at **http://localhost:8082** via Docker.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
All 11 SDD tasks are complete. The app is fully functional end-to-end across all 6 phases. This session was spent fixing bugs discovered during real use and adding triage UX improvements.
|
||||||
|
|
||||||
|
**Last commit:** `a265b08` — fix: use amber/sky colors for journal/story borders and badges
|
||||||
|
|
||||||
|
## What was built this session
|
||||||
|
|
||||||
|
### New triage UX (Phase 2)
|
||||||
|
|
||||||
|
**Desktop:**
|
||||||
|
- **Selection ring**: white ring on currently focused card; first card auto-selected on load
|
||||||
|
- **Arrow key navigation**: `←` / `→` move the selection ring through photos
|
||||||
|
- **Enter**: open lightbox for current card; **click**: also opens lightbox
|
||||||
|
- **Lightbox**: full-screen overlay — `←`/`→` navigate, `J`/`S`/`X` tag without closing, `Esc` close; badge + date shown at bottom
|
||||||
|
- **Badge**: amber J / sky S / ghost X on every tagged photo; updates dynamically when tag changes
|
||||||
|
- **Colored borders**: amber (`border-amber-500`) for journal, sky blue (`border-sky-400`) for story, dimmed for skip
|
||||||
|
- **Skip all untagged** button: bulk-skips everything still untagged
|
||||||
|
|
||||||
|
**Mobile (<768px):**
|
||||||
|
- Tinder-style HammerJS swipe cards: right=journal, left=skip, up=story
|
||||||
|
- Card tilts + color overlay during drag (amber/sky/grey)
|
||||||
|
- Three tap buttons (X / J / S) below the card as alternative
|
||||||
|
- Back button with undo stack (max 10 actions)
|
||||||
|
- Progress bar synced with header counter
|
||||||
|
- Horizontal thumbnail strip at bottom: all photos, colored dot per tag, tap to jump to any photo
|
||||||
|
|
||||||
|
## Bugs fixed this session
|
||||||
|
|
||||||
|
| Bug | Root cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Triage badge not updating on tag change | Badge is server-rendered; JS wasn't updating it | Added `updateBadge(el, tag)` helper called after each tag |
|
||||||
|
| Arrow keys not working | Alpine modifier is `left`/`right` not `arrowleft`/`arrowright` | Fixed modifier names |
|
||||||
|
| Dimmed photo stays dimmed after retag | Jinja classes have newlines → `.split(' ')` produces `'opacity-40\n'` not `'opacity-40'` | Changed to `.split(/\s+/).filter(c => c && ...)` |
|
||||||
|
| Colored border disappears after retag | Filter `!c.startsWith('border-')` strips `border-4` (width) too | Re-add `border-4` alongside color class |
|
||||||
|
| Borders appear white | DaisyUI `border-success`/`border-info` near-invisible in `forest` theme | Use explicit Tailwind: `border-amber-500`, `border-sky-400` |
|
||||||
|
| Badge text unreadable | DaisyUI badge semantic classes give poor contrast in `forest` theme | Use `bg-amber-500 text-black border-0 font-bold` etc. |
|
||||||
|
|
||||||
|
## Gotchas for next session
|
||||||
|
|
||||||
|
- **Docker rebuild required after any template/code change**: `docker compose build travel-memories && docker compose up -d --force-recreate travel-memories`
|
||||||
|
- **`--force-recreate` required** to pick up `.env` changes (plain `restart` doesn't re-read it)
|
||||||
|
- **Immich API key needs scopes**: `album.read`, `asset.read`, `asset.download` (Immich calls it `asset.view` in some versions — check the Immich UI)
|
||||||
|
- **State directory permissions**: if state/ was created as root, run `docker compose exec -u root travel-memories chown 1000:1000 /app/state`
|
||||||
|
- **Never read `.env`** — contains real Immich credentials; pass to docker commands only
|
||||||
|
|
||||||
|
## What's not done yet
|
||||||
|
|
||||||
|
Nothing was explicitly left incomplete — the pipeline works end-to-end. Potential next steps:
|
||||||
|
|
||||||
|
1. **Full-resolution lightbox**: currently shows Immich preview thumbnail; could load `/proxy/original/<id>` for the full-res image (endpoint may need adding to `routes/proxy.py`)
|
||||||
|
2. **End-to-end test for triage UX**: the new JS-heavy triage UI has no Playwright coverage
|
||||||
|
3. **Phase 2 → real trip**: use the app on the actual japan-korea-2026 Immich album
|
||||||
|
4. **Mobile swipe color consistency**: swipe-right currently shows green overlay (intuitive for "go") — could switch to amber to match journal color, but debatable
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
```
|
||||||
|
services/travel-memories/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py Flask factory
|
||||||
|
│ ├── immich.py Immich API client (x-api-key auth)
|
||||||
|
│ ├── state.py TripState / Photo models, atomic JSON R/W
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── albums.py Phase 1 — album selection + slug sanitisation
|
||||||
|
│ ├── triage.py Phase 2 — tag/skip-untagged/done endpoints
|
||||||
|
│ ├── curate.py Phase 3 — reorder/swap
|
||||||
|
│ ├── group.py Phase 4 — grouping + dividers
|
||||||
|
│ ├── write.py Phase 5 — titles/captions
|
||||||
|
│ ├── export.py Phase 6 — write Grav markdown files
|
||||||
|
│ ├── proxy.py Immich thumbnail proxy
|
||||||
|
│ └── nav.py Shared nav context + stale propagation
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── base.html DaisyUI forest + Alpine + HammerJS CDN
|
||||||
|
│ ├── phase1.html Album selection
|
||||||
|
│ ├── phase2.html Triage (desktop grid + mobile swipe + lightbox)
|
||||||
|
│ └── phase[3-6].html Curate, group, write, export
|
||||||
|
├── Dockerfile
|
||||||
|
└── docker-compose.yml Port 8082, UID/GID env vars, state volume
|
||||||
|
```
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
date: 2026-07-08
|
||||||
|
topic: travel-blog-reader-experience-and-road-workflow
|
||||||
|
focus: reader experience, story mode, on-the-road posting workflow — ahead of Denmark 2026 (departing ~mid-July)
|
||||||
|
mode: repo-grounded
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ideation: Reader Experience, Story Mode & the Road Workflow
|
||||||
|
|
||||||
|
## Grounding Context
|
||||||
|
|
||||||
|
**Codebase context.** Grav 2.0.7 blog structured around Trips → Entries/Stories (CONCEPTS.md). Posting pipeline is mature and hardened as of the 2026-07-08 journal-post-form ship: `/post` create+edit, FilePond photos with client HEIC→JPEG, live photo editor, draft persistence, owner-scoped `entry-actions` API (delete / reorder / trip publish). Trip page renders inline map + filter-bar feed + stats. `main.js` already has a lightbox. Verified gaps: **no Open Graph / twitter:card meta anywhere in `user/themes/intotheeast/templates/partials/base.html.twig`**, **no RSS/feed plugin installed**, `transport_mode` is serialized into the map JSON (`trip.html.twig:66-69`) but **no JS or partial consumes it**, `entry.html.twig` detail view is a 12-line stub already slated for retirement (`docs/working/backlog.md`).
|
||||||
|
|
||||||
|
**Past learnings & open threads.** Curated-home brainstorm PAUSED mid-layout (hero+stats / map / latest entry / latest story / CTA; marker→popup preview). Per-photo captions deferred (`data-alt` uses filename placeholder). Transport-mode visualization deferred. Story-blocks authoring deferred until real stories are written. `travel-memories` Immich→Grav pipeline complete. Backlog: Komoot GPX pull, GPX-manager polish, full-res photo re-import.
|
||||||
|
|
||||||
|
**External context.** Polarsteps' most-loved follow feature: family views a shared trip link **without an account or app** ([Polarsteps vs FindPenguins](https://voluntouring.org/2025/07/04/polarsteps-vs-findpenguins/), [Polarsteps review](https://www.overlandsite.com/tools/polarsteps-review/)); both apps monetise post-trip printed travel books. RSS-to-email digests (Buttondown, MailerLite, Mailchimp RSS campaigns) are the standard low-friction "family inbox" channel ([RSS-to-email guide](https://www.wprssaggregator.com/rss-to-email/), [service comparison 2026](https://www.readless.app/blog/rss-to-email-services-2026)).
|
||||||
|
|
||||||
|
**Run notes.** Autonomous overnight run: no blocking questions asked; ideation frames applied inline by one agent instead of the parallel fleet (budget-lean), orchestrator-only basis verification. `direct:` bases were verified by grep/read against the working tree this night.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topic Axes
|
||||||
|
|
||||||
|
- Following along — how family & friends learn there's a new entry
|
||||||
|
- Reading the feed — arrival/dwell experience on the trip page
|
||||||
|
- Story mode — curated set pieces
|
||||||
|
- On-the-road posting — the owner's daily workflow
|
||||||
|
- After the trip — compounding, archive, keepsakes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ranked Ideas
|
||||||
|
|
||||||
|
1. [The follow-along stack](#1-the-follow-along-stack-og--share--rss--digest)
|
||||||
|
2. [The thirty-second post](#2-the-thirty-second-post-quick-log--auto-location--auto-weather)
|
||||||
|
3. [Transport-mode visualization](#3-transport-mode-visualization)
|
||||||
|
4. [The "Today" view](#4-the-today-view-resume-the-curated-home)
|
||||||
|
5. [Per-photo captions](#5-per-photo-captions)
|
||||||
|
6. [Trip Wrapped recap page](#6-trip-wrapped-recap-page)
|
||||||
|
7. [Komoot route pull](#7-komoot-route-pull-in-gpx-manager)
|
||||||
|
|
||||||
|
### 1. The follow-along stack (OG → share → RSS → digest)
|
||||||
|
|
||||||
|
**Description:** Make following the trip effortless for people who will never bookmark a blog. Four stages, each independently shippable, each building on the last:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
A[Stage 1: Open Graph + twitter:card meta\nper entry/trip/story] --> B[Stage 2: Share button on the\npost-success panel - Web Share API]
|
||||||
|
B --> C[Stage 3: RSS/Atom feed\nof the active trip]
|
||||||
|
C --> D[Stage 4: RSS-to-email digest\nfor family inboxes]
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 1 alone changes every link pasted into WhatsApp/Signal from a bare URL into a photo + title + location card. Stage 2 turns the existing post-success panel ("View your journal / Post another") into "…/ Share this entry" — one tap after every post, while the moment is fresh. Stage 3 gives the RSS-literate a subscription and is the substrate for Stage 4, where a Buttondown/MailerLite RSS campaign mails new entries to subscribed family on a daily/weekly cadence.
|
||||||
|
|
||||||
|
**Axis:** Following along
|
||||||
|
**Basis:** direct: grep confirms zero `og:` / `twitter:` meta tags in `partials/base.html.twig` and no feed plugin in `plugins.txt` or `user/plugins/`; the success panel exists in `post-form.js` (`initSuccessState`). external: Polarsteps' account-free share link is its most-cited family feature; RSS-to-email is a commodity integration.
|
||||||
|
**Rationale:** The site's readers are family and friends on phones in messaging apps — not blog visitors. Every entry already produces a perfect preview image (cover = first photo, by design). This is the highest leverage-to-effort ratio in the whole candidate set, and Stage 1 could ship before departure.
|
||||||
|
**Downsides:** Stage 4 introduces an external service and subscriber management; OG images should respect draft/unpublished state (don't leak draft covers to crawlers); feed must exclude unpublished entries.
|
||||||
|
**Confidence:** 90% (Stage 1–2), 75% (Stage 3–4)
|
||||||
|
**Complexity:** Low (Stage 1–2), Medium (Stage 3–4)
|
||||||
|
|
||||||
|
### 2. The thirty-second post (quick log + auto-location + auto-weather)
|
||||||
|
|
||||||
|
**Description:** A "quick log" posting mode for hard days: one photo + one sentence, no title required (derive it from date/location), plus removal of the two manual taps that remain in the flow — read GPS from the first photo's EXIF server-side to fill `lat`/`lng` when the fields are empty, and fetch weather server-side at submit time from coords + entry date (Open-Meteo archive API for backdated entries). The full form stays for real writing days; quick log keeps the streak alive on the days that produce none.
|
||||||
|
|
||||||
|
**Axis:** On-the-road posting
|
||||||
|
**Basis:** direct: `post-form.md` requires photos + title + content + date; location and weather are manual button taps in `post-form.js` (`initGeo`). reasoned: on a solo trip the binding constraint on journal completeness is end-of-day energy, not tooling; every removed field measurably raises the posting rate — the same logic that already removed the hero-image field and auto-collapsed the photo section.
|
||||||
|
**Rationale:** The blog's value compounds with consistency. Denmark is a cycling trip — many days will end tired. A 30-second floor means zero-entry days become one-photo entries instead of gaps.
|
||||||
|
**Downsides:** EXIF GPS may not survive the client-side HEIC→JPEG conversion (canvas-based converters typically strip metadata) — verify with a real iPhone photo first; if stripped, read EXIF client-side before conversion and post coords explicitly. Title-less entries need a rendering decision in the feed partials.
|
||||||
|
**Confidence:** 70%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 3. Transport-mode visualization
|
||||||
|
|
||||||
|
**Description:** Consume the already-serialized `transport_mode` field: style the map connector line per mode (e.g. dashed for train/bus/plane, solid for walking/cycling) and show the mode emoji/icon on entry cards and map popups. The data is being shipped to the client on every trip page load and rendered nowhere.
|
||||||
|
|
||||||
|
**Axis:** Reading the feed
|
||||||
|
**Basis:** direct: `trip.html.twig:68` serializes `transport_mode` into the map entries JSON; grep finds zero consumers in `maplibre-utils.js`, `main.js`, or any partial. The form select (walking/bicycle/bus/train/car/plane) shipped in the current post form.
|
||||||
|
**Rationale:** For a cycling-centric trip, *how you moved* is half the story the map tells. This closes a loop that was deliberately half-built: the capture side shipped, the display side was deferred. All data will exist from day one of Denmark — the earlier this ships, the more of the trip benefits.
|
||||||
|
**Downsides:** Connector styling interacts with the GPX-vs-connector suppression logic (`force_connect`, same-file proximity checks) — needs care in `MapUtils.initEntryMap`; `js/map.js` rebuild via `make build-assets`.
|
||||||
|
**Confidence:** 85%
|
||||||
|
**Complexity:** Low–Medium
|
||||||
|
|
||||||
|
### 4. The "Today" view (resume the curated home)
|
||||||
|
|
||||||
|
**Description:** Resume the paused curated-home brainstorm with a sharper frame: the active-trip home is the page family checks daily, so lead with *now* — a pulsing last-position marker, "Day 12 · Aarhus · 340 km so far", the latest entry, the latest story, then the full feed/map below. Marker→popup preview (already sketched in the paused brainstorm) makes the map the navigation surface.
|
||||||
|
|
||||||
|
**Axis:** Following along / Reading the feed
|
||||||
|
**Basis:** direct: the curated-home brainstorm exists and is paused at the layout question (hero+stats / map / latest entry / latest story / CTA). external: Polarsteps' follow screen is exactly this — current position + day counter first, log second.
|
||||||
|
**Rationale:** The home page is the URL family will have. Today it renders the same feed chrome as the trip page; a "where is he *now*" lead answers the question every visitor actually arrives with, in one glance, and gives repeat visits a reason.
|
||||||
|
**Downsides:** It's a design decision as much as a build — the brainstorm needs finishing first; risks scope creep against the shared `trip-feed-col` partial (keep the partial single-purpose, add a curated lead above it rather than forking it).
|
||||||
|
**Confidence:** 65%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 5. Per-photo captions
|
||||||
|
|
||||||
|
**Description:** Give photos one-line captions: store per-image captions in Grav media metadata (`<file>.meta.yaml`), add a caption field to the edit-mode photo editor grid (tap a thumbnail → caption input, persisted via the media API), render as museum-style wall text in the feed and lightbox, and use it as real `alt` text (replacing the filename placeholder in `data-alt`).
|
||||||
|
|
||||||
|
**Axis:** Reading the feed
|
||||||
|
**Basis:** direct: `data-alt` currently carries the filename as a placeholder; per-image captions were explicitly deferred "pending Mischa's decision". reasoned: photos carry most of the feed's content weight; a single line of context ("the ferry that almost left without me") is the cheapest possible narrative upgrade and doubles as accessibility.
|
||||||
|
**Rationale:** Between a bare photo grid and a written story there is nothing today; captions are the missing middle register — and they make the eventual printed book/recap dramatically better.
|
||||||
|
**Downsides:** Captioning is one more thing to do on the road (keep it optional and editable later); `.meta.yaml` sidecars must survive the `photo-NN` renumber pipeline (`PhotoRenumberer` currently renames files — sidecars need to move with them, and `deleteUnlistedImages` already deletes them).
|
||||||
|
**Confidence:** 70%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 6. Trip Wrapped recap page
|
||||||
|
|
||||||
|
**Description:** An auto-generated end-of-trip recap: days on the road, total km (GPX-exact where available), entries written, photos taken, countries/towns visited, transport-mode split, biggest climbing day — rendered as a shareable, designed page per trip (`/trips/<slug>/recap` or an inline trip-page section that unlocks when the trip ends). Extension later: print-CSS → the Polarsteps-style trip book.
|
||||||
|
|
||||||
|
**Axis:** After the trip
|
||||||
|
**Basis:** external: Spotify Wrapped / Strava Year in Sport demonstrate the format's shareability; Polarsteps' printed travel book is its flagship post-trip product. direct: the stats machinery (per-file GPX aggregation, cycling stats, haversine fallback) already exists in `initTripStats`.
|
||||||
|
**Rationale:** The site already computes most of these numbers live; a recap reuses them as a keepsake and gives every finished trip a satisfying capstone that the trip page (an infinite feed) doesn't provide. Slovenia/Italy/US archives get retroactive value.
|
||||||
|
**Downsides:** Needs the full-res photo re-import (backlog) before a *printed* extension is worthwhile; design effort is the real cost — a half-designed recap undercuts the point.
|
||||||
|
**Confidence:** 65%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
### 7. Komoot route pull in gpx-manager
|
||||||
|
|
||||||
|
**Description:** Paste a Komoot tour URL into `/gpx-manager` and the server fetches the GPX (`api.komoot.de` returns GPX per tour ID) and saves it to the trip page — replacing the export→download→upload dance after each riding day.
|
||||||
|
|
||||||
|
**Axis:** On-the-road posting
|
||||||
|
**Basis:** direct: `docs/working/backlog.md` names this with the API endpoint; the gpx-manager UI, slugification, and media API plumbing all exist.
|
||||||
|
**Rationale:** On a cycling trip the GPX step is *daily* friction; this collapses it to a paste. Server-side fetch also sidesteps mobile-browser download/upload juggling.
|
||||||
|
**Downsides:** Auth requirements for non-public tours are unresearched (backlog says the same); Komoot's API is unofficial — could break mid-trip, so the manual upload path must remain first-class.
|
||||||
|
**Confidence:** 60%
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rejection Summary
|
||||||
|
|
||||||
|
| # | Idea | Reason Rejected |
|
||||||
|
|---|------|-----------------|
|
||||||
|
| 1 | Offline-first `/post` (service-worker queue) | Cost ≫ value: queued multipart uploads vs sessions/nonces is genuinely hard, Denmark coverage is good, and localStorage drafts already protect the text — too risky days before departure |
|
||||||
|
| 2 | Retire entry permalink + add `#anchor` deep links | Already a tracked backlog item; cleanup, not a product direction |
|
||||||
|
| 3 | Auto-story scaffold from a date range | Premature — story authoring tooling is deliberately deferred until real stories have been written; revisit with material in hand |
|
||||||
|
| 4 | No-account emoji reactions on entries | Adds the site's first anonymous public **write** endpoint (abuse/rate-limit/storage surface) right before departure; worth revisiting post-trip as the only "return channel" idea |
|
||||||
|
| 5 | Printed trip book (standalone) | Folded into idea 6 as its extension — the recap is the shippable first step and the book depends on the full-res re-import |
|
||||||
|
| 6 | Full-res pixelfed re-import + srcset | Enabler already tracked in the backlog, not an idea in itself; sequence it before any print/keepsake work |
|
||||||
|
| 7 | Distribution foundation (RSS+OG+sitemap bundle) | Duplicate of idea 1, which stages the same work |
|
||||||
|
| 8 | travel-memories on-trip cadence | Workflow practice with the existing app; nothing to build |
|
||||||
|
| — | axis: story mode | No survivors — deliberate gap: story tooling stays deferred until the first real stories exist (only candidate was rejection #3) |
|
||||||
@@ -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.**
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# docs/working/ — work in flight
|
||||||
|
|
||||||
|
Everything here is a live working document: specs being built from, plans being executed, notes from sessions in progress. Once something is finished it stays (as a record) rather than being deleted — the `**Status:**` line is how you tell the difference.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `specs/` | Design docs — the *what* and *why*, written before a plan. Named `YYYY-MM-DD-<topic>-design.md` |
|
||||||
|
| `plans/` | Implementation plans — the ordered *how*, with a status line. Named `YYYY-MM-DD-<topic>.md` |
|
||||||
|
| `milestones/` | Milestone scope documents (`milestone-1.md` … ) |
|
||||||
|
| `qa/` | Test plans, QA results, readiness audits |
|
||||||
|
| `handovers/` | Session handover notes — context for picking up unfinished work |
|
||||||
|
| `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` | **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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan status convention
|
||||||
|
|
||||||
|
Every plan in `plans/` carries a `**Status:**` line immediately after its title heading. This is the single place a plan's state is recorded — there is no separate tracker.
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `📋 Not started` | Plan written and reviewed; no work begun yet |
|
||||||
|
| `🔄 In progress — <note>` | Actively being worked on. The note says where it stopped, so anyone (or any session) can resume |
|
||||||
|
| `⏸️ Deferred — <reason>` | Intentionally postponed. Still valid, just not now — the reason matters more than the status |
|
||||||
|
| `✅ Complete (YYYY-MM-DD)` | Done and shipped. The date is when it landed, not when the plan was written |
|
||||||
|
| `❌ Abandoned — <reason>` | Won't be implemented. Kept so the decision (and its reasoning) is not re-litigated later |
|
||||||
|
|
||||||
|
Notes on using it:
|
||||||
|
|
||||||
|
- **A trailing note after `✅ Complete` is normal and encouraged** for anything non-trivial — what actually shipped, what was deferred, which commit or environment it landed in. Several plans here carry a paragraph.
|
||||||
|
- **`Deferred` is not `Abandoned`.** Deferred means "still want this"; abandoned means "decided against it". Keeping them distinct is the whole point of having both.
|
||||||
|
- **Update the status when the work lands**, not later. A plan whose status lags reality is worse than no plan, because it is trusted.
|
||||||
|
|
||||||
|
### Asking Claude what's open
|
||||||
|
|
||||||
|
Claude reads these statuses directly (the convention is also in [`../../CLAUDE.md`](../../CLAUDE.md), so it applies without being asked). When asked what's open it will surface `Not started` and `In progress`, show `Deferred` items with the label made explicit, and leave out `Complete` and `Abandoned` unless you ask for them. It sets the status to `✅ Complete (YYYY-MM-DD)` on finishing a plan.
|
||||||
|
|
||||||
|
A quick manual sweep of the same thing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rH '^\*\*Status:\*\*' docs/working/plans/ | grep -v 'Complete\|Abandoned'
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
Ideas and improvements not yet planned or scheduled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production — remaining items
|
||||||
|
|
||||||
|
- [x] Prod Twig prod-mode (`cache: true`, `debug/auto_reload: false`) — applied as a per-environment override via `make remote-apply-env-prod` (source: `deploy/env/prod/system.yaml`); committed `system.yaml` stays dev
|
||||||
|
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on)
|
||||||
|
- [x] Confirm `/post` requires login — verified on prod (returns the login gate to unauthenticated visitors)
|
||||||
|
- [ ] Register at carto.com and review terms for production traffic
|
||||||
|
- [ ] Update `GRAV_VERSION` in `.env.prod` to `2.0.4` (was stale `2.0.0-rc.10`; fixed on the running server via self-upgrade, but a future fresh install would repeat the RC)
|
||||||
|
- [ ] git-sync on prod: install, add encrypted token, apply `folders:` fix, enable after first content round-trip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hero-image cleanup (journal)
|
||||||
|
|
||||||
|
The `hero_image` field was removed from the post form (journal heroes now come
|
||||||
|
from the first uploaded photo). Follow-up: purge the now-unused field from the
|
||||||
|
journal entity end-to-end.
|
||||||
|
|
||||||
|
- [ ] **Remove hero from the journal entity** — drop `hero_image` from the entry blueprint/template so journal entries no longer carry or reference it (journal rendering already uses `entry.media.images|first`)
|
||||||
|
- [ ] **Remove hero from posts + demo content** — strip `hero_image` frontmatter from existing journal entries and the `italy-2026-demo` seed content (`user/docs/demo/`), then re-run `make demo-load`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Journal entry detail page
|
||||||
|
|
||||||
|
- [ ] **Retire the journal-entry detail page** — the trip/home feed already renders each entry's full body inline (`entry.content|raw` in `partials/entry-journal.html.twig`), so the standalone `entry.html.twig` route per journal entry is largely redundant. Consider removing the route/permalink for journal entries. **Journal only** — stories are full standalone pages and keep their detail view. (Surfaced during the front-end edit brainstorm; unrelated to edit/delete itself.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content quality — luxury improvements (much later)
|
||||||
|
|
||||||
|
- [ ] **Re-import pixelfed photos at full resolution** — the current import pulled pixelfed's optimised web renditions, so imported images cap at ~1440px on the long edge (portraits are 700–1200px wide). This is fine for the feed and 1x banners, but the retina cover 2x only kicks in for genuinely wide (≥1440px) sources, so auto-picked trip banners are currently 1x-only. Find the original high-quality versions in the local filesystem and re-import them (or point the pipeline at the originals rather than the pixelfed web renditions). Purely a quality upgrade — no functional gap; future content shot/stored at full res won't have this ceiling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPX Manager (`/gpx-manager`)
|
||||||
|
|
||||||
|
- [ ] **Polish the UI** — the current design is functional but bare; align with the Field Notes aesthetic, add better empty states, drag-and-drop upload area
|
||||||
|
- [ ] **Link from Admin2** — Admin2 is a compiled SPA so we can't inject a sidebar link; options: (1) add a link to the site's nav when logged in, (2) a bookmarklet, or (3) wait for Admin2 to support plugin-contributed sidebar entries
|
||||||
|
- [ ] **Komoot integration** — explore how to pull GPX routes directly from Komoot without a manual export step. Komoot has an API (`api.komoot.de`) that returns GPX for a tour given its ID. Could be: a field on the GPX manager where you paste a Komoot tour URL/ID and it fetches + saves server-side, or a script run via `make`. Worth researching auth requirements (public tours may not need auth).
|
||||||
@@ -9,6 +9,12 @@ Backlog of confirmed bugs with root cause analysis and implementation spec for t
|
|||||||
**Status:** fixed 2026-06-18
|
**Status:** fixed 2026-06-18
|
||||||
**Reported:** 2026-06-18
|
**Reported:** 2026-06-18
|
||||||
|
|
||||||
|
> **Follow-up (2026-07-07):** `deleteAll()` alone does not rebuild Grav's
|
||||||
|
> page-tree *index*, so once `/post` gained an edit mode a freshly-posted entry
|
||||||
|
> would 404 on its edit-prefill API lookup. Fixed by also calling
|
||||||
|
> `Cache::invalidateCache()`. See
|
||||||
|
> [`docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`](../solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md).
|
||||||
|
|
||||||
### Symptom
|
### Symptom
|
||||||
|
|
||||||
After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/trips/<active_trip>/dailies` feed or in the Grav Admin panel until the cache is manually flushed.
|
After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/trips/<active_trip>/dailies` feed or in the Grav Admin panel until the cache is manually flushed.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Git Sync Plugin — Setup Notes
|
||||||
|
|
||||||
|
## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
|
||||||
|
|
||||||
|
Prod has a per-environment override directory `user/env/<hostname>/config/`
|
||||||
|
(created for Twig prod-mode — see [`../guides/deploy-cycle.md`](../guides/deploy-cycle.md) →
|
||||||
|
"The env override tree"). **A crucial Grav side effect:
|
||||||
|
once that env dir exists, the Admin panel saves ALL config changes — system and
|
||||||
|
plugin — into the active environment's config tree**, not `user/config/`.
|
||||||
|
|
||||||
|
So on prod, `git-sync.yaml` (configured via Admin) lives at:
|
||||||
|
|
||||||
|
```
|
||||||
|
user/env/intotheeast.com/config/plugins/git-sync.yaml ← here (env tree)
|
||||||
|
user/config/plugins/git-sync.yaml ← NOT here
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
|
||||||
|
- **⚠️ `user/env/` is NOT safe unless gitignored — it is NOT scoped out by the
|
||||||
|
`folders` setting.** An earlier version of this note claimed `user/env/`
|
||||||
|
"never reaches Gitea" because it is outside git-sync's synced folders. **That
|
||||||
|
is wrong and caused a live secret leak (2026-07-05).** git-sync's auto-commit
|
||||||
|
stages files *outside* the configured `folders`; on prod it pushed the whole
|
||||||
|
`user/env/intotheeast.com/config/` tree — JWT secret, CSRF salt, **and the
|
||||||
|
git-sync token + webhook secret** — to Gitea. The fix was to **gitignore
|
||||||
|
`/env/`** (commit `6e8eadb`). So: prod Admin config edits stay server-only
|
||||||
|
*only because `/env/` is now gitignored*, not because of folder scope. Author
|
||||||
|
durable config in the repo, not prod Admin. Full analysis:
|
||||||
|
`docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md`.
|
||||||
|
- **Look in both places.** When inspecting/toggling server config, check
|
||||||
|
`user/config/plugins/<name>.yaml` **and**
|
||||||
|
`user/env/<host>/config/plugins/<name>.yaml` (env wins).
|
||||||
|
- **Tooling is env-path-aware.** `scripts/git-sync-toggle.sh` takes a `WEBROOT`
|
||||||
|
and searches `user/env/*/config/plugins/git-sync.yaml` first, then
|
||||||
|
`user/config/plugins/git-sync.yaml`. `make remote-git-sync-disable/enable-<env>`
|
||||||
|
and `make remote-diag-<env>` use it.
|
||||||
|
|
||||||
|
## Folders format
|
||||||
|
|
||||||
|
Older plugin versions' UI saved the `folders` field as a single comma-string
|
||||||
|
(`- 'pages,config,themes'`), which the plugin iterated as one path, so sync
|
||||||
|
silently did nothing. **git-sync v3.4.4 (installed on prod 2026-07-04) saves it
|
||||||
|
correctly** as separate list items:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
folders:
|
||||||
|
- pages
|
||||||
|
- config
|
||||||
|
- themes
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see the comma-string form on an older version, fix it by editing
|
||||||
|
`git-sync.yaml` directly (at whichever path it lives — see above); do not
|
||||||
|
re-save folders via the Admin UI on the buggy version.
|
||||||
|
|
||||||
|
## Per-install / secret files — must be gitignored (gitignore is the boundary)
|
||||||
|
|
||||||
|
git-sync's auto-commit stages **everything under `user/` that is not
|
||||||
|
gitignored** — the `folders` setting does *not* scope the commit add-set (a
|
||||||
|
2026-07-05 leak proved this by pushing `user/env/**`, outside the configured
|
||||||
|
folders). So `.gitignore` — not folder scope — is the only thing keeping a
|
||||||
|
per-install or secret file off Gitea. Keep all of these gitignored in
|
||||||
|
`user/.gitignore`:
|
||||||
|
|
||||||
|
| Path | Why |
|
||||||
|
|---|---|
|
||||||
|
| `env/` | **whole per-host env tree** — holds the live git-sync token, JWT secret, CSRF salt + all server-side Admin config. Gitignored + untracked 2026-07-05 (commit `6e8eadb`) after it leaked to Gitea. NOT safe on folder scope alone. |
|
||||||
|
| `config/plugins/git-sync.yaml` | encrypted token; server-specific (also lives at env path on prod) |
|
||||||
|
| `config/plugins/api-private.php` | API JWT secret |
|
||||||
|
| `config/security.yaml` | Grav nonces/salts (legacy location) |
|
||||||
|
| `config/versions.yaml` | per-install Grav schema-migration state — differs per env (dev 2.0.4, prod 2.0.7); Grav regenerates it. Untracked 2026-07-04. |
|
||||||
|
| `config/security-private.php` | CSRF/nonce + admin rate-limit signing salt; gitignored + untracked 2026-07-05 (commit 2840018). Each env keeps its own; untracking regenerates prod's salt (one-time admin re-login). |
|
||||||
|
|
||||||
|
> **Why a key inside a *tracked* config file (e.g. `popularity.salt` in `api.yaml`) can't just be stripped** — it regenerates at runtime and boomerangs back via git-sync's `git add -A`. See `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` for the full round-trippable-set model.
|
||||||
|
|
||||||
|
## git-sync config summary (prod, 2026-07-04)
|
||||||
|
|
||||||
|
- `repository: https://git.gorinskat.nl/m038/intotheeast-com-content.git`,
|
||||||
|
`branch: main`, HTTPS + token auth (SSH is Tailscale-only).
|
||||||
|
- `sync.direction: both`, `on_save/on_delete/on_media: true` → prod Admin edits
|
||||||
|
and `/post` push to Gitea; content-repo pushes pull to prod **via webhook**
|
||||||
|
(`/_git-sync`). The webhook is configured in Gitea repo settings (same secret
|
||||||
|
as the test instance).
|
||||||
|
- **Before enabling on a fresh server**, reset the synced folders clean
|
||||||
|
(`make remote-fetch-content-<env>`) so no install-time drift (e.g. a stale
|
||||||
|
`versions.yaml`) gets pushed on the first sync. Toggle with
|
||||||
|
`make remote-git-sync-disable/enable-<env>`.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Session handover — Playwright coverage for the edit-mode photo editor
|
||||||
|
|
||||||
|
> **✅ COMPLETE (2026-07-07) — SUPERSEDED by `2026-07-05` → `2026-07-07-journal-post-form-review-handover-and-qa.md`.**
|
||||||
|
> The requested coverage landed: `tests/ui/post/photo-editor.spec.js` + `edit-mode.spec.js` now
|
||||||
|
> cover the add/delete/reorder happy **and** failure paths (auth-expiry "sign in again" E5/E7,
|
||||||
|
> retry-able delete failure E4/DEL3, prefill-failure ES2/ES3). Verified green: `39 passed` on
|
||||||
|
> `:8091` (2026-07-07). All remaining work (owner UI QA + landing) is tracked in the 2026-07-07
|
||||||
|
> handover. This file is retained for history only — no further action.
|
||||||
|
|
||||||
|
**Date:** 2026-07-05
|
||||||
|
**Branch:** `feat/journal-post-form` (worktree: `.worktrees/journal-post-form`)
|
||||||
|
**Next session goal:** Add Playwright coverage for the edit-mode photo editor add / delete / reorder paths — **especially the failure paths** just implemented, which currently have zero automated coverage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — where things stand
|
||||||
|
|
||||||
|
The photo-editor media-API feature is **code-complete and committed** but **not smoke-tested**. Three review follow-ups landed this session (commit `7ffd75e`) on the edit-mode add/delete/reorder **failure** paths. Those paths are exercised by **no** existing test, so nothing proves the behavioral changes work end-to-end. That's the whole reason for the next session.
|
||||||
|
|
||||||
|
**Do not** push, **do not** bump the submodule pin, and **do not** touch the other-session WIP (see Constraints) until the new tests pass and Mischa says go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git state at handover
|
||||||
|
|
||||||
|
Outer repo (`.worktrees/journal-post-form`):
|
||||||
|
- `HEAD` = `7534d7d test(post-form): expect zero-padded photo-01..NN filenames`
|
||||||
|
- Status: only `M user` — the submodule pin is **intentionally stale** (not bumped mid-feature; per project convention bump once at feature end). **Leave it.**
|
||||||
|
|
||||||
|
`user/` submodule (branch `feat/journal-post-form`):
|
||||||
|
- `HEAD` = `7ffd75e fix(review): surface auth-expiry, harden add-batch rollback, add audit log`
|
||||||
|
- `361a6b4 fix(review): harden photo reorder against data loss + failure-path drift`
|
||||||
|
- `a4432d8 feat(post-form): live photo editor on entry edit (media API + SortableJS)`
|
||||||
|
- **Dirty (DO NOT COMMIT — belongs to a different session):**
|
||||||
|
- `config/plugins/api.yaml`
|
||||||
|
- `config/site.yaml`
|
||||||
|
- `themes/intotheeast/js/src/post-form.css` (a trailing FilePond CSS block)
|
||||||
|
- Nothing pushed on either repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What commit `7ffd75e` changed (the code under test)
|
||||||
|
|
||||||
|
All in the edit-mode photo editor (the `initPhotoEditor` IIFE in
|
||||||
|
`user/themes/intotheeast/js/src/post-form.js`, bundled to
|
||||||
|
`user/themes/intotheeast/js/post/post-form.js`):
|
||||||
|
|
||||||
|
1. **Surfaced auth-expiry.** Replaced the boolean `apiOk` with `apiSend(url, opts, okStatuses)`, which rejects with an `Error` carrying `.status`. A lapsed owner login mid-edit (**401/403**) now shows *"Your login session expired — sign in again, then retry."* instead of a generic "try again". Applies to reorder, delete, and add paths (`editErrorMsg(err, fallback)` picks the copy).
|
||||||
|
2. **Hardened the add-batch rollback (review item #6).** When a post-upload reorder fails, the cleanup DELETEs no longer swallow individual failures. Each rollback DELETE resolves true/false (204/404 = truly gone); any `false` sets `rollbackIncomplete`, producing *"Couldn't finish adding photos and cleanup was incomplete — reload the page and check your photos."* instead of a false "rolled back cleanly". This closes the window where a surviving stock-named file steals the lexicographic cover slot (`media.images|first`).
|
||||||
|
3. **Audit log** on the two owner-only destructive routes in
|
||||||
|
`user/plugins/entry-actions/classes/EntryActionsApiController.php`
|
||||||
|
(`deleteEntry`, `reorderPhotos`) — behaviorally inert, logs owner + slug. Not worth a Playwright test.
|
||||||
|
|
||||||
|
**User-facing strings to assert against** (stable; survive minification):
|
||||||
|
- `login session expired` / `sign in again`
|
||||||
|
- `cleanup was incomplete`
|
||||||
|
- The N-photos-couldn't-be-added count message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The API surface the editor talks to
|
||||||
|
|
||||||
|
- **Add photo:** `POST /api/v1/pages{route}/media` (stock media API, multipart)
|
||||||
|
- **Delete photo:** `DELETE /api/v1/pages{route}/media/{filename}` — editor treats **204 and 404** as success
|
||||||
|
- **Reorder:** `POST /api/v1/entry/{slug}/photos/order`, body `{ "order": ["photo-01.jpg", …] }` — custom scope-guarded route in the `entry-actions` plugin; returns **204**
|
||||||
|
- All requests use `credentials: 'include'` (session-cookie auth).
|
||||||
|
|
||||||
|
Server-side numbering invariant lives in `PhotoRenumberer` (shared by cache-on-save + entry-actions): every on-disk image is renamed `photo-01..NN` zero-padded; the manifest only supplies order, and any unlisted image is appended (never lost).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test harness facts (read before writing specs)
|
||||||
|
|
||||||
|
- **Runner:** Playwright, config at `playwright.config.js`. `testDir: ./tests/ui`. Specs are `*.spec.js`.
|
||||||
|
- **Auth is already solved.** The `setup` project (`tests/ui/auth/auth.setup.js`) logs in with `GRAV_TEST_USER` / `GRAV_TEST_PASS` (from `.env`) and saves `storageState` to `tests/.auth/user.json`; the `chromium` project loads it. **So every test already runs as the authenticated owner** — edit mode is reachable without extra login steps.
|
||||||
|
- **⚠️ Port:** `baseURL` defaults to `http://localhost:8081`, but **this worktree's dev container serves on `:8091`** (`itte_journal_grav`, mapped `8091->80`). Run with `GRAV_BASE_URL=http://localhost:8091` or the specs will hit the wrong container.
|
||||||
|
- **Helpers** (`tests/ui/helpers.js`, exported): `fillEditor`, `waitForPhotoUpload`, `postEntry`, `cleanupEntry`, `findEntry`, `readEntryMd`, `TRACKER_DIR`, `ACTIVE_TRIP_URL`. `findEntry(tag)`/`cleanupEntry(tag)` locate/remove an entry folder on disk — use them to build a fixture entry and to clean up.
|
||||||
|
- **Existing post specs** live in `tests/ui/post/` (`post-form-ux.spec.js`, `post.spec.js`, `validation.spec.js`). They cover the **create** form only — none open `/post?edit=…` or the photo editor. Mirror their style (fixtures at `tests/fixtures/test-photo*.jpg`).
|
||||||
|
- **Global setup/teardown:** `tests/global-setup.js` / `tests/global-teardown.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested test plan for the next session
|
||||||
|
|
||||||
|
Edit mode is `GET /post?edit=<slug>` (verify the exact param against the template). Failure paths need **`page.route()` interception** to force API errors — that's the core technique here.
|
||||||
|
|
||||||
|
1. **Fixture:** post one entry via the create form (or drop a folder), capture its slug, open it in edit mode. Clean up with `cleanupEntry` in `afterAll`.
|
||||||
|
2. **Happy paths** (no interception): add a photo → persists (appears on disk / in grid); delete a photo → gone; drag-reorder → files renamed `photo-01..NN` in new order.
|
||||||
|
3. **Auth-expiry (item #1):** `page.route('**/api/v1/**', r => r.fulfill({ status: 401 }))` on a reorder/delete/add → assert the *"login session expired … sign in again"* copy appears.
|
||||||
|
4. **Incomplete rollback (#6):** let the uploads succeed but force the reorder to fail **and** at least one cleanup DELETE to fail (route-match `DELETE **/media/**` → 500). Assert the *"cleanup was incomplete — reload"* message. This is the highest-value, never-before-tested branch.
|
||||||
|
5. **Delete failure:** force a `DELETE` to 500 → assert *"Couldn't delete that photo. Try again."* and the photo stays in the grid.
|
||||||
|
|
||||||
|
Keep assertions on the **user-facing strings** above, not on minified identifiers.
|
||||||
|
|
||||||
|
### Also pending: manual smoke test
|
||||||
|
Independent of automation, the behavioral changes still want one **manual owner-session pass on `:8091`**: log in, open an entry in edit mode, add/delete/reorder and confirm each persists; then simulate a lapsed session and confirm the "sign in again" copy. If Playwright covers 2–5 above, this becomes a quick confidence check rather than the only verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints (carried from this session — still in force)
|
||||||
|
|
||||||
|
- **Other-session WIP is off-limits.** Do not stage/commit `config/plugins/api.yaml`, `config/site.yaml`, or the FilePond block in `themes/intotheeast/js/src/post-form.css`. If `make build-assets` recompiles `css-compiled/post-form.css` from that dirty source, **revert it**: `git checkout -- themes/intotheeast/css-compiled/post-form.css`.
|
||||||
|
- **Never** read `.env`, `.env.prod`, `.env.test` (pass them to `make`/`compose` only). `GRAV_TEST_USER`/`PASS` live there.
|
||||||
|
- **Only** write inside `travel-blog-intotheeast/` or subfolders.
|
||||||
|
- **Do not** bump the submodule pin or push until the feature is done and Mischa approves.
|
||||||
|
- **Do not** hand-edit the bundle (`js/post/post-form.js`) or `css-compiled/*` — edit `js/src/*` and rebuild with `make build-assets`.
|
||||||
|
- No dev/prod mode switching; fix issues at the app level.
|
||||||
|
- New test files go in the **outer repo** (`tests/` is outer-repo, not the `user/` submodule).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fast start for the next session
|
||||||
|
|
||||||
|
```
|
||||||
|
# worktree root
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.worktrees/journal-post-form
|
||||||
|
|
||||||
|
# confirm the dev container is up on 8091
|
||||||
|
docker ps --format '{{.Names}}\t{{.Ports}}' | grep itte
|
||||||
|
|
||||||
|
# run existing post specs against THIS worktree's container
|
||||||
|
GRAV_BASE_URL=http://localhost:8091 npx playwright test tests/ui/post
|
||||||
|
```
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Journal Post Form — Review Handover & Owner QA
|
||||||
|
|
||||||
|
**Date:** 2026-07-07
|
||||||
|
**Branch:** `feat/journal-post-form` (worktree `.worktrees/journal-post-form`)
|
||||||
|
**State:** Implementation + code-review complete. **Remaining: owner UI QA (Part B) → then landing (Part A §Landing).**
|
||||||
|
|
||||||
|
This doc has two audiences:
|
||||||
|
- **Part A — Handover (Claude → future Claude):** exact branch state, what's committed where, the dual-session/worktree situation, and the landing procedure. Read this first in a fresh session before touching anything.
|
||||||
|
- **Part B — QA checklist (Mischa):** the owner-session UI pass the test harness cannot do (it can't obtain your login). Run on http://localhost:8091.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Handover (Claude → future Claude)
|
||||||
|
|
||||||
|
### What this branch delivers
|
||||||
|
Front-end journal posting + editing, reusing `/post` + `add-page-by-form`:
|
||||||
|
- Create/edit/delete/unpublish entries from the feed (plans `2026-07-04-journal-post-form`, `2026-07-04-frontend-entry-edit`).
|
||||||
|
- In-form photo editor: add (HEIC→JPEG), inline-confirm delete, drag reorder, `photo-01..NN` renumber, first = cover (plan `2026-07-05-photo-editor-media-api`).
|
||||||
|
|
||||||
|
### Commits made in the 2026-07-07 review session (code-review F1–F8)
|
||||||
|
All **local to this worktree's branch** — nothing pushed, no pin bump, no `content-push`.
|
||||||
|
|
||||||
|
**Submodule `user/`** (on `feat/journal-post-form`):
|
||||||
|
- `8db3ffe` — F1/F7: latch cache invalidation (`$cacheInvalidated`) to once-per-submit + info log — `plugins/cache-on-save/cache-on-save.php`
|
||||||
|
- `7f6bf9e` — F4: `initDisclosure` reads each toggle's default from the rendered `[checked]` attribute instead of a `/\[published\]$/` field-name regex; rebuilt bundle — `themes/intotheeast/js/src/post-form.js` + `js/post/post-form.js`
|
||||||
|
|
||||||
|
**Outer repo** (on `feat/journal-post-form`):
|
||||||
|
- `d576487` — F2/F3/F6: shared `createPhotoEntry()` helper; register cleanup **before** the awaited success toast (fixes slow-success entry leak); AE3b disclosure-deviation test — `tests/ui/helpers.js` + 4 specs
|
||||||
|
- `e10496a` — F8/F5: BUG-001 Part 2 solution doc + cross-link — `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`, `docs/working/bugs-and-fixes.md`
|
||||||
|
|
||||||
|
Earlier same-branch commits (prior sessions): outer `d3c1779`, `f4dbac6`; submodule `7775a4e`, `a7bda6e` — create→edit stale-cache fix (`Cache::invalidateCache()`) + H1/M8 skip-with-reason.
|
||||||
|
|
||||||
|
### DO NOT commit — off-limits WIP left dirty on purpose
|
||||||
|
- Submodule: `config/site.yaml` (owner's local `travelling:false` / `active_trip` testing config — `m` dirty is normal), `config/plugins/api.yaml`, `themes/intotheeast/js/src/post-form.css`, `themes/intotheeast/css-compiled/post-form.css` (the two CSS files get touched by `make build-assets` rebuilding from the in-progress `post-form.css` source — not part of this work).
|
||||||
|
- Outer: the `user` gitlink (`M user` — pin **intentionally not bumped**).
|
||||||
|
|
||||||
|
### Dual-session / worktree situation (verified 2026-07-07)
|
||||||
|
Two Claude sessions run in parallel. **Local isolation is real and proven:**
|
||||||
|
- This worktree's `user/` git dir: `.git/worktrees/journal-post-form/modules/user`, branch `feat/journal-post-form` — its **own object store**. The other session's branch (`feat/trip-description-hero`) is not visible here and its HEAD commit does not exist in this object store.
|
||||||
|
- Other checkouts: `content-fixes` worktree → `user/` on `feat/trip-description-hero`; main checkout → `user/` on `main`.
|
||||||
|
|
||||||
|
**The only shared resource is Gitea `origin`** (the `intotheeast-com-content.git` content repo) + the single outer pin + outer `main`. Collisions can *only* happen at push / merge-to-main / pin-bump. **Therefore: never push, never `content-push`, never bump the pin from a worktree mid-flight. Landing is a single deliberate step the owner triggers.**
|
||||||
|
|
||||||
|
### Landing procedure (owner-triggered, once QA passes) — do NOT run unprompted
|
||||||
|
1. **Owner UI QA** (Part B) passes.
|
||||||
|
2. **Submodule first.** Reconcile `user/` `feat/journal-post-form` → `user/` `main` (merge; prefer the merge commit, not the branch tip). Push `user/` to Gitea → this triggers the production content pull via webhook.
|
||||||
|
3. **Bump the pin.** In the outer repo, stage the `user` gitlink pointing at that `user/` `main` merge commit (must already be pushed). Commit.
|
||||||
|
4. **Outer.** Merge outer `feat/journal-post-form` → outer `main`, push.
|
||||||
|
5. **Plugin patch.** `add-page-by-form` is GPM-managed/git-ignored; the Grav-2.0 header fix lives at `deploy/patches/add-page-by-form-grav2-header.patch`. Re-apply with `make apply-plugin-patches` after any plugin (re)install on the server — R9 (add photos on edit) breaks without it.
|
||||||
|
6. **Env override.** Re-run `make remote-apply-env-prod` after any fresh install (prod Twig cache settings live only in `user/env/<host>/`, not synced by content).
|
||||||
|
7. **Pre-launch smoke** (CLAUDE.md): submit one post via `/post` on prod, confirm it appears in the trip feed immediately (verifies cache-on-save under `twig.cache:true`).
|
||||||
|
|
||||||
|
### Running the tests
|
||||||
|
- Full post suite: `GRAV_BASE_URL=http://localhost:8091 npx playwright test post/ --reporter=line` (20 pass as of 2026-07-07).
|
||||||
|
- After any `js/src/*` edit: `make build-assets` (never hand-edit `js/post/*` or `css-compiled/*`).
|
||||||
|
- `setup` project logs in → `tests/.auth/user.json`; specs run as the authenticated owner (anon-view clears storageState).
|
||||||
|
|
||||||
|
### Verified vs NOT verified
|
||||||
|
- **Verified (harness):** 20 post specs on :8091 incl. ES1 (create→edit round-trip, the cache fix), AE3b (disclosure deviation), delete flow, anon/draft visibility, HEIC convert, photo renumber; `PhotoRenumberer` unit tests.
|
||||||
|
- **NOT verifiable by harness (needs owner login / real device):** interactive photo add/delete/**drag** reorder in edit mode, on-device **touch**-drag, combined add+delete+reorder in one save. → **This is Part B.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — Owner QA checklist (Mischa)
|
||||||
|
|
||||||
|
Run logged in as the owner on **http://localhost:8091** (worktree dev server). Check each box; if any fails, stop and note it — do not land.
|
||||||
|
|
||||||
|
### Create
|
||||||
|
- [ ] Post an entry with **1 photo** → success toast; entry appears in the active-trip feed **immediately**; that photo is the cover.
|
||||||
|
- [ ] Post an entry with **multiple photos including a HEIC** → HEIC converts to JPEG, all attach, first image is the cover.
|
||||||
|
- [ ] Post with **Published = No** (under "More options") → entry shows a **Draft badge** to you; open the same trip page in a **private/incognito window** → the draft is **absent**.
|
||||||
|
|
||||||
|
### Edit (open an entry's Edit link from the feed)
|
||||||
|
- [ ] Change **title + body**, Save → feed reflects the new title/body.
|
||||||
|
- [ ] Open the entry you *just* created for editing → **no "this entry no longer exists"** banner (the create→edit cache fix).
|
||||||
|
- [ ] **Add** a new photo on edit → attaches and renumbers; regressions don't drop existing photos.
|
||||||
|
- [ ] **Delete** a photo via the inline confirm → removed from disk; if you removed the first, the **cover updates** to the new first.
|
||||||
|
- [ ] **Reorder** photos by **mouse drag** → order persists after Save; first = cover on the feed.
|
||||||
|
- [ ] **Combined** in one save: add + delete + reorder → all three land correctly (cover=first, existing preserved, dropped removed).
|
||||||
|
|
||||||
|
### On-device
|
||||||
|
- [ ] On a **phone or tablet**, edit an entry and **touch-drag** to reorder photos → works and persists.
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
- [ ] Delete an entry from the feed (Delete → Confirm) → card disappears and the folder leaves disk.
|
||||||
|
- [ ] Delete → **Cancel** → nothing removed.
|
||||||
|
|
||||||
|
When every box is checked, hand back to a fresh Claude session and point it at **Part A §Landing procedure**.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Documentation Reconciliation — Handover
|
||||||
|
|
||||||
|
**Date:** 2026-07-25
|
||||||
|
**Branch:** `feat/docs-reconcile` (worktree `.worktrees/docs-reconcile`, dev server :8091)
|
||||||
|
**State:** Work **complete and pushed**. Remaining: **open the PR** (needs one interactive command) and **decide on 7 logged recommendations**. No code was changed; no `user/` commits were made.
|
||||||
|
|
||||||
|
Two audiences:
|
||||||
|
- **Part A — Claude → future Claude:** exact state, the one trap that nearly caused a regression, and what must not be "tidied up".
|
||||||
|
- **Part B — Mischa:** the two things only you can do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A — Handover (Claude → future Claude)
|
||||||
|
|
||||||
|
### What this branch delivers
|
||||||
|
|
||||||
|
A whole-repo reconciliation of the documentation against the code, after five weeks in which the app
|
||||||
|
changed and the docs did not. **The code was treated as the source of truth throughout.**
|
||||||
|
|
||||||
|
Three deliverables:
|
||||||
|
|
||||||
|
1. **`docs/reference/superseded-decisions.md`** (new) — the supersession ledger. 14 reversals, each with
|
||||||
|
what was planned, where, what is true now, when it changed, and why. Plus a "decisions that were
|
||||||
|
*not* reversed" section so old planning docs don't all read as suspect.
|
||||||
|
2. **Inline `> **Superseded …**` notes** at each stale claim, in the 4 milestone docs, `summary.md`,
|
||||||
|
`pm-analysis.md`, and `design-system-light.md`. Reuses the repo's existing `> History:` /
|
||||||
|
`> **Changed 2026-07:**` patterns — do not invent a third convention.
|
||||||
|
3. **Corrections to the nine present-tense docs** (`CLAUDE.md`, `README.md`, `CONCEPTS.md`,
|
||||||
|
`docs/README.md`, `docs/working/README.md`, `reference/architecture.md`,
|
||||||
|
`reference/design-system.md`, `reference/design-system-light.md`, `guides/posting.md`).
|
||||||
|
|
||||||
|
Plus the compounded learning (`docs/solutions/conventions/reconciling-drifted-docs-tense-tiering-and-a-supersession-ledger.md`),
|
||||||
|
the design/verification record (`docs/working/specs/2026-07-25-docs-reconciliation-design.md`), and the
|
||||||
|
unacted findings (`docs/working/2026-07-25-doc-drift-recommendations.md`).
|
||||||
|
|
||||||
|
### The governing idea — do not undo this
|
||||||
|
|
||||||
|
Scope was split by **tense**, because the halves need opposite treatment:
|
||||||
|
|
||||||
|
| Kind | Staleness is | Treatment |
|
||||||
|
|---|---|---|
|
||||||
|
| Present-tense: `CLAUDE.md`, `reference/`, `guides/`, `README.md`, `CONCEPTS.md` | a **defect** | corrected against the code |
|
||||||
|
| Past-tense: `plans/`, `specs/`, `milestones/`, `summary.md`, `pm-analysis.md` | **correct and expected** | annotated only, **never rewritten** |
|
||||||
|
|
||||||
|
**A completed plan is supposed to be stale — that is what makes it a record.** If a future session is
|
||||||
|
tempted to "finish the job" by rewriting the milestone docs or the 41 completed plans to match today's
|
||||||
|
code, that is the wrong instinct and destroys the audit trail. The banners are the fix.
|
||||||
|
|
||||||
|
### Commits (all on `feat/docs-reconcile`, all pushed)
|
||||||
|
|
||||||
|
`origin/feat/docs-reconcile` == local `HEAD` == `7c9c140`.
|
||||||
|
|
||||||
|
- `8202d2a` — the reconciliation: ledger + inline notes + the nine present-tense corrections
|
||||||
|
- `d946eaa` — compounded learning into `docs/solutions/conventions/` + new `CONCEPTS.md` Documentation cluster
|
||||||
|
- `7c9c140` — **merge of `main`** (see the trap below)
|
||||||
|
|
||||||
|
Net diff vs `main` is 19 files, +897/−60, **no deletions**, and the submodule gitlink is byte-identical
|
||||||
|
to `main`.
|
||||||
|
|
||||||
|
### ⚠️ The trap — `main` moved 13 commits mid-audit
|
||||||
|
|
||||||
|
This is the most important thing on this page.
|
||||||
|
|
||||||
|
While the audit ran, the location-override work was merged into the outer repo, advancing `main` by 13
|
||||||
|
commits. **It independently fixed two of the audit's own findings:**
|
||||||
|
|
||||||
|
- `829325c` — carved out the single-map-path exception for `js/src/location-map.js` in `CLAUDE.md`
|
||||||
|
- `a517331` — set `2026-07-23-post-form-location-override.md` to `✅ Complete`
|
||||||
|
|
||||||
|
Had this branch been merged without first merging `main` in, it would have **reverted both**. Both
|
||||||
|
conflicts were resolved **in `main`'s favour** (its wording was better informed in each case), and the
|
||||||
|
audit's own notes were then corrected to stop claiming credit for fixes it did not make.
|
||||||
|
|
||||||
|
**If you pick this up on 2026-07-26 or later, `main` may have moved again. Do this first:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .worktrees/docs-reconcile
|
||||||
|
git fetch origin
|
||||||
|
git log --oneline HEAD..origin/main # anything here? then merge before touching the PR
|
||||||
|
git merge origin/main # read each conflict as a finding, not a chore
|
||||||
|
```
|
||||||
|
|
||||||
|
Two rules that came out of this, now recorded in the learning doc §6:
|
||||||
|
- **Re-check the baseline before publishing, not only before starting.** A long audit races the work it audits.
|
||||||
|
- **When the incoming version is better, take it wholesale.** An audit has no special authority over the work it audits.
|
||||||
|
|
||||||
|
### Submodule situation
|
||||||
|
|
||||||
|
- `user/` in this worktree is on branch `feat/docs-reconcile` at **`dd19995`** — I moved it off the
|
||||||
|
outer repo's older pin (`02fa4e9`) so the audit ran against the state that actually runs. Auditing
|
||||||
|
the pin would have reported a shipped feature as unbuilt.
|
||||||
|
- **No commits were made inside `user/`.** `git -C user status` is clean. Nothing to push there.
|
||||||
|
- The merge commit **preserves `main`'s pin bump to `dd19995`**. The no-gitlink-commit rule in
|
||||||
|
`CLAUDE.md` is about not bumping the pin as a side effect of routine work — not about discarding a
|
||||||
|
bump `main` already made. An earlier `git reset -- user` here had silently reverted it to the old pin;
|
||||||
|
that was caught and fixed. **Verify before any future commit on this branch:**
|
||||||
|
`git diff main..HEAD -- user` must be empty.
|
||||||
|
|
||||||
|
### What was verified, and how
|
||||||
|
|
||||||
|
Nothing was inferred from prose. Full table in the spec doc; the load-bearing ones:
|
||||||
|
|
||||||
|
| Claim | Verified against |
|
||||||
|
|---|---|
|
||||||
|
| Nav labels | `partials/base.html.twig:27-31` |
|
||||||
|
| Asset sources → outputs | the theme's `package.json` build script |
|
||||||
|
| `css-compiled/` provenance | CSS imports in `js/src/*.js`; `assets.addCss` in `base.html.twig:7-8` |
|
||||||
|
| No light mode | absence of `prefers-color-scheme` / `data-theme` **and** of light hex values in `css/` |
|
||||||
|
| Photo rules (1–6, required) | `user/pages/02.post/post-form.md:35-46` |
|
||||||
|
| `entry-actions` routes | `entry-actions.php:63-73` |
|
||||||
|
| Env-suffix rule | `Makefile` `guard-env:41-43` + the `make-env-target` macro at `:45-46` |
|
||||||
|
| `travel-memories` removal | `git log -- services/` → `a80b0a9`, then a real `docker compose build` failure |
|
||||||
|
|
||||||
|
**One finding was withdrawn** after reading `package.json`: the asset table lists esbuild *entry
|
||||||
|
points*, so imported-only sources (`api-utils.js`, `location-map.js`, `map-style.js`, `post-form.css`)
|
||||||
|
are correctly absent from it. If a future pass "fixes" that table by adding them, it is reintroducing a
|
||||||
|
non-defect.
|
||||||
|
|
||||||
|
Checks run before pushing: no conflict markers anywhere; `ce-compound`'s frontmatter validator exits 0;
|
||||||
|
all relative links resolve. **One link check hit is a known false positive** —
|
||||||
|
`../reference/architecture.md` inside a ```diff fence in the learning doc, quoting
|
||||||
|
`docs/working/README.md`'s literal content, where that path is correct.
|
||||||
|
|
||||||
|
### Not done, deliberately
|
||||||
|
|
||||||
|
- **The PR is not open.** `tea` requires an interactive TTY for the SSH passphrase. Command in Part B.
|
||||||
|
- **None of the 7 recommendations were acted on**, per instruction. They are decisions, not chores —
|
||||||
|
several are behaviour changes that would have made this diff unreviewable as documentation.
|
||||||
|
- **No `user/` changes**, including the `italy-2025` demo fixtures (recommendation P6).
|
||||||
|
|
||||||
|
### Do not, without being asked
|
||||||
|
|
||||||
|
- Rewrite any past-tense doc to match current code — annotate instead.
|
||||||
|
- Act on `docs/working/2026-07-25-doc-drift-recommendations.md`. **P5 in particular is booby-trapped:**
|
||||||
|
removing `shortcode-gallery-plusplus` may take `shortcode-core` with it (it is a GPM dependency and is
|
||||||
|
*not* in `plugins.txt`), which would break stories. Add `shortcode-core` explicitly first, in its own
|
||||||
|
step, then remove and verify a story page renders.
|
||||||
|
- Bump the submodule pin beyond preserving `main`'s.
|
||||||
|
- `content-push` — nothing here touches content.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Worktree dev server on **http://localhost:8091** (`itte_docs-reconcile_grav`, from `.worktree-env`).
|
||||||
|
Nothing in this branch needs a running server — it is documentation only — so tearing it down is safe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make worktree-rm NAME=docs-reconcile # compose down → submodule deinit → worktree remove → prune
|
||||||
|
```
|
||||||
|
|
||||||
|
The branch is pushed, so removing the worktree loses nothing. Note `main`'s `cfe070e` fixed
|
||||||
|
`worktree-rm` so it no longer unregisters `user/` for the main checkout — that fix is in this branch via
|
||||||
|
the merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B — For Mischa
|
||||||
|
|
||||||
|
### 1. Open the PR (one command)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.worktrees/docs-reconcile
|
||||||
|
tea pr create --login git.gorinskat.nl --repo m038/intotheeast-com \
|
||||||
|
--head feat/docs-reconcile --base main \
|
||||||
|
--title "docs: reconcile documentation against the code; add a supersession ledger" \
|
||||||
|
--description "$(cat /home/mischa/.claude-work/jobs/18e4d444/tmp/pr-body.md)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in the browser: https://git.gorinskat.nl/m038/intotheeast-com/pulls/new/feat/docs-reconcile
|
||||||
|
|
||||||
|
⚠️ The body file lives in a Claude job directory and disappears when that job is deleted. If it is
|
||||||
|
already gone, the PR description is reconstructable from
|
||||||
|
`docs/working/specs/2026-07-25-docs-reconciliation-design.md` plus the recommendations doc.
|
||||||
|
|
||||||
|
### 2. Decide on the recommendations
|
||||||
|
|
||||||
|
`docs/working/2026-07-25-doc-drift-recommendations.md`, ordered. The first is a live bug:
|
||||||
|
|
||||||
|
| | What | Why it needs you |
|
||||||
|
|---|---|---|
|
||||||
|
| **P1** | `make start` / `make setup` fail on any clean checkout — `docker-compose.yml` still builds `travel-memories`, whose source you removed in `a80b0a9` | Three options (delete the service / put it behind a compose profile / re-point `build`). It is your call whether that project ever runs alongside Grav again. **It works on your machine only because a pre-removal Docker image is cached** — it breaks in every new worktree and after any `docker image prune` |
|
||||||
|
| **P2** | A repeatable `make docs-check` | The half you deferred. Every defect this pass found was mechanically checkable, so this is what stops the drift recurring. Sequence it *after* P1, or the first thing it reports is P1 |
|
||||||
|
| **P3** | Plan status can silently lag a merge | Three options, cheapest is naming the plan path in the feature commit |
|
||||||
|
| **P4** | `README.md` was designated authoritative for a list it did not hold | Structural: either generate the command tables from the `Makefile`, or soften the `CLAUDE.md` pointer |
|
||||||
|
| **P5** | `shortcode-gallery-plusplus` has no consumer | ⚠️ See the booby-trap warning in Part A before touching it |
|
||||||
|
| **P6** | `italy-2025` demo fixtures still ship `map.md` / `stats.md` for retired views | A `user/` submodule change, so it was out of scope here |
|
||||||
|
| **P7** | Structural notes — e.g. whether `design-system-light.md` should move out of `reference/`, since it documents a theme that does not exist | Judgement calls, both defensible |
|
||||||
|
|
||||||
|
### 3. Worth knowing
|
||||||
|
|
||||||
|
The three findings most likely to have bitten you in practice:
|
||||||
|
|
||||||
|
- **`posting.md` would have failed if followed** — it said photos were optional; they are required, 1–6.
|
||||||
|
- **Every `make remote-*` command in `README.md` was unrunnable** — all documented without the
|
||||||
|
`-test`/`-prod` suffix `guard-env` requires. `deploy-cycle.md` had it right the whole time; the defect
|
||||||
|
was a second copy of the knowledge drifting from the first.
|
||||||
|
- **`docs/working/README.md` advertised `summary.md` as "current state"** while `summary.md` describes
|
||||||
|
Leaflet, `/tracker`, `/map` and `/stats`. An index that vouches for a stale doc is worse than the
|
||||||
|
stale doc, because it defeats your judgement before it engages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/working/specs/2026-07-25-docs-reconciliation-design.md` — design, scope rationale, and the full verification table
|
||||||
|
- `docs/reference/superseded-decisions.md` — the ledger itself
|
||||||
|
- `docs/working/2026-07-25-doc-drift-recommendations.md` — the 7 unacted findings
|
||||||
|
- `docs/solutions/conventions/reconciling-drifted-docs-tense-tiering-and-a-supersession-ledger.md` — the compounded learning
|
||||||
|
- `docs/solutions/conventions/claude-md-content-tiering.md` — the prior learning this extends; flagged as a consolidation candidate if a third documentation learning appears
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Mobile UX Session Learnings — 2026-06-22
|
||||||
|
|
||||||
|
Discoveries from the mobile polish session (stat scaling, map fullscreen, panel toggles, shared partials).
|
||||||
|
|
||||||
|
## MapLibre GL JS v4 — Attribution starts expanded despite compact: true
|
||||||
|
|
||||||
|
**Problem:** `new maplibregl.AttributionControl({ compact: true })` renders a `<details>` element. In MapLibre v4, this element has `open` set after `map.on('load')` fires, so the attribution panel starts expanded even though `compact: true` was passed.
|
||||||
|
|
||||||
|
**Fix:** In the `load` handler, explicitly remove the `open` attribute:
|
||||||
|
```js
|
||||||
|
map.on('load', function () {
|
||||||
|
var attrib = map.getContainer().querySelector('.maplibregl-ctrl-attrib');
|
||||||
|
if (attrib) attrib.removeAttribute('open');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also:** To avoid the default attribution control conflicting with a custom button in `bottom-right`, disable it in the constructor and add it manually to `bottom-left`:
|
||||||
|
```js
|
||||||
|
var map = new maplibregl.Map({ ..., attributionControl: false });
|
||||||
|
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Panel Animation — max-height beats grid-template-rows: 0fr
|
||||||
|
|
||||||
|
**Problem:** `grid-template-rows: 0fr → 1fr` transition fails when the direct grid child has `overflow: hidden`. The child creates a Block Formatting Context (BFC) that prevents `0fr` from collapsing to zero height.
|
||||||
|
|
||||||
|
**Fix:** Use `max-height` transition on the outer container:
|
||||||
|
```css
|
||||||
|
.panel {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.4s ease;
|
||||||
|
}
|
||||||
|
.panel.is-open {
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fluid Font Sizing with clamp()
|
||||||
|
|
||||||
|
```css
|
||||||
|
.stat-value {
|
||||||
|
font-size: clamp(2rem, 6vw, var(--text-3xl));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `clamp(min, preferred, max)`: scales linearly between min and max
|
||||||
|
- `6vw` at 333px viewport = 20px = 1.25rem, but floor is 2rem (32px)
|
||||||
|
- Keep labels at `--text-xs` (0.75rem) intentionally — the contrast makes values pop
|
||||||
|
|
||||||
|
## CSS Grid — Spanning the Lone Last Item in a 2-Column Grid
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.my-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.my-grid .item:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `minmax(0, 1fr)` — strictly equal columns (bare `1fr` has a hidden `auto` minimum)
|
||||||
|
- `:last-child:nth-child(odd)` — matches an item that is both last and in an odd position
|
||||||
|
|
||||||
|
## PhotoSwipe v5 — Correct Element for CSS Animations
|
||||||
|
|
||||||
|
**Problem:** `pswp.currSlide.el` is `undefined` in PhotoSwipe v5.
|
||||||
|
|
||||||
|
**Fix:** Use `pswp.currSlide.container` — the DOM wrapper for the current slide:
|
||||||
|
```js
|
||||||
|
var el = pswp.currSlide && pswp.currSlide.container;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('pswp-key-from-right');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mobile Fullscreen Map Pattern
|
||||||
|
|
||||||
|
```css
|
||||||
|
.feed-map-wrap.is-fullscreen {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
height: 100dvh !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
fsBtn.addEventListener('click', function() {
|
||||||
|
var isFs = mapCol.classList.toggle('is-fullscreen');
|
||||||
|
document.body.style.overflow = isFs ? 'hidden' : '';
|
||||||
|
setTimeout(function() { map.resize(); }, 50);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Marker click while fullscreen:** Exit fullscreen first, then scroll after the transition:
|
||||||
|
```js
|
||||||
|
if (isFullscreen) {
|
||||||
|
fsBtn.click();
|
||||||
|
setTimeout(scrollAndHighlight, 450);
|
||||||
|
} else {
|
||||||
|
scrollAndHighlight();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared Twig Partial Pattern
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% include 'partials/feed-map.html.twig' with {
|
||||||
|
'map_entries': map_entries,
|
||||||
|
'map_id': 'feed-map',
|
||||||
|
'map_var': 'feedMap',
|
||||||
|
'link_href': page.parent().url ~ '/map',
|
||||||
|
'card_prefix': 'entry-',
|
||||||
|
'trip_page': trip_page,
|
||||||
|
'show_journey': true
|
||||||
|
} only %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Grav's global Twig functions (`url()`, `theme_var()`) remain available with `only`. Only parent template variables are excluded.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Milestone 2: Template Refactor — Session Brief
|
||||||
|
|
||||||
|
Use this as the starting point for the brainstorm in a new session.
|
||||||
|
Invoke the brainstorming skill (`/brainstorm`) and hand it this file as context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this milestone is about
|
||||||
|
|
||||||
|
The asset pipeline (Milestone 1) is done — CDN dependencies eliminated, JS deduplicated into shared bundles. The templates themselves still have structural problems that make them hard to maintain and extend.
|
||||||
|
|
||||||
|
## Problems to solve
|
||||||
|
|
||||||
|
### 1. `trip.html.twig` mixes three concerns
|
||||||
|
|
||||||
|
Currently ~384 lines after Milestone 1 cleanup. Still mixes:
|
||||||
|
- Twig data-building loops (collecting `map_entries`, building entry lists, GPX URL arrays)
|
||||||
|
- HTML structure (cards, panels, filter bar)
|
||||||
|
- Inline JS (map init, GPX stats block)
|
||||||
|
|
||||||
|
Goal: split into focused, readable sections or partials.
|
||||||
|
|
||||||
|
### 2. `map_entries` loop is duplicated across 4 templates
|
||||||
|
|
||||||
|
Near-identical Twig loop that builds `[{lat, lng, title, slug, url, type, ...}]` appears in:
|
||||||
|
- `trip.html.twig`
|
||||||
|
- `dailies.html.twig`
|
||||||
|
- `stories.html.twig`
|
||||||
|
- `map.html.twig`
|
||||||
|
|
||||||
|
Candidate for a Twig macro so a change only needs to happen once.
|
||||||
|
|
||||||
|
### 3. Stats computation is slow Twig loops
|
||||||
|
|
||||||
|
Country counting, temperature range, days on road — currently computed in Twig on every uncached page load. At 60–80 entries this is noticeable.
|
||||||
|
|
||||||
|
**Stronger option:** Move to a small PHP Grav plugin that exposes a single `{{ trip_stats }}` Twig variable. PHP loops are significantly faster than Twig loops. This is also the prerequisite for showing stats on other pages (homepage, story pages) in future.
|
||||||
|
|
||||||
|
### 4. Date range formatting duplicated
|
||||||
|
|
||||||
|
Same date formatting logic in both `story.html.twig` and `stories.html.twig`.
|
||||||
|
|
||||||
|
### 5. Latent bugs on inactive pages (fix while touching templates)
|
||||||
|
|
||||||
|
While refactoring, fix these two issues on pages not yet in active use:
|
||||||
|
- `map.html.twig`: inline map init needs `DOMContentLoaded` wrapper; `{% block map_assets %}` nested inside `{% block content %}` (double-registers assets)
|
||||||
|
- `feed-map.html.twig` (partial): `{% do assets.addCss %}` registers after `{{ assets.css()|raw }}` has rendered; inline map init also needs `DOMContentLoaded`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key constraint
|
||||||
|
|
||||||
|
Mischa wants stats and cycling data (distance, elevation gain/loss, moving time) visible on other pages in future (homepage, story pages). Centralising the computation — whether as Twig macros or a PHP plugin — is the prerequisite for that.
|
||||||
|
|
||||||
|
## What NOT to do in this milestone
|
||||||
|
|
||||||
|
- Don't touch JS or asset pipeline (that's Milestone 1, done)
|
||||||
|
- Don't redesign the visual layout
|
||||||
|
- Don't activate `dailies.html.twig`, `stories.html.twig`, or `map.html.twig` as new features — just fix their structural bugs while you're in the templates
|
||||||
|
|
||||||
|
## Relevant files
|
||||||
|
|
||||||
|
- `user/themes/intotheeast/templates/trip.html.twig` — main template (~384 lines)
|
||||||
|
- `user/themes/intotheeast/templates/partials/base.html.twig` — base layout
|
||||||
|
- `user/themes/intotheeast/templates/partials/feed-map.html.twig` — mini-map partial
|
||||||
|
- `user/themes/intotheeast/templates/map.html.twig` — full-page map (inactive)
|
||||||
|
- `user/themes/intotheeast/templates/dailies.html.twig` — journal feed (inactive)
|
||||||
|
- `user/themes/intotheeast/templates/stories.html.twig` — stories grid (inactive)
|
||||||
|
- `user/themes/intotheeast/templates/story.html.twig` — single story page
|
||||||
|
- `user/plugins/` — where a new stats plugin would live
|
||||||
|
|
||||||
|
## Open question for the brainstorm
|
||||||
|
|
||||||
|
The biggest design decision: **PHP plugin vs Twig macro for stats computation.**
|
||||||
|
|
||||||
|
- Twig macro: simpler, no new plugin, but still slow Twig loops
|
||||||
|
- PHP plugin: faster, reusable across pages, but adds a plugin to maintain
|
||||||
|
|
||||||
|
Mischa's stated preference leans toward the PHP plugin given the future-reuse goal, but hasn't committed yet.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Milestone 1 Spec — Entry Enrichment
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
- As a traveler (Mischa), when I submit the post form, I want my current weather conditions auto-filled so I don't have to look them up manually.
|
||||||
|
- As a traveler, I want to type my city and country once and have it appear on the entry and in the feed card, so readers know where I am without reading the whole post.
|
||||||
|
- As a reader, when I scan the feed, I want to see a thumbnail photo and location for each entry so I can quickly get a sense of where Mischa is and whether to read the full entry.
|
||||||
|
- As a reader, when I open an entry, I want to see all uploaded photos in a gallery I can browse, not a wall of raw images.
|
||||||
|
- As a traveler, when I submit a form without photos, the entry should still display cleanly with no broken image placeholders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Details
|
||||||
|
|
||||||
|
### 1.1 — Location Name Field on Post Form
|
||||||
|
|
||||||
|
**What:** Add two text fields to the post form: `location_city` and `location_country`.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Both are optional (GPS coordinates are also optional)
|
||||||
|
- Placeholder text: "e.g. Kyoto" and "e.g. Japan"
|
||||||
|
- Displayed below the lat/lng fields
|
||||||
|
- On submit, stored in entry frontmatter as `location_city` and `location_country`
|
||||||
|
- On the form, shown as a single labeled group "Location Name" with two side-by-side inputs on desktop, stacked on mobile
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- If left blank: entry shows no location badge. No error, no broken UI.
|
||||||
|
- Long city names (e.g. "Ulaanbaatar") must not overflow card layout.
|
||||||
|
- Special characters (accents, non-Latin) must render correctly.
|
||||||
|
|
||||||
|
**Mobile behavior:** Both fields full-width, stacked, 44px min touch targets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 — Weather Auto-Fetch on Post Form
|
||||||
|
|
||||||
|
**What:** A "Get Weather" button on the post form that calls the Open-Meteo free API (no API key) using the lat/lng already entered, and fills hidden weather fields.
|
||||||
|
|
||||||
|
**Fields to fetch and store:**
|
||||||
|
- `weather_temp_c` — temperature in Celsius (integer)
|
||||||
|
- `weather_desc` — short description: one of: Sunny, Partly cloudy, Cloudy, Foggy, Drizzle, Rain, Snow, Thunderstorm (derived from WMO weather code)
|
||||||
|
|
||||||
|
**WMO code mapping (Open-Meteo uses WMO codes):**
|
||||||
|
- 0 → Sunny
|
||||||
|
- 1,2 → Partly cloudy
|
||||||
|
- 3 → Cloudy
|
||||||
|
- 45,48 → Foggy
|
||||||
|
- 51,53,55,56,57 → Drizzle
|
||||||
|
- 61,63,65,66,67,80,81,82 → Rain
|
||||||
|
- 71,73,75,77,85,86 → Snow
|
||||||
|
- 95,96,99 → Thunderstorm
|
||||||
|
|
||||||
|
**API call:**
|
||||||
|
```
|
||||||
|
https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lng}¤t=temperature_2m,weather_code&temperature_unit=celsius
|
||||||
|
```
|
||||||
|
|
||||||
|
**UX flow:**
|
||||||
|
1. User fills in lat/lng (manually or via "Get Location" button)
|
||||||
|
2. User taps "Get Weather" button
|
||||||
|
3. Button shows "Fetching…" while loading
|
||||||
|
4. On success: fills temp and desc fields (visible, editable text inputs)
|
||||||
|
5. On failure (no network, no lat/lng): shows inline error "Could not fetch weather — enter manually"
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- If lat/lng not filled when button tapped: show inline error "Enter coordinates first"
|
||||||
|
- Weather fields are always editable manually (auto-fill is a convenience, not mandatory)
|
||||||
|
- If weather fields left blank: entry shows no weather badge. No broken UI.
|
||||||
|
- Open-Meteo returns current conditions, not historical — this is fine for posting in real time
|
||||||
|
|
||||||
|
**Mobile behavior:** "Get Weather" button is full-width, 44px height, placed immediately below the lat/lng + location name fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 — Weather Display on Entry Page
|
||||||
|
|
||||||
|
**What:** If `weather_temp_c` or `weather_desc` is present in frontmatter, display a weather badge on the entry page.
|
||||||
|
|
||||||
|
**Display format:** `☀️ Sunny · 28°C` (icon + description + temperature)
|
||||||
|
- Icon chosen from a small set based on `weather_desc`:
|
||||||
|
- Sunny → ☀️
|
||||||
|
- Partly cloudy → ⛅
|
||||||
|
- Cloudy → ☁️
|
||||||
|
- Foggy → 🌫️
|
||||||
|
- Drizzle → 🌦️
|
||||||
|
- Rain → 🌧️
|
||||||
|
- Snow → ❄️
|
||||||
|
- Thunderstorm → ⛈️
|
||||||
|
|
||||||
|
**Placement:** In the entry header, between the date and the body text. Same line as GPS coordinates if those are shown.
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Only temp, no desc → show temp only
|
||||||
|
- Only desc, no temp → show desc only
|
||||||
|
- Neither → hide weather section entirely
|
||||||
|
- Temperature should always be integer (round if float)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 — Location Badge on Feed Cards and Entry Page
|
||||||
|
|
||||||
|
**What:** Display `location_city, location_country` as a small badge on tracker feed cards and at the top of entry pages.
|
||||||
|
|
||||||
|
**Feed card:** Below the date, above the excerpt. Format: `📍 Kyoto, Japan`
|
||||||
|
|
||||||
|
**Entry page:** In the header below the date, above the content. Format: `📍 Kyoto, Japan`
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Only city, no country → `📍 Kyoto`
|
||||||
|
- Only country, no city → `📍 Japan`
|
||||||
|
- Neither → location badge hidden entirely
|
||||||
|
- Long location names: truncate with ellipsis at 30 chars on cards (full text on entry page)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 — Photo Gallery on Entry Page
|
||||||
|
|
||||||
|
**What:** Photos uploaded to an entry should display in a responsive grid gallery with lightbox (click to enlarge).
|
||||||
|
|
||||||
|
**Implementation approach:** Use Grav's native media collection for the entry page. Each `.entry` folder contains its photos. Render them in a grid in `entry.html.twig`. Use a minimal vanilla JS lightbox — no external framework.
|
||||||
|
|
||||||
|
**Gallery behavior:**
|
||||||
|
- Photos displayed in a 2-column grid on mobile, 3-column on desktop
|
||||||
|
- Each thumbnail is square-cropped, 150px on mobile
|
||||||
|
- Clicking/tapping a thumbnail opens a lightbox overlay
|
||||||
|
- Lightbox: dark overlay, full-size image centered, tap/click outside or press Escape to close
|
||||||
|
- Left/right navigation arrows in lightbox (swipe on mobile)
|
||||||
|
- No captions needed for v1
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- 0 photos: gallery section hidden entirely
|
||||||
|
- 1 photo: still uses grid (single item), lightbox works
|
||||||
|
- Many photos (>10): gallery still renders (no hard limit on display)
|
||||||
|
- Non-image files in the media folder: skip them (only render jpg, jpeg, png, webp, gif)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 — Hero Image on Tracker Feed Cards
|
||||||
|
|
||||||
|
**What:** If an entry has photos, the first photo (or the one named in `hero_image` frontmatter) appears as a thumbnail on the tracker feed card.
|
||||||
|
|
||||||
|
**Implementation:** In `tracker.html.twig`, for each entry:
|
||||||
|
1. If `entry.header.hero_image` is set, use `entry.media[entry.header.hero_image]`
|
||||||
|
2. Else, use the first image in `entry.media` sorted by name
|
||||||
|
3. Render as a 16:9 aspect-ratio thumbnail, full width of card, above the title
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- No photos: card shows no image, just text. No broken `<img>` tag.
|
||||||
|
- `hero_image` set but file missing: fall back to first media file, or no image
|
||||||
|
- Very tall/wide images: CSS `object-fit: cover` maintains card aspect ratio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (Milestone 1)
|
||||||
|
|
||||||
|
- Map features (Milestone 2)
|
||||||
|
- Statistics page (Milestone 3)
|
||||||
|
- Video support
|
||||||
|
- Comments or reactions
|
||||||
|
- Automated reverse geocoding (city name comes from form input, not auto-detected)
|
||||||
|
- Altitude display (data may not be present)
|
||||||
|
- Historical weather (Open-Meteo current endpoint only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. Post form has `location_city` and `location_country` fields that save to entry frontmatter
|
||||||
|
2. Post form has "Get Weather" button that fills `weather_temp_c` and `weather_desc` via Open-Meteo when lat/lng are provided
|
||||||
|
3. Entry page shows weather badge when weather fields are present; hidden when absent
|
||||||
|
4. Entry page shows location badge `📍 City, Country` when location fields are present; hidden when absent
|
||||||
|
5. Tracker feed card shows location badge when present
|
||||||
|
6. Tracker feed card shows a hero image when photos exist for an entry
|
||||||
|
7. Entry page shows a 2-col (mobile) / 3-col (desktop) photo grid
|
||||||
|
8. Clicking any photo opens a full-screen lightbox with prev/next navigation
|
||||||
|
9. Pressing Escape or clicking outside lightbox closes it
|
||||||
|
10. All fields are optional — empty values produce no broken UI elements
|
||||||
|
11. All interactive elements meet 44px minimum touch target on mobile
|
||||||
|
12. Form submits correctly with all new fields populated or all blank
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
- Weather and location badges should be subtle — small text, muted color, not the visual focus
|
||||||
|
- Use emoji icons for weather — universal, no icon font dependency
|
||||||
|
- Gallery grid: `gap: 4px` between thumbs, no borders, square crops
|
||||||
|
- Lightbox: `background: rgba(0,0,0,0.92)`, image centered with `max-height: 90vh`
|
||||||
|
- Feed card image: `aspect-ratio: 16/9`, `object-fit: cover`, rounded top corners matching card
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Milestone 2 Spec — Interactive Map
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
- As a reader, I want to see a world map showing where Mischa has been so I can understand the journey at a glance without reading every entry.
|
||||||
|
- As a reader, I want to click a map marker and see the entry date, title, and a thumbnail — and be able to click through to the full entry.
|
||||||
|
- As a reader on mobile, I want to pan and pinch-zoom the map with my fingers without the page scrolling underneath.
|
||||||
|
- As a traveler (Mischa), I want the map to automatically include every entry that has lat/lng data — I should not need to do any manual map maintenance.
|
||||||
|
- As a reader, I want the map to show the route line connecting stops in the order they were visited, so the journey makes narrative sense.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Details
|
||||||
|
|
||||||
|
### 2.1 — Map Page
|
||||||
|
|
||||||
|
**Route:** `/map`
|
||||||
|
|
||||||
|
**Template:** `map.html.twig` — extends `partials/base.html.twig`
|
||||||
|
|
||||||
|
**Page file:** `user/pages/03.map/map.md`
|
||||||
|
|
||||||
|
**Content:**
|
||||||
|
- Full-viewport-height map container below the site header
|
||||||
|
- Leaflet.js loaded from CDN (jsDelivr): `https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js`
|
||||||
|
- Leaflet CSS from same CDN
|
||||||
|
- Tile layer: OpenStreetMap (free, no API key): `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`
|
||||||
|
- Attribution: "© OpenStreetMap contributors"
|
||||||
|
|
||||||
|
**Map initialization:**
|
||||||
|
- Default zoom: auto-fit to bounds of all markers (use `map.fitBounds()`)
|
||||||
|
- If no entries with GPS data: show world view, zoom 2, centered at 0,0 with a message "No locations yet"
|
||||||
|
- Min zoom: 2, Max zoom: 18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 — Entry Data Serialization
|
||||||
|
|
||||||
|
**How entries reach the map JS:**
|
||||||
|
|
||||||
|
In `map.html.twig`, Grav's Twig will iterate all published entries under `/tracker` and serialize them to a JSON array embedded in a `<script>` tag:
|
||||||
|
|
||||||
|
```js
|
||||||
|
var ENTRIES = [
|
||||||
|
{
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"title": "Paris morning",
|
||||||
|
"date": "2026-06-18",
|
||||||
|
"url": "/tracker/2026-06-18",
|
||||||
|
"hero": "/path/to/thumb.jpg" // null if no photo
|
||||||
|
},
|
||||||
|
...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Only entries with valid lat AND lng are included** (skip entries where either is empty/null).
|
||||||
|
|
||||||
|
Entries sorted ascending by date (oldest first) so the route line is drawn in travel order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 — Route Polyline
|
||||||
|
|
||||||
|
**What:** A colored line drawn between entry markers in chronological order.
|
||||||
|
|
||||||
|
**Style:**
|
||||||
|
- Color: `#0066cc` (brand blue, matches existing CSS)
|
||||||
|
- Weight: 3px
|
||||||
|
- Opacity: 0.7
|
||||||
|
- No arrow heads for v1
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Line drawn between consecutive entries (by date) that have valid GPS
|
||||||
|
- If only 1 entry: no line (just a single marker)
|
||||||
|
- If two consecutive entries are very far apart (>5000km): line still drawn — it's a flight, expected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 — Entry Markers
|
||||||
|
|
||||||
|
**What:** One circular marker per entry with GPS coordinates.
|
||||||
|
|
||||||
|
**Marker design:**
|
||||||
|
- Custom circular marker (not default Leaflet teardrop)
|
||||||
|
- Color: `#0066cc` fill, white border, 2px border
|
||||||
|
- Size: 12px diameter on mobile, 14px on desktop
|
||||||
|
- Most recent entry: larger (18px) and brighter color to indicate "current location"
|
||||||
|
|
||||||
|
**Popup on click/tap:**
|
||||||
|
```
|
||||||
|
[thumbnail if available — 120px wide, 80px tall, cover cropped]
|
||||||
|
📅 18 June 2026
|
||||||
|
Paris morning
|
||||||
|
[Read entry →]
|
||||||
|
```
|
||||||
|
- Popup width: 180px max
|
||||||
|
- "Read entry →" links to the entry page
|
||||||
|
- Tapping outside popup closes it
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Two entries at the same lat/lng: Leaflet clusters or offsets them slightly (use small offset to prevent exact overlap — just add 0.0001° offset per duplicate)
|
||||||
|
- Entry with GPS but no photo: popup shows no image, just date + title + link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 — Mobile Map UX
|
||||||
|
|
||||||
|
**Problem:** On mobile, a map inside a scrollable page creates a scroll-trap (finger intended for page scroll gets captured by map pan).
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Map container is `height: calc(100vh - 60px)` (full viewport minus header)
|
||||||
|
- Map is the primary content of the page — no scroll needed
|
||||||
|
- `touch-action: none` on the map container prevents page scroll interference
|
||||||
|
- Leaflet handles touch pan/zoom natively
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 — Navigation Link
|
||||||
|
|
||||||
|
**What:** "Map" link added to the site header navigation.
|
||||||
|
|
||||||
|
**Where:** `partials/base.html.twig` nav section — add `<a href="{{ base_url_absolute }}/map">Map</a>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (Milestone 2)
|
||||||
|
|
||||||
|
- Filtering markers by date range
|
||||||
|
- Clustering markers at low zoom levels
|
||||||
|
- Heatmap or density visualization
|
||||||
|
- Showing the route on the tracker feed page (Milestone 4)
|
||||||
|
- Showing elevation profile
|
||||||
|
- Country highlight/fill on the map
|
||||||
|
- Offline map tiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `/map` page exists and returns HTTP 200
|
||||||
|
2. Page renders a full-height interactive map
|
||||||
|
3. All published entries with valid lat/lng appear as markers
|
||||||
|
4. Markers are connected by a route line in date order
|
||||||
|
5. Clicking/tapping a marker shows a popup with date, title, and link
|
||||||
|
6. Popup link navigates to the correct entry page
|
||||||
|
7. Most recent entry marker is visually distinct (larger/brighter)
|
||||||
|
8. If no entries have GPS: map renders at world zoom with "No locations yet" message
|
||||||
|
9. Map is pannable and zoomable by touch on mobile
|
||||||
|
10. "Map" link appears in site navigation and routes to `/map`
|
||||||
|
11. Map auto-fits to show all markers on page load
|
||||||
|
12. Entries without lat/lng are silently excluded (no JS errors)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
- Map tile layer: OpenStreetMap default tiles. Clean, recognizable, free.
|
||||||
|
- Keep the Grav site header visible above the map — don't go full-screen (users need the nav)
|
||||||
|
- Popup design: minimal. White background, slight box-shadow, 8px border-radius
|
||||||
|
- Do not use any Leaflet plugins beyond the core library — keep the dependency footprint tiny
|
||||||
|
- The map page should load fast: Leaflet is ~42KB gzipped. Tile images load progressively. No blocking.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Milestone 3 Spec — Statistics Page
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
- As a reader, I want to see a quick summary of how far Mischa has traveled and how many countries they've visited, without having to read every entry.
|
||||||
|
- As a traveler (Mischa), I want to see my own trip stats at a glance — a satisfying progress indicator while traveling.
|
||||||
|
- As a reader, I want stats that update automatically as new entries are posted — no manual maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Details
|
||||||
|
|
||||||
|
### 3.1 — Stats Page
|
||||||
|
|
||||||
|
**Route:** `/stats`
|
||||||
|
|
||||||
|
**Template:** `stats.html.twig` — extends `partials/base.html.twig`
|
||||||
|
|
||||||
|
**Page file:** `user/pages/04.stats/stats.md`
|
||||||
|
|
||||||
|
**Computed in Twig** (server-side, from published entries under `/tracker`):
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 — Stat: Days on the Road
|
||||||
|
|
||||||
|
**Definition:** Number of calendar days from the date of the first published entry to today.
|
||||||
|
|
||||||
|
**Formula (Twig):**
|
||||||
|
```twig
|
||||||
|
{% set first_entry = entries|first %}
|
||||||
|
{% set days = (now.timestamp - first_entry.date|date('U'))|round / 86400 %}
|
||||||
|
{% set days_on_road = [days|round(0, 'floor'), 0]|max %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Display:** `42 days on the road`
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- No entries: show `0 days on the road` or `Trip not started yet`
|
||||||
|
- Only one entry (today): show `1 day on the road`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 — Stat: Entries Posted
|
||||||
|
|
||||||
|
**Definition:** Count of all published entries under `/tracker`.
|
||||||
|
|
||||||
|
**Display:** `17 entries posted`
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- 0 entries: `0 entries posted`
|
||||||
|
- 1 entry: `1 entry posted` (singular)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 — Stat: Countries Visited
|
||||||
|
|
||||||
|
**Definition:** Unique values of `location_country` across all published entries, non-empty.
|
||||||
|
|
||||||
|
**Display:** Count + list
|
||||||
|
|
||||||
|
```
|
||||||
|
6 countries visited
|
||||||
|
Japan · South Korea · Mongolia · Russia · Finland · Estonia
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- No entries have `location_country`: show `Countries: —`
|
||||||
|
- Some entries missing `location_country`: count only those that have it; note "(based on X of Y entries)"
|
||||||
|
- Duplicate country names are de-duplicated (case-insensitive)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 — Stat: Approximate Distance Traveled
|
||||||
|
|
||||||
|
**Definition:** Sum of great-circle (haversine) distances between consecutive entries that have valid lat/lng, in ascending date order.
|
||||||
|
|
||||||
|
**Implementation:** Computed in Twig using a haversine formula macro.
|
||||||
|
|
||||||
|
**Haversine in Twig:**
|
||||||
|
```twig
|
||||||
|
{% macro haversine(lat1, lng1, lat2, lng2) %}
|
||||||
|
{% set R = 6371 %}
|
||||||
|
{% set dLat = ((lat2 - lat1) * 3.14159265 / 180) %}
|
||||||
|
{% set dLng = ((lng2 - lng1) * 3.14159265 / 180) %}
|
||||||
|
{% set a = (dLat/2)|sin * (dLat/2)|sin + (lat1 * 3.14159265 / 180)|cos * (lat2 * 3.14159265 / 180)|cos * (dLng/2)|sin * (dLng/2)|sin %}
|
||||||
|
{% set c = 2 * a|sqrt|asin %}
|
||||||
|
{{ (R * c)|round }}
|
||||||
|
{% endmacro %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Twig does not have `sin`/`cos`/`asin`/`sqrt` built-in. Use a JavaScript-side calculation instead:
|
||||||
|
|
||||||
|
**Implementation:** Embed the entry GPS data as JSON in the template (same pattern as Milestone 2), compute distance in vanilla JS, and write the result into the DOM on page load.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function haversine(lat1, lng1, lat2, lng2) {
|
||||||
|
var R = 6371;
|
||||||
|
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
var a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;
|
||||||
|
return R * 2 * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 1; i < GPS_POINTS.length; i++) {
|
||||||
|
total += haversine(GPS_POINTS[i-1][0], GPS_POINTS[i-1][1], GPS_POINTS[i][0], GPS_POINTS[i][1]);
|
||||||
|
}
|
||||||
|
document.getElementById('stat-distance').textContent = Math.round(total).toLocaleString() + ' km';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Display:** `~3,400 km traveled`
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- 0 or 1 GPS points: `Distance: —`
|
||||||
|
- Very large numbers (trans-continental trip): use thousands separator: `12,400 km`
|
||||||
|
- Disclaimer note: "approximate — based on straight lines between entry locations"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 — Visual Layout
|
||||||
|
|
||||||
|
**Layout:** 4 large stat blocks in a 2×2 grid on desktop, stacked on mobile.
|
||||||
|
|
||||||
|
Each block:
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 42 │
|
||||||
|
│ days on road │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Number: large (3rem), bold, brand blue
|
||||||
|
- Label: small (0.85rem), muted grey
|
||||||
|
- Background: white, 1px border, 8px radius, subtle shadow
|
||||||
|
- Mobile: 2-col grid (2 stats per row)
|
||||||
|
|
||||||
|
Below the grid: list of countries visited (plain text, centered, muted).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 — Navigation Link
|
||||||
|
|
||||||
|
Add "Stats" to the site navigation in `partials/base.html.twig`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (Milestone 3)
|
||||||
|
|
||||||
|
- Charts or graphs (bar charts, line graphs, etc.)
|
||||||
|
- World map with highlighted countries (that's a visual enhancement, deferred)
|
||||||
|
- Per-country breakdown (km in each country, days in each country)
|
||||||
|
- Speed statistics (km/day average)
|
||||||
|
- Elevation statistics
|
||||||
|
- Historical comparison (vs. last trip)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `/stats` page exists and returns HTTP 200
|
||||||
|
2. "Days on the road" shows correct count from first entry date to today
|
||||||
|
3. "Entries posted" shows count of published entries
|
||||||
|
4. "Countries visited" shows correct count + list of unique non-empty `location_country` values
|
||||||
|
5. "Distance traveled" shows km sum of haversine distances between consecutive GPS entries
|
||||||
|
6. All four stats display in a 2×2 grid on desktop
|
||||||
|
7. On mobile (375px), stats stack into a 2-column responsive grid
|
||||||
|
8. Stats auto-update when new entries are published (no manual maintenance)
|
||||||
|
9. If no entries: all stats show 0 or `—`, no JS errors
|
||||||
|
10. "Stats" link in navigation routes to `/stats`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
- Stats should feel like a dashboard, not a table — big numbers, small labels
|
||||||
|
- Do not use any external charting library for v1
|
||||||
|
- Countries list below the grid: inline, separated by `·`, muted grey
|
||||||
|
- The "approximate" disclaimer for distance should be in small print below the distance stat
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Milestone 4 Spec — Mini-Map on Tracker Feed
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
- As a reader landing on the tracker feed, I want to immediately see where Mischa currently is without having to navigate to the full map page.
|
||||||
|
- As a reader, I want to click a marker on the mini-map and jump to that entry.
|
||||||
|
- As a traveler (Mischa), I want the feed page to feel like a live travel dashboard, not just a blog list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Details
|
||||||
|
|
||||||
|
### 4.1 — Mini-Map Placement
|
||||||
|
|
||||||
|
**Where:** At the top of `tracker.html.twig`, before the entry card list.
|
||||||
|
|
||||||
|
**Height:** 240px on mobile, 320px on desktop.
|
||||||
|
|
||||||
|
**Width:** Full width of content column (max 680px).
|
||||||
|
|
||||||
|
**Tile layer:** Same OpenStreetMap tiles as Milestone 2.
|
||||||
|
|
||||||
|
**No duplicate Leaflet load:** Leaflet is already loaded on the map page; on the tracker page, load it only if needed. Check with `if (typeof L === 'undefined')` before initializing. (In practice, the CSS and JS are loaded unconditionally from the same CDN — caching handles it.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 — What's Shown
|
||||||
|
|
||||||
|
- **All entries with GPS** shown as small markers (not just recent 10 — the map auto-fits to bounds)
|
||||||
|
- **Route line** connecting them in chronological order (same style as Milestone 2)
|
||||||
|
- **Most recent marker** highlighted (larger, brighter)
|
||||||
|
- **No popups by default** — tapping a marker links directly to the entry (no popup intermediary for the mini-map, keeps it fast)
|
||||||
|
- Map auto-fits bounds to all markers; if only 1 marker, zoom to 10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 — Interaction
|
||||||
|
|
||||||
|
- Tap/click marker → navigate to entry URL directly
|
||||||
|
- Map is pannable and zoomable (same touch handling as M2)
|
||||||
|
- "View full map →" link below the mini-map → navigates to `/map`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 — Entry Data
|
||||||
|
|
||||||
|
Same JSON serialization as Milestone 2 (embed `TRACKER_ENTRIES` in the Twig template). This can reuse the same data variable name if both map and tracker pages use the same template pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 — Empty State
|
||||||
|
|
||||||
|
If no entries have GPS coordinates:
|
||||||
|
- Mini-map hidden entirely (don't show an empty world map on the feed page)
|
||||||
|
- Entry list still shows normally
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (Milestone 4)
|
||||||
|
|
||||||
|
- Clustering markers at low zoom
|
||||||
|
- Filtering by date
|
||||||
|
- Satellite/terrain tile layers
|
||||||
|
- Search on the mini-map
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. Mini-map appears above entry cards on the tracker feed page
|
||||||
|
2. All entries with valid lat/lng appear as markers on the mini-map
|
||||||
|
3. Route line connects markers in date order
|
||||||
|
4. Most recent marker is visually distinct
|
||||||
|
5. Clicking/tapping a marker navigates directly to that entry
|
||||||
|
6. "View full map →" link appears below the mini-map and routes to `/map`
|
||||||
|
7. If no entries have GPS, mini-map is hidden and entry list shows normally
|
||||||
|
8. Mini-map is pannable and zoomable by touch on mobile
|
||||||
|
9. Mini-map does not block page scrolling on mobile (map is fixed height, not full-screen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
- Mini-map border-radius should match the card design (8px)
|
||||||
|
- Light 1px border or subtle shadow to separate from content
|
||||||
|
- "View full map →" in small muted text, right-aligned
|
||||||
|
- Keep the mini-map lightweight: same Leaflet instance, no additional plugins
|
||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
# Grav 2.0 Upgrade Implementation Plan
|
# Grav 2.0 Upgrade Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-18)
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Upgrade the local dev Docker environment from linuxserver/grav 1.7 to getgrav/grav 2.0 RC, validate the full Milestone 1 posting workflow, and update the production install script for a fresh Grav 2.0 deploy.
|
**Goal:** Upgrade the local dev Docker environment from linuxserver/grav 1.7 to getgrav/grav 2.0 RC, validate the full Milestone 1 posting workflow, and update the production install script for a fresh Grav 2.0 deploy.
|
||||||
File diff suppressed because it is too large
Load Diff
+25
-23
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
**Goal:** Replace the warm-paper light theme with a warm-dark "notebook at night" aesthetic — dark-only, no toggle, paper grain texture, dark terrain map tiles, typography polish.
|
**Goal:** Replace the warm-paper light theme with a warm-dark "notebook at night" aesthetic — dark-only, no toggle, paper grain texture, dark terrain map tiles, typography polish.
|
||||||
|
|
||||||
**Architecture:** Pure CSS token swap in `tokens.css` (all components update automatically), grain overlay via `body::after` SVG data URI in `style.css`, map tile URL swap in two Twig templates. No new dependencies, no JS changes, no structural changes.
|
**Architecture:** Pure CSS token swap in `tokens.css` (all components update automatically), grain overlay via `body::after` SVG data URI in `style.css`, map tile URL swap in two Twig templates. No new dependencies, no JS changes, no structural changes.
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: CSS custom properties consumed by every component in `style.css` and Twig templates
|
- Produces: CSS custom properties consumed by every component in `style.css` and Twig templates
|
||||||
|
|
||||||
- [ ] **Step 1: Read the current tokens file**
|
- [x] **Step 1: Read the current tokens file**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat user/themes/intotheeast/css/tokens.css
|
cat user/themes/intotheeast/css/tokens.css
|
||||||
@@ -37,7 +39,7 @@ cat user/themes/intotheeast/css/tokens.css
|
|||||||
|
|
||||||
Confirm these token names exist before editing: `--color-paper`, `--color-canvas`, `--color-ink`, `--color-ink-2`, `--color-ink-muted`, `--color-border`, `--color-border-soft`, `--color-accent`, `--color-accent-hover`, `--color-accent-light`, `--color-accent-on`.
|
Confirm these token names exist before editing: `--color-paper`, `--color-canvas`, `--color-ink`, `--color-ink-2`, `--color-ink-muted`, `--color-border`, `--color-border-soft`, `--color-accent`, `--color-accent-hover`, `--color-accent-light`, `--color-accent-on`.
|
||||||
|
|
||||||
- [ ] **Step 2: Replace the color block in tokens.css**
|
- [x] **Step 2: Replace the color block in tokens.css**
|
||||||
|
|
||||||
Replace the entire `:root` color block (from `--color-paper` through `--color-accent-on`) with:
|
Replace the entire `:root` color block (from `--color-paper` through `--color-accent-on`) with:
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ Replace the entire `:root` color block (from `--color-paper` through `--color-ac
|
|||||||
|
|
||||||
Keep all non-color tokens (`--text-*`, `--leading-*`, `--space-*`, font variables, etc.) unchanged.
|
Keep all non-color tokens (`--text-*`, `--leading-*`, `--space-*`, font variables, etc.) unchanged.
|
||||||
|
|
||||||
- [ ] **Step 3: Verify no syntax errors**
|
- [x] **Step 3: Verify no syntax errors**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
|
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
|
||||||
@@ -68,7 +70,7 @@ docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcach
|
|||||||
|
|
||||||
Expected: `200`
|
Expected: `200`
|
||||||
|
|
||||||
- [ ] **Step 4: Visual smoke check**
|
- [x] **Step 4: Visual smoke check**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'color: var(--color-paper)' | head -3
|
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'color: var(--color-paper)' | head -3
|
||||||
@@ -76,7 +78,7 @@ curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'color: v
|
|||||||
|
|
||||||
Not a definitive check — just confirm the page renders. Open a browser and verify the background is dark and text is cream.
|
Not a definitive check — just confirm the page renders. Open a browser and verify the background is dark and text is cream.
|
||||||
|
|
||||||
- [ ] **Step 5: Run test suite**
|
- [x] **Step 5: Run test suite**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test-ui
|
make test-ui
|
||||||
@@ -84,7 +86,7 @@ make test-ui
|
|||||||
|
|
||||||
Expected: 24/25 pass (P2 FilePond is pre-existing failure, all others pass).
|
Expected: 24/25 pass (P2 FilePond is pre-existing failure, all others pass).
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git -C user add themes/intotheeast/css/tokens.css
|
git -C user add themes/intotheeast/css/tokens.css
|
||||||
@@ -101,7 +103,7 @@ git -C user commit -m "feat: switch to warm-dark color tokens"
|
|||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: dark color tokens from Task 1
|
- Consumes: dark color tokens from Task 1
|
||||||
|
|
||||||
- [ ] **Step 1: Find all hardcoded color literals in style.css**
|
- [x] **Step 1: Find all hardcoded color literals in style.css**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -n '#[0-9a-fA-F]\{3,6\}\|background: white\|background:#fff\|color: #\|background-color: #' user/themes/intotheeast/css/style.css
|
grep -n '#[0-9a-fA-F]\{3,6\}\|background: white\|background:#fff\|color: #\|background-color: #' user/themes/intotheeast/css/style.css
|
||||||
@@ -109,7 +111,7 @@ grep -n '#[0-9a-fA-F]\{3,6\}\|background: white\|background:#fff\|color: #\|back
|
|||||||
|
|
||||||
Make note of every hit — each one is a candidate to replace with a token. Exceptions: the CSS SVG data URI you are about to add (the noise filter hex values are part of the graphic, not UI colors).
|
Make note of every hit — each one is a candidate to replace with a token. Exceptions: the CSS SVG data URI you are about to add (the noise filter hex values are part of the graphic, not UI colors).
|
||||||
|
|
||||||
- [ ] **Step 2: Add paper grain texture to body**
|
- [x] **Step 2: Add paper grain texture to body**
|
||||||
|
|
||||||
Find the `body` rule in `style.css`. It will look something like:
|
Find the `body` rule in `style.css`. It will look something like:
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ body::after {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Fix hardcoded login form colors**
|
- [x] **Step 3: Fix hardcoded login form colors**
|
||||||
|
|
||||||
Find this rule (around line 497):
|
Find this rule (around line 497):
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ Replace with:
|
|||||||
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); text-decoration: none; line-height: 44px; padding: 0 1rem; }
|
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); text-decoration: none; line-height: 44px; padding: 0 1rem; }
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Fix any other hardcoded colors found in Step 1**
|
- [x] **Step 4: Fix any other hardcoded colors found in Step 1**
|
||||||
|
|
||||||
For each hardcoded literal found in Step 1 (excluding the data URI you added):
|
For each hardcoded literal found in Step 1 (excluding the data URI you added):
|
||||||
- `#fff` / `white` → `var(--color-canvas)` (if a surface) or `var(--color-paper)` (if a page background)
|
- `#fff` / `white` → `var(--color-canvas)` (if a surface) or `var(--color-paper)` (if a page background)
|
||||||
@@ -161,7 +163,7 @@ For each hardcoded literal found in Step 1 (excluding the data URI you added):
|
|||||||
|
|
||||||
Use judgment: if a hex is inside a gradient or SVG path data, leave it alone.
|
Use judgment: if a hex is inside a gradient or SVG path data, leave it alone.
|
||||||
|
|
||||||
- [ ] **Step 5: Typography — increase entry body paragraph spacing**
|
- [x] **Step 5: Typography — increase entry body paragraph spacing**
|
||||||
|
|
||||||
Find:
|
Find:
|
||||||
|
|
||||||
@@ -171,11 +173,11 @@ Find:
|
|||||||
|
|
||||||
Change `margin-bottom: 1.1em` to `margin-bottom: 1.4em`.
|
Change `margin-bottom: 1.1em` to `margin-bottom: 1.4em`.
|
||||||
|
|
||||||
- [ ] **Step 6: Typography — tighten h1/h2 tracking**
|
- [x] **Step 6: Typography — tighten h1/h2 tracking**
|
||||||
|
|
||||||
Find the `h1` and `h2` rules. Any rule that applies `letter-spacing: -0.01em` to an `h1` or `h2` — change it to `-0.02em`. Do not touch h3/h4/h5/h6.
|
Find the `h1` and `h2` rules. Any rule that applies `letter-spacing: -0.01em` to an `h1` or `h2` — change it to `-0.02em`. Do not touch h3/h4/h5/h6.
|
||||||
|
|
||||||
- [ ] **Step 7: Stats page — tabular numbers**
|
- [x] **Step 7: Stats page — tabular numbers**
|
||||||
|
|
||||||
Find any CSS rule targeting stats numbers (look for `.stat-value`, `.stats-number`, or similar). Add `font-variant-numeric: tabular-nums` to it. If no such specific rule exists, search the template:
|
Find any CSS rule targeting stats numbers (look for `.stat-value`, `.stats-number`, or similar). Add `font-variant-numeric: tabular-nums` to it. If no such specific rule exists, search the template:
|
||||||
|
|
||||||
@@ -185,7 +187,7 @@ grep -n 'stat\|number\|count' user/themes/intotheeast/templates/stats.html.twig
|
|||||||
|
|
||||||
Then add a targeted rule in style.css for whatever class wraps the numeric values.
|
Then add a targeted rule in style.css for whatever class wraps the numeric values.
|
||||||
|
|
||||||
- [ ] **Step 8: Verify no syntax errors and visual check**
|
- [x] **Step 8: Verify no syntax errors and visual check**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
|
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
|
||||||
@@ -193,7 +195,7 @@ docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcach
|
|||||||
|
|
||||||
Expected: `200`. Open browser — grain should be subtly visible on the dark background.
|
Expected: `200`. Open browser — grain should be subtly visible on the dark background.
|
||||||
|
|
||||||
- [ ] **Step 9: Run test suite**
|
- [x] **Step 9: Run test suite**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test-ui
|
make test-ui
|
||||||
@@ -201,7 +203,7 @@ make test-ui
|
|||||||
|
|
||||||
Expected: 24/25 (P2 pre-existing).
|
Expected: 24/25 (P2 pre-existing).
|
||||||
|
|
||||||
- [ ] **Step 10: Commit**
|
- [x] **Step 10: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git -C user add themes/intotheeast/css/style.css
|
git -C user add themes/intotheeast/css/style.css
|
||||||
@@ -220,7 +222,7 @@ git -C user commit -m "feat: add paper grain texture, fix hardcoded colors, impr
|
|||||||
- Consumes: Leaflet.js already loaded in both templates
|
- Consumes: Leaflet.js already loaded in both templates
|
||||||
- Produces: Stadia Alidade Smooth Dark tiles replacing OpenStreetMap tiles in both map views
|
- Produces: Stadia Alidade Smooth Dark tiles replacing OpenStreetMap tiles in both map views
|
||||||
|
|
||||||
- [ ] **Step 1: Read current tile setup in both templates**
|
- [x] **Step 1: Read current tile setup in both templates**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -n "tileLayer\|openstreetmap\|attribution\|stadia" user/themes/intotheeast/templates/map.html.twig user/themes/intotheeast/templates/dailies.html.twig
|
grep -n "tileLayer\|openstreetmap\|attribution\|stadia" user/themes/intotheeast/templates/map.html.twig user/themes/intotheeast/templates/dailies.html.twig
|
||||||
@@ -228,7 +230,7 @@ grep -n "tileLayer\|openstreetmap\|attribution\|stadia" user/themes/intotheeast/
|
|||||||
|
|
||||||
Confirm the current tile URL pattern (`{s}.tile.openstreetmap.org`) in both files.
|
Confirm the current tile URL pattern (`{s}.tile.openstreetmap.org`) in both files.
|
||||||
|
|
||||||
- [ ] **Step 2: Replace tile layer in map.html.twig**
|
- [x] **Step 2: Replace tile layer in map.html.twig**
|
||||||
|
|
||||||
Find:
|
Find:
|
||||||
|
|
||||||
@@ -248,11 +250,11 @@ L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{
|
|||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Replace tile layer in dailies.html.twig (mini-map)**
|
- [x] **Step 3: Replace tile layer in dailies.html.twig (mini-map)**
|
||||||
|
|
||||||
Apply the identical tile swap to the mini-map `L.tileLayer` call in `dailies.html.twig`. Find the OpenStreetMap tile URL and replace it with the Stadia dark URL (same as Step 2, same attribution, same TODO comment).
|
Apply the identical tile swap to the mini-map `L.tileLayer` call in `dailies.html.twig`. Find the OpenStreetMap tile URL and replace it with the Stadia dark URL (same as Step 2, same attribution, same TODO comment).
|
||||||
|
|
||||||
- [ ] **Step 4: Verify tiles load**
|
- [x] **Step 4: Verify tiles load**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
|
||||||
@@ -274,7 +276,7 @@ Open the map in a browser and confirm:
|
|||||||
- Entry pins render correctly on top
|
- Entry pins render correctly on top
|
||||||
- Attribution footer is present
|
- Attribution footer is present
|
||||||
|
|
||||||
- [ ] **Step 5: Check mini-map on dailies page**
|
- [x] **Step 5: Check mini-map on dailies page**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'stadiamaps'
|
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'stadiamaps'
|
||||||
@@ -282,7 +284,7 @@ curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'stadiama
|
|||||||
|
|
||||||
Expected: `stadiamaps`.
|
Expected: `stadiamaps`.
|
||||||
|
|
||||||
- [ ] **Step 6: Run test suite**
|
- [x] **Step 6: Run test suite**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test-ui
|
make test-ui
|
||||||
@@ -290,7 +292,7 @@ make test-ui
|
|||||||
|
|
||||||
Expected: 24/25 (P2 pre-existing).
|
Expected: 24/25 (P2 pre-existing).
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git -C user add themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/dailies.html.twig
|
git -C user add themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/dailies.html.twig
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# GPX Manager Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-19)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a protected admin page at `/gpx-manager` that lists all trip GPX files and supports upload and deletion via the Grav API.
|
||||||
|
|
||||||
|
**Architecture:** A Grav page (`user/pages/03.gpx-manager/`) with a custom Twig template. Access is enforced by the Login plugin via `access.admin.login: true` in page frontmatter. The template renders a section per trip using the Grav page tree, then vanilla JavaScript calls the existing Grav API (`/api/v1/pages{route}/media`) using the browser's live session cookie — no JWT or separate login needed.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav 2.0 Twig, Vanilla JS (fetch API), Grav API plugin v1, Grav Login plugin (page access control)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Grav 2.0.0-rc.9 + Admin2 v2.0.0-rc.15; theme `intotheeast` at `user/themes/intotheeast/`
|
||||||
|
- API base URL: `/api/v1` (`route: /api`, `version_prefix: v1` in `user/plugins/api/api.yaml`)
|
||||||
|
- Session auth: all fetch calls use `credentials: 'include'` — no JWT handling (`session_enabled: true` in api.yaml)
|
||||||
|
- API media routes (confirmed from `user/plugins/api/classes/Api/ApiRouter.php:333`):
|
||||||
|
- `GET /api/v1/pages{route}/media` — list; response `{ data: [{ filename, size, modified, type }] }`
|
||||||
|
- `POST /api/v1/pages{route}/media` — multipart file upload
|
||||||
|
- `DELETE /api/v1/pages{route}/media/{filename}` — delete single file
|
||||||
|
- `{route}` is the full Grav route including leading slash, e.g. `/trips/italy-2025`
|
||||||
|
- Style: teal `#1F6B5A`, warm border `#e0ddd6`, font-family `'DM Sans', sans-serif` — match existing theme tokens
|
||||||
|
- No new plugins, no npm, no build step. All changes inside `user/` only.
|
||||||
|
- The page must be `visible: false` — must not appear in site navigation.
|
||||||
|
- Trip pages live at `user/pages/01.trips/<slug>/`; retrieved via `grav.pages.find('/trips').children.published()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Page definition
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/pages/03.gpx-manager/gpx-manager.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: Grav page routed at `/gpx-manager`, protected by Login plugin, hidden from nav, using template `gpx-manager`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the page file**
|
||||||
|
|
||||||
|
Create `user/pages/03.gpx-manager/gpx-manager.md` with this exact content:
|
||||||
|
|
||||||
|
```
|
||||||
|
---
|
||||||
|
title: 'GPX Manager'
|
||||||
|
template: gpx-manager
|
||||||
|
visible: false
|
||||||
|
routable: true
|
||||||
|
access:
|
||||||
|
admin.login: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify protection (no template yet)**
|
||||||
|
|
||||||
|
With the dev server running, open `http://localhost:8081/gpx-manager` while **logged out** of admin. You should be redirected to the login page. While **logged in**, you'll see a blank page or a Twig error (template missing) — that's fine at this stage.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add pages/03.gpx-manager/gpx-manager.md
|
||||||
|
git -C user commit -m "feat: add gpx-manager page definition (access-protected)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Template — layout and trip sections
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/themes/intotheeast/templates/gpx-manager.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `grav.pages.find('/trips').children.published()` — each trip object exposes `.route` (string, e.g. `/trips/italy-2025`), `.title` (string), `.slug` (string, e.g. `italy-2025`)
|
||||||
|
- Produces: one `.gpx-trip[data-route]` section per trip; `data-route` = full route string (e.g. `/trips/italy-2025`); `data-trip-route` on upload form = same value
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the template**
|
||||||
|
|
||||||
|
Create `user/themes/intotheeast/templates/gpx-manager.html.twig`:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set trips_page = grav.pages.find('/trips') %}
|
||||||
|
{% set trips = trips_page ? trips_page.children.published() : [] %}
|
||||||
|
|
||||||
|
<div class="gpx-manager">
|
||||||
|
<h1 class="gpx-manager__title">GPX Files</h1>
|
||||||
|
|
||||||
|
{% if trips is empty %}
|
||||||
|
<p>No trips found.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for trip in trips %}
|
||||||
|
<section class="gpx-trip" data-route="{{ trip.route }}">
|
||||||
|
<h2 class="gpx-trip__name">{{ trip.title }}</h2>
|
||||||
|
<div class="gpx-file-list" id="files-{{ trip.slug }}">
|
||||||
|
<p class="gpx-loading">Loading…</p>
|
||||||
|
</div>
|
||||||
|
<form class="gpx-upload-form" data-trip-route="{{ trip.route }}">
|
||||||
|
<label class="gpx-upload-label">
|
||||||
|
<input type="file" accept=".gpx,application/gpx+xml" name="file" class="gpx-file-input">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="gpx-upload-btn">Upload</button>
|
||||||
|
<span class="gpx-status"></span>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gpx-manager { max-width: 720px; margin: 2rem auto; padding: 0 1rem; font-family: 'DM Sans', sans-serif; }
|
||||||
|
.gpx-manager__title { font-family: 'DM Serif Display', serif; font-size: 1.75rem; margin-bottom: 2rem; }
|
||||||
|
.gpx-trip { border: 1px solid #e0ddd6; border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; }
|
||||||
|
.gpx-trip__name { font-size: 1.1rem; font-weight: 600; margin: 0 0 1rem; }
|
||||||
|
.gpx-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin-bottom: 1rem; }
|
||||||
|
.gpx-table th { text-align: left; color: #666; font-weight: 500; padding: 0.25rem 0.5rem; border-bottom: 1px solid #e0ddd6; }
|
||||||
|
.gpx-table td { padding: 0.5rem; border-bottom: 1px solid #f0ede8; }
|
||||||
|
.gpx-empty, .gpx-loading { color: #888; font-size: 0.875rem; margin-bottom: 0.75rem; }
|
||||||
|
.gpx-upload-form { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; }
|
||||||
|
.gpx-upload-btn { background: #1F6B5A; color: #fff; border: none; border-radius: 5px; padding: 0.4rem 1rem; font-size: 0.875rem; cursor: pointer; }
|
||||||
|
.gpx-upload-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.gpx-delete { background: none; border: 1px solid #ccc; border-radius: 4px; padding: 0.2rem 0.5rem; font-size: 0.8rem; cursor: pointer; color: #c0392b; }
|
||||||
|
.gpx-delete:disabled { opacity: 0.5; }
|
||||||
|
.gpx-status { font-size: 0.8rem; color: #555; }
|
||||||
|
.gpx-status.error { color: #c0392b; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* GPX manager JS — added in Task 3 */
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify trip sections render**
|
||||||
|
|
||||||
|
Open `http://localhost:8081/gpx-manager` while logged in. You should see:
|
||||||
|
- Heading "GPX Files"
|
||||||
|
- One card per trip (Italy 2025, Japan-Korea 2026) each showing "Loading…" and an upload form with a file picker and Upload button.
|
||||||
|
- The page header/nav from `base.html.twig` is present.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/gpx-manager.html.twig
|
||||||
|
git -C user commit -m "feat: gpx-manager template layout with trip sections"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: JavaScript — list, upload, delete
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/gpx-manager.html.twig` — replace `/* GPX manager JS — added in Task 3 */` inside the existing `<script>` tag
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.gpx-trip[data-route]` and `.gpx-upload-form[data-trip-route]` from Task 2
|
||||||
|
- Consumes: Grav API at `/api/v1` (session cookie auth)
|
||||||
|
- API list response: `{ data: [{ filename: string, size: number, modified: string, type: string }] }`
|
||||||
|
- API upload: multipart `FormData` with field name `file`
|
||||||
|
- API delete: `DELETE /api/v1/pages{route}/media/{encodedFilename}` → 200 or 204 on success
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the placeholder comment with the full script**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/gpx-manager.html.twig`, replace `/* GPX manager JS — added in Task 3 */` with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
|
return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(url, options) {
|
||||||
|
const res = await fetch(url, { credentials: 'include', ...options });
|
||||||
|
if (res.status === 401) { window.location.href = '/admin'; return null; }
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles(tripRoute) {
|
||||||
|
const res = await apiFetch(`${API}/pages${tripRoute}/media`);
|
||||||
|
if (!res || !res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return (data.data || []).filter(f => f.filename.toLowerCase().endsWith('.gpx'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTrip(tripEl) {
|
||||||
|
const route = tripEl.dataset.route;
|
||||||
|
const list = tripEl.querySelector('.gpx-file-list');
|
||||||
|
list.innerHTML = '<p class="gpx-loading">Loading…</p>';
|
||||||
|
|
||||||
|
const files = await loadFiles(route);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
list.innerHTML = '<p class="gpx-empty">No GPX files.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = files.map(f =>
|
||||||
|
`<tr>
|
||||||
|
<td>${f.filename}</td>
|
||||||
|
<td>${formatSize(f.size)}</td>
|
||||||
|
<td>${formatDate(f.modified)}</td>
|
||||||
|
<td><button class="gpx-delete" data-filename="${f.filename}">Delete</button></td>
|
||||||
|
</tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
list.innerHTML = `<table class="gpx-table">
|
||||||
|
<thead><tr><th>File</th><th>Size</th><th>Modified</th><th></th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
list.querySelectorAll('.gpx-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete ${btn.dataset.filename}?`)) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
const res = await apiFetch(
|
||||||
|
`${API}/pages${route}/media/${encodeURIComponent(btn.dataset.filename)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
if (res && (res.ok || res.status === 204)) {
|
||||||
|
await renderTrip(tripEl);
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
alert('Delete failed — check console.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initUpload(formEl) {
|
||||||
|
formEl.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const route = formEl.dataset.tripRoute;
|
||||||
|
const fileInput = formEl.querySelector('input[type=file]');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
const status = formEl.querySelector('.gpx-status');
|
||||||
|
const btn = formEl.querySelector('.gpx-upload-btn');
|
||||||
|
|
||||||
|
if (!file) { status.textContent = 'Choose a file first.'; return; }
|
||||||
|
|
||||||
|
status.textContent = 'Uploading…';
|
||||||
|
status.className = 'gpx-status';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
|
||||||
|
const res = await apiFetch(`${API}/pages${route}/media`, { method: 'POST', body: fd });
|
||||||
|
btn.disabled = false;
|
||||||
|
|
||||||
|
if (res && res.ok) {
|
||||||
|
status.textContent = 'Uploaded!';
|
||||||
|
fileInput.value = '';
|
||||||
|
await renderTrip(formEl.closest('.gpx-trip'));
|
||||||
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
||||||
|
} else {
|
||||||
|
const err = res ? await res.json().catch(() => ({})) : {};
|
||||||
|
status.textContent = 'Error: ' + (err.detail || (res ? res.statusText : 'network error'));
|
||||||
|
status.className = 'gpx-status error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.gpx-trip').forEach(renderTrip);
|
||||||
|
document.querySelectorAll('.gpx-upload-form').forEach(initUpload);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test file listing**
|
||||||
|
|
||||||
|
Open `http://localhost:8081/gpx-manager` while logged in. Open DevTools → Network tab.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `GET /api/v1/pages/trips/italy-2025/media` → 200, Italy 2025 section shows a table with 3 rows (day-5, day-6, day-8) with sizes (~1.8 MB, ~2.2 MB, ~1.9 MB) and dates.
|
||||||
|
- `GET /api/v1/pages/trips/japan-korea-2026/media` → 200, Japan-Korea 2026 section shows "No GPX files."
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test upload**
|
||||||
|
|
||||||
|
In the Japan-Korea 2026 section: click the file input, select any `.gpx` file from disk, click Upload.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Status shows "Uploading…" then "Uploaded!"
|
||||||
|
- The file table re-renders with the new file listed.
|
||||||
|
- DevTools shows `POST /api/v1/pages/trips/japan-korea-2026/media` → 200.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test delete**
|
||||||
|
|
||||||
|
Click Delete on the file just uploaded. Confirm the dialog.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- The row disappears immediately.
|
||||||
|
- DevTools shows `DELETE /api/v1/pages/trips/japan-korea-2026/media/<filename>` → 200 or 204.
|
||||||
|
- Reload the page — file is gone.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Test 401 redirect**
|
||||||
|
|
||||||
|
Log out of Admin2. In a new tab, navigate to `http://localhost:8081/gpx-manager`.
|
||||||
|
|
||||||
|
Expected: redirected to login page (Login plugin enforces `access.admin.login: true` before the page renders, so the JS never runs).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/gpx-manager.html.twig
|
||||||
|
git -C user commit -m "feat: gpx-manager list, upload, delete via Grav API session auth"
|
||||||
|
```
|
||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
# Home Page & Content Flow Implementation Plan
|
# Home Page & Content Flow Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-19)
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Replace the redirect-based home page with a real side-by-side map + feed home page, add a past-trips archive, add story cards to all feeds, and enrich the trip page with a sticky sidebar index.
|
**Goal:** Replace the redirect-based home page with a real side-by-side map + feed home page, add a past-trips archive, add story cards to all feeds, and enrich the trip page with a sticky sidebar index.
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
# MapLibre GL Migration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
|
**Goal:** Replace Leaflet JS across all three maps (full map, mini-map on dailies, home page map) with MapLibre GL JS, add an animated journey line, and improve map CSS using our design tokens.
|
||||||
|
|
||||||
|
**Architecture:** A shared JS utility file (`maplibre-utils.js`) provides `animateJourneyLine`, `addJourneyLine`, and `createDotMarker` — reused by all three map templates. Each template loads MapLibre GL + the utility file, then calls these helpers. GPX rendering switches from `leaflet-gpx` to `@mapbox/togeojson` + MapLibre GeoJSON layers.
|
||||||
|
|
||||||
|
**Tech Stack:** MapLibre GL JS 4.x (CDN), `@mapbox/togeojson` 0.16.2 (CDN), CARTO dark-matter vector style (free, no key), vanilla JS (no framework).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- MapLibre GL CDN: `https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js` and `.css`
|
||||||
|
- toGeoJSON CDN: `https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js`
|
||||||
|
- Map tile style URL: `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json`
|
||||||
|
- Accent colour (journey line, markers): `#2A8C73` — matches `--color-accent` in `tokens.css`
|
||||||
|
- Latest-entry marker accent: `#155244` (same as current Leaflet code)
|
||||||
|
- Animation duration: 5000ms, ease-out cubic
|
||||||
|
- Respect `prefers-reduced-motion: reduce` — skip animation, show full line immediately
|
||||||
|
- `cooperativeGestures` on embedded maps (mini-map, home map); full-page map uses default (free) gestures
|
||||||
|
- No new Grav plugins, no npm — CDN only
|
||||||
|
- Run `make content-push` after changes to sync to production git repo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: CSS — Remove Leaflet override, add MapLibre design-token styles
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css` (around line 371)
|
||||||
|
|
||||||
|
**What:** Delete the one Leaflet-specific rule and add a MapLibre CSS block that styles navigation controls, attribution bar, popups, and cursor using design tokens.
|
||||||
|
|
||||||
|
- [x] **Open style.css and find the Leaflet block**
|
||||||
|
|
||||||
|
Locate (around line 371):
|
||||||
|
```css
|
||||||
|
/* match CartoDB dark tile background so no grey flash on load/zoom */
|
||||||
|
.leaflet-container { background: #282828 !important; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Delete that rule and replace with the MapLibre block**
|
||||||
|
|
||||||
|
Delete the line above. Immediately after the `.map-empty { ... }` block (around line 381), add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── MapLibre GL overrides ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Navigation controls (zoom +/−) */
|
||||||
|
.maplibregl-ctrl-group {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-group button {
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-group button:hover {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-group button + button {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attribution bar */
|
||||||
|
.maplibregl-ctrl-attrib {
|
||||||
|
background: rgba(26, 24, 20, 0.75) !important;
|
||||||
|
color: var(--color-ink-muted) !important;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-attrib a {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup */
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.maplibregl-popup-tip {
|
||||||
|
border-top-color: var(--color-canvas) !important;
|
||||||
|
}
|
||||||
|
.maplibregl-popup-close-button {
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
}
|
||||||
|
.maplibregl-popup-close-button:hover {
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor */
|
||||||
|
.maplibregl-canvas-container.maplibregl-interactive { cursor: grab; }
|
||||||
|
.maplibregl-canvas-container.maplibregl-interactive:active { cursor: grabbing; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Verify: open `http://localhost:8081/map` in browser**
|
||||||
|
|
||||||
|
If no entries exist, run `make demo-load` first. Check:
|
||||||
|
- No JS errors in console
|
||||||
|
- Page layout unchanged (map still fills viewport below nav)
|
||||||
|
|
||||||
|
- [x] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/css/style.css
|
||||||
|
git -C user commit -m "style: swap Leaflet CSS override for MapLibre design-token styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Shared JS utilities file
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/themes/intotheeast/js/maplibre-utils.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `window.MapUtils.animateJourneyLine(map, coords, sourceId)`, `window.MapUtils.addJourneyLine(map, coords, sourceId)`, `window.MapUtils.createDotMarker(isLatest)`, `window.MapUtils.MAP_STYLE`, `window.MapUtils.ACCENT`
|
||||||
|
- Loaded by: all three map templates via `<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>`
|
||||||
|
|
||||||
|
**What:** Extract the animated journey line logic and marker factory into a single file so all three templates share one implementation.
|
||||||
|
|
||||||
|
- [x] **Create `user/themes/intotheeast/js/maplibre-utils.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
/* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
|
||||||
|
(function (global) {
|
||||||
|
var ACCENT = '#2A8C73';
|
||||||
|
var ACCENT_DIM = '#155244';
|
||||||
|
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||||
|
|
||||||
|
/* Build a GeoJSON LineString feature */
|
||||||
|
function lineFeature(coords) {
|
||||||
|
return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Progressively draw the journey line using a requestAnimationFrame loop.
|
||||||
|
* coords: [[lng, lat], ...] in chronological order.
|
||||||
|
* sourceId: the MapLibre source id to update each frame.
|
||||||
|
*/
|
||||||
|
function animateJourneyLine(map, coords, sourceId) {
|
||||||
|
if (coords.length < 2) return;
|
||||||
|
|
||||||
|
/* Cumulative Euclidean distance between waypoints */
|
||||||
|
var segDist = [0];
|
||||||
|
for (var i = 1; i < coords.length; i++) {
|
||||||
|
var dx = coords[i][0] - coords[i - 1][0];
|
||||||
|
var dy = coords[i][1] - coords[i - 1][1];
|
||||||
|
segDist.push(segDist[i - 1] + Math.sqrt(dx * dx + dy * dy));
|
||||||
|
}
|
||||||
|
var totalDist = segDist[segDist.length - 1];
|
||||||
|
var DURATION = 5000;
|
||||||
|
var startTime = performance.now();
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
if (!map.getSource(sourceId)) return; /* map was removed */
|
||||||
|
var t = Math.min((now - startTime) / DURATION, 1);
|
||||||
|
var eased = 1 - Math.pow(1 - t, 3); /* ease-out cubic */
|
||||||
|
var target = eased * totalDist;
|
||||||
|
|
||||||
|
var animCoords = [coords[0]];
|
||||||
|
for (var j = 1; j < coords.length; j++) {
|
||||||
|
if (segDist[j] <= target) {
|
||||||
|
animCoords.push(coords[j]);
|
||||||
|
} else {
|
||||||
|
var frac = (target - segDist[j - 1]) / (segDist[j] - segDist[j - 1]);
|
||||||
|
animCoords.push([
|
||||||
|
coords[j - 1][0] + (coords[j][0] - coords[j - 1][0]) * frac,
|
||||||
|
coords[j - 1][1] + (coords[j][1] - coords[j - 1][1]) * frac
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.getSource(sourceId).setData(lineFeature(animCoords));
|
||||||
|
if (t < 1) requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add a journey line source + two layers (glow + main) to a loaded map,
|
||||||
|
* then animate or draw instantly based on prefers-reduced-motion.
|
||||||
|
*/
|
||||||
|
function addJourneyLine(map, coords, sourceId) {
|
||||||
|
if (coords.length < 2) return;
|
||||||
|
|
||||||
|
map.addSource(sourceId, { type: 'geojson', data: lineFeature([coords[0]]) });
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: sourceId + '-glow', type: 'line', source: sourceId,
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': ACCENT, 'line-width': 6, 'line-opacity': 0.18 }
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: sourceId + '-line', type: 'line', source: sourceId,
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': ACCENT, 'line-width': 2.5, 'line-opacity': 0.85 }
|
||||||
|
});
|
||||||
|
|
||||||
|
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (reducedMotion) {
|
||||||
|
map.getSource(sourceId).setData(lineFeature(coords));
|
||||||
|
} else {
|
||||||
|
animateJourneyLine(map, coords, sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return a styled <div> element for a map marker dot.
|
||||||
|
* isLatest: make it larger with a teal ring.
|
||||||
|
*/
|
||||||
|
function createDotMarker(isLatest) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
var size = isLatest ? 18 : 12;
|
||||||
|
var bg = isLatest ? ACCENT_DIM : ACCENT;
|
||||||
|
var ring = isLatest ? ',0 0 0 4px rgba(42,140,115,0.25)' : '';
|
||||||
|
el.style.cssText = [
|
||||||
|
'width:' + size + 'px',
|
||||||
|
'height:' + size + 'px',
|
||||||
|
'background:' + bg,
|
||||||
|
'border:2px solid #fff',
|
||||||
|
'border-radius:50%',
|
||||||
|
'box-shadow:0 1px 4px rgba(0,0,0,0.4)' + ring,
|
||||||
|
'cursor:pointer'
|
||||||
|
].join(';');
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.MapUtils = { MAP_STYLE: MAP_STYLE, ACCENT: ACCENT, addJourneyLine: addJourneyLine, createDotMarker: createDotMarker };
|
||||||
|
})(window);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Verify the file parses without syntax errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --check /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/maplibre-utils.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output (clean parse).
|
||||||
|
|
||||||
|
- [x] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/js/maplibre-utils.js
|
||||||
|
git -C user commit -m "feat: add shared MapLibre GL utilities (journey line, markers)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Full map page — migrate map.html.twig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/map.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `window.MapUtils` from Task 2 (`MAP_STYLE`, `addJourneyLine`, `createDotMarker`)
|
||||||
|
- Twig data shape consumed unchanged: `map_entries` array with `lat`, `lng`, `title`, `date`, `url`, `hero` keys; `gpx_urls` array of strings
|
||||||
|
|
||||||
|
**What:** Replace the Leaflet map + GPX rendering with MapLibre GL. Keep all Twig data-gathering logic at the top unchanged. Only the HTML/CSS/JS at the bottom changes.
|
||||||
|
|
||||||
|
- [x] **Replace everything from `<div class="map-container"...>` to end of `{% endblock %}`**
|
||||||
|
|
||||||
|
The Twig data-gathering at the top (lines 1–33) is unchanged. Replace from line 35 onwards with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<div class="map-container" id="trip-map"></div>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var ENTRIES = {{ map_entries|json_encode|raw }};
|
||||||
|
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
|
||||||
|
|
||||||
|
var map = new maplibregl.Map({
|
||||||
|
container: 'trip-map',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||||
|
|
||||||
|
if (ENTRIES.length === 0) {
|
||||||
|
var empty = document.createElement('div');
|
||||||
|
empty.className = 'map-empty';
|
||||||
|
empty.textContent = 'No locations yet — entries with GPS will appear here.';
|
||||||
|
document.getElementById('trip-map').appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('load', function () {
|
||||||
|
/* ── GPX tracks ──────────────────────────────────────────── */
|
||||||
|
GPX_URLS.forEach(function (url, idx) {
|
||||||
|
fetch(url)
|
||||||
|
.then(function (r) { return r.text(); })
|
||||||
|
.then(function (text) {
|
||||||
|
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||||
|
var geojson = toGeoJSON.gpx(xml);
|
||||||
|
var sid = 'gpx-' + idx;
|
||||||
|
map.addSource(sid, { type: 'geojson', data: geojson });
|
||||||
|
map.addLayer({
|
||||||
|
id: sid + '-line', type: 'line', source: sid,
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function (err) { console.warn('GPX load failed:', url, err); });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ENTRIES.length === 0) return;
|
||||||
|
|
||||||
|
/* ── Markers ─────────────────────────────────────────────── */
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
var coords = [];
|
||||||
|
|
||||||
|
ENTRIES.forEach(function (entry, i) {
|
||||||
|
var isLatest = (i === ENTRIES.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
coords.push(lngLat);
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = MapUtils.createDotMarker(isLatest);
|
||||||
|
|
||||||
|
var popupHtml = '<div style="min-width:160px;max-width:200px;">';
|
||||||
|
if (entry.hero) {
|
||||||
|
popupHtml += '<img src="' + entry.hero + '" alt="" style="width:100%;height:80px;object-fit:cover;border-radius:4px;display:block;margin-bottom:8px;">';
|
||||||
|
}
|
||||||
|
popupHtml += '<div style="font-size:0.75rem;color:var(--color-ink-muted);margin-bottom:2px;">📅 ' + entry.date + '</div>';
|
||||||
|
popupHtml += '<div style="font-weight:600;font-size:0.9rem;margin-bottom:8px;color:var(--color-ink);">' + entry.title + '</div>';
|
||||||
|
popupHtml += '<a href="' + entry.url + '" style="color:var(--color-accent);font-size:0.85rem;text-decoration:none;">Read entry →</a>';
|
||||||
|
popupHtml += '</div>';
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setPopup(new maplibregl.Popup({ offset: 10, maxWidth: '220px' }).setHTML(popupHtml))
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Journey line ────────────────────────────────────────── */
|
||||||
|
MapUtils.addJourneyLine(map, coords, 'journey');
|
||||||
|
|
||||||
|
/* ── Fit bounds ──────────────────────────────────────────── */
|
||||||
|
if (ENTRIES.length === 1) {
|
||||||
|
map.jumpTo({ center: coords[0], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: 60, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Verify in browser at `http://localhost:8081/trips/japan-korea-2026/map`**
|
||||||
|
|
||||||
|
With demo data loaded (`make demo-load`):
|
||||||
|
- Dark vector map fills the viewport
|
||||||
|
- 7 teal dot markers visible on Japan→Korea route
|
||||||
|
- Journey line animates in over ~5 seconds on load
|
||||||
|
- Click a marker → popup appears with date, title, "Read entry →" link
|
||||||
|
- Navigate controls (zoom +/−) are styled with dark background (design tokens)
|
||||||
|
- Attribution bar is dark/muted (not white)
|
||||||
|
- No console errors
|
||||||
|
|
||||||
|
- [x] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/map.html.twig
|
||||||
|
git -C user commit -m "feat: migrate full map page to MapLibre GL with animated journey line"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Embedded maps — migrate dailies mini-map and home map
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/dailies.html.twig` (mini-map section, around lines 37–78)
|
||||||
|
- Modify: `user/themes/intotheeast/templates/home.html.twig` (map section, around lines 126–168)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `window.MapUtils` from Task 2
|
||||||
|
- Twig data shapes unchanged: `map_entries` (both files) with `lat`, `lng`, `title`, `slug`, `url` keys
|
||||||
|
|
||||||
|
**What:** Both embedded maps follow the same pattern — no GPX, no popup (markers navigate on click), `cooperativeGestures: true` to prevent mobile scroll-trap, animated line via `MapUtils.addJourneyLine`.
|
||||||
|
|
||||||
|
- [x] **Replace the map block in `dailies.html.twig`**
|
||||||
|
|
||||||
|
Find the `{% if map_entries|length > 0 %}` block (around line 31) and replace from there to the closing `{% endif %}` and the script block:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if map_entries|length > 0 %}
|
||||||
|
<div class="feed-map-wrap">
|
||||||
|
<div class="feed-map" id="feed-map"></div>
|
||||||
|
<a class="feed-map-link" href="{{ page.parent().url }}/map">View full map →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
|
||||||
|
|
||||||
|
var feedMap = new maplibregl.Map({
|
||||||
|
container: 'feed-map',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2,
|
||||||
|
cooperativeGestures: true
|
||||||
|
});
|
||||||
|
|
||||||
|
feedMap.on('load', function () {
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
var coords = [];
|
||||||
|
|
||||||
|
FEED_ENTRIES.forEach(function (entry, i) {
|
||||||
|
var isLatest = (i === FEED_ENTRIES.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
coords.push(lngLat);
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = MapUtils.createDotMarker(isLatest);
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
window.location.href = entry.url;
|
||||||
|
});
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
MapUtils.addJourneyLine(feedMap, coords, 'feed-journey');
|
||||||
|
|
||||||
|
if (FEED_ENTRIES.length === 1) {
|
||||||
|
feedMap.jumpTo({ center: coords[0], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
feedMap.fitBounds(bounds, { padding: 20, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Replace the map block in `home.html.twig`**
|
||||||
|
|
||||||
|
Find the `{% if map_entries|length > 0 %}` block (around line 125) and replace from there to end of `{% endblock %}`:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if map_entries|length > 0 %}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
var HOME_ENTRIES = {{ map_entries|json_encode|raw }};
|
||||||
|
|
||||||
|
var homeMap = new maplibregl.Map({
|
||||||
|
container: 'home-map',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2,
|
||||||
|
cooperativeGestures: true
|
||||||
|
});
|
||||||
|
|
||||||
|
homeMap.on('load', function () {
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
var coords = [];
|
||||||
|
|
||||||
|
HOME_ENTRIES.forEach(function (entry, i) {
|
||||||
|
var isLatest = (i === HOME_ENTRIES.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
coords.push(lngLat);
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = MapUtils.createDotMarker(isLatest);
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('entry-' + entry.slug);
|
||||||
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(homeMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
MapUtils.addJourneyLine(homeMap, coords, 'home-journey');
|
||||||
|
|
||||||
|
if (HOME_ENTRIES.length === 1) {
|
||||||
|
homeMap.jumpTo({ center: coords[0], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
homeMap.fitBounds(bounds, { padding: 20, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(function () { homeMap.resize(); }, 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Verify mini-map at `http://localhost:8081/trips/japan-korea-2026/dailies`**
|
||||||
|
|
||||||
|
- Mini-map appears above journal feed with dark vector tiles
|
||||||
|
- Journey line animates in
|
||||||
|
- Click a marker → navigates to that entry's page (not a popup)
|
||||||
|
- On mobile: pinch-zoom within the mini-map requires two fingers; one finger scrolls the page past it
|
||||||
|
- "View full map →" link works
|
||||||
|
|
||||||
|
- [x] **Verify home map at `http://localhost:8081`**
|
||||||
|
|
||||||
|
- Left column sticky map shows dark vector tiles
|
||||||
|
- Journey line animates in
|
||||||
|
- Click a marker → page scrolls to the matching entry card in the right column
|
||||||
|
- On mobile (< 768px): map collapses to 40vh above the feed, touch-scroll works on page
|
||||||
|
|
||||||
|
- [x] **Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/dailies.html.twig themes/intotheeast/templates/home.html.twig
|
||||||
|
git -C user commit -m "feat: migrate mini-map and home map to MapLibre GL"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Final sync**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
# Stats Redesign — Implementation Plan
|
||||||
|
|
||||||
|
*Derived from spec: docs/working/specs/2026-06-19-stats-redesign.md*
|
||||||
|
|
||||||
|
> **For agentic workers:** Use superpowers:subagent-driven-development to execute this plan task-by-task.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
|
**Goal:** Expand trip statistics from 4 to 6 stats (add cities visited + temperature range), add smart distance labelling (Mode A: GPX-based "km cycled" vs Mode B: entry-lat/lng "km roamed"), and add a collapsible cycling panel (only when GPX files are present) with 7 cycling-specific stats derived from GPX track data.
|
||||||
|
|
||||||
|
**Architecture:** Twig server-side computation for new stats (cities, temp range, GPX detection, date_end-aware days-on-road). Client-side JS for: distance computation in both modes, GPX parsing, cycling panel population. No new pages, no Grav config changes.
|
||||||
|
|
||||||
|
**Tech Stack:** Twig (Grav 2.0), vanilla JS (ES5), CSS custom properties
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **ES5 JS only** — no `const`/`let`, no arrow functions `() =>`, no template literals `` ` `` — all scripts are inline Twig and run as plain `<script>` blocks
|
||||||
|
- **CSS custom properties only** — no raw hex or pixel values; use tokens from `tokens.css`
|
||||||
|
- **6 stats must be identical** between `stats.html.twig` and the inline stats block in `trip.html.twig` — same order, same labels, same Twig logic
|
||||||
|
- `parseGpxFiles` function defined **once** in `trip.html.twig`; shared between distance Mode A update and cycling panel population
|
||||||
|
- `stats.html.twig` does **not** have a cycling panel — GPX parsing there is simpler (only for distance)
|
||||||
|
- Do **not** touch `dailies.html.twig`, `map.html.twig`, `stories.html.twig`, `entry.html.twig`, or any other template
|
||||||
|
- Commit after each task in the `user/` sub-repo (cd to `user/` before `git add` / `git commit`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference: Existing Files
|
||||||
|
|
||||||
|
- `user/themes/intotheeast/templates/stats.html.twig` — standalone stats page
|
||||||
|
- `user/themes/intotheeast/templates/trip.html.twig` — trip page (has inline stats block + filter bar)
|
||||||
|
- `user/themes/intotheeast/css/style.css`
|
||||||
|
- `.stats-grid` at line ~468: `grid-template-columns: repeat(2, 1fr)` — used by stats.html.twig
|
||||||
|
- `.stat-block`, `.stat-value`, `.stat-label` at lines ~475–502
|
||||||
|
- `.trip-stats-grid` at line ~987: `grid-template-columns: repeat(4, 1fr)` — used by trip inline block
|
||||||
|
- `.trip-stats-block`, `.trip-stats-note`, `.trip-stats-countries` at lines ~979–1008
|
||||||
|
- `.trip-stats-btn` at line ~789 — both Stats and Cycling buttons share this class
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Six Stats (order matters — apply identically in both templates)
|
||||||
|
|
||||||
|
| # | Stat | Label | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Days on the road | `day/days on the road` | `date_end - date_start` if `date_end` set; else `now - first entry date` | date_end-aware |
|
||||||
|
| 2 | Entries posted | `entry/entries posted` | `all_entries\|length` | Unchanged |
|
||||||
|
| 3 | Countries visited | `country/countries visited` | Dedup `location_country` | Unchanged |
|
||||||
|
| 4 | Cities visited | `city/cities visited` | Dedup `location_city` | New |
|
||||||
|
| 5 | Distance | `km cycled` (Mode A) or `km roamed` (Mode B) | GPX trackpoints (A) or entry lat/lng (B) | Label + JS value |
|
||||||
|
| 6 | Temperature range | `°C range` | min/max `weather_temp_c` | New; value: `−2 → 28` or `18` if single; `—` if no data |
|
||||||
|
|
||||||
|
**Distance stat stat-note text:**
|
||||||
|
- Mode A (GPX): `"Distance based on GPS track data."`
|
||||||
|
- Mode B (no GPX): `"Distance is approximate — straight lines between entry locations."`
|
||||||
|
|
||||||
|
**Distance stat icon (in label, as emoji prefix):**
|
||||||
|
- Mode A: `🚴 km cycled`
|
||||||
|
- Mode B: `🧭 km roamed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPX Parsing Algorithm (for both templates)
|
||||||
|
|
||||||
|
```
|
||||||
|
Master trackpoints = []
|
||||||
|
for each GPX URL:
|
||||||
|
fetch URL → parse as XML via DOMParser
|
||||||
|
get all <trkpt> elements
|
||||||
|
for each <trkpt>:
|
||||||
|
lat = parseFloat(trkpt.getAttribute('lat'))
|
||||||
|
lon = parseFloat(trkpt.getAttribute('lon'))
|
||||||
|
ele = parseFloat(trkpt.querySelector('ele').textContent) [or NaN if missing]
|
||||||
|
time = trkpt.querySelector('time').textContent [ISO 8601 string]
|
||||||
|
push {lat, lon, ele, time} to Master
|
||||||
|
|
||||||
|
Compute over Master (length n):
|
||||||
|
distance = sum haversine(p[i-1], p[i]) for i=1..n-1 [km]
|
||||||
|
ele_gain = sum max(0, ele[i]-ele[i-1]-1) for i=1..n-1 [m, 1m threshold]
|
||||||
|
ele_loss = sum max(0, ele[i-1]-ele[i]-1) for i=1..n-1 [m, 1m threshold]
|
||||||
|
highest = max(ele) across all trackpoints [m]
|
||||||
|
lowest = min(ele) across all trackpoints [m]
|
||||||
|
dt_hrs[i] = (Date.parse(time[i]) - Date.parse(time[i-1])) / 3600000 [hours]
|
||||||
|
speed[i] = haversine(p[i-1], p[i]) / dt_hrs[i] [km/h]
|
||||||
|
moving_time = sum dt_hrs[i] where speed[i] >= 1 [hours]
|
||||||
|
avg_speed = distance / moving_time [km/h]
|
||||||
|
moving_time_fmt = floor(moving_time) + ':' + padded_minutes [h:mm]
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip segments where dt_hrs[i] is 0 or NaN (avoids divide-by-zero). Skip `ele` computation for trackpoints where ele is NaN.
|
||||||
|
|
||||||
|
**Haversine function** (same as already used in trip.html.twig):
|
||||||
|
```javascript
|
||||||
|
function haversine(lat1, lng1, lat2, lng2) {
|
||||||
|
var R = 6371;
|
||||||
|
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
|
||||||
|
Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*
|
||||||
|
Math.sin(dLng/2)*Math.sin(dLng/2);
|
||||||
|
return R * 2 * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Update `stats.html.twig` — 6-stat grid + distance mode detection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/stats.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css` (`.stats-grid` only)
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
### Twig changes in stats.html.twig
|
||||||
|
|
||||||
|
The trip page is `page.parent()`. Add after the existing Twig computation block (after the `gps_points` collection loop):
|
||||||
|
|
||||||
|
**1. Date-end-aware days on road:**
|
||||||
|
|
||||||
|
Replace the existing `first_ts`/`days_on_road` block with:
|
||||||
|
```twig
|
||||||
|
{% set trip_page = page.parent() %}
|
||||||
|
{% set days_on_road = 0 %}
|
||||||
|
{% if trip_page.header.date_end is not empty %}
|
||||||
|
{# Past trip: use declared end date #}
|
||||||
|
{% set start_ts = trip_page.header.date_start|date('U') %}
|
||||||
|
{% set end_ts = trip_page.header.date_end|date('U') %}
|
||||||
|
{% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %}
|
||||||
|
{% else %}
|
||||||
|
{# Active trip: first entry to now #}
|
||||||
|
{% set first_ts = null %}
|
||||||
|
{% for entry in all_entries %}
|
||||||
|
{% set ts = entry.date|date('U') %}
|
||||||
|
{% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if first_ts is not null %}
|
||||||
|
{% set diff_seconds = "now"|date('U') - first_ts %}
|
||||||
|
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
|
||||||
|
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Cities dedup** (add after country dedup block, same pattern):
|
||||||
|
```twig
|
||||||
|
{% set seen_city_lower = [] %}
|
||||||
|
{% set city_display = [] %}
|
||||||
|
{% for entry in all_entries %}
|
||||||
|
{% if entry.header.location_city is not empty %}
|
||||||
|
{% set lower = entry.header.location_city|trim|lower %}
|
||||||
|
{% if lower not in seen_city_lower %}
|
||||||
|
{% set seen_city_lower = seen_city_lower|merge([lower]) %}
|
||||||
|
{% set city_display = city_display|merge([entry.header.location_city|trim]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Temperature range** (add after cities block):
|
||||||
|
```twig
|
||||||
|
{% set temp_min = null %}
|
||||||
|
{% set temp_max = null %}
|
||||||
|
{% for entry in all_entries %}
|
||||||
|
{% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %}
|
||||||
|
{% set t = entry.header.weather_temp_c %}
|
||||||
|
{% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %}
|
||||||
|
{% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. GPX detection** (add after gps_points collection):
|
||||||
|
```twig
|
||||||
|
{% set gpx_urls = [] %}
|
||||||
|
{% for name, media in trip_page.media.all %}
|
||||||
|
{% if name|split('.')|last == 'gpx' %}
|
||||||
|
{% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set has_gpx = gpx_urls|length > 0 %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML changes in stats.html.twig
|
||||||
|
|
||||||
|
Replace the current 4-stat grid with a 6-stat grid in this order:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ days_on_road }}</span>
|
||||||
|
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ entry_count }}</span>
|
||||||
|
<span class="stat-label">{{ entry_count == 1 ? 'entry' : 'entries' }} posted</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ country_display|length }}</span>
|
||||||
|
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ city_display|length }}</span>
|
||||||
|
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="stat-distance">—</span>
|
||||||
|
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
{% if temp_min is not null %}
|
||||||
|
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="stat-value">—</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="stat-label">°C range</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the stats note (below the countries list) to be mode-sensitive:
|
||||||
|
```twig
|
||||||
|
<p class="stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### JS changes in stats.html.twig
|
||||||
|
|
||||||
|
Replace the existing haversine/distance script entirely with mode-aware logic:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
<script>
|
||||||
|
var GPS_POINTS = {{ gps_points|json_encode|raw }};
|
||||||
|
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
|
||||||
|
|
||||||
|
function haversine(lat1, lng1, lat2, lng2) {
|
||||||
|
var R = 6371;
|
||||||
|
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
|
||||||
|
Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*
|
||||||
|
Math.sin(dLng/2)*Math.sin(dLng/2);
|
||||||
|
return R * 2 * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
var distEl = document.getElementById('stat-distance');
|
||||||
|
|
||||||
|
if (GPX_URLS.length > 0) {
|
||||||
|
// Mode A: sum haversine between all GPX trackpoints
|
||||||
|
var pending = GPX_URLS.length;
|
||||||
|
var masterPts = [];
|
||||||
|
GPX_URLS.forEach(function(url) {
|
||||||
|
fetch(url)
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(text) {
|
||||||
|
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||||
|
var trkpts = xml.querySelectorAll('trkpt');
|
||||||
|
trkpts.forEach(function(pt) {
|
||||||
|
masterPts.push({
|
||||||
|
lat: parseFloat(pt.getAttribute('lat')),
|
||||||
|
lon: parseFloat(pt.getAttribute('lon'))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
pending--;
|
||||||
|
if (pending === 0) {
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 1; i < masterPts.length; i++) {
|
||||||
|
total += haversine(masterPts[i-1].lat, masterPts[i-1].lon,
|
||||||
|
masterPts[i].lat, masterPts[i].lon);
|
||||||
|
}
|
||||||
|
distEl.textContent = masterPts.length < 2 ? '—' : Math.round(total).toLocaleString();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { console.warn('GPX load failed:', url, err); pending--; });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Mode B: sum haversine between consecutive entry lat/lng points
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 1; i < GPS_POINTS.length; i++) {
|
||||||
|
total += haversine(
|
||||||
|
parseFloat(GPS_POINTS[i-1][0]), parseFloat(GPS_POINTS[i-1][1]),
|
||||||
|
parseFloat(GPS_POINTS[i][0]), parseFloat(GPS_POINTS[i][1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
distEl.textContent = GPS_POINTS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS change in style.css
|
||||||
|
|
||||||
|
Update `.stats-grid` from 2 to 3 columns:
|
||||||
|
```css
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the mobile breakpoint if one exists; add one if not:
|
||||||
|
```css
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd user && git add themes/intotheeast/templates/stats.html.twig themes/intotheeast/css/style.css
|
||||||
|
git commit -m "feat: expand stats page to 6 stats — cities, temp range, distance mode detection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Update `trip.html.twig` — inline stats + cycling panel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
### Twig changes in trip.html.twig
|
||||||
|
|
||||||
|
Add after the existing `{% set story_count %}` line (line ~19), mirroring Task 1's logic but using `page` directly (not `page.parent()`):
|
||||||
|
|
||||||
|
**1. Date-end-aware days on road** — replace the existing `days_on_road` block:
|
||||||
|
```twig
|
||||||
|
{% set days_on_road = 0 %}
|
||||||
|
{% if page.header.date_end is not empty %}
|
||||||
|
{% set start_ts = page.header.date_start|date('U') %}
|
||||||
|
{% set end_ts = page.header.date_end|date('U') %}
|
||||||
|
{% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %}
|
||||||
|
{% else %}
|
||||||
|
{% set first_ts = null %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% set ts = entry.date|date('U') %}
|
||||||
|
{% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if first_ts is not null %}
|
||||||
|
{% set diff_seconds = "now"|date('U') - first_ts %}
|
||||||
|
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
|
||||||
|
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Cities dedup** (add after country dedup block):
|
||||||
|
```twig
|
||||||
|
{% set seen_city_lower = [] %}
|
||||||
|
{% set city_display = [] %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% if entry.header.location_city is not empty %}
|
||||||
|
{% set lower = entry.header.location_city|trim|lower %}
|
||||||
|
{% if lower not in seen_city_lower %}
|
||||||
|
{% set seen_city_lower = seen_city_lower|merge([lower]) %}
|
||||||
|
{% set city_display = city_display|merge([entry.header.location_city|trim]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Temperature range** (add after cities block):
|
||||||
|
```twig
|
||||||
|
{% set temp_min = null %}
|
||||||
|
{% set temp_max = null %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %}
|
||||||
|
{% set t = entry.header.weather_temp_c %}
|
||||||
|
{% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %}
|
||||||
|
{% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. GPX detection** — `gpx_urls` already computed in trip.html.twig; add:
|
||||||
|
```twig
|
||||||
|
{% set has_gpx = gpx_urls|length > 0 %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML changes in trip.html.twig
|
||||||
|
|
||||||
|
**A. Update filter bar** — add Cycling button next to Stats button (hidden if no GPX):
|
||||||
|
|
||||||
|
Find the current filter bar:
|
||||||
|
```twig
|
||||||
|
<div class="trip-filter-bar">
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="journal">Journal</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="story">Stories</button>
|
||||||
|
</div>
|
||||||
|
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```twig
|
||||||
|
<div class="trip-filter-bar">
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="journal">Journal</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="story">Stories</button>
|
||||||
|
</div>
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
|
||||||
|
{% if has_gpx %}
|
||||||
|
<button class="trip-stats-btn" id="trip-cycling-toggle">Cycling</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Update inline stats block** — expand from 4 to 6 stats (same order as Task 1):
|
||||||
|
|
||||||
|
Replace the current `.trip-stats-grid` content with:
|
||||||
|
```twig
|
||||||
|
<div id="trip-stats-block" class="trip-stats-block" style="display:none">
|
||||||
|
<div class="trip-stats-grid">
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ days_on_road }}</span>
|
||||||
|
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ journal_count }}</span>
|
||||||
|
<span class="stat-label">{{ journal_count == 1 ? 'entry' : 'entries' }} posted</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ country_display|length }}</span>
|
||||||
|
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ city_display|length }}</span>
|
||||||
|
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="stat-distance">—</span>
|
||||||
|
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
{% if temp_min is not null %}
|
||||||
|
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="stat-value">—</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="stat-label">°C range</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if country_display|length > 0 %}
|
||||||
|
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="trip-stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**C. Add cycling panel** — immediately after the inline stats block, before `<div class="feed">`:
|
||||||
|
```twig
|
||||||
|
{% if has_gpx %}
|
||||||
|
<div id="trip-cycling-block" class="trip-cycling-block" style="display:none">
|
||||||
|
<div class="trip-cycling-header">
|
||||||
|
<span class="trip-cycling-icon">🚴</span>
|
||||||
|
<span class="trip-cycling-title">Cycling Stats</span>
|
||||||
|
</div>
|
||||||
|
<div class="trip-cycling-grid">
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-distance">—</span>
|
||||||
|
<span class="stat-label">km distance</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-ele-gain">—</span>
|
||||||
|
<span class="stat-label">m ↑ gain</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-ele-loss">—</span>
|
||||||
|
<span class="stat-label">m ↓ loss</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-highest">—</span>
|
||||||
|
<span class="stat-label">m highest</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-lowest">—</span>
|
||||||
|
<span class="stat-label">m lowest</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-moving-time">—</span>
|
||||||
|
<span class="stat-label">moving time</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-avg-speed">—</span>
|
||||||
|
<span class="stat-label">km/h avg speed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JS changes in trip.html.twig
|
||||||
|
|
||||||
|
The existing script block has: map setup, GPX route drawing for map, filter bar JS, stats distance + toggle JS.
|
||||||
|
|
||||||
|
Make the following JS changes:
|
||||||
|
|
||||||
|
**1. Replace the existing `STATS_GPS` + distance IIFE** with a unified GPX/distance function (place after the existing map + filter bar IIFE, before `</script>`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var STATS_GPS = {{ gps_points|json_encode|raw }};
|
||||||
|
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
|
||||||
|
|
||||||
|
function haversineKm(lat1, lng1, lat2, lng2) {
|
||||||
|
var R = 6371;
|
||||||
|
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
|
||||||
|
Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*
|
||||||
|
Math.sin(dLng/2)*Math.sin(dLng/2);
|
||||||
|
return R * 2 * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGpxFiles(urls, callback) {
|
||||||
|
var pending = urls.length;
|
||||||
|
var masterPts = [];
|
||||||
|
if (pending === 0) { callback({ error: 'no files' }); return; }
|
||||||
|
urls.forEach(function(url) {
|
||||||
|
fetch(url)
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(text) {
|
||||||
|
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||||
|
var trkpts = xml.querySelectorAll('trkpt');
|
||||||
|
trkpts.forEach(function(pt) {
|
||||||
|
var eleEl = pt.querySelector('ele');
|
||||||
|
var timeEl = pt.querySelector('time');
|
||||||
|
masterPts.push({
|
||||||
|
lat: parseFloat(pt.getAttribute('lat')),
|
||||||
|
lon: parseFloat(pt.getAttribute('lon')),
|
||||||
|
ele: eleEl ? parseFloat(eleEl.textContent) : NaN,
|
||||||
|
time: timeEl ? timeEl.textContent : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
pending--;
|
||||||
|
if (pending === 0) { computeAndCallback(); }
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.warn('GPX load failed:', url, err);
|
||||||
|
pending--;
|
||||||
|
if (pending === 0) { computeAndCallback(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeAndCallback() {
|
||||||
|
var n = masterPts.length;
|
||||||
|
if (n < 2) { callback({ distance: 0 }); return; }
|
||||||
|
var distance = 0, eleGain = 0, eleLoss = 0;
|
||||||
|
var highest = NaN, lowest = NaN, movingTime = 0;
|
||||||
|
for (var i = 1; i < n; i++) {
|
||||||
|
var p0 = masterPts[i-1], p1 = masterPts[i];
|
||||||
|
var d = haversineKm(p0.lat, p0.lon, p1.lat, p1.lon);
|
||||||
|
distance += d;
|
||||||
|
if (!isNaN(p0.ele) && !isNaN(p1.ele)) {
|
||||||
|
var dEle = p1.ele - p0.ele;
|
||||||
|
if (dEle > 1) eleGain += dEle - 1;
|
||||||
|
else if (dEle < -1) eleLoss += (-dEle) - 1;
|
||||||
|
if (isNaN(highest) || p1.ele > highest) highest = p1.ele;
|
||||||
|
if (isNaN(lowest) || p1.ele < lowest) lowest = p1.ele;
|
||||||
|
}
|
||||||
|
if (p0.time && p1.time) {
|
||||||
|
var dtHrs = (Date.parse(p1.time) - Date.parse(p0.time)) / 3600000;
|
||||||
|
if (dtHrs > 0) {
|
||||||
|
var speed = d / dtHrs;
|
||||||
|
if (speed >= 1) movingTime += dtHrs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// include first point in elevation range
|
||||||
|
if (!isNaN(masterPts[0].ele)) {
|
||||||
|
if (isNaN(highest) || masterPts[0].ele > highest) highest = masterPts[0].ele;
|
||||||
|
if (isNaN(lowest) || masterPts[0].ele < lowest) lowest = masterPts[0].ele;
|
||||||
|
}
|
||||||
|
var avgSpeed = movingTime > 0 ? distance / movingTime : 0;
|
||||||
|
var movHours = Math.floor(movingTime);
|
||||||
|
var movMins = Math.round((movingTime - movHours) * 60);
|
||||||
|
if (movMins === 60) { movHours++; movMins = 0; }
|
||||||
|
callback({
|
||||||
|
distance: distance,
|
||||||
|
eleGain: eleGain,
|
||||||
|
eleLoss: eleLoss,
|
||||||
|
highest: highest,
|
||||||
|
lowest: lowest,
|
||||||
|
movingTime: movHours + ':' + (movMins < 10 ? '0' : '') + movMins,
|
||||||
|
avgSpeed: avgSpeed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var distEl = document.getElementById('stat-distance');
|
||||||
|
|
||||||
|
if (HAS_GPX) {
|
||||||
|
parseGpxFiles(GPX_URLS, function(result) {
|
||||||
|
// Mode A: update distance stat
|
||||||
|
if (distEl) {
|
||||||
|
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
|
||||||
|
}
|
||||||
|
// Populate cycling panel
|
||||||
|
function setText(id, val) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
|
||||||
|
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
|
||||||
|
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
|
||||||
|
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
|
||||||
|
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
|
||||||
|
setText('cyc-moving-time', result.movingTime || '—');
|
||||||
|
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Mode B: haversine between entry points
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 1; i < STATS_GPS.length; i++) {
|
||||||
|
total += haversineKm(
|
||||||
|
parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
|
||||||
|
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (distEl) {
|
||||||
|
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats toggle
|
||||||
|
var statsToggle = document.getElementById('trip-stats-toggle');
|
||||||
|
var statsBlock = document.getElementById('trip-stats-block');
|
||||||
|
if (statsToggle && statsBlock) {
|
||||||
|
statsToggle.addEventListener('click', function() {
|
||||||
|
var isOpen = statsBlock.style.display !== 'none';
|
||||||
|
statsBlock.style.display = isOpen ? 'none' : '';
|
||||||
|
statsToggle.classList.toggle('is-active', !isOpen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycling toggle (only present when has_gpx)
|
||||||
|
var cycToggle = document.getElementById('trip-cycling-toggle');
|
||||||
|
var cycBlock = document.getElementById('trip-cycling-block');
|
||||||
|
if (cycToggle && cycBlock) {
|
||||||
|
cycToggle.addEventListener('click', function() {
|
||||||
|
var isOpen = cycBlock.style.display !== 'none';
|
||||||
|
cycBlock.style.display = isOpen ? 'none' : '';
|
||||||
|
cycToggle.classList.toggle('is-active', !isOpen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Remove the old `STATS_GPS` declaration and the old stats IIFE that's currently in the template (the one starting with `var STATS_GPS = ...`), replacing it entirely with the new unified block above. The `haversine` function used by `MapUtils.addJourneyLine` is in `maplibre-utils.js` — the new `haversineKm` function in this script is a local copy for stats; do not remove any map-related code.
|
||||||
|
|
||||||
|
### CSS changes in style.css
|
||||||
|
|
||||||
|
**1. Update `.trip-stats-grid`** from 4 to 3 columns (3 columns × 2 rows = 6 stats):
|
||||||
|
```css
|
||||||
|
.trip-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Add cycling panel styles** (after the existing `.trip-stats-note` rule):
|
||||||
|
```css
|
||||||
|
/* ── Trip page cycling panel ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.trip-cycling-block {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-cycling-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-cycling-icon {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-cycling-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-cycling-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.trip-cycling-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd user && git add themes/intotheeast/templates/trip.html.twig themes/intotheeast/css/style.css
|
||||||
|
git commit -m "feat: expand trip inline stats to 6 stats + add cycling panel with GPX parsing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- [x] Both templates show exactly 6 stats in the same order (days, entries, countries, cities, distance, temp range)
|
||||||
|
- [x] Distance label is server-side conditional: "🚴 km cycled" (GPX) vs "🧭 km roamed" (no GPX)
|
||||||
|
- [x] Stats note text is conditional matching the mode
|
||||||
|
- [x] GPX Mode A: fetches all GPX files, sums trackpoint haversine distances
|
||||||
|
- [x] GPX Mode B: sums haversine between consecutive entry lat/lng points
|
||||||
|
- [x] Cycling button only rendered when `has_gpx` is true
|
||||||
|
- [x] Cycling panel hidden by default; toggled by cycling button
|
||||||
|
- [x] Stats toggle and Cycling toggle are independent (opening one doesn't close the other)
|
||||||
|
- [x] `parseGpxFiles` called once; results used for both distance stat and cycling panel
|
||||||
|
- [x] Old haversine function and STATS_GPS IIFE removed and replaced in trip.html.twig
|
||||||
|
- [x] `.stats-grid` updated to 3 columns
|
||||||
|
- [x] `.trip-stats-grid` updated to 3 columns
|
||||||
|
- [x] Cycling panel CSS added
|
||||||
|
- [x] No raw hex/pixel values in CSS
|
||||||
|
- [x] No ES6 syntax in inline JS
|
||||||
File diff suppressed because it is too large
Load Diff
+2
@@ -1,5 +1,7 @@
|
|||||||
# Trip Entity Implementation Plan
|
# Trip Entity Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-19)
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task.
|
||||||
|
|
||||||
**Goal:** Restructure the site around a Trip entity — tracker/map/stats/stories become children of `/trips/japan-korea-2026/`, GPX route files live as media on the trip page, and `site.yaml` holds an `active_trip` slug so the nav can switch trips via config.
|
**Goal:** Restructure the site around a Trip entity — tracker/map/stats/stories become children of `/trips/japan-korea-2026/`, GPX route files live as media on the trip page, and `site.yaml` holds an `active_trip` slug so the nav can switch trips via config.
|
||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
# Trip Page Filter Bar — Implementation Plan
|
# Trip Page Filter Bar — Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Replace the three unstyled nav links on the trip page with an in-page filter bar (All content / Journal / Stories) and an inline Stats toggle — no page navigation needed.
|
**Goal:** Replace the three unstyled nav links on the trip page with an in-page filter bar (All content / Journal / Stories) and an inline Stats toggle — no page navigation needed.
|
||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
# Tuscany Demo Stories Implementation Plan
|
# Tuscany Demo Stories Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Add three Italy 2025 Tuscany demo stories that showcase distinct story-mode composition patterns.
|
**Goal:** Add three Italy 2025 Tuscany demo stories that showcase distinct story-mode composition patterns.
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
# Accessibility Audit Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-26)
|
||||||
|
|
||||||
|
**Goal:** Fix all eight WCAG 2.1 AA failures identified in the accessibility audit and add axe-core Playwright regression tests.
|
||||||
|
|
||||||
|
**Architecture:** Six sequential tasks — each implements one audit finding (or related group), writes a Playwright test first, then implements the fix in the relevant template/CSS/JS files. All tests go into a new `tests/ui/accessibility.spec.js` file that grows task by task. Task 6 adds axe-core automated scans on top of the feature-specific checks.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav 2.0 Twig templates, CSS custom properties, vanilla JS, Playwright with `@axe-core/playwright`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Target standard: WCAG 2.1 Level AA
|
||||||
|
- Dev server: http://localhost:8081 (Docker container `intotheeast_grav`)
|
||||||
|
- Two git repos: outer at `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast`, user subrepo at `/home/mischa/Projects/travel-blog-intotheeast/user`
|
||||||
|
- Template files are in the **user subrepo** (`user/themes/intotheeast/templates/`, `user/themes/intotheeast/css/`) — commit there first, then commit the outer repo with the updated `user/` pointer
|
||||||
|
- Tests and `package.json` are in the **outer repo** only
|
||||||
|
- Run all Playwright tests with: `cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast && npx playwright test --project=chromium`
|
||||||
|
- Demo data must be loaded before running tests: `make demo-load` (run from outer repo)
|
||||||
|
- Never read `.env`; pass it only to `make` / `docker compose`
|
||||||
|
- Do NOT add comments to CSS or JS unless the WHY is non-obvious
|
||||||
|
|
||||||
|
**Note on F8 (lightbox alt text):** The existing JS in `entry.html.twig` already handles this correctly — `open(index)` sets `lbImg.alt = btn.dataset.alt` which is populated from `{{ image.filename }}`. No code change needed for F8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Skip link + `<main id="main-content">`
|
||||||
|
|
||||||
|
**Fixes:** F1 (WCAG 2.4.1 Bypass Blocks)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
- Create: `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `.skip-link` element, `#main-content` id on `<main>` — both consumed by A1 test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the failing test**
|
||||||
|
|
||||||
|
Create `tests/ui/accessibility.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: A1–A5 (feature checks) and AX1–AX5 (axe scans)
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── A1: Skip link ──────────────────────────────────────────────────────────────
|
||||||
|
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const skipLink = page.locator('.skip-link');
|
||||||
|
await expect(skipLink).toBeAttached();
|
||||||
|
await expect(skipLink).toHaveAttribute('href', '#main-content');
|
||||||
|
await expect(page.locator('#main-content')).toBeAttached();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run A1 to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `.skip-link` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add skip link to `base.html.twig`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/partials/base.html.twig`:
|
||||||
|
|
||||||
|
Change line 15–16 (the opening `<body>` and `<header>`):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
|
||||||
|
<header class="site-header">
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
|
||||||
|
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||||
|
<header class="site-header">
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change line 25:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<main class="site-main">
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<main class="site-main" id="main-content">
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add skip-link CSS to `style.css`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/css/style.css`, find the `:focus-visible` rule (around line 782):
|
||||||
|
|
||||||
|
```css
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this block directly before it:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -10000px;
|
||||||
|
top: auto;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.skip-link:focus-visible {
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-on);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run A1 to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run existing tests to check for regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/nav.spec.js tests/ui/home.spec.js tests/ui/dailies.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
|
||||||
|
git commit -m "feat(a11y): add skip-to-main link and main landmark id"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/accessibility.spec.js user
|
||||||
|
git commit -m "test(a11y): add A1 skip link test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Color token contrast fixes
|
||||||
|
|
||||||
|
**Fixes:** F2 (`--color-ink-muted` fails 4.5:1), F3 (`--color-accent` fails 4.5:1)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/css/tokens.css`
|
||||||
|
- Modify: `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `tests/ui/accessibility.spec.js` from Task 1
|
||||||
|
- Produces: updated token values verified by A2 test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the failing test**
|
||||||
|
|
||||||
|
Append to `tests/ui/accessibility.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── A2: Color token contrast ───────────────────────────────────────────────────
|
||||||
|
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const [muted, accent] = await page.evaluate(() => [
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
|
||||||
|
]);
|
||||||
|
expect(muted.toLowerCase()).toBe('#90887e');
|
||||||
|
expect(accent.toLowerCase()).toBe('#2e9880');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run A2 to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — values are `#7a7268` and `#2a8c73`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `tokens.css`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/css/tokens.css`, make three changes:
|
||||||
|
|
||||||
|
Change `--color-ink-muted`:
|
||||||
|
```css
|
||||||
|
--color-ink-muted: #7A7268; /* labels, timestamps, captions */
|
||||||
|
```
|
||||||
|
→
|
||||||
|
```css
|
||||||
|
--color-ink-muted: #90887E; /* labels, timestamps, captions */
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `--color-accent`:
|
||||||
|
```css
|
||||||
|
--color-accent: #2A8C73; /* teal — lightened for dark contrast */
|
||||||
|
```
|
||||||
|
→
|
||||||
|
```css
|
||||||
|
--color-accent: #2E9880; /* teal — lightened for dark contrast */
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `--color-accent-hover`:
|
||||||
|
```css
|
||||||
|
--color-accent-hover: #236655; /* hover/pressed teal */
|
||||||
|
```
|
||||||
|
→
|
||||||
|
```css
|
||||||
|
--color-accent-hover: #287A68; /* hover/pressed teal */
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrast verification (for reference only — these numbers are correct):
|
||||||
|
- `#90887E` on `#1A1814` = 5.07:1 ✓, on `#22201B` = 4.66:1 ✓
|
||||||
|
- `#2E9880` on `#1A1814` = 5.00:1 ✓, on `#22201B` = 4.59:1 ✓
|
||||||
|
- `#287A68` on `#1A1814` = 3.58:1 ✓ (non-text, needs 3:1)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run A2 to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all accessibility tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A1 and A2 pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/css/tokens.css
|
||||||
|
git commit -m "feat(a11y): fix --color-ink-muted and --color-accent contrast ratios"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/accessibility.spec.js user
|
||||||
|
git commit -m "test(a11y): add A2 color contrast token test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: ARIA states for filter buttons and toggle panels
|
||||||
|
|
||||||
|
**Fixes:** F4 (filter buttons lack `aria-pressed`), F5 (toggle buttons lack `aria-expanded`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||||
|
- Modify: `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `tests/ui/accessibility.spec.js` from Task 2
|
||||||
|
- Produces: `aria-pressed` on `.trip-filter-btn`, `aria-expanded` + `aria-controls` on `#trip-stats-toggle` and `#trip-cycling-toggle`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/ui/accessibility.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
|
||||||
|
const TRIP_URL = '/trips/japan-korea-2026';
|
||||||
|
|
||||||
|
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await page.click('.trip-filter-btn[data-filter="journal"]');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await page.click('#trip-stats-toggle');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await page.click('#trip-stats-toggle');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run A3a–A3d to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all four FAIL — attributes not present.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `aria-pressed` to filter buttons in template**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/trip.html.twig`, find lines 124–128:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="journal">Journal</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="story">Stories</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `aria-expanded` + `aria-controls` to toggle buttons in template**
|
||||||
|
|
||||||
|
Find lines 129–134:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
|
||||||
|
{% if has_gpx %}
|
||||||
|
<button class="trip-stats-btn" id="trip-cycling-toggle">Cycling</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-stats-btn" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats</button>
|
||||||
|
{% if has_gpx %}
|
||||||
|
<button class="trip-stats-btn" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update the filter JS to toggle `aria-pressed`**
|
||||||
|
|
||||||
|
In the same file, find the filter click handler inside the `<script>` block (around line 384):
|
||||||
|
|
||||||
|
```js
|
||||||
|
filterBtns.forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
filterBtns.forEach(function(b) { b.classList.remove('is-active'); });
|
||||||
|
btn.classList.add('is-active');
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace those four lines with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
filterBtns.forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
filterBtns.forEach(function(b) { b.classList.remove('is-active'); b.setAttribute('aria-pressed', 'false'); });
|
||||||
|
btn.classList.add('is-active');
|
||||||
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update the stats toggle JS to set `aria-expanded`**
|
||||||
|
|
||||||
|
Find the stats toggle handler (around line 548):
|
||||||
|
|
||||||
|
```js
|
||||||
|
statsToggle.addEventListener('click', function() {
|
||||||
|
var isOpen = statsBlock.style.display !== 'none';
|
||||||
|
statsBlock.style.display = isOpen ? 'none' : '';
|
||||||
|
statsToggle.classList.toggle('is-active', !isOpen);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
statsToggle.addEventListener('click', function() {
|
||||||
|
var isOpen = statsBlock.style.display !== 'none';
|
||||||
|
statsBlock.style.display = isOpen ? 'none' : '';
|
||||||
|
statsToggle.classList.toggle('is-active', !isOpen);
|
||||||
|
statsToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Update the cycling toggle JS to set `aria-expanded`**
|
||||||
|
|
||||||
|
Find the cycling toggle handler (around line 560):
|
||||||
|
|
||||||
|
```js
|
||||||
|
cycToggle.addEventListener('click', function() {
|
||||||
|
var isOpen = cycBlock.style.display !== 'none';
|
||||||
|
cycBlock.style.display = isOpen ? 'none' : '';
|
||||||
|
cycToggle.classList.toggle('is-active', !isOpen);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
cycToggle.addEventListener('click', function() {
|
||||||
|
var isOpen = cycBlock.style.display !== 'none';
|
||||||
|
cycBlock.style.display = isOpen ? 'none' : '';
|
||||||
|
cycToggle.classList.toggle('is-active', !isOpen);
|
||||||
|
cycToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run A3a–A3d to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all four PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Run the existing filter tests to check for regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/trip-filter.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/templates/trip.html.twig
|
||||||
|
git commit -m "feat(a11y): add aria-pressed to filter buttons and aria-expanded to stats/cycling toggles"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/accessibility.spec.js user
|
||||||
|
git commit -m "test(a11y): add A3a-A3d aria-pressed and aria-expanded tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Photo strip keyboard navigation
|
||||||
|
|
||||||
|
**Fixes:** F6 (photo strip not keyboard-navigable)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
- Modify: `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `tests/ui/accessibility.spec.js` from Task 3
|
||||||
|
- Produces: `.strip-controls` div with `.strip-prev` / `.strip-next` buttons injected by JS for strips with `data-slides >= 2`; all strips get `role="region"` and `aria-label="Photo strip"`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/ui/accessibility.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
|
||||||
|
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
|
||||||
|
await page.goto('/trips/japan-korea-2026/dailies');
|
||||||
|
const strips = page.locator('.journal-photo-strip');
|
||||||
|
const count = await strips.count();
|
||||||
|
if (count === 0) return;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
|
||||||
|
await page.goto('/trips/japan-korea-2026/dailies');
|
||||||
|
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
|
||||||
|
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
|
||||||
|
);
|
||||||
|
if (multiCount === 0) return;
|
||||||
|
await expect(page.locator('.strip-prev').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-next').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
|
||||||
|
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run A4a–A4b to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A4"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A4a FAIL (no `role` attribute), A4b PASS vacuously (no multi-slide strips in demo data).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the dot-sync IIFE in `base.html.twig`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/partials/base.html.twig`, find the IIFE (lines 30–41):
|
||||||
|
|
||||||
|
```js
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
|
||||||
|
var dots = strip.nextElementSibling;
|
||||||
|
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
|
||||||
|
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
|
||||||
|
strip.addEventListener('scroll', function () {
|
||||||
|
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
|
||||||
|
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
|
||||||
|
strip.setAttribute('role', 'region');
|
||||||
|
strip.setAttribute('aria-label', 'Photo strip');
|
||||||
|
|
||||||
|
var slideCount = parseInt(strip.dataset.slides, 10) || 1;
|
||||||
|
var dots = strip.nextElementSibling;
|
||||||
|
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
|
||||||
|
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
|
||||||
|
|
||||||
|
strip.addEventListener('scroll', function () {
|
||||||
|
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
|
||||||
|
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
if (slideCount < 2) return;
|
||||||
|
|
||||||
|
var prev = document.createElement('button');
|
||||||
|
prev.className = 'strip-prev';
|
||||||
|
prev.setAttribute('aria-label', 'Previous photo');
|
||||||
|
prev.textContent = '‹';
|
||||||
|
prev.addEventListener('click', function () {
|
||||||
|
strip.scrollBy({ left: -strip.offsetWidth, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
var next = document.createElement('button');
|
||||||
|
next.className = 'strip-next';
|
||||||
|
next.setAttribute('aria-label', 'Next photo');
|
||||||
|
next.textContent = '›';
|
||||||
|
next.addEventListener('click', function () {
|
||||||
|
strip.scrollBy({ left: strip.offsetWidth, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
var controls = document.createElement('div');
|
||||||
|
controls.className = 'strip-controls';
|
||||||
|
controls.appendChild(prev);
|
||||||
|
controls.appendChild(next);
|
||||||
|
dots.insertAdjacentElement('afterend', controls);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add strip-controls CSS to `style.css`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/css/style.css`, find the `.journal-photo-dot.is-active` rule (around line 245):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.journal-photo-dot.is-active {
|
||||||
|
background: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this block directly after it:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.strip-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: calc(-1 * var(--space-2));
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-prev,
|
||||||
|
.strip-next {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-prev:hover,
|
||||||
|
.strip-next:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run A4a–A4b to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A4"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full accessibility suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A1–A4 all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
|
||||||
|
git commit -m "feat(a11y): add keyboard prev/next to photo strip and region landmark"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/accessibility.spec.js user
|
||||||
|
git commit -m "test(a11y): add A4a-A4b photo strip keyboard tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: GPX delete button unique accessible names
|
||||||
|
|
||||||
|
**Fixes:** F7 (delete buttons have no unique name)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/gpx-manager.html.twig`
|
||||||
|
- Modify: `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `tests/ui/accessibility.spec.js` from Task 4
|
||||||
|
- Produces: delete buttons with `aria-label="Delete <filename>"`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the failing test**
|
||||||
|
|
||||||
|
Append to `tests/ui/accessibility.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── A5: GPX delete button unique accessible names ──────────────────────────────
|
||||||
|
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/pages**/media', async route => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const deleteBtn = page.locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
|
||||||
|
await expect(deleteBtn).toBeVisible();
|
||||||
|
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run A5 to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `aria-label` attribute not present on the delete button.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `aria-label` to the delete button in `gpx-manager.html.twig`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/gpx-manager.html.twig`, find line 99 inside the `rows` template string:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<td><button class="gpx-delete" data-filename="${f.filename}">Delete</button></td>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<td><button class="gpx-delete" data-filename="${f.filename}" aria-label="Delete ${f.filename}">Delete</button></td>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run A5 to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "A5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full accessibility suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A1–A5 all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/templates/gpx-manager.html.twig
|
||||||
|
git commit -m "feat(a11y): add unique aria-label to GPX delete buttons"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/accessibility.spec.js user
|
||||||
|
git commit -m "test(a11y): add A5 GPX delete button accessible name test"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: axe-core WCAG 2.1 AA regression scans
|
||||||
|
|
||||||
|
**Adds:** AX1–AX5 automated axe scans across all main page types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `tests/ui/accessibility.spec.js` from Task 5
|
||||||
|
- Produces: five axe scans that fail on any `critical` or `serious` WCAG violation
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install `@axe-core/playwright`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npm install --save-dev @axe-core/playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
After install, `package.json` devDependencies should include `"@axe-core/playwright": "^4.x.x"` (exact semver will vary).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the axe scans to `accessibility.spec.js`**
|
||||||
|
|
||||||
|
Append to `tests/ui/accessibility.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── AX1–AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
|
||||||
|
const { AxeBuilder } = require('@axe-core/playwright');
|
||||||
|
|
||||||
|
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
|
||||||
|
const BLOCKING = ['critical', 'serious'];
|
||||||
|
|
||||||
|
function axeScan(id, url) {
|
||||||
|
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
|
||||||
|
await page.goto(url);
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
|
||||||
|
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
|
||||||
|
expect(
|
||||||
|
violations,
|
||||||
|
violations.map(v =>
|
||||||
|
`[${v.impact}] ${v.id}: ${v.description}\n ` +
|
||||||
|
v.nodes.map(n => n.html).join('\n ')
|
||||||
|
).join('\n\n')
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
axeScan('AX1', '/');
|
||||||
|
axeScan('AX2', '/trips/japan-korea-2026');
|
||||||
|
axeScan('AX3', '/trips/japan-korea-2026/dailies');
|
||||||
|
axeScan('AX4', '/trips/japan-korea-2026/dailies/2026-03-25-1540-wheels-down-narita');
|
||||||
|
axeScan('AX5', '/trips');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the axe scans**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js --grep "AX"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all five PASS (after Tasks 1–5 fixed the known violations). If any fail, read the violation output — it will name the rule ID, description, and offending HTML. Fix the violation if it represents a real issue, or note it in the ledger if it is a known limitation outside scope (e.g. map canvas element).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full accessibility test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/accessibility.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: A1–A5 and AX1–AX5 all pass (10 tests total).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full Playwright suite to check for regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass (or pre-existing failures only — check the progress ledger for known pre-existing failures before marking as blocker).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add package.json package-lock.json tests/ui/accessibility.spec.js
|
||||||
|
git commit -m "test(a11y): add axe-core WCAG 2.1 AA regression scans AX1-AX5"
|
||||||
|
```
|
||||||
@@ -0,0 +1,971 @@
|
|||||||
|
# Demo Data Redesign Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace patchwork demo content with a single high-quality `italy-2026-demo` trip following the real 8-day Tuscan cycling loop, with 12 journal entries (with photos) and 4 stories exercising all shortcode types.
|
||||||
|
|
||||||
|
**Architecture:** All demo source files live in `user/docs/demo/trips/italy-2026-demo/`. `make demo-load` copies them into `user/pages/01.trips/italy-2026-demo/` inside Docker. Images are downloaded once and committed to the demo source so the Makefile stays simple (cp -r copies everything). Tests in `tests/ui/stories.spec.js` reference story slugs that must match the new folder names.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav CMS page files (YAML frontmatter + Markdown), story-blocks shortcodes (`snap-gallery`, `scrolly-section`, `chapter-break`, `pull-quote`), picsum.photos placeholder images, Playwright tests, Make.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All content paths are relative to project root: `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/`
|
||||||
|
- `user/` is a **separate git repo** — commit content changes there with `git -C user commit`
|
||||||
|
- Never read `.env`; never ssh directly to production
|
||||||
|
- Demo trip slug: `italy-2026-demo`; fictional dates: 2026-09-01 to 2026-09-08
|
||||||
|
- `hero_image: ''` in all entry frontmatter — template auto-selects `01.jpg` as hero
|
||||||
|
- Story images: 1600×1000 from `https://picsum.photos/seed/<seed>/1600/1000`
|
||||||
|
- Entry images: 1200×800 from `https://picsum.photos/seed/<seed>/1200/800`
|
||||||
|
- Dev server: `http://localhost:8081`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Cleanup, GPX rename, trip.md, dailies.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Delete: `user/docs/demo/trips/japan-korea-2026/` (entire folder)
|
||||||
|
- Delete: `user/docs/demo/trips/italy-2025/dailies/` (entries only)
|
||||||
|
- Delete: `user/docs/demo/trips/italy-2025/04.stories/` (stories only)
|
||||||
|
- Delete: `user/docs/demo/trips/italy-2026-demo/dailies/` (replace with new entries in later tasks)
|
||||||
|
- Delete: `user/docs/demo/trips/italy-2026-demo/04.stories/` (replace with new stories in later tasks)
|
||||||
|
- Rename: 4 GPX files in `user/docs/demo/trips/italy-2026-demo/`
|
||||||
|
- Modify: `user/docs/demo/trips/italy-2026-demo/trip.md`
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/dailies/dailies.md`
|
||||||
|
- Modify: `CLAUDE.md` (update demo-load description)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove old demo data**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf user/docs/demo/trips/japan-korea-2026
|
||||||
|
rm -rf user/docs/demo/trips/italy-2025/dailies
|
||||||
|
rm -rf user/docs/demo/trips/italy-2025/04.stories
|
||||||
|
rm -rf user/docs/demo/trips/italy-2026-demo/dailies
|
||||||
|
rm -rf user/docs/demo/trips/italy-2026-demo/04.stories
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rename the 4 new GPX files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd user/docs/demo/trips/italy-2026-demo
|
||||||
|
|
||||||
|
mv "2025-10-11_2627663255_TGE Tuscany 2025 Final Route - 8 days - Day 1 - from Venturina Terme to Sugherella.gpx" \
|
||||||
|
day-1-campiglia-to-sugherella.gpx
|
||||||
|
|
||||||
|
mv "2025-10-12_2630489431_TGE Tuscany 2025 Final Route - 8 days - Day 2.gpx" \
|
||||||
|
day-2-sugherella-to-orbetello.gpx
|
||||||
|
|
||||||
|
mv "2025-10-13_2632495944_TGE Tuscany 2025 Final Route - 8 days - Day 3.gpx" \
|
||||||
|
day-3-orbetello-to-sorano.gpx
|
||||||
|
|
||||||
|
mv "2025-10-14_2634086364_TGE Tuscany 2025 Final Route - 8 days - Day 4.gpx" \
|
||||||
|
day-4-sorano-to-val-dorcia.gpx
|
||||||
|
|
||||||
|
cd ../../../..
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify 7 GPX files exist with correct names**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls user/docs/demo/trips/italy-2026-demo/*.gpx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (7 files):
|
||||||
|
```
|
||||||
|
day-1-campiglia-to-sugherella.gpx
|
||||||
|
day-2-sugherella-to-orbetello.gpx
|
||||||
|
day-3-orbetello-to-sorano.gpx
|
||||||
|
day-4-sorano-to-val-dorcia.gpx
|
||||||
|
day-5-val-dorcia-to-siena.gpx
|
||||||
|
day-6-siena-to-florence.gpx
|
||||||
|
day-8-coast-to-piombino.gpx
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update trip.md**
|
||||||
|
|
||||||
|
Write `user/docs/demo/trips/italy-2026-demo/trip.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Tuscany 2026'
|
||||||
|
template: trip
|
||||||
|
date: '2026-09-01'
|
||||||
|
date_start: '2026-09-01'
|
||||||
|
date_end: '2026-09-08'
|
||||||
|
cover_image: ''
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create dailies index page**
|
||||||
|
|
||||||
|
Create `user/docs/demo/trips/italy-2026-demo/dailies/dailies.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Journal
|
||||||
|
template: dailies
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Recreate empty story and entry directories**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/docs/demo/trips/italy-2026-demo/dailies
|
||||||
|
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Update CLAUDE.md demo-load description**
|
||||||
|
|
||||||
|
In `CLAUDE.md`, find the line:
|
||||||
|
```
|
||||||
|
- `make demo-load` — load demo entries for both trips (Japan/Korea 2026 + Italy 2025 with real GPX)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```
|
||||||
|
- `make demo-load` — load demo content into `italy-2026-demo` trip (journal entries + stories + GPX)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "chore(demo): cleanup old demo data, rename GPX files, update trip.md"
|
||||||
|
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: update demo-load description in CLAUDE.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update stories.spec.js for new story slugs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ui/stories.spec.js`
|
||||||
|
|
||||||
|
The existing tests point to old story slugs (`val-dorcia-dawn`, `long-climb-montalcino`). New slugs are `val-dorcia-at-dawn` and `sorano-rock-and-time`. The shortcode assertions stay identical — the new stories are designed to match them.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update slug constants and comments in stories.spec.js**
|
||||||
|
|
||||||
|
Replace the top of `tests/ui/stories.spec.js` (lines up to the first test):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// @ts-check
|
||||||
|
// Tests: S1–S7 — story mode rendering and navigation
|
||||||
|
// Requires demo data: run `make demo-load` before this suite.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const STORIES_URL = '/trips/italy-2026-demo/stories';
|
||||||
|
const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote
|
||||||
|
const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image
|
||||||
|
const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // used for cross-trip hero sanity check
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the two hardcoded URLs in S7**
|
||||||
|
|
||||||
|
In `tests/ui/stories.spec.js`, find and replace the hardcoded URL in S7:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```javascript
|
||||||
|
test('S7: story body back link has back-pill class', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/stories/val-dorcia-dawn');
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```javascript
|
||||||
|
test('S7: story body back link has back-pill class', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify tests reference correct slugs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "val-dorcia\|montalcino\|sorano\|florence-without" tests/ui/stories.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no references to `val-dorcia-dawn` or `long-climb-montalcino`; `val-dorcia-at-dawn` and `sorano-rock-and-time` appear.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ui/stories.spec.js
|
||||||
|
git commit -m "test(stories): update story slugs to match new demo content"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Write Story 1 — Sorano: Rock and Time
|
||||||
|
|
||||||
|
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/`
|
||||||
|
**Test coverage:** `STORY_SCROLLY` — needs scrolly-section × 2, chapter-break, pull-quote with image
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/hero.jpg`
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/photo-1.jpg`
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/photo-2.jpg`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create story directory and download images**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time
|
||||||
|
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s1-hero/1600/1000" -o "$SDIR/hero.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s1-1/1600/1000" -o "$SDIR/photo-1.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s1-2/1600/1000" -o "$SDIR/photo-2.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write story.md**
|
||||||
|
|
||||||
|
Write `user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`:
|
||||||
|
|
||||||
|
```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
|
||||||
|
---
|
||||||
|
|
||||||
|
The road from Orbetello climbs inland through scrubland and heat. For most of the afternoon there is nothing on the horizon except sky and the occasional electricity pylon. Then, at the top of a ridge, Sorano appears — and the word "appears" does not quite cover it. The town has been carved from a cliff of tufa, a pale volcanic rock so soft you can score it with a fingernail. The buildings are the cliff and the cliff is the buildings.
|
||||||
|
|
||||||
|
[scrolly-section image="hero.jpg" alt="Medieval town of Sorano seen from the approach road, perched on pale tufa cliffs" caption="Sorano — tufa cliff town, Grosseto province"]
|
||||||
|
The approach by bike gives you an unusually long time to study it. The descent into the valley and the climb back up take perhaps forty minutes, and the town is visible for most of that time, doing nothing, requiring nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Close up the rock is extraordinary. Hundreds of tomb niches cut into the cliff face — Etruscan graves, most of them open to the sky now, their contents long removed. The people who built this town chose to live surrounded by the evidence of their own mortality. This seems either very brave or very sensible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The gate into the old town is fifteenth century and narrow enough that loaded bikes don't fit without turning sideways. Inside, the air is noticeably cooler and the alleys are steep, paved with the same pale tufa, worn smooth by centuries of feet.
|
||||||
|
[/scrolly-section]
|
||||||
|
|
||||||
|
We found a wall to lean the bikes against and sat looking south over the valley we had come from. The light was going amber. Below us, the road we had ridden was already in shadow.
|
||||||
|
|
||||||
|
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Narrow medieval alley in Sorano at dusk, pale stone walls glowing warm" /]
|
||||||
|
|
||||||
|
[pull-quote image="photo-1.jpg" alt="Stone alley in Sorano lit by a single lantern at night"]
|
||||||
|
A town built on rock, carved from rock, returning slowly to rock. Two thousand years of human effort and the cliff remains indifferent.
|
||||||
|
[/pull-quote]
|
||||||
|
|
||||||
|
[scrolly-section image="photo-2.jpg" alt="View south from the tufa cliff walls of Sorano at dusk" caption="Val di Fiora, from the old walls"]
|
||||||
|
One restaurant was open. The menu was four items. We had the pasta with wild boar and the pasta with truffles and a carafe of local wine that cost six euros and was excellent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The owner sat at the next table watching a football match on his phone without headphones. Nobody minded. The town outside was completely quiet.
|
||||||
|
[/scrolly-section]
|
||||||
|
|
||||||
|
We were in bed before nine. Sorano at night is absolutely silent. It has been this quiet, in approximately this configuration, for a very long time.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify shortcode counts match test S3 expectations**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c "scrolly-section" user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md
|
||||||
|
grep -c "chapter-break" user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md
|
||||||
|
grep -c "pull-quote image=" user/docs/demo/trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `2` scrolly-section tags (opening tags only), `1` chapter-break, `1` pull-quote with image.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "feat(demo): add story 1 — Sorano: Rock and Time"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Write Story 2 — Val d'Orcia at Dawn
|
||||||
|
|
||||||
|
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/`
|
||||||
|
**Test coverage:** `STORY_GALLERY` — needs snap-gallery × 2, chapter-break, text-only pull-quote
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md`
|
||||||
|
- Create: `hero.jpg`, `photo-1.jpg`, `photo-2.jpg` (same directory)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create story directory and download images**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn
|
||||||
|
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s2-hero/1600/1000" -o "$SDIR/hero.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s2-1/1600/1000" -o "$SDIR/photo-1.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s2-2/1600/1000" -o "$SDIR/photo-2.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write story.md**
|
||||||
|
|
||||||
|
Write `user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Val d'Orcia at Dawn"
|
||||||
|
date: '2026-09-05'
|
||||||
|
location_name: Val d'Orcia
|
||||||
|
location_country: Italy
|
||||||
|
lat: 43.078
|
||||||
|
lng: 11.676
|
||||||
|
hero_image: hero.jpg
|
||||||
|
hero_alt: Wide Tuscan valley at dawn, long cypress shadows across pale gravel road
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
We left before the heat arrived. The alarm was five-thirty and the sky outside the tent was still more grey than blue. The valley was invisible in the dark except as an absence — a vast silence below us where the shapes of hills ought to be. By six the light had changed. The Val d'Orcia is one of those landscapes that photographers wait years to shoot at this hour, and you can see why: the light arrives at an angle that makes everything look like something from a different century.
|
||||||
|
|
||||||
|
[snap-gallery images="hero.jpg,photo-1.jpg,photo-2.jpg" captions="Six in the morning: the valley belongs entirely to the light,The Cypress Road — every photograph of Tuscany was taken here or somewhere like it,A farmhouse that has been sitting on this hill for four hundred years" alts="Wide misty Tuscan valley at dawn with long shadows,Straight road lined by tall cypress trees in morning light,Stone farmhouse on a hilltop with rolling landscape behind" /]
|
||||||
|
|
||||||
|
The roads down here are white gravel — strade bianche — and the tyres make a particular sound on them that you don't get anywhere else. We rode for two hours without seeing a car. The only other people were two elderly men walking a dog in the opposite direction. They waved.
|
||||||
|
|
||||||
|
[chapter-break image="photo-1.jpg" title="The Hour Before Heat" alt="Cypress road vanishing into a hazy summer morning" /]
|
||||||
|
|
||||||
|
By nine the temperature had already shifted. The quality of the light changed — softer, more diffuse, the sky turning white at the edges. The windows of the farmhouses began to open. Dogs that had been invisible in the dark became visible on walls and in doorways, watching us with professional detachment.
|
||||||
|
|
||||||
|
[snap-gallery images="photo-2.jpg,hero.jpg" captions="The road changes from asphalt to gravel to packed earth and back again without warning,The valley floor at nine: the shadows have shortened, the colours have flattened" alts="Farmhouse detail with terracotta roof and single cypress tree,Tuscan valley road in mid-morning haze" /]
|
||||||
|
|
||||||
|
[pull-quote]
|
||||||
|
The best hours of a cycling day are the ones nobody else sees. Before the heat arrives, before the cafes open, before the traffic comes. Everything belongs to you then.
|
||||||
|
[/pull-quote]
|
||||||
|
|
||||||
|
We reached Pienza at eleven-thirty. The ice-cream queue was eight deep and entirely justified.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify shortcode counts match test S2 expectations**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c "snap-gallery" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
|
||||||
|
grep -c "chapter-break" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
|
||||||
|
grep -c "pull-quote__inner--no-image\|^\[pull-quote\]" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Simpler check — verify exactly 2 `[snap-gallery` tags and 1 `[pull-quote]` (no `image=`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c "\[snap-gallery" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
|
||||||
|
grep "\[pull-quote" user/docs/demo/trips/italy-2026-demo/04.stories/02.val-dorcia-at-dawn/story.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `2` snap-gallery, and the pull-quote line has no `image=` attribute.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "feat(demo): add story 2 — Val d'Orcia at Dawn"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Write Story 3 — One Evening in Siena
|
||||||
|
|
||||||
|
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena/`
|
||||||
|
**Primary shortcode:** `pull-quote` with background image; also uses `chapter-break` and `scrolly-section`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena/story.md`
|
||||||
|
- Create: `hero.jpg`, `photo-1.jpg` (same directory)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create story directory and download images**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena
|
||||||
|
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s3-hero/1600/1000" -o "$SDIR/hero.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s3-1/1600/1000" -o "$SDIR/photo-1.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write story.md**
|
||||||
|
|
||||||
|
Write `user/docs/demo/trips/italy-2026-demo/04.stories/03.one-evening-siena/story.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'One Evening in Siena'
|
||||||
|
date: '2026-09-05'
|
||||||
|
location_name: Siena
|
||||||
|
location_country: Italy
|
||||||
|
lat: 43.318
|
||||||
|
lng: 11.330
|
||||||
|
hero_image: hero.jpg
|
||||||
|
hero_alt: Piazza del Campo at dusk, terracotta paving fading from gold to shadow
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
[pull-quote image="hero.jpg" alt="Piazza del Campo seen from the upper rim at golden hour"]
|
||||||
|
Siena is not a city that tries to impress you. It has been here for a thousand years and intends to be here for a thousand more. You fit around it, not the other way.
|
||||||
|
[/pull-quote]
|
||||||
|
|
||||||
|
We rolled in at half past six, legs finished, panniers heavier than they started. The Campo appeared without warning at the end of a narrow street and we both stopped pedalling at exactly the same moment. That particular square does something to people. It is partly the shape — a shallow bowl, a scallop shell, the way it holds you — and partly the light at that hour, which turns the terracotta pavement the colour of old copper.
|
||||||
|
|
||||||
|
[chapter-break image="photo-1.jpg" title="The Campo" number="I" alt="Detail of Siena's herringbone brick pavement catching the last light" /]
|
||||||
|
|
||||||
|
[scrolly-section image="hero.jpg" alt="Piazza del Campo filling with people as evening comes" caption="Campo, 19:00 — the square fills from the edges inward"]
|
||||||
|
The locals arrive first. They know which spot faces west and which benches stay in the shade longest. Then the tourists, then the pigeons, then the long shadows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A busker with an accordion near the Fonte Gaia. A group of students lying on the slope reading. Three children running in a circle for reasons nobody questioned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We sat on the pavement with our backs against the warm brickwork of the Palazzo Pubblico and did not move for forty minutes. The relief of sitting still after eight hours on a bike is a specific physical sensation. It travels upward from your legs and settles somewhere just behind the sternum.
|
||||||
|
[/scrolly-section]
|
||||||
|
|
||||||
|
We found a place for dinner three streets away, down a flight of steps with no sign outside. The pasta was handmade, the wine was local, the bill was reasonable. We were in bed by ten. Tomorrow: Florence.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "feat(demo): add story 3 — One Evening in Siena"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Write Story 4 — Florence Without a Map
|
||||||
|
|
||||||
|
**Target:** `user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map/`
|
||||||
|
**Primary shortcode:** `chapter-break` as structural divider; also uses `snap-gallery`, `pull-quote` (text-only), `scrolly-section`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map/story.md`
|
||||||
|
- Create: `hero.jpg`, `photo-1.jpg` (same directory)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create story directory and download images**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map
|
||||||
|
SDIR=user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s4-hero/1600/1000" -o "$SDIR/hero.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-s4-1/1600/1000" -o "$SDIR/photo-1.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write story.md**
|
||||||
|
|
||||||
|
Write `user/docs/demo/trips/italy-2026-demo/04.stories/04.florence-without-a-map/story.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Florence Without a Map'
|
||||||
|
date: '2026-09-07'
|
||||||
|
location_name: Florence
|
||||||
|
location_country: Italy
|
||||||
|
lat: 43.769
|
||||||
|
lng: 11.255
|
||||||
|
hero_image: hero.jpg
|
||||||
|
hero_alt: Arno river at midday with Ponte Vecchio, ochre buildings reflected in still water
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
No route today. No GPS, no distance target, no reason to be anywhere by any particular time. After six days of forward motion this felt almost wrong — the instinct to check the elevation profile arriving at nothing. We put the bikes in the hotel basement and walked out into Florence on foot.
|
||||||
|
|
||||||
|
[chapter-break image="hero.jpg" title="Day Seven" number="VII" alt="Arno river and Ponte Vecchio from Ponte Santa Trinita at midday" /]
|
||||||
|
|
||||||
|
[snap-gallery images="hero.jpg,photo-1.jpg" captions="The Arno at noon — greener than expected, the bridges older than you remember,Via dei Servi: washing lines, shutters, a cat on a warm stone ledge that had been warm since morning" alts="Arno river with Ponte Vecchio reflected in still water at midday,Narrow Florence street with laundry strung between buildings" /]
|
||||||
|
|
||||||
|
[pull-quote]
|
||||||
|
Cycling makes you earn every city you arrive at. Florence, we got for free. It felt like a gift and a debt simultaneously.
|
||||||
|
[/pull-quote]
|
||||||
|
|
||||||
|
[scrolly-section image="photo-1.jpg" alt="Narrow Oltrarno street in afternoon light" caption="Oltrarno, 14:00"]
|
||||||
|
The Uffizi had a queue that stretched around two corners and disappeared into a side street. We looked at it for a moment and went to find coffee instead. This felt correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A covered market in the Oltrarno that nobody had told us about. A man selling leather goods from a table he clearly reassembled each morning from identical components. A small dog sleeping under a fruit stall in a precisely calculated patch of shade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We crossed the Ponte Vecchio at two in the afternoon, which is exactly the wrong time to cross the Ponte Vecchio, and it was still worth it. The light off the Arno at that hour is genuinely extraordinary and all the photographs in the world do not prepare you for it.
|
||||||
|
[/scrolly-section]
|
||||||
|
|
||||||
|
Dinner near the apartment, early. Feet sore in a different way from legs sore — a smaller, more concentrated complaint. Tomorrow: the last day. The coast road home.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "feat(demo): add story 4 — Florence Without a Map"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Write Journal Entries — Days 1–4 (entries 1–6)
|
||||||
|
|
||||||
|
**Target:** `user/docs/demo/trips/italy-2026-demo/dailies/`
|
||||||
|
|
||||||
|
Each entry directory name: `<slug>.entry/`
|
||||||
|
Each entry contains: `entry.md` + numbered images `01.jpg`, `02.jpg`, …
|
||||||
|
|
||||||
|
- [ ] **Step 1: Entry 1 — Setting Off from Campiglia (2 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d1-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d1-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Setting Off from Campiglia'
|
||||||
|
date: '2026-09-01 07:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.024
|
||||||
|
lng: 10.603
|
||||||
|
location_city: Campiglia Marittima
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 27
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
Seven in the morning and the coast road is still cool. We loaded the bikes in the car park below the old town, the panniers heavier than they should be and the weather forecast saying nine consecutive days of sun. The route heads south first — down into the Maremma, then east, then a long loop back. Eight days. Nobody goes this way in September except cyclists and people who have got lost.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Entry 2 — Maremma in Full Sun (3 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-02-1130-maremma-in-full-sun.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d2a-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d2a-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d2a-3/1200/800" -o "$EDIR/03.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Maremma in Full Sun'
|
||||||
|
date: '2026-09-02 11:30'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 42.612
|
||||||
|
lng: 11.171
|
||||||
|
location_city: Maremma
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 29
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
Eleven-thirty and already thirty degrees. The Maremma is agricultural land and scrubland and very little else, and in September it has the quality of a landscape that has given up trying. The road is straight, the sun is direct, the shadows are almost vertical. We stopped at a petrol station and drank two cans of something cold each. The man at the counter looked at us like people who had made a series of questionable decisions.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Entry 3 — The Lagoon at Dusk (3 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-02-1900-the-lagoon-at-dusk.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d2b-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d2b-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d2b-3/1200/800" -o "$EDIR/03.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'The Lagoon at Dusk'
|
||||||
|
date: '2026-09-02 19:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 42.442
|
||||||
|
lng: 11.218
|
||||||
|
location_city: Orbetello
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 24
|
||||||
|
weather_desc: Partly cloudy
|
||||||
|
---
|
||||||
|
|
||||||
|
Orbetello sits on a causeway between two lagoons and at dusk the light does something remarkable to the water. Pink flamingos — real ones, not ornamental — were standing in the shallows on the western side, perfectly still. We ate at a table outside overlooking the eastern lagoon. The sky turned orange and then purple and then a deep blue that was almost indistinguishable from the water. The wine was cold and the pasta had clams.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Entry 4 — Orbetello Morning (2 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-03-0800-orbetello-morning.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d3a-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d3a-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Orbetello Morning'
|
||||||
|
date: '2026-09-03 08:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 42.442
|
||||||
|
lng: 11.217
|
||||||
|
location_city: Orbetello
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 22
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
The lagoon at eight in the morning is a different thing from the lagoon at eight in the evening. Flat, silver, nearly silent. A single fisherman in a small boat about two hundred metres out, not appearing to fish. We left before the town had properly woken up, heading northeast on roads that climbed immediately and steeply into a landscape of oak and limestone that felt nothing like the coast we had left behind twenty minutes before.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Entry 5 — Tufa and Towers (2 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-03-1700-tufa-and-towers.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d3b-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d3b-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Tufa and Towers'
|
||||||
|
date: '2026-09-03 17:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 42.683
|
||||||
|
lng: 11.715
|
||||||
|
location_city: Sorano
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 26
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
Sorano appears on the horizon an hour before you reach it: a cluster of towers and walls on a pale cliff, floating above the valley. The closer you get the stranger it becomes. The town is not built on rock — the town is rock, volcanic tufa carved and inhabited over two thousand years. The Etruscans started it. Everyone since has just kept adding floors. We are staying the night and it already feels like somewhere that requires more time than we have.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Entry 6 — The Long Climb North (4 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-04-1500-the-long-climb-north.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d4-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d4-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d4-3/1200/800" -o "$EDIR/03.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d4-4/1200/800" -o "$EDIR/04.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'The Long Climb North'
|
||||||
|
date: '2026-09-04 15:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.077
|
||||||
|
lng: 11.678
|
||||||
|
location_city: "Val d'Orcia"
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 23
|
||||||
|
weather_desc: Partly cloudy
|
||||||
|
---
|
||||||
|
|
||||||
|
Today was the hardest day. The route from Sorano to the Val d'Orcia crosses the eastern slope of Monte Amiata, which sounds manageable on a map and is not manageable at all. By noon we had climbed eleven hundred metres. By two we were somewhere above Seggiano in thin cloud, the views long gone, legs complaining in a language that had become very specific. Then the cloud lifted and the Val d'Orcia was simply there below us: pale roads, dark cypress, the whole thing exactly as advertised. Sometimes the landscapes that have been photographed to death are still worth arriving at.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit entries 1–6**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "feat(demo): add journal entries days 1–4 with photos"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Write Journal Entries — Days 5–8 (entries 7–12)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Entry 7 — Before the Heat Arrives (2 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-05-0830-before-the-heat-arrives.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d5a-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d5a-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Before the Heat Arrives'
|
||||||
|
date: '2026-09-05 08:30'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.078
|
||||||
|
lng: 11.676
|
||||||
|
location_city: Pienza
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 21
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
Six o'clock and the valley below Pienza is still in shadow. We left camp early on purpose — the route to Siena is long and September sun waits for no one. On the strade bianche the tyres make a sound like distant applause. No cars for the first two hours. Just the road and the light doing things to the cypress trees that would be embarrassing to describe in any other context.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Entry 8 — Into Siena (3 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-05-1800-into-siena.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d5b-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d5b-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d5b-3/1200/800" -o "$EDIR/03.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Into Siena'
|
||||||
|
date: '2026-09-05 18:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.318
|
||||||
|
lng: 11.335
|
||||||
|
location_city: Siena
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 25
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
The approach to Siena by bike is through streets that get progressively older and steeper until suddenly the Campo is there. We had both seen it in photographs and the photographs are accurate in every way except one: they do not tell you how the square smells — stone and frying onions and the particular warm stillness of a Sienese summer evening. We sat on the pavement with our backs against the Palazzo Pubblico for forty minutes and did not want to be anywhere else.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Entry 9 — Florence by Nightfall (3 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-06-2000-florence-by-nightfall.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d6-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d6-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d6-3/1200/800" -o "$EDIR/03.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Florence by Nightfall'
|
||||||
|
date: '2026-09-06 20:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.767
|
||||||
|
lng: 11.253
|
||||||
|
location_city: Florence
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 21
|
||||||
|
weather_desc: Cloudy
|
||||||
|
---
|
||||||
|
|
||||||
|
A long day. Siena to Florence is ninety kilometres and involves two significant climbs before you reach the Chianti hills, after which it becomes more manageable but you have already used the legs you needed. We came in from the south as the light was going, the city materialising from a distance as a density of rooftops and towers. The Arno appeared between buildings and we crossed it and then we were in, which is always a slightly surprising moment after a long day.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Entry 10 — One Rest Day (2 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-07-1400-one-rest-day.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d7-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d7-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'One Rest Day'
|
||||||
|
date: '2026-09-07 14:00'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.769
|
||||||
|
lng: 11.255
|
||||||
|
location_city: Florence
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 22
|
||||||
|
weather_desc: Partly cloudy
|
||||||
|
---
|
||||||
|
|
||||||
|
The bikes stayed in the basement. We walked instead, which after six days of cycling felt simultaneously easier and harder — easier on the legs, harder on the feet, which are used to being passive. Florence does not require a plan. Every street contains something. We crossed the Arno four times from different bridges, each one giving a slightly different version of the same view, all of them good.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Entry 11 — Dawn on the Cecina Coast (1 photo)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-08-0730-dawn-on-the-cecina-coast.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d8a-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Dawn on the Cecina Coast'
|
||||||
|
date: '2026-09-08 07:30'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.553
|
||||||
|
lng: 10.313
|
||||||
|
location_city: Cecina
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 20
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
The last day starts on the coast road south of Cecina, the sea visible between the pine trees. We have been inland for most of the week and the smell of salt water is a surprise. The road is flat, which after eight days of Tuscan hills feels almost suspicious. We rode in silence for the first hour. There was nothing that needed saying.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Entry 12 — Home (2 photos)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDIR=user/docs/demo/trips/italy-2026-demo/dailies/2026-09-08-1630-home.entry
|
||||||
|
mkdir -p "$EDIR"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d8b-1/1200/800" -o "$EDIR/01.jpg"
|
||||||
|
curl -sL "https://picsum.photos/seed/demo-d8b-2/1200/800" -o "$EDIR/02.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$EDIR/entry.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Home'
|
||||||
|
date: '2026-09-08 16:30'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: ''
|
||||||
|
lat: 43.017
|
||||||
|
lng: 10.587
|
||||||
|
location_city: Campiglia Marittima
|
||||||
|
location_country: Italy
|
||||||
|
weather_temp_c: 26
|
||||||
|
weather_desc: Sunny
|
||||||
|
---
|
||||||
|
|
||||||
|
The old town of Campiglia was visible on its hill for the last twenty kilometres, appearing and disappearing between the trees the way it had appeared on the horizon eight days ago when we left. The loop is complete: same car park, same view across the coast, different legs. The bikes went back in the car and we sat on a wall and counted the countries and the kilometres and the pasta dishes. Eight days, one loop, Tuscany in September. It was exactly what it was supposed to be.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify 12 entry directories exist**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls user/docs/demo/trips/italy-2026-demo/dailies/ | grep -c "\.entry$"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `12`
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit entries 7–12**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "feat(demo): add journal entries days 5–8 with photos"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Verify with demo-load and run tests
|
||||||
|
|
||||||
|
**Prerequisite:** Docker dev server running at `http://localhost:8081`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reset any existing demo content**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make demo-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Load demo content**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make demo-load
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors; ends with `Cache cleared`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke check in browser**
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo` — verify:
|
||||||
|
- Trip page shows "Tuscany 2026"
|
||||||
|
- Filter bar shows All / Journal / Stories
|
||||||
|
- Journal entries visible (should show most recent first)
|
||||||
|
- Stories tab shows 4 story cards
|
||||||
|
|
||||||
|
Open one entry (e.g. Entry 6 with 4 photos) and verify:
|
||||||
|
- Hero image renders (01.jpg)
|
||||||
|
- Gallery grid shows all 4 photos
|
||||||
|
- Lightbox opens on click
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo/stories/sorano-rock-and-time` and verify:
|
||||||
|
- `scrolly` sections render
|
||||||
|
- `chapter-break` renders
|
||||||
|
- `pull-quote` with background image renders
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo/map` and verify:
|
||||||
|
- All 7 GPX routes render on the map
|
||||||
|
- Entry markers appear at the correct coordinates
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass. Key tests to watch:
|
||||||
|
- `S1`: stories listing shows ≥ 3 cards → passes (4 stories)
|
||||||
|
- `S2`: gallery story has 2 snap-galleries, chapter-break, text-only pull-quote
|
||||||
|
- `S3`: scrolly story has 2 scrolly-sections, chapter-break, pull-quote with image
|
||||||
|
- `S4`: no JS errors on scrolly story
|
||||||
|
- `S5`: back button navigates to stories listing
|
||||||
|
- `S6/S7`: demo story hero renders, back-pill present
|
||||||
|
|
||||||
|
If a test fails, diagnose before moving on — do not proceed to final commit with failing tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "chore(demo): verify demo content complete — all tests passing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- [x] Spec § 1 Cleanup → Task 1
|
||||||
|
- [x] Spec § 2 GPX rename (4 files) → Task 1, Step 2–3
|
||||||
|
- [x] Spec § 3 Journal entries (12) → Tasks 7–8
|
||||||
|
- [x] Spec § 4 Stories (4) → Tasks 3–6; shortcode counts designed to match S2/S3 test assertions
|
||||||
|
- [x] Spec § 5 Makefile — existing `cp -r` commands already handle images; no Makefile changes needed
|
||||||
|
- [x] Spec § 6 trip.md update → Task 1, Step 4
|
||||||
|
- [x] Spec § 7 What is NOT changing — italy-2025 pages untouched, japan-korea-2026 pages untouched ✓
|
||||||
|
- [x] stories.spec.js slug update → Task 2 (covers both constants and the hardcoded S7 URL)
|
||||||
|
- [x] `dailies.md` index page → Task 1, Step 5 (needed for Grav to render the dailies listing after demo-reset)
|
||||||
|
- [x] No placeholder text in any step
|
||||||
|
- [x] All 4 shortcode types appear across 4 stories, with STORY_GALLERY and STORY_SCROLLY matching test assertion counts
|
||||||
+2
@@ -1,5 +1,7 @@
|
|||||||
# GPX Connector Logic Implementation Plan
|
# GPX Connector Logic Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Suppress the straight-line connector between adjacent map markers when a single GPX file covers both endpoints; keep connectors for uncovered gaps; add `force_connect` and `transport_mode` fields to entry/story blueprints.
|
**Goal:** Suppress the straight-line connector between adjacent map markers when a single GPX file covers both endpoints; keep connectors for uncovered gaps; add `force_connect` and `transport_mode` fields to entry/story blueprints.
|
||||||
@@ -0,0 +1,857 @@
|
|||||||
|
# Inline Journal Feed Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20)
|
||||||
|
|
||||||
|
**Goal:** Replace click-through journal entry cards with fully inline posts (photo strip + full text) across the trip page, dailies page, and home page.
|
||||||
|
|
||||||
|
**Architecture:** Each journal entry becomes an `<article class="journal-post">` block that renders all its images in a CSS scroll-snap strip with dot indicators, followed by the full body text. The `id`, `data-type`, `data-lat`, `data-lng` attributes stay on the root so map targeting, filter JS, and flash animation continue to work. Story cards in all three feeds are unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav 2.0 Twig templates, CSS scroll-snap (no library), vanilla JS IntersectionObserver-free dot sync via scroll event, Playwright tests
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All CSS values must use design tokens (`var(--...)`) — no hard-coded colours, sizes, or radii
|
||||||
|
- `id="entry-{{ entry.slug }}"` must remain on the journal post root (map scroll targeting)
|
||||||
|
- `data-type="journal"` must remain on the journal post root (filter bar JS)
|
||||||
|
- `data-lat` and `data-lng` must remain on the journal post root (map marker rendering)
|
||||||
|
- Story cards (`<a class="entry-card entry-card--story">`) are not touched by any task
|
||||||
|
- Two git repos: user content at `/home/mischa/Projects/travel-blog-intotheeast/user/` (separate git repo); outer repo at `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/`. Templates and CSS commit to the user subrepo; tests commit to the outer repo. Always update the outer repo's `user` submodule pointer in the same commit as the test changes.
|
||||||
|
- Dev server: http://localhost:8081
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `user/themes/intotheeast/css/style.css` | Add `.journal-post` component; remove journal-card-only rules; update `.is-highlighted` selector |
|
||||||
|
| `user/themes/intotheeast/templates/partials/base.html.twig` | Add photo-strip dot-sync JS before `</body>` |
|
||||||
|
| `user/themes/intotheeast/templates/dailies.html.twig` | Replace journal card block with `.journal-post`; add `weather_icons` |
|
||||||
|
| `user/themes/intotheeast/templates/trip.html.twig` | Replace journal card block with `.journal-post`; add `weather_icons` |
|
||||||
|
| `user/themes/intotheeast/templates/home.html.twig` | Replace journal card block with `.journal-post`; add `weather_icons` |
|
||||||
|
| `tests/ui/dailies.spec.js` | Update T1 selector; update T2 selectors |
|
||||||
|
| `tests/ui/maps.spec.js` | Update M7 selector |
|
||||||
|
| `tests/ui/home.spec.js` | New file — H1 test |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: CSS foundation + dot-sync JS
|
||||||
|
|
||||||
|
Add all new `.journal-post` CSS and the photo-strip dot-sync JS. Remove CSS classes that are only used by the old journal entry card (not by story cards). This task has no template changes — existing tests must still pass at the end.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `.journal-post`, `.journal-post-header`, `.journal-post-title`, `.journal-post-meta`, `.journal-post-permalink`, `.journal-post-location`, `.journal-post-weather`, `.journal-photo-strip`, `.journal-photo-slide`, `.journal-photo-dots`, `.journal-photo-dot.is-active`, `.journal-post-body`, `.journal-post.is-highlighted` — all usable by Tasks 2–4
|
||||||
|
|
||||||
|
- [x] **Step 1: Add `.journal-post` CSS block to `style.css`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/css/style.css`, find the line:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Single entry ────────────────────────────────────────────────────────────── */
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert the following block **before** that comment:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Journal post (inline feed) ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.journal-post {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: var(--space-12);
|
||||||
|
margin-bottom: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--color-ink);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-permalink {
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-permalink:hover { color: var(--color-accent); }
|
||||||
|
|
||||||
|
.journal-post-location,
|
||||||
|
.journal-post-weather {
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-photo-strip {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: scroll;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-photo-strip::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.journal-photo-slide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-photo-slide img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-photo-dots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-photo-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-photo-dot.is-active {
|
||||||
|
background: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-body {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-body p { margin-bottom: var(--space-4); }
|
||||||
|
.journal-post-body p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.journal-post.is-highlighted {
|
||||||
|
animation: card-highlight 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Remove journal-card-only CSS rules from `style.css`**
|
||||||
|
|
||||||
|
These rules are only used by the old journal entry card. Story cards do not use them. Remove each block exactly as shown.
|
||||||
|
|
||||||
|
**Remove `.entry-card-photo-overlay` and its children:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.entry-card-photo-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
padding: var(--space-5) var(--space-4) var(--space-3);
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.58) 0%, transparent 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-date-overlay {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-location-overlay {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with nothing (delete the block entirely).
|
||||||
|
|
||||||
|
**Remove the text-only meta block and its comment:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Card: text-only variant */
|
||||||
|
|
||||||
|
.entry-card-textmeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-date-plain {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-location-plain {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with nothing.
|
||||||
|
|
||||||
|
**Remove `.entry-excerpt` and `.entry-read-more`:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.entry-excerpt {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-read-more {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with nothing.
|
||||||
|
|
||||||
|
**Replace `.entry-card.is-highlighted` with `.journal-post.is-highlighted`:**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```css
|
||||||
|
.entry-card.is-highlighted {
|
||||||
|
animation: card-highlight 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```css
|
||||||
|
.journal-post.is-highlighted {
|
||||||
|
animation: card-highlight 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Add dot-sync JS to `base.html.twig`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/partials/base.html.twig`, find:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{{ assets.js('bottom')|raw }}
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{{ assets.js('bottom')|raw }}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
|
||||||
|
var dots = strip.nextElementSibling;
|
||||||
|
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
|
||||||
|
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
|
||||||
|
strip.addEventListener('scroll', function () {
|
||||||
|
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
|
||||||
|
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run existing tests to confirm nothing broke**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all existing tests pass (M7 still passes because `trip.html.twig` has not changed yet — the JS still adds `is-highlighted` to `.entry-card` elements, and the old M7 selector `.entry-card.is-highlighted` finds the element).
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit user subrepo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/css/style.css themes/intotheeast/templates/partials/base.html.twig
|
||||||
|
git commit -m "feat: add journal-post CSS component and dot-sync JS; remove stale journal-card-only rules"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: dailies.html.twig + T1/T2 test updates
|
||||||
|
|
||||||
|
Replace the journal entry card in `dailies.html.twig` with the new `.journal-post` inline block. Update T1 and T2 tests to match the new structure.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
|
||||||
|
- Modify: `tests/ui/dailies.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.journal-post` CSS from Task 1
|
||||||
|
- Produces: `/trips/japan-korea-2026/dailies` renders `.journal-post` blocks; T1 and T2 pass with new selectors
|
||||||
|
|
||||||
|
- [x] **Step 1: Update T1 and T2 tests to their new selectors**
|
||||||
|
|
||||||
|
In `tests/ui/dailies.spec.js`, make the following changes:
|
||||||
|
|
||||||
|
**T1** — change `.entry-card` to `.journal-post`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// OLD
|
||||||
|
await expect(page.locator('.entry-card').first()).toBeVisible();
|
||||||
|
// NEW
|
||||||
|
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||||
|
```
|
||||||
|
|
||||||
|
**T2** — replace the entire card locator + index block with id-based selectors:
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```js
|
||||||
|
// Both fixture entries must be visible on the page
|
||||||
|
const newerCard = page.locator(`.entry-card[href*="${NEWER_SLUG}"]`);
|
||||||
|
const olderCard = page.locator(`.entry-card[href*="${OLDER_SLUG}"]`);
|
||||||
|
|
||||||
|
await expect(newerCard).toBeVisible();
|
||||||
|
await expect(olderCard).toBeVisible();
|
||||||
|
|
||||||
|
// The newer entry should appear higher in the DOM (lower index)
|
||||||
|
const newerIdx = await newerCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
|
||||||
|
});
|
||||||
|
const olderIdx = await olderCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```js
|
||||||
|
// Both fixture entries must be visible on the page
|
||||||
|
const newerCard = page.locator(`#entry-${NEWER_SLUG}`);
|
||||||
|
const olderCard = page.locator(`#entry-${OLDER_SLUG}`);
|
||||||
|
|
||||||
|
await expect(newerCard).toBeVisible();
|
||||||
|
await expect(olderCard).toBeVisible();
|
||||||
|
|
||||||
|
// The newer entry should appear higher in the DOM (lower index)
|
||||||
|
const newerIdx = await newerCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||||
|
});
|
||||||
|
const olderIdx = await olderCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run T1 and T2 to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T1:|T2:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `.journal-post` selector finds no elements (the page still renders `.entry-card`).
|
||||||
|
|
||||||
|
- [x] **Step 3: Add `weather_icons` map and replace journal card in `dailies.html.twig`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/dailies.html.twig`, find the line:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
This `{% if item.type == 'journal' %}` block ends at `</a>` before `{% else %}`. Replace the entire journal card block (from `{% if item.type == 'journal' %}` through the closing `</a>` of the journal branch, leaving the `{% else %}` story branch intact) with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
{% set weather_icons = {
|
||||||
|
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
|
||||||
|
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
|
||||||
|
'Snow': '❄️', 'Thunderstorm': '⛈️'
|
||||||
|
} %}
|
||||||
|
<article class="journal-post" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
||||||
|
<header class="journal-post-header">
|
||||||
|
<h2 class="journal-post-title">{{ entry.title }}</h2>
|
||||||
|
<p class="journal-post-meta">
|
||||||
|
<a class="journal-post-permalink" href="{{ entry.url }}">
|
||||||
|
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
|
||||||
|
</a>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="journal-post-location">
|
||||||
|
· 📍
|
||||||
|
{%- set _loc = [] -%}
|
||||||
|
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
|
||||||
|
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
|
||||||
|
{{ _loc|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.header.weather_desc %}
|
||||||
|
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% set images = entry.media.images %}
|
||||||
|
{% if images|length > 0 %}
|
||||||
|
<div class="journal-photo-strip" data-slides="{{ images|length }}">
|
||||||
|
{% for img in images %}
|
||||||
|
<div class="journal-photo-slide">
|
||||||
|
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if images|length > 1 %}
|
||||||
|
<div class="journal-photo-dots" aria-hidden="true">
|
||||||
|
{% for img in images %}
|
||||||
|
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="journal-post-body">{{ entry.content|raw }}</div>
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact text to find and replace is the old journal branch. The old branch starts with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
|
||||||
|
{% if hero %}
|
||||||
|
<div class="entry-card-photo">
|
||||||
|
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
<div class="entry-card-photo-overlay">
|
||||||
|
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
|
||||||
|
{{ entry.date|date('d M Y')|upper }}
|
||||||
|
</time>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="entry-location-overlay">
|
||||||
|
📍
|
||||||
|
{% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
|
||||||
|
{% if entry.header.location_city and entry.header.location_country %}, {% endif %}
|
||||||
|
{% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="entry-card-textmeta">
|
||||||
|
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
|
||||||
|
{{ entry.date|date('d M Y')|upper }}
|
||||||
|
</time>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="entry-location-plain">
|
||||||
|
{%- set _loc = [] -%}
|
||||||
|
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
|
||||||
|
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
|
||||||
|
📍 {{ _loc|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="entry-card-body">
|
||||||
|
<h2 class="entry-title">{{ entry.title }}</h2>
|
||||||
|
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
|
||||||
|
<span class="entry-read-more">Read entry →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run T1 and T2 to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T1:|T2:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 5: Run the full suite to check no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/templates/dailies.html.twig
|
||||||
|
git commit -m "feat: replace journal entry card with inline journal-post in dailies feed"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/dailies.spec.js user
|
||||||
|
git commit -m "test: update T1/T2 selectors for inline journal-post structure"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: trip.html.twig + M7 test update
|
||||||
|
|
||||||
|
Replace the journal entry card in `trip.html.twig` with the `.journal-post` block. Update M7 which currently tests `.entry-card.is-highlighted` on the trip page.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||||
|
- Modify: `tests/ui/maps.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.journal-post` CSS and `.journal-post.is-highlighted` from Task 1; journal-post HTML pattern from Task 2
|
||||||
|
- Produces: `/trips/japan-korea-2026` renders `.journal-post` blocks; M7 passes with `.journal-post.is-highlighted`
|
||||||
|
|
||||||
|
- [x] **Step 1: Update M7 to the new selector**
|
||||||
|
|
||||||
|
In `tests/ui/maps.spec.js`, find:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Within 500ms of click + delay, one entry-card should have is-highlighted
|
||||||
|
await expect(page.locator('.entry-card.is-highlighted')).toBeVisible({ timeout: 1500 });
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Within 500ms of click + delay, one journal-post should have is-highlighted
|
||||||
|
await expect(page.locator('.journal-post.is-highlighted')).toBeVisible({ timeout: 1500 });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run M7 to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `.journal-post.is-highlighted` not found (trip.html.twig still renders `<a class="entry-card">`).
|
||||||
|
|
||||||
|
- [x] **Step 3: Replace journal card in `trip.html.twig`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/trip.html.twig`, find and replace the journal branch of the `{% if item.type == 'journal' %}` block. The old branch to replace is:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
|
||||||
|
{% if hero %}
|
||||||
|
<div class="entry-card-photo">
|
||||||
|
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
<div class="entry-card-photo-overlay">
|
||||||
|
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
|
||||||
|
{{ entry.date|date('d M Y')|upper }}
|
||||||
|
</time>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="entry-location-overlay">
|
||||||
|
📍
|
||||||
|
{% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
|
||||||
|
{% if entry.header.location_city and entry.header.location_country %}, {% endif %}
|
||||||
|
{% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="entry-card-textmeta">
|
||||||
|
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
|
||||||
|
{{ entry.date|date('d M Y')|upper }}
|
||||||
|
</time>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="entry-location-plain">
|
||||||
|
{%- set _loc = [] -%}
|
||||||
|
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
|
||||||
|
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
|
||||||
|
📍 {{ _loc|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="entry-card-body">
|
||||||
|
<h2 class="entry-title">{{ entry.title }}</h2>
|
||||||
|
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
|
||||||
|
<span class="entry-read-more">Read entry →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
{% set weather_icons = {
|
||||||
|
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
|
||||||
|
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
|
||||||
|
'Snow': '❄️', 'Thunderstorm': '⛈️'
|
||||||
|
} %}
|
||||||
|
<article class="journal-post" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
||||||
|
<header class="journal-post-header">
|
||||||
|
<h2 class="journal-post-title">{{ entry.title }}</h2>
|
||||||
|
<p class="journal-post-meta">
|
||||||
|
<a class="journal-post-permalink" href="{{ entry.url }}">
|
||||||
|
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
|
||||||
|
</a>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="journal-post-location">
|
||||||
|
· 📍
|
||||||
|
{%- set _loc = [] -%}
|
||||||
|
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
|
||||||
|
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
|
||||||
|
{{ _loc|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.header.weather_desc %}
|
||||||
|
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% set images = entry.media.images %}
|
||||||
|
{% if images|length > 0 %}
|
||||||
|
<div class="journal-photo-strip" data-slides="{{ images|length }}">
|
||||||
|
{% for img in images %}
|
||||||
|
<div class="journal-photo-slide">
|
||||||
|
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if images|length > 1 %}
|
||||||
|
<div class="journal-photo-dots" aria-hidden="true">
|
||||||
|
{% for img in images %}
|
||||||
|
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="journal-post-body">{{ entry.content|raw }}</div>
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run M7 to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 5: Run full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/templates/trip.html.twig
|
||||||
|
git commit -m "feat: replace journal entry card with inline journal-post in trip feed"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/maps.spec.js user
|
||||||
|
git commit -m "test: update M7 selector for journal-post.is-highlighted"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: home.html.twig + H1 test
|
||||||
|
|
||||||
|
Replace the journal entry card in `home.html.twig` and add a minimal home page test.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/home.html.twig`
|
||||||
|
- Create: `tests/ui/home.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.journal-post` CSS from Task 1; journal-post HTML pattern from Task 2
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing H1 test**
|
||||||
|
|
||||||
|
Create `tests/ui/home.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: H1 — home page journal feed
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── H1: Home page renders inline journal posts ─────────────────────────────────
|
||||||
|
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run H1 to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/home.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `.journal-post` not found (home page still renders `<a class="entry-card">`).
|
||||||
|
|
||||||
|
- [x] **Step 3: Replace journal card in `home.html.twig`**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/home.html.twig`, find the journal branch:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
<a class="entry-card" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
|
||||||
|
{% if hero %}
|
||||||
|
<div class="entry-card-photo">
|
||||||
|
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
<div class="entry-card-photo-overlay">
|
||||||
|
<time class="entry-date-overlay" datetime="{{ entry.date|date('Y-m-d') }}">
|
||||||
|
{{ entry.date|date('d M Y')|upper }}
|
||||||
|
</time>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="entry-location-overlay">
|
||||||
|
📍
|
||||||
|
{% if entry.header.location_city %}{{ entry.header.location_city|slice(0,20) }}{% endif %}
|
||||||
|
{% if entry.header.location_city and entry.header.location_country %}, {% endif %}
|
||||||
|
{% if entry.header.location_country %}{{ entry.header.location_country }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="entry-card-textmeta">
|
||||||
|
<time class="entry-date-plain" datetime="{{ entry.date|date('Y-m-d') }}">
|
||||||
|
{{ entry.date|date('d M Y')|upper }}
|
||||||
|
</time>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="entry-location-plain">
|
||||||
|
{%- set _loc = [] -%}
|
||||||
|
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
|
||||||
|
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
|
||||||
|
📍 {{ _loc|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="entry-card-body">
|
||||||
|
<h2 class="entry-title">{{ entry.title }}</h2>
|
||||||
|
<p class="entry-excerpt">{{ entry.summary|striptags|slice(0, 250)|trim }}</p>
|
||||||
|
<span class="entry-read-more">Read entry →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
{% set weather_icons = {
|
||||||
|
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
|
||||||
|
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
|
||||||
|
'Snow': '❄️', 'Thunderstorm': '⛈️'
|
||||||
|
} %}
|
||||||
|
<article class="journal-post" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
||||||
|
<header class="journal-post-header">
|
||||||
|
<h2 class="journal-post-title">{{ entry.title }}</h2>
|
||||||
|
<p class="journal-post-meta">
|
||||||
|
<a class="journal-post-permalink" href="{{ entry.url }}">
|
||||||
|
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
|
||||||
|
</a>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="journal-post-location">
|
||||||
|
· 📍
|
||||||
|
{%- set _loc = [] -%}
|
||||||
|
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
|
||||||
|
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
|
||||||
|
{{ _loc|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.header.weather_desc %}
|
||||||
|
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% set images = entry.media.images %}
|
||||||
|
{% if images|length > 0 %}
|
||||||
|
<div class="journal-photo-strip" data-slides="{{ images|length }}">
|
||||||
|
{% for img in images %}
|
||||||
|
<div class="journal-photo-slide">
|
||||||
|
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if images|length > 1 %}
|
||||||
|
<div class="journal-photo-dots" aria-hidden="true">
|
||||||
|
{% for img in images %}
|
||||||
|
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="journal-post-body">{{ entry.content|raw }}</div>
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `home.html.twig` journal posts do **not** include `data-type` (the home page has no filter bar) — this matches the existing `<a class="entry-card">` on home which also had no `data-type`.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run H1 to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
npx playwright test --project=chromium tests/ui/home.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 5: Run the full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/maps.spec.js tests/ui/trip-filter.spec.js tests/ui/stories.spec.js tests/ui/home.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast/user
|
||||||
|
git add themes/intotheeast/templates/home.html.twig
|
||||||
|
git commit -m "feat: replace journal entry card with inline journal-post on home page"
|
||||||
|
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git add tests/ui/home.spec.js user
|
||||||
|
git commit -m "test: add H1 home page journal-post test"
|
||||||
|
```
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
# Pixelfed Import & Demo Reorganisation Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-26)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Import 36 Pixelfed posts into three permanent trips and reorganise the demo system so Italy demo content moves to a clearly-labelled `italy-2026-demo` trip and Japan demo content is retired.
|
||||||
|
|
||||||
|
**Architecture:** Three independent tasks — demo cleanup first, then real trip scaffolding, then the Python import script that routes posts by year and downloads photos. All user-facing content lives in `user/pages/` and is committed to the `user/` git repo; the Makefile and import script are committed to the main repo.
|
||||||
|
|
||||||
|
**Tech Stack:** Bash (file operations), Python 3 stdlib only (json, os, urllib.request, datetime), Grav Flat-File CMS YAML frontmatter.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Dev server: `http://localhost:8081` — must be running (`make start`) to test cache clears
|
||||||
|
- `user/` is a separate git repo — all commits to `user/pages/`, `user/docs/`, `user/themes/` use `git -C user commit`; Makefile and `scripts/` use the root `git commit`
|
||||||
|
- Never read `.env` directly
|
||||||
|
- All new trip pages use `template: trip` for `trip.md`, `template: dailies` for the dailies index, `template: map` for map, `template: stats` for stats, `template: stories` for stories — matching existing trips exactly
|
||||||
|
- Input JSON: `/home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json` (36 posts)
|
||||||
|
- Trip routing by `created_at` year: 2023 → `central-asia-2023`, 2024 → `us-canada-mex-2024`, 2025 → `italy-2025`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Change | Repo |
|
||||||
|
|---|---|---|
|
||||||
|
| `user/docs/demo/trips/italy-2026-demo/` | New — copy of italy-2025 demo source with updated `trip.md` | user |
|
||||||
|
| `user/pages/01.trips/italy-2026-demo/` | Not committed — created at runtime by `demo-load` | — |
|
||||||
|
| `user/pages/01.trips/italy-2025/trip.md` | Update title to `Cycling Tuscany 2025` | user |
|
||||||
|
| `user/pages/01.trips/italy-2025/01.dailies/dailies.md` | New — missing index page | user |
|
||||||
|
| `user/pages/01.trips/italy-2025/04.stories/01.val-dorcia-dawn/` | Delete demo story | user |
|
||||||
|
| `user/pages/01.trips/italy-2025/04.stories/02.long-climb-montalcino/` | Delete demo story | user |
|
||||||
|
| `user/pages/01.trips/italy-2025/04.stories/03.one-evening-siena/` | Delete demo story | user |
|
||||||
|
| `user/pages/01.trips/italy-2025/01.dailies/2025-09-*.entry/` | Delete 5 demo entries | user |
|
||||||
|
| `user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-*.entry` + `2026-04-*.entry` | Delete 9 demo entries | user |
|
||||||
|
| `user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates/` | Delete demo story | user |
|
||||||
|
| `user/pages/01.trips/central-asia-2023/` | New permanent trip page tree | user |
|
||||||
|
| `user/pages/01.trips/us-canada-mex-2024/` | New permanent trip page tree | user |
|
||||||
|
| `Makefile` | Replace `demo-load` and `demo-reset` targets | main |
|
||||||
|
| `scripts/pixelfed-import.py` | New one-time import script | main |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Demo reorganisation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/docs/demo/trips/italy-2026-demo/` (copy + edit)
|
||||||
|
- Modify: `user/pages/01.trips/italy-2025/trip.md`
|
||||||
|
- Create: `user/pages/01.trips/italy-2025/01.dailies/dailies.md`
|
||||||
|
- Delete: `user/pages/01.trips/italy-2025/04.stories/01.val-dorcia-dawn/`, `02.long-climb-montalcino/`, `03.one-evening-siena/`
|
||||||
|
- Delete: `user/pages/01.trips/italy-2025/01.dailies/2025-09-*.entry/` (5 demo entries)
|
||||||
|
- Delete: `user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-*.entry` + `2026-04-*.entry` (9 demo entries)
|
||||||
|
- Delete: `user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates/`
|
||||||
|
- Modify: `Makefile`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `demo-load` and `demo-reset` targets that only touch `italy-2026-demo`; `italy-2025` is clean and ready for real content; `japan-korea-2026` has only the real `2026-06-17.entry`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Copy italy-2025 demo source to italy-2026-demo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r user/docs/demo/trips/italy-2025 user/docs/demo/trips/italy-2026-demo
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the trip.md in the new demo source**
|
||||||
|
|
||||||
|
Edit `user/docs/demo/trips/italy-2026-demo/trip.md` to read:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Italy 2026 (Demo)'
|
||||||
|
template: trip
|
||||||
|
date: '2026-09-01'
|
||||||
|
date_start: '2026-09-01'
|
||||||
|
date_end: '2026-09-08'
|
||||||
|
cover_image: ''
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove italy-2025 demo stories from pages**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/04.stories/01.val-dorcia-dawn
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/04.stories/02.long-climb-montalcino
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/04.stories/03.one-evening-siena
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove italy-2025 demo dailies entries from pages**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-05-0800-rolling-through-val-dorcia.entry
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-05-1900-siena-at-dusk.entry
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-06-1200-towers-of-san-gimignano.entry
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-06-1800-into-florence.entry
|
||||||
|
rm -rf user/pages/01.trips/italy-2025/01.dailies/2025-09-08-0900-tyrrhenian-coast.entry
|
||||||
|
```
|
||||||
|
|
||||||
|
Check actual folder names first in case any differ:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls user/pages/01.trips/italy-2025/01.dailies/
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove all folders listed (they are all demo content).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add missing dailies.md to italy-2025**
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/italy-2025/01.dailies/dailies.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'The Journey'
|
||||||
|
template: dailies
|
||||||
|
content:
|
||||||
|
items: '@self.children'
|
||||||
|
order:
|
||||||
|
by: date
|
||||||
|
dir: desc
|
||||||
|
filter:
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update italy-2025 trip title**
|
||||||
|
|
||||||
|
Edit `user/pages/01.trips/italy-2025/trip.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Cycling Tuscany 2025'
|
||||||
|
template: trip
|
||||||
|
date: '2025-10-11'
|
||||||
|
date_start: '2025-10-11'
|
||||||
|
date_end: '2025-10-16'
|
||||||
|
cover_image: ''
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Remove japan demo content from pages**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-25-1540-wheels-down-narita.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-26-1000-sakura-in-ueno-park.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-27-0715-summit-clouds-and-snow.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-28-1130-thousand-torii-gates.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-29-1400-deer-of-nara.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-30-1800-dotonbori-after-dark.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-03-31-0730-last-morning-in-arashiyama.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-04-01-0900-seoul-calling.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/01.dailies/2026-04-02-1100-gyeongbokgung-and-beyond.entry
|
||||||
|
rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Update the Makefile demo targets**
|
||||||
|
|
||||||
|
Replace the entire `demo-load` and `demo-reset` blocks (lines 42–64) with:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
demo-load:
|
||||||
|
# Load italy-2026-demo trip (create pages if absent)
|
||||||
|
mkdir -p user/pages/01.trips/italy-2026-demo/01.dailies user/pages/01.trips/italy-2026-demo/02.map user/pages/01.trips/italy-2026-demo/03.stats user/pages/01.trips/italy-2026-demo/04.stories
|
||||||
|
cp user/docs/demo/trips/italy-2026-demo/trip.md user/pages/01.trips/italy-2026-demo/trip.md 2>/dev/null || true
|
||||||
|
cp user/docs/demo/trips/italy-2026-demo/map.md user/pages/01.trips/italy-2026-demo/02.map/map.md 2>/dev/null || true
|
||||||
|
cp user/docs/demo/trips/italy-2026-demo/stats.md user/pages/01.trips/italy-2026-demo/03.stats/stats.md 2>/dev/null || true
|
||||||
|
cp user/docs/demo/trips/italy-2026-demo/stories.md user/pages/01.trips/italy-2026-demo/04.stories/stories.md 2>/dev/null || true
|
||||||
|
cp -r user/docs/demo/trips/italy-2026-demo/04.stories/. user/pages/01.trips/italy-2026-demo/04.stories/ 2>/dev/null || true
|
||||||
|
cp -r user/docs/demo/trips/italy-2026-demo/dailies/. user/pages/01.trips/italy-2026-demo/01.dailies/
|
||||||
|
cp user/docs/demo/trips/italy-2026-demo/*.gpx user/pages/01.trips/italy-2026-demo/ 2>/dev/null || true
|
||||||
|
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||||
|
|
||||||
|
demo-reset:
|
||||||
|
rm -rf user/pages/01.trips/italy-2026-demo
|
||||||
|
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Verify demo-load and demo-reset work**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make demo-load
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `italy-2026-demo` appears at `http://localhost:8081` trips listing. Confirm stories and GPX map load.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make demo-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `italy-2026-demo` disappears from the trips listing. `italy-2025` and `japan-korea-2026` are unaffected.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit user repo changes**
|
||||||
|
|
||||||
|
Includes the new demo source, cleaned pages, updated italy-2025 title, and new dailies.md:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user commit -m "chore: move italy demo to italy-2026-demo; clean japan and italy-2025 demo content"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 11: Commit main repo changes (Makefile only)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Makefile
|
||||||
|
git commit -m "chore: update demo-load/demo-reset for italy-2026-demo; retire japan demo"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create real trip page trees
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/pages/01.trips/central-asia-2023/` (trip.md + 4 subfolders with index pages)
|
||||||
|
- Create: `user/pages/01.trips/us-canada-mex-2024/` (trip.md + 4 subfolders with index pages)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `central-asia-2023` and `us-canada-mex-2024` trip folder trees with `01.dailies/dailies.md` present — required for the import script to write entries into them
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create Central Asia 2023 trip tree**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/pages/01.trips/central-asia-2023/01.dailies
|
||||||
|
mkdir -p user/pages/01.trips/central-asia-2023/02.map
|
||||||
|
mkdir -p user/pages/01.trips/central-asia-2023/03.stats
|
||||||
|
mkdir -p user/pages/01.trips/central-asia-2023/04.stories
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/central-asia-2023/trip.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Central Asia 2023'
|
||||||
|
template: trip
|
||||||
|
date: '2023-08-28'
|
||||||
|
date_start: '2023-08-28'
|
||||||
|
date_end: '2023-10-18'
|
||||||
|
cover_image: ''
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/central-asia-2023/01.dailies/dailies.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'The Journey'
|
||||||
|
template: dailies
|
||||||
|
content:
|
||||||
|
items: '@self.children'
|
||||||
|
order:
|
||||||
|
by: date
|
||||||
|
dir: desc
|
||||||
|
filter:
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/central-asia-2023/02.map/map.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Trip Map'
|
||||||
|
template: map
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/central-asia-2023/03.stats/stats.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Trip Stats'
|
||||||
|
template: stats
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/central-asia-2023/04.stories/stories.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: Stories
|
||||||
|
template: stories
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create Northern America 2024 trip tree**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/pages/01.trips/us-canada-mex-2024/01.dailies
|
||||||
|
mkdir -p user/pages/01.trips/us-canada-mex-2024/02.map
|
||||||
|
mkdir -p user/pages/01.trips/us-canada-mex-2024/03.stats
|
||||||
|
mkdir -p user/pages/01.trips/us-canada-mex-2024/04.stories
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/us-canada-mex-2024/trip.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Northern America 2024'
|
||||||
|
template: trip
|
||||||
|
date: '2024-05-28'
|
||||||
|
date_start: '2024-05-28'
|
||||||
|
date_end: '2024-08-07'
|
||||||
|
cover_image: ''
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/us-canada-mex-2024/01.dailies/dailies.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'The Journey'
|
||||||
|
template: dailies
|
||||||
|
content:
|
||||||
|
items: '@self.children'
|
||||||
|
order:
|
||||||
|
by: date
|
||||||
|
dir: desc
|
||||||
|
filter:
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/us-canada-mex-2024/02.map/map.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Trip Map'
|
||||||
|
template: map
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/us-canada-mex-2024/03.stats/stats.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: 'Trip Stats'
|
||||||
|
template: stats
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/pages/01.trips/us-canada-mex-2024/04.stories/stories.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: Stories
|
||||||
|
template: stories
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify trips appear in the site**
|
||||||
|
|
||||||
|
Open `http://localhost:8081` — the Past Trips section should list Central Asia 2023 and Northern America 2024.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit to user repo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add user/pages/01.trips/central-asia-2023 user/pages/01.trips/us-canada-mex-2024
|
||||||
|
git -C user commit -m "feat: add central-asia-2023 and us-canada-mex-2024 trip page trees"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Pixelfed import script
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/pixelfed-import.py`
|
||||||
|
- Modify: `Makefile` (add `pixelfed-import` target)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `user/pages/01.trips/{trip}/01.dailies/` folders from Task 2 and existing `italy-2025`
|
||||||
|
- Produces: `{date}-pixelfed-{N}.entry/` folders with `entry.md` + downloaded photo files
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the import script**
|
||||||
|
|
||||||
|
Create `scripts/pixelfed-import.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-time import of Pixelfed statuses into Grav entry pages."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
INPUT_FILE = '/home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json'
|
||||||
|
USER_PAGES = 'user/pages/01.trips'
|
||||||
|
|
||||||
|
TRIP_MAP = {
|
||||||
|
'2023': 'central-asia-2023',
|
||||||
|
'2024': 'us-canada-mex-2024',
|
||||||
|
'2025': 'italy-2025',
|
||||||
|
}
|
||||||
|
|
||||||
|
EXT_MAP = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
}
|
||||||
|
|
||||||
|
ENTRY_TEMPLATE = """\
|
||||||
|
---
|
||||||
|
title: '{title}'
|
||||||
|
date: '{date}'
|
||||||
|
template: entry
|
||||||
|
published: true
|
||||||
|
hero_image: '{hero_image}'
|
||||||
|
lat: ''
|
||||||
|
lng: ''
|
||||||
|
location_city: '{location_city}'
|
||||||
|
location_country: '{location_country}'
|
||||||
|
weather_temp_c: ''
|
||||||
|
weather_desc: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
{body}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def download(url, dest):
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url, dest)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
print(f' Warning: download failed {url}: {exc}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open(INPUT_FILE) as f:
|
||||||
|
posts = json.load(f)
|
||||||
|
|
||||||
|
counters = {}
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
year = post['created_at'][:4]
|
||||||
|
trip = TRIP_MAP.get(year)
|
||||||
|
if not trip:
|
||||||
|
print(f"Skip: no trip mapping for year {year} (post {post['id']})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
counters[trip] = counters.get(trip, 0) + 1
|
||||||
|
n = counters[trip]
|
||||||
|
|
||||||
|
date_str = post['created_at'][:10] # YYYY-MM-DD
|
||||||
|
folder = f'{date_str}-pixelfed-{n}.entry'
|
||||||
|
path = os.path.join(USER_PAGES, trip, '01.dailies', folder)
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
print(f'Skip: {folder} already exists')
|
||||||
|
continue
|
||||||
|
|
||||||
|
os.makedirs(path)
|
||||||
|
print(f'Creating {trip}/{folder}')
|
||||||
|
|
||||||
|
hero_image = ''
|
||||||
|
for i, att in enumerate(post.get('media_attachments', []), 1):
|
||||||
|
ext = EXT_MAP.get(att.get('mime', ''), 'jpg')
|
||||||
|
filename = f'photo-{i}.{ext}'
|
||||||
|
if download(att['url'], os.path.join(path, filename)) and i == 1:
|
||||||
|
hero_image = filename
|
||||||
|
|
||||||
|
place = post.get('place') or {}
|
||||||
|
dt = datetime.fromisoformat(post['created_at'].replace('Z', '+00:00'))
|
||||||
|
date_fmt = dt.strftime('%Y-%m-%d %H:%M')
|
||||||
|
|
||||||
|
entry_md = ENTRY_TEMPLATE.format(
|
||||||
|
title=f'Pixelfed Import {n}',
|
||||||
|
date=date_fmt,
|
||||||
|
hero_image=hero_image,
|
||||||
|
location_city=place.get('name', ''),
|
||||||
|
location_country=place.get('country', ''),
|
||||||
|
body=post.get('content_text', '').strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(os.path.join(path, 'entry.md'), 'w') as f:
|
||||||
|
f.write(entry_md)
|
||||||
|
|
||||||
|
print(f'\nDone. Posts per trip: {counters}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add make target**
|
||||||
|
|
||||||
|
In `Makefile`, after the `demo-reset` block, add:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
pixelfed-import:
|
||||||
|
python3 scripts/pixelfed-import.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the import**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pixelfed-import
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (approximately):
|
||||||
|
|
||||||
|
```
|
||||||
|
Creating central-asia-2023/2023-08-28-pixelfed-1.entry
|
||||||
|
Creating central-asia-2023/2023-08-29-pixelfed-2.entry
|
||||||
|
...
|
||||||
|
Creating us-canada-mex-2024/2024-05-28-pixelfed-1.entry
|
||||||
|
...
|
||||||
|
Creating italy-2025/2025-10-11-pixelfed-1.entry
|
||||||
|
Creating italy-2025/2025-10-16-pixelfed-2.entry
|
||||||
|
|
||||||
|
Done. Posts per trip: {'central-asia-2023': 22, 'us-canada-mex-2024': 12, 'italy-2025': 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify entries in the browser**
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/central-asia-2023/dailies` — confirm entries appear in reverse-date order with photos.
|
||||||
|
|
||||||
|
Open one entry (e.g. the first Central Asia post) — confirm the hero image displays and the body text is readable.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify entries in Admin2**
|
||||||
|
|
||||||
|
Log in at `http://localhost:8081/admin`. Navigate to Pages → find one of the new entry pages. Confirm the media tab shows the downloaded photos.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit main repo (script + Makefile)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/pixelfed-import.py Makefile
|
||||||
|
git commit -m "feat: add pixelfed-import script and make target"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit user repo (imported entries)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add user/pages/01.trips/central-asia-2023/01.dailies
|
||||||
|
git -C user add user/pages/01.trips/us-canada-mex-2024/01.dailies
|
||||||
|
git -C user add user/pages/01.trips/italy-2025/01.dailies
|
||||||
|
git -C user commit -m "feat: import 36 Pixelfed posts into central-asia-2023, us-canada-mex-2024, italy-2025"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Push to Gitea**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: push completes, production webhook fires.
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
# UI/UX Alignment Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-20) — also extended to story map markers (white diamond) and story card flash highlight.
|
||||||
|
|
||||||
|
**Goal:** Unify three micro-interaction patterns across the site: back navigation pills, card hover lift, and a map-to-card flash highlight.
|
||||||
|
|
||||||
|
**Architecture:** CSS-first — shared `.back-pill` class drives visual consistency; entry card markup is collapsed from a two-level `article > a` to a flat `<a>` to align hover targets across all three card types; map flash is a short CSS keyframe triggered by a JS-added class.
|
||||||
|
|
||||||
|
**Tech Stack:** Twig templates, vanilla CSS custom properties, vanilla JS, Playwright for tests.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Dev server: `http://localhost:8081` — must be running (`make start`) before any Playwright run
|
||||||
|
- Playwright: `npx playwright test --project=chromium tests/ui/<file>.spec.js` — always run the affected spec after changes
|
||||||
|
- Demo data required for story/map tests: `make demo-load`
|
||||||
|
- All CSS uses design tokens from `user/themes/intotheeast/css/tokens.css` — never hard-code colours
|
||||||
|
- `--color-paper: #1A1814`, `--color-canvas: #22201B`, `--color-ink: #EDE8DF` — the site is dark-themed
|
||||||
|
- `--site-header-height: 60px` — fixed pills must clear the site nav
|
||||||
|
- Never read `.env` directly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | What changes |
|
||||||
|
|---|---|
|
||||||
|
| `user/themes/intotheeast/css/style.css` | Add `.back-pill` class; remove duplicate `.story-escape` block; migrate `.entry-card-inner` hover rules to `.entry-card`; add uniform card hover lift; add `@keyframes card-highlight` |
|
||||||
|
| `user/themes/intotheeast/templates/story.html.twig` | Add `class="back-pill"` to story-footer back link (line 61) |
|
||||||
|
| `user/themes/intotheeast/templates/entry.html.twig` | Add fixed top back pill before `<article class="entry">`; replace footer teal link with `.back-pill`; add `.entry-back-fixed` CSS |
|
||||||
|
| `user/themes/intotheeast/templates/trip.html.twig` | Collapse `<article class="entry-card"><a class="entry-card-inner">` to `<a class="entry-card">` for both card variants; update marker click handler with flash delay |
|
||||||
|
| `tests/ui/dailies.spec.js` | Update T2 selectors from `.entry-card a[href*="..."]` to `.entry-card[href*="..."]`; add T6 (back pills on entry page) |
|
||||||
|
| `tests/ui/maps.spec.js` | Add M7 (marker click adds `is-highlighted` class) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: CSS foundation — `.back-pill`, card hover lift, flash keyframe, story-escape cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `.back-pill` class (surface pill), `.entry-card.is-highlighted` animation, uniform hover lift on `.trip-card:hover`, `.entry-card:hover`, `.story-card:hover`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add `.back-pill` surface pill class**
|
||||||
|
|
||||||
|
Find the `/* ── Back to top pill ──` section (around line 1217). Insert the following block immediately **before** it:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Back pill (shared navigation pill component) ───────────────────── */
|
||||||
|
.back-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--color-canvas);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-pill:hover { border-color: var(--color-accent); color: var(--color-accent); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Remove the duplicate `.story-escape` block**
|
||||||
|
|
||||||
|
Around line 958 there is a `/* ── Story page escape link ──` section with a `.story-escape` rule that is overridden later by the story-section block. Remove this entire section:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Story page escape link ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.story-escape {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--space-5);
|
||||||
|
left: var(--space-5);
|
||||||
|
z-index: 200;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-escape:hover { color: var(--color-accent); }
|
||||||
|
```
|
||||||
|
|
||||||
|
The authoritative `.story-escape` definition remains in the `/* ── Story pages ──` section (~line 1056).
|
||||||
|
|
||||||
|
- [x] **Step 3: Add uniform card hover lift + fix story-card transition**
|
||||||
|
|
||||||
|
Find the `.trip-card:hover` rule (in `/* ── Past trips archive ──`). After the existing `.trip-card:hover` block, add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.trip-card:hover,
|
||||||
|
.entry-card:hover,
|
||||||
|
.story-card:hover {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then find `.story-card` in the `/* ── Stories listing ──` section and add `background 0.15s` to its existing transition so the lift animates:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Before: */
|
||||||
|
.story-card {
|
||||||
|
...
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* After: */
|
||||||
|
.story-card {
|
||||||
|
...
|
||||||
|
transition: box-shadow 0.2s, background 0.15s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Add map flash keyframe**
|
||||||
|
|
||||||
|
At the end of the `/* ── Feed ──` section (after `.entry-card` and related rules, around line 210), add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes card-highlight {
|
||||||
|
0% { background-color: color-mix(in srgb, var(--color-accent) 12%, transparent); }
|
||||||
|
100% { background-color: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card.is-highlighted {
|
||||||
|
animation: card-highlight 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Verify no JS errors on the site**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M1:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (map page loads without errors — confirms CSS is valid).
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/css/style.css
|
||||||
|
git commit -m "feat: add back-pill class, card hover lift, flash keyframe; remove duplicate story-escape"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Story template — apply `.back-pill` to body back link
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/story.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.back-pill` class from Task 1
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `tests/ui/stories.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── S7: Story body back link is styled as a back-pill ────────────────────────
|
||||||
|
test('S7: story body back link has back-pill class', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2025/stories/val-dorcia-dawn');
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
// Scroll past the hero to reveal the story body
|
||||||
|
await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5));
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const bodyBack = page.locator('.story-footer .back-pill');
|
||||||
|
await expect(bodyBack).toBeAttached();
|
||||||
|
await expect(bodyBack).toHaveText(/← Back/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/stories.spec.js -g "S7:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — "locator('.story-footer .back-pill')" found 0 elements.
|
||||||
|
|
||||||
|
- [x] **Step 3: Apply `.back-pill` to the story footer back link + fix `.story-footer a` conflict**
|
||||||
|
|
||||||
|
In `story.html.twig`, the story footer currently reads:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<footer class="story-footer">
|
||||||
|
<a href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `<a>` to:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<footer class="story-footer">
|
||||||
|
<a class="back-pill" href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `style.css`, find `.story-footer a` and add `:not(.back-pill)` so it no longer overrides the pill colour:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Before: */
|
||||||
|
.story-footer a {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* After: */
|
||||||
|
.story-footer a:not(.back-pill) {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/stories.spec.js -g "S7:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 5: Run full stories suite to check no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/stories.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All S1–S7 pass.
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/templates/story.html.twig user/themes/intotheeast/css/style.css tests/ui/stories.spec.js
|
||||||
|
git commit -m "feat: apply back-pill class to story footer back link"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Entry page — fixed top back pill + footer back pill
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/entry.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
- Modify: `tests/ui/dailies.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.back-pill` class from Task 1
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `tests/ui/dailies.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const KNOWN_ENTRY = '/trips/japan-korea-2026/dailies/2026-03-25-1540-wheels-down-narita.entry';
|
||||||
|
|
||||||
|
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────
|
||||||
|
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
|
||||||
|
await page.goto(KNOWN_ENTRY);
|
||||||
|
await expect(page.locator('article.entry')).toBeVisible();
|
||||||
|
// Fixed top pill (outside the article, before it)
|
||||||
|
const topPill = page.locator('.entry-back-fixed');
|
||||||
|
await expect(topPill).toBeVisible();
|
||||||
|
await expect(topPill).toHaveText(/← Back/);
|
||||||
|
// Footer pill
|
||||||
|
const footerPill = page.locator('.entry-footer .back-pill');
|
||||||
|
await expect(footerPill).toBeVisible();
|
||||||
|
await expect(footerPill).toHaveText(/← Back/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T6:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `.entry-back-fixed` not found.
|
||||||
|
|
||||||
|
- [x] **Step 3: Add fixed top back pill to entry template**
|
||||||
|
|
||||||
|
In `entry.html.twig`, the content block currently starts with `<article class="entry">`. Add the fixed pill immediately before it:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<a class="back-pill entry-back-fixed" href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||||
|
|
||||||
|
<article class="entry">
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Replace footer teal text link with `.back-pill`**
|
||||||
|
|
||||||
|
The current entry footer (around line 124 of entry.html.twig):
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<footer class="entry-footer">
|
||||||
|
<a href="{{ page.parent().url }}" onclick="if(history.length>1){event.preventDefault();history.back()}">← Back</a>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<footer class="entry-footer">
|
||||||
|
<a class="back-pill" href="{{ page.parent().url }}" onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Add `.entry-back-fixed` positioning to CSS**
|
||||||
|
|
||||||
|
In `style.css`, in the `/* ── Single entry ──` section, add after the existing `.entry-hero` rules:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.entry-back-fixed {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--site-header-height) + var(--space-3));
|
||||||
|
left: var(--space-4);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: Run test to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T6:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 7: Run full dailies suite to check no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: T1–T6 all pass.
|
||||||
|
|
||||||
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/templates/entry.html.twig user/themes/intotheeast/css/style.css tests/ui/dailies.spec.js
|
||||||
|
git commit -m "feat: add fixed top and footer back pills to entry page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Entry card structural refactor + CSS migration
|
||||||
|
|
||||||
|
Collapse the two-level `<article class="entry-card"><a class="entry-card-inner">` to a flat `<a class="entry-card">`, matching the structure of trip and story cards.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
- Modify: `tests/ui/dailies.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `.entry-card` is now an `<a>` element; `id`, `data-type`, `data-lat`, `data-lng` attributes remain on the card root; `.entry-card-inner` class is eliminated
|
||||||
|
|
||||||
|
- [x] **Step 1: Update T2 test selectors before touching the templates**
|
||||||
|
|
||||||
|
In `tests/ui/dailies.spec.js`, find the T2 test and replace:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// OLD — inner <a> is nested inside .entry-card
|
||||||
|
const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`);
|
||||||
|
const olderCard = page.locator(`.entry-card a[href*="${OLDER_SLUG}"]`);
|
||||||
|
|
||||||
|
await expect(newerCard).toBeVisible();
|
||||||
|
await expect(olderCard).toBeVisible();
|
||||||
|
|
||||||
|
const newerIdx = await newerCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el));
|
||||||
|
});
|
||||||
|
const olderIdx = await olderCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.entry-card')].findIndex(c => c.contains(el));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// NEW — .entry-card is itself the <a>
|
||||||
|
const newerCard = page.locator(`.entry-card[href*="${NEWER_SLUG}"]`);
|
||||||
|
const olderCard = page.locator(`.entry-card[href*="${OLDER_SLUG}"]`);
|
||||||
|
|
||||||
|
await expect(newerCard).toBeVisible();
|
||||||
|
await expect(olderCard).toBeVisible();
|
||||||
|
|
||||||
|
const newerIdx = await newerCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
|
||||||
|
});
|
||||||
|
const olderIdx = await olderCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.entry-card')].findIndex(c => c === el);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run T2 to verify it fails (not yet refactored)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T2:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `.entry-card[href*="..."]` finds 0 elements (the `href` is on the inner `<a>`, not the article).
|
||||||
|
|
||||||
|
- [x] **Step 3: Refactor journal entry card markup in `trip.html.twig`**
|
||||||
|
|
||||||
|
Find the journal card block:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
<article class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
||||||
|
<a class="entry-card-inner" href="{{ entry.url }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
<a class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}" href="{{ entry.url }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
And close the card with `</a>` instead of `</a></article>`. The closing tags currently are:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Refactor story-in-feed card markup in `trip.html.twig`**
|
||||||
|
|
||||||
|
Find the story-in-feed card block:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story">
|
||||||
|
<a class="entry-card-inner" href="{{ entry.url }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story" href="{{ entry.url }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
And its closing tags (currently `</a></article>`) become:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Migrate `.entry-card-inner` CSS rules to `.entry-card`**
|
||||||
|
|
||||||
|
In `style.css`, find the `/* ── Feed ──` section. Currently:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.entry-card { border-bottom: 1px solid var(--color-border); padding-bottom: var(--space-12); }
|
||||||
|
|
||||||
|
.entry-card-inner {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with (merge inner styles onto card, `.entry-card-inner` is eliminated):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.entry-card {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: var(--space-12);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then find the two `.entry-card-inner:hover` rules and rename them to `.entry-card:hover`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Before: */
|
||||||
|
.entry-card-inner:hover .entry-card-photo img { transform: scale(1.04); }
|
||||||
|
/* After: */
|
||||||
|
.entry-card:hover .entry-card-photo img { transform: scale(1.04); }
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Before: */
|
||||||
|
.entry-card-inner:hover .entry-title { color: var(--color-accent); }
|
||||||
|
/* After: */
|
||||||
|
.entry-card:hover .entry-title { color: var(--color-accent); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: Run T2 to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js -g "T2:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 7: Run full test suites to check no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/trip-filter.spec.js tests/ui/maps.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: T1–T6, F1–F7, M1–M6 all pass.
|
||||||
|
|
||||||
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css tests/ui/dailies.spec.js
|
||||||
|
git commit -m "refactor: collapse entry card article+a to flat <a>, unify hover targets across card types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Map flash — JS update + test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||||
|
- Modify: `tests/ui/maps.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `.entry-card.is-highlighted` CSS animation from Task 1; `id="entry-{{ slug }}"` on `<a class="entry-card">` from Task 4
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `tests/ui/maps.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── M7: Clicking a trip-page map marker adds is-highlighted to the entry card ──
|
||||||
|
test('M7: clicking map marker briefly highlights the corresponding entry card', async ({ page }) => {
|
||||||
|
await page.goto('/trips/japan-korea-2026');
|
||||||
|
// Wait for map canvas and at least one marker
|
||||||
|
await expect(page.locator('#trip-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Click the first marker
|
||||||
|
await page.locator('.maplibregl-marker').first().click();
|
||||||
|
|
||||||
|
// Within 500ms of click + delay, one entry-card should have is-highlighted
|
||||||
|
await expect(page.locator('.entry-card.is-highlighted')).toBeVisible({ timeout: 1500 });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `.entry-card.is-highlighted` not found.
|
||||||
|
|
||||||
|
- [x] **Step 3: Update the marker click handler in `trip.html.twig`**
|
||||||
|
|
||||||
|
Find the existing marker click handler in `trip.html.twig`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('entry-' + entry.slug);
|
||||||
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('entry-' + entry.slug);
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setTimeout(function () {
|
||||||
|
card.classList.add('is-highlighted');
|
||||||
|
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
|
||||||
|
}, 350);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/maps.spec.js -g "M7:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [x] **Step 5: Run full maps suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/maps.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: M1–M7 all pass.
|
||||||
|
|
||||||
|
- [x] **Step 6: Run all affected suites for final check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium tests/ui/dailies.spec.js tests/ui/trip-filter.spec.js tests/ui/maps.spec.js tests/ui/stories.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All pass.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/templates/trip.html.twig tests/ui/maps.spec.js
|
||||||
|
git commit -m "feat: add map-to-card flash highlight on marker click"
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,329 @@
|
|||||||
|
# Entry Enrichment Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-21)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Enrich all real trip journal entries with location, GPS coordinates, and approximate weather by generating per-trip Markdown review docs, letting the user correct them, then applying changes to YAML frontmatter.
|
||||||
|
|
||||||
|
**Architecture:** Three round-trips (one per trip): Claude generates a review table → user edits the doc → Claude applies the approved values to `entry.md` frontmatter. No scripts — all edits via the Edit tool directly. Human review gates between each trip pair.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav YAML frontmatter, Markdown tables, OpenStreetMap URLs for coordinates.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Never read `.env`
|
||||||
|
- Only write to `user/` and `docs/` directories
|
||||||
|
- `lat`/`lng` must be decimal degree strings (e.g. `'48.8566'`) — not integers
|
||||||
|
- `weather_temp_c` must be a string integer (e.g. `'22'`)
|
||||||
|
- `weather_desc` must be a single short phrase (e.g. `sunny`, `partly cloudy`, `rainy`)
|
||||||
|
- Map Links use OSM format: `https://www.openstreetmap.org/#map=15/{lat}/{lng}`
|
||||||
|
- All edits committed to the `user/` git repo via `make content-push` after all three trips are done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `docs/enrichment/central-asia-2023.md` | Create | Review table, 22 rows |
|
||||||
|
| `docs/enrichment/us-canada-mex-2024.md` | Create | Review table, 12 rows |
|
||||||
|
| `docs/enrichment/italy-2025.md` | Create | Review table, 2 rows |
|
||||||
|
| `user/pages/01.trips/central-asia-2023/01.dailies/*/entry.md` | Modify | Update 6 frontmatter fields (22 files) |
|
||||||
|
| `user/pages/01.trips/us-canada-mex-2024/01.dailies/*/entry.md` | Modify | Update 6 frontmatter fields (12 files) |
|
||||||
|
| `user/pages/01.trips/italy-2025/01.dailies/*/entry.md` | Modify | Update 6 frontmatter fields (2 files) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Generate central-asia-2023 review doc
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/enrichment/central-asia-2023.md`
|
||||||
|
- Read: `user/pages/01.trips/central-asia-2023/01.dailies/*/entry.md` (22 files)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read all 22 entry.md files**
|
||||||
|
|
||||||
|
Read each file at `user/pages/01.trips/central-asia-2023/01.dailies/{folder}/entry.md`. Extract: folder name, `date`, `title`, `location_city`, `location_country`, body text.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Infer location for each entry**
|
||||||
|
|
||||||
|
For each entry:
|
||||||
|
1. Read the title first — most locations are explicit.
|
||||||
|
2. Fall back to body text if title is ambiguous.
|
||||||
|
3. If neither reveals a location clearly, leave City/Country blank and put `?` in the Map Link cell.
|
||||||
|
|
||||||
|
Known city → approximate coordinates mapping for this trip (use these; do not hallucinate unfamiliar coordinates):
|
||||||
|
|
||||||
|
| City | Country | Lat | Lng |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Berlin | Germany | 52.5200 | 13.4050 |
|
||||||
|
| Astana (Nur-Sultan) | Kazakhstan | 51.1801 | 71.4460 |
|
||||||
|
| Almaty | Kazakhstan | 43.2220 | 76.8512 |
|
||||||
|
| Karakol | Kyrgyzstan | 42.4900 | 78.3936 |
|
||||||
|
| Dushanbe | Tajikistan | 38.5598 | 68.7870 |
|
||||||
|
| Samarkand | Uzbekistan | 39.6542 | 66.9597 |
|
||||||
|
| Tbilisi | Georgia | 41.6938 | 44.8015 |
|
||||||
|
|
||||||
|
- [ ] **Step 3: Estimate weather for each entry**
|
||||||
|
|
||||||
|
Use typical daytime high (°C) and one-word description for the city + month. Reference values:
|
||||||
|
|
||||||
|
| City | Aug | Sep | Oct |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Berlin | 24, sunny | 19, partly cloudy | 13, cloudy |
|
||||||
|
| Astana | 26, sunny | 17, partly cloudy | 5, cold |
|
||||||
|
| Almaty | 28, sunny | 21, sunny | 12, partly cloudy |
|
||||||
|
| Karakol | 24, sunny | 16, partly cloudy | 8, cold |
|
||||||
|
| Dushanbe | 35, sunny | 28, sunny | 18, sunny |
|
||||||
|
| Samarkand | 33, sunny | 25, sunny | 16, partly cloudy |
|
||||||
|
| Tbilisi | 29, sunny | 23, sunny | 14, partly cloudy |
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the review doc**
|
||||||
|
|
||||||
|
Create `docs/enrichment/central-asia-2023.md` with this exact structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# central-asia-2023 Enrichment Review
|
||||||
|
|
||||||
|
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
|
||||||
|
|
||||||
|
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 2023-08-28-pixelfed-1.entry | 2023-08-28 | Welcome to My Central Asian Picture Diary | Berlin | Germany | https://www.openstreetmap.org/#map=15/52.5200/13.4050 | 24 | sunny |
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
One row per entry, in date order.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the generated doc**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast
|
||||||
|
git add docs/enrichment/central-asia-2023.md
|
||||||
|
git commit -m "docs: add central-asia-2023 enrichment review doc"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Prompt user to review**
|
||||||
|
|
||||||
|
Tell the user: "Review doc generated at `docs/enrichment/central-asia-2023.md`. Please open it, correct any locations or weather values, and let me know when it's ready to apply."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Apply central-asia-2023 enrichment (after user approval)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `docs/enrichment/central-asia-2023.md`
|
||||||
|
- Modify: `user/pages/01.trips/central-asia-2023/01.dailies/*/entry.md` (22 files)
|
||||||
|
|
||||||
|
**Prerequisite:** User has reviewed and approved `docs/enrichment/central-asia-2023.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the reviewed doc**
|
||||||
|
|
||||||
|
Read `docs/enrichment/central-asia-2023.md`. Parse each data row of the table.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract coordinates from Map Links**
|
||||||
|
|
||||||
|
For each row where Map Link is not blank:
|
||||||
|
|
||||||
|
- OSM format `https://www.openstreetmap.org/#map={zoom}/{lat}/{lng}` → split on `/`, take last two values as lat and lng.
|
||||||
|
- Google Maps format `https://www.google.com/maps/@{lat},{lng},{zoom}z` → extract the `@lat,lng` portion.
|
||||||
|
- If Map Link is blank or `?`, set lat and lng to `''`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply to each entry.md**
|
||||||
|
|
||||||
|
For each row, open `user/pages/01.trips/central-asia-2023/01.dailies/{entry}/entry.md` and update these six fields using the Edit tool:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
location_city: '{City}'
|
||||||
|
location_country: '{Country}'
|
||||||
|
lat: '{Lat}'
|
||||||
|
lng: '{Lng}'
|
||||||
|
weather_temp_c: '{Temp}'
|
||||||
|
weather_desc: '{Weather}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the existing (likely empty) values. Keep all other frontmatter untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
For the first 3 and last entry, read back the file and confirm the six fields are set correctly.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast && git add user/pages/01.trips/central-asia-2023/01.dailies/
|
||||||
|
git commit -m "content: enrich central-asia-2023 entries with location and weather"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Generate us-canada-mex-2024 review doc
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/enrichment/us-canada-mex-2024.md`
|
||||||
|
- Read: `user/pages/01.trips/us-canada-mex-2024/01.dailies/*/entry.md` (12 files)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read all 12 entry.md files**
|
||||||
|
|
||||||
|
Read each file at `user/pages/01.trips/us-canada-mex-2024/01.dailies/{folder}/entry.md`. Extract: folder name, `date`, `title`, body text.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Infer location for each entry**
|
||||||
|
|
||||||
|
Known cities from titles for this trip:
|
||||||
|
|
||||||
|
| Title hint | City | Country | Lat | Lng |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Piran | Piran | Slovenia | 45.5285 | 13.5680 |
|
||||||
|
| Portland / Amtrak (destination) | Portland | USA | 45.5231 | -122.6765 |
|
||||||
|
| San Francisco / Golden Gate / Highway 1 | San Francisco | USA | 37.7749 | -122.4194 |
|
||||||
|
| Los Angeles / burrito / beach | Los Angeles | USA | 34.0522 | -118.2437 |
|
||||||
|
| Toronto | Toronto | Canada | 43.6532 | -79.3832 |
|
||||||
|
| Niagara Falls | Niagara Falls | Canada | 43.0896 | -79.0849 |
|
||||||
|
| Montreal | Montreal | Canada | 45.5017 | -73.5673 |
|
||||||
|
| Mexico City | Mexico City | Mexico | 19.4326 | -99.1332 |
|
||||||
|
| Twin Peaks / windmills / craft beer | San Francisco | USA | 37.7749 | -122.4194 |
|
||||||
|
| Amtrak eighteen hours | (train — use Portland as destination) | USA | 45.5231 | -122.6765 |
|
||||||
|
|
||||||
|
Use the title + body together to identify each city.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Estimate weather**
|
||||||
|
|
||||||
|
Reference values (daytime high °C, description) for this trip's cities + months (May–Aug 2024):
|
||||||
|
|
||||||
|
| City | May | Jul | Aug |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Piran | 21, sunny | — | — |
|
||||||
|
| San Francisco | — | 18, partly cloudy | 18, partly cloudy |
|
||||||
|
| Los Angeles | — | 28, sunny | 29, sunny |
|
||||||
|
| Portland | — | 27, sunny | 27, sunny |
|
||||||
|
| Toronto | — | — | 27, sunny |
|
||||||
|
| Niagara Falls | — | — | 26, sunny |
|
||||||
|
| Montreal | — | — | 26, sunny |
|
||||||
|
| Mexico City | — | — | 22, partly cloudy |
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the review doc**
|
||||||
|
|
||||||
|
Create `docs/enrichment/us-canada-mex-2024.md` with the same structure as the central-asia doc:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# us-canada-mex-2024 Enrichment Review
|
||||||
|
|
||||||
|
**Instructions:** Review each row. To correct coordinates, replace the Map Link with a new OSM link (`https://www.openstreetmap.org/#map=15/{lat}/{lng}`) or a Google Maps URL — coordinates are extracted from the link. Edit City, Country, Temp, and Weather cells directly. Leave Map Link blank if no location is known.
|
||||||
|
|
||||||
|
| Entry | Date | Title | City | Country | Map Link | Temp °C | Weather |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 2024-05-28-pixelfed-1.entry | 2024-05-28 | Ice Cream and Old Walls in Piran | Piran | Slovenia | https://www.openstreetmap.org/#map=15/45.5285/13.5680 | 21 | sunny |
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast
|
||||||
|
git add docs/enrichment/us-canada-mex-2024.md
|
||||||
|
git commit -m "docs: add us-canada-mex-2024 enrichment review doc"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Prompt user to review**
|
||||||
|
|
||||||
|
Tell the user: "Review doc generated at `docs/enrichment/us-canada-mex-2024.md`. Please open it, correct any locations or weather values, and let me know when it's ready to apply."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Apply us-canada-mex-2024 enrichment (after user approval)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `docs/enrichment/us-canada-mex-2024.md`
|
||||||
|
- Modify: `user/pages/01.trips/us-canada-mex-2024/01.dailies/*/entry.md` (12 files)
|
||||||
|
|
||||||
|
**Prerequisite:** User has reviewed and approved `docs/enrichment/us-canada-mex-2024.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the reviewed doc and parse rows** — same method as Task 2 Step 1.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract coordinates from Map Links** — same method as Task 2 Step 2.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply to each entry.md**
|
||||||
|
|
||||||
|
For each row, open `user/pages/01.trips/us-canada-mex-2024/01.dailies/{entry}/entry.md` and update the six frontmatter fields.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify** — read back first 3 and last entry, confirm fields are set.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast && git add user/pages/01.trips/us-canada-mex-2024/01.dailies/
|
||||||
|
git commit -m "content: enrich us-canada-mex-2024 entries with location and weather"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Generate italy-2025 review doc
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/enrichment/italy-2025.md`
|
||||||
|
- Read: `user/pages/01.trips/italy-2025/01.dailies/*/entry.md` (2 files)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read both entry.md files.**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Infer location.**
|
||||||
|
|
||||||
|
Entry 1 (2025-10-11): "600km of Tuscany Begins with an Aperitif" — route starts at Venturina Terme (from GPX filename). City: Venturina Terme, Italy. Lat: 43.0183, Lng: 10.6059.
|
||||||
|
|
||||||
|
Entry 2 (2025-10-16): read title + body to determine.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Estimate weather.**
|
||||||
|
|
||||||
|
Tuscany, October: 18°C, partly cloudy (typical autumn).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the review doc.**
|
||||||
|
|
||||||
|
Create `docs/enrichment/italy-2025.md` with the same structure, 2 data rows.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast
|
||||||
|
git add docs/enrichment/italy-2025.md
|
||||||
|
git commit -m "docs: add italy-2025 enrichment review doc"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Prompt user to review.**
|
||||||
|
|
||||||
|
Tell the user: "Review doc generated at `docs/enrichment/italy-2025.md`. Please open it, correct any values, and let me know when ready to apply."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Apply italy-2025 enrichment (after user approval)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `docs/enrichment/italy-2025.md`
|
||||||
|
- Modify: `user/pages/01.trips/italy-2025/01.dailies/*/entry.md` (2 files)
|
||||||
|
|
||||||
|
**Prerequisite:** User has reviewed and approved `docs/enrichment/italy-2025.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the reviewed doc and parse rows.**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract coordinates from Map Links.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply to both entry.md files** — update six frontmatter fields.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify** — read both files back, confirm fields are set.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit and sync**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Projects/travel-blog-intotheeast && git add user/pages/01.trips/italy-2025/01.dailies/
|
||||||
|
git commit -m "content: enrich italy-2025 entries with location and weather"
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- All 36 entries covered across 6 tasks (3 generate + 3 apply)
|
||||||
|
- Human review gates are explicit: each "apply" task has a **Prerequisite** line
|
||||||
|
- Coordinate extraction rules cover both OSM and Google Maps URL formats
|
||||||
|
- Weather reference tables provide concrete values — no vague "look it up"
|
||||||
|
- `make content-push` only runs after the final trip to avoid partial syncs
|
||||||
|
- No test files needed — this is data enrichment; verification steps replace TDD
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
# Homepage Redesign Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-21)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Context-aware homepage with a persistent two-column map+feed layout: active-trip mode shows the live feed + GPX on the home map; between-trips mode shows a curated highlights grid from all trips with markers-only on the map.
|
||||||
|
|
||||||
|
**Architecture:** Single `home.html.twig` with a `{% if config.site.travelling %}` branch. Active trip branch keeps the existing feed and adds GPX loading to the home map. Between-trips branch selects one random `featured:true` entry per trip (max 6), renders a highlight card grid, and passes coordinates to the map (no GPX, no journey line). Three blueprint files expose new data fields. A site-config blueprint exposes the mode switch and active-trip selector in Admin2.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav CMS 2.0 (PHP/Twig), MapLibre GL v4, toGeoJSON CDN, Playwright (Node.js)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All `user/` file changes committed via `git -C user` from the project root (or `git` from within `user/`)
|
||||||
|
- Test files in `tests/ui/` and `scripts/` committed via plain `git` from the project root
|
||||||
|
- No new Grav plugins
|
||||||
|
- No JS build pipeline — plain CSS and vanilla JS only
|
||||||
|
- `config.site.active_trip` stores a **full page route**: `/trips/italy-2026-demo` (not a bare slug)
|
||||||
|
- `config.site.travelling` is `true` for active-trip mode, `false` for between-trips mode
|
||||||
|
- `entry.header.featured` and `story.header.featured` (bool) gate highlight eligibility — no type-based auto-include; both stories and journal entries use the same flag
|
||||||
|
- `trip.header.tagline` (string) is the trip description shown on highlight cards
|
||||||
|
- Dev server at `http://localhost:8081` must be running (`make start`) for all Playwright tests
|
||||||
|
- Demo data must be loaded (`make demo-load`) before running Playwright tests
|
||||||
|
- Playwright test IDs continue sequentially: next map test is M8; next home tests are H2–H5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Blueprints, config, and demo seed data
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/blueprints/config/site.yaml`
|
||||||
|
- Modify: `user/themes/intotheeast/blueprints/trip.yaml` — add `tagline` field in Trip tab
|
||||||
|
- Modify: `user/themes/intotheeast/blueprints/entry.yaml` — add `featured` toggle in Entry tab
|
||||||
|
- Modify: `user/themes/intotheeast/blueprints/story.yaml` — add `featured` toggle in Publishing tab
|
||||||
|
- Modify: `user/config/site.yaml` — change `active_trip` to full route, add `travelling: true`
|
||||||
|
- Modify: `user/pages/01.trips/italy-2026-demo/04.stories/val-dorcia-at-dawn/story.md` — add `featured: true`
|
||||||
|
- Modify: first journal entry under `user/pages/01.trips/italy-2026-demo/01.dailies/` — add `featured: true`
|
||||||
|
- Modify: matching demo source files in `user/docs/demo/trips/italy-2026-demo/` — mirror the `featured: true` additions
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `config.site.travelling` (bool) — read in Twig as `config.site.travelling`
|
||||||
|
- Produces: `config.site.active_trip` (string, full route) — used in Task 2 path lookups
|
||||||
|
- Produces: `entry.header.featured` / `story.header.featured` (bool) — used in Task 3 selection logic
|
||||||
|
- Produces: `trip.header.tagline` (string) — used in Task 3 card rendering
|
||||||
|
|
||||||
|
- [ ] **Step 1: Record baseline test result**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 13 passed, 1 failed (`parent set to /trips/japan-korea-2026/dailies` — pre-existing). Record this so you can verify nothing changed after your edits.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `user/blueprints/config/site.yaml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
form:
|
||||||
|
validation: loose
|
||||||
|
fields:
|
||||||
|
active_trip:
|
||||||
|
type: pages
|
||||||
|
label: Active Trip
|
||||||
|
start_route: '/trips'
|
||||||
|
show_root: false
|
||||||
|
show_slug: true
|
||||||
|
|
||||||
|
travelling:
|
||||||
|
type: toggle
|
||||||
|
label: Currently Travelling
|
||||||
|
highlight: 1
|
||||||
|
default: false
|
||||||
|
options:
|
||||||
|
1: 'Yes'
|
||||||
|
0: 'No'
|
||||||
|
validate:
|
||||||
|
type: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `type: pages` is confirmed present in Admin2's JS bundle but untested in a site config blueprint. If it fails to render in Admin2, fall back to `type: select` with explicit `options:` entries — one per trip slug — and no other code changes are needed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `tagline` to `user/themes/intotheeast/blueprints/trip.yaml`**
|
||||||
|
|
||||||
|
In the `trip` tab's `fields` block, after `header.album_url`, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header.tagline:
|
||||||
|
type: text
|
||||||
|
label: Tagline
|
||||||
|
placeholder: '6 weeks from Venice to Sicily by train'
|
||||||
|
help: 'Short description shown on homepage highlight cards'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `featured` toggle to `user/themes/intotheeast/blueprints/entry.yaml`**
|
||||||
|
|
||||||
|
In the `entry` tab's `fields` block, after `header.force_connect`, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header.featured:
|
||||||
|
type: toggle
|
||||||
|
label: Featured highlight
|
||||||
|
help: 'Show as a homepage highlight when not travelling'
|
||||||
|
highlight: 1
|
||||||
|
default: 0
|
||||||
|
options:
|
||||||
|
1: 'Yes'
|
||||||
|
0: 'No'
|
||||||
|
validate:
|
||||||
|
type: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `featured` toggle to `user/themes/intotheeast/blueprints/story.yaml`**
|
||||||
|
|
||||||
|
In the `publishing` tab's `fields` block, after `header.published`, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header.featured:
|
||||||
|
type: toggle
|
||||||
|
label: Featured highlight
|
||||||
|
help: 'Show as a homepage highlight when not travelling'
|
||||||
|
highlight: 1
|
||||||
|
default: 0
|
||||||
|
options:
|
||||||
|
1: 'Yes'
|
||||||
|
0: 'No'
|
||||||
|
validate:
|
||||||
|
type: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update `user/config/site.yaml`**
|
||||||
|
|
||||||
|
Replace the file contents with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
title: 'Into the East'
|
||||||
|
description: 'A travel blog by Mischa'
|
||||||
|
author:
|
||||||
|
name: Mischa
|
||||||
|
email: mischa@gorinskat.nl
|
||||||
|
taxonomies: [category, tag]
|
||||||
|
metadata:
|
||||||
|
description: 'Into the East — travel journal'
|
||||||
|
active_trip: /trips/italy-2026-demo
|
||||||
|
travelling: true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Mark the demo story as featured**
|
||||||
|
|
||||||
|
Open `user/pages/01.trips/italy-2026-demo/04.stories/val-dorcia-at-dawn/story.md` and add `featured: true` to its YAML frontmatter block. For example, if the existing frontmatter ends with `published: true`, add the line after it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
featured: true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Mark one demo journal entry as featured**
|
||||||
|
|
||||||
|
Find the first entry folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls user/pages/01.trips/italy-2026-demo/01.dailies/ | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `user/pages/01.trips/italy-2026-demo/01.dailies/<that-slug>/entry.md` and add `featured: true` to its YAML frontmatter. Ensure the entry has `lat` and `lng` set — if the first entry doesn't, pick the first one that does (check with `grep -l "^lat:" user/pages/01.trips/italy-2026-demo/01.dailies/*/entry.md | head -1`).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Mirror featured flags to demo source**
|
||||||
|
|
||||||
|
The demo source lives in `user/docs/demo/trips/italy-2026-demo/`. Apply the same `featured: true` additions to:
|
||||||
|
- `user/docs/demo/trips/italy-2026-demo/stories/val-dorcia-at-dawn/story.md`
|
||||||
|
- The matching journal entry in `user/docs/demo/trips/italy-2026-demo/dailies/<slug>/entry.md`
|
||||||
|
|
||||||
|
This ensures featured flags survive `make demo-reset`.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Verify tests unchanged**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: identical to Step 1 (13 passed, 1 pre-existing failure). These are YAML-only changes — no template or script changed.
|
||||||
|
|
||||||
|
- [ ] **Step 11: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add blueprints/config/site.yaml \
|
||||||
|
themes/intotheeast/blueprints/trip.yaml \
|
||||||
|
themes/intotheeast/blueprints/entry.yaml \
|
||||||
|
themes/intotheeast/blueprints/story.yaml \
|
||||||
|
config/site.yaml \
|
||||||
|
pages/01.trips/italy-2026-demo/04.stories/val-dorcia-at-dawn/story.md \
|
||||||
|
docs/demo/trips/italy-2026-demo/stories/val-dorcia-at-dawn/story.md
|
||||||
|
git -C user commit -m "feat: add blueprints for active_trip/travelling config, tagline, featured fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then commit the journal entry (substitute the actual slug discovered in Step 8):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add pages/01.trips/italy-2026-demo/01.dailies/<slug>/entry.md \
|
||||||
|
docs/demo/trips/italy-2026-demo/dailies/<slug>/entry.md
|
||||||
|
git -C user commit -m "chore: mark demo entries as featured for homepage highlight testing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Active trip mode — route-based lookup + GPX on home map
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/home.html.twig` — full replacement
|
||||||
|
- Modify: `tests/ui/maps.spec.js` — add M8
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `config.site.travelling` (bool) — Task 1
|
||||||
|
- Consumes: `config.site.active_trip` (full route string) — Task 1
|
||||||
|
- Produces: `window.homeMap` global (already existed — now with GPX sources `home-gpx-0` … and `home-journey`)
|
||||||
|
- Produces: `{% else %}` placeholder in template for Task 3 to fill
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test M8**
|
||||||
|
|
||||||
|
Add to `tests/ui/maps.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── M8: Home map has GPX journey source on active trip ────────────────────────
|
||||||
|
test('M8: home map has a journey source after GPX settles (active trip)', async ({ page }) => {
|
||||||
|
// Requires travelling: true in user/config/site.yaml (set in Task 1).
|
||||||
|
// Requires GPX files attached to the active trip (italy-2026-demo has 7).
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('#home-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await page.waitForFunction(function () {
|
||||||
|
return window.homeMap &&
|
||||||
|
(window.homeMap.getSource('home-journey') !== undefined ||
|
||||||
|
window.homeMap.getSource('home-gpx-0') !== undefined);
|
||||||
|
}, { timeout: 20000 });
|
||||||
|
|
||||||
|
const hasSource = await page.evaluate(function () {
|
||||||
|
return !!(window.homeMap.getSource('home-journey') || window.homeMap.getSource('home-gpx-0'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasSource, 'Home map has a journey or GPX source').toBe(true);
|
||||||
|
expect(errors, 'No JS errors on home page').toHaveLength(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run to confirm it fails:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps.spec.js --grep "M8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL (GPX sources not yet added to home map).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `home.html.twig` with the active-trip-mode version**
|
||||||
|
|
||||||
|
Replace the entire file with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set trip_route = config.site.active_trip %}
|
||||||
|
{% set trip = grav.pages.find(trip_route) %}
|
||||||
|
|
||||||
|
{% if config.site.travelling %}
|
||||||
|
{# ══════════════════════════════════════════════════════════ ACTIVE TRIP MODE #}
|
||||||
|
|
||||||
|
{% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %}
|
||||||
|
{% set stories_page = grav.pages.find(trip_route ~ '/stories') %}
|
||||||
|
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
|
||||||
|
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
|
||||||
|
|
||||||
|
{% set all_items = [] %}
|
||||||
|
{% for e in journal_entries %}
|
||||||
|
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for s in story_entries %}
|
||||||
|
{% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.header.date}]) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set all_items = all_items|sort_by_key('date', 3) %}
|
||||||
|
|
||||||
|
{% set journal_count = journal_entries|length %}
|
||||||
|
{% set story_count = story_entries|length %}
|
||||||
|
|
||||||
|
{% set map_entries = [] %}
|
||||||
|
{% for item in all_items %}
|
||||||
|
{% if item.type == 'journal' and item.page.header.lat is not empty and item.page.header.lng is not empty %}
|
||||||
|
{% set map_entries = map_entries|merge([{
|
||||||
|
'lat': item.page.header.lat|number_format(6, '.', ''),
|
||||||
|
'lng': item.page.header.lng|number_format(6, '.', ''),
|
||||||
|
'slug': item.page.slug,
|
||||||
|
'title': item.page.title,
|
||||||
|
'url': item.page.url,
|
||||||
|
'force_connect': item.page.header.force_connect ? true : false
|
||||||
|
}]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set home_gpx_urls = [] %}
|
||||||
|
{% if trip %}
|
||||||
|
{% for name, media in trip.media.all %}
|
||||||
|
{% if name|split('.')|last == 'gpx' %}
|
||||||
|
{% set home_gpx_urls = home_gpx_urls|merge([trip.url ~ '/' ~ name]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="home-layout">
|
||||||
|
<div class="home-map-col">
|
||||||
|
<div class="home-map" id="home-map"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-feed-col">
|
||||||
|
<div class="home-trip-header">
|
||||||
|
<h1 class="home-trip-name">{{ trip ? trip.title : trip_route }}</h1>
|
||||||
|
<span class="home-trip-counts">
|
||||||
|
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
|
||||||
|
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feed">
|
||||||
|
{% if all_items|length > 0 %}
|
||||||
|
{% for item in all_items %}
|
||||||
|
{% set entry = item.page %}
|
||||||
|
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
{% set weather_icons = {
|
||||||
|
'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
|
||||||
|
'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
|
||||||
|
'Snow': '❄️', 'Thunderstorm': '⛈️'
|
||||||
|
} %}
|
||||||
|
<article class="journal-post" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
||||||
|
<header class="journal-post-header">
|
||||||
|
<h2 class="journal-post-title">{{ entry.title }}</h2>
|
||||||
|
<p class="journal-post-meta">
|
||||||
|
<a class="journal-post-permalink" href="{{ entry.url }}">
|
||||||
|
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
|
||||||
|
</a>
|
||||||
|
{% if entry.header.location_city or entry.header.location_country %}
|
||||||
|
<span class="journal-post-location">
|
||||||
|
· 📍
|
||||||
|
{%- set _loc = [] -%}
|
||||||
|
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
|
||||||
|
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
|
||||||
|
{{ _loc|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.header.weather_desc %}
|
||||||
|
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% set images = entry.media.images %}
|
||||||
|
{% if images|length > 0 %}
|
||||||
|
<div class="journal-photo-strip" data-slides="{{ images|length }}">
|
||||||
|
{% for img in images %}
|
||||||
|
<div class="journal-photo-slide">
|
||||||
|
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if images|length > 1 %}
|
||||||
|
<div class="journal-photo-dots" aria-hidden="true">
|
||||||
|
{% for img in images %}
|
||||||
|
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="journal-post-body">{{ entry.content|raw }}</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
{% set hero = null %}
|
||||||
|
{% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
|
||||||
|
{% set hero = entry.media[entry.header.hero_image] %}
|
||||||
|
{% elseif entry.media.images|length > 0 %}
|
||||||
|
{% set hero = entry.media.images|first %}
|
||||||
|
{% endif %}
|
||||||
|
<a class="entry-card entry-card--story" id="entry-{{ entry.slug }}" href="{{ entry.url }}">
|
||||||
|
{% if hero %}
|
||||||
|
<div class="entry-card-photo entry-card-photo--story">
|
||||||
|
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="entry-card-body">
|
||||||
|
<span class="story-badge">✦ Story</span>
|
||||||
|
<h2 class="entry-title">{{ entry.title }}</h2>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if map_entries|length > 0 %}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
{% if home_gpx_urls|length > 0 %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
var HOME_ENTRIES = {{ map_entries|json_encode|raw }};
|
||||||
|
var HOME_GPX_URLS = {{ home_gpx_urls|json_encode|raw }};
|
||||||
|
|
||||||
|
var homeMap = new maplibregl.Map({
|
||||||
|
container: 'home-map',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
homeMap.on('load', function () {
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
var coords = [];
|
||||||
|
|
||||||
|
HOME_ENTRIES.forEach(function (entry, i) {
|
||||||
|
var isLatest = (i === HOME_ENTRIES.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
coords.push(lngLat);
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = MapUtils.createDotMarker(isLatest);
|
||||||
|
el.dataset.url = entry.url;
|
||||||
|
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||||
|
el.addEventListener('mouseenter', function () { popup.addTo(homeMap); });
|
||||||
|
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('entry-' + entry.slug);
|
||||||
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(homeMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Draw simple journey line immediately; replaced below if GPX is present */
|
||||||
|
MapUtils.addJourneyLine(homeMap, coords, 'home-journey');
|
||||||
|
|
||||||
|
if (HOME_ENTRIES.length === 1) {
|
||||||
|
homeMap.jumpTo({ center: coords[0], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
homeMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(function () { homeMap.resize(); }, 100);
|
||||||
|
|
||||||
|
if (HOME_GPX_URLS.length > 0) {
|
||||||
|
Promise.all(HOME_GPX_URLS.map(function (url, idx) {
|
||||||
|
return fetch(url)
|
||||||
|
.then(function (r) { return r.text(); })
|
||||||
|
.then(function (text) {
|
||||||
|
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||||
|
var geojson = toGeoJSON.gpx(xml);
|
||||||
|
var sid = 'home-gpx-' + idx;
|
||||||
|
homeMap.addSource(sid, { type: 'geojson', data: geojson });
|
||||||
|
homeMap.addLayer({
|
||||||
|
id: sid + '-line', type: 'line', source: sid,
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
|
||||||
|
});
|
||||||
|
return MapUtils.extractTrackpoints(geojson);
|
||||||
|
})
|
||||||
|
.catch(function (err) { console.warn('GPX load failed:', url, err); return []; });
|
||||||
|
})).then(function (allTrackpoints) {
|
||||||
|
if (homeMap.getLayer('home-journey')) homeMap.removeLayer('home-journey');
|
||||||
|
if (homeMap.getSource('home-journey')) homeMap.removeSource('home-journey');
|
||||||
|
var valid = allTrackpoints.filter(function (tp) { return tp.length > 0; });
|
||||||
|
var segments = MapUtils.buildJourneySegments(HOME_ENTRIES, valid, 10);
|
||||||
|
MapUtils.addJourneySegments(homeMap, segments, 'home-journey');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ════════════════════════════════════════════════ BETWEEN-TRIPS MODE (Task 3) #}
|
||||||
|
<p class="feed-empty" style="padding: 2rem;">Off season — highlights coming in Task 3.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run M8 test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps.spec.js --grep "M8"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS. The home map now loads GPX URLs, adds `home-gpx-N` layer sources, and replaces the simple `home-journey` source with connector-suppressed segments.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run existing home + map tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps.spec.js tests/ui/home.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: M4 and H1 still pass; M8 passes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/home.html.twig
|
||||||
|
git -C user commit -m "feat: add travelling branch and GPX to home map (active trip mode)"
|
||||||
|
git add tests/ui/maps.spec.js
|
||||||
|
git commit -m "test(maps): add M8 — home map GPX source on active trip"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Between-trips highlights mode + CSS + Playwright tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/home.html.twig` — replace `{% else %}` placeholder with full highlights branch
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css` — append highlight card and grid styles
|
||||||
|
- Create: `tests/ui/home-highlights.spec.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `config.site.travelling` (bool) — Task 1
|
||||||
|
- Consumes: `entry.header.featured` / `story.header.featured` (bool) — Task 1
|
||||||
|
- Consumes: `trip.header.tagline` (string) — Task 1
|
||||||
|
- Produces: `.home-highlights-grid` — the grid container, used in Playwright selectors
|
||||||
|
- Produces: `.home-highlight-card[id="highlight-<slug>"]` — per-card IDs for map marker scroll-to
|
||||||
|
- Produces: `.home-highlights-cta` — CTA link to `/trips`
|
||||||
|
- Produces: `window.homeMap` global in between-trips mode (same name, separate branch)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Create `tests/ui/home-highlights.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: H2–H5 — Between-trips highlights mode
|
||||||
|
// These tests temporarily set travelling: false in user/config/site.yaml,
|
||||||
|
// run the assertions, then restore the original value.
|
||||||
|
// Requires demo data with featured entries: run `make demo-load` first.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');
|
||||||
|
|
||||||
|
test.describe('Between-trips highlights mode', () => {
|
||||||
|
let originalSiteYaml;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
|
||||||
|
const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, patched);
|
||||||
|
// Brief pause for Grav to re-read config on next request
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H2: Highlights grid is visible ──────────────────────────────────────────
|
||||||
|
test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H3: Highlight cards contain trip link ────────────────────────────────────
|
||||||
|
test('H3: highlight cards have a View-trip link', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H4: Between-trips home map renders without JS errors ────────────────────
|
||||||
|
test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
expect(errors, 'No JS errors').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H5: CTA links to /trips ──────────────────────────────────────────────────
|
||||||
|
test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const cta = page.locator('.home-highlights-cta');
|
||||||
|
await expect(cta).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(cta).toHaveAttribute('href', /\/trips/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run to confirm they fail:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/home-highlights.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: H2–H5 all FAIL (`.home-highlights-grid` not present).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Append highlight CSS to `user/themes/intotheeast/css/style.css`**
|
||||||
|
|
||||||
|
Append to the end of `style.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ── Between-trips highlights grid ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.home-highlights-header {
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
padding-bottom: var(--space-6);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlights-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-ink);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlights-subtitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlights-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin-bottom: var(--space-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.home-highlights-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.home-highlights-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-image {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-body {
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant: small-caps;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-badge--journal {
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-title:hover { color: var(--color-accent); }
|
||||||
|
|
||||||
|
.home-highlight-trip {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-trip-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-tagline { font-style: italic; }
|
||||||
|
|
||||||
|
.home-highlight-trip-link {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlight-trip-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.home-highlights-cta-wrap {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlights-cta {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-highlights-cta:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-canvas);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the placeholder `{% else %}` branch in `home.html.twig`**
|
||||||
|
|
||||||
|
Find this exact block (added in Task 2):
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% else %}
|
||||||
|
{# ════════════════════════════════════════════════ BETWEEN-TRIPS MODE (Task 3) #}
|
||||||
|
<p class="feed-empty" style="padding: 2rem;">Off season — highlights coming in Task 3.</p>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% else %}
|
||||||
|
{# ══════════════════════════════════════════════════════ BETWEEN-TRIPS MODE #}
|
||||||
|
|
||||||
|
{# ── Highlight selection ─────────────────────────────────────────────────── #}
|
||||||
|
{% set trips_page = grav.pages.find('/trips') %}
|
||||||
|
{% set pool = [] %}
|
||||||
|
{% if trips_page %}
|
||||||
|
{% for trip_item in trips_page.children.published() %}
|
||||||
|
{% set t_dailies = grav.pages.find(trip_item.route ~ '/dailies') %}
|
||||||
|
{% set t_stories = grav.pages.find(trip_item.route ~ '/stories') %}
|
||||||
|
{% set candidates = [] %}
|
||||||
|
{% if t_dailies %}
|
||||||
|
{% for e in t_dailies.children.published() %}
|
||||||
|
{% if e.header.featured %}
|
||||||
|
{% set candidates = candidates|merge([{'type': 'journal', 'page': e, 'trip': trip_item}]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if t_stories %}
|
||||||
|
{% for s in t_stories.children.published() %}
|
||||||
|
{% if s.header.featured %}
|
||||||
|
{% set candidates = candidates|merge([{'type': 'story', 'page': s, 'trip': trip_item}]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if candidates|length > 0 %}
|
||||||
|
{% set pool = pool|merge([random(candidates)]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% set pool = pool|shuffle %}
|
||||||
|
{% set highlights = pool|slice(0, 6) %}
|
||||||
|
|
||||||
|
{# ── Map entries (entries with coordinates) ──────────────────────────────── #}
|
||||||
|
{% set highlights_map_entries = [] %}
|
||||||
|
{% for item in highlights %}
|
||||||
|
{% if item.page.header.lat is not empty and item.page.header.lng is not empty %}
|
||||||
|
{% set highlights_map_entries = highlights_map_entries|merge([{
|
||||||
|
'lat': item.page.header.lat|number_format(6, '.', ''),
|
||||||
|
'lng': item.page.header.lng|number_format(6, '.', ''),
|
||||||
|
'slug': item.page.slug,
|
||||||
|
'title': item.page.title,
|
||||||
|
'url': item.page.url
|
||||||
|
}]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="home-layout">
|
||||||
|
<div class="home-map-col">
|
||||||
|
<div class="home-map" id="home-map"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-feed-col">
|
||||||
|
<div class="home-highlights-header">
|
||||||
|
<h1 class="home-highlights-title">Into the East</h1>
|
||||||
|
<p class="home-highlights-subtitle">A few moments from past journeys</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if highlights|length > 0 %}
|
||||||
|
<div class="home-highlights-grid">
|
||||||
|
{% for item in highlights %}
|
||||||
|
{% set entry = item.page %}
|
||||||
|
{% set hero = null %}
|
||||||
|
{% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
|
||||||
|
{% set hero = entry.media[entry.header.hero_image] %}
|
||||||
|
{% elseif entry.media.images|length > 0 %}
|
||||||
|
{% set hero = entry.media.images|first %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="home-highlight-card" id="highlight-{{ entry.slug }}">
|
||||||
|
{% if hero %}
|
||||||
|
<div class="home-highlight-image">
|
||||||
|
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ entry.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="home-highlight-body">
|
||||||
|
{% if item.type == 'story' %}
|
||||||
|
<span class="home-highlight-badge">✦ Story</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="home-highlight-badge home-highlight-badge--journal">▸ Journal</span>
|
||||||
|
{% endif %}
|
||||||
|
<a class="home-highlight-title" href="{{ entry.url }}">{{ entry.title }}</a>
|
||||||
|
<div class="home-highlight-trip">
|
||||||
|
<span class="home-highlight-trip-name">{{ item.trip.title }}</span>
|
||||||
|
{% if item.trip.header.tagline %}
|
||||||
|
<span class="home-highlight-tagline">{{ item.trip.header.tagline }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<a class="home-highlight-trip-link" href="{{ item.trip.url }}">→ View trip</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="feed-empty">No highlights yet — mark entries as featured to show them here.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="home-highlights-cta-wrap">
|
||||||
|
<a class="home-highlights-cta" href="/trips">Explore all past trips →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if highlights_map_entries|length > 0 %}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
var HIGHLIGHTS_ENTRIES = {{ highlights_map_entries|json_encode|raw }};
|
||||||
|
|
||||||
|
var homeMap = new maplibregl.Map({
|
||||||
|
container: 'home-map',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
homeMap.on('load', function () {
|
||||||
|
if (HIGHLIGHTS_ENTRIES.length === 0) return;
|
||||||
|
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
|
||||||
|
HIGHLIGHTS_ENTRIES.forEach(function (entry) {
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = MapUtils.createDotMarker(false);
|
||||||
|
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||||
|
el.addEventListener('mouseenter', function () { popup.addTo(homeMap); });
|
||||||
|
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('highlight-' + entry.slug);
|
||||||
|
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(homeMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (HIGHLIGHTS_ENTRIES.length === 1) {
|
||||||
|
homeMap.jumpTo({ center: [parseFloat(HIGHLIGHTS_ENTRIES[0].lng), parseFloat(HIGHLIGHTS_ENTRIES[0].lat)], zoom: 8 });
|
||||||
|
} else {
|
||||||
|
homeMap.fitBounds(bounds, { padding: 60, maxZoom: 8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(function () { homeMap.resize(); }, 100);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run H2–H5 tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/home-highlights.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: H2, H3, H4, H5 all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
npx playwright test tests/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `make test` same as baseline (13 passed, 1 pre-existing failure). All Playwright tests pass — H1 and M4 use `travelling: true`; H2–H5 temporarily flip to `false` and restore it.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/home.html.twig \
|
||||||
|
themes/intotheeast/css/style.css
|
||||||
|
git -C user commit -m "feat: add between-trips highlights mode with grid and map markers"
|
||||||
|
git add tests/ui/home-highlights.spec.js
|
||||||
|
git commit -m "test(home): add H2–H5 between-trips highlights Playwright tests"
|
||||||
|
```
|
||||||
@@ -0,0 +1,975 @@
|
|||||||
|
# Playwright Tests — Improvement & Expansion
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-21)
|
||||||
|
|
||||||
|
> **STATUS: DONE** — merged to main 2026-06-21 (commits c703a09…1d29c30). All 6 tasks complete; 79 tests passing. See `docs/working/specs/2026-06-21-playwright-tests-design.md` for details.
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Reorganise the flat `tests/ui/` into feature subdirectories, fix stale trip-slug references that cause 30 pre-existing failures, add missing test files from the main branch, add a GPX Manager end-to-end suite (GM1–GM7), extend the post form suite (P6–P8), and extend axe scans (AX6–AX7).
|
||||||
|
|
||||||
|
**Architecture:** All changes stay inside `tests/`. Playwright's `testDir: './tests/ui'` recurses automatically so `playwright.config.js` is untouched. End-to-end tests hit the live Grav server at `http://localhost:8081`; demo data is loaded by `globalSetup` via `make demo-load`.
|
||||||
|
|
||||||
|
**Tech Stack:** Playwright (Node), Grav REST API (`/api/v1`), axe-core via `@axe-core/playwright`, existing `helpers.js`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Branch: `worktree-playwright-tests`; working directory: `.claude/worktrees/playwright-tests/`
|
||||||
|
- Grav server must be running at `http://localhost:8081` before running tests
|
||||||
|
- Demo trip used in tests: `italy-2026-demo` (slug used in all fixture references)
|
||||||
|
- `helpers.js` stays at `tests/ui/helpers.js` — moved specs update their import from `./helpers` to `../helpers`
|
||||||
|
- `auth.setup.js` `testMatch: /auth\.setup\.js/` resolves by filename — works at any depth
|
||||||
|
- P2 remains skipped (photo upload needs post-form work first)
|
||||||
|
- End-to-end GPX Manager tests make real API calls — `afterAll` cleans up uploaded fixture files
|
||||||
|
- Run tests: `npx playwright test --reporter=line`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
fixtures/
|
||||||
|
test-photo.jpg (existing — untouched)
|
||||||
|
test-nonimage.txt (existing — untouched)
|
||||||
|
test-route.gpx (NEW — Task 2)
|
||||||
|
ui/
|
||||||
|
helpers.js (stays here — shared)
|
||||||
|
auth/
|
||||||
|
auth.setup.js (moved — Task 3)
|
||||||
|
auth.spec.js (moved — Task 3)
|
||||||
|
post/
|
||||||
|
post.spec.js (moved + P6-P8 — Tasks 3 & 5)
|
||||||
|
validation.spec.js (moved — Task 3)
|
||||||
|
gpx/
|
||||||
|
gpx-journey.spec.js (moved — Task 3)
|
||||||
|
gpx-manager.spec.js (NEW — Task 4)
|
||||||
|
maps/
|
||||||
|
maps.spec.js (moved — Task 3)
|
||||||
|
stories/
|
||||||
|
stories.spec.js (moved — Task 3)
|
||||||
|
dailies/
|
||||||
|
dailies.spec.js (moved — Task 3)
|
||||||
|
home/
|
||||||
|
home.spec.js (NEW — Task 1)
|
||||||
|
home-highlights.spec.js (NEW — Task 1)
|
||||||
|
nav/
|
||||||
|
nav.spec.js (moved — Task 3)
|
||||||
|
trip/
|
||||||
|
trip-filter.spec.js (moved — Task 3)
|
||||||
|
a11y/
|
||||||
|
accessibility.spec.js (NEW — Task 1; AX6-AX7 added — Task 6)
|
||||||
|
global-setup.js (untouched)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Sync missing test files and fix stale trip-slug references
|
||||||
|
|
||||||
|
30 of 49 tests fail on the current branch because tests reference `japan-korea-2026` and `italy-2025` trips that no longer match the demo data. This task brings the branch up to parity with `main`'s up-to-date test files.
|
||||||
|
|
||||||
|
**Files to overwrite (correct content below):**
|
||||||
|
- `tests/ui/nav.spec.js`
|
||||||
|
- `tests/ui/dailies.spec.js`
|
||||||
|
- `tests/ui/stories.spec.js`
|
||||||
|
- `tests/ui/gpx-journey.spec.js`
|
||||||
|
|
||||||
|
**Files to create:**
|
||||||
|
- `tests/ui/home.spec.js`
|
||||||
|
- `tests/ui/home-highlights.spec.js`
|
||||||
|
- `tests/ui/accessibility.spec.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Overwrite nav.spec.js**
|
||||||
|
|
||||||
|
Replace `tests/ui/nav.spec.js` entirely with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: N1–N5 — page loads and navigation links
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── N1: /trips/italy-2026-demo/dailies renders ───────────────────────────────
|
||||||
|
test('N1: /trips/italy-2026-demo/dailies page loads with site header', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
await expect(page).toHaveTitle(/Into the East/i);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N2: /trips/italy-2026-demo/map renders without JS errors ─────────────────
|
||||||
|
test('N2: /trips/italy-2026-demo/map page loads without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/trips/italy-2026-demo/map');
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N3: /trips/italy-2026-demo/stats renders ─────────────────────────────────
|
||||||
|
test('N3: /trips/italy-2026-demo/stats page loads with site header', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/trips/italy-2026-demo/stats');
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N4: trip page has Journal filter button (replaced nav link) ───────────────
|
||||||
|
test('N4: trip page filter bar has Journal button', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
|
||||||
|
test.skip('N5: Map nav link navigates to /map', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.click('nav a[href*="map"]');
|
||||||
|
await expect(page).toHaveURL(/\/map/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Overwrite dailies.spec.js**
|
||||||
|
|
||||||
|
Replace `tests/ui/dailies.spec.js` entirely with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: T1–T6 — dailies feed and individual entry pages
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// Known fixture entries that always exist in the repo
|
||||||
|
const KNOWN_SLUG = '2026-09-01-0700-setting-off-from-campiglia.entry';
|
||||||
|
const KNOWN_TITLE = 'Setting Off from Campiglia';
|
||||||
|
const KNOWN_CITY = 'Campiglia Marittima';
|
||||||
|
const KNOWN_COUNTRY = 'Italy';
|
||||||
|
|
||||||
|
// Use two real entries from central-asia-2023 to verify descending order
|
||||||
|
const NEWER_SLUG = '2023-10-18-pixelfed-22.entry'; // newest date in that trip
|
||||||
|
const OLDER_SLUG = '2023-08-28-pixelfed-1.entry'; // oldest date in that trip
|
||||||
|
|
||||||
|
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
|
||||||
|
test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T2: Entries are newest-first ──────────────────────────────────────────────
|
||||||
|
// Verify using two known real entries from central-asia-2023 (22 entries, stable order).
|
||||||
|
test('T2: dailies shows newer entries before older entries', async ({ page }) => {
|
||||||
|
await page.goto('/trips/central-asia-2023/dailies');
|
||||||
|
|
||||||
|
// Use attribute selector to handle dots in slug names (CSS dots are class selectors)
|
||||||
|
const newerCard = page.locator(`.journal-post[id="entry-${NEWER_SLUG}"]`);
|
||||||
|
const olderCard = page.locator(`.journal-post[id="entry-${OLDER_SLUG}"]`);
|
||||||
|
|
||||||
|
await expect(newerCard).toBeVisible();
|
||||||
|
await expect(olderCard).toBeVisible();
|
||||||
|
|
||||||
|
// The newer entry should appear higher in the DOM (lower index)
|
||||||
|
const newerIdx = await newerCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||||
|
});
|
||||||
|
const olderIdx = await olderCard.evaluate(el => {
|
||||||
|
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newerIdx).toBeLessThan(olderIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T3: Individual entry page loads ───────────────────────────────────────────
|
||||||
|
test('T3: individual entry page loads at /trips/italy-2026-demo/dailies/{slug}', async ({ page }) => {
|
||||||
|
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||||
|
await expect(page.locator('article.entry')).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T4: Entry page shows title, date, and content ─────────────────────────────
|
||||||
|
test('T4: entry page shows title and body content', async ({ page }) => {
|
||||||
|
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||||
|
await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
|
||||||
|
await expect(page.locator('.entry-body')).not.toBeEmpty();
|
||||||
|
await expect(page.locator('time.entry-date')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T5: Entry page shows location when present ────────────────────────────────
|
||||||
|
test('T5: entry page shows city and country when set', async ({ page }) => {
|
||||||
|
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||||
|
await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
|
||||||
|
await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────────
|
||||||
|
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
|
||||||
|
const KNOWN_ENTRY = `/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`;
|
||||||
|
await page.goto(KNOWN_ENTRY);
|
||||||
|
await expect(page.locator('article.entry')).toBeVisible();
|
||||||
|
const topPill = page.locator('.entry-back-fixed');
|
||||||
|
await expect(topPill).toBeVisible();
|
||||||
|
await expect(topPill).toHaveText(/← Back/);
|
||||||
|
const footerPill = page.locator('.entry-footer .back-pill');
|
||||||
|
await expect(footerPill).toBeVisible();
|
||||||
|
await expect(footerPill).toHaveText(/← Back/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Overwrite stories.spec.js**
|
||||||
|
|
||||||
|
Replace `tests/ui/stories.spec.js` entirely with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: S1–S7 — story mode rendering and navigation
|
||||||
|
// Requires demo data: run `make demo-load` before this suite.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const STORIES_URL = '/trips/italy-2026-demo/stories';
|
||||||
|
const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn';
|
||||||
|
const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time';
|
||||||
|
const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn';
|
||||||
|
|
||||||
|
// ── S1: Stories listing shows cards ──────────────────────────────────────────
|
||||||
|
test('S1: stories listing renders at least 3 story cards', async ({ page }) => {
|
||||||
|
await page.goto(STORIES_URL);
|
||||||
|
const cards = page.locator('.story-card');
|
||||||
|
await expect(cards.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
const count = await cards.count();
|
||||||
|
expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ──
|
||||||
|
test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => {
|
||||||
|
await page.goto(STORY_GALLERY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
|
||||||
|
const galleries = page.locator('.pgallery');
|
||||||
|
await expect(galleries.first()).toBeVisible();
|
||||||
|
expect(await galleries.count(), 'Two snap-galleries').toBe(2);
|
||||||
|
await expect(page.locator('.chapter-break')).toBeVisible();
|
||||||
|
await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S3: Scrolly-led story — two scrolly-sections + pull-quote with image ─────
|
||||||
|
test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => {
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
const scrollySections = page.locator('.scrolly');
|
||||||
|
await expect(scrollySections.first()).toBeVisible();
|
||||||
|
expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2);
|
||||||
|
await expect(page.locator('.pull-quote__bg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S4: Scrolly story loads without JS errors (Scrollama CDN) ────────────────
|
||||||
|
test('S4: scrolly story page loads without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
expect(errors, 'No JS errors on story page').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S5: Back button returns to stories listing ────────────────────────────────
|
||||||
|
test('S5: back button navigates back to stories listing', async ({ page }) => {
|
||||||
|
await page.goto(STORIES_URL);
|
||||||
|
await page.locator('.story-card').first().click();
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await page.locator('.story-escape').click();
|
||||||
|
await expect(page).toHaveURL(/italy-2026-demo\/stories$/);
|
||||||
|
await expect(page.locator('.story-card').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S6: Demo story — hero image sanity check ─────────────────────────────────
|
||||||
|
test('S6: demo story renders hero image without placeholder', async ({ page }) => {
|
||||||
|
await page.goto(DEMO_STORY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S7: Story body back link is styled as a back-pill ────────────────────────
|
||||||
|
test('S7: story body back link has back-pill class', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
await page.evaluate(() => window.scrollBy(0, window.innerHeight * 1.5));
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const bodyBack = page.locator('.story-footer .back-pill');
|
||||||
|
await expect(bodyBack).toBeAttached();
|
||||||
|
await expect(bodyBack).toHaveText(/← Back/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Overwrite gpx-journey.spec.js**
|
||||||
|
|
||||||
|
Replace only the `getMapUtils` function to point at `italy-2026-demo`. Change line 9:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function getMapUtils(page) {
|
||||||
|
await page.goto('/trips/italy-2026-demo/map');
|
||||||
|
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All G1–G5 test bodies are unchanged — only the URL in `getMapUtils` changes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create home.spec.js**
|
||||||
|
|
||||||
|
Create `tests/ui/home.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: H1 — home page journal feed
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── H1: Home page renders inline journal posts ─────────────────────────────────
|
||||||
|
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create home-highlights.spec.js**
|
||||||
|
|
||||||
|
Create `tests/ui/home-highlights.spec.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: H2–H5 — Between-trips highlights mode
|
||||||
|
// These tests temporarily set travelling: false in user/config/site.yaml,
|
||||||
|
// run the assertions, then restore the original value.
|
||||||
|
// Requires demo data with featured entries: run `make demo-load` first.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');
|
||||||
|
|
||||||
|
test.describe('Between-trips highlights mode', () => {
|
||||||
|
let originalSiteYaml;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
originalSiteYaml = fs.readFileSync(SITE_YAML_PATH, 'utf8');
|
||||||
|
const patched = originalSiteYaml.replace(/^travelling:\s*true/m, 'travelling: false');
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, patched);
|
||||||
|
await new Promise(r => setTimeout(r, 400));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
fs.writeFileSync(SITE_YAML_PATH, originalSiteYaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H2: Highlights grid is visible ──────────────────────────────────────────
|
||||||
|
test('H2: homepage shows highlights grid when not travelling', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlights-grid')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H3: Highlight cards contain trip link ────────────────────────────────────
|
||||||
|
test('H3: highlight cards have a View-trip link', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.home-highlight-card').first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.home-highlight-trip-link').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H4: Between-trips home map renders without JS errors ────────────────────
|
||||||
|
test('H4: home map renders in between-trips mode without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
expect(errors, 'No JS errors').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── H5: CTA links to /trips ──────────────────────────────────────────────────
|
||||||
|
test('H5: "Explore all past trips" CTA links to /trips', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const cta = page.locator('.home-highlights-cta');
|
||||||
|
await expect(cta).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(cta).toHaveAttribute('href', /\/trips/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Create accessibility.spec.js**
|
||||||
|
|
||||||
|
Create `tests/ui/accessibility.spec.js` with the full A1–A5 + AX1–AX5 suite:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: A1–A5 (feature checks) and AX1–AX5 (axe scans)
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── A1: Skip link ──────────────────────────────────────────────────────────────
|
||||||
|
test('A1: skip link targets #main-content and is first focusable element', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const skipLink = page.locator('.skip-link');
|
||||||
|
await expect(skipLink).toBeAttached();
|
||||||
|
await expect(skipLink).toHaveAttribute('href', '#main-content');
|
||||||
|
await expect(page.locator('#main-content')).toBeAttached();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A2: Color token contrast ───────────────────────────────────────────────────
|
||||||
|
test('A2: contrast tokens meet WCAG AA 4.5:1 floor', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const [muted, accent] = await page.evaluate(() => [
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-ink-muted').trim(),
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
|
||||||
|
]);
|
||||||
|
expect(muted.toLowerCase()).toBe('#90887e');
|
||||||
|
expect(accent.toLowerCase()).toBe('#2e9880');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A3: Filter button aria-pressed + toggle aria-expanded ──────────────────────
|
||||||
|
const TRIP_URL = '/trips/italy-2026-demo';
|
||||||
|
|
||||||
|
test('A3a: All-content filter has aria-pressed="true" on load', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3b: clicking Journal filter toggles aria-pressed', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await page.click('.trip-filter-btn[data-filter="journal"]');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3c: Stats toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-controls', 'trip-stats-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3d: clicking Stats toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||||||
|
await page.goto(TRIP_URL);
|
||||||
|
await page.click('#trip-stats-toggle');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await page.click('#trip-stats-toggle');
|
||||||
|
await expect(page.locator('#trip-stats-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
const ITALY_URL = '/trips/italy-2026-demo';
|
||||||
|
|
||||||
|
test('A3e: Cycling toggle has aria-expanded="false" and aria-controls on load', async ({ page }) => {
|
||||||
|
await page.goto(ITALY_URL);
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-controls', 'trip-cycling-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false', async ({ page }) => {
|
||||||
|
await page.goto(ITALY_URL);
|
||||||
|
await page.click('#trip-cycling-toggle');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await page.click('#trip-cycling-toggle');
|
||||||
|
await expect(page.locator('#trip-cycling-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
|
||||||
|
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
const strips = page.locator('.journal-photo-strip');
|
||||||
|
const count = await strips.count();
|
||||||
|
if (count === 0) return;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('role', 'region');
|
||||||
|
await expect(strips.nth(i)).toHaveAttribute('aria-label', 'Photo strip');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
|
||||||
|
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
|
||||||
|
);
|
||||||
|
if (multiCount === 0) return;
|
||||||
|
await expect(page.locator('.strip-prev').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-next').first()).toBeAttached();
|
||||||
|
await expect(page.locator('.strip-prev').first()).toHaveAttribute('aria-label', 'Previous photo');
|
||||||
|
await expect(page.locator('.strip-next').first()).toHaveAttribute('aria-label', 'Next photo');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A5: GPX delete button unique accessible names ──────────────────────────────
|
||||||
|
test('A5: GPX delete buttons have unique aria-labels per filename', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/pages**/media', async route => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{ filename: 'tokyo-day1.gpx', size: 102400, modified: '2026-03-25T10:00:00Z' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const deleteBtn = page.locator('.gpx-trip').first().locator('.gpx-delete[data-filename="tokyo-day1.gpx"]');
|
||||||
|
await expect(deleteBtn).toBeVisible();
|
||||||
|
await expect(deleteBtn).toHaveAttribute('aria-label', 'Delete tokyo-day1.gpx');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AX1–AX5: axe-core WCAG 2.1 AA regression scans ───────────────────────────
|
||||||
|
const { AxeBuilder } = require('@axe-core/playwright');
|
||||||
|
|
||||||
|
const WCAG_TAGS = ['wcag2a', 'wcag2aa'];
|
||||||
|
const BLOCKING = ['critical', 'serious'];
|
||||||
|
|
||||||
|
function axeScan(id, url) {
|
||||||
|
test(`${id}: ${url} passes axe WCAG 2.1 AA (critical/serious)`, async ({ page }) => {
|
||||||
|
await page.goto(url);
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
|
||||||
|
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
|
||||||
|
expect(
|
||||||
|
violations,
|
||||||
|
violations.map(v =>
|
||||||
|
`[${v.impact}] ${v.id}: ${v.description}\n ` +
|
||||||
|
v.nodes.map(n => n.html).join('\n ')
|
||||||
|
).join('\n\n')
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
axeScan('AX1', '/');
|
||||||
|
axeScan('AX2', '/trips/italy-2026-demo');
|
||||||
|
axeScan('AX3', '/trips/italy-2026-demo/dailies');
|
||||||
|
axeScan('AX4', '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry');
|
||||||
|
axeScan('AX5', '/trips');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests to verify the fix**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --reporter=line 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: at least 40 of the 62 tests pass (the pre-existing M7 marker-click, home map, and between-trips tests may still fail if the site state doesn't match — that's acceptable; what must NOT happen is failures in N1–N4, T1–T6, G1–G5, S1–S7).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ui/nav.spec.js tests/ui/dailies.spec.js tests/ui/stories.spec.js \
|
||||||
|
tests/ui/gpx-journey.spec.js tests/ui/home.spec.js \
|
||||||
|
tests/ui/home-highlights.spec.js tests/ui/accessibility.spec.js
|
||||||
|
git commit -m "test: fix stale trip-slug references; add home, highlights, a11y specs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add test-route.gpx fixture
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create tests/fixtures/test-route.gpx**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<trk><trkseg>
|
||||||
|
<trkpt lat="43.7696" lon="11.2558"><ele>50</ele></trkpt>
|
||||||
|
</trkseg></trk>
|
||||||
|
</gpx>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/fixtures/test-route.gpx
|
||||||
|
git commit -m "test: add minimal GPX fixture for GPX Manager tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Reorganize tests into subdirectories
|
||||||
|
|
||||||
|
Move all 13 spec/setup files from `tests/ui/` into feature subdirectories using `git mv`. `helpers.js` stays at `tests/ui/helpers.js`. After moving, update every `require('./helpers')` to `require('../helpers')`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create subdirectory structure and move files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p tests/ui/auth tests/ui/post tests/ui/gpx tests/ui/maps \
|
||||||
|
tests/ui/stories tests/ui/dailies tests/ui/home tests/ui/nav \
|
||||||
|
tests/ui/trip tests/ui/a11y
|
||||||
|
|
||||||
|
git mv tests/ui/auth.setup.js tests/ui/auth/auth.setup.js
|
||||||
|
git mv tests/ui/auth.spec.js tests/ui/auth/auth.spec.js
|
||||||
|
git mv tests/ui/post.spec.js tests/ui/post/post.spec.js
|
||||||
|
git mv tests/ui/validation.spec.js tests/ui/post/validation.spec.js
|
||||||
|
git mv tests/ui/gpx-journey.spec.js tests/ui/gpx/gpx-journey.spec.js
|
||||||
|
git mv tests/ui/maps.spec.js tests/ui/maps/maps.spec.js
|
||||||
|
git mv tests/ui/stories.spec.js tests/ui/stories/stories.spec.js
|
||||||
|
git mv tests/ui/dailies.spec.js tests/ui/dailies/dailies.spec.js
|
||||||
|
git mv tests/ui/home.spec.js tests/ui/home/home.spec.js
|
||||||
|
git mv tests/ui/home-highlights.spec.js tests/ui/home/home-highlights.spec.js
|
||||||
|
git mv tests/ui/nav.spec.js tests/ui/nav/nav.spec.js
|
||||||
|
git mv tests/ui/trip-filter.spec.js tests/ui/trip/trip-filter.spec.js
|
||||||
|
git mv tests/ui/accessibility.spec.js tests/ui/a11y/accessibility.spec.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update helpers import path in every moved spec**
|
||||||
|
|
||||||
|
Every moved file has `require('./helpers')` — change to `require('../helpers')`. Run this from the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find tests/ui -mindepth 2 -name "*.js" -exec \
|
||||||
|
sed -i "s|require('./helpers')|require('../helpers')|g" {} \;
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify no `./helpers` remain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -r "require('./helpers')" tests/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update home-highlights.spec.js path for site.yaml**
|
||||||
|
|
||||||
|
`home-highlights.spec.js` has `path.join(__dirname, '../../user/config/site.yaml')`. After the move it sits one level deeper, so update to `'../../../user/config/site.yaml'`:
|
||||||
|
|
||||||
|
In `tests/ui/home/home-highlights.spec.js`, change:
|
||||||
|
```js
|
||||||
|
const SITE_YAML_PATH = path.join(__dirname, '../../user/config/site.yaml');
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```js
|
||||||
|
const SITE_YAML_PATH = path.join(__dirname, '../../../user/config/site.yaml');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update helpers.js TRACKER_DIR path**
|
||||||
|
|
||||||
|
`helpers.js` uses `path.join(__dirname, '../../user/pages/...')`. It stays at `tests/ui/helpers.js` so no change needed — verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
head -6 tests/ui/helpers.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `__dirname` still resolves to `tests/ui/` — no change needed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify move didn't break anything**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --reporter=line 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: same pass/fail count as after Task 1.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A tests/ui/
|
||||||
|
git commit -m "test: reorganise tests/ui/ into feature subdirectories"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add GPX Manager end-to-end spec
|
||||||
|
|
||||||
|
Create `tests/ui/gpx/gpx-manager.spec.js` with GM1–GM7. Tests make real API calls; `afterAll` cleans up uploaded files via Node `fetch`.
|
||||||
|
|
||||||
|
The `storageState` file at `tests/.auth/user.json` contains the session cookie. `afterAll` reads it to authenticate a cleanup DELETE call.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create tests/ui/gpx/gpx-manager.spec.js**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: GM1–GM7 — GPX Manager end-to-end (real API calls)
|
||||||
|
// Requires: Grav server at localhost:8081, demo-load completed, user logged in.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const BASE_URL = process.env.GRAV_BASE_URL || 'http://localhost:8081';
|
||||||
|
const API = `${BASE_URL}/api/v1`;
|
||||||
|
const TRIP_ROUTE = '/trips/italy-2026-demo';
|
||||||
|
const AUTH_FILE = path.join(__dirname, '../../.auth/user.json');
|
||||||
|
const GPX_FIXTURE = path.join(__dirname, '../../../fixtures/test-route.gpx');
|
||||||
|
const GPX_FIXTURE_CONTENT = fs.readFileSync(GPX_FIXTURE);
|
||||||
|
|
||||||
|
// Track uploaded filenames for cleanup
|
||||||
|
const uploaded = [];
|
||||||
|
|
||||||
|
async function apiDelete(filename) {
|
||||||
|
const authState = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
|
||||||
|
const cookie = authState.cookies
|
||||||
|
.map(c => `${c.name}=${c.value}`)
|
||||||
|
.join('; ');
|
||||||
|
await fetch(
|
||||||
|
`${API}/pages${TRIP_ROUTE}/media/${encodeURIComponent(filename)}`,
|
||||||
|
{ method: 'DELETE', headers: { Cookie: cookie } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
for (const name of uploaded) {
|
||||||
|
try { await apiDelete(name); } catch (_) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM1: Page loads with auth ─────────────────────────────────────────────────
|
||||||
|
test('GM1: /gpx-manager loads with auth and shows one section per trip', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const sections = page.locator('.gpx-trip');
|
||||||
|
await expect(sections.first()).toBeVisible({ timeout: 8000 });
|
||||||
|
const count = await sections.count();
|
||||||
|
expect(count, 'At least one trip section').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM2: Page without auth shows login form ───────────────────────────────────
|
||||||
|
test('GM2: /gpx-manager without auth renders inline login form', async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(`${BASE_URL}/gpx-manager`);
|
||||||
|
await expect(page.locator('#grav-login')).toBeVisible({ timeout: 8000 });
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM3: File list settles (loading placeholder gone) ────────────────────────
|
||||||
|
test('GM3: file list resolves — loading placeholder is gone after API call', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
// Wait for loading placeholder to disappear
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM4: Upload test-route.gpx → appears in file list ────────────────────────
|
||||||
|
test('GM4: uploading test-route.gpx shows it in the file list', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('input[type=file]').setInputFiles({
|
||||||
|
name: 'test-route.gpx',
|
||||||
|
mimeType: 'application/gpx+xml',
|
||||||
|
buffer: GPX_FIXTURE_CONTENT,
|
||||||
|
});
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
|
||||||
|
// Wait for status to show "Uploaded!" and file list to refresh
|
||||||
|
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
|
||||||
|
await expect(italySection.locator('.gpx-table td', { hasText: 'test-route.gpx' })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
uploaded.push('test-route.gpx');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM5: Filename with spaces/caps gets slugified ─────────────────────────────
|
||||||
|
test('GM5: filename with spaces and capitals is slugified before upload', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('input[type=file]').setInputFiles({
|
||||||
|
name: 'My Route 1.gpx',
|
||||||
|
mimeType: 'application/gpx+xml',
|
||||||
|
buffer: GPX_FIXTURE_CONTENT,
|
||||||
|
});
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
|
||||||
|
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
|
||||||
|
// The client-side slugify turns "My Route 1.gpx" → "my-route-1.gpx"
|
||||||
|
await expect(italySection.locator('.gpx-table td', { hasText: 'my-route-1.gpx' })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
uploaded.push('my-route-1.gpx');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM6: Submit without file shows error message ──────────────────────────────
|
||||||
|
test('GM6: submitting upload form without a file shows "Choose a file first."', async ({ page }) => {
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
await expect(form.locator('.gpx-status')).toHaveText('Choose a file first.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GM7: Delete uploaded file removes it from list ───────────────────────────
|
||||||
|
test('GM7: deleting an uploaded file removes its row from the file list', async ({ page }) => {
|
||||||
|
// Upload a file first so we have something to delete
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
const italySection = page.locator('.gpx-trip[data-route="/trips/italy-2026-demo"]');
|
||||||
|
await expect(italySection).toBeVisible({ timeout: 8000 });
|
||||||
|
await expect(italySection.locator('.gpx-loading')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
|
||||||
|
const form = italySection.locator('.gpx-upload-form');
|
||||||
|
await form.locator('input[type=file]').setInputFiles({
|
||||||
|
name: 'to-delete.gpx',
|
||||||
|
mimeType: 'application/gpx+xml',
|
||||||
|
buffer: GPX_FIXTURE_CONTENT,
|
||||||
|
});
|
||||||
|
await form.locator('.gpx-upload-btn').click();
|
||||||
|
await expect(form.locator('.gpx-status')).toContainText('Uploaded!', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Click delete for the uploaded file
|
||||||
|
page.once('dialog', dialog => dialog.accept());
|
||||||
|
await italySection.locator('.gpx-delete[data-filename="to-delete.gpx"]').click();
|
||||||
|
|
||||||
|
// Row must disappear
|
||||||
|
await expect(italySection.locator('.gpx-table td', { hasText: 'to-delete.gpx' }))
|
||||||
|
.toHaveCount(0, { timeout: 10000 });
|
||||||
|
// No cleanup needed — the test deleted it itself
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run GM tests in isolation to verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/gpx/gpx-manager.spec.js --reporter=line 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: GM1, GM3, GM6 pass (read-only). GM2 passes (auth check). GM4, GM5, GM7 pass if demo data is loaded and API is up.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full suite to check no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --reporter=line 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ui/gpx/gpx-manager.spec.js
|
||||||
|
git commit -m "test: add GPX Manager end-to-end spec (GM1-GM7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add post form tests P6–P8
|
||||||
|
|
||||||
|
Append three tests to `tests/ui/post/post.spec.js`. These test the success message, date pre-fill, and form reset after a successful submit.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append P6–P8 to tests/ui/post/post.spec.js**
|
||||||
|
|
||||||
|
Add after the existing P5 test block (before the final closing `}`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── P6: Success message is visible after submit ───────────────────────────────
|
||||||
|
test('P6: successful submit shows "Entry posted successfully!" message', async ({ page }) => {
|
||||||
|
const tag = `p6-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await page.fill('textarea[name="data[content]"]', 'P6 test. Safe to delete.');
|
||||||
|
await page.locator('.btn-post').evaluate(el => el.click());
|
||||||
|
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||||
|
'Entry posted successfully!', { timeout: 15_000 }
|
||||||
|
);
|
||||||
|
created.push(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P7: Date field is pre-filled with a recent timestamp ─────────────────────
|
||||||
|
test('P7: date field is pre-filled within 5 minutes of now on page load', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
const rawValue = await page.locator('input[name="data[date]"]').inputValue();
|
||||||
|
// Blueprint format: Y-m-d H:i → "2026-06-21 14:30"
|
||||||
|
expect(rawValue, 'date field must not be empty').toBeTruthy();
|
||||||
|
const parsed = new Date(rawValue.replace(' ', 'T'));
|
||||||
|
expect(isNaN(parsed.getTime()), 'date field must parse as a valid date').toBe(false);
|
||||||
|
const diffMs = Math.abs(Date.now() - parsed.getTime());
|
||||||
|
expect(diffMs, 'date must be within 5 minutes of now').toBeLessThan(5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── P8: Form fields are cleared after successful submit (reset: true) ─────────
|
||||||
|
test('P8: title and content fields are empty after a successful submit', async ({ page }) => {
|
||||||
|
const tag = `p8-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await page.fill('textarea[name="data[content]"]', 'P8 reset test. Safe to delete.');
|
||||||
|
await page.locator('.btn-post').evaluate(el => el.click());
|
||||||
|
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||||
|
'Entry posted successfully!', { timeout: 15_000 }
|
||||||
|
);
|
||||||
|
// After reset, the form fields should be empty
|
||||||
|
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
|
||||||
|
await expect(page.locator('textarea[name="data[content]"]')).toHaveValue('');
|
||||||
|
created.push(tag);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run post tests in isolation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/post/post.spec.js --reporter=line 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: P1, P3, P4, P5, P6, P8 pass. P2 skipped. P7 passes (date pre-fill from blueprint `default: now`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ui/post/post.spec.js
|
||||||
|
git commit -m "test: add P6-P8 — success message, date pre-fill, form reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Extend accessibility scans (AX6–AX7)
|
||||||
|
|
||||||
|
Add two more `axeScan()` calls to the bottom of `tests/ui/a11y/accessibility.spec.js`. AX6 mocks the API so the GPX Manager file list renders without a real upload dependency.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append AX6 and AX7 to accessibility.spec.js**
|
||||||
|
|
||||||
|
AX6 needs a mocked API route so the `.gpx-loading` placeholder resolves. Add a dedicated test (not via `axeScan()` helper) to support the mock:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ── AX6: /gpx-manager passes axe (mocked file list) ──────────────────────────
|
||||||
|
test('AX6: /gpx-manager passes axe WCAG 2.1 AA (critical/serious)', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/pages**/media', async route => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [{ filename: 'day1.gpx', size: 51200, modified: '2026-06-01T10:00:00Z' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto('/gpx-manager');
|
||||||
|
// Wait for file list to render before scanning
|
||||||
|
await expect(page.locator('.gpx-table')).toBeVisible({ timeout: 10000 });
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
|
||||||
|
const violations = results.violations.filter(v => BLOCKING.includes(v.impact));
|
||||||
|
expect(
|
||||||
|
violations,
|
||||||
|
violations.map(v =>
|
||||||
|
`[${v.impact}] ${v.id}: ${v.description}\n ` +
|
||||||
|
v.nodes.map(n => n.html).join('\n ')
|
||||||
|
).join('\n\n')
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Then append the standard call for AX7:
|
||||||
|
|
||||||
|
```js
|
||||||
|
axeScan('AX7', '/trips/italy-2026-demo/stories/val-dorcia-at-dawn');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run accessibility tests in isolation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/a11y/accessibility.spec.js --reporter=line 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all AX scans pass or fail only on pre-existing violations (document any new ones as known issues in the test failure message).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full suite — final check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --reporter=line 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Document the final pass/fail count in the commit message.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ui/a11y/accessibility.spec.js
|
||||||
|
git commit -m "test: add AX6 (gpx-manager, mocked) and AX7 (story page) axe scans"
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,792 @@
|
|||||||
|
# Feed-Map Alignment, Stories Map & E2E Tests
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-22)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Extract the shared feed mini-map into a reusable Twig partial, add the same map to the stories listing page, fix dailies attribution/marker-click bugs, document session learnings, and add regression tests for all new UX features.
|
||||||
|
|
||||||
|
**Architecture:** A single `partials/feed-map.html.twig` partial handles all mini-map surfaces (dailies + stories), accepting parameterised `map_id`, `map_var`, `card_prefix`, and `show_journey` variables. The trip page (`trip.html.twig`) is a different layout and stays separate. Tests live in the existing `tests/ui/maps/` folder; new map-UX tests (panels, sort, fullscreen) go into `tests/ui/maps/map-ux.spec.js`.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav CMS 2.0 Twig templates, MapLibre GL JS v4, Playwright E2E tests (Node.js/Chromium).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Never read or expose `.env`** — contains sensitive credentials; pass it to `make` commands only
|
||||||
|
- **Dev server URL:** `http://localhost:8081`
|
||||||
|
- **Playwright runs via:** `npx playwright test --project=chromium` (auth session already cached in `tests/.auth/user.json`)
|
||||||
|
- **All Twig template changes** go to `user/themes/intotheeast/templates/` — commit with `git -C user/ commit`
|
||||||
|
- **All CSS changes** go to `user/themes/intotheeast/css/style.css` — commit with `git -C user/ commit`
|
||||||
|
- **Test changes** go to `tests/ui/` in the main project repo — commit with `git commit` (not `git -C user/`)
|
||||||
|
- **Docs/CLAUDE.md changes** go in the main project repo — commit with `git commit`
|
||||||
|
- **Demo trip slug:** `italy-2026-demo` — all E2E tests use this trip
|
||||||
|
- **MapLibre global vars:** `window.feedMap` (dailies), `window.storiesMap` (stories), `window.tripMap` (trip page)
|
||||||
|
- **Marker click behaviour:** scroll to `#<card_prefix><slug>` + flash `.is-highlighted`. Fall back to `window.location.href = entry.url` only if the card element is not found
|
||||||
|
- **Attribution fix:** after map `load`, call `map.getContainer().querySelector('.maplibregl-ctrl-attrib')?.removeAttribute('open')`
|
||||||
|
- **Twig include:** use `{% include '...' with {...} only %}` — Grav global functions (`url()`) still work under `only`
|
||||||
|
- **Worktree root:** `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.claude/worktrees/align-maps-tests/`
|
||||||
|
- **user/ repo path:** `/home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.claude/worktrees/align-maps-tests/user/` — this is a git submodule; commit there with `git -C user/ ...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create `partials/feed-map.html.twig` shared partial
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/themes/intotheeast/templates/partials/feed-map.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: A Twig partial renderable via `{% include 'partials/feed-map.html.twig' with {...} only %}`.
|
||||||
|
- The `window.<map_var>` MapLibre instance is accessible to Playwright tests as e.g. `window.feedMap`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the partial file**
|
||||||
|
|
||||||
|
Create `user/themes/intotheeast/templates/partials/feed-map.html.twig`:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{#
|
||||||
|
Feed mini-map partial — shared by dailies.html.twig and stories.html.twig.
|
||||||
|
|
||||||
|
Required variables (via {% include ... with {...} only %}):
|
||||||
|
map_entries — array: [{lat, lng, title, slug, url, type, force_connect, transport_mode}]
|
||||||
|
map_id — string: HTML id for the map div (e.g. 'feed-map', 'stories-map')
|
||||||
|
map_var — string: JS variable name for the MapLibre Map (e.g. 'feedMap', 'storiesMap')
|
||||||
|
link_href — string|null: URL for "View full map" link; null/empty hides the link
|
||||||
|
card_prefix — string: prefix for scroll-to card IDs ('entry-' or 'story-')
|
||||||
|
trip_page — Grav page: trip page for autoconnect setting (used when show_journey is true)
|
||||||
|
show_journey — bool: whether to draw the route connector line between markers
|
||||||
|
#}
|
||||||
|
{% if map_entries|length > 0 %}
|
||||||
|
<div class="feed-map-wrap">
|
||||||
|
<div class="feed-map" id="{{ map_id }}">
|
||||||
|
<button class="feed-map-fullscreen-btn" id="{{ map_id }}-fullscreen" aria-label="Expand map">
|
||||||
|
<svg class="feed-map-fs-open" aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||||
|
<path d="M0 0v4h1.5V1.5H4V0z M14 0H10v1.5h2.5V4H14z M0 14v-4h1.5v2.5H4V14z M14 14H10v-1.5h2.5V10H14z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="feed-map-fs-close" aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% if link_href %}
|
||||||
|
<a class="feed-map-link" href="{{ link_href }}">View full map →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
{% set js_suffix = map_id|replace({'-': '_'})|upper %}
|
||||||
|
var MAP_ENTRIES_{{ js_suffix }} = {{ map_entries|json_encode|raw }};
|
||||||
|
{% if show_journey %}
|
||||||
|
{% set _ac = trip_page ? (trip_page.header.autoconnect ?? 'on') : 'on' %}
|
||||||
|
var AUTOCONNECT_{{ js_suffix }} = "{{ _ac == 'intelligent_gpx' ? 'on' : _ac }}";
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
var {{ map_var }} = new maplibregl.Map({
|
||||||
|
container: '{{ map_id }}',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2,
|
||||||
|
attributionControl: false
|
||||||
|
});
|
||||||
|
{{ map_var }}.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||||||
|
|
||||||
|
{{ map_var }}.on('load', function () {
|
||||||
|
var attrib = {{ map_var }}.getContainer().querySelector('.maplibregl-ctrl-attrib');
|
||||||
|
if (attrib) attrib.removeAttribute('open');
|
||||||
|
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
var entries = MAP_ENTRIES_{{ js_suffix }};
|
||||||
|
|
||||||
|
entries.forEach(function (entry, i) {
|
||||||
|
var isLatest = (entry.type !== 'story') && (i === entries.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = entry.type === 'story' ? MapUtils.createStoryMarker() : MapUtils.createDotMarker(isLatest);
|
||||||
|
el.dataset.url = entry.url;
|
||||||
|
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||||
|
el.addEventListener('mouseenter', function () { popup.addTo({{ map_var }}); });
|
||||||
|
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||||
|
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('{{ card_prefix }}' + entry.slug);
|
||||||
|
var mapWrap = document.querySelector('.feed-map-wrap');
|
||||||
|
var isFs = mapWrap && mapWrap.classList.contains('is-fullscreen');
|
||||||
|
function scrollAndHighlight() {
|
||||||
|
if (!card) { window.location.href = entry.url; return; }
|
||||||
|
window.location.hash = '{{ card_prefix }}' + entry.slug;
|
||||||
|
setTimeout(function () {
|
||||||
|
card.classList.add('is-highlighted');
|
||||||
|
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
if (isFs) {
|
||||||
|
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
|
||||||
|
if (fsBtn) fsBtn.click();
|
||||||
|
setTimeout(scrollAndHighlight, 450);
|
||||||
|
} else {
|
||||||
|
scrollAndHighlight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo({{ map_var }});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 1) {
|
||||||
|
{{ map_var }}.jumpTo({ center: [parseFloat(entries[0].lng), parseFloat(entries[0].lat)], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
{{ map_var }}.fitBounds(bounds, { padding: 60, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if show_journey %}
|
||||||
|
var segments = MapUtils.buildJourneySegments(entries, { connectMode: AUTOCONNECT_{{ js_suffix }} });
|
||||||
|
MapUtils.addJourneySegments({{ map_var }}, segments, '{{ map_id }}-journey');
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
|
||||||
|
var mapWrap = document.querySelector('.feed-map-wrap');
|
||||||
|
if (!fsBtn || !mapWrap) return;
|
||||||
|
fsBtn.addEventListener('click', function() {
|
||||||
|
var isFs = mapWrap.classList.toggle('is-fullscreen');
|
||||||
|
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
|
||||||
|
document.body.style.overflow = isFs ? 'hidden' : '';
|
||||||
|
setTimeout(function() { typeof {{ map_var }} !== 'undefined' && {{ map_var }}.resize(); }, 50);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the file exists**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la user/themes/intotheeast/templates/partials/feed-map.html.twig
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: file exists, size > 2000 bytes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit to user repo**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user/ add themes/intotheeast/templates/partials/feed-map.html.twig
|
||||||
|
git -C user/ commit -m "feat: add shared feed-map partial (dailies + stories)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Refactor dailies to use the shared partial
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
|
||||||
|
|
||||||
|
The current inline map block (lines 38–110: from `{% if map_entries|length > 0 %}` through the fullscreen `</script>`) is replaced with a single `{% include %}`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `partials/feed-map.html.twig` (Task 1).
|
||||||
|
- The `window.feedMap` global is still produced (now by the partial).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify M3 passes as baseline**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps/maps.spec.js --project=chromium --grep="M3" 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `1 passed`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the inline map block in dailies.html.twig**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/dailies.html.twig`, find the entire block:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if map_entries|length > 0 %}
|
||||||
|
<div class="feed-map-wrap">
|
||||||
|
```
|
||||||
|
|
||||||
|
…through the end of the second `</script>` tag (the fullscreen toggle script). Delete those ~73 lines and replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% include 'partials/feed-map.html.twig' with {
|
||||||
|
'map_entries': map_entries,
|
||||||
|
'map_id': 'feed-map',
|
||||||
|
'map_var': 'feedMap',
|
||||||
|
'link_href': page.parent().url ~ '/map',
|
||||||
|
'card_prefix': 'entry-',
|
||||||
|
'trip_page': trip_page,
|
||||||
|
'show_journey': true
|
||||||
|
} only %}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `map_entries` and `trip_page` variables are already set above this line in dailies.html.twig (lines 21–36), so they're available.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm the page renders**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8081/trips/italy-2026-demo/dailies | grep -c "maplibregl"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: count ≥ 2 (CSS link + JS script).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run M3**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps/maps.spec.js --project=chromium --grep="M3" 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `1 passed`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user/ add themes/intotheeast/templates/dailies.html.twig
|
||||||
|
git -C user/ commit -m "refactor(dailies): use shared feed-map partial"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add map + story card IDs to the stories listing page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/stories.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
All 4 demo stories already have `lat`/`lng` in their frontmatter (42–43° N, 11° E), so no content changes needed.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `partials/feed-map.html.twig` (Task 1).
|
||||||
|
- Produces: `window.storiesMap` global; story cards with `id="story-<slug>"`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite stories.html.twig**
|
||||||
|
|
||||||
|
Full replacement for `user/themes/intotheeast/templates/stories.html.twig`:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set stories = page.children.published().order('date', 'asc') %}
|
||||||
|
|
||||||
|
{# Collect stories that have coordinates for the mini-map #}
|
||||||
|
{% set map_entries = [] %}
|
||||||
|
{% for story in stories %}
|
||||||
|
{% if story.header.lat is not empty and story.header.lng is not empty %}
|
||||||
|
{% set map_entries = map_entries|merge([{
|
||||||
|
'lat': story.header.lat,
|
||||||
|
'lng': story.header.lng,
|
||||||
|
'title': story.title,
|
||||||
|
'slug': story.slug,
|
||||||
|
'url': story.url,
|
||||||
|
'type': 'story',
|
||||||
|
'force_connect': false,
|
||||||
|
'transport_mode': null
|
||||||
|
}]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set trip_page = page.parent() %}
|
||||||
|
|
||||||
|
{% include 'partials/feed-map.html.twig' with {
|
||||||
|
'map_entries': map_entries,
|
||||||
|
'map_id': 'stories-map',
|
||||||
|
'map_var': 'storiesMap',
|
||||||
|
'link_href': null,
|
||||||
|
'card_prefix': 'story-',
|
||||||
|
'trip_page': trip_page,
|
||||||
|
'show_journey': false
|
||||||
|
} only %}
|
||||||
|
|
||||||
|
<div class="stories-listing">
|
||||||
|
<div class="stories-listing__header">
|
||||||
|
<h1 class="stories-listing__heading">Stories</h1>
|
||||||
|
<button class="trip-stats-btn" id="feed-sort-toggle" aria-label="Sort: oldest first">↑ Oldest first</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if stories|length > 0 %}
|
||||||
|
<div class="stories-grid">
|
||||||
|
{% for story in stories %}
|
||||||
|
{% set hero = null %}
|
||||||
|
{% if story.header.hero_image and story.media[story.header.hero_image] is defined %}
|
||||||
|
{% set hero = story.media[story.header.hero_image] %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set date_str = story.date|date('d M Y') %}
|
||||||
|
{% if story.header.end_date %}
|
||||||
|
{% set date_str = story.date|date('d M') ~ '–' ~ story.header.end_date|date('d M Y') %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="story-card" id="story-{{ story.slug }}" href="{{ story.url }}">
|
||||||
|
{% if hero %}
|
||||||
|
<div class="story-card__photo">
|
||||||
|
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ story.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="story-card__photo story-card__photo--empty"></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="story-card__body">
|
||||||
|
<time class="story-card__date" datetime="{{ story.date|date('Y-m-d') }}">{{ date_str }}</time>
|
||||||
|
{% if story.header.location_name %}
|
||||||
|
<span class="story-card__location">📍 {{ story.header.location_name }}{% if story.header.location_country %}, {{ story.header.location_country }}{% endif %}</span>
|
||||||
|
{% endif %}
|
||||||
|
<h2 class="story-card__title">{{ story.title }}</h2>
|
||||||
|
<span class="story-card__cta">Read story →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="stories-empty">No stories yet — check back soon.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var sortBtn = document.getElementById('feed-sort-toggle');
|
||||||
|
if (!sortBtn) return;
|
||||||
|
var grid = document.querySelector('.stories-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
var ascending = true;
|
||||||
|
|
||||||
|
sortBtn.addEventListener('click', function() {
|
||||||
|
ascending = !ascending;
|
||||||
|
var cards = Array.from(grid.querySelectorAll('.story-card'));
|
||||||
|
cards.reverse().forEach(function(el) { grid.appendChild(el); });
|
||||||
|
sortBtn.textContent = ascending ? '↑ Oldest first' : '↓ Newest first';
|
||||||
|
sortBtn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
|
||||||
|
sortBtn.classList.toggle('is-active', !ascending);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `.story-card.is-highlighted` to style.css**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/css/style.css`, find:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.journal-post.is-highlighted,
|
||||||
|
.entry-card.is-highlighted {
|
||||||
|
animation: card-highlight 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.journal-post.is-highlighted,
|
||||||
|
.entry-card.is-highlighted,
|
||||||
|
.story-card.is-highlighted {
|
||||||
|
animation: card-highlight 0.7s ease-out forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify stories page renders a map**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8081/trips/italy-2026-demo/stories | grep -c "storiesMap"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: count ≥ 2.
|
||||||
|
|
||||||
|
Also check story card IDs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8081/trips/italy-2026-demo/stories | grep 'id="story-'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4 lines (one per demo story).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user/ add themes/intotheeast/templates/stories.html.twig themes/intotheeast/css/style.css
|
||||||
|
git -C user/ commit -m "feat(stories): add mini-map via shared partial, add story card IDs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Document session learnings and update CLAUDE.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/working/learnings/2026-06-22-mobile-ux-learnings.md`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- No code dependencies. Standalone documentation task.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create learnings document**
|
||||||
|
|
||||||
|
Create `docs/working/learnings/2026-06-22-mobile-ux-learnings.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Mobile UX Session Learnings — 2026-06-22
|
||||||
|
|
||||||
|
Discoveries from the mobile polish session (stat scaling, map fullscreen, panel toggles, shared partials).
|
||||||
|
|
||||||
|
## MapLibre GL JS v4 — Attribution starts expanded despite compact: true
|
||||||
|
|
||||||
|
**Problem:** `new maplibregl.AttributionControl({ compact: true })` renders a `<details>` element. In MapLibre v4, this element has `open` set after `map.on('load')` fires, so the attribution panel starts expanded even though `compact: true` was passed.
|
||||||
|
|
||||||
|
**Fix:** In the `load` handler, explicitly remove the `open` attribute:
|
||||||
|
```js
|
||||||
|
map.on('load', function () {
|
||||||
|
var attrib = map.getContainer().querySelector('.maplibregl-ctrl-attrib');
|
||||||
|
if (attrib) attrib.removeAttribute('open');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also:** To avoid the default attribution control conflicting with a custom button in `bottom-right`, disable it in the constructor and add it manually to `bottom-left`:
|
||||||
|
```js
|
||||||
|
var map = new maplibregl.Map({ ..., attributionControl: false });
|
||||||
|
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Panel Animation — max-height beats grid-template-rows: 0fr
|
||||||
|
|
||||||
|
**Problem:** `grid-template-rows: 0fr → 1fr` transition fails when the direct grid child has `overflow: hidden`. The child creates a Block Formatting Context (BFC) that prevents `0fr` from collapsing to zero height.
|
||||||
|
|
||||||
|
**Fix:** Use `max-height` transition on the outer container:
|
||||||
|
```css
|
||||||
|
.panel {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.4s ease;
|
||||||
|
}
|
||||||
|
.panel.is-open {
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fluid Font Sizing with clamp()
|
||||||
|
|
||||||
|
```css
|
||||||
|
.stat-value {
|
||||||
|
font-size: clamp(2rem, 6vw, var(--text-3xl));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `clamp(min, preferred, max)`: scales linearly between min and max
|
||||||
|
- `6vw` at 333px viewport = 20px = 1.25rem, but floor is 2rem (32px)
|
||||||
|
- Keep labels at `--text-xs` (0.75rem) intentionally — the contrast makes values pop
|
||||||
|
|
||||||
|
## CSS Grid — Spanning the Lone Last Item in a 2-Column Grid
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.my-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.my-grid .item:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `minmax(0, 1fr)` — strictly equal columns (bare `1fr` has a hidden `auto` minimum)
|
||||||
|
- `:last-child:nth-child(odd)` — matches an item that is both last and in an odd position
|
||||||
|
|
||||||
|
## PhotoSwipe v5 — Correct Element for CSS Animations
|
||||||
|
|
||||||
|
**Problem:** `pswp.currSlide.el` is `undefined` in PhotoSwipe v5.
|
||||||
|
|
||||||
|
**Fix:** Use `pswp.currSlide.container` — the DOM wrapper for the current slide:
|
||||||
|
```js
|
||||||
|
var el = pswp.currSlide && pswp.currSlide.container;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('pswp-key-from-right');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mobile Fullscreen Map Pattern
|
||||||
|
|
||||||
|
```css
|
||||||
|
.map-col.is-fullscreen {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
height: 100dvh !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
fsBtn.addEventListener('click', function() {
|
||||||
|
var isFs = mapCol.classList.toggle('is-fullscreen');
|
||||||
|
document.body.style.overflow = isFs ? 'hidden' : '';
|
||||||
|
setTimeout(function() { map.resize(); }, 50);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Marker click while fullscreen:** Exit fullscreen first, then scroll after the transition:
|
||||||
|
```js
|
||||||
|
if (isFullscreen) {
|
||||||
|
fsBtn.click();
|
||||||
|
setTimeout(scrollAndHighlight, 450);
|
||||||
|
} else {
|
||||||
|
scrollAndHighlight();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared Twig Partial Pattern
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% include 'partials/feed-map.html.twig' with {
|
||||||
|
'map_entries': map_entries,
|
||||||
|
'map_id': 'feed-map',
|
||||||
|
'map_var': 'feedMap',
|
||||||
|
'link_href': page.parent().url ~ '/map',
|
||||||
|
'card_prefix': 'entry-',
|
||||||
|
'trip_page': trip_page,
|
||||||
|
'show_journey': true
|
||||||
|
} only %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Grav's global Twig functions (`url()`, `theme_var()`) remain available with `only`. Only parent template variables are excluded.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add shared partial section to CLAUDE.md**
|
||||||
|
|
||||||
|
In `CLAUDE.md`, find the exact text:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### GPX file management
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert the following block immediately before that line:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Shared feed-map partial
|
||||||
|
|
||||||
|
The mini-map above the feed is shared across two pages via a Twig partial:
|
||||||
|
|
||||||
|
- **Partial:** `user/themes/intotheeast/templates/partials/feed-map.html.twig`
|
||||||
|
- **Used by:** `dailies.html.twig` and `stories.html.twig`
|
||||||
|
- **NOT used by:** `trip.html.twig` (uses its own `#trip-map` / `.home-map-col` layout)
|
||||||
|
|
||||||
|
**Parameters (passed via `{% include ... with {...} only %}`):**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `map_entries` | array | `[{lat, lng, title, slug, url, type, force_connect, transport_mode}]` |
|
||||||
|
| `map_id` | string | HTML id for map div: `'feed-map'` or `'stories-map'` |
|
||||||
|
| `map_var` | string | JS global variable: `'feedMap'` or `'storiesMap'` |
|
||||||
|
| `link_href` | string\|null | "View full map" link URL; `null` hides it |
|
||||||
|
| `card_prefix` | string | Scroll-to ID prefix: `'entry-'` (dailies) or `'story-'` (stories) |
|
||||||
|
| `trip_page` | Page | Trip page object for autoconnect setting |
|
||||||
|
| `show_journey` | bool | `true` draws the route connector; `false` skips it |
|
||||||
|
|
||||||
|
The partial always: starts attribution collapsed, shows the fullscreen button (mobile-only, CSS `display:none` ≥769px), and on marker click scrolls to `#<card_prefix><slug>` + flashes `.is-highlighted`.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p docs/working/learnings
|
||||||
|
git add docs/working/learnings/2026-06-22-mobile-ux-learnings.md CLAUDE.md
|
||||||
|
git commit -m "docs: add mobile-ux session learnings and shared partial architecture"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: E2E tests for stories map, attribution, panels, sort, and fullscreen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ui/maps/maps.spec.js` (add M9–M11)
|
||||||
|
- Create: `tests/ui/maps/map-ux.spec.js` (MUX1–MUX5)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: live dev server at `http://localhost:8081` with demo content loaded.
|
||||||
|
- Produces: 8 new passing tests.
|
||||||
|
|
||||||
|
> **Important:** Tasks 1–3 must be complete before running these tests (they test the newly built behaviour).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append M9–M11 to the end of `tests/ui/maps/maps.spec.js`**
|
||||||
|
|
||||||
|
Add after the last line of the existing file:
|
||||||
|
|
||||||
|
```js
|
||||||
|
|
||||||
|
// ── M9: Stories mini-map renders MapLibre canvas ──────────────────────────────
|
||||||
|
test('M9: Stories mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
|
await page.goto('/trips/italy-2026-demo/stories');
|
||||||
|
await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
expect(errors, 'No JS errors on stories page').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── M10: Stories mini-map has at least one story marker ──────────────────────
|
||||||
|
test('M10: Stories mini-map has at least one story marker', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/stories');
|
||||||
|
await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('#stories-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const markerCount = await page.locator('#stories-map .maplibregl-marker').count();
|
||||||
|
expect(markerCount, 'At least one story marker').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── M11: Dailies attribution control starts collapsed ─────────────────────────
|
||||||
|
test('M11: Dailies mini-map attribution starts collapsed (no open attribute)', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('#feed-map .maplibregl-ctrl-attrib')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const hasOpen = await page.evaluate(function () {
|
||||||
|
var attrib = document.querySelector('#feed-map .maplibregl-ctrl-attrib');
|
||||||
|
return attrib ? attrib.hasAttribute('open') : null;
|
||||||
|
});
|
||||||
|
expect(hasOpen, 'Attribution is collapsed (no open attribute)').toBe(false);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `tests/ui/maps/map-ux.spec.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @ts-check
|
||||||
|
// Tests: MUX1–MUX5 — Map UX features: panel toggles, sort toggle, fullscreen button
|
||||||
|
// Requires demo data: `make demo-load` before running.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// ── MUX1: Trip stats panel toggles open and closed ──────────────────────────
|
||||||
|
test('MUX1: trip stats panel opens and closes on button click', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo');
|
||||||
|
|
||||||
|
const statsBtn = page.locator('#trip-stats-toggle');
|
||||||
|
const statsBlock = page.locator('#trip-stats-block');
|
||||||
|
|
||||||
|
await expect(statsBtn).toBeVisible();
|
||||||
|
await expect(statsBlock).not.toHaveClass(/is-open/);
|
||||||
|
|
||||||
|
await statsBtn.click();
|
||||||
|
await expect(statsBlock).toHaveClass(/is-open/);
|
||||||
|
await expect(page.locator('.trip-stats-grid')).toBeVisible();
|
||||||
|
|
||||||
|
await statsBtn.click();
|
||||||
|
await expect(statsBlock).not.toHaveClass(/is-open/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MUX2: Trip cycling panel toggles open and closed ────────────────────────
|
||||||
|
test('MUX2: trip cycling panel opens and closes on button click', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo');
|
||||||
|
|
||||||
|
const cyclingBtn = page.locator('#trip-cycling-toggle');
|
||||||
|
const cyclingBlock = page.locator('#trip-cycling-block');
|
||||||
|
|
||||||
|
await expect(cyclingBtn).toBeVisible();
|
||||||
|
await expect(cyclingBlock).not.toHaveClass(/is-open/);
|
||||||
|
|
||||||
|
await cyclingBtn.click();
|
||||||
|
await expect(cyclingBlock).toHaveClass(/is-open/);
|
||||||
|
|
||||||
|
await cyclingBtn.click();
|
||||||
|
await expect(cyclingBlock).not.toHaveClass(/is-open/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MUX3: Trip page map has a fullscreen button in the DOM ────────────────────
|
||||||
|
test('MUX3: trip page map has a fullscreen toggle button', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo');
|
||||||
|
await expect(page.locator('#trip-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const fsBtn = page.locator('#trip-map-fullscreen');
|
||||||
|
await expect(fsBtn).toBeAttached();
|
||||||
|
await expect(fsBtn).toHaveAttribute('aria-label', 'Expand map');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MUX4: Dailies sort toggle reverses entry order ───────────────────────────
|
||||||
|
test('MUX4: dailies sort toggle reverses the feed entry order', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/dailies');
|
||||||
|
|
||||||
|
const sortBtn = page.locator('#feed-sort-toggle');
|
||||||
|
await expect(sortBtn).toBeVisible();
|
||||||
|
|
||||||
|
const firstBefore = await page.locator('[data-type]').first().getAttribute('id');
|
||||||
|
|
||||||
|
await sortBtn.click();
|
||||||
|
|
||||||
|
const firstAfter = await page.locator('[data-type]').first().getAttribute('id');
|
||||||
|
expect(firstAfter, 'Entry order reversed after sort').not.toBe(firstBefore);
|
||||||
|
|
||||||
|
await sortBtn.click();
|
||||||
|
const firstRestored = await page.locator('[data-type]').first().getAttribute('id');
|
||||||
|
expect(firstRestored, 'Entry order restored after second toggle').toBe(firstBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MUX5: Stories sort toggle reverses story card order ─────────────────────
|
||||||
|
test('MUX5: stories sort toggle reverses the story card order', async ({ page }) => {
|
||||||
|
await page.goto('/trips/italy-2026-demo/stories');
|
||||||
|
|
||||||
|
const sortBtn = page.locator('#feed-sort-toggle');
|
||||||
|
await expect(sortBtn).toBeVisible();
|
||||||
|
|
||||||
|
const firstBefore = await page.locator('.story-card').first().getAttribute('id');
|
||||||
|
|
||||||
|
await sortBtn.click();
|
||||||
|
|
||||||
|
const firstAfter = await page.locator('.story-card').first().getAttribute('id');
|
||||||
|
expect(firstAfter, 'Story order reversed after sort').not.toBe(firstBefore);
|
||||||
|
|
||||||
|
await sortBtn.click();
|
||||||
|
const firstRestored = await page.locator('.story-card').first().getAttribute('id');
|
||||||
|
expect(firstRestored, 'Story order restored after second toggle').toBe(firstBefore);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the new maps tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps/ --project=chromium 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: M1–M7, M9–M11, MUX1–MUX5 pass. (M8 is a pre-existing failure — active trip GPX config.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full suite — verify no regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium 2>&1 | grep -E "^[[:space:]]*(passed|failed|skipped)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass count ≥ 76 (baseline), failed count ≤ 4 (pre-existing).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ui/maps/maps.spec.js tests/ui/maps/map-ux.spec.js
|
||||||
|
git commit -m "test: add M9-M11 stories map + MUX1-5 panel/sort/fullscreen regression tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Merge worktree branch to main and push user/ content
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Main repo: merge `worktree-align-maps-tests` → `main`
|
||||||
|
- user/ repo: push `main` to origin
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify all 5 tasks are committed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -10
|
||||||
|
git -C user/ log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: docs commit, tests commit in main repo; at least 3 commits in user/ (partial, dailies refactor, stories+CSS).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Exit worktree and merge to main**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||||
|
git merge worktree-align-maps-tests --no-ff -m "feat: align maps, add stories map, add regression tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push user/ content to origin (triggers production pull)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm tests still pass on main**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium 2>&1 | grep -E "passed|failed"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass count ≥ baseline.
|
||||||
@@ -0,0 +1,969 @@
|
|||||||
|
# Asset Pipeline & Frontend Reliability Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-25)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Eliminate all CDN dependencies, self-host fonts, and deduplicate shared JS logic into versioned bundles built via Docker.
|
||||||
|
|
||||||
|
**Architecture:** esbuild (via throwaway Docker Node container) produces two IIFE bundles — `js/main.js` (universal UI + fonts) and `js/map.js` (MapLibre + GPX utils) — plus extracted CSS in `css-compiled/`. Templates register assets via Grav's Asset Manager instead of hardcoded CDN tags. All duplicated inline JS moves to the main bundle; map init code stays in templates.
|
||||||
|
|
||||||
|
**Tech Stack:** esbuild 0.21+, Node 20 Alpine (Docker only), MapLibre GL 4, PhotoSwipe 5, Scrollama 3, @mapbox/togeojson 0.16, @fontsource-variable/dm-sans, @fontsource/dm-serif-display, Grav 2.0 Asset Manager.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All commands run inside Docker — no local Node.js required
|
||||||
|
- Output files (`js/main.js`, `js/map.js`, `css-compiled/*.css`, `fonts/*.woff2`) are committed to the user/ repo
|
||||||
|
- `node_modules/` is gitignored
|
||||||
|
- Working directory for all file edits: `user/themes/intotheeast/`
|
||||||
|
- All JS bundles use `--format=iife` so templates can reference `maplibregl`, `MapUtils`, `toGeoJSON` as window globals
|
||||||
|
- Grav Asset Manager is used for all asset registration — no hardcoded `<script>`/`<link>` tags in templates
|
||||||
|
- Map init code stays inline in templates (trip.html.twig, feed-map.html.twig, map.html.twig) — only CDN tags and duplicated utility JS move out
|
||||||
|
- Dev server: `http://localhost:8081`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | Path |
|
||||||
|
|---|---|
|
||||||
|
| Create | `user/themes/intotheeast/package.json` |
|
||||||
|
| Create | `user/themes/intotheeast/js/src/main.js` |
|
||||||
|
| Create | `user/themes/intotheeast/js/src/map.js` |
|
||||||
|
| Create | `user/themes/intotheeast/css-compiled/` (by esbuild) |
|
||||||
|
| Create | `user/themes/intotheeast/fonts/` (by esbuild) |
|
||||||
|
| Modify | `Makefile` — add `build-assets` target |
|
||||||
|
| Modify | `user/.gitignore` — add `themes/intotheeast/node_modules/` |
|
||||||
|
| Modify | `user/themes/intotheeast/js/maplibre-utils.js` — add `parseGpxFiles`, export `haversineKm` |
|
||||||
|
| Modify | `user/themes/intotheeast/css/style.css` — remove Google Fonts `@import` |
|
||||||
|
| Modify | `user/themes/intotheeast/templates/partials/base.html.twig` |
|
||||||
|
| Modify | `user/themes/intotheeast/templates/trip.html.twig` |
|
||||||
|
| Modify | `user/themes/intotheeast/templates/partials/feed-map.html.twig` |
|
||||||
|
| Modify | `user/themes/intotheeast/templates/map.html.twig` |
|
||||||
|
| Modify | `user/themes/intotheeast/templates/story.html.twig` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Build scaffolding
|
||||||
|
|
||||||
|
Set up `package.json`, the Docker `make build-assets` target, and gitignore. Verify the Docker build completes.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/themes/intotheeast/package.json`
|
||||||
|
- Modify: `Makefile` (add `build-assets` target after existing `build` target)
|
||||||
|
- Modify: `user/.gitignore` (add node_modules line)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `make build-assets` command that runs `npm ci && npm run build` in Docker Node 20 Alpine
|
||||||
|
|
||||||
|
- [x] **Step 1: Create package.json**
|
||||||
|
|
||||||
|
Create `user/themes/intotheeast/package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && mkdir -p css-compiled fonts && mv js/main.css css-compiled/main.css && mv js/map.css css-compiled/map.css"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/dm-sans": "latest",
|
||||||
|
"@fontsource/dm-serif-display": "latest",
|
||||||
|
"@mapbox/togeojson": "^0.16.2",
|
||||||
|
"maplibre-gl": "^4",
|
||||||
|
"photoswipe": "^5",
|
||||||
|
"scrollama": "^3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Add `build-assets` target to Makefile**
|
||||||
|
|
||||||
|
Add after the existing `build:` target in `Makefile`:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
build-assets:
|
||||||
|
docker run --rm \
|
||||||
|
-v $(PWD)/user/themes/intotheeast:/app \
|
||||||
|
-w /app node:20-alpine \
|
||||||
|
sh -c "npm install && npm run build"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Add node_modules to user/ gitignore**
|
||||||
|
|
||||||
|
Add to `user/.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/themes/intotheeast/node_modules/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Create placeholder source files so the build has something to process**
|
||||||
|
|
||||||
|
Create `user/themes/intotheeast/js/src/main.js`:
|
||||||
|
```javascript
|
||||||
|
// placeholder — replaced in Task 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `user/themes/intotheeast/js/src/map.js`:
|
||||||
|
```javascript
|
||||||
|
// placeholder — replaced in Task 4
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Run the build and verify it completes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Docker pulls `node:20-alpine`, runs `npm install` (generates `package-lock.json`), runs `npm run build`. Build will warn about empty entry points but should exit 0. Verify these files exist:
|
||||||
|
```bash
|
||||||
|
ls user/themes/intotheeast/js/main.js
|
||||||
|
ls user/themes/intotheeast/js/map.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/package.json themes/intotheeast/package-lock.json themes/intotheeast/js/src/main.js themes/intotheeast/js/src/map.js .gitignore
|
||||||
|
git -C user commit -m "build: add esbuild scaffolding and Docker build-assets target"
|
||||||
|
git add Makefile
|
||||||
|
git commit -m "build: add build-assets make target"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Main JS bundle — fonts, PhotoSwipe, all UI utilities
|
||||||
|
|
||||||
|
Write the full `js/src/main.js`. This is the single source of truth for all duplicated UI behaviour.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/js/src/main.js` (replace placeholder)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing from other tasks
|
||||||
|
- Produces:
|
||||||
|
- `js/main.js` — IIFE bundle, no exports (all inits called on DOMContentLoaded)
|
||||||
|
- `css-compiled/main.css` — PhotoSwipe CSS + @font-face rules for DM Sans variable + DM Serif Display
|
||||||
|
- `fonts/*.woff2` — copied from @fontsource packages by esbuild
|
||||||
|
|
||||||
|
- [x] **Step 1: Write js/src/main.js**
|
||||||
|
|
||||||
|
Replace `user/themes/intotheeast/js/src/main.js` with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/* ── Fonts ───────────────────────────────────────────────── */
|
||||||
|
import '@fontsource-variable/dm-sans';
|
||||||
|
import '@fontsource/dm-serif-display/400.css';
|
||||||
|
import '@fontsource/dm-serif-display/400-italic.css';
|
||||||
|
|
||||||
|
/* ── PhotoSwipe ──────────────────────────────────────────── */
|
||||||
|
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||||
|
import PhotoSwipe from 'photoswipe';
|
||||||
|
import 'photoswipe/style.css';
|
||||||
|
|
||||||
|
/* ── Scrollama (used by story.html.twig inline script) ────── */
|
||||||
|
import scrollama from 'scrollama';
|
||||||
|
window.scrollama = scrollama;
|
||||||
|
|
||||||
|
/* ── Photo strip: prev/next buttons + scroll-based dot sync ─ */
|
||||||
|
function initPhotoStrip() {
|
||||||
|
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
|
||||||
|
strip.setAttribute('role', 'region');
|
||||||
|
strip.setAttribute('aria-label', 'Photo strip');
|
||||||
|
strip.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
|
var slideCount = parseInt(strip.dataset.slides, 10) || 1;
|
||||||
|
var dots = strip.nextElementSibling;
|
||||||
|
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
|
||||||
|
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
|
||||||
|
|
||||||
|
strip.addEventListener('scroll', function () {
|
||||||
|
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
|
||||||
|
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
if (slideCount < 2) return;
|
||||||
|
|
||||||
|
var prev = document.createElement('button');
|
||||||
|
prev.className = 'strip-prev';
|
||||||
|
prev.setAttribute('aria-label', 'Previous photo');
|
||||||
|
prev.textContent = '‹';
|
||||||
|
prev.addEventListener('click', function () {
|
||||||
|
strip.scrollBy({ left: -strip.offsetWidth, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
var next = document.createElement('button');
|
||||||
|
next.className = 'strip-next';
|
||||||
|
next.setAttribute('aria-label', 'Next photo');
|
||||||
|
next.textContent = '›';
|
||||||
|
next.addEventListener('click', function () {
|
||||||
|
strip.scrollBy({ left: strip.offsetWidth, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
var controls = document.createElement('div');
|
||||||
|
controls.className = 'strip-controls';
|
||||||
|
controls.appendChild(prev);
|
||||||
|
controls.appendChild(next);
|
||||||
|
var wrap = strip.closest('.journal-photo-wrap');
|
||||||
|
(wrap || dots).insertAdjacentElement('afterend', controls);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PhotoSwipe lightbox + IntersectionObserver dot sync ──── */
|
||||||
|
function initPhotoSwipe() {
|
||||||
|
if (!document.querySelector('.pswp-gallery')) return;
|
||||||
|
|
||||||
|
var lightbox = new PhotoSwipeLightbox({
|
||||||
|
gallery: '.pswp-gallery',
|
||||||
|
children: 'a.journal-photo-slide',
|
||||||
|
pswpModule: PhotoSwipe
|
||||||
|
});
|
||||||
|
|
||||||
|
lightbox.on('afterOpen', function () {
|
||||||
|
var pswp = lightbox.pswp;
|
||||||
|
var keyDir = 0;
|
||||||
|
var clearTimer = null;
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'ArrowRight') keyDir = 1;
|
||||||
|
else if (e.key === 'ArrowLeft') keyDir = -1;
|
||||||
|
else keyDir = 0;
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
|
||||||
|
pswp.on('change', function () {
|
||||||
|
if (!keyDir) return;
|
||||||
|
var dir = keyDir;
|
||||||
|
keyDir = 0;
|
||||||
|
var el = pswp.currSlide && pswp.currSlide.container;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
|
||||||
|
el.offsetWidth; /* force reflow */
|
||||||
|
el.classList.add(dir > 0 ? 'pswp-key-from-right' : 'pswp-key-from-left');
|
||||||
|
clearTimeout(clearTimer);
|
||||||
|
clearTimer = setTimeout(function () {
|
||||||
|
el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
pswp.on('close', function () {
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
clearTimeout(clearTimer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
lightbox.init();
|
||||||
|
|
||||||
|
/* Per-strip: IntersectionObserver dot sync + expand button */
|
||||||
|
document.querySelectorAll('.journal-photo-wrap').forEach(function (wrap) {
|
||||||
|
var strip = wrap.querySelector('.journal-photo-strip');
|
||||||
|
if (!strip) return;
|
||||||
|
var slides = Array.from(strip.querySelectorAll('a.journal-photo-slide'));
|
||||||
|
var expandBtn = wrap.querySelector('.journal-photo-expand');
|
||||||
|
var article = wrap.closest('article');
|
||||||
|
var dots = article ? Array.from(article.querySelectorAll('.journal-photo-dot')) : [];
|
||||||
|
var visibleIdx = 0;
|
||||||
|
|
||||||
|
var io = new IntersectionObserver(function (entries) {
|
||||||
|
entries.forEach(function (e) {
|
||||||
|
if (!e.isIntersecting) return;
|
||||||
|
visibleIdx = slides.indexOf(e.target);
|
||||||
|
dots.forEach(function (d) { d.classList.remove('is-active'); });
|
||||||
|
if (dots[visibleIdx]) dots[visibleIdx].classList.add('is-active');
|
||||||
|
});
|
||||||
|
}, { root: strip, threshold: 0.5 });
|
||||||
|
slides.forEach(function (s) { io.observe(s); });
|
||||||
|
|
||||||
|
if (expandBtn && slides.length) {
|
||||||
|
expandBtn.addEventListener('click', function () {
|
||||||
|
slides[visibleIdx].dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sort button ─────────────────────────────────────────────
|
||||||
|
btnId: element id of the sort toggle button
|
||||||
|
containerSel: CSS selector for the list container
|
||||||
|
itemSel: CSS selector for sortable items within container
|
||||||
|
withLabel: true = button shows "↑ Oldest first" / "↓ Newest first"
|
||||||
|
false = button shows "↑" / "↓" only
|
||||||
|
──────────────────────────────────────────────────────────── */
|
||||||
|
function initSortButton(btnId, containerSel, itemSel, withLabel) {
|
||||||
|
var btn = document.getElementById(btnId);
|
||||||
|
if (!btn) return;
|
||||||
|
var container = document.querySelector(containerSel);
|
||||||
|
if (!container) return;
|
||||||
|
var sentinel = container.querySelector('#feed-filter-empty');
|
||||||
|
var ascending = true;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
ascending = !ascending;
|
||||||
|
var items = Array.from(container.querySelectorAll(itemSel));
|
||||||
|
items.reverse().forEach(function (el) {
|
||||||
|
if (sentinel) container.insertBefore(el, sentinel);
|
||||||
|
else container.appendChild(el);
|
||||||
|
});
|
||||||
|
btn.textContent = withLabel
|
||||||
|
? (ascending ? '↑ Oldest first' : '↓ Newest first')
|
||||||
|
: (ascending ? '↑' : '↓');
|
||||||
|
btn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
|
||||||
|
btn.classList.toggle('is-active', !ascending);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter bar (trip page: All / Journal / Stories) ─────── */
|
||||||
|
function initFilterBar() {
|
||||||
|
var filterBtns = document.querySelectorAll('.trip-filter-btn');
|
||||||
|
if (!filterBtns.length) return;
|
||||||
|
var cards = document.querySelectorAll('[data-type]');
|
||||||
|
var filterEmpty = document.getElementById('feed-filter-empty');
|
||||||
|
|
||||||
|
filterBtns.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
filterBtns.forEach(function (b) {
|
||||||
|
b.classList.remove('is-active');
|
||||||
|
b.setAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
btn.classList.add('is-active');
|
||||||
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
var filter = btn.getAttribute('data-filter');
|
||||||
|
var visible = 0;
|
||||||
|
cards.forEach(function (card) {
|
||||||
|
var show = filter === 'all' || card.getAttribute('data-type') === filter;
|
||||||
|
card.style.display = show ? '' : 'none';
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filterEmpty) {
|
||||||
|
if (visible === 0) {
|
||||||
|
filterEmpty.textContent = filter === 'story'
|
||||||
|
? 'No stories yet for this trip.'
|
||||||
|
: 'No entries yet.';
|
||||||
|
filterEmpty.style.display = '';
|
||||||
|
} else {
|
||||||
|
filterEmpty.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Back to top ─────────────────────────────────────────── */
|
||||||
|
function initBackToTop(btnId) {
|
||||||
|
var btn = document.getElementById(btnId);
|
||||||
|
if (!btn) return;
|
||||||
|
var threshold = window.innerHeight * 0.8;
|
||||||
|
var shown = false;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
history.pushState(null, '', window.location.pathname + window.location.search);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
window.addEventListener('scroll', function () {
|
||||||
|
var shouldShow = window.scrollY > threshold;
|
||||||
|
if (shouldShow !== shown) {
|
||||||
|
shown = shouldShow;
|
||||||
|
btn.classList.toggle('is-visible', shown);
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel toggles (trip stats / cycling panels) ─────────── */
|
||||||
|
function initPanelToggles() {
|
||||||
|
document.querySelectorAll('.trip-panel-toggle').forEach(function (toggle) {
|
||||||
|
var blockId = toggle.getAttribute('aria-controls');
|
||||||
|
var block = blockId ? document.getElementById(blockId) : null;
|
||||||
|
if (!block) return;
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
var isOpen = block.classList.contains('is-open');
|
||||||
|
block.classList.toggle('is-open', !isOpen);
|
||||||
|
toggle.classList.toggle('is-active', !isOpen);
|
||||||
|
toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.trip-panel-close').forEach(function (btn) {
|
||||||
|
var toggleBtn = document.getElementById(btn.getAttribute('data-toggle'));
|
||||||
|
if (toggleBtn) btn.addEventListener('click', function () { toggleBtn.click(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Boot ────────────────────────────────────────────────── */
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initPhotoStrip();
|
||||||
|
initPhotoSwipe();
|
||||||
|
initFilterBar();
|
||||||
|
/* Sort buttons — each call is silent if its button/container isn't on this page */
|
||||||
|
initSortButton('trip-sort-toggle', '.feed', '[data-type]', false);
|
||||||
|
initSortButton('feed-sort-toggle', '.feed', '[data-type]', true);
|
||||||
|
initSortButton('feed-sort-toggle', '.stories-grid', '.story-card', true);
|
||||||
|
initBackToTop('story-totop');
|
||||||
|
initBackToTop('trip-totop');
|
||||||
|
initPanelToggles();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exits 0. Verify output:
|
||||||
|
```bash
|
||||||
|
ls user/themes/intotheeast/js/main.js
|
||||||
|
ls user/themes/intotheeast/css-compiled/main.css
|
||||||
|
ls user/themes/intotheeast/fonts/
|
||||||
|
```
|
||||||
|
|
||||||
|
`fonts/` should contain woff2 files from @fontsource packages.
|
||||||
|
|
||||||
|
- [x] **Step 3: Verify font output in CSS**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep '@font-face' user/themes/intotheeast/css-compiled/main.css | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: multiple `@font-face` rules referencing `../fonts/*.woff2` paths.
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/js/src/main.js themes/intotheeast/js/main.js themes/intotheeast/css-compiled/main.css themes/intotheeast/fonts/
|
||||||
|
git -C user commit -m "build: add main.js bundle — fonts, PhotoSwipe, UI utilities"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Extend maplibre-utils.js — parseGpxFiles + export haversineKm
|
||||||
|
|
||||||
|
Move `parseGpxFiles` from `trip.html.twig` into the shared utility and expose `haversineKm` publicly so template code can call both as `MapUtils.*`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/js/maplibre-utils.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `haversineKm` (already defined privately in maplibre-utils.js at line 184)
|
||||||
|
- Produces:
|
||||||
|
- `MapUtils.haversineKm(lat1, lng1, lat2, lng2)` → `number` (km)
|
||||||
|
- `MapUtils.parseGpxFiles(urls, callback)` — `urls: string[]`, `callback({ distance, eleGain, eleLoss, highest, lowest, movingTime, avgSpeed } | { error: string })` → `void`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add parseGpxFiles function to maplibre-utils.js**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/js/maplibre-utils.js`, add the following block immediately before the `global.MapUtils = {` line (currently line 333):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/*
|
||||||
|
* Parse one or more GPX files and compute aggregate cycling statistics.
|
||||||
|
* urls: array of GPX file URL strings
|
||||||
|
* callback: called once with { distance, eleGain, eleLoss, highest, lowest, movingTime, avgSpeed }
|
||||||
|
* or { error: 'no files' } if urls is empty.
|
||||||
|
*
|
||||||
|
* distance/eleGain/eleLoss in raw units (km / metres).
|
||||||
|
* movingTime: "H:MM" string. avgSpeed: km/h number.
|
||||||
|
*/
|
||||||
|
function parseGpxFiles(urls, callback) {
|
||||||
|
var pending = urls.length;
|
||||||
|
var fileResults = new Array(urls.length);
|
||||||
|
if (pending === 0) { callback({ error: 'no files' }); return; }
|
||||||
|
|
||||||
|
urls.forEach(function (url, idx) {
|
||||||
|
fetch(url)
|
||||||
|
.then(function (r) { return r.text(); })
|
||||||
|
.then(function (text) {
|
||||||
|
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||||
|
var pts = [];
|
||||||
|
xml.querySelectorAll('trkpt').forEach(function (pt) {
|
||||||
|
var eleEl = pt.querySelector('ele');
|
||||||
|
var timeEl = pt.querySelector('time');
|
||||||
|
pts.push({
|
||||||
|
lat: parseFloat(pt.getAttribute('lat')),
|
||||||
|
lon: parseFloat(pt.getAttribute('lon')),
|
||||||
|
ele: eleEl ? parseFloat(eleEl.textContent) : NaN,
|
||||||
|
time: timeEl ? timeEl.textContent : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
fileResults[idx] = pts;
|
||||||
|
if (--pending === 0) computeAndCallback();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.warn('GPX load failed:', url, err);
|
||||||
|
fileResults[idx] = [];
|
||||||
|
if (--pending === 0) computeAndCallback();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeAndCallback() {
|
||||||
|
var totalDistance = 0, totalEleGain = 0, totalEleLoss = 0;
|
||||||
|
var globalHighest = NaN, globalLowest = NaN, totalMovingTime = 0;
|
||||||
|
|
||||||
|
fileResults.forEach(function (pts) {
|
||||||
|
if (!pts || pts.length < 2) return;
|
||||||
|
/* Include first point of each file in elevation range */
|
||||||
|
if (!isNaN(pts[0].ele)) {
|
||||||
|
if (isNaN(globalHighest) || pts[0].ele > globalHighest) globalHighest = pts[0].ele;
|
||||||
|
if (isNaN(globalLowest) || pts[0].ele < globalLowest) globalLowest = pts[0].ele;
|
||||||
|
}
|
||||||
|
for (var i = 1; i < pts.length; i++) {
|
||||||
|
var p0 = pts[i - 1], p1 = pts[i];
|
||||||
|
totalDistance += haversineKm(p0.lat, p0.lon, p1.lat, p1.lon);
|
||||||
|
if (!isNaN(p0.ele) && !isNaN(p1.ele)) {
|
||||||
|
var dEle = p1.ele - p0.ele;
|
||||||
|
if (dEle > 0) totalEleGain += dEle;
|
||||||
|
if (dEle < 0) totalEleLoss += (-dEle);
|
||||||
|
if (isNaN(globalHighest) || p1.ele > globalHighest) globalHighest = p1.ele;
|
||||||
|
if (isNaN(globalLowest) || p1.ele < globalLowest) globalLowest = p1.ele;
|
||||||
|
}
|
||||||
|
if (p0.time && p1.time) {
|
||||||
|
var dtHrs = (Date.parse(p1.time) - Date.parse(p0.time)) / 3600000;
|
||||||
|
if (dtHrs > 0 && (haversineKm(p0.lat, p0.lon, p1.lat, p1.lon) / dtHrs) >= 1) {
|
||||||
|
totalMovingTime += dtHrs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var avgSpeed = totalMovingTime > 0 ? totalDistance / totalMovingTime : 0;
|
||||||
|
var movHours = Math.floor(totalMovingTime);
|
||||||
|
var movMins = Math.round((totalMovingTime - movHours) * 60);
|
||||||
|
if (movMins === 60) { movHours++; movMins = 0; }
|
||||||
|
|
||||||
|
callback({
|
||||||
|
distance: totalDistance,
|
||||||
|
eleGain: totalEleGain,
|
||||||
|
eleLoss: totalEleLoss,
|
||||||
|
highest: globalHighest,
|
||||||
|
lowest: globalLowest,
|
||||||
|
movingTime: movHours + ':' + (movMins < 10 ? '0' : '') + movMins,
|
||||||
|
avgSpeed: avgSpeed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Add haversineKm and parseGpxFiles to MapUtils exports**
|
||||||
|
|
||||||
|
Find the `global.MapUtils = {` block (currently the last block in the file) and add both new entries:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
global.MapUtils = {
|
||||||
|
MAP_STYLE: MAP_STYLE,
|
||||||
|
ACCENT: ACCENT,
|
||||||
|
haversineKm: haversineKm,
|
||||||
|
parseGpxFiles: parseGpxFiles,
|
||||||
|
addJourneyLine: addJourneyLine,
|
||||||
|
addJourneySegments: addJourneySegments,
|
||||||
|
buildJourneySegments: buildJourneySegments,
|
||||||
|
renderGpxJourney: renderGpxJourney,
|
||||||
|
createDotMarker: createDotMarker,
|
||||||
|
createStoryMarker: createStoryMarker
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/js/maplibre-utils.js
|
||||||
|
git -C user commit -m "feat: add parseGpxFiles and export haversineKm from MapUtils"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Map JS bundle
|
||||||
|
|
||||||
|
Write `js/src/map.js` to bundle MapLibre GL, toGeoJSON, and maplibre-utils as a single file, attaching them as window globals for existing template inline scripts.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/js/src/map.js` (replace placeholder)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `MapUtils.parseGpxFiles`, `MapUtils.haversineKm` (from Task 3 — already in maplibre-utils.js which this imports)
|
||||||
|
- Produces:
|
||||||
|
- `js/map.js` — IIFE bundle
|
||||||
|
- `css-compiled/map.css` — MapLibre GL CSS
|
||||||
|
- `window.maplibregl` — MapLibre GL instance
|
||||||
|
- `window.toGeoJSON` — toGeoJSON converter
|
||||||
|
- `window.MapUtils` — all MapUtils functions (set by maplibre-utils.js side effect)
|
||||||
|
|
||||||
|
- [x] **Step 1: Write js/src/map.js**
|
||||||
|
|
||||||
|
Replace `user/themes/intotheeast/js/src/map.js` with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import toGeoJSON from '@mapbox/togeojson';
|
||||||
|
|
||||||
|
/* maplibre-utils.js attaches MapUtils to window as a side effect */
|
||||||
|
import '../maplibre-utils.js';
|
||||||
|
|
||||||
|
window.maplibregl = maplibregl;
|
||||||
|
window.toGeoJSON = toGeoJSON;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exits 0. Verify:
|
||||||
|
```bash
|
||||||
|
ls user/themes/intotheeast/js/map.js
|
||||||
|
ls user/themes/intotheeast/css-compiled/map.css
|
||||||
|
```
|
||||||
|
|
||||||
|
`css-compiled/map.css` should be non-empty (~100KB+) as it contains full MapLibre GL styles.
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/js/src/map.js themes/intotheeast/js/map.js themes/intotheeast/css-compiled/map.css
|
||||||
|
git -C user commit -m "build: add map.js bundle — MapLibre GL, toGeoJSON, MapUtils"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: base.html.twig — Asset Manager + remove Google Fonts + remove photo strip script
|
||||||
|
|
||||||
|
Register the universal bundle via Grav's Asset Manager, remove Google Fonts external requests, remove the inline photo strip script, and add the `map_assets` block for map pages to fill.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `{% block map_assets %}{% endblock %}` — filled by trip.html.twig, feed-map.html.twig, map.html.twig in later tasks
|
||||||
|
|
||||||
|
- [x] **Step 1: Update base.html.twig**
|
||||||
|
|
||||||
|
Replace the entire file content of `user/themes/intotheeast/templates/partials/base.html.twig` with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}</title>
|
||||||
|
{% do assets.addCss('theme://css/tokens.css') %}
|
||||||
|
{% do assets.addCss('theme://css/style.css') %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/main.css') %}
|
||||||
|
{% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %}
|
||||||
|
{{ assets.css()|raw }}
|
||||||
|
{{ assets.js()|raw }}
|
||||||
|
</head>
|
||||||
|
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
|
||||||
|
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="site-title" href="{{ base_url_absolute }}">into the east</a>
|
||||||
|
{% block nav %}
|
||||||
|
<nav class="site-nav" aria-label="Main navigation">
|
||||||
|
<a href="{{ base_url_absolute }}"{% if page.template == 'home' %} aria-current="page"{% endif %}>Home</a>
|
||||||
|
<a href="{{ base_url_absolute }}/trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Past Trips</a>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
</header>
|
||||||
|
<main class="site-main" id="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
{% block map_assets %}{% endblock %}
|
||||||
|
{{ assets.js('bottom')|raw }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes from original:
|
||||||
|
- Removed `<link rel="preconnect" href="https://fonts.googleapis.com">` (lines 7–9)
|
||||||
|
- Replaced hardcoded `<link>` tags with `{% do assets.addCss(...) %}` calls
|
||||||
|
- Removed the `<script>` photo strip block (lines 30–73) — now in `js/main.js`
|
||||||
|
- Added `{% block map_assets %}{% endblock %}` before `{{ assets.js('bottom')|raw }}`
|
||||||
|
|
||||||
|
- [x] **Step 2: Remove Google Fonts @import from style.css if present**
|
||||||
|
|
||||||
|
Check whether `css/style.css` contains a Google Fonts import:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n 'googleapis\|fonts.g' user/themes/intotheeast/css/style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
If any line is found, remove it. The font-family declarations using `--font-display` and `--font-ui` stay unchanged — they reference the CSS custom properties defined in `tokens.css`, which will work with the self-hosted fonts in `css-compiled/main.css`.
|
||||||
|
|
||||||
|
- [x] **Step 3: Load the dev server and verify the page renders**
|
||||||
|
|
||||||
|
Open `http://localhost:8081` in a browser. The page should load with correct fonts (DM Sans for body, DM Serif Display for headings). Open browser DevTools → Network tab → filter by "google" — no requests to `fonts.googleapis.com` or `fonts.gstatic.com` should appear.
|
||||||
|
|
||||||
|
If fonts look wrong, check that `css-compiled/main.css` is served by opening `http://localhost:8081/user/themes/intotheeast/css-compiled/main.css`.
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
|
||||||
|
git -C user commit -m "feat: register assets via Asset Manager, remove Google Fonts, remove inline photo strip script"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: trip.html.twig — full JS cleanup and map bundle registration
|
||||||
|
|
||||||
|
This is the largest template change. Remove all duplicated JS blocks, CDN tags, and the haversineKm duplicate. Wire `MapUtils.parseGpxFiles` and `MapUtils.haversineKm` in the GPX stats block.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes:
|
||||||
|
- `window.maplibregl` — from `js/map.js` (Task 4)
|
||||||
|
- `window.MapUtils.parseGpxFiles(urls, cb)` — from `js/maplibre-utils.js` via map bundle (Task 3)
|
||||||
|
- `window.MapUtils.haversineKm(lat1, lng1, lat2, lng2)` — from map bundle (Task 3)
|
||||||
|
- `window.scrollama` — from `js/main.js` (Task 2, though not used on this page)
|
||||||
|
|
||||||
|
- [x] **Step 1: Remove the CDN script/link tags and PhotoSwipe CSS link**
|
||||||
|
|
||||||
|
Remove these lines from `trip.html.twig`:
|
||||||
|
- Line 4: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css">`
|
||||||
|
- Lines 247–250:
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Add map_assets block immediately after `{% block content %}`**
|
||||||
|
|
||||||
|
After the opening `{% block content %}` line, add:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% block map_assets %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/map.css') %}
|
||||||
|
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Remove the duplicated JS blocks**
|
||||||
|
|
||||||
|
Remove the following `<script>` blocks entirely from `trip.html.twig`. These are now handled by `js/main.js`:
|
||||||
|
|
||||||
|
1. The filter bar IIFE (the block from `(function() {` at line ~339 through to the closing `})();` at line ~371 — the one with `.trip-filter-btn` and `data-filter`)
|
||||||
|
2. The sort toggle IIFE (from `(function() {` containing `trip-sort-toggle` through its `})();` at line ~388)
|
||||||
|
3. The back-to-top block (from `document.addEventListener('DOMContentLoaded', function () {` at line ~545 through its `});` at line ~561)
|
||||||
|
4. The entire `<script type="module">` block at the bottom (lines ~566–626) containing the PhotoSwipe lightbox and IntersectionObserver photo strip code
|
||||||
|
|
||||||
|
Note: the `makePanelToggle` code lives *inside* the GPX stats IIFE (which stays) — that is handled in Step 5, not here.
|
||||||
|
|
||||||
|
- [x] **Step 4: Update the GPX stats block to use MapUtils**
|
||||||
|
|
||||||
|
In the remaining GPX stats IIFE (the block starting with `(function() {` that references `HAS_GPX`, `parseGpxFiles`, and `haversineKm`):
|
||||||
|
|
||||||
|
Replace the local `haversineKm` function definition (lines ~393–401):
|
||||||
|
```javascript
|
||||||
|
function haversineKm(lat1, lng1, lat2, lng2) {
|
||||||
|
var R = 6371;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Delete this entire function — it is now `MapUtils.haversineKm`.
|
||||||
|
|
||||||
|
Replace the local `parseGpxFiles` function definition (lines ~403–485):
|
||||||
|
```javascript
|
||||||
|
function parseGpxFiles(urls, callback) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Delete this entire function — it is now `MapUtils.parseGpxFiles`.
|
||||||
|
|
||||||
|
Update the two call sites to use MapUtils:
|
||||||
|
|
||||||
|
Find (Mode A call, line ~491):
|
||||||
|
```javascript
|
||||||
|
parseGpxFiles(GPX_URLS, function(result) {
|
||||||
|
```
|
||||||
|
Replace with:
|
||||||
|
```javascript
|
||||||
|
MapUtils.parseGpxFiles(GPX_URLS, function(result) {
|
||||||
|
```
|
||||||
|
|
||||||
|
Find (Mode B haversine call, lines ~512–515):
|
||||||
|
```javascript
|
||||||
|
total += haversineKm(
|
||||||
|
parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
|
||||||
|
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
|
||||||
|
);
|
||||||
|
```
|
||||||
|
Replace with:
|
||||||
|
```javascript
|
||||||
|
total += MapUtils.haversineKm(
|
||||||
|
parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
|
||||||
|
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Remove the makePanelToggle function from the GPX stats IIFE**
|
||||||
|
|
||||||
|
Inside the GPX stats IIFE, find and remove:
|
||||||
|
- The `makePanelToggle` function definition
|
||||||
|
- The two `makePanelToggle(...)` calls
|
||||||
|
- The `document.querySelectorAll('.trip-panel-close').forEach(...)` block
|
||||||
|
|
||||||
|
These are now handled by `initPanelToggles()` in `main.js`.
|
||||||
|
|
||||||
|
- [x] **Step 6: Open the trip page and verify all features**
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/japan-korea-2026` (or the current active trip URL). Verify:
|
||||||
|
- Map loads and markers are visible
|
||||||
|
- GPX track renders
|
||||||
|
- Sort button (↑/↓) reverses feed order
|
||||||
|
- Filter bar (All / Journal / Stories) shows and hides cards
|
||||||
|
- Stats panel opens and closes
|
||||||
|
- Cycling panel opens and closes (if GPX present)
|
||||||
|
- Back-to-top button appears after scrolling
|
||||||
|
- Journal photo strip: dots sync, prev/next work, expand opens lightbox
|
||||||
|
- No console errors
|
||||||
|
|
||||||
|
Open DevTools → Network tab → reload. Filter by "cdn.jsdelivr" — zero results expected.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/trip.html.twig
|
||||||
|
git -C user commit -m "refactor: trip.html.twig — remove CDN tags, deduplicate JS, use MapUtils.parseGpxFiles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Remaining template CDN cleanup
|
||||||
|
|
||||||
|
Remove CDN tags from `feed-map.html.twig`, `map.html.twig`, and `story.html.twig`. These pages are not in active use but should not make CDN requests when visited.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/partials/feed-map.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/templates/map.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/templates/story.html.twig`
|
||||||
|
|
||||||
|
- [x] **Step 1: feed-map.html.twig — remove CDN tags, add map_assets block**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/partials/feed-map.html.twig`:
|
||||||
|
|
||||||
|
Remove lines 28–30:
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the map assets block at the very top of the `{% if map_entries|length > 0 %}` block (before the `<div class="feed-map-wrap">`):
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% block map_assets %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/map.css') %}
|
||||||
|
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: map.html.twig — remove CDN tags, add map_assets block**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/map.html.twig`:
|
||||||
|
|
||||||
|
Remove lines 39–42:
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
|
||||||
|
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add after `{% block content %}`:
|
||||||
|
```twig
|
||||||
|
{% block map_assets %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/map.css') %}
|
||||||
|
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: story.html.twig — remove Scrollama CDN tag**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/story.html.twig`:
|
||||||
|
|
||||||
|
Remove line 72:
|
||||||
|
```html
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/scrollama@3/build/scrollama.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Scrollama is now bundled in `main.js` and exposed as `window.scrollama`. The existing inline script that calls `scrollama()` will work unchanged.
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/partials/feed-map.html.twig themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/story.html.twig
|
||||||
|
git -C user commit -m "refactor: remove CDN tags from feed-map, map, story templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: End-to-end verification — no external requests, all features intact
|
||||||
|
|
||||||
|
**Files:** None modified. Verification only.
|
||||||
|
|
||||||
|
- [x] **Step 1: Verify zero external requests on the trip page**
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/japan-korea-2026`. Open DevTools → Network tab → reload.
|
||||||
|
|
||||||
|
Check these domains appear zero times:
|
||||||
|
- `cdn.jsdelivr.net`
|
||||||
|
- `fonts.googleapis.com`
|
||||||
|
- `fonts.gstatic.com`
|
||||||
|
|
||||||
|
- [x] **Step 2: Verify trip page features**
|
||||||
|
|
||||||
|
- Map renders with markers and GPX track
|
||||||
|
- Marker click scrolls to entry card and flashes it
|
||||||
|
- Fullscreen map toggle expands/collapses
|
||||||
|
- Filter bar: All / Journal / Stories each filter correctly
|
||||||
|
- Sort toggle (↑/↓) reverses feed order
|
||||||
|
- Stats panel opens and closes (click "Stats ▾" button)
|
||||||
|
- Cycling panel opens and closes if GPX present ("Cycling ▾" button)
|
||||||
|
- GPX distance figure populates in stats grid
|
||||||
|
- Cycling stats grid populates (distance, gain, loss, highest, lowest, moving time, avg speed)
|
||||||
|
- Back-to-top button appears after scrolling down; click scrolls to top
|
||||||
|
- Journal photo strip: swipe/scroll dots sync; ‹ › buttons navigate; expand button opens PhotoSwipe; arrow keys advance; click outside closes
|
||||||
|
|
||||||
|
- [x] **Step 3: Verify story page**
|
||||||
|
|
||||||
|
Open a story URL (e.g. `http://localhost:8081/trips/italy-2026-demo/stories/sorano-rock-and-time`):
|
||||||
|
- Hero image loads
|
||||||
|
- Scroll overlay darkens/lightens on scroll
|
||||||
|
- Story title fades into nav bar as hero scrolls out
|
||||||
|
- Back-to-top button appears and works
|
||||||
|
- If page has `.scrolly` sections: they animate on scroll
|
||||||
|
- No console errors
|
||||||
|
|
||||||
|
- [x] **Step 4: Check built file sizes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lh user/themes/intotheeast/js/main.js user/themes/intotheeast/js/map.js user/themes/intotheeast/css-compiled/main.css user/themes/intotheeast/css-compiled/map.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Rough expected sizes (minified):
|
||||||
|
- `main.js`: ~80–150 KB (PhotoSwipe + Scrollama + UI code)
|
||||||
|
- `map.js`: ~600–900 KB (MapLibre GL dominates)
|
||||||
|
- `main.css`: ~30–60 KB (PhotoSwipe + @font-face rules)
|
||||||
|
- `map.css`: ~80–120 KB (MapLibre GL styles)
|
||||||
|
|
||||||
|
If `map.js` is unexpectedly small (<100 KB), MapLibre GL may not have bundled — check that `import maplibregl from 'maplibre-gl'` is in `js/src/map.js`.
|
||||||
|
|
||||||
|
- [x] **Step 5: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add -A
|
||||||
|
git -C user status # confirm only expected files
|
||||||
|
git -C user commit -m "chore: verify asset pipeline — all CDN deps eliminated"
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: complete asset pipeline — self-hosted deps, deduplicated JS"
|
||||||
|
```
|
||||||
@@ -0,0 +1,995 @@
|
|||||||
|
# Template Refactor Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-26)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Extract stats computation and panel HTML into Twig macros, extract date range formatting into a macro, and fix two latent bugs in inactive templates — with zero visual change.
|
||||||
|
|
||||||
|
**Architecture:** Three Twig macros live in `templates/macros/`. `trip.html.twig` imports and calls the stats/cycling macros, shrinking from 386 to ~260 lines. `story.html.twig` and `stories.html.twig` both use the date-range macro. `feed-map.html.twig` and `map.html.twig` get DOMContentLoaded wrappers and correct asset registration timing.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav 2.0, Twig 3, Playwright (test runner), `make test-ui` → `npx playwright test`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Working directory for all file edits: `user/themes/intotheeast/templates/`
|
||||||
|
- Dev server URL: `http://localhost:8081`
|
||||||
|
- Demo trip used for testing: `/trips/italy-2026-demo`
|
||||||
|
- Zero visual change — no HTML structure, CSS class, or JS logic changes
|
||||||
|
- Twig macros are imported with `{% import 'macros/file.html.twig' as alias %}` inside `{% block content %}`
|
||||||
|
- Macros live in `templates/macros/` (new directory — create it)
|
||||||
|
- All macro arguments are positional (Twig macros use named params with defaults as of Twig 1.12, but positional is fine here)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | Path | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| Create | `templates/macros/stats.html.twig` | Stats computation + stats panel HTML |
|
||||||
|
| Create | `templates/macros/cycling.html.twig` | Cycling panel HTML (all JS placeholders) |
|
||||||
|
| Create | `templates/macros/date-range.html.twig` | Smart condensed date range string |
|
||||||
|
| Modify | `templates/trip.html.twig` | Import + call stats/cycling macros; remove 130 lines |
|
||||||
|
| Modify | `templates/story.html.twig` | Replace 15-line date logic with macro call |
|
||||||
|
| Modify | `templates/stories.html.twig` | Add `{% block map_assets %}`; replace date logic with macro call |
|
||||||
|
| Modify | `templates/partials/feed-map.html.twig` | Remove asset calls; wrap map init in DOMContentLoaded |
|
||||||
|
| Modify | `templates/map.html.twig` | Move `{% block map_assets %}` to top level; add DOMContentLoaded |
|
||||||
|
| Modify | `templates/dailies.html.twig` | Add `{% block map_assets %}` override |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Stats + cycling macros; update trip.html.twig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `templates/macros/stats.html.twig`
|
||||||
|
- Create: `templates/macros/cycling.html.twig`
|
||||||
|
- Modify: `templates/trip.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `stats_panel(journal_entries, page, journal_count, has_gpx)` — renders `<div id="trip-stats-block">`
|
||||||
|
- Produces: `cycling_panel()` — renders `<div id="trip-cycling-block">`
|
||||||
|
- Both macros are imported at the top of `{% block content %}` in trip.html.twig
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the macros directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user/themes/intotheeast/templates/macros
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `templates/macros/stats.html.twig`**
|
||||||
|
|
||||||
|
This macro receives the entry collection, computes all server-side stats internally, and renders the complete stats panel. The `id="stat-distance"` placeholder is left empty for JS to fill after page load.
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% macro stats_panel(journal_entries, page, journal_count, has_gpx) %}
|
||||||
|
{% set days_on_road = 0 %}
|
||||||
|
{% if page.header.date_end is not empty %}
|
||||||
|
{% set start_ts = page.header.date_start|date('U') %}
|
||||||
|
{% set end_ts = page.header.date_end|date('U') %}
|
||||||
|
{% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %}
|
||||||
|
{% else %}
|
||||||
|
{% set first_ts = null %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% set ts = entry.date|date('U') %}
|
||||||
|
{% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if first_ts is not null %}
|
||||||
|
{% set diff_seconds = "now"|date('U') - first_ts %}
|
||||||
|
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
|
||||||
|
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set seen_lower = [] %}
|
||||||
|
{% set country_display = [] %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% if entry.header.location_country is not empty %}
|
||||||
|
{% set lower = entry.header.location_country|trim|lower %}
|
||||||
|
{% if lower not in seen_lower %}
|
||||||
|
{% set seen_lower = seen_lower|merge([lower]) %}
|
||||||
|
{% set country_display = country_display|merge([entry.header.location_country|trim]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set seen_city_lower = [] %}
|
||||||
|
{% set city_display = [] %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% if entry.header.location_city is not empty %}
|
||||||
|
{% set lower = entry.header.location_city|trim|lower %}
|
||||||
|
{% if lower not in seen_city_lower %}
|
||||||
|
{% set seen_city_lower = seen_city_lower|merge([lower]) %}
|
||||||
|
{% set city_display = city_display|merge([entry.header.location_city|trim]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set temp_min = null %}
|
||||||
|
{% set temp_max = null %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %}
|
||||||
|
{% set t = entry.header.weather_temp_c %}
|
||||||
|
{% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %}
|
||||||
|
{% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div id="trip-stats-block" class="trip-stats-block">
|
||||||
|
<div class="trip-panel-inner">
|
||||||
|
<div class="trip-stats-grid">
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ days_on_road }}</span>
|
||||||
|
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ journal_count }}</span>
|
||||||
|
<span class="stat-label">{{ journal_count == 1 ? 'entry' : 'entries' }} posted</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ country_display|length }}</span>
|
||||||
|
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value">{{ city_display|length }}</span>
|
||||||
|
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="stat-distance">—</span>
|
||||||
|
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
{% if temp_min is not null %}
|
||||||
|
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="stat-value">—</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="stat-label">°C range</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if country_display|length > 0 %}
|
||||||
|
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="trip-stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
|
||||||
|
<button class="trip-panel-close" data-toggle="trip-stats-toggle">↑ Close stats</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `templates/macros/cycling.html.twig`**
|
||||||
|
|
||||||
|
All stat values are JS placeholders — JS fills them via `MapUtils.parseGpxFiles()` after page load. No computation needed.
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% macro cycling_panel() %}
|
||||||
|
<div id="trip-cycling-block" class="trip-cycling-block">
|
||||||
|
<div class="trip-panel-inner">
|
||||||
|
<div class="trip-cycling-header">
|
||||||
|
<span class="trip-cycling-icon">🚴</span>
|
||||||
|
<span class="trip-cycling-title">Cycling Stats</span>
|
||||||
|
</div>
|
||||||
|
<div class="trip-cycling-grid">
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-distance">—</span>
|
||||||
|
<span class="stat-label">km distance</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-ele-gain">—</span>
|
||||||
|
<span class="stat-label">m ↑ gain</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-ele-loss">—</span>
|
||||||
|
<span class="stat-label">m ↓ loss</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-highest">—</span>
|
||||||
|
<span class="stat-label">m highest</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-lowest">—</span>
|
||||||
|
<span class="stat-label">m lowest</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-moving-time">—</span>
|
||||||
|
<span class="stat-label">moving time</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block">
|
||||||
|
<span class="stat-value" id="cyc-avg-speed">—</span>
|
||||||
|
<span class="stat-label">km/h avg speed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="trip-panel-close" data-toggle="trip-cycling-toggle">↑ Close cycling</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `templates/trip.html.twig`**
|
||||||
|
|
||||||
|
Replace lines 1–9 (extends + opening of block content) with macro imports added at the top of `{% block content %}`. Then:
|
||||||
|
- Remove lines 25–76 (stats computation: days, countries, cities, temp range) — the macro handles this now
|
||||||
|
- Remove lines 150–230 (both panel HTML divs) — replaced by macro calls
|
||||||
|
|
||||||
|
The full replacement for `trip.html.twig`:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import 'macros/stats.html.twig' as stats_m %}
|
||||||
|
{% import 'macros/cycling.html.twig' as cycling_m %}
|
||||||
|
{% block map_assets %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/map.css') %}
|
||||||
|
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
|
||||||
|
{% endblock %}
|
||||||
|
{% set dailies_page = grav.pages.find(page.route ~ '/dailies') %}
|
||||||
|
{% set stories_page = grav.pages.find(page.route ~ '/stories') %}
|
||||||
|
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
|
||||||
|
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
|
||||||
|
|
||||||
|
{% set all_items = [] %}
|
||||||
|
{% for e in journal_entries %}
|
||||||
|
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for s in story_entries %}
|
||||||
|
{% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.header.date}]) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set all_items = all_items|sort_by_key('date', 4) %}
|
||||||
|
|
||||||
|
{% set journal_count = journal_entries|length %}
|
||||||
|
{% set story_count = story_entries|length %}
|
||||||
|
|
||||||
|
{% set gps_points = [] %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
|
||||||
|
{% set gps_points = gps_points|merge([[entry.header.lat, entry.header.lng]]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set gpx_urls = [] %}
|
||||||
|
{% for name, media in page.media.all %}
|
||||||
|
{% if name|split('.')|last == 'gpx' %}
|
||||||
|
{% set gpx_urls = gpx_urls|merge([page.url ~ '/' ~ name]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set has_gpx = gpx_urls|length > 0 %}
|
||||||
|
|
||||||
|
{% set map_entries = [] %}
|
||||||
|
{% for item in all_items %}
|
||||||
|
{% if item.page.header.lat is not empty and item.page.header.lng is not empty %}
|
||||||
|
{% set map_entries = map_entries|merge([{
|
||||||
|
'type': item.type,
|
||||||
|
'lat': item.page.header.lat|number_format(6, '.', ''),
|
||||||
|
'lng': item.page.header.lng|number_format(6, '.', ''),
|
||||||
|
'slug': item.page.slug,
|
||||||
|
'title': item.page.title,
|
||||||
|
'url': item.page.url,
|
||||||
|
'force_connect': item.page.header.force_connect ? true : false,
|
||||||
|
'transport_mode': item.page.header.transport_mode ? item.page.header.transport_mode : null
|
||||||
|
}]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="home-layout">
|
||||||
|
<div class="home-map-col">
|
||||||
|
<div class="home-map" id="trip-map">
|
||||||
|
<button class="feed-map-fullscreen-btn" id="trip-map-fullscreen" aria-label="Expand map">
|
||||||
|
<svg class="feed-map-fs-open" aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||||
|
<path d="M0 0v4h1.5V1.5H4V0z M14 0H10v1.5h2.5V4H14z M0 14v-4h1.5v2.5H4V14z M14 14H10v-1.5h2.5V10H14z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="feed-map-fs-close" aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-feed-col">
|
||||||
|
<div class="home-trip-header">
|
||||||
|
<h1 class="home-trip-name">{{ page.title }}</h1>
|
||||||
|
{% if page.header.date_start %}
|
||||||
|
<p class="trip-dates" style="font-size:var(--text-sm);color:var(--color-ink-muted);margin:var(--space-1) 0 var(--space-2);">
|
||||||
|
{{ page.header.date_start|date('d M Y') }}
|
||||||
|
{% if page.header.date_end %} — {{ page.header.date_end|date('d M Y') }}{% else %} — Ongoing{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<span class="home-trip-counts">
|
||||||
|
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
|
||||||
|
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="trip-filter-bar">
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
|
||||||
|
</div>
|
||||||
|
<button class="trip-stats-btn" id="trip-sort-toggle" aria-label="Sort: oldest first">↑</button>
|
||||||
|
</div>
|
||||||
|
<div class="trip-panel-toggles">
|
||||||
|
<button class="trip-panel-toggle" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
|
||||||
|
{% if has_gpx %}
|
||||||
|
<button class="trip-panel-toggle" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ stats_m.stats_panel(journal_entries, page, journal_count, has_gpx) }}
|
||||||
|
|
||||||
|
{% if has_gpx %}
|
||||||
|
{{ cycling_m.cycling_panel() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="feed">
|
||||||
|
{% if all_items|length > 0 %}
|
||||||
|
{% for item in all_items %}
|
||||||
|
{% set entry = item.page %}
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
{% include 'partials/entry-journal.html.twig' %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'partials/entry-story.html.twig' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var TRIP_ENTRIES = {{ map_entries|json_encode|raw }};
|
||||||
|
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
|
||||||
|
var USE_GPX = {{ page.header.use_gpx ?? true ? 'true' : 'false' }};
|
||||||
|
var AUTOCONNECT = "{{ page.header.autoconnect ?? 'on' }}";
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
|
var tripMap = new maplibregl.Map({
|
||||||
|
container: 'trip-map',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2,
|
||||||
|
attributionControl: false
|
||||||
|
});
|
||||||
|
tripMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||||||
|
|
||||||
|
tripMap.on('load', function () {
|
||||||
|
if (TRIP_ENTRIES.length === 0) {
|
||||||
|
tripMap.jumpTo({ center: [0, 20], zoom: 2 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Markers + bounds ──────────────────────────────────────── */
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
|
||||||
|
TRIP_ENTRIES.forEach(function (entry, i) {
|
||||||
|
var isLatest = (entry.type !== 'story') && (i === TRIP_ENTRIES.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = entry.type === 'story' ? MapUtils.createStoryMarker() : MapUtils.createDotMarker(isLatest);
|
||||||
|
el.dataset.url = entry.url;
|
||||||
|
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||||
|
el.addEventListener('mouseenter', function () { popup.addTo(tripMap); });
|
||||||
|
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('entry-' + entry.slug);
|
||||||
|
if (!card) return;
|
||||||
|
var mapCol = document.querySelector('.home-map-col');
|
||||||
|
var isFs = mapCol && mapCol.classList.contains('is-fullscreen');
|
||||||
|
function scrollAndHighlight() {
|
||||||
|
window.location.hash = 'entry-' + entry.slug;
|
||||||
|
setTimeout(function () {
|
||||||
|
card.classList.add('is-highlighted');
|
||||||
|
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
if (isFs) {
|
||||||
|
var fsBtn = document.getElementById('trip-map-fullscreen');
|
||||||
|
if (fsBtn) fsBtn.click();
|
||||||
|
setTimeout(scrollAndHighlight, 450);
|
||||||
|
} else {
|
||||||
|
scrollAndHighlight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(tripMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Fit bounds ─────────────────────────────────────────────── */
|
||||||
|
if (TRIP_ENTRIES.length === 1) {
|
||||||
|
tripMap.jumpTo({ center: [parseFloat(TRIP_ENTRIES[0].lng), parseFloat(TRIP_ENTRIES[0].lat)], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
tripMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GPX tracks + journey segments ─────────────────────────── */
|
||||||
|
MapUtils.renderGpxJourney(tripMap, USE_GPX ? GPX_URLS : [], TRIP_ENTRIES, 'gpx', 'trip-journey', { connectMode: AUTOCONNECT });
|
||||||
|
|
||||||
|
// Collapse attribution <details> which MapLibre may open on load
|
||||||
|
var attrib = tripMap.getContainer().querySelector('.maplibregl-ctrl-attrib');
|
||||||
|
if (attrib) attrib.removeAttribute('open');
|
||||||
|
});
|
||||||
|
setTimeout(function () { tripMap.resize(); }, 100);
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var fsBtn = document.getElementById('trip-map-fullscreen');
|
||||||
|
var mapCol = document.querySelector('.home-map-col');
|
||||||
|
if (!fsBtn || !mapCol) return;
|
||||||
|
fsBtn.addEventListener('click', function() {
|
||||||
|
var isFs = mapCol.classList.toggle('is-fullscreen');
|
||||||
|
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
|
||||||
|
document.body.style.overflow = isFs ? 'hidden' : '';
|
||||||
|
setTimeout(function() { tripMap.resize(); }, 50);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
var STATS_GPS = {{ gps_points|json_encode|raw }};
|
||||||
|
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var distEl = document.getElementById('stat-distance');
|
||||||
|
|
||||||
|
if (HAS_GPX) {
|
||||||
|
MapUtils.parseGpxFiles(GPX_URLS, function(result) {
|
||||||
|
if (distEl) {
|
||||||
|
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
|
||||||
|
}
|
||||||
|
function setText(id, val) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
|
||||||
|
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
|
||||||
|
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
|
||||||
|
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
|
||||||
|
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
|
||||||
|
setText('cyc-moving-time', result.movingTime || '—');
|
||||||
|
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 1; i < STATS_GPS.length; i++) {
|
||||||
|
total += MapUtils.haversineKm(
|
||||||
|
parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
|
||||||
|
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (distEl) {
|
||||||
|
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
|
}); // DOMContentLoaded
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="story-totop" id="trip-totop" aria-label="Back to top">↑ Top</button>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Clear Grav cache**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stop && make start
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if cache clearing is available without restart:
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8081/admin/cache/clear 2>/dev/null || make stop && make start
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify trip page renders correctly**
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo` in a browser.
|
||||||
|
|
||||||
|
Check:
|
||||||
|
- Page loads without Twig errors (no white page, no "Twig error" text)
|
||||||
|
- Trip header shows title, dates, entry count
|
||||||
|
- Click "Stats ▾" button — stats panel expands showing days, entries, countries, cities, temp range as numbers (not empty/zero)
|
||||||
|
- `stat-distance` shows "—" then fills after a moment (JS loading GPX)
|
||||||
|
- Click "Cycling ▾" button — cycling panel expands with 7 stat placeholders (all "—" initially, then fill)
|
||||||
|
- Map shows markers
|
||||||
|
- Browser console has no JS errors
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run existing Playwright tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass (F1–F7 filter tests, M1–M5 map tests). If any fail, investigate before committing.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/templates/macros/stats.html.twig \
|
||||||
|
user/themes/intotheeast/templates/macros/cycling.html.twig \
|
||||||
|
user/themes/intotheeast/templates/trip.html.twig
|
||||||
|
git commit -m "refactor: extract stats and cycling panels to Twig macros"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Date range macro; update story.html.twig and stories.html.twig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `templates/macros/date-range.html.twig`
|
||||||
|
- Modify: `templates/story.html.twig` (lines 19–34)
|
||||||
|
- Modify: `templates/stories.html.twig` (lines 49–52 + add `{% block map_assets %}`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing from Task 1
|
||||||
|
- Produces: `format_date_range(start_date, end_date)` — outputs a text string:
|
||||||
|
- Single day (no end_date or end == start): `23 Jun 2026`
|
||||||
|
- Same month: `12 – 15 Jun 2026`
|
||||||
|
- Same year, different month: `12 Jun – 3 Jul 2026`
|
||||||
|
- Different years: `28 Dec 2025 – 3 Jan 2026`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `templates/macros/date-range.html.twig`**
|
||||||
|
|
||||||
|
Logic extracted verbatim from `story.html.twig` lines 19–34, generalised to accept arguments instead of reading `page.date` / `page.header.end_date` directly.
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% macro format_date_range(start_date, end_date) %}
|
||||||
|
{%- if end_date is not empty and end_date|date('Y-m-d') != start_date|date('Y-m-d') -%}
|
||||||
|
{%- set sd = start_date|date('d') -%}
|
||||||
|
{%- set sm = start_date|date('M') -%}
|
||||||
|
{%- set sy = start_date|date('Y') -%}
|
||||||
|
{%- set ed = end_date|date('d') -%}
|
||||||
|
{%- set em = end_date|date('M') -%}
|
||||||
|
{%- set ey = end_date|date('Y') -%}
|
||||||
|
{%- if sy == ey and sm == em -%}
|
||||||
|
{{- sd ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey -}}
|
||||||
|
{%- elseif sy == ey -%}
|
||||||
|
{{- sd ~ ' ' ~ sm ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey -}}
|
||||||
|
{%- else -%}
|
||||||
|
{{- sd ~ ' ' ~ sm ~ ' ' ~ sy ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey -}}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- else -%}
|
||||||
|
{{- start_date|date('d M Y') -}}
|
||||||
|
{%- endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the `{%- -%}` whitespace-control tags prevent the macro from outputting leading/trailing newlines, so it can be used inline in HTML without extra whitespace.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `templates/story.html.twig`**
|
||||||
|
|
||||||
|
Replace lines 19–34 (date computation) with a macro call. The `{% import %}` goes at the top of `{% block content %}`, just after the `{% block content %}` opening tag.
|
||||||
|
|
||||||
|
Find the existing `{% block content %}` line and the lines immediately after it:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% block content %}
|
||||||
|
{% set hero_url = null %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% block content %}
|
||||||
|
{% import 'macros/date-range.html.twig' as dr_m %}
|
||||||
|
{% set hero_url = null %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then find and replace the entire date computation block (lines 19–34):
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% set date_str = page.date|date('d M Y') %}
|
||||||
|
{% if page.header.end_date and page.header.end_date|date('Y-m-d') != page.date|date('Y-m-d') %}
|
||||||
|
{% set sd = page.date|date('d') %}
|
||||||
|
{% set sm = page.date|date('M') %}
|
||||||
|
{% set sy = page.date|date('Y') %}
|
||||||
|
{% set ed = page.header.end_date|date('d') %}
|
||||||
|
{% set em = page.header.end_date|date('M') %}
|
||||||
|
{% set ey = page.header.end_date|date('Y') %}
|
||||||
|
{% if sy == ey and sm == em %}
|
||||||
|
{% set date_str = sd ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey %}
|
||||||
|
{% elseif sy == ey %}
|
||||||
|
{% set date_str = sd ~ ' ' ~ sm ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey %}
|
||||||
|
{% else %}
|
||||||
|
{% set date_str = sd ~ ' ' ~ sm ~ ' ' ~ sy ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% set date_str = dr_m.format_date_range(page.date, page.header.end_date ?? null) %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `templates/stories.html.twig`**
|
||||||
|
|
||||||
|
This file needs two changes: adding `{% block map_assets %}` (CSS timing fix, from Task 4's bug) and replacing the date string logic.
|
||||||
|
|
||||||
|
Replace the current opening of the file:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set stories = page.children.published().order('date', 'asc') %}
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block map_assets %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/map.css') %}
|
||||||
|
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% import 'macros/date-range.html.twig' as dr_m %}
|
||||||
|
{% set stories = page.children.published().order('date', 'asc') %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then find and replace the date_str logic inside the stories loop (lines 49–52 of the original):
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% set date_str = story.date|date('d M Y') %}
|
||||||
|
{% if story.header.end_date %}
|
||||||
|
{% set date_str = story.date|date('d M') ~ '–' ~ story.header.end_date|date('d M Y') %}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% set date_str = dr_m.format_date_range(story.date, story.header.end_date ?? null) %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clear cache and verify story page**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stop && make start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo/stories/val-dorcia-at-dawn` (single-day story):
|
||||||
|
- Date renders as e.g. `3 Jun 2026` (no range)
|
||||||
|
- No Twig errors
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo/stories/sorano-rock-and-time` (multi-day story if it has end_date):
|
||||||
|
- Date renders condensed if same month (e.g. `5 – 7 Jun 2026`)
|
||||||
|
- No Twig errors
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo/stories`:
|
||||||
|
- Story cards show dates in the same smart format
|
||||||
|
- Map renders without console errors
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run Playwright tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass. Story tests in `tests/ui/stories/stories.spec.js` should pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/templates/macros/date-range.html.twig \
|
||||||
|
user/themes/intotheeast/templates/story.html.twig \
|
||||||
|
user/themes/intotheeast/templates/stories.html.twig
|
||||||
|
git commit -m "refactor: extract date range macro; fix stories.html.twig asset registration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Fix latent bugs in feed-map.html.twig, map.html.twig, dailies.html.twig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `templates/partials/feed-map.html.twig`
|
||||||
|
- Modify: `templates/map.html.twig`
|
||||||
|
- Modify: `templates/dailies.html.twig`
|
||||||
|
|
||||||
|
**The two bugs:**
|
||||||
|
|
||||||
|
1. `feed-map.html.twig` calls `assets.addCss/addJs` inside `{% block content %}`, after `base.html.twig` has already rendered `{{ assets.css() }}` in `<head>`. Map.css never reaches `<head>`. Fix: remove asset calls from the partial; add `{% block map_assets %}` in callers.
|
||||||
|
|
||||||
|
2. `feed-map.html.twig` and `map.html.twig` call `new maplibregl.Map()` in inline `<script>` blocks that execute before `map.js` is loaded (map.js is in the `bottom` group, rendered after all content). Fix: wrap in `DOMContentLoaded`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing from Tasks 1 or 2
|
||||||
|
- The `feed-map.html.twig` partial is included by `dailies.html.twig` and `stories.html.twig`. `stories.html.twig` already got its `{% block map_assets %}` in Task 2. Only `dailies.html.twig` remains.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `templates/partials/feed-map.html.twig`**
|
||||||
|
|
||||||
|
Remove the two asset registration lines (14–15) and merge both `<script>` blocks into one, wrapped in `DOMContentLoaded`.
|
||||||
|
|
||||||
|
The full replacement for `feed-map.html.twig`:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{#
|
||||||
|
Feed mini-map partial — shared by dailies.html.twig and stories.html.twig.
|
||||||
|
|
||||||
|
Required variables (via {% include ... with {...} only %}):
|
||||||
|
map_entries — array: [{lat, lng, title, slug, url, type, force_connect, transport_mode}]
|
||||||
|
map_id — string: HTML id for the map div (e.g. 'feed-map', 'stories-map')
|
||||||
|
map_var — string: JS variable name for the MapLibre Map (e.g. 'feedMap', 'storiesMap')
|
||||||
|
link_href — string|null: URL for "View full map" link; null/empty hides the link
|
||||||
|
card_prefix — string: prefix for scroll-to card IDs ('entry-' or 'story-')
|
||||||
|
trip_page — Grav page: trip page for autoconnect setting (used when show_journey is true)
|
||||||
|
show_journey — bool: whether to draw the route connector line between markers
|
||||||
|
|
||||||
|
Callers must register map assets via {% block map_assets %} in their own template.
|
||||||
|
#}
|
||||||
|
{% if map_entries|length > 0 %}
|
||||||
|
<div class="feed-map-wrap">
|
||||||
|
<div class="feed-map" id="{{ map_id }}">
|
||||||
|
<button class="feed-map-fullscreen-btn" id="{{ map_id }}-fullscreen" aria-label="Expand map">
|
||||||
|
<svg class="feed-map-fs-open" aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||||
|
<path d="M0 0v4h1.5V1.5H4V0z M14 0H10v1.5h2.5V4H14z M0 14v-4h1.5v2.5H4V14z M14 14H10v-1.5h2.5V10H14z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="feed-map-fs-close" aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% if link_href %}
|
||||||
|
<a class="feed-map-link" href="{{ link_href }}">View full map →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{% set js_suffix = map_id|replace({'-': '_'})|upper %}
|
||||||
|
{% if show_journey %}
|
||||||
|
{% set _ac = trip_page ? (trip_page.header.autoconnect ?? 'on') : 'on' %}
|
||||||
|
{% endif %}
|
||||||
|
var MAP_ENTRIES_{{ js_suffix }} = {{ map_entries|json_encode|raw }};
|
||||||
|
{% if show_journey %}
|
||||||
|
var AUTOCONNECT_{{ js_suffix }} = "{{ _ac == 'intelligent_gpx' ? 'on' : _ac }}";
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var {{ map_var }} = new maplibregl.Map({
|
||||||
|
container: '{{ map_id }}',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2,
|
||||||
|
attributionControl: false
|
||||||
|
});
|
||||||
|
{{ map_var }}.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||||||
|
|
||||||
|
{{ map_var }}.on('load', function () {
|
||||||
|
var attrib = {{ map_var }}.getContainer().querySelector('.maplibregl-ctrl-attrib');
|
||||||
|
if (attrib) attrib.removeAttribute('open');
|
||||||
|
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
var entries = MAP_ENTRIES_{{ js_suffix }};
|
||||||
|
|
||||||
|
entries.forEach(function (entry, i) {
|
||||||
|
var isLatest = (entry.type !== 'story') && (i === entries.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = entry.type === 'story' ? MapUtils.createStoryMarker() : MapUtils.createDotMarker(isLatest);
|
||||||
|
el.dataset.url = entry.url;
|
||||||
|
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||||
|
el.addEventListener('mouseenter', function () { popup.addTo({{ map_var }}); });
|
||||||
|
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||||
|
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var card = document.getElementById('{{ card_prefix }}' + entry.slug);
|
||||||
|
var mapWrap = document.querySelector('.feed-map-wrap');
|
||||||
|
var isFs = mapWrap && mapWrap.classList.contains('is-fullscreen');
|
||||||
|
function scrollAndHighlight() {
|
||||||
|
if (!card) { window.location.href = entry.url; return; }
|
||||||
|
window.location.hash = '{{ card_prefix }}' + entry.slug;
|
||||||
|
setTimeout(function () {
|
||||||
|
card.classList.add('is-highlighted');
|
||||||
|
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
if (isFs) {
|
||||||
|
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
|
||||||
|
if (fsBtn) fsBtn.click();
|
||||||
|
setTimeout(scrollAndHighlight, 450);
|
||||||
|
} else {
|
||||||
|
scrollAndHighlight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo({{ map_var }});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 1) {
|
||||||
|
{{ map_var }}.jumpTo({ center: [parseFloat(entries[0].lng), parseFloat(entries[0].lat)], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
{{ map_var }}.fitBounds(bounds, { padding: 60, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if show_journey %}
|
||||||
|
var segments = MapUtils.buildJourneySegments(entries, { connectMode: AUTOCONNECT_{{ js_suffix }} });
|
||||||
|
MapUtils.addJourneySegments({{ map_var }}, segments, '{{ map_id }}-journey');
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var fsBtn = document.getElementById('{{ map_id }}-fullscreen');
|
||||||
|
var mapWrap = document.querySelector('.feed-map-wrap');
|
||||||
|
if (!fsBtn || !mapWrap) return;
|
||||||
|
fsBtn.addEventListener('click', function() {
|
||||||
|
var isFs = mapWrap.classList.toggle('is-fullscreen');
|
||||||
|
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
|
||||||
|
document.body.style.overflow = isFs ? 'hidden' : '';
|
||||||
|
setTimeout(function() { typeof {{ map_var }} !== 'undefined' && {{ map_var }}.resize(); }, 50);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}); // DOMContentLoaded
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `templates/map.html.twig`**
|
||||||
|
|
||||||
|
Move `{% block map_assets %}` outside `{% block content %}` (so it runs at line 11 of base.html.twig, before assets.css), and wrap the map init in DOMContentLoaded.
|
||||||
|
|
||||||
|
Full replacement for `map.html.twig`:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% extends 'partials/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block map_assets %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/map.css') %}
|
||||||
|
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set trip_page = page.parent() %}
|
||||||
|
{% set tracker_page = grav.pages.find(page.parent().route ~ '/dailies') %}
|
||||||
|
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
|
||||||
|
|
||||||
|
{% set gpx_urls = [] %}
|
||||||
|
{% for name, media in trip_page.media.all %}
|
||||||
|
{% if name|split('.')|last == 'gpx' %}
|
||||||
|
{% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set map_entries = [] %}
|
||||||
|
{% for entry in all_entries %}
|
||||||
|
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
|
||||||
|
{% set hero_url = null %}
|
||||||
|
{% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %}
|
||||||
|
{% set hero_url = entry.media[entry.header.hero_image].cropResize(240, 135).url %}
|
||||||
|
{% elseif entry.media.images|length > 0 %}
|
||||||
|
{% set hero_url = entry.media.images|first.cropResize(240, 135).url %}
|
||||||
|
{% endif %}
|
||||||
|
{% set map_entries = map_entries|merge([{
|
||||||
|
'lat': entry.header.lat|number_format(6, '.', ''),
|
||||||
|
'lng': entry.header.lng|number_format(6, '.', ''),
|
||||||
|
'title': entry.title,
|
||||||
|
'date': entry.date|date('d M Y'),
|
||||||
|
'url': entry.url,
|
||||||
|
'hero': hero_url,
|
||||||
|
'force_connect': entry.header.force_connect ? true : false,
|
||||||
|
'transport_mode': entry.header.transport_mode ? entry.header.transport_mode : null
|
||||||
|
}]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="map-container" id="trip-map"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var ENTRIES = {{ map_entries|json_encode|raw }};
|
||||||
|
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
|
||||||
|
var USE_GPX = {{ trip_page.header.use_gpx ?? true ? 'true' : 'false' }};
|
||||||
|
var AUTOCONNECT = "{{ trip_page.header.autoconnect ?? 'on' }}";
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var map = new maplibregl.Map({
|
||||||
|
container: 'trip-map',
|
||||||
|
style: MapUtils.MAP_STYLE,
|
||||||
|
center: [20, 20],
|
||||||
|
zoom: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||||
|
|
||||||
|
if (ENTRIES.length === 0) {
|
||||||
|
var empty = document.createElement('div');
|
||||||
|
empty.className = 'map-empty';
|
||||||
|
empty.textContent = 'No locations yet — entries with GPS will appear here.';
|
||||||
|
document.getElementById('trip-map').appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('load', function () {
|
||||||
|
if (ENTRIES.length === 0) return;
|
||||||
|
|
||||||
|
/* ── Markers + bounds ──────────────────────────────────────── */
|
||||||
|
var bounds = new maplibregl.LngLatBounds();
|
||||||
|
|
||||||
|
ENTRIES.forEach(function (entry, i) {
|
||||||
|
var isLatest = (i === ENTRIES.length - 1);
|
||||||
|
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||||
|
bounds.extend(lngLat);
|
||||||
|
|
||||||
|
var el = MapUtils.createDotMarker(isLatest);
|
||||||
|
el.dataset.url = entry.url;
|
||||||
|
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||||
|
el.addEventListener('mouseenter', function () { popup.addTo(map); });
|
||||||
|
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||||
|
el.addEventListener('click', function () { window.location.href = entry.url; });
|
||||||
|
|
||||||
|
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Fit bounds ─────────────────────────────────────────────── */
|
||||||
|
if (ENTRIES.length === 1) {
|
||||||
|
map.jumpTo({ center: [parseFloat(ENTRIES[0].lng), parseFloat(ENTRIES[0].lat)], zoom: 10 });
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: 100, maxZoom: 11 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GPX tracks + journey segments ─────────────────────────── */
|
||||||
|
MapUtils.renderGpxJourney(map, USE_GPX ? GPX_URLS : [], ENTRIES, 'gpx', 'journey', { connectMode: AUTOCONNECT });
|
||||||
|
});
|
||||||
|
}); // DOMContentLoaded
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `templates/dailies.html.twig`**
|
||||||
|
|
||||||
|
Add `{% block map_assets %}` override so map.css reaches `<head>`. Place it between `{% extends %}` and `{% block content %}`.
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```twig
|
||||||
|
{% extends 'default.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```twig
|
||||||
|
{% extends 'default.html.twig' %}
|
||||||
|
|
||||||
|
{% block map_assets %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/map.css') %}
|
||||||
|
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clear cache and verify map page**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stop && make start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo/map`:
|
||||||
|
- MapLibre canvas renders
|
||||||
|
- Markers appear on the map
|
||||||
|
- Browser console has no `maplibregl is not defined` error (the M1 test catches this)
|
||||||
|
|
||||||
|
Open `http://localhost:8081/trips/italy-2026-demo/stories`:
|
||||||
|
- Stories mini-map renders (MapLibre canvas visible)
|
||||||
|
- No JS errors in console
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass, including M1 (map page), M3 (dailies mini-map), M9–M11 (stories map).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add user/themes/intotheeast/templates/partials/feed-map.html.twig \
|
||||||
|
user/themes/intotheeast/templates/map.html.twig \
|
||||||
|
user/themes/intotheeast/templates/dailies.html.twig
|
||||||
|
git commit -m "fix: DOMContentLoaded wrapper + correct asset registration in map templates"
|
||||||
|
```
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# Frontend Polish Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete — implemented 2026-06-24
|
||||||
|
|
||||||
|
**Spec:** `docs/working/specs/2026-06-24-frontend-polish-design.md`
|
||||||
|
|
||||||
|
**Goal:** Visual polish across the five primary page templates: pill grammar, stats field-notes style, header identity, emoji replacement, trip card cover images, story progress bar, and story opening transition.
|
||||||
|
|
||||||
|
**Architecture:** Tasks 1–2 are pure CSS (style.css only). Task 3 touches one partial (emoji). Task 4 adds a blueprint field and updates one template. Tasks 5–6 each add CSS + a small Twig block to story.html.twig.
|
||||||
|
|
||||||
|
**Already done (this session):**
|
||||||
|
- `entry.html.twig` unified with feed partial — hero removed, PhotoSwipe replaces broken lightbox
|
||||||
|
- Dead CSS from old entry layout stripped from `style.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All changes in `user/` — commit with `git -C user`, not main-repo git
|
||||||
|
- All new CSS uses token variables only — no hardcoded hex values
|
||||||
|
- Changes must degrade gracefully when optional data (cover image, location) is absent
|
||||||
|
- `prefers-reduced-motion` must be respected for any animations in Tasks 5–6
|
||||||
|
- Clear Grav cache after each template change: `make remote-cache-clear` or via Admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Pure CSS — Pill grammar + Stats style + Header identity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: visual changes to trip filter buttons, stats blocks, and site header across all pages
|
||||||
|
|
||||||
|
- [x] **Step 1: Pill grammar — change filter/sort buttons to rounded-rect**
|
||||||
|
|
||||||
|
Find `.trip-filter-btn,` selector block:
|
||||||
|
```css
|
||||||
|
.trip-filter-btn,
|
||||||
|
.trip-stats-btn {
|
||||||
|
...
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change only `border-radius` to `var(--radius-sm)`. Leave all other properties unchanged.
|
||||||
|
|
||||||
|
- [x] **Step 2: Stats — remove box, add left rule**
|
||||||
|
|
||||||
|
Find `.stat-block` rule:
|
||||||
|
```css
|
||||||
|
.stat-block {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-6) var(--space-5);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```css
|
||||||
|
.stat-block {
|
||||||
|
border-left: 2px solid var(--color-accent);
|
||||||
|
padding: var(--space-2) 0 var(--space-2) var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Stats — change number color from accent to ink**
|
||||||
|
|
||||||
|
Find `.stat-value` rule. It contains `color: var(--color-accent)`. Change to `color: var(--color-ink)`. Leave all other properties unchanged.
|
||||||
|
|
||||||
|
- [x] **Step 4: Header — widen site title tracking and size**
|
||||||
|
|
||||||
|
Find `.site-title` rule:
|
||||||
|
```css
|
||||||
|
.site-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change:
|
||||||
|
- `font-size: var(--text-lg)` → `font-size: var(--text-xl)`
|
||||||
|
- `letter-spacing: -0.01em` → `letter-spacing: 0.06em`
|
||||||
|
|
||||||
|
- [x] **Step 5: Header — thicken and gradient the accent stripe**
|
||||||
|
|
||||||
|
Find `.site-header` rule. It contains `border-top: 3px solid var(--color-accent)`.
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
```css
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-image: linear-gradient(90deg, var(--color-accent), var(--color-accent-hover)) 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: Visual smoke check**
|
||||||
|
|
||||||
|
Open browser and verify:
|
||||||
|
- `/trips` — trip filter buttons are square-cornered (not pill-shaped)
|
||||||
|
- `/trips/<any-trip>` — stats panel shows left accent stripe, cream numbers, no box border
|
||||||
|
- Header — "into the east" is slightly larger with wider tracking; accent stripe has gradient
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/css/style.css
|
||||||
|
git -C user commit -m "style: pill grammar, stats field-notes, header identity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Replace emoji icons in journal entry partial
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/partials/entry-journal.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Affects: every journal entry in the home feed, trip feed, and standalone entry page
|
||||||
|
|
||||||
|
- [x] **Step 1: Replace location emoji with SVG pin**
|
||||||
|
|
||||||
|
Find in the partial:
|
||||||
|
```twig
|
||||||
|
· 📍
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```twig
|
||||||
|
· <svg width="11" height="13" viewBox="0 0 12 14" fill="currentColor" aria-hidden="true" style="flex-shrink:0;vertical-align:-1px"><path d="M6 0C3.24 0 1 2.24 1 5c0 3.75 5 9 5 9s5-5.25 5-9c0-2.76-2.24-5-5-5zm0 6.75A1.75 1.75 0 1 1 6 3.25a1.75 1.75 0 0 1 0 3.5z"/></svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Strip weather emoji prefix**
|
||||||
|
|
||||||
|
Find in the partial:
|
||||||
|
```twig
|
||||||
|
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```twig
|
||||||
|
<span class="journal-post-weather">· {{ entry.header.weather_desc }}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `weather_icons` map at the top of the partial can stay (removing it is optional cleanup); it will simply go unused.
|
||||||
|
|
||||||
|
- [x] **Step 3: Smoke check**
|
||||||
|
|
||||||
|
Open any trip page in browser. Confirm:
|
||||||
|
- Location shows small SVG pin instead of 📍
|
||||||
|
- Weather shows plain text (e.g. "· Sunny") with no emoji
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/partials/entry-journal.html.twig
|
||||||
|
git -C user commit -m "style: replace emoji icons with SVG pin and plain weather text"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Trip cards — cover image
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/blueprints/trip.yaml`
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trips.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: optional cover image banner on each trip card
|
||||||
|
- Consumes: `trip.header.cover_image` (new field) or first image from first published entry
|
||||||
|
|
||||||
|
- [x] **Step 1: Read the current trip blueprint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat user/themes/intotheeast/blueprints/trip.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Locate the correct position to insert the new field (after `tagline` or near other media fields).
|
||||||
|
|
||||||
|
- [x] **Step 2: Add cover_image field to blueprint**
|
||||||
|
|
||||||
|
Insert in `trip.yaml` at an appropriate location:
|
||||||
|
```yaml
|
||||||
|
cover_image:
|
||||||
|
type: filepicker
|
||||||
|
label: Cover Image
|
||||||
|
preview_images: true
|
||||||
|
folder: '@self'
|
||||||
|
accept:
|
||||||
|
- image/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Add cover image CSS to style.css**
|
||||||
|
|
||||||
|
In the `/* ── Past trips archive */` section, add after `.trip-card-counts`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.trip-card-cover {
|
||||||
|
aspect-ratio: 3 / 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-card-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-card:hover .trip-card-cover img { transform: scale(1.04); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Update trips.html.twig to render cover image**
|
||||||
|
|
||||||
|
Inside the `{% for trip in trips %}` loop, before the `.trip-card-title` div, add:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{# Cover image: explicit field first, then first entry's first image #}
|
||||||
|
{% set cover = null %}
|
||||||
|
{% if trip.header.cover_image and trip.media[trip.header.cover_image] is defined %}
|
||||||
|
{% set cover = trip.media[trip.header.cover_image] %}
|
||||||
|
{% elseif dailies_page %}
|
||||||
|
{% set first_entry = dailies_page.children.published()|first %}
|
||||||
|
{% if first_entry and first_entry.media.images|length > 0 %}
|
||||||
|
{% set cover = first_entry.media.images|first %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if cover %}
|
||||||
|
<div class="trip-card-cover">
|
||||||
|
<img src="{{ cover.cropResize(720, 240).url }}" alt="{{ trip.title }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Smoke check**
|
||||||
|
|
||||||
|
Open `/trips` in browser. Confirm:
|
||||||
|
- Trips with media show a 3:1 cover photo banner
|
||||||
|
- Trips without media show text-only card (no broken image element)
|
||||||
|
- Hover scales the image slightly
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/blueprints/trip.yaml themes/intotheeast/templates/trips.html.twig themes/intotheeast/css/style.css
|
||||||
|
git -C user commit -m "feat: trip cards show cover image with 3:1 crop and hover zoom"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Story opening transition
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/story.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `date_str` and `location` already computed at the top of `story.html.twig`
|
||||||
|
- Produces: a centered location/date block at the top of `.story-body` with fade-in animation
|
||||||
|
|
||||||
|
- [x] **Step 1: Add story-opener CSS to style.css**
|
||||||
|
|
||||||
|
In the `/* ── Story pages */` section, after `.story-body p` rules, add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.story-opener {
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: var(--space-12);
|
||||||
|
margin-bottom: var(--space-12);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
opacity: 0;
|
||||||
|
animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.8s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-opener__text {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.story-opener { opacity: 1; animation: none; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Add opener block to story.html.twig**
|
||||||
|
|
||||||
|
Inside `.story-body`, immediately before `{{ page.content|raw }}`, add:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% if location or date_str %}
|
||||||
|
<div class="story-opener">
|
||||||
|
<span class="story-opener__text">
|
||||||
|
{{- date_str -}}
|
||||||
|
{%- if location and date_str %} · {% endif -%}
|
||||||
|
{{- location -}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Smoke check**
|
||||||
|
|
||||||
|
Open any published story in browser. Confirm:
|
||||||
|
- A small uppercase line showing date and location appears below the hero spacer
|
||||||
|
- It is separated from the prose by a thin horizontal rule
|
||||||
|
- It fades in after the hero title animation completes
|
||||||
|
- On a story with no location set: only date appears (or nothing if both are absent)
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/story.html.twig themes/intotheeast/css/style.css
|
||||||
|
git -C user commit -m "feat: story opening transition with location and date eyebrow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Reading progress bar on story pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/story.html.twig`
|
||||||
|
- Modify: `user/themes/intotheeast/css/style.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: 2px teal bar fixed at bottom of site header, progress tied to `.story-body` scroll position
|
||||||
|
- No bar rendered at all if `prefers-reduced-motion` is set (JS skips creating it)
|
||||||
|
|
||||||
|
- [x] **Step 1: Add progress bar CSS to style.css**
|
||||||
|
|
||||||
|
In the `/* ── Story pages */` section, add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.story-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--site-header-height);
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
width: 0%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: width;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Add progress bar element and JS to story.html.twig**
|
||||||
|
|
||||||
|
Immediately after `{% block content %}` (before the hero markup), add:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
<div class="story-progress" id="story-progress"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `<script>` block at the bottom (after the existing scroll/reveal scripts), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/* ── Reading progress bar ────────────────────────────────── */
|
||||||
|
(function () {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
var bar = document.getElementById('story-progress');
|
||||||
|
var body = document.querySelector('.story-body');
|
||||||
|
if (!bar || !body) return;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
var rect = body.getBoundingClientRect();
|
||||||
|
var total = body.offsetHeight - window.innerHeight;
|
||||||
|
var scrolled = -rect.top;
|
||||||
|
var pct = total > 0 ? Math.min(100, Math.max(0, (scrolled / total) * 100)) : 0;
|
||||||
|
bar.style.width = pct.toFixed(1) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', update, { passive: true });
|
||||||
|
update();
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Smoke check**
|
||||||
|
|
||||||
|
Open any published story in browser. Confirm:
|
||||||
|
- A thin teal line appears at the top of the content area (below the sticky header) as you scroll into the story body
|
||||||
|
- Bar is at 0% when the hero is visible, fills to 100% as you reach the story footer
|
||||||
|
- Bar is invisible (absent) on a device with `prefers-reduced-motion`
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user add themes/intotheeast/templates/story.html.twig themes/intotheeast/css/style.css
|
||||||
|
git -C user commit -m "feat: reading progress bar on story pages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
After all tasks complete:
|
||||||
|
|
||||||
|
1. Visual check list (browser):
|
||||||
|
- `/trips` — cover images on trip cards, hover scales; text-only fallback if no media
|
||||||
|
- `/trips/<any-trip>` — filter buttons square-cornered; stats left-rule style with cream numbers
|
||||||
|
- `/trips/<any-trip>/dailies/<any-entry>` (standalone URL) — SVG location pin, plain weather text, photo strip + PhotoSwipe, no hero image
|
||||||
|
- `/trips/<any-trip>/<any-story>` — opener block visible below hero; progress bar fills on scroll; no emoji anywhere
|
||||||
|
- Any page header — "into the east" wider-tracked; accent stripe slightly thicker with gradient
|
||||||
|
|
||||||
|
2. Reduced-motion check: simulate `prefers-reduced-motion: reduce` in browser devtools and confirm no animations fire on story pages (opener snaps visible immediately, progress bar absent).
|
||||||
|
|
||||||
|
3. Empty-data check: visit a trip with no media attached and confirm `/trips` degrades to text-only card without errors.
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
# Home / Trip View Convergence Implementation Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-27)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the home page's active-trip view present the same feed-col chrome (date range, filter bar, stats/cycling panels) as the trip page, by extracting the chrome into one shared Twig partial and the stats computation into one shared JS function.
|
||||||
|
|
||||||
|
**Architecture:** A new partial `templates/partials/trip-feed-col.html.twig` holds the entire `.home-feed-col` markup (header, filter bar, panel toggles, stats/cycling macro calls, feed loop) and is included by both `trip.html.twig` and `home.html.twig` (active branch). The inline stats/cycling computation currently in `trip.html.twig` becomes a window-exposed `initTripStats(config)` in `js/src/main.js`; the partial emits a small `DOMContentLoaded` inline script that calls it with page-specific data. The two intended differences (home has no sort button and keeps its own feed order) are driven by partial params, not separate markup.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav 2.0 / Twig templates, esbuild-bundled vanilla JS (`js/src/main.js` → `js/main.js`), MapLibre via `map.js` (`window.MapUtils`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Only ever write changes inside `travel-blog-intotheeast/` or subfolders.** The `user/` tree is a standalone git repo synced via `make content-push`; commit there as instructed by the execution skill.
|
||||||
|
- **Dev mode stays dev** — `twig.cache: false` is already set. Do NOT toggle any dev/prod config flag to work around caching; theme edits take effect on reload.
|
||||||
|
- **No map convergence.** Both inline map `<script>` blocks and both `.home-map-col` markup blocks stay exactly as they are. Do not touch map markers, fullscreen wiring, or map data-build loops.
|
||||||
|
- **No visual restyling.** Home reuses the trip's existing CSS classes unchanged. No new CSS class names except the pre-departure divider (`home-predeparture-divider`) and reuse of existing `home-highlights-cta` / `home-highlights-cta-wrap` for the pre-departure button.
|
||||||
|
- **No new JS for filter/sort/panels** — `initFilterBar()`, `initPanelToggles()`, `initSortButton()` are already global and selector-guarded. Only `initTripStats` is new.
|
||||||
|
- **Trip page rendered output must be visually and functionally identical** to before for the populated and empty cases — exact bytes may differ (the partial re-indents the feed-col markup, and the stats logic moves into a relocated inline `<script>`). Structural refactor only on that side; verify by behavioral smoke test, not a literal diff.
|
||||||
|
- **Built JS is generated** — never hand-edit `js/main.js`; edit `js/src/main.js` and rebuild with `make build-assets`.
|
||||||
|
- Dev server: `http://localhost:8081`. All verification is manual browser smoke testing (no JS test harness exists).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `user/themes/intotheeast/js/src/main.js` (edit) | Add `initTripStats(config)`; expose on `window`. Rebuild → `js/main.js`. |
|
||||||
|
| `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` (new) | The entire shared `.home-feed-col`: header, filter bar (sort button gated), panel toggles, stats/cycling macro calls, feed loop, pre-departure block, and the inline `initTripStats` call. |
|
||||||
|
| `user/themes/intotheeast/templates/trip.html.twig` (edit) | Replace inline `.home-feed-col` (`:70-120`) with the partial include; remove inline stats script (`:213-249`). Map untouched. |
|
||||||
|
| `user/themes/intotheeast/templates/home.html.twig` (edit) | Active branch: add `gps_points` build; replace bespoke feed-col (`:60-83`) with the partial include (`show_sort: false`, `pre_departure` gated). Map untouched. Between-trips branch untouched. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Shared stats glue `initTripStats(config)` in main.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/js/src/main.js` (add function near the other init functions, ~after `initPanelToggles` at `:239`; expose on `window`)
|
||||||
|
- Rebuild artifact: `user/themes/intotheeast/js/main.js` (via `make build-assets`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `window.MapUtils.parseGpxFiles(urls, cb)`, `window.MapUtils.haversineKm(lat1, lng1, lat2, lng2)` (from `map.js`, loaded in the `bottom` asset group).
|
||||||
|
- Produces: `window.initTripStats(config)` where `config = { gpxUrls: string[], gpsPoints: [number,number][], hasGpx: boolean }`. Selector-guarded: no-op when `#stat-distance` is absent. No-GPX fallback writes `'—'` (not `~0`) and returns when `gpsPoints.length < 2`. This is the exact contract the partial's inline script (Task 2) and both templates (Tasks 3–4) rely on.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `initTripStats` function**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/js/src/main.js`, immediately after the `initPanelToggles` function (after line 239, before the `/* ── Boot ── */` comment), add:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/* ── Trip stats / cycling computation (trip + home-active) ───
|
||||||
|
config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
|
||||||
|
No-op if #stat-distance is absent (page rendered no stats panel).
|
||||||
|
No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0'). */
|
||||||
|
function initTripStats(config) {
|
||||||
|
var distEl = document.getElementById('stat-distance');
|
||||||
|
if (!distEl) return;
|
||||||
|
|
||||||
|
var gpxUrls = config.gpxUrls || [];
|
||||||
|
var gpsPoints = config.gpsPoints || [];
|
||||||
|
|
||||||
|
if (config.hasGpx) {
|
||||||
|
MapUtils.parseGpxFiles(gpxUrls, function (result) {
|
||||||
|
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
|
||||||
|
function setText(id, val) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
|
||||||
|
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
|
||||||
|
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
|
||||||
|
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
|
||||||
|
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
|
||||||
|
setText('cyc-moving-time', result.movingTime || '—');
|
||||||
|
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (gpsPoints.length < 2) {
|
||||||
|
distEl.textContent = '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var total = 0;
|
||||||
|
for (var i = 1; i < gpsPoints.length; i++) {
|
||||||
|
total += MapUtils.haversineKm(
|
||||||
|
parseFloat(gpsPoints[i-1][0]), parseFloat(gpsPoints[i-1][1]),
|
||||||
|
parseFloat(gpsPoints[i][0]), parseFloat(gpsPoints[i][1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
distEl.textContent = '~' + Math.round(total).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.initTripStats = initTripStats;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the function is **not** added to the `DOMContentLoaded` boot block — it is called per-page from the partial's inline script (Task 2) with page-specific config. `window.initTripStats =` is required because `main.js` is bundled as an IIFE, so the function is otherwise not reachable from inline template scripts.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rebuild the JS bundle**
|
||||||
|
|
||||||
|
Run: `make build-assets`
|
||||||
|
Expected: completes without esbuild errors; `user/themes/intotheeast/js/main.js` is regenerated.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the function is exposed in the built bundle**
|
||||||
|
|
||||||
|
Run: `grep -c "initTripStats" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/main.js`
|
||||||
|
Expected: a non-zero count (the minified bundle contains the symbol).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Smoke-test that existing pages still work (no regression from the additive change)**
|
||||||
|
|
||||||
|
Load `http://localhost:8081/trips/japan-korea-2026` (or the active trip) in a browser. The trip page still uses its own inline stats script at this point, so stats should populate exactly as before. Open the console and confirm **no errors** and that `typeof window.initTripStats === 'function'`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||||
|
git add js/src/main.js js/main.js
|
||||||
|
git commit -m "feat(theme): add shared initTripStats() for trip+home stats panels"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Shared partial `trip-feed-col.html.twig`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (params, passed via `{% include 'partials/trip-feed-col.html.twig' with {…} only %}`):
|
||||||
|
|
||||||
|
| Param | Type | Trip passes | Home-active passes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `trip_page` | Page | `page` | `trip` |
|
||||||
|
| `all_items` | array | sorted by date, flag 4 | sorted by date, flag 3 |
|
||||||
|
| `journal_entries` | array | dailies children | dailies children |
|
||||||
|
| `journal_count` | int | count | count |
|
||||||
|
| `story_count` | int | count | count |
|
||||||
|
| `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` |
|
||||||
|
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||||
|
| `gps_points` | array | `gps_points` | `gps_points` (new on home, Task 4) |
|
||||||
|
| `show_sort` | bool | `true` | `false` |
|
||||||
|
| `pre_departure` | bool | `false` | `all_items\|length == 0` |
|
||||||
|
|
||||||
|
`gpx_urls` and `gps_points` are added to the spec's interface table as the agreed implementation choice: the partial emits the `initTripStats` inline call itself (single place), so it needs the page-specific data.
|
||||||
|
- Consumes globally: `window.initTripStats` (Task 1), `window.MapUtils` (map.js), CSS classes from the existing theme.
|
||||||
|
- Produces: the `.home-feed-col` DOM that `initFilterBar` / `initPanelToggles` / `initSortButton('trip-sort-toggle', …)` already key off (`.trip-filter-btn`, `[data-type]`, `.trip-panel-toggle`, `#feed-filter-empty`, `#trip-sort-toggle`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the partial file**
|
||||||
|
|
||||||
|
Create `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` with exactly:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% import 'macros/stats.html.twig' as stats_m %}
|
||||||
|
{% import 'macros/cycling.html.twig' as cycling_m %}
|
||||||
|
<div class="home-feed-col">
|
||||||
|
{% if pre_departure %}
|
||||||
|
{# ── Pre-departure landing state (home-active only) ──────────── #}
|
||||||
|
<div class="home-trip-header">
|
||||||
|
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
||||||
|
{% if trip_page.header.date_start %}
|
||||||
|
<p class="trip-dates">Departing {{ trip_page.header.date_start|date('d M Y') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<span class="home-trip-counts">Coming soon</span>
|
||||||
|
</div>
|
||||||
|
<div class="feed">
|
||||||
|
<hr class="home-predeparture-divider">
|
||||||
|
<p class="feed-empty">The journey hasn't begun yet — check back once we're on the road.</p>
|
||||||
|
<div class="home-highlights-cta-wrap">
|
||||||
|
<a class="home-highlights-cta" href="/trips">In the meantime, explore my other trips →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="home-trip-header">
|
||||||
|
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
||||||
|
{% if trip_page.header.date_start %}
|
||||||
|
<p class="trip-dates">
|
||||||
|
{{ trip_page.header.date_start|date('d M Y') }}
|
||||||
|
{% if trip_page.header.date_end %} — {{ trip_page.header.date_end|date('d M Y') }}{% else %} — Ongoing{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<span class="home-trip-counts">
|
||||||
|
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
|
||||||
|
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="trip-filter-bar">
|
||||||
|
<div class="trip-filter-group">
|
||||||
|
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
|
||||||
|
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
|
||||||
|
</div>
|
||||||
|
{% if show_sort %}
|
||||||
|
<button class="trip-stats-btn" id="trip-sort-toggle" aria-label="Sort: oldest first">↑</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="trip-panel-toggles">
|
||||||
|
<button class="trip-panel-toggle" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
|
||||||
|
{% if has_gpx %}
|
||||||
|
<button class="trip-panel-toggle" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ stats_m.stats_panel(journal_entries, trip_page, journal_count, has_gpx) }}
|
||||||
|
|
||||||
|
{% if has_gpx %}
|
||||||
|
{{ cycling_m.cycling_panel() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="feed">
|
||||||
|
{% if all_items|length > 0 %}
|
||||||
|
{% for item in all_items %}
|
||||||
|
{% set entry = item.page %}
|
||||||
|
{% if item.type == 'journal' %}
|
||||||
|
{% include 'partials/entry-journal.html.twig' %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'partials/entry-story.html.twig' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initTripStats({
|
||||||
|
gpxUrls: {{ gpx_urls|json_encode|raw }},
|
||||||
|
gpsPoints: {{ gps_points|json_encode|raw }},
|
||||||
|
hasGpx: {{ has_gpx ? 'true' : 'false' }}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes baked into this markup:
|
||||||
|
- The non-pre-departure feed keeps the `{% else %}` "No entries yet" fallback so the trip page's empty-case output is unchanged (trip always passes `pre_departure: false`). Home never reaches this fallback because home-empty sets `pre_departure: true`.
|
||||||
|
- The `initTripStats` call is wrapped in `DOMContentLoaded` so `window.initTripStats` and `window.MapUtils` (both in the `bottom` asset group rendered at the end of `<body>`) are defined when it runs.
|
||||||
|
- The call is **not** nested inside any map-entries condition, so a trip with GPX but zero geocoded journal entries still populates the panels.
|
||||||
|
- The partial is included with `only`, so it imports the `stats`/`cycling` macros itself.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify Twig syntax compiles (no include yet, so render via a temporary check)**
|
||||||
|
|
||||||
|
The partial isn't referenced anywhere yet, so it can't render on its own. Verify there are no obvious Twig errors by confirming the file is well-formed:
|
||||||
|
|
||||||
|
Run: `grep -c "endif\|endfor\|endmacro" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
|
||||||
|
Expected: non-zero (sanity check the file saved). Real verification happens in Task 3 when the trip page includes it.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||||
|
git add templates/partials/trip-feed-col.html.twig
|
||||||
|
git commit -m "feat(theme): add shared trip-feed-col partial"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Refactor `trip.html.twig` to use the partial
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig` (replace `:70-120`; remove `:213-249`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
|
||||||
|
- Produces: visually and functionally identical trip-page output (regression-critical) — exact bytes may differ (re-indented markup, relocated stats `<script>`); confirm via the behavioral checks in Step 3, not a literal diff. The trip page already builds `all_items` (flag 4), `journal_entries`, `journal_count`, `story_count`, `gps_points`, `gpx_urls`, `has_gpx` — all passed straight through.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the inline `.home-feed-col` block with the include**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/trip.html.twig`, replace the entire block from line 70 (` <div class="home-feed-col">`) through line 120 (` </div>`, the closing of `.home-feed-col`) with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% include 'partials/trip-feed-col.html.twig' with {
|
||||||
|
trip_page: page,
|
||||||
|
all_items: all_items,
|
||||||
|
journal_entries: journal_entries,
|
||||||
|
journal_count: journal_count,
|
||||||
|
story_count: story_count,
|
||||||
|
has_gpx: has_gpx,
|
||||||
|
gpx_urls: gpx_urls,
|
||||||
|
gps_points: gps_points,
|
||||||
|
show_sort: true,
|
||||||
|
pre_departure: false
|
||||||
|
} only %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the surrounding `<div class="home-layout">` and `.home-map-col` block (lines 58–68) and the closing `</div>` of `.home-layout` (line 121) intact.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the inline stats script**
|
||||||
|
|
||||||
|
In the same file, delete the inline stats block — from line 213 (`var STATS_GPS = …`) through line 249 (the closing `})();` of the stats IIFE), inclusive. Specifically remove:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
var STATS_GPS = {{ gps_points|json_encode|raw }};
|
||||||
|
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var distEl = document.getElementById('stat-distance');
|
||||||
|
|
||||||
|
if (HAS_GPX) {
|
||||||
|
MapUtils.parseGpxFiles(GPX_URLS, function(result) {
|
||||||
|
...
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var total = 0;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
The map `<script>`'s `document.addEventListener('DOMContentLoaded', function() { … });` wrapper and its closing `}); // DOMContentLoaded` (line 251) stay — only the stats portion inside it is removed. The map setup, marker loop, fitBounds, `renderGpxJourney`, and the fullscreen IIFE (`:201-211`) remain untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reload and regression-test the trip page**
|
||||||
|
|
||||||
|
Load `http://localhost:8081/trips/japan-korea-2026` (active trip with content). Confirm:
|
||||||
|
- Header, date range, counts render as before.
|
||||||
|
- Filter bar **with** the sort button (`↑`) is present.
|
||||||
|
- Stats panel toggles open; distance populates (GPX → exact number; no GPX → `~`-prefixed estimate).
|
||||||
|
- If the trip has GPX: Cycling toggle present and its panel populates.
|
||||||
|
- Feed lists journal + stories, default order oldest→newest (flag 4, unchanged).
|
||||||
|
- Filter All/Journal/Stories works; sort button flips order.
|
||||||
|
- Console shows no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the map is unaffected**
|
||||||
|
|
||||||
|
On the same page, confirm the map renders with markers, fits bounds, draws the GPX/journey route, and the mobile fullscreen button still works (resize on toggle). Marker click still scrolls to and flashes the card.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||||
|
git add templates/trip.html.twig
|
||||||
|
git commit -m "refactor(theme): trip.html.twig uses shared trip-feed-col partial + initTripStats"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Wire `home.html.twig` active branch to the partial
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch: add `gps_points` build at `:30-31`; replace `:60-83`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
|
||||||
|
- Produces: home-active now renders date range, filter bar (no sort button), and stats/cycling panels, plus the pre-departure block when no entries exist. The between-trips branch (`{% else %}`) is untouched and does not use the partial.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `gps_points` build (no-GPX stats fallback)**
|
||||||
|
|
||||||
|
In `user/themes/intotheeast/templates/home.html.twig`, in the active-trip branch, after the counts at line 30 (`{% set story_count = story_entries|length %}`) and before the `map_entries` build (line 32), insert:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
|
||||||
|
{% set gps_points = [] %}
|
||||||
|
{% for entry in journal_entries %}
|
||||||
|
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
|
||||||
|
{% set gps_points = gps_points|merge([[entry.header.lat, entry.header.lng]]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors `trip.html.twig:27-32`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the bespoke feed-col with the include**
|
||||||
|
|
||||||
|
In the same file, replace the entire `<div class="home-feed-col">` block from line 60 through its closing `</div>` at line 83 with:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{% include 'partials/trip-feed-col.html.twig' with {
|
||||||
|
trip_page: trip,
|
||||||
|
all_items: all_items,
|
||||||
|
journal_entries: journal_entries,
|
||||||
|
journal_count: journal_count,
|
||||||
|
story_count: story_count,
|
||||||
|
has_gpx: home_gpx_urls|length > 0,
|
||||||
|
gpx_urls: home_gpx_urls,
|
||||||
|
gps_points: gps_points,
|
||||||
|
show_sort: false,
|
||||||
|
pre_departure: all_items|length == 0
|
||||||
|
} only %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `<div class="home-layout">` and the `.home-map-col` block (lines 55–58) and the closing `</div>` of `.home-layout` (line 84) intact. The map `<script>` block (lines 86–139, gated by `map_entries|length > 0`) stays untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reload and test home-active (with content)**
|
||||||
|
|
||||||
|
Ensure `config.site.travelling: true` and the active trip has posts. Load `http://localhost:8081/`. Confirm:
|
||||||
|
- Date range, counts, and filter bar appear — **no** sort button.
|
||||||
|
- Stats panel toggles open and distance populates (`~` estimate from `gps_points` when no GPX; exact when GPX present); Cycling panel appears and populates only if the trip has GPX.
|
||||||
|
- Filter All/Journal/Stories works; panel toggles work.
|
||||||
|
- Feed default order is home's own (flag 3, unchanged from today).
|
||||||
|
- Console shows no errors; map still renders.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test the pre-departure empty state**
|
||||||
|
|
||||||
|
With `travelling: true` and **no posts** in the active trip's `dailies`/`stories` (temporarily, or on a fresh trip), load `/`. Confirm:
|
||||||
|
- The pre-departure block shows the trip title, "Departing <date>", "Coming soon", a divider, and the "In the meantime, explore my other trips →" button linking to `/trips`.
|
||||||
|
- The filter bar, panel toggles, and the generic "No entries yet" fallback do **not** appear.
|
||||||
|
- After posting one entry (or restoring content), the pre-departure block disappears and the normal filter bar + feed render.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Regression-test between-trips mode**
|
||||||
|
|
||||||
|
Set `config.site.travelling: false`, load `/`. Confirm the highlights grid layout is unchanged (this branch does not use the partial). Restore `travelling: true` afterward if that is the intended dev state.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||||
|
git add templates/home.html.twig
|
||||||
|
git commit -m "feat(theme): home-active reuses trip-feed-col partial with stats + pre-departure state"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Date-range header, filter bar, stats/cycling panels on home-active → Tasks 2 + 4. ✅
|
||||||
|
- Chrome in one place (partial) → Task 2; both pages include it → Tasks 3, 4. ✅
|
||||||
|
- Home keeps own order, no sort button → `show_sort: false`, `all_items` flag 3 unchanged (Task 4). ✅
|
||||||
|
- Stats/cycling from single shared JS → Task 1 (`initTripStats`), called via partial. ✅
|
||||||
|
- No visual/functional change to trip output (exact bytes may differ: re-indented markup, relocated stats `<script>`) → Task 3 passes through existing vars; partial preserves the empty-case `{% else %}` fallback. ✅
|
||||||
|
- Stats glue runs in `DOMContentLoaded`, not nested in map block, `<2`-points guard writes `—` → Task 1 + partial script. ✅
|
||||||
|
- Home `gps_points` build added → Task 4 Step 1. ✅
|
||||||
|
- Pre-departure block (title + start date + "Coming soon" + divider + button; suppresses filter bar/fallback; panels hidden) → Task 2 markup + Task 4 gating. ✅
|
||||||
|
- Map convergence out of scope; both map blocks untouched → Tasks 3, 4 leave map markup/scripts intact. ✅
|
||||||
|
- Between-trips branch untouched → Task 4 only edits the active branch. ✅
|
||||||
|
|
||||||
|
**Placeholder scan:** No TBD/TODO/"handle edge cases" — every step has concrete code or an exact command. ✅
|
||||||
|
|
||||||
|
**Type consistency:** `initTripStats` config keys (`gpxUrls`, `gpsPoints`, `hasGpx`) match between Task 1 (definition), the partial's inline call (Task 2), and the data both pages pass (Tasks 3, 4). Partial param names match the include calls in both templates. `gpx_urls`/`gps_points`/`has_gpx` consistent throughout. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Plan complete and saved to `docs/working/plans/2026-06-27-home-trip-view-convergence.md`. Two execution options:**
|
||||||
|
|
||||||
|
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
|
||||||
|
|
||||||
|
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
|
||||||
|
|
||||||
|
Which approach?
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# Map Init Consolidation — shared `MapUtils.initEntryMap()`
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-06-27)
|
||||||
|
|
||||||
|
> Plan type: `refactor` · Depth: Standard · Origin: deferred memory `project-map-init-refactor` (re-scoped 2026-06-27 after home/trip convergence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The recent home/trip convergence work multiplied an already-duplicated pattern: there are now **five** near-identical MapLibre init blocks, each repeating ~50–130 lines of map construction, marker/popup loop, bounds-fitting, journey rendering, and fullscreen wiring. The shared `maplibre-utils.js` already centralizes marker *creation* and GPX/journey rendering, but **not the init orchestration** — that is what is copy-pasted and has since drifted into subtly inconsistent behavior.
|
||||||
|
|
||||||
|
This plan extracts the init orchestration into one config-driven function, `MapUtils.initEntryMap(opts)`, in `user/themes/intotheeast/js/maplibre-utils.js` (which esbuild already bundles into `js/map.js`, so every template that loads `map.js` picks it up). The two actively-used surfaces — the **trip page** and the **home active-trip view** — are converted to call it and become behaviorally identical. The **home highlights (between-trips) view** is converted too, with a deliberate small UX change: marker click navigates to the article instead of scrolling to a grid card (hover-title already exists). The dormant `feed-map.html.twig` partial and `map.html.twig` full-page map are **left untouched** this pass.
|
||||||
|
|
||||||
|
This is a refactor with two intentional, scoped behavior changes (both on the home page) — not byte-for-byte preservation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
`maplibre-utils.js` gives every map the same building blocks (`createDotMarker`, `createStoryMarker`, `renderGpxJourney`, `MAP_STYLE`), but each template still hand-writes the *assembly*: `new maplibregl.Map(...)`, attribution control, the `on('load')` marker loop with hover popup + click handler, `fitBounds`/`jumpTo`, and the fullscreen toggle IIFE. Five copies exist:
|
||||||
|
|
||||||
|
| Surface | Container | Marker click (today) | In active use? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `trip.html.twig` | `#trip-map` | scroll+flash `entry-` card, fullscreen-aware | ✅ active |
|
||||||
|
| `home.html.twig` active branch | `#home-map` | set hash to `entry-` card, **no flash, not fullscreen-aware** | ✅ active |
|
||||||
|
| `home.html.twig` highlights branch | `#home-map` | `scrollIntoView` to `highlight-` card | ✅ active |
|
||||||
|
| `partials/feed-map.html.twig` | `#feed-map` / `#stories-map` | scroll+flash else navigate, fullscreen-aware | ⚠️ dormant (dailies/stories) |
|
||||||
|
| `map.html.twig` | `#trip-map` (full page) | always navigate to URL | ⚠️ dormant |
|
||||||
|
|
||||||
|
**Consequences of the duplication:**
|
||||||
|
- The trip page and home active view are meant to be the same component but have already drifted (home active lacks the flash highlight and the fullscreen button trip has).
|
||||||
|
- Any future map change must be applied in up to five places, by hand, with no shared test surface.
|
||||||
|
- The click logic carries five subtly different implementations of just **two** real intents: *scroll to the matching card on this page*, or *navigate to the entry's own page*.
|
||||||
|
|
||||||
|
**Why now:** The deferral in `project-map-init-refactor` was justified by "dailies/stories/full-map aren't in active use." That still holds for those three — but trip and home are now both active and nearly identical, so the high-value consolidation is unblocked while the dormant surfaces stay out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- New `MapUtils.initEntryMap(opts)` in `maplibre-utils.js` + esbuild rebuild.
|
||||||
|
- Convert `trip.html.twig` to call it (behavior preserved).
|
||||||
|
- Convert `home.html.twig` active branch to call it + add a fullscreen button so it matches the trip page exactly.
|
||||||
|
- Convert `home.html.twig` highlights branch to call it (click → navigate to article).
|
||||||
|
|
||||||
|
**Intentional behavior changes (both home page only):**
|
||||||
|
- Home active view **gains** the flash-highlight on card scroll and the fullscreen button/awareness it currently lacks → becomes identical to the trip page.
|
||||||
|
- Home highlights view marker click **changes** from `scrollIntoView` to the grid card → navigate to the article URL. Hover-title popup is unchanged (already present).
|
||||||
|
- **Both home maps' attribution restyles.** `initEntryMap` always constructs with `attributionControl: false` + a compact `AttributionControl` bottom-left, collapsed on load (mirroring trip). The home active and home highlights maps currently use MapLibre's default attribution (expanded, bottom-right), so both move to trip's compact collapsed bottom-left. For home active this is part of "identical to trip"; for home highlights — which is otherwise unchanged except for the click behavior — it is an *incidental* restyle. If highlights should keep the default attribution, parameterize attribution in `opts` (e.g. `attribution: { compact, position, collapse }`) rather than baking trip's treatment into every caller.
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
- Converting `partials/feed-map.html.twig` (dailies/stories) onto `initEntryMap`. It is already a shared partial with low duplication cost and the pages are dormant; retrofit it when those feeds return to active use. The unified click rule already matches feed-map's current behavior, so this will be a near-drop-in later.
|
||||||
|
- Converting `map.html.twig` (full-page map) onto `initEntryMap`. Dormant; navigate-only behavior is the `cardPrefix: null` path, so it too will be a clean later conversion.
|
||||||
|
|
||||||
|
**Out of scope:** no CSS changes, no map-style change, no change to the Twig-side `map_entries`/`gpx_urls` computation, no change to `createDotMarker`/`createStoryMarker`/`renderGpxJourney`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
**KTD1 — Init orchestration lives in `maplibre-utils.js`, not per-template.**
|
||||||
|
`maplibre-utils.js` is imported by `js/src/map.js` and bundled by esbuild into the minified `js/map.js` that every map-bearing template loads via `{% block map_assets %}`. Adding `initEntryMap` there + rebuilding makes it available everywhere with a single source of truth. This is the whole point of the refactor.
|
||||||
|
|
||||||
|
**KTD2 — One unified click rule, no click-mode enum.**
|
||||||
|
The function takes an optional `cardPrefix`. Click behavior is a single rule: *if `cardPrefix` is set and `document.getElementById(cardPrefix + slug)` exists, scroll to it (via `location.hash`) and flash `is-highlighted`, fullscreen-aware when a fullscreen target is configured; otherwise navigate to `entry.url`.* This single rule subsumes every behavior the in-scope surfaces need — trip and home active pass `cardPrefix: 'entry-'`; home highlights passes no prefix and gets navigate-on-click for free. It also happens to match the dormant feed-map/map.html behaviors, easing their later conversion. No `clickMode` parameter is introduced.
|
||||||
|
|
||||||
|
**Card-absent fallback — note the divergence from trip today.** The current trip handler does `if (!card) return;` (a no-op) when no card matches the slug; the unified rule instead **navigates** to `entry.url`. This is behavior-preserving on every in-scope surface **only under the invariant that every map marker has a matching feed card** (`entry-<slug>`, emitted by both the journal and story entry partials). Document that invariant where it is relied on (U2). If a future map entry can ever lack a feed card (a map-only POI, a new pin type), make the fallback per-surface — trip = no-op, highlights = navigate — rather than letting the shared default retroactively change trip's semantics.
|
||||||
|
|
||||||
|
**KTD3 — `map_entries` / `gpx_urls` stay computed in Twig.**
|
||||||
|
Per the Milestone 2 refactor decision, Twig macros cannot return arrays, so each template keeps its existing Twig loop that builds `map_entries` and serializes it to a JS var. The only change is replacing the inline init `<script>` body with a single `MapUtils.initEntryMap({...})` call inside `DOMContentLoaded`. Each template's map `<script>` shrinks from ~50–90 lines to ~10.
|
||||||
|
|
||||||
|
**KTD4 — Dormant surfaces excluded.**
|
||||||
|
`feed-map.html.twig` and `map.html.twig` keep their current inline init this pass (see Deferred). Reduces blast radius to the two active surfaces plus the home highlights view.
|
||||||
|
|
||||||
|
**KTD5 — Home active fullscreen button reuses existing CSS.**
|
||||||
|
The fullscreen button uses the same `.feed-map-fullscreen-btn` markup as the trip page, and the fullscreen target is `.home-map-col`. Both `.feed-map-fullscreen-btn` (`css/style.css:636`) and `.home-map-col.is-fullscreen` (`css/style.css:911`) already exist — no CSS changes required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
**Who calls the shared function (after this plan):**
|
||||||
|
|
||||||
|
```
|
||||||
|
maplibre-utils.js ── MapUtils.initEntryMap(opts) ──┐
|
||||||
|
(bundled into js/map.js) │
|
||||||
|
├─ trip.html.twig → cardPrefix:'entry-', fullscreen, story markers
|
||||||
|
├─ home.html.twig (active) → cardPrefix:'entry-', fullscreen
|
||||||
|
└─ home.html.twig (highlights) → no cardPrefix (→ navigate)
|
||||||
|
|
||||||
|
feed-map.html.twig / map.html.twig → unchanged (own inline init, deferred)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unified marker-click rule** (the single behavior the function implements):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Marker clicked] --> B{cardPrefix set AND<br/>card #prefix+slug exists?}
|
||||||
|
B -- no --> C[navigate to entry.url]
|
||||||
|
B -- yes --> D{fullscreen target<br/>configured AND open?}
|
||||||
|
D -- yes --> E[close fullscreen,<br/>then scroll+flash after delay]
|
||||||
|
D -- no --> F[scroll to hash,<br/>flash is-highlighted]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`initEntryMap(opts)` shape** (directional, not a signature spec):
|
||||||
|
|
||||||
|
| Option | Type | Used by | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `container` | string | all | map div id (`'trip-map'`, `'home-map'`) |
|
||||||
|
| `entries` | array | all | already-parsed `map_entries` from Twig |
|
||||||
|
| `cardPrefix` | string \| null | trip, home active | `'entry-'`; omit/null → markers navigate to `entry.url` |
|
||||||
|
| `storyMarkers` | bool | trip | render `createStoryMarker()` for `type === 'story'`; default dot markers |
|
||||||
|
| `markLatest` | bool | trip, home active | enlarge the final non-story entry's dot (default `true`); home highlights passes `false` so no marker is singled out in the shuffled set (added during execution — preserves highlights' current all-equal dots) |
|
||||||
|
| `fullscreen` | `{ btnId, colSelector }` \| null | trip, home active | wires the fullscreen toggle; null → no fullscreen |
|
||||||
|
| `gpx` | `{ urls, use, autoconnect, sourcePrefix, journeyId }` \| null | trip, home active | forwarded to `renderGpxJourney`; null → skip |
|
||||||
|
| `fit` | `{ padding, maxZoom, singleZoom }` | all | defaults `{60, 11, 10}`; highlights uses `maxZoom: 8`, `singleZoom: 8` |
|
||||||
|
|
||||||
|
The function returns the map instance and internally does: construct map (`attributionControl: false`) + compact `AttributionControl` bottom-left; on `load` build bounds, loop entries → marker + hover popup + unified click handler, `fitBounds`/`jumpTo`, collapse the attribution `<details>`, call `renderGpxJourney` when `gpx` is set; wire the fullscreen IIFE when `fullscreen` is set; `setTimeout(map.resize, 100)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Add `MapUtils.initEntryMap(opts)` to `maplibre-utils.js`
|
||||||
|
|
||||||
|
**Goal:** Introduce the config-driven init function and rebuild the bundle. No template consumes it yet.
|
||||||
|
|
||||||
|
**Dependencies:** none.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/js/maplibre-utils.js` (add `initEntryMap`, export it on the `global.MapUtils` object)
|
||||||
|
- Build artifact (regenerated, do not hand-edit): `user/themes/intotheeast/js/map.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Add `function initEntryMap(opts) { ... }` near the other public helpers; add `initEntryMap: initEntryMap` to the `global.MapUtils = { ... }` export block.
|
||||||
|
- Implement the full orchestration described in HTD: map construction, attribution control + collapse, the `on('load')` marker loop (hover popup identical to current `map-tip` popups; marker element via `createStoryMarker()` when `opts.storyMarkers && entry.type === 'story'`, else `createDotMarker(isLatest)` where `isLatest = (entry.type !== 'story') && (i === entries.length - 1)` — the final-indexed entry, and only when it is not a story, mirroring `trip.html.twig:110` exactly; note this enlarges *nothing* when the last entry is a story, which is the current trip behavior and must be preserved — do **not** reinterpret it as "the last non-story entry"), bounds fit (`fitBounds` with `opts.fit` defaults, `jumpTo` for single entry), `renderGpxJourney` when `opts.gpx`, the fullscreen toggle IIFE when `opts.fullscreen`, and the trailing `resize`.
|
||||||
|
- Implement KTD2's unified click rule exactly: resolve card by `opts.cardPrefix + entry.slug`; if absent → `window.location.href = entry.url`; if present → set `location.hash`, then after 350ms add `is-highlighted` for 700ms; when `opts.fullscreen` is configured and the col is `.is-fullscreen`, click the fullscreen button first and defer the scroll ~450ms (mirror the current trip handler timings).
|
||||||
|
- Keep the empty-entries behavior cheap: if `entries.length === 0`, still construct the map and return (the trip page's existing "no locations yet" copy is page-specific and stays in the template if needed — do not bake page copy into the util).
|
||||||
|
- Rebuild: run `make build-assets` so `js/map.js` regenerates from source. Never hand-edit `js/map.js`.
|
||||||
|
|
||||||
|
**Patterns to follow:** mirror the existing trip page handler (`trip.html.twig:100-171`) as the canonical behavior, since trip is the surface whose behavior is being preserved; reuse the existing module structure and IIFE export pattern already in `maplibre-utils.js`.
|
||||||
|
|
||||||
|
**Execution note:** This is the load-bearing unit. Implement it to faithfully reproduce the trip page's current behavior before any caller is switched, so U2 is behavior-preserving — a no-op under the card-matching invariant noted in KTD2; the card-absent navigate fallback is the one deliberate divergence and is unreachable on trip today.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- *Happy path (card present):* with a `cardPrefix` and a matching card in the DOM, clicking a marker sets `location.hash` to `prefix+slug` and toggles `is-highlighted` on the card (added after ~350ms, removed ~700ms later).
|
||||||
|
- *Happy path (no prefix):* with `cardPrefix` null/omitted, clicking a marker sets `window.location.href` to `entry.url`.
|
||||||
|
- *Fallback:* with a `cardPrefix` set but no matching card in the DOM, clicking navigates to `entry.url`.
|
||||||
|
- *Fullscreen-aware:* with `fullscreen` configured and the col `.is-fullscreen`, a marker click triggers the fullscreen button click first, then scrolls.
|
||||||
|
- *Bounds:* one entry → `jumpTo` at `fit.singleZoom`; multiple entries → `fitBounds` with `fit.padding`/`fit.maxZoom`.
|
||||||
|
- *Story markers:* with `storyMarkers: true` and an entry `type === 'story'`, a story marker is used and that entry is never treated as `isLatest`.
|
||||||
|
- *GPX:* with `gpx` set, `renderGpxJourney` is called with the forwarded urls/sourcePrefix/journeyId/connectMode; with `gpx` null it is not called.
|
||||||
|
- *Empty:* `entries: []` constructs the map without throwing and renders no markers.
|
||||||
|
- *Build:* after `make build-assets`, `js/map.js` is regenerated and `window.MapUtils.initEntryMap` is defined at runtime.
|
||||||
|
|
||||||
|
**Verification:** `MapUtils.initEntryMap` is exported and callable; `make build-assets` completes and updates `js/map.js`; no console errors when invoked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U2. Convert `trip.html.twig` to `initEntryMap` (behavior preserved)
|
||||||
|
|
||||||
|
**Goal:** Replace the trip page's inline ~85-line map `<script>` body with a single `initEntryMap` call; behavior is preserved — behaviorally equivalent under the card-matching invariant in KTD2 (not literally byte-for-byte, since the `<script>` body is rewritten and the card-absent fallback changes from no-op to navigate, which is unreachable on trip).
|
||||||
|
|
||||||
|
**Dependencies:** U1.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||||
|
|
||||||
|
**Approach:** Keep the Twig `map_entries`/`gpx_urls` computation and the `TRIP_ENTRIES`/`GPX_URLS`/`USE_GPX`/`AUTOCONNECT` JS var declarations. Replace everything inside `DOMContentLoaded` (the `new maplibregl.Map`, the `on('load')` loop, fit-bounds, `renderGpxJourney`, attribution collapse, the fullscreen IIFE, the trailing resize) with one call: `MapUtils.initEntryMap({ container: 'trip-map', entries: TRIP_ENTRIES, cardPrefix: 'entry-', storyMarkers: true, fullscreen: { btnId: 'trip-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'gpx', journeyId: 'trip-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`. Leave the fullscreen button markup and the `#trip-totop` button as-is.
|
||||||
|
|
||||||
|
**Patterns to follow:** existing `trip.html.twig` markup and var names; the include-call style already used for partials.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- *Markers + hover:* trip page renders one dot per entry plus story markers; hovering shows the `map-tip` title popup (unchanged).
|
||||||
|
- *Click → scroll+flash:* clicking a marker scrolls to its `entry-<slug>` feed card and flashes it.
|
||||||
|
- *Fullscreen:* the fullscreen button still expands `.home-map-col` and the marker-click-while-fullscreen path still closes then scrolls.
|
||||||
|
- *GPX:* GPX tracks + journey segments still render when `use_gpx` is on.
|
||||||
|
- *Regression:* visual diff against pre-change trip page shows no behavioral difference.
|
||||||
|
|
||||||
|
**Verification:** trip page at `localhost:8081/trips/<active_trip>` behaves identically to before — markers, popups, click-scroll-flash, fullscreen, GPX all intact; no console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U3. Convert `home.html.twig` active-trip branch + add fullscreen button (match trip page)
|
||||||
|
|
||||||
|
**Goal:** The home active-trip map becomes behaviorally identical to the trip page — it gains the flash-highlight and a working fullscreen button it currently lacks.
|
||||||
|
|
||||||
|
**Dependencies:** U1. Independent of U2.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch markup + script)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Markup: add the fullscreen button inside the active branch's `<div class="home-map" id="home-map">` (currently `home.html.twig:64`), reusing the exact `.feed-map-fullscreen-btn` markup from `trip.html.twig:61-67` with `id="home-map-fullscreen"`. No CSS changes (KTD5).
|
||||||
|
- Script: keep the `HOME_ENTRIES`/`HOME_GPX_URLS`/`USE_GPX`/`AUTOCONNECT` var declarations; replace the inline `new maplibregl.Map` + `on('load')` body with `MapUtils.initEntryMap({ container: 'home-map', entries: HOME_ENTRIES, cardPrefix: 'entry-', fullscreen: { btnId: 'home-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: HOME_GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'home-gpx', journeyId: 'home-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`.
|
||||||
|
- Note: `storyMarkers` is omitted (home active currently uses dot markers only — preserved).
|
||||||
|
|
||||||
|
**Patterns to follow:** the trip page conversion (U2) and the trip fullscreen button markup.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- *Parity:* home active map renders markers, hover popups, and click-scroll **with flash** (previously no flash) to `entry-<slug>` cards.
|
||||||
|
- *Fullscreen (new):* the new `home-map-fullscreen` button expands `.home-map-col`, and a marker click while fullscreen closes then scrolls — matching trip.
|
||||||
|
- *GPX:* home GPX journey still renders (`home-gpx` / `home-journey` source ids preserved).
|
||||||
|
- *No story markers:* dot markers only, as before.
|
||||||
|
|
||||||
|
**Verification:** home page (travelling/active state) map matches the trip page in every interaction; fullscreen button visible and functional on mobile widths; no console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U4. Convert `home.html.twig` highlights branch (click → navigate)
|
||||||
|
|
||||||
|
**Goal:** The between-trips highlights map uses the shared init, and marker click opens the article instead of scrolling to a grid card. Hover-title popup unchanged.
|
||||||
|
|
||||||
|
**Dependencies:** U1. Lands naturally alongside U3 (same file) but is a distinct behavior change.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `user/themes/intotheeast/templates/home.html.twig` (highlights branch script, `home.html.twig:245-289`)
|
||||||
|
|
||||||
|
**Approach:** Keep the `HIGHLIGHTS_ENTRIES` var declaration. Replace the inline `new maplibregl.Map` + `on('load')` body (including the current `scrollIntoView` click handler) with `MapUtils.initEntryMap({ container: 'home-map', entries: HIGHLIGHTS_ENTRIES, fit: { padding: 60, maxZoom: 8, singleZoom: 8 } })` — no `cardPrefix`, no `fullscreen`, no `gpx`. The absent `cardPrefix` yields navigate-on-click per KTD2; the `map-tip` hover popup is provided by the shared loop, so hover-title is preserved with no extra code. Note: this also restyles the highlights map's attribution to trip's compact collapsed bottom-left (see Scope Boundaries → "Both home maps' attribution restyles") — an incidental change; pass an attribution `opts` override if the MapLibre default should be retained here.
|
||||||
|
|
||||||
|
**Patterns to follow:** the navigate path of the unified click rule (KTD2).
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- *Hover:* hovering a highlights marker shows the article title popup (preserved).
|
||||||
|
- *Click → navigate:* clicking a highlights marker navigates to `entry.url` (changed from `scrollIntoView`).
|
||||||
|
- *Bounds:* highlights map still fits at the wider zoom (`maxZoom`/`singleZoom` 8).
|
||||||
|
- *No fullscreen / no GPX:* no fullscreen button appears and no GPX journey renders on the highlights map.
|
||||||
|
|
||||||
|
**Verification:** between-trips home state shows the highlights map; hovering a pin shows its title, clicking it opens the article; no console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **Regression on the two live surfaces.** Trip and home active are the primary UI. Mitigation: U2 is a strict behavior-preserving change verified by visual diff; U1 is built to reproduce the trip handler exactly before any caller switches. Check existing Playwright map coverage (see `docs/working/plans/2026-06-22-align-maps-tests.md` / `2026-06-21-playwright-tests.md`) and run it after U2–U4.
|
||||||
|
- **Stale bundle.** `js/map.js` is generated; forgetting `make build-assets` ships old behavior. Mitigation: U1 explicitly includes the rebuild and a runtime check that `MapUtils.initEntryMap` is defined.
|
||||||
|
- **Duplicate element id.** The new `home-map-fullscreen` button must exist only in the active branch (highlights branch has no fullscreen). The two `#home-map` containers are already in mutually-exclusive Twig branches, so no real-DOM collision occurs.
|
||||||
|
- **Sequencing:** U2, U3, U4 all depend only on U1. U3 and U4 touch the same file and will typically land in one commit.
|
||||||
|
- **Known limitation — filter-hidden card click (accepted).** The unified rule keys on the card *existing* (`getElementById`), not on it being visible. When the feed filter bar (All/Journal/Stories) has hidden the target card (`display:none`), a marker click sets the hash and flashes an off-screen card, so the map appears unresponsive. This is a pre-existing rough edge being carried forward deliberately (not a regression introduced here) — accepted as-is for this pass rather than adding filter-reset or navigate-fallback handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Strategy
|
||||||
|
|
||||||
|
1. After U1: `make build-assets` succeeds; `js/map.js` updated; `window.MapUtils.initEntryMap` defined.
|
||||||
|
2. After U2: trip page (`/trips/<active_trip>`) — markers, hover, click-scroll-flash, fullscreen, GPX all unchanged.
|
||||||
|
3. After U3: home active state — identical to trip, including the new fullscreen button and flash.
|
||||||
|
4. After U4: home between-trips state — hover-title + click-to-open; wider zoom; no fullscreen/GPX.
|
||||||
|
5. Run existing Playwright map tests; confirm no new failures.
|
||||||
|
6. Confirm net line reduction across `trip.html.twig` + `home.html.twig` (the duplication is gone) and that `maplibre-utils.js` is the single source of init truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Outcome (2026-06-27)
|
||||||
|
|
||||||
|
All four units landed. `MapUtils.initEntryMap(opts)` added to `maplibre-utils.js` and bundled via `make build-assets`; `trip.html.twig`, both `home.html.twig` branches converted. Two notes from execution:
|
||||||
|
|
||||||
|
- **`markLatest` opt added** — the highlights branch rendered all dots equal (`createDotMarker(false)`), but the shared `isLatest = (i === length-1)` would have enlarged the last (shuffled) highlight. Added a `markLatest` flag (default `true`; highlights passes `false`) to preserve that.
|
||||||
|
- **Map instance exposed as `window.tripMap` / `window.homeMap`** — `initEntryMap` returns the map, and the templates assign it to these globals. This is the affordance the existing Playwright specs (M7, M8) already assumed; wiring it up turned two perma-failing tests green, giving real regression coverage on the converted surfaces.
|
||||||
|
|
||||||
|
**Test status:** `tests/ui/maps`, `tests/ui/home`, `tests/ui/trip`, `tests/ui/gpx` — 38 passed. Remaining failures are pre-existing and out of scope: **M6** asserts `window.map` on the deferred `map.html.twig` (untouched this pass); **H1** is a parallel-load timing flake (passes 4/4 in isolation). Both fail identically on the pre-refactor baseline.
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- Origin: memory `project-map-init-refactor` (deferral), re-scoped after `project-homepage-redesign` / home-trip convergence.
|
||||||
|
- Milestone 2 refactor decision that `map_entries` stays Twig-side: memory `project-template-refactor-milestone2`, plan `docs/working/plans/2026-06-23-template-refactor.md`.
|
||||||
|
- Current behavior read directly from: `trip.html.twig:83-174`, `home.html.twig:87-139` (active) and `:245-289` (highlights), `partials/feed-map.html.twig`, `map.html.twig`, `js/maplibre-utils.js`, build config `package.json` `build` script.
|
||||||
|
- No external research — strong local patterns; behavior is fully specified by the existing code.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
---
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
execution: code
|
||||||
|
product_contract_source: ce-brainstorm
|
||||||
|
title: Front-End Journal Entry Edit - Plan
|
||||||
|
date: 2026-07-04
|
||||||
|
---
|
||||||
|
|
||||||
|
# Front-End Journal Entry Edit - Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-07-08) — M1 (U1–U6) complete & verified (V1–V7). M2 **partially delivered** (2026-07-05): **U7 (load existing photos into FilePond) + remove + reorder** are implemented and verified end-to-end on the :8091 container — V9 (photos load, cover-ordered) and V10 (remove a photo, reorder so a different image is the cover; on-disk `photo-1..N` renumber) both pass; reconcile helpers also covered by a reflection unit test (4 cases). One real bug found & fixed en route: `onFormProcessed` fires once per `process:` action (4×), so photo reconciliation is now latched to run **once** (a 2nd pass deleted the just-renamed `photo-N` files). Changes are in `cache-on-save.php` (edit-aware reconcile) + `post-form.js` (U7 load, D1 disable-sweep excludes the FilePond field). **R9 (add NEW photos on edit) now WORKS (2026-07-05)** via a local patch to add-page-by-form. Root cause: its edit-mode merge read existing frontmatter with `(array)$page->header()`, but Grav 2.0's `Grav\Common\Page\Header` keeps data in a protected `items`, so the cast mangled keys (`\0*\0items`) and `$original_frontmatter['photos']` was never set → `array_merge(null,…)` TypeError on any edit that uploads a file. Fix: use `Header::toArray()` (clean keys) + guard the per-field merge. add-page-by-form is abandoned upstream (last release Sept 2023) and its dir is **git-ignored/GPM-managed**, so the patch is tracked as `deploy/patches/add-page-by-form-grav2-header.patch` and re-applied via `make apply-plugin-patches` after any plugin reinstall — until the plugin is forked. Verified end-to-end on :8091: add a photo, remove one, reorder, and all three combined in one save (cover=first, existing preserved, dropped removed); create-with-photos and edit remove/reorder regressions still pass. (Grav 2.0.7 does **not** fix this on its own — the Header object is unchanged across the patch; only the plugin fix does.) **Code-review complete (2026-07-07)** — the multi-agent review of the branch ran and all findings (F1–F8) were applied & verified (20/20 post specs on :8091); the review's own PERF finding confirmed and hardened the once-per-submit cache latch noted above. **Landed 2026-07-08:** merged to `main` in both repos with `feat/journal-post-form`, pin bumped, and content pushed to Gitea → prod (outer pin `f4ab730` == `user/` `main` == `origin/main`). Owner-session UI QA and on-device touch-drag (Part B of `docs/working/handovers/2026-07-07-journal-post-form-review-handover-and-qa.md`) both passed 2026-07-08.
|
||||||
|
|
||||||
|
## Goal Capsule
|
||||||
|
|
||||||
|
- **Objective:** Let the site owner edit, delete, and unpublish/publish journal entries directly from the front-end feed — reusing the existing `/post` form and the `add-page-by-form` plugin's native edit mode — without touching the Admin2 backend.
|
||||||
|
- **Product authority:** Mischa (site owner, sole author).
|
||||||
|
- **Open blockers:** None blocking. Two planning-time details flagged under Outstanding Questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
### Actors
|
||||||
|
- **Owner** (authenticated via the existing `site.login` gate) — the only actor who can edit, delete, or change publish state. Everything below is gated to this actor.
|
||||||
|
- **Public visitor** (unauthenticated) — sees only published entries; never sees edit/delete controls or drafts.
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Correcting a typo, fixing metadata, reordering photos, or shelving a half-written entry currently means logging into Admin2 and navigating the page tree. The owner wants to do all of it inline, from the same feed where the entries already live, on the same phone-friendly form used to post them.
|
||||||
|
|
||||||
|
### What we're building
|
||||||
|
Edit/delete/publish controls that live on the **journal feed cards of the active trip** (its trip page and the home active-trip feed, both rendered by the shared `partials/trip-feed-col.html.twig`). There is **no detail-page route** involved — the feed already renders each entry's full body inline, so the card is the surface. Delivered in two milestones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Milestone 1 — Edit, delete & publish-state from the feed cards
|
||||||
|
|
||||||
|
**Photos are untouched in M1** (the entry keeps its existing images exactly as-is).
|
||||||
|
|
||||||
|
- **R1 — Edit control.** Each journal card shows an **Edit** control when the owner is logged in. The Edit control **navigates to the post form** at `/post?edit=<entry-path>` (a query param carrying the entry's path) — a plain redirect to the existing full-page `/post` surface, not a modal or inline card expansion. The form loads prefilled with the entry's current values: title, date, content, lat, lng, location_city, location_country, weather_desc, weather_temp_c, transport_mode, featured, force_connect, published.
|
||||||
|
- **R2 — Save in place.** Saving writes back to the entry's **existing folder** (via the plugin's `overwrite_mode: edit` + a hidden path field). Editing the title or date does **not** rename the folder or change the URL — identity is stable by design. After saving, the form does a **full page reload** back to the feed (matching the existing post flow — no in-place card update).
|
||||||
|
- **R3 — Delete control.** Each journal card shows a **Delete** control (owner only). Deleting requires an explicit **confirmation step** — an inline button swap on the card (Delete → **Cancel** / **Confirm delete**), no browser dialog or modal — then removes the entry via the Grav API (session-auth `DELETE`, the pattern already used by `/gpx-manager`), clears the page-tree cache, and the card disappears from the feed.
|
||||||
|
- **R4 — Publish/unpublish toggle.** The form carries a publish-state toggle. The owner can unpublish an entry (to shelve it for later rewriting) or re-publish it. This sets the entry's `published` frontmatter. Publishing/unpublishing happens **only through the edit form** — there is no separate card-level publish control.
|
||||||
|
- **R5 — Drafts stay owner-visible.** An unpublished (draft) entry remains visible **to the logged-in owner** in the feed, marked with a **"Draft"** badge, and stays **editable** from its card (opening the edit form, where it can be re-published). It is **hidden from the public** feed entirely. Draft cards appear under **both** the "All content" and "Journal" filter tabs. Drafts are **excluded from the trip map and stats counts** — they render as a feed card only (no map marker, no stat contribution).
|
||||||
|
- **R6 — Server-side guard.** Edit, delete, and publish actions are enforced server-side, not just hidden in the UI: authenticated owner only, and only for entries inside the **active trip's** `dailies` container. The controls render **only on the active trip's** feed cards — past-trip feed pages (which share the same `trip-feed-col` partial) do **not** show them. Neither the `add-page-by-form` save path nor the Grav API delete path enforces trip-scope on its own (the plugin accepts a client-supplied `parent`/`edit_path`, and `PagesController::delete` checks only write-permission), so this guard must be a **custom server-side hook** on both the save and delete paths, validating the target route against `site.active_trip` before proceeding.
|
||||||
|
|
||||||
|
### Milestone 2 — Editable photos in the edit form (FilePond)
|
||||||
|
|
||||||
|
- **R7 — Load existing photos.** Opening an entry for edit loads its current photos into the FilePond field so they can be managed.
|
||||||
|
- **R8 — Remove photos.** The owner can delete any existing photo from the entry.
|
||||||
|
- **R9 — Add photos.** The owner can upload new photos, appended to the set, with the same HEIC→JPEG conversion used when posting.
|
||||||
|
- **R10 — Reorder.** Existing + new photos can be dragged into any order. The **first photo is the cover** — this reuses the live `photo-1..N` ordering convention, *not* the removed `hero_image` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scope Boundaries (non-goals)
|
||||||
|
- **Stories are untouched** by all of this — no edit/delete/publish changes to stories; they keep their standalone detail pages and `hero_image`.
|
||||||
|
- **No detail-page edit route** — edit is invoked from feed cards only (the Edit control redirects to `/post?edit=<path>`).
|
||||||
|
- **No editing of past-trip entries** — controls appear only on the active trip's cards; past trips are read-only through this UI (edit them via Admin2 if ever needed).
|
||||||
|
- **Retiring the journal detail page** is out of scope (tracked in `docs/working/backlog.md` → "Journal entry detail page"). It is cleanup unrelated to edit/delete.
|
||||||
|
- **No bulk operations** (multi-select edit/delete/publish).
|
||||||
|
|
||||||
|
### Success criteria
|
||||||
|
- The owner can fix a typo or metadata on an existing entry from the feed and see it update, with the entry's URL unchanged.
|
||||||
|
- The owner can delete an entry from the feed (after confirming) and it disappears.
|
||||||
|
- The owner can unpublish an entry, still see it (badged "Draft") and re-open it later to finish and publish — while the public never sees it.
|
||||||
|
- (M2) The owner can remove, add, and reorder an entry's photos and see the cover change to match the new first photo.
|
||||||
|
|
||||||
|
### Dependencies / Assumptions
|
||||||
|
- **`add-page-by-form` edit mode exists but needs a create-path patch** — `overwrite_mode: edit` saves to the existing folder "respecting any already present uploaded files," targeting it via a hidden **`edit_path`** field (the plugin checks `edit_path` first, then `file_path`, at `add-page-by-form.php:537-545` — standardize on `edit_path`). Note the edit branch does **not** fall through to `slug_field` when `edit_path` is empty, so the shared-form create path requires the plugin patch in KTD1/U1. This is the backbone of M1/M2 save-in-place.
|
||||||
|
- **Post-form field parity** — the `/post` form's fields already map 1:1 to entry frontmatter, so prefill is a matter of loading values, not redesigning the form.
|
||||||
|
- **Cover = first image** is an existing convention (`entry.media.images|first` in `partials/entry-journal.html.twig`); the `hero_image` field was removed and is not reintroduced.
|
||||||
|
- **Feed collection is `.published()`** — both `trip.html.twig` and `home.html.twig` collect dailies via `.children.published()`, which drops unpublished pages unconditionally. R5 (owner-visible drafts) requires replacing this with an **auth-aware collection** in both templates: include unpublished entries only when the owner is authenticated, then gate the Draft badge/controls by auth.
|
||||||
|
- **Delete + cache** — deleting an entry must clear the page-tree cache. Note cache-on-save only clears on the `new-entry` form submit, so it does **not** fire on an API delete; the Grav API's `PagesController::delete` clears the cache itself, so the delete path inherits cache-clearing from the API, not from cache-on-save.
|
||||||
|
- **Auth** reuses the existing `site.login` gate; no new auth system.
|
||||||
|
|
||||||
|
### Outstanding Questions (resolve in planning)
|
||||||
|
- **"Save as draft" on create?** The publish toggle is a shared form field, so it will also appear on the *new-entry* path — confirm whether the create form should let the owner save a brand-new entry directly as a draft (likely yes, near-zero extra cost) or always publish new entries.
|
||||||
|
- **Draft direct-URL access?** Confirm Grav returns a **404 at a draft's direct URL** for anonymous visitors (not merely hiding it from the feed collection) under the current Login plugin config — otherwise draft content is reachable by anyone who guesses the date-slug URL.
|
||||||
|
- **Auth-varying feed vs. output caching?** Once `twig.cache: true` at launch, the feed renders differently for the owner (drafts shown) vs. the public (drafts hidden). Confirm the draft branch is evaluated **per-request** (or the feed bypasses output cache for authenticated sessions) so a cached render can't leak drafts to the public or hide them from the owner. Add a launch smoke test: load the feed as owner, then anonymous, and confirm drafts don't leak.
|
||||||
|
|
||||||
|
**Planning resolutions (2026-07-04):**
|
||||||
|
- *Save as draft on create* → **Yes.** The `published` toggle is a shared field defaulting to Published; flipping it off on the create path saves a brand-new entry as a draft. Near-zero cost, falls out of the shared field (see KTD3).
|
||||||
|
- The *draft direct-URL* and *auth-vs-cache* questions are not planning blockers — they are **launch-time verifications** carried into the Verification Contract (V7, V8). Both are low-risk for a solo-owner blog but must be confirmed before `twig.cache: true` at launch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Contract preservation
|
||||||
|
|
||||||
|
Product Contract unchanged. Planning enriches this artifact in place (requirements-only → implementation-ready); all R1–R10 IDs, scope boundaries, and success criteria are preserved verbatim. The only additions are the resolutions above and the Planning Contract below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **KTD1 — Edit reuses the `new-entry` form via `overwrite_mode: edit` + a hidden `edit_path`; the plugin's edit branch is patched to preserve create.** Set `pageconfig.overwrite_mode: edit` on `post-form.md` unconditionally and add a hidden `edit_path` field that is **empty on create, populated on edit**. **Code check (feasibility + adversarial, confidence 100):** in `add-page-by-form.php` the `slug_field: date,title` computation lives *only* in the `else` (non-edit) branch (~lines 550-602); under `overwrite_mode === 'edit'` the slug is derived solely from `basename(dirname($form_data['edit_path']))` (line 541, guarded by `isset()`, not `!empty()`). So with an empty/absent `edit_path` the create path does **not** fall through to `slug_field` — it either writes into the dailies container itself (`basename(dirname(''))` → `.`) or aborts with a 'slug empty' error. The "one form for both" reuse is therefore **not implementable as written**. **Decision:** patch the plugin's edit branch so that when both `edit_path` and `file_path` are empty it falls through to the existing `slug_field` computation (restoring create behavior). This patch is a **required file of U1**, not a deferred contingency. V1 verifies both branches (empty `edit_path` → fresh dated folder; populated → in-place). *(Alternative considered and rejected for higher carrying cost: a separate edit-form page with its own `overwrite_mode: edit`.)*
|
||||||
|
|
||||||
|
- **KTD2 — Publish is folded into the edit save; no separate publish endpoint.** R4 specifies publish/unpublish happens only through the edit form, so the `published` toggle is a normal form field written to page frontmatter on save. This removes an entire endpoint from the surface — the only new server API is delete (KTD5).
|
||||||
|
|
||||||
|
- **KTD3 — `published` becomes a real form field, replacing the static `pagefrontmatter.published: true`.** Add a `published` toggle to the blueprint (default `1`). Remove the static `pagefrontmatter.published: true` so the field value is authoritative on every submit (create and edit). *Verification:* confirm the field value lands in frontmatter and the static default no longer overrides it (V2).
|
||||||
|
|
||||||
|
- **KTD4 — Prefill is client-side via the Grav API.** The Edit link opens `/post?edit=<entry-route>`; `post-form.js` reads the param, `GET /api/v1/pages<route>` (session-auth, `credentials: 'include'` — the gpx-manager pattern), and populates each field + the hidden `edit_path` + the `published` toggle. Reuses the JS layer we own and the already-configured session API. No server-side Twig form-default plumbing.
|
||||||
|
|
||||||
|
- **KTD5 — Delete is a purpose-built, active-trip-scoped API route in a new `entry-actions` plugin.** The stock `DELETE /api/v1/pages<route>` has no trip-scope guard (`PagesController::delete` checks only write-permission), which violates R6. A thin new plugin registers one route via `onApiRegisterRoutes` that: (a) requires the authenticated **owner** — `grav.user.username == site.owner_username`, **not** merely any login (the super-admin `tester` account also authenticates — see KTD8); (b) resolves the delete target **through the page tree** via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation) and asserts the resolved page is non-null and `->parent()->route()` equals the active trip's dailies route — rejecting any slug containing `/` or `..` at the handler entry with 400; (c) deletes the page folder; (d) clears the page-tree cache. Rejects with 403 otherwise. **Shared guard (FYI A2):** the plugin exports the active-trip→dailies-parent resolution + "is direct child of active dailies" assertion as one helper; `cache-on-save` (KTD6) calls the *same* helper so the two R6 enforcement points cannot diverge. See the `grav-api-integration` skill for the `AbstractApiController` + `onApiRegisterRoutes` contract.
|
||||||
|
|
||||||
|
- **KTD6 — The save-path scope guard lives in `cache-on-save`'s existing `onFormValidationProcessed`.** That handler already runs for `new-entry`, resolves `site.active_trip`, and injects the parent. Extend it: when `edit_path` is present, **normalize it first** — resolve via `$grav['pages']->find($edit_path)` and assert the returned page is non-null and its `->parent()->route()` equals the active dailies route (using the KTD5 shared helper). A raw string-prefix check is insufficient: a value like `/trips/<active>/dailies/../other-slug/entry.md` passes a prefix test while `basename(dirname())` targets a *different* entry (security-lens, confidence 75). Also assert owner identity (KTD8), consistent with the delete route. Throw a `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched. This is R6's enforcement point for edit/publish — no new plugin needed for the save side.
|
||||||
|
|
||||||
|
- **KTD7 — Auth-aware feed collection; map/stats stay published-only.** Replace `.children.published()` with an owner-aware collection: `grav.user.authenticated ? dailies_page.children : dailies_page.children.published()`. The feed (`all_items`) uses the owner-aware list so drafts show to the owner; the **map `entries` array and stats inputs continue to use `.published()` only**, so drafts never get a marker or a stat contribution (R5). The between-trips home grid stays `.published()` (past trips are public-only).
|
||||||
|
|
||||||
|
- **KTD8 — Controls are gated by `owner_can_edit`, computed once per surface and threaded through the feed-col partial.** **Owner identity, not just authentication (security-lens, confidence 100):** `grav.user.authenticated` is true for *any* login, including the super-admin `tester` account, so gating on it alone would grant edit/delete/draft-visibility to every account. Gate on the specific owner: `owner_can_edit = grav.user.authenticated and grav.user.username == site.owner_username and (trip.slug == site.active_trip)`. Add `owner_username` to `site.yaml` (single source of truth) so the same identity check backs the UI gate here **and** the server guards (KTD5/KTD6) — the UI gate is cosmetic; the server is authoritative. `trip.html.twig` and the home active-trip branch compute it and pass it into `trip-feed-col.html.twig`, which passes it into `entry-journal.html.twig`. Past-trip pages compute `false`, so no controls render there — satisfying R6's "active trip only" at the UI layer, matching the server guard.
|
||||||
|
|
||||||
|
- **KTD9 — M1 hides the photos field and relaxes the ≥1-photo rule in edit mode.** Photos are untouched in M1, and the create flow requires ≥1 photo (`post-form.js initValidation`). In edit mode (`?edit=` present) the photos section is hidden and the ≥1-photo check is skipped, so an edit submit with an empty FilePond leaves existing images intact (`overwrite_mode: edit` "respects already present uploaded files"). M2 replaces this by loading the real photos into FilePond.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
**Edit round-trip (M1):**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Owner (browser)
|
||||||
|
participant C as Journal card
|
||||||
|
participant P as /post?edit=route
|
||||||
|
participant JS as post-form.js
|
||||||
|
participant API as Grav API (session auth)
|
||||||
|
participant APBF as add-page-by-form
|
||||||
|
participant COS as cache-on-save guard
|
||||||
|
|
||||||
|
U->>C: click Edit (owner + active trip only)
|
||||||
|
C->>P: navigate /post?edit=<entry-route>
|
||||||
|
P->>JS: page load, ?edit present
|
||||||
|
JS->>API: GET /api/v1/pages<route>
|
||||||
|
API-->>JS: frontmatter + content
|
||||||
|
JS->>P: fill fields, set hidden edit_path,<br/>set published toggle, hide photos, relax photo rule
|
||||||
|
U->>P: edit + Save
|
||||||
|
P->>COS: form submit (new-entry)
|
||||||
|
COS->>COS: assert edit_path ∈ active dailies (else ValidationException/fail closed)
|
||||||
|
COS->>APBF: proceed
|
||||||
|
APBF->>APBF: overwrite_mode:edit → write to existing folder
|
||||||
|
COS->>COS: clear page-tree cache
|
||||||
|
P-->>U: full reload → feed shows updated entry (URL unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete flow (M1):**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Owner (browser)
|
||||||
|
participant C as Journal card
|
||||||
|
participant EA as entry-actions plugin (API route)
|
||||||
|
|
||||||
|
U->>C: click Delete
|
||||||
|
C->>C: swap to Cancel / Confirm delete
|
||||||
|
U->>C: Confirm delete
|
||||||
|
C->>EA: DELETE /api/v1/entry/<slug> (credentials: include)
|
||||||
|
EA->>EA: authenticated? target ∈ active-trip dailies?
|
||||||
|
alt authorized
|
||||||
|
EA->>EA: delete folder + clear cache
|
||||||
|
EA-->>C: 200 → remove card from DOM
|
||||||
|
else rejected
|
||||||
|
EA-->>C: 403 → restore Delete control + inline error
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Blueprint: `published` field + enable edit mode
|
||||||
|
|
||||||
|
- **Goal:** Make the post form capable of editing in place and carrying publish state.
|
||||||
|
- **Requirements:** R1, R2, R4; KTD1, KTD3.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `user/pages/02.post/post-form.md`; `user/plugins/add-page-by-form/add-page-by-form.php` (create-path patch, KTD1); `user/config/site.yaml` (`owner_username`, KTD8).
|
||||||
|
- **Approach:** Set `pageconfig.overwrite_mode: edit`. Add a hidden `edit_path` field (empty default). Add a `published` toggle field (default `1`, near the advanced fields). Remove the static `pagefrontmatter.published: true` so the field is authoritative (KTD3). **Patch the plugin's edit branch (KTD1):** in the `if ($overwrite_mode === 'edit')` block, when both `edit_path` and `file_path` are empty, fall through to the existing `slug_field: date,title` computation from the `else` branch (factor it into a shared code path or duplicate the slug build) so create still writes a fresh dated folder. Add `owner_username` to `site.yaml`.
|
||||||
|
- **Patterns to follow:** existing `force_connect`/`featured` toggle fields in the same blueprint; hidden field via `type: hidden`; the existing `slug_field` build in `add-page-by-form.php`'s non-edit branch.
|
||||||
|
- **Execution note:** characterization-first on the plugin patch — capture the current create-path slug output before changing the edit branch, so the patch is proven not to alter create.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Create path preserved under edit mode: submit a new entry with `overwrite_mode: edit` and an empty `edit_path` → a new dated folder is written (not the dailies container, not a 'slug empty' error), `published: true` in frontmatter. *Covers V1.*
|
||||||
|
- Publish field write: submit with `published` off → frontmatter shows `published: false` (assert the on-disk type is a real boolean/int, not the quoted string `'0'`). *Covers V2.*
|
||||||
|
- `Test expectation:` blueprint + plugin patch are behavior-bearing — covered by the two scenarios above plus U2/U5 integration.
|
||||||
|
- **Verification:** posting a brand-new entry still works exactly as before the blueprint flipped to edit mode; `published` value round-trips to frontmatter as a real boolean.
|
||||||
|
|
||||||
|
### U2. Save-path active-trip scope guard (cache-on-save)
|
||||||
|
|
||||||
|
- **Goal:** Enforce R6 on the edit/publish save path.
|
||||||
|
- **Requirements:** R6; KTD6.
|
||||||
|
- **Dependencies:** U1.
|
||||||
|
- **Files:** `user/plugins/cache-on-save/cache-on-save.php`, `tests/` (PHP or UI integration).
|
||||||
|
- **Approach:** In `onFormValidationProcessed` (already gated to `new-entry`), when `edit_path` is present **normalize it via `$grav['pages']->find($edit_path)`** and assert the resolved page is non-null and its `->parent()->route()` equals the active dailies route — using the KTD5 shared helper so save and delete share one scope check. Reject a `null` resolution or any `..`/traversal segment (a raw string-prefix check is insufficient — see KTD6). Also assert `grav.user.username == site.owner_username` (KTD8). Throw `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched.
|
||||||
|
- **Execution note:** test-first — add failing tests asserting both an out-of-scope `edit_path` **and** a traversal `edit_path` (`/trips/<active>/dailies/../other/entry.md`) are rejected before writing the guard.
|
||||||
|
- **Patterns to follow:** the existing fail-closed `ValidationException` for a missing `active_trip` in the same method; the KTD5 shared scope-guard helper.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Edit within active trip's dailies → guard passes, save proceeds.
|
||||||
|
- Edit with `edit_path` pointing outside active dailies (e.g. another trip, or `/`) → `ValidationException`, no page write. *Covers V3.*
|
||||||
|
- Traversal `edit_path` that string-prefix-matches the active dailies but resolves elsewhere → `ValidationException`, no page write. *Covers V3 (traversal branch).*
|
||||||
|
- Non-owner authenticated session (e.g. `tester`) → `ValidationException`, no page write.
|
||||||
|
- Create (no `edit_path`) → guard is a no-op, entry posts normally.
|
||||||
|
- **Verification:** a forged out-of-scope or traversal `edit_path`, and a non-owner session, cannot write; in-scope owner edits and normal creates are unaffected.
|
||||||
|
|
||||||
|
### U3. Auth-aware feed collection; drafts excluded from map/stats
|
||||||
|
|
||||||
|
- **Goal:** Owner sees drafts in the feed; public and map/stats do not.
|
||||||
|
- **Requirements:** R5; KTD7, KTD8.
|
||||||
|
- **Dependencies:** none (parallel-safe with U1/U2).
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/trip.html.twig`, `user/themes/intotheeast/templates/home.html.twig`.
|
||||||
|
- **Approach:** Swap `.children.published()` → `grav.user.authenticated ? dailies_page.children : dailies_page.children.published()` for the **feed** list only. Keep the map `entries` array and stats inputs on a `.published()`-only list. Compute `owner_can_edit` (KTD8) and pass it into `trip-feed-col`. Home active-trip branch: `owner_can_edit = grav.user.authenticated`. Between-trips grid stays `.published()`.
|
||||||
|
- **Patterns to follow:** existing `{% set journal_entries = ... %}` blocks at `trip.html.twig:12`, `home.html.twig:17`; the existing `{% include 'partials/trip-feed-col.html.twig' with { ... } only %}` param list.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Anonymous visitor: draft entry absent from feed, map, and stats. *Covers V4.*
|
||||||
|
- Authenticated owner: draft entry present in feed; still absent from map markers and stat counts.
|
||||||
|
- Published entries: unchanged for both audiences.
|
||||||
|
- **Verification:** draft visibility differs by auth in the feed only; map/stats identical for both.
|
||||||
|
|
||||||
|
### U4. Card UI: Draft badge + Edit/Delete controls
|
||||||
|
|
||||||
|
- **Goal:** Render the badge and the owner controls on the journal card.
|
||||||
|
- **Requirements:** R1, R3, R5, R6; KTD8.
|
||||||
|
- **Dependencies:** U3 (provides `owner_can_edit` and draft flag).
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`, `user/themes/intotheeast/templates/partials/entry-journal.html.twig`, theme CSS (`user/themes/intotheeast/css/…` or the relevant partial styles).
|
||||||
|
- **Approach:** Thread `owner_can_edit` (owner-username gated per KTD8, not merely authenticated) through `trip-feed-col` into `entry-journal`. In `entry-journal.html.twig`: when `entry.published` is false, render a "Draft" badge in the header. When `owner_can_edit`, render an **Edit** link (`/post?edit={{ entry.route }}&return={{ page.url|url_encode }}` — the `return` param lets a save from the home feed reload back to home, not always the trip page; see U5/D5) and a **Delete** control with the inline Cancel/Confirm button-swap markup (no browser dialog). Carry `data-entry-route` for the delete JS. **Touch targets (D8):** Edit/Delete/Cancel/Confirm controls get a min 44×44px tap area (phone-first, field use) — add the sizing to the card-control CSS.
|
||||||
|
- **Patterns to follow:** the card header structure at `entry-journal.html.twig:3-22`; the filter/`data-*` attribute convention already on the `<article>`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Anonymous: no Edit/Delete controls, no Draft badge visible (drafts absent anyway).
|
||||||
|
- Owner on active trip: Edit + Delete present on every journal card; Draft badge on unpublished ones. *Covers V5.*
|
||||||
|
- Non-owner authenticated (e.g. `tester`) on active trip: no controls (owner_can_edit false).
|
||||||
|
- Owner on a past-trip page: no controls (owner_can_edit false).
|
||||||
|
- `Test expectation:` markup/gating — covered by the above UI assertions.
|
||||||
|
- **Verification:** controls appear only for owner+active-trip; badge tracks publish state; controls meet the 44px tap-target minimum.
|
||||||
|
|
||||||
|
### U5. Edit prefill + edit-mode form behavior (post-form.js)
|
||||||
|
|
||||||
|
- **Goal:** Fill the form from the entry and adapt the form for editing.
|
||||||
|
- **Requirements:** R1, R2; KTD1, KTD4, KTD9.
|
||||||
|
- **Dependencies:** U1 (fields exist).
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/post-form.js` (rebuild via `make build-assets`; never hand-edit `js/post/post-form.js`).
|
||||||
|
- **Approach:** On `?edit=<route>` detection, **before the fetch fires, disable all form fields and swap the submit button to a "Loading entry…" state (D1)** — this prevents the owner typing into empty fields on a slow mobile connection and having that input silently overwritten when the prefill resolves. Then `GET /api/v1/pages<route>` (`credentials: 'include'`), populate title/date/content/lat/lng/city/country/weather/transport/featured/force_connect/published, set the hidden `edit_path`, hide the photos section, and skip the ≥1-photo validation (KTD9); re-enable fields + restore the submit button on success. **Edit-mode chrome (D6):** set the form `h1` to "Edit entry" and the submit button to "Save changes". **On fetch failure (D7):** show an inline error banner between the form heading and the first field, restore empty defaults, keep fields disabled (don't leave a half-filled form). **Save behavior:** keep the existing full-reload-on-success, redirecting to the `return` URL param when present, else the active trip page (D5). **Save-failure state (D3):** if the server guard rejects the submit, the error re-render must preserve the hidden `edit_path`, the `published` toggle, and the prefilled fields so the owner doesn't lose edit context (relevant given the Form 9.1.10 re-render path — see Risks/A3). **Pre-U5 check (A4):** confirm with one `curl` (session cookie) that `GET /api/v1/pages<route>` returns the required frontmatter keys + content and record the exact JSON path (`header.*` vs flat) before wiring field mapping — the gpx-manager reference only covers `/media`.
|
||||||
|
- **Patterns to follow:** the existing API-fetch + `credentials: 'include'` usage in `gpx-manager.html.twig`; the existing `initValidation` and field-setting helpers in `post-form.js`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Loading state: on `?edit=`, fields are disabled and the button reads "Loading entry…" until the fetch resolves; typing is impossible before prefill lands. *Covers D1.*
|
||||||
|
- Edit load: `/post?edit=<route>` fills every field with the entry's values, sets `edit_path`, and shows the "Edit entry" heading. *Covers V6.*
|
||||||
|
- Edit save: change the title, submit → same folder/URL, title updated, photos intact; reload lands on the `return` surface. *Covers V1 (edit branch), D5.*
|
||||||
|
- Photos hidden + ≥1-photo rule relaxed in edit mode: submitting with empty FilePond succeeds and keeps existing images.
|
||||||
|
- API fetch failure: inline error banner shown between heading and first field; form not silently broken.
|
||||||
|
- **Verification:** editing round-trips values with a stable URL; the form is never editable before prefill lands; photos survive an M1 edit; a failed save preserves edit context.
|
||||||
|
|
||||||
|
### U6. Delete API route + card delete wiring (entry-actions plugin)
|
||||||
|
|
||||||
|
- **Goal:** Actually delete an entry, scoped to the active trip.
|
||||||
|
- **Requirements:** R3, R6; KTD5.
|
||||||
|
- **Dependencies:** U4 (delete control markup).
|
||||||
|
- **Files:** new plugin `user/plugins/entry-actions/` (`entry-actions.php`, `entry-actions.yaml`, `blueprints.yaml`); **delete JS in a small feed-scoped script** `user/themes/intotheeast/js/src/feed-actions.js` (rebuilt via `make build-assets`) — the delete control lives in `entry-journal.html.twig` (rendered by the feed partial, not the `/post` page), so it does **not** belong in `post-form.js` (C3); `plugins.txt` note only if GPM-managed (this is custom-in-repo, so **not** added to `plugins.txt`).
|
||||||
|
- **Approach:** Register `DELETE /api/v1/entry/<slug>` via `onApiRegisterRoutes`. Handler: require the authenticated **owner** (`grav.user.username == site.owner_username`, KTD8); reject any slug with `/` or `..` at entry (400); resolve the target through the page tree via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation); assert the resolved page is non-null and a direct child of the active dailies (KTD5 shared helper); delete the page folder; `cache->deleteAll()`. Return 200/400/403/404 as appropriate. **Frontend (feed-actions.js):** Delete → inline swap to Cancel/Confirm. **On Confirm-click (D2): immediately disable both buttons and set Confirm to "Deleting…", and announce via an `aria-live` region** — prevents a mobile double-tap firing two DELETEs (the second 500s on an already-removed folder). On 200: **capture the next-sibling journal card, remove the deleted card, then move focus to that sibling (or the feed heading if it was the last card) and announce "Entry deleted" via `aria-live` (D4)**. On 403/error: re-enable both buttons, restore labels, show a one-line inline message directly below the control, constrained to card width (D7).
|
||||||
|
- **Execution note:** test-first on the scope guard — out-of-scope, traversal, and non-owner deletes must be refused before the happy path is wired.
|
||||||
|
- **Patterns to follow:** `grav-api-integration` skill (`AbstractApiController`, `onApiRegisterRoutes`, response/exception helpers); `api.yaml` session-auth config; the gpx-manager delete fetch shape.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Owner deletes an active-trip entry → folder gone, cache cleared, card removed, focus moves to the next card. *Covers V5 (delete).*
|
||||||
|
- Delete targeting a non-active-trip / arbitrary page route → 403, nothing deleted. *Covers V3 (delete branch).*
|
||||||
|
- Traversal slug (`../`) or slug containing `/` → 400, nothing deleted.
|
||||||
|
- Non-owner authenticated session (`tester`) → 403, nothing deleted.
|
||||||
|
- Unauthenticated delete request → 401/403, nothing deleted.
|
||||||
|
- In-flight guard: double-tapping Confirm fires exactly one DELETE (buttons disabled after first click).
|
||||||
|
- Confirmation UX: Delete → Cancel restores original control; Delete → Confirm triggers the request.
|
||||||
|
- **Verification:** scoped delete works for the owner only; out-of-scope/traversal/non-owner/unauth requests are refused; no double-submit; focus is preserved after removal.
|
||||||
|
|
||||||
|
### U7. M2: Load existing photos into FilePond on edit
|
||||||
|
|
||||||
|
- **Goal:** Show the entry's current photos in the edit form so they can be managed.
|
||||||
|
- **Requirements:** R7; (M2).
|
||||||
|
- **Dependencies:** U5 (edit mode established). Milestone 2.
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/post-form.js`; possibly the `entry-actions` plugin or Grav media API for per-photo metadata.
|
||||||
|
- **Approach:** In edit mode, instead of hiding the photos section (KTD9's M1 behavior), pre-populate FilePond with the entry's existing images as remote/local items (FilePond `files` init pointing at the entry media URLs). Re-enable the photos section for edit.
|
||||||
|
- **Patterns to follow:** the existing FilePond init + `GravFilePond` usage in `post-form.js`; entry media URLs as rendered in `entry-journal.html.twig`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Edit load: existing photos appear as FilePond items in current order. *Covers V9.*
|
||||||
|
- Entry with a single photo / many photos both render correctly.
|
||||||
|
- **Verification:** the edit form shows the real photos ready to manage.
|
||||||
|
|
||||||
|
### U8. M2: Persist add / remove / reorder (cover = first)
|
||||||
|
|
||||||
|
- **Goal:** Save photo changes back to the entry.
|
||||||
|
- **Requirements:** R8, R9, R10; (M2).
|
||||||
|
- **Dependencies:** U7.
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/post-form.js`, `user/plugins/cache-on-save/cache-on-save.php` (`reorderPhotos`), `user/plugins/add-page-by-form/add-page-by-form.php` (file-delete path).
|
||||||
|
- **Approach:** On save, reconcile FilePond state to the `photo-1..N` scheme (drag order = cover order, reusing the existing rename convention). Route removals through `add-page-by-form`'s existing deleted-files mechanism (`add-page-by-form.php:121, 715-718`) so dropped images are unlinked. New uploads get the same HEIC→JPEG conversion as create. Verify `reorderPhotos` is reachable from the edit path.
|
||||||
|
- **Execution note:** characterization-first — capture current `reorderPhotos` behavior before extending it to the edit path.
|
||||||
|
- **Patterns to follow:** existing `photo-1..N` rename + `reorderPhotos()` in `cache-on-save`; HEIC→JPEG `beforeAddFile` hook in `post-form.js`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Remove a photo → file unlinked on disk; remaining renumbered; feed cover updates. *Covers V10.*
|
||||||
|
- Add a photo (incl. HEIC) → appended, converted, renamed into sequence.
|
||||||
|
- Reorder so a different image is first → that image becomes the feed cover.
|
||||||
|
- Mixed add+remove+reorder in one save → final on-disk set matches the FilePond order exactly.
|
||||||
|
- **Verification:** the on-disk photo set and cover match the FilePond state after save.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Contract
|
||||||
|
|
||||||
|
- **V1 — Create not regressed by edit mode.** With `overwrite_mode: edit` and no `edit_path`, posting a new entry writes a fresh dated folder identical to prior behavior — verified by the KTD1 plugin patch (empty `edit_path`/`file_path` falls through to `slug_field`). Assert on the **on-disk folder + feed**, not the re-rendered form (the Form 9.1.10 re-render may 500 — see Risks/A3).
|
||||||
|
- **V2 — Publish field round-trips.** The `published` toggle writes a real boolean `published: true/false` to frontmatter (not the quoted string `'0'`) and the removed static default no longer overrides it.
|
||||||
|
- **V3 — Scope guard rejects out-of-scope, traversal, and non-owner writes/deletes.** A forged out-of-scope `edit_path`/delete route, a traversal path that string-prefix-matches active dailies but resolves elsewhere, and a non-owner authenticated session (e.g. `tester`) are each refused server-side (edit → `ValidationException`; delete → 403/400), with no disk change. Both guards call one shared helper (KTD5).
|
||||||
|
- **V4 — Draft visibility is auth-scoped.** Anonymous: draft absent from feed/map/stats. Owner: draft present in feed only (still absent from map markers and stat counts).
|
||||||
|
- **V5 — Owner (only) can edit and delete from the card.** Active-trip cards expose working Edit and Delete (with confirm) to the owner username only; the edited entry keeps its URL; the deleted entry disappears and focus moves to the next card. Assert on disk/feed, not the re-render (A3).
|
||||||
|
- **V6 — Prefill loads all fields.** `/post?edit=<route>` populates every listed field plus `edit_path` and the publish toggle, and the form is not editable until prefill lands (D1).
|
||||||
|
- **V7 — (interim + launch) Draft direct-URL returns 404 to anonymous.** Confirm a `published: false` entry's URL 404s for anonymous visitors under the current Login config — not merely feed-hidden. **Run this in the dev container during M1** (added to DoD), not only as a launch gate — entry URLs follow a guessable date-slug pattern.
|
||||||
|
- **V8 — (launch) No draft leak under `twig.cache: true`.** With caching on, load the feed as owner then anonymous; drafts never leak to the public nor vanish for the owner.
|
||||||
|
- **V9 — (M2) Existing photos load into FilePond on edit.**
|
||||||
|
- **V10 — (M2) Add/remove/reorder persists; cover = first photo.**
|
||||||
|
|
||||||
|
Existing UI suite to extend: `tests/ui/post/post-form-ux.spec.js` and helpers in `tests/ui/helpers`. Standalone Playwright scripts run against the container per the session norm. **Given the Form 9.1.10 filepond regression (Risks/A3), M1 UI assertions target the on-disk entry and the re-rendered feed, not the post-submit form re-render.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- All M1 units (U1–U6) implemented; V1–V6 pass, plus **V7 run in the dev container** as an M1 check (draft direct-URL 404s for anonymous). V8 recorded as a launch-gate check (not blocking M1 merge but tracked).
|
||||||
|
- Owner (owner-username, not merely any authenticated account) can edit, delete (with confirm), and unpublish/publish a journal entry entirely from the active-trip feed, with the entry URL stable and the public never seeing drafts.
|
||||||
|
- Server-side scope guard proven on both save and delete paths via the shared helper (V3), including traversal and non-owner rejection.
|
||||||
|
- Empty-`jwt_secret` risk resolved: confirmed the API does not accept empty-signed tokens on the new routes (see Risks/S1).
|
||||||
|
- M2 units (U7–U8) implemented; V9–V10 pass — may land as a separate follow-up PR after M1.
|
||||||
|
- No regression to the create flow (V1) or to stories.
|
||||||
|
- Assets rebuilt via `make build-assets`; no hand-edits to `js/post/post-form.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **`overwrite_mode: edit` create-path behavior (KTD1)** was the load-bearing assumption and it **fails as originally written** (feasibility + adversarial, confidence 100) — the plan now resolves it with a required plugin patch in U1 (fall through to `slug_field` when `edit_path`/`file_path` empty). V1 verifies the patched create path. Contingency if the patch proves unworkable: a dedicated edit-form page.
|
||||||
|
- **Empty `jwt_secret` in `api.yaml` (S1, security-lens).** `jwt_secret: ''` alongside `jwt_enabled: true` — if the API plugin accepts tokens signed with the empty string, the "authenticated owner" guard on both new routes (delete, prefill GET) is forgeable by an unauthenticated attacker. **Pre-M1 check:** verify against the api plugin source (or empirically) that an empty secret means "JWT disabled" and does not accept empty-signed tokens; if it does, set a real secret before shipping. The plugin's own owner-identity assertion (KTD5/KTD8) is the primary control regardless.
|
||||||
|
- **Grav API session permission for the custom delete route** — confirm the `site.login` session carries sufficient permission for the plugin's delete action (page removal may need an elevated check); the plugin owns its own auth assertion regardless (KTD5).
|
||||||
|
- **Form 9.1.10 filepond regression** (flagged in project instructions: the post-submit re-render 500s on the filepond field) affects **M2** photo editing **and also M1's edit-save reload (A3, adversarial)** — every `new-entry` submit, including an M1 edit save, goes through the same re-render. It also already breaks the 6 post UI specs. **Mitigation for M1:** assert V1/V5/V6 on the on-disk entry + re-rendered feed rather than the post-submit form re-render (see Verification). M2 photo editing should land only once the regression is resolved in the form-to-page/image-upload rework; do not work around it here.
|
||||||
|
- **CSRF posture** — the delete route relies on the existing `cors.credentials: false` (blocks cross-origin credentialed fetch). The edit-save POST additionally depends on the PHP session cookie's `SameSite` attribute; confirm it is `Lax`/`Strict`. Document this dependency; revisit if CORS is ever loosened.
|
||||||
|
- **Owner account hygiene** — the super-admin `tester` account authenticates and, under a naive `grav.user.authenticated` gate, would gain full edit/delete rights; the owner-username gate (KTD8) closes this. The `tester` account should not ship to production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- Codebase (grounding for every KTD): `user/plugins/add-page-by-form/add-page-by-form.php` (edit mode 537-545, delete path 121/715-718), `user/plugins/cache-on-save/cache-on-save.php` (parent injection + cache clear), `user/pages/02.post/post-form.md` (blueprint), `user/themes/intotheeast/templates/trip.html.twig` & `home.html.twig` (feed collection), `partials/trip-feed-col.html.twig` & `partials/entry-journal.html.twig` (card), `user/themes/intotheeast/templates/gpx-manager.html.twig` + `user/plugins/api/api.yaml` (session-auth API delete pattern), `user/themes/intotheeast/js/src/main.js` (filter bar).
|
||||||
|
- Skills: `grav-api-integration` (custom API route contract for the `entry-actions` delete endpoint).
|
||||||
|
- Upstream: this artifact's own Product Contract (ce-brainstorm) and the ce-doc-review pass of 2026-07-04.
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
# Grav 2.0.4 Upgrade + GPM-Manage Plugins — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Status:** ✅ Complete — Phase 1 (local), Phase 2 (test env), and **Phase 3 (production) all executed and shipped**. Phase 3 was run for real on 2026-07-05 (see the Phase 3 section for the execution outcome and the three `docs/solutions/` gotchas it produced). git-sync re-enabled on test and set up on prod. See "Known issue" below re: Form 9.1.10 filepond.
|
||||||
|
|
||||||
|
**Goal:** Upgrade Grav core `2.0.0-rc.10` → `2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only).
|
||||||
|
|
||||||
|
**Architecture:** One dependency-forced atomic upgrade. Local core is baked into the Docker image (rebuild); the server upgrades in place via `bin/gpm self-upgrade` + `bin/gpm update`. The GPM release channel is switched from `testing` to `stable` in `user/config/system.yaml`. `git-sync` is disabled for the duration of the remote upgrade and left off pending user validation.
|
||||||
|
|
||||||
|
**Tech Stack:** Grav 2.0 (PHP 8.3), GPM CLI, Docker Compose, Make (env-suffixed remote targets), Gitea content sync.
|
||||||
|
|
||||||
|
**Spec:** `docs/working/specs/2026-07-04-grav-2.0.4-upgrade-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Version floors (GPM enforces): `grav >= 2.0.4`, `api >= 1.0.6`, `admin2 >= 2.0.9`, `flex-objects >= 1.4.3`, `login >= 3.8.11`, `form >= 6.0.0`. Assert with `>=`, not `==` — GPM may serve a newer stable patch at execution time.
|
||||||
|
- Only write inside `travel-blog-intotheeast/` or its subfolders.
|
||||||
|
- **Dual git repos.** The project root is one repo; `user/` is a *separate* repo where only `pages/ config/ accounts/ themes/` are tracked (`plugins/` is gitignored except `cache-on-save/` and `story-blocks/`). Changes to `user/config/system.yaml` commit to the **user repo** and reach the server via `make content-push` → server pull; everything else commits to the **root repo**.
|
||||||
|
- GPM channel authority is `user/config/system.yaml` → `gpm.releases` (must be `stable` on the server *before* any GPM op). `GRAV_CHANNEL` in docker-compose is cosmetic/consistency only.
|
||||||
|
- Never read `.env*`. Use `make remote-*` targets for all server ops.
|
||||||
|
- Do not touch `twig.cache` (stays `false` in dev per CLAUDE.md).
|
||||||
|
- `git-sync` is remote-only: never add it to `plugins.txt`; disable it during the remote upgrade and leave it disabled until the user re-enables.
|
||||||
|
- Prod is empty → **Phase 3 is documentation only, never executed.**
|
||||||
|
- Verified CLI names (against the rc.10 container): `php bin/gpm self-upgrade -y` (core), `php bin/gpm update -y` (all plugins), `php bin/grav cache` (clear cache). `bin/grav upgrade` does **not** exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Repo | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `Dockerfile` | root | Baked local core version (grav-admin zip URL) |
|
||||||
|
| `plugins.txt` | root | GPM plugin manifest — gains admin2/api/flex-objects |
|
||||||
|
| `docker-compose.yml` | root | `GRAV_CHANNEL` cosmetic bump |
|
||||||
|
| `user/config/system.yaml` | **user** | Authoritative GPM channel (`gpm.releases`) |
|
||||||
|
| `scripts/server-install.sh` | root | Fresh-install script — drop admin2/api special-casing |
|
||||||
|
| `scripts/git-sync-toggle.sh` | root | New: idempotently set git-sync `enabled:` on the server |
|
||||||
|
| `Makefile` | root | Fix `remote-upgrade-grav`; add 3 remote targets |
|
||||||
|
| `docs/working/plans/...` `CLAUDE.md` `docs/reference/architecture.md` | root | Runbook + stack docs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Phase 0 — core, plugin-list, and channel edits
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Dockerfile` (the grav-admin zip URL line)
|
||||||
|
- Modify: `plugins.txt`
|
||||||
|
- Modify: `docker-compose.yml` (`GRAV_CHANNEL`)
|
||||||
|
- Modify: `user/config/system.yaml` (`gpm.releases`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: local image that installs Grav 2.0.4; `plugins.txt` containing `api`, `admin2`, `flex-objects`; `stable` GPM channel consumed by Task 2 (local) and Task 5 (server).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump the core version in the Dockerfile**
|
||||||
|
|
||||||
|
In `Dockerfile`, change the download URL:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \
|
||||||
|
-o /tmp/grav-admin.zip \
|
||||||
|
```
|
||||||
|
|
||||||
|
(Only the URL changes — the zip still extracts to `/tmp/grav-admin/`, so every `cp` line below it is unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the three plugins to `plugins.txt`**
|
||||||
|
|
||||||
|
Append these lines to `plugins.txt` (order is not significant; GPM resolves deps):
|
||||||
|
|
||||||
|
```
|
||||||
|
api
|
||||||
|
admin2
|
||||||
|
flex-objects
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Switch the GPM channel to stable**
|
||||||
|
|
||||||
|
In `user/config/system.yaml`, under the `gpm:` block (currently line ~212):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gpm:
|
||||||
|
releases: stable
|
||||||
|
official_gpm_only: true
|
||||||
|
```
|
||||||
|
|
||||||
|
(Change `testing` → `stable`. Leave `official_gpm_only` as-is.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Bump the cosmetic channel env**
|
||||||
|
|
||||||
|
In `docker-compose.yml`, under the `grav` service environment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- GRAV_CHANNEL=production
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit the user-repo change**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd user
|
||||||
|
git add config/system.yaml
|
||||||
|
git commit -m "config: switch GPM release channel testing -> stable"
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: commit succeeds in the `user` repo.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the root-repo changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Dockerfile plugins.txt docker-compose.yml
|
||||||
|
git commit -m "build: pin Grav core 2.0.4 and add admin2/api/flex-objects to plugins.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: commit succeeds on branch `grav-2.0.4-upgrade`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Phase 1 — local build, clean install, validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No file edits. Executes the Task 1 changes locally.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1 (Dockerfile 2.0.4, plugins.txt, stable channel).
|
||||||
|
- Produces: a proven-working local 2.0.4 stack — the go/no-go gate for the remote phases.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the stale manually-extracted plugin folders**
|
||||||
|
|
||||||
|
These were hand-extracted from the rc.10 bundle; GPM must install them fresh.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf user/plugins/admin2 user/plugins/api user/plugins/flex-objects
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the three folders are gone (`ls user/plugins/` no longer lists them). They are gitignored, so `git status` in `user/` is unaffected.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rebuild the image with core 2.0.4**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build completes; the RUN layer downloads `grav-admin-v2.0.4.zip`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Recreate the container**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `intotheeast_grav` is up on http://localhost:8081.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm the core version is 2.0.4**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec intotheeast_grav php bin/grav --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output contains: `Grav CLI Application 2.0.4` (or a newer 2.0.x).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update already-installed GPM plugins to stable**
|
||||||
|
|
||||||
|
This bumps `login` (3.8.9 → ≥3.8.11, required by `api`) and `form` before the new plugins install. `gpm install` alone would skip them because they already exist.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -w /var/www/html intotheeast_grav php bin/gpm update -y
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `login`, `form`, `shortcode-core`, etc. report as updated (or already up to date).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Install the newly-listed plugins**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `admin2`, `api`, `flex-objects` install; their dependencies resolve against the now-current `login`/`form`; no "requires grav >= 2.0.4" errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Clear the cache**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec intotheeast_grav php bin/grav cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "Cache cleared" output.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Assert plugin versions meet the floors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec intotheeast_grav sh -c 'cd /var/www/html && for p in admin2 api flex-objects login form; do printf "%s: " "$p"; grep -m1 "^version:" user/plugins/$p/blueprints.yaml; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected (at least): `admin2: version: 2.0.9`, `api: version: 1.0.6`, `flex-objects: version: 1.4.3`, `login: version: 3.8.11`, `form: version: 9.1.8` — equal or higher.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Run the automated smoke suite**
|
||||||
|
|
||||||
|
This exercises the posting pipeline (`test-post` submits via the real form → add-page-by-form → cache-on-save) and renders pages via Playwright — the exact admin2/api-critical path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `test-config`, `test-post`, and `test-ui` all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Manual browser spot-check**
|
||||||
|
|
||||||
|
Visit and confirm each renders without error:
|
||||||
|
- http://localhost:8081/ (home)
|
||||||
|
- the active trip page (`/trips/japan-korea-2026`) — filter bar + map load
|
||||||
|
- one story page — hero + shortcodes render
|
||||||
|
- http://localhost:8081/admin2 — login page loads; log in
|
||||||
|
- http://localhost:8081/gpx-manager — list loads; upload a small `.gpx`, then delete it
|
||||||
|
|
||||||
|
Expected: all load; no PHP errors in `docker logs intotheeast_grav`.
|
||||||
|
|
||||||
|
- [ ] **Step 11: Checkpoint (no commit needed)**
|
||||||
|
|
||||||
|
No files changed in this task. If any step failed, stop and diagnose before proceeding — this is the go/no-go gate for remote work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Remote Makefile targets (fix + additions)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/git-sync-toggle.sh`
|
||||||
|
- Modify: `Makefile` (fix `remote-upgrade-grav`; add `remote-update-plugins`, `remote-git-sync-disable`, `remote-git-sync-enable`; register the new targets in `REMOTE_TARGETS`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `make remote-upgrade-grav-<env>`, `make remote-update-plugins-<env>`, `make remote-git-sync-disable-<env>`, `make remote-git-sync-enable-<env>` — consumed by Task 5.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the git-sync toggle script**
|
||||||
|
|
||||||
|
Create `scripts/git-sync-toggle.sh` (piped to the server via `bash -s`, matching the `server-install.sh` pattern). It only ever rewrites the top-level `enabled:` key — never `folders` or the encrypted token.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
FILE="$1"
|
||||||
|
STATE="$2"
|
||||||
|
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
||||||
|
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
||||||
|
|
||||||
|
if [ ! -f "$FILE" ]; then
|
||||||
|
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qE '^enabled:' "$FILE"; then
|
||||||
|
sed -i -E "s/^enabled:.*/enabled: ${STATE}/" "$FILE"
|
||||||
|
else
|
||||||
|
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Make it executable**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/git-sync-toggle.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix the broken `remote-upgrade-grav` target**
|
||||||
|
|
||||||
|
In `Makefile`, replace the body of `remote-upgrade-grav` (currently `php bin/grav upgrade`, which is not a real command):
|
||||||
|
|
||||||
|
```make
|
||||||
|
remote-upgrade-grav: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the plugin-update target**
|
||||||
|
|
||||||
|
Add below `remote-install-plugins`:
|
||||||
|
|
||||||
|
```make
|
||||||
|
remote-update-plugins: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the git-sync toggle targets**
|
||||||
|
|
||||||
|
```make
|
||||||
|
remote-git-sync-disable: guard-env
|
||||||
|
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
|
||||||
|
|
||||||
|
remote-git-sync-enable: guard-env
|
||||||
|
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add a server content-status target**
|
||||||
|
|
||||||
|
For reviewing config drift after the plugin upgrade without raw SSH:
|
||||||
|
|
||||||
|
```make
|
||||||
|
remote-content-status: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Register the new targets for env-suffix generation**
|
||||||
|
|
||||||
|
In `Makefile`, extend the `REMOTE_TARGETS` list so the `-test`/`-prod` variants get generated:
|
||||||
|
|
||||||
|
```make
|
||||||
|
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
|
||||||
|
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
|
||||||
|
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
|
||||||
|
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify the targets exist and expand correctly (dry run)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -n remote-update-plugins-test
|
||||||
|
make -n remote-git-sync-disable-test
|
||||||
|
make -n remote-upgrade-grav-test
|
||||||
|
make -n remote-content-status-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: each prints the intended `ssh ...` command with `ENV=test` resolved, and no "No rule to make target" error. (No server is contacted by `-n`.)
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/git-sync-toggle.sh Makefile
|
||||||
|
git commit -m "build: fix remote-upgrade-grav; add remote plugin-update, git-sync toggle, content-status targets"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Fresh-install script cleanup (option B server-side)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/server-install.sh` (remove admin2/api stash+restore)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `plugins.txt` now containing admin2/api/flex-objects (Task 1).
|
||||||
|
- Produces: a fresh-install path where admin2/api/flex install purely via `gpm install $PLUGINS`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the zip-stash lines**
|
||||||
|
|
||||||
|
In `scripts/server-install.sh`, delete lines 25–26 (the admin2/api stash into `/tmp`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
|
||||||
|
cp -rf grav-admin/user/plugins/api /tmp/api-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the restore lines**
|
||||||
|
|
||||||
|
Delete lines 43–45 (the restore after the user re-clone):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -rf /tmp/admin2-plugin user/plugins/admin2
|
||||||
|
cp -rf /tmp/api-plugin user/plugins/api
|
||||||
|
rm -rf /tmp/admin2-plugin /tmp/api-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `mkdir -p user/plugins user/accounts user/data` in place. admin2/api/flex now come from `php bin/gpm install $PLUGINS -y` (unchanged line ~48).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Syntax-check the script**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -n scripts/server-install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output (valid syntax).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/server-install.sh
|
||||||
|
git commit -m "build: drop admin2/api zip-stash from server-install; install via GPM (option B)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Phase 2 — test env upgrade
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No file edits. Executes against the test environment using Task 3 targets.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 1–4 (pushed to Gitea), the `-test` make targets.
|
||||||
|
- Produces: test env on 2.0.4 with GPM-managed plugins, validated; git-sync left disabled.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push all changes to Gitea**
|
||||||
|
|
||||||
|
The server pulls `user/` content (incl. the stable-channel `system.yaml`) from Gitea; the root repo pushes normally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin grav-2.0.4-upgrade # or merge to the branch the server tracks, per your deploy convention
|
||||||
|
make content-push # pushes the user repo commit (system.yaml) to Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both remotes updated. (Confirm with the user which branch the test server tracks before pushing.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Disable git-sync on test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make remote-git-sync-disable-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: prints `git-sync now: enabled: false`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Pull latest content to the test server**
|
||||||
|
|
||||||
|
Brings the `gpm.releases: stable` change onto the server *before* any GPM op.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make remote-fetch-content-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: server `user/` fast-forwards; `user/config/system.yaml` shows `releases: stable`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Upgrade the core on test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make remote-upgrade-grav-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `bin/gpm self-upgrade` moves core rc.10 → 2.0.4 (stable channel); cache cleared. If it fails on a shared-folder error (see spec Risks), retry is safe — `self-upgrade` supports `-o/--overwrite`; add it to the target temporarily if a retry is needed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update all plugins on test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make remote-update-plugins-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: admin2 → ≥2.0.9, api → ≥1.0.6, flex-objects → ≥1.4.3, login → ≥3.8.11, form, git-sync all update to their stable versions; cache cleared.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Review server config drift**
|
||||||
|
|
||||||
|
Inspect the server `user/` working tree for unexpected rewrites from the plugin upgrades (do NOT blind-commit):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make remote-content-status-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: review any `config/` diffs deliberately. Discard server-specific/reformatting churn; keep only intended changes. (git-sync is disabled, so nothing auto-commits while you review.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Smoke-test the test URL**
|
||||||
|
|
||||||
|
Against the test site (URL per your test env), confirm:
|
||||||
|
- home, a trip page, a story render
|
||||||
|
- admin2 login works
|
||||||
|
- submit one `/post` → the entry appears in the active trip's dailies
|
||||||
|
- `/gpx-manager` lists, uploads, and deletes a file
|
||||||
|
|
||||||
|
Expected: all pass. (git-sync stays disabled, so the new post will not auto-sync yet — that's expected and verified in Step 9.)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Notify the user — validation checkpoint**
|
||||||
|
|
||||||
|
Report results and explicitly state that **git-sync remains disabled** on test pending their validation. Do not re-enable automatically.
|
||||||
|
|
||||||
|
- [ ] **Step 9: (User-gated) Re-enable git-sync and verify sync**
|
||||||
|
|
||||||
|
After the user confirms validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make remote-git-sync-enable-test
|
||||||
|
make content-push # or trigger a content change; confirm it syncs through
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `git-sync now: enabled: true`; a content round-trip syncs between the test server and Gitea.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Docs, prod runbook, and memory
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (stack versions + plugin-management model)
|
||||||
|
- Modify: `docs/reference/architecture.md` (versions/channel)
|
||||||
|
- Modify: `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` (this file — Phase 3 runbook + Status)
|
||||||
|
- Modify: memory files under the auto-memory dir (project-grav2-upgrade, project-plugin-architecture)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the completed local + test upgrade.
|
||||||
|
- Produces: current docs; an executable-but-unexecuted prod runbook.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the stack facts in `CLAUDE.md`**
|
||||||
|
|
||||||
|
Change the "Current stack" block: Grav `2.0.4` (not rc.10); Admin2 to the installed stable version; note that admin2/api/flex-objects are now **GPM-managed via `plugins.txt`** (no longer hand-extracted); note `gpm.releases: stable`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `docs/reference/architecture.md`**
|
||||||
|
|
||||||
|
Reflect core 2.0.4, stable channel, and the three-category plugin model (GPM-managed / former-manual-now-GPM / remote-only git-sync).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the Phase 3 prod runbook**
|
||||||
|
|
||||||
|
Append a "Phase 3 — Production (fresh install, NOT executed)" section to this plan documenting: run `make remote-install-prod` with `GRAV_VERSION=2.0.4`; admin2/api/flex install via GPM from `plugins.txt`; then set up git-sync manually (install, add encrypted token, apply the `folders:` array fix per `docs/working/git-sync-notes.md`), and leave it disabled until first validation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update memory**
|
||||||
|
|
||||||
|
Update `project-grav2-upgrade.md` (now on 2.0.4 stable; GPM serves stable so direct-download-only no longer applies) and `project-plugin-architecture.md` (admin2/api/flex now GPM-managed; git-sync remote-only category). Refresh the `MEMORY.md` pointers if the hooks change.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Set the plan Status to complete**
|
||||||
|
|
||||||
|
Change the `**Status:**` line at the top of this file to `✅ Complete (YYYY-MM-DD)` (today's date at execution).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md docs/reference/architecture.md docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md
|
||||||
|
git commit -m "docs: record Grav 2.0.4 upgrade; GPM-managed plugins; prod runbook"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Memory files live outside the repo; they are written directly, not committed here.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If any phase fails and cannot be fixed forward:
|
||||||
|
1. `git revert` the relevant commits on `grav-2.0.4-upgrade` (root repo) and the `user` repo `system.yaml` commit.
|
||||||
|
2. Local: `make build && make start && make install-plugins`.
|
||||||
|
3. Test server: config, content, and plugin reverts are delivered via `make content-push` + `make remote-fetch-content-test`. **The core is the exception — once `bin/gpm self-upgrade` has completed it cannot downgrade, so treat a completed core upgrade as forward-only and fix forward; there is no revert for it.**
|
||||||
|
|
||||||
|
> ⚠️ Do **not** run the fresh-install path (`scripts/server-install.sh`) against a live server as a rollback. It does `rm -rf user; git clone`, which destroys the server-only, gitignored `user/config/plugins/git-sync.yaml` (the encrypted git-sync token a clone never restores). The fresh-install path is for empty/new servers only.
|
||||||
|
|
||||||
|
Content, config, and accounts are in git, so no data restore is required — but note the core caveat above: "rollback = git" covers config/content/plugins, **not** a completed server core self-upgrade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution outcome (2026-07-04)
|
||||||
|
|
||||||
|
**Installed local versions (all at/above floors):** Grav `2.0.4`, admin2 `2.0.10`, api `1.0.7`, flex-objects `1.4.4`, login `3.8.11`, form `9.1.10`, shortcode-core `6.2.2`.
|
||||||
|
|
||||||
|
**Phase 1 (local): validated + shipped.** Image rebuilt on 2.0.4, plugins installed via GPM, cache clears, rendering clean (home, trips, story, `/admin2` login, `/gpx-manager` list/upload/delete all 200). Test suite: **75 passing**. Test-config was re-pointed off the retired `japan-korea-2026` onto the vetted `italy-2026-demo` data. A self-contained, gitignored `testrunner` account (created via `make test-account` with `--admin-type both`) makes `make test` runnable without the real account in `.env`.
|
||||||
|
|
||||||
|
**Known issue — Form 9.1.10 filepond regression (blocked elsewhere, not a go/no-go blocker).** The post form's `filepond` photo field 500s on the post-submit re-render (`filepond.html.twig` runs `merge` on a string). The journal entry still saves correctly (curl/on-disk `test-post.sh` passes); only the browser re-render errors, failing 6 `post.spec.js` UI specs. This is a stock-plugin upgrade regression, being fixed independently in the form-to-page/image-upload rework. **Do not** add a theme-override workaround in this upgrade — let that rework own the fix.
|
||||||
|
|
||||||
|
**Tasks 3 & 4 (remote Makefile targets + server-install cleanup): shipped** (committed on this branch).
|
||||||
|
|
||||||
|
**Task 5 (Phase 2, remote test-env): executed and validated (2026-07-04).** Sequence run: `remote-git-sync-disable-test` → `content-push` → `remote-fetch-content-test` → `remote-upgrade-grav-test` (core rc.10 → **2.0.7**; stable served a newer patch than the 2.0.4 floor) → `remote-update-plugins-test` (all plugins to stable) → `remote-content-status-test`. Smoke test on `https://test.intotheeast.com`: `/` (renders), `/admin` (admin2 panel; note the server routes admin at `/admin`, not `/admin2`), and `/gpx-manager` all return 200 with no Twig/PHP errors. git-sync was **re-enabled** afterward at the user's request.
|
||||||
|
|
||||||
|
**Config-drift gotcha (reconciled).** The server's `bin/gpm self-upgrade` ran Grav's schema migration, which rewrote `system.yaml` `strict_mode` from the 1.7-era `twig_compat: false` to `twig2_compat: false` + `twig3_compat: true`. The **local** upgrade never triggered this because it was a fresh Docker-image build, not a `self-upgrade` — so the repo's `system.yaml` was stale and a future `remote-fetch-content` (`reset --hard`) would have reverted the server. Fix: folded the Twig 3 flags into the repo's `user/config/system.yaml` (committed `2e32a85`, content-pushed), verified the local Grav 2.0.x container renders 200 under them, then reset the server to the new `origin/main` before re-enabling git-sync so its working tree was clean. `versions.yaml` and `accounts/.htaccess` drift is install-local and left to Grav to manage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Production (fresh install) — EXECUTED 2026-07-05
|
||||||
|
|
||||||
|
> **Execution outcome (2026-07-05):** the fresh prod install was run for real
|
||||||
|
> (`make remote-install-prod`) and the site is live at `https://intotheeast.com`.
|
||||||
|
> The runbook below was followed, but three non-obvious gotchas surfaced — each
|
||||||
|
> now has its own learning in `docs/solutions/`:
|
||||||
|
> - **Stale `.env.prod GRAV_VERSION`** installed Grav rc.10, so GPM wouldn't
|
||||||
|
> serve the `api` plugin (needs ≥2.0.4) → Admin2 login 404'd silently. Fixed
|
||||||
|
> via `make remote-upgrade-grav-prod` (→ 2.0.7) + reinstall. See
|
||||||
|
> `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`.
|
||||||
|
> **TODO: bump `.env.prod GRAV_VERSION` to `2.0.4`** so a future fresh install
|
||||||
|
> doesn't repeat the RC.
|
||||||
|
> - **Double `Content-Encoding` header** (non-FastCGI host + mod_deflate)
|
||||||
|
> rendered a garbage page once prod switched to `twig.debug: false`. Fixed via
|
||||||
|
> `debugger.shutdown.close_connection: false` in the prod env override. See
|
||||||
|
> `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md`.
|
||||||
|
> - **Plugin config stranded in the untracked `user/plugins/`** doesn't deploy.
|
||||||
|
> See `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md`.
|
||||||
|
>
|
||||||
|
> Twig prod-mode is applied as a per-environment override (`deploy/env/prod/system.yaml`
|
||||||
|
> via `make remote-apply-env-prod`); git-sync is installed, configured, and enabled
|
||||||
|
> (see `docs/working/git-sync-notes.md`). Remaining minor follow-ups: gitignore
|
||||||
|
> `config/security-private.php` (committed salt); optional `popularity.salt` strip.
|
||||||
|
|
||||||
|
The original runbook (production was empty, so this was a **fresh install**, not
|
||||||
|
an upgrade):
|
||||||
|
|
||||||
|
1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`.
|
||||||
|
2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt` — `admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.
|
||||||
|
3. **git-sync (remote-only, manual):** it is deliberately absent from `plugins.txt`. Install it on the server, add the encrypted token to `user/config/plugins/git-sync.yaml` (server-only, gitignored — a fresh clone never restores it), and apply the `folders:` array fix per `docs/working/git-sync-notes.md`. Leave it **disabled** (`make remote-git-sync-disable-prod`) until the first content round-trip is validated, then `make remote-git-sync-enable-prod`.
|
||||||
|
4. **Smoke test** the prod URL as in Task 5 Step 7 (home / trip / story / admin2 login / one `/post` / `/gpx-manager`). Note the Form filepond known-issue above will surface on `/post` until the separate rework lands — the entry still saves.
|
||||||
|
5. **Never** run `scripts/server-install.sh` against a populated server (it `rm -rf user; git clone`, destroying the server-only git-sync token). Fresh/empty servers only.
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
product_contract_source: ce-brainstorm
|
||||||
|
title: Journal Post Form Improvements - Plan
|
||||||
|
type: feat
|
||||||
|
date: 2026-07-04
|
||||||
|
execution: code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Journal Post Form Improvements — Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-07-04) — implemented on `feat/journal-post-form` (U1–U7). U4 changed course during execution: the "plain input + custom uploader" fallback uploaded to Grav's flash but couldn't attach photos to the entry without replicating FilePond's undocumented submit contract, so photos now stay on `type:filepond` with a `beforeAddFile` hook that converts HEIC→JPEG then re-adds via `pond.addFile()` (FilePond owns upload+attach). Verified end-to-end in a browser (HEIC→JPEG attach, corrupt-HEIC fail-closed, disclosure, weather gating, draft restore) and via curl (active-trip parent injection + empty-`active_trip` fail-closed). Merged into `main` on 2026-07-08 (outer-repo `feat/journal-post-form`).
|
||||||
|
|
||||||
|
> Plan type: `feat` · Depth: Deep — feature · Origin: `/ce-brainstorm` "improve the current php plugin that allows me to add a new journal page to the current active trip" (2026-07-04)
|
||||||
|
|
||||||
|
**Product Contract preservation:** Product Contract unchanged. Planning enriches this artifact in place — Requirements R1–R20, Key Flows, and Acceptance Examples are carried verbatim from the brainstorm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Capsule
|
||||||
|
|
||||||
|
- **Objective:** Redesign the frontend `/post` journal form so a daily entry can be posted end-to-end from an iPhone — auto-targeting the active trip, exposing every entry field, loading a light markdown editor, converting HEIC photos in the browser, and matching the Field Notes design system.
|
||||||
|
- **Authority hierarchy:** This plan's Requirements (R1–R20) and the three resolved Key Technical Decisions govern. Where an implementation detail is unspecified, follow existing repo conventions (esbuild bundle, Playwright suite, plugin structure). `CLAUDE.md` project rules override everything (only write inside `travel-blog-intotheeast/`; `user/` is a standalone repo; never read `.env`; use `make` for remote ops).
|
||||||
|
- **Stop conditions:** Stop and surface if (a) intercepting Grav's managed FilePond instance for HEIC conversion proves infeasible without replacing the field type (U4 is the load-bearing risk), or (b) any change would require a server-side image pipeline or Docker rebuild — that path is explicitly deferred.
|
||||||
|
- **Execution profile:** Frontend-weighted. One PHP handler (U1); the rest is form blueprint, Twig, an esbuild bundle with two new npm deps, and CSS. Dev server at `http://localhost:8081`; rebuild JS with `make build-assets` (never hand-edit `js/main.js`).
|
||||||
|
- **Tail ownership:** Verify with `make test` (config + post + Playwright UI) and manual dev-server walkthrough on a narrow viewport.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Redesign the frontend `/post` journal form to auto-target the active trip, expose every entry-blueprint field (core visible, advanced behind "More options"), load a light markdown editor, convert iPhone HEIC photos to JPEG in the browser, and match the site's Field Notes design system — all behind the existing site login, optimised for posting from an iPhone during a trip.
|
||||||
|
|
||||||
|
### Problem Frame
|
||||||
|
|
||||||
|
Posting a daily entry today has five rough edges. The parent trip is hardcoded in `user/pages/02.post/post-form.md` (`pageconfig.parent`) and must be kept in sync by hand with `site.active_trip`; forgetting on a trip switch silently files entries under the wrong trip. The form exposes only a subset of the entry blueprint, so `transport_mode`, `hero_image`, `force_connect`, `featured`, and a proper weather-condition picker are unreachable without opening Admin2. The content field is a bare `<textarea>` — the bundled SimpleMDE never loads because `add-page-by-form` keys its editor on a form named `add_page*`, and this form is `new-entry`. Photos come straight off an iPhone, most often as HEIC, which Grav's GD pipeline cannot read at all, so thumbnails break. The form also uses generic Grav markup rather than the site's visual language, and isn't tuned for one-handed mobile use — which is the only way it will be used during the trip.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
- **Active trip is resolved dynamically, not hardcoded.** The parent is derived from `site.active_trip` at submit time instead of from a static `pageconfig.parent`. `add-page-by-form` already honours a submitted `parent` value (`add-page-by-form.php` ~L521), so a small server-side hook injects `<active_trip>/dailies`. This removes the manual two-file sync and its silent-misfile failure mode.
|
||||||
|
- **HEIC is handled client-side only.** Desktop story work sources images from Immich, which already yields JPEG, so HEIC only ever originates from this mobile form — a single path. A browser converter covers it without adding a libheif-enabled ImageMagick to the baked Docker image. Server-side conversion would maintain infrastructure for a case that, by the owner's workflow, never occurs.
|
||||||
|
- **Advanced fields sit behind a "More options" disclosure.** Core fields stay visible for fast phone posting; `hero_image`, `force_connect`, and `featured` are collapsed by default but reachable — nothing is Admin-only anymore.
|
||||||
|
- **Editor is EasyMDE.** The maintained SimpleMDE successor, with a minimal toolbar (bold, italic, list, link) plus a preview toggle — enough affordance without the mobile clutter of a full toolbar.
|
||||||
|
- **The footprint is mostly frontend, not PHP.** Despite the original framing, the only PHP change is the active-trip parent injection. Fields, editor, HEIC conversion, styling, and mobile layout all live in the form blueprint, the Twig template, and its JS/CSS.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
**Active-trip targeting**
|
||||||
|
|
||||||
|
- R1. Submitting the form stores the new entry under the currently active trip's `dailies` folder, resolved from `site.active_trip` at submit time.
|
||||||
|
- R2. Switching trips (changing `active_trip`) requires no edit to the post form; the hardcoded `pageconfig.parent` coupling is removed.
|
||||||
|
|
||||||
|
**Entry fields**
|
||||||
|
|
||||||
|
- R3. The form can set the following entry fields: title, date, content, photos, location (city, country, lat, lng), weather (condition, temperature), `transport_mode`, `hero_image`, `force_connect`, `featured`. (title/date/content/photos are page-level and media fields supplied by the form; the remaining fields live in `entry.yaml`.)
|
||||||
|
- R4. Core fields are always visible: title, date, content, photos, location, weather condition, weather temperature, transport mode.
|
||||||
|
- R5. `hero_image`, `force_connect`, and `featured` sit behind a "More options" disclosure that is collapsed by default.
|
||||||
|
- R6. Weather condition is a labelled picker matching the blueprint's options; the existing "Get Weather" action pre-fills it, and it stays manually overridable.
|
||||||
|
|
||||||
|
**Editor**
|
||||||
|
|
||||||
|
- R7. The content field uses EasyMDE with a minimal toolbar (bold, italic, list, link) and a preview toggle, bound to the underlying content field so both submission and validation read its value.
|
||||||
|
- R15. EasyMDE syncs its content back to the underlying textarea before the form's custom required-field validation runs (e.g. `editor.codemirror.save()` on submit, or bound on change), so a valid entry is never rejected as empty and an empty one never slips past.
|
||||||
|
|
||||||
|
**Image handling**
|
||||||
|
|
||||||
|
- R8. HEIC/HEIF photos selected on the device are converted to JPEG in the browser before upload, so only web-renderable images reach the server.
|
||||||
|
- R9. Conversion is a no-op for photos already in a web format (JPEG/PNG), including HEIC that iOS Safari has already transcoded on file-pick.
|
||||||
|
- R10. No server-side or Docker image change is required; image handling is entirely client-side. (Client-side conversion is a UX convenience; the trust boundary at the upload endpoint is an accepted, deferred gap — see Open Questions.)
|
||||||
|
- R16. HEIC/HEIF is detected by content sniffing, not filename or MIME alone. If a photo is HEIC/HEIF and conversion fails, times out, or the file is corrupt/ambiguous, that photo is blocked from upload with an inline error while other selected photos and Submit remain usable; the original HEIC is never posted.
|
||||||
|
- R17. Each photo still converting shows a per-thumbnail "converting…" indicator, and Submit is disabled until every selected photo has finished converting.
|
||||||
|
|
||||||
|
**Styling and mobile UX**
|
||||||
|
|
||||||
|
- R11. The form is styled to the Field Notes design system (teal accent, DM Serif Display + DM Sans, warm paper background), consistent with the rest of the site.
|
||||||
|
- R12. The form is single-column and mobile-first: large tap targets, native-keyboard-friendly inputs, a comfortable writing area, and smooth "Get Location" / "Get Weather" / photo-capture actions on iPhone.
|
||||||
|
- R13. Photo input supports selecting or capturing images from an iPhone, up to 4.
|
||||||
|
- R18. "Get Location" and "Get Weather" each expose idle, loading (spinner on the button), success (fields filled), and error/permission-denied states; on failure an inline message appears and the fields stay manually editable.
|
||||||
|
- R19. Submit runs blocking inline validation with per-field messages for missing required fields (at minimum title and content, matching the form's current validation), and on a failed save it preserves all entered input and surfaces a retry.
|
||||||
|
|
||||||
|
**Access**
|
||||||
|
|
||||||
|
- R14. `/post` remains gated by the existing frontend site login; one login persists for the session. No public or unauthenticated posting.
|
||||||
|
- R20. If the site-login session expires while an entry is being composed, submitting does not lose the in-progress **text**: the entered title, content, location, weather, and other field values are preserved so the owner can re-authenticate and resubmit. **Scope limit:** selected/converted photos are *not* preserved across a reload or re-auth — `localStorage` cannot hold `File`/`Blob` objects — so photos must be re-picked after re-authenticating. The form surfaces an inline hint to that effect rather than silently dropping them.
|
||||||
|
|
||||||
|
### Key Flows
|
||||||
|
|
||||||
|
- F1. **Post a daily entry from an iPhone.**
|
||||||
|
- **Trigger:** owner opens `/post` on their phone (already logged into the site).
|
||||||
|
- Fills title, date, content (EasyMDE), taps "Get Location" then "Get Weather" to auto-fill coords + weather, sets transport mode, optionally expands "More options".
|
||||||
|
- Picks up to 4 photos. Any HEIC is converted to JPEG in the browser before upload; already-web-format photos pass through untouched.
|
||||||
|
- On submit, the entry is written to `<site.active_trip>/dailies` (parent injected server-side), media attached, and the page cache cleared so it appears immediately in the feed.
|
||||||
|
|
||||||
|
### Acceptance Examples
|
||||||
|
|
||||||
|
- AE1. **Covers R8, R9.** A HEIC photo is selected → converted to JPEG client-side → the posted entry renders with a working thumbnail and hero. A JPEG photo is selected → uploaded unchanged.
|
||||||
|
- AE2. **Covers R1, R2.** With `active_trip: /trips/japan-korea-2026`, a new post lands in `/trips/japan-korea-2026/dailies` without any edit to the form definition.
|
||||||
|
- AE3. **Covers R4, R5.** On load, title/date/content/photos/location/weather/transport are visible; `hero_image`, `force_connect`, and `featured` are hidden until "More options" is expanded.
|
||||||
|
- AE4. **Covers R16, R17.** While a photo converts it shows a "converting…" indicator and Submit is disabled. A HEIC photo whose conversion fails (or a corrupt/ambiguous file) is blocked with an inline error while other photos and Submit stay usable; the original HEIC is never posted.
|
||||||
|
|
||||||
|
### Scope Boundaries
|
||||||
|
|
||||||
|
- **Deferred:** server-side HEIC conversion and a custom libheif-enabled ImageMagick Docker image — revisit only if HEIC begins arriving through a non-Immich path.
|
||||||
|
- **Deferred:** server-side upload validation (accept-list + size cap on `/post`). The authenticated SVG/HEIC/oversized-payload gap is real but login-gated and low-risk for a solo owner; left in Open Questions rather than pulled into this plan. Client-side conversion is UX, not the security boundary.
|
||||||
|
- **Separate brainstorm:** moving story authoring into a frontend add-page flow ("capture a story from the road"). Stories remain desktop-authored for now.
|
||||||
|
- **Unchanged:** the auth model (no PIN/magic-link), the travel-memories / Immich pipeline, and Admin2 authoring.
|
||||||
|
|
||||||
|
### Dependencies / Assumptions
|
||||||
|
|
||||||
|
- Desktop story images come from Immich as JPEG — this is what makes client-side-only HEIC handling sufficient.
|
||||||
|
- `add-page-by-form` continues to honour a submitted `parent` value that overrides `pageconfig.parent`.
|
||||||
|
- A browser HEIC→JPEG library ([heic-to](https://github.com/hoppergee/heic-to)) integrates into the filepond upload step.
|
||||||
|
- EasyMDE can be bound to the content field so its value syncs to the submitted form data.
|
||||||
|
- The frontend Login-plugin session persists on iOS for the trip's duration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Contract
|
||||||
|
|
||||||
|
### Key Technical Decisions
|
||||||
|
|
||||||
|
- KTD1. **Parent injection lives in `cache-on-save`, as a second handler on `onFormValidationProcessed`, and is server-authoritative.** The plugin gains an `onFormValidationProcessed` handler that, for form `new-entry`, reads `site.active_trip` and sets the form's `parent` value (via `$form->value()`) to `<active_trip>/dailies` before `add-page-by-form`'s `add_page` action reads `$form->value()->toArray()['parent']` on its own `onFormProcessed` handler (`add-page-by-form.php:521`). `onFormValidationProcessed` is chosen deliberately over a higher-priority `onFormProcessed`: it is the only pre-write event that can **abort** the submit by failing validation. The existing cache-clear handler is untouched. No `parent` field is added to the form blueprint — injecting server-side (not via a client-submitted hidden field) keeps the write target out of the client's control. `pageconfig.parent` is removed from `post-form.md` so nothing can drift out of sync. **Empty-`active_trip` fail-closed:** if `active_trip` is missing/empty the handler must fail validation (raise an `onFormValidationError` / throw) so `add_page` never runs — merely leaving `parent` unset is not enough, because with `pageconfig.parent` gone `getParentPage('')` resolves to the `/post` page itself and the entry would silently land under `/post` (not the site root). Failing validation is what guarantees no misfile.
|
||||||
|
- KTD2. **EasyMDE + heic-to ship in a `/post`-scoped, code-split bundle, not the global `main.js`.** A new entry `js/src/post-form.js` is bundled to `js/post-form.js` and loaded only by `post-form.html.twig` — keeping ~1.5 MB of converter + editor off every other page. Unlike the site's other bundles (`--format=iife`, no splitting), the `/post` entry is built with `--format=esm --splitting` so the dynamic `import('heic-to')` (KTD4) becomes a **separately-fetched chunk** rather than being inlined — the HEIC converter's weight stays out of the initial `/post` download and is fetched only when a HEIC is actually picked. This matters because `/post` is the cold-load-on-cellular surface. Consequences to carry through: the template must load the entry as `<script type="module" src="js/post-form.js">` (not a classic `<script>`), esbuild emits shared/dynamic chunks alongside the entry (the whole emitted set must ship, so the build's `outdir`/chunk output is committed, not just the single file), and this is the only ESM/split entry in `package.json`'s `build` script — the existing IIFE entries are untouched. CSS is still extracted to `css-compiled/post-form.css`.
|
||||||
|
- KTD3. **EasyMDE flushes to the textarea before validation.** Init EasyMDE on the content `<textarea>`, and call `editor.codemirror.save()` on `change` and at the top of the existing `submit` handler, so the custom `novalidate` validator (which reads `[name="data[content]"]`, `post-form.html.twig:39–45`) sees the live value. This preserves the current validation approach rather than replacing it.
|
||||||
|
- KTD4. **HEIC is detected by magic-byte sniffing and the converter is lazy-loaded.** Sniff the first bytes for the ISO-BMFF `ftyp` box with `heic`/`heif`/`mif1` brands rather than trusting extension or MIME. Only when a HEIC is detected is heic-to dynamically imported (keeps the initial `/post` payload small). On success the file is replaced with a JPEG blob (slugified `.jpg` name); on failure/timeout/corrupt the file is rejected fail-closed with an inline error. Submit is gated on a "conversions in flight" counter.
|
||||||
|
- KTD5. **"More options" is an accessible native `<details>`/disclosure.** Advanced fields (`hero_image`, `force_connect`, `featured`) render inside a `<details>` collapsed by default, auto-expanded if any advanced field is non-empty on load. Native `<details>` gives keyboard/AT support without custom ARIA wiring.
|
||||||
|
- KTD6. **Submit resilience via a `localStorage` draft.** Field values (content especially) are mirrored to `localStorage` on input and restored on load; the draft is cleared on a confirmed successful post. On a failed submit — validation, save error, or a session-expiry response that renders the login form instead of the success message — the draft survives so the owner re-authenticates and resubmits without loss.
|
||||||
|
|
||||||
|
### High-Level Technical Design
|
||||||
|
|
||||||
|
The submit pipeline spans client (conversion, editor sync, validation) and server (parent injection, page write, cache clear). The load-bearing ordering is that parent injection must run *before* `add-page-by-form`'s `add_page` action.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Client
|
||||||
|
A[Pick photos] --> B{HEIC?<br/>magic-byte sniff}
|
||||||
|
B -->|yes| C[Lazy-load heic-to<br/>convert to JPEG]
|
||||||
|
B -->|no| D[Pass through]
|
||||||
|
C -->|fail| E[Block photo,<br/>inline error]
|
||||||
|
C -->|ok| F[Replace with JPEG blob]
|
||||||
|
D --> F
|
||||||
|
G[EasyMDE] -->|codemirror.save| H[textarea value]
|
||||||
|
F --> I{Submit}
|
||||||
|
H --> I
|
||||||
|
I -->|conversions in flight| J[Submit disabled]
|
||||||
|
I -->|required missing| K[Inline validation, preserve draft]
|
||||||
|
I -->|ok| L[POST /post]
|
||||||
|
end
|
||||||
|
subgraph Server
|
||||||
|
L --> M[onFormValidationProcessed<br/>cache-on-save injects parent<br/>= active_trip + /dailies]
|
||||||
|
M --> N[add-page-by-form add_page<br/>reads form_data.parent L521]
|
||||||
|
N --> O[Page written under active trip]
|
||||||
|
O --> P[cache-on-save clears cache]
|
||||||
|
P --> Q[Entry appears in feed]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequencing
|
||||||
|
|
||||||
|
U1 (parent injection) and U2 (fields) are independent and can land first in either order. U3 introduces the `/post` bundle. U4's HEIC logic is independent of U3's editor logic, but U4 depends on that bundle scaffolding — build U3 first so the bundle exists, then U4 adds to it. U5 (styling/disclosure/feedback) depends on U2's field definitions and U3's bundle. U6 (draft resilience) depends on U3 and U5. U7 (tests) comes last and verifies the whole.
|
||||||
|
|
||||||
|
### Assumptions / Execution-time unknowns
|
||||||
|
|
||||||
|
- The exact hook for injecting into Grav's managed FilePond instance (U4) is unresolved and is the plan's chief risk — see Risks. Resolve during implementation by inspecting the rendered filepond field and FilePond's `beforeAddFile` / `server.process` options; a fallback is documented in U4.
|
||||||
|
- Whether `onFormValidationProcessed` exposes a settable `parent` on the form in this Grav/add-page-by-form version, or whether a higher-priority `onFormProcessed` is needed, is confirmed at implementation time against a live submit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Server-authoritative active-trip parent injection
|
||||||
|
|
||||||
|
- **Goal:** New entries land under `<site.active_trip>/dailies` automatically; the hardcoded parent sync is removed (R1, R2).
|
||||||
|
- **Requirements:** R1, R2. Covers AE2.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:**
|
||||||
|
- `user/plugins/cache-on-save/cache-on-save.php` — add a second subscribed event + handler for parent injection.
|
||||||
|
- `user/pages/02.post/post-form.md` — remove `pageconfig.parent`; drop the "keep in sync" comment.
|
||||||
|
- **Approach:** Subscribe to `onFormValidationProcessed` (keep the existing `onFormProcessed` cache-clear). In the new handler, guard on `$form->getName() === 'new-entry'`, read `active_trip` from `$this->grav['config']->get('site.active_trip')`, and set the form's `parent` value to `<active_trip>/dailies` so `add-page-by-form` picks it up at `add-page-by-form.php:521`. Do not add a `parent` form field. If `active_trip` is empty, fail validation (raise an `onFormValidationError` / throw) so the `add_page` action never runs — do not just leave `parent` unset, which would misfile under `/post` (see KTD1). Optionally tighten the existing `deleteAll()` to run once (minor; only if trivially safe).
|
||||||
|
- **Patterns to follow:** existing `cache-on-save.php` handler shape and `getSubscribedEvents()`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Covers AE2. With `active_trip: /trips/italy-2026-demo`, a form post creates the page under `/trips/italy-2026-demo/dailies`.
|
||||||
|
- Change `active_trip` to another trip → next post lands there with no edit to `post-form.md`.
|
||||||
|
- `active_trip` empty/unset → validation fails and the `add_page` action never runs; no page is written under `/post` or anywhere.
|
||||||
|
- Existing cache-clear behavior still fires (new entry appears immediately in the feed).
|
||||||
|
- **Verification:** `make test-post` and `make test-config` pass; a manual post at `http://localhost:8081/post` lands in the active trip and appears in its dailies feed immediately.
|
||||||
|
|
||||||
|
### U2. Full entry-field exposure + weather picker
|
||||||
|
|
||||||
|
- **Goal:** The form can set every entry field, with a proper weather-condition picker; core fields visible, advanced fields defined for the U5 disclosure (R3, R4, R6).
|
||||||
|
- **Requirements:** R3, R4, R6. Supports R5 (disclosure UI in U5).
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `user/pages/02.post/post-form.md` — field definitions.
|
||||||
|
- **Approach:** Change `weather_desc` from `hidden` to a `select` mirroring `entry.yaml`'s options (the emoji-labelled conditions). Add `transport_mode` (select, options from `entry.yaml`), `hero_image` (text), `force_connect` (toggle), `featured` (toggle). Keep `weather_temp_c` (populated by Get Weather; a `number` input so it stays user-editable). Order fields so core (title, date, content, photos, location, weather condition, weather temp, transport) precede the advanced trio; the visual grouping/disclosure is U5. Field names must match the `entry.yaml` header keys so `pagefrontmatter` serialization lands them correctly. **Caution:** turning `weather_desc` into a `<select>` breaks the existing Get Weather handler's `getField('weather_desc')` lookup, which queries `input[name="data[weather_desc]"]` (`post-form.html.twig:65-67`) and will return `null` for a select — U5 must generalize that selector when it migrates the handler, or Get Weather's condition pre-fill silently no-ops.
|
||||||
|
- **Patterns to follow:** `entry.yaml` field types and option lists; existing field blocks in `post-form.md`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Covers AE3 (field presence half). A logged-in `/post` render shows title, date, content, photos, location, weather condition (as a select with emoji options), weather temp, and transport mode.
|
||||||
|
- Posting with `transport_mode`, `hero_image`, `force_connect`, `featured` set writes those keys into the entry frontmatter.
|
||||||
|
- Weather condition select round-trips a manually chosen value (not overwritten unless Get Weather runs).
|
||||||
|
- **Verification:** `make test-config` passes; posted entry frontmatter contains the new fields; entry renders with transport/weather on the trip feed.
|
||||||
|
|
||||||
|
### U3. EasyMDE editor + validation sync + `/post` bundle scaffolding
|
||||||
|
|
||||||
|
- **Goal:** Content uses EasyMDE with a minimal toolbar + preview, synced to the textarea before validation; establish the `/post`-scoped bundle (R7, R15).
|
||||||
|
- **Requirements:** R7, R15.
|
||||||
|
- **Dependencies:** none (introduces the bundle U4/U5/U6 extend).
|
||||||
|
- **Files:**
|
||||||
|
- `user/themes/intotheeast/package.json` — add `easymde` dep; add a `js/src/post-form.js` esbuild entry to the `build` script built with `--format=esm --splitting` (per KTD2, so KTD4's `import('heic-to')` is a real deferred chunk), CSS extracted to `css-compiled/post-form.css`. The existing IIFE entries stay as-is.
|
||||||
|
- `user/themes/intotheeast/js/src/post-form.js` — new bundle entry: init EasyMDE, wire sync.
|
||||||
|
- `user/themes/intotheeast/templates/post-form.html.twig` — load the bundle as `<script type="module" src="js/post-form.js">` + `css-compiled/post-form.css` (page-scoped); migrate the inline validation script's content read to use the synced textarea. (Module scripts defer by default — ensure any inline init that depends on globals accounts for that.)
|
||||||
|
- **Approach:** In `post-form.js`, guard on the presence of the content textarea (no-op otherwise, mirroring `initTripStats`). Init EasyMDE with `toolbar: ['bold','italic','unordered-list','link','preview']`. On `editor.codemirror` `change` and at the start of the existing submit handler, call `editor.codemirror.save()` so `[name="data[content]"]` holds the live value for validation and submission. Rebuild with `make build-assets` (never hand-edit `js/post-form.js`).
|
||||||
|
- **Patterns to follow:** `js/src/main.js` `initTripStats` presence-guard pattern; `package.json` `build` script esbuild invocation; `base.html.twig` `assets.addJs(..., {group:'bottom'})` for the page-scoped adds in the template.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Typing content in EasyMDE, then submitting, posts the entered markdown (content persists).
|
||||||
|
- Submitting with an empty editor triggers the required-field error (sync makes the empty value visible to the validator).
|
||||||
|
- Content with markdown (bold, list, link) round-trips into the entry body.
|
||||||
|
- Preview toggle renders markdown without breaking submit.
|
||||||
|
- **Verification:** `make build-assets` completes clean; `make test-ui` post spec (U7) passes; manual check that a valid entry is never wrongly rejected as empty.
|
||||||
|
|
||||||
|
### U4. Client-side HEIC→JPEG conversion with progress + failure states
|
||||||
|
|
||||||
|
- **Goal:** HEIC photos are detected and converted before upload with a converting indicator and fail-closed handling; web-format photos pass through (R8, R9, R16, R17).
|
||||||
|
- **Requirements:** R8, R9, R16, R17. Covers AE1, AE4.
|
||||||
|
- **Dependencies:** U3 (the `/post` bundle).
|
||||||
|
- **Files:**
|
||||||
|
- `user/themes/intotheeast/package.json` — add `heic-to` dep (dynamically imported).
|
||||||
|
- `user/themes/intotheeast/js/src/post-form.js` — HEIC detection, conversion, progress/failure UI, Submit gating.
|
||||||
|
- `user/themes/intotheeast/css/style.css` (or `post-form.css` bundle) — converting indicator + inline photo error styles.
|
||||||
|
- **Approach:** Hook the filepond field's file intake. Sniff the first bytes for an ISO-BMFF `ftyp` box with `heic`/`heif`/`mif1` brands. On a HEIC, dynamically `import('heic-to')`, convert to a JPEG blob, and substitute it (slugified `.jpg` name) before it uploads; show a per-thumbnail "converting…" state and increment an in-flight counter that disables Submit. On success decrement; on failure/timeout/corrupt, reject that file with an inline error, leave other files + Submit usable, and never upload the original. Non-HEIC files pass through untouched (R9), including HEIC already transcoded to JPEG by iOS on pick.
|
||||||
|
- **Execution note:** This is the plan's highest-risk unit — Grav's `filepond` field manages its own FilePond instance. Resolve the exact interception point at implementation time (FilePond `beforeAddFile` / `server.process`, or converting the `File` before it enters filepond). **Fallback if the managed instance can't be hooked cleanly:** replace the `filepond` field with a plain multiple `file` input for `/post` and drive conversion + preview directly. Surface this as a blocker (per Goal Capsule stop condition) before adopting the fallback.
|
||||||
|
- **Patterns to follow:** none local for filepond interception — see Sources; follow `initTripStats` presence-guard for the init.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Covers AE1. A JPEG uploads unchanged; the posted entry renders a working thumbnail + hero.
|
||||||
|
- Covers AE4. A HEIC file shows a "converting…" indicator, converts, and posts as JPEG; Submit is disabled until conversion completes.
|
||||||
|
- A corrupt/ambiguous HEIC (or a conversion that throws) is blocked with an inline error; other selected photos and Submit remain usable; the original HEIC is not posted.
|
||||||
|
- A HEIC renamed to `.jpg` (misleading extension) is still detected by sniffing and converted, not passed through.
|
||||||
|
- Selecting a 5th photo respects the `limit: 4` cap.
|
||||||
|
- **Verification:** `make build-assets` clean; `make test-ui` HEIC spec (U7) passes using a real `.heic` fixture; manual iPhone-Safari check that a camera HEIC posts with a working thumbnail.
|
||||||
|
|
||||||
|
### U5. Field Notes styling, mobile layout, "More options" disclosure, async feedback
|
||||||
|
|
||||||
|
- **Goal:** The form matches the design system and is mobile-first; advanced fields sit behind an accessible disclosure; Get Location / Get Weather / submit validation expose full feedback states (R5, R11, R12, R13, R18, R19).
|
||||||
|
- **Requirements:** R5, R11, R12, R13, R18, R19. Covers AE3 (disclosure half).
|
||||||
|
- **Dependencies:** U2 (field definitions), U3 (the `/post` bundle + EasyMDE-synced content value).
|
||||||
|
- **Files:**
|
||||||
|
- `user/themes/intotheeast/templates/post-form.html.twig` — wrap advanced fields in a `<details>` "More options"; restructure for single-column mobile; migrate inline scripts into the bundle where practical.
|
||||||
|
- `user/themes/intotheeast/js/src/post-form.js` — Get Location / Get Weather state machine (idle/loading/success/error), Get Weather disabled until coords present, blocking submit validation with per-field messages.
|
||||||
|
- `user/themes/intotheeast/css/style.css` and/or `post-form.css` — Field Notes tokens (`tokens.css`), large tap targets, disclosure styling, `.form-status` states, `.field-error`.
|
||||||
|
- **Approach:** Use `tokens.css` variables (teal accent, DM Serif Display + DM Sans, paper background) for a single-column layout with ≥44px tap targets and native-friendly inputs. Advanced fields render inside `<details>` collapsed by default, auto-`open` when any advanced field is non-empty. Extend the existing Get Location / Get Weather handlers (`post-form.html.twig:69–124`) with explicit loading (button spinner), success, and error/permission-denied states; disable Get Weather with a hint until lat/lng exist. Keep the fields manually editable on failure. When migrating the Get Weather handler, generalize the `weather_desc` lookup so it matches the U2 `<select>` (not `input[...]`). Submit validation stays the custom `novalidate` approach (title + content required), now reading the EasyMDE-synced value. Add a **save-failure feedback state** distinct from field validation: on a failed `add_page`/`upload` — including KTD1's empty-`active_trip` validation error — show an inline error with an explicit retry affordance while the draft (U6) is preserved; specify what the empty-`active_trip` case tells the user ("no active trip is set").
|
||||||
|
- **Patterns to follow:** `css/tokens.css` variables; existing `.form-status--ok` / `.form-status--err` classes; `.journal-post` / site card styling for visual consistency.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Covers AE3 (disclosure). Advanced trio is hidden until "More options" is expanded; expands automatically when an advanced field has a value.
|
||||||
|
- Get Location denied → inline error, lat/lng stay manually editable.
|
||||||
|
- Get Weather tapped before coords exist → disabled/hint, no dead tap.
|
||||||
|
- Get Weather success fills the weather condition select + temp; failure shows an inline message.
|
||||||
|
- Submit with empty title → per-field inline error, focus moves to the field, no navigation.
|
||||||
|
- Save failure (e.g. empty `active_trip`) → inline save-error message with a retry affordance; entered content preserved (not reset).
|
||||||
|
- Narrow viewport (~375px) renders single-column with no horizontal scroll.
|
||||||
|
- **Verification:** `make test-ui` (incl. `tests/ui/a11y/accessibility.spec.js`) passes; manual dev-server walkthrough at 375px width.
|
||||||
|
|
||||||
|
### U6. Submit resilience — draft persistence
|
||||||
|
|
||||||
|
- **Goal:** A failed submit or an expired session mid-compose never loses the entry's **text** (R19 preservation, R20); photos are out of scope for persistence and the form says so.
|
||||||
|
- **Requirements:** R19 (preserve-on-failure), R20.
|
||||||
|
- **Dependencies:** U3, U5 (bundle + submit handling).
|
||||||
|
- **Files:** `user/themes/intotheeast/js/src/post-form.js` — draft mirror/restore; `user/themes/intotheeast/templates/post-form.html.twig` — re-auth hint markup if needed.
|
||||||
|
- **Approach:** Mirror **text** field values (content especially — title, date, content, location, weather, transport, advanced fields) to `localStorage` on input under a `new-entry` key. Photos are explicitly out of scope: `File`/`Blob` objects can't be serialized to `localStorage`, so picked/converted photos are not persisted and must be re-selected after a reload or re-auth — render an inline hint near the photo field on restore ("photos need re-selecting"). On load, restore any text draft into the fields + editor. Clear the draft only after a confirmed successful post (success message present) — and ensure this clear runs before/independently of the form's `process.reset: true`, so the reset doesn't repopulate blank fields back into `localStorage`. **Text-draft survival is guaranteed by this clear-only-on-success invariant**, independent of any failure-type detection. The tailored "session expired — log in and resubmit" hint is best-effort on top: verify at implementation time what a multipart POST under an expired session/nonce actually returns (an inline `#grav-login`, a Grav nonce/validation error, or a 302 redirect) before keying the hint on it — the auth spec's `#grav-login` assumption is GET-scoped and may not hold for the POST.
|
||||||
|
- **Patterns to follow:** the auth spec's assumption that `/post` renders `#grav-login` inline when unauthenticated (`tests/ui/auth/auth.spec.js` A4) — detect that to distinguish session-expiry from other failures.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Type content, reload the page → content is restored from the draft.
|
||||||
|
- Successful post → draft is cleared (a fresh `/post` load is empty).
|
||||||
|
- Failed validation submit → entered values persist (not wiped by reset).
|
||||||
|
- Simulated session-expiry response (login form) → text draft survives; re-auth + resubmit posts the text without loss.
|
||||||
|
- After a reload with a photo previously picked → the photo is gone (expected) and the inline "photos need re-selecting" hint is shown; text fields are still restored.
|
||||||
|
- **Verification:** `make test-ui` draft spec (U7) passes; manual check that a reload mid-compose restores content.
|
||||||
|
|
||||||
|
### U7. Post-form test coverage
|
||||||
|
|
||||||
|
- **Goal:** Lock the behavior with a Playwright spec and fixtures (verifies AE1–AE4 and the new UX).
|
||||||
|
- **Requirements:** verification for R1–R20. Two are preserve/constraint requirements with no new-behavior scenario: R10 (no server-side/Docker change) is enforced by the "Scope discipline" Definition-of-Done line; R14 (login gating, no public posting) is covered by the existing `tests/ui/auth/auth.spec.js` (A4).
|
||||||
|
- **Dependencies:** U1–U6.
|
||||||
|
- **Files:**
|
||||||
|
- `tests/ui/post/post.spec.js` and `tests/ui/post/validation.spec.js` — **update existing specs**: they (and `tests/ui/helpers.js`) currently fill `textarea[name="data[content]"]`, which EasyMDE hides once U3 lands. Retarget content entry to the CodeMirror instance (type into `.CodeMirror textarea` or call the EasyMDE API) or the Playwright suite goes red.
|
||||||
|
- `tests/ui/helpers.js` — update the shared content-fill helper for the same reason.
|
||||||
|
- `tests/ui/post/post.spec.js` — extend with the new coverage (or add a focused sibling spec) for disclosure, HEIC conversion + failure, active-trip landing, feedback states, draft restore.
|
||||||
|
- `tests/fixtures/test-photo.heic` — real HEIC fixture for the conversion path.
|
||||||
|
- `scripts/test-post.sh` — extend if the active-trip landing assertion belongs there rather than in Playwright.
|
||||||
|
- **Approach:** Follow the existing spec style (`tests/ui/post/post.spec.js`, `auth.spec.js`): use the logged-in storage state, drive `/post`, and assert field presence (AE3), disclosure behavior, HEIC conversion + failure (AE1/AE4), active-trip landing (AE2), and draft restore. Add the `.heic` fixture alongside `test-photo.jpg` / `test-nonimage.txt`. Note the filepond-targeting specs (and helpers) also need updating if U4's plain-input fallback is adopted.
|
||||||
|
- **Patterns to follow:** existing `tests/ui/**` specs; `.env.test` provides `GRAV_TEST_USER` / `GRAV_TEST_PASS` / `GRAV_BASE_URL`.
|
||||||
|
- **Test scenarios:** the spec *is* the scenarios — AE1, AE2, AE3, AE4, plus disclosure, feedback states, and draft restore.
|
||||||
|
- **Verification:** `make test` (config + post + UI) is green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Contract
|
||||||
|
|
||||||
|
| Gate | Command | Proves |
|
||||||
|
|---|---|---|
|
||||||
|
| Asset build | `make build-assets` | `/post` bundle compiles as ESM with splitting; `js/post-form.js` entry + the `heic-to` dynamic chunk + `css-compiled/post-form.css` all emitted and committed |
|
||||||
|
| Form config | `make test-config` (`scripts/test-form-config.sh`) | `post-form.md` blueprint is valid; new fields parse |
|
||||||
|
| Post pipeline | `make test-post` (`scripts/test-post.sh`) | A post lands under the active trip and appears in the feed |
|
||||||
|
| UI suite | `make test-ui` (`npx playwright test`) | AE1–AE4, disclosure, feedback states, draft restore, accessibility |
|
||||||
|
| Full gate | `make test` | All of the above in sequence |
|
||||||
|
|
||||||
|
Manual: on `http://localhost:8081/post` at ~375px width, post a real iPhone HEIC and confirm a working thumbnail; verify the entry lands in the active trip's dailies immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- **Global:** All of R1–R20 satisfied; `make test` green; `make build-assets` clean with no hand-edits to generated `js/*.js`; the form is posted successfully end-to-end from a narrow (mobile) viewport including one real HEIC photo.
|
||||||
|
- **Per unit:** each unit's Test scenarios pass and its Verification holds.
|
||||||
|
- **Scope discipline:** no server-side image pipeline, no Docker change, no server-side upload validation added (deferred per decision); `pageconfig.parent` removed and no new client-submittable `parent` field introduced.
|
||||||
|
- **Cleanup:** any exploratory filepond-interception dead-ends removed; if the U4 fallback (plain file input) was adopted, the managed-filepond attempt is not left commented in the bundle.
|
||||||
|
- **Docs:** if `active_trip`/post-form coupling notes in `CLAUDE.md` are now stale (the two-file sync is gone), update them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Both are deferred (security posture), not launch-blocking. They stay in Open Questions by owner decision:
|
||||||
|
|
||||||
|
- **HEIC single-path durability.** The client-side-only decision assumes HEIC only ever enters via this mobile form, but Admin2 media edits, Immich-served originals, and the same `/post` form opened in a desktop browser can each introduce an unconverted HEIC that bypasses the converter. Decide whether to add a cheap server-side HEIC rejection backstop or to explicitly accept (and document) that non-`/post` HEIC uploads render broken. Reversal cost of the deferred server-side path is a Docker image rebuild.
|
||||||
|
- **Server-side upload validation vs. client-only posture.** A direct authenticated POST can bypass the browser conversion and the `accept: image/*` filter — sending still-HEIC, oversized, non-image, or SVG payloads (`media.yaml` serves `svg`, making an uploaded SVG stored XSS). Deferred: login-gated and low-risk for a solo owner. If pulled in later, enforce a server-side accept-list (jpeg/png/webp; reject SVG + HEIC) and per-file size cap in the same `cache-on-save` handler added in U1, treating client-side conversion as UX rather than a security control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
- **Filepond interception (U4) is the load-bearing risk.** Grav's managed FilePond instance may not expose a clean hook for pre-upload conversion. Mitigation: documented fallback to a plain file input scoped to `/post`; surface as a blocker before adopting it.
|
||||||
|
- **heic-to browser support.** Relies on WASM/libheif in-browser; verify it works in iOS Safari (the only target). Mitigation: the U7 HEIC fixture test plus a manual real-device check.
|
||||||
|
- **EasyMDE ↔ custom validation ordering.** If `codemirror.save()` doesn't fire before the validator reads the textarea, valid entries get rejected. Mitigated by KTD3 (save on change *and* at submit-handler top) and a U3 test.
|
||||||
|
- **`onFormValidationProcessed` parent settability.** The exact event/priority at which `parent` is settable before `add-page-by-form` reads it is confirmed against a live submit in U1; a higher-priority `onFormProcessed` is the fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources / Research
|
||||||
|
|
||||||
|
- **Code:** `user/pages/02.post/post-form.md`, `user/plugins/add-page-by-form/add-page-by-form.php` (`parent` override L521–523), `user/plugins/cache-on-save/cache-on-save.php` (`onFormProcessed` handler), `user/themes/intotheeast/blueprints/entry.yaml` (field types/options), `user/themes/intotheeast/templates/post-form.html.twig` (inline validation + Get Location/Weather), `user/themes/intotheeast/package.json` (esbuild `build` script), `user/themes/intotheeast/templates/partials/base.html.twig` (asset loading), `user/config/media.yaml` (no `heic`; serves `svg`), `user/config/site.yaml` (`active_trip`), `tests/ui/**` (Playwright suite), `tests/fixtures/` (`test-photo.jpg`).
|
||||||
|
- **External:** [Grav Media docs](https://learn.getgrav.org/17/content/media) (HEIC unsupported; jpg/png/gif/svg) · [Grav forum — image upload preprocessing](https://getgrav.org/forum/forms-blueprints/image-upload-with-preprocessing-t610) · [heic-to](https://github.com/hoppergee/heic-to) · [EasyMDE](https://github.com/Ionaru/easy-markdown-editor).
|
||||||
|
- **Design:** `docs/reference/design-system.md`, `user/themes/intotheeast/css/tokens.css`.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: requirements-only
|
||||||
|
product_contract_source: ce-brainstorm
|
||||||
|
---
|
||||||
|
|
||||||
|
# Standalone Sub-Page Cleanup — retire redundant map/stats/dailies/stories pages
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-07-04). Phase 1 (retire map/stats/dailies/stories views), Phase 1.5 (align dailies title to "Journal"), Phase 2 (shared entry-map partial), plus follow-ups: fixed a live back-button fallback regression (entry/story pills pointed at retired containers → now the trip page) and re-pointed/cleaned the Playwright suite (9 spec files) off the deleted views. All pushed to production. Auth-gated gpx-manager/post specs not run here (no test creds); everything else green.
|
||||||
|
|
||||||
|
> Plan type: `refactor` · Depth: Standard · Origin: sequel to `2026-06-27-map-init-consolidation.md` — that plan unified the map *engine* (`MapUtils.initEntryMap()`) onto trip + home but deferred `feed-map`/`map.html`; this plan removes those deferred surfaces entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Every trip carries four standalone sub-pages — `02.map`, `03.stats`, plus the standalone `dailies` and `stories` list views — that are **fully consolidated onto the trip page** (inline map + filter bar + inline stats via `trip-feed-col`) and are **no longer reachable from navigation**. They are also the last consumers of the *old* map code path: `feed-map.html.twig` hand-rolls an inline MapLibre init that duplicates `MapUtils.initEntryMap()`, and `map.html.twig` is a third variant built on `renderGpxJourney` directly.
|
||||||
|
|
||||||
|
This plan removes the dead pages/logic (**Phase 1**) and then finishes the shared-logic arc by de-duplicating the map markup that trip and home still copy-paste (**Phase 2**).
|
||||||
|
|
||||||
|
**Net effect after both phases:** the entire site renders maps through exactly one code path (`initEntryMap()`), invoked from exactly one shared partial.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
- Remove unused pages and view logic so the codebase has no orphaned templates or dead map variants.
|
||||||
|
- Preserve all content and all currently-linked behavior — this is a pure internal cleanup, no user-visible feature change on the pages that remain.
|
||||||
|
- Converge on a single map code path.
|
||||||
|
|
||||||
|
## Product authority / decisions locked
|
||||||
|
|
||||||
|
- **Scope = Option 2** (all four standalone views retired), confirmed by owner.
|
||||||
|
- **Keepers — must not break:** `home.html.twig`, `trips.html.twig` (trip overview), `trip.html.twig`, `story.html.twig`, and every shared element they use (`trip-feed-col`, `home-predeparture`, `macros/stats`, `macros/cycling`, `macros/date-range`, `map.css`, `map.js`).
|
||||||
|
- **Containers stay:** `01.dailies/` and `04.stories/` folders remain as data containers (they physically hold entries/stories; trip + home fetch children via `grav.pages.find(route ~ '/dailies').children`).
|
||||||
|
- **Old URLs are don't-care:** `/map`, `/stats`, `/dailies`, `/stories` direct hits may 404. No redirects required (owner decision). Individual entry/story detail pages underneath remain reachable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Cleanup (content + logic + old templates)
|
||||||
|
|
||||||
|
**Delete these page templates (old / unreachable views):**
|
||||||
|
|
||||||
|
- `user/themes/intotheeast/templates/map.html.twig` — old map variant (`renderGpxJourney` inline)
|
||||||
|
- `user/themes/intotheeast/templates/stats.html.twig` — orphaned (nothing links to it)
|
||||||
|
- `user/themes/intotheeast/templates/dailies.html.twig` — standalone list view, consolidated onto trip page
|
||||||
|
- `user/themes/intotheeast/templates/stories.html.twig` — standalone list view, consolidated onto trip page
|
||||||
|
|
||||||
|
**Delete this partial (dies with its only two consumers):**
|
||||||
|
|
||||||
|
- `user/themes/intotheeast/templates/partials/feed-map.html.twig` — old inline-script map duplicate; included **only** by the two deleted list views.
|
||||||
|
|
||||||
|
**Delete these page folders (empty pure-view shells) across every trip:**
|
||||||
|
|
||||||
|
- `user/pages/01.trips/<slug>/02.map/`
|
||||||
|
- `user/pages/01.trips/<slug>/03.stats/`
|
||||||
|
- (applies to all trips: `central-asia-2023`, `italy-2025`, `italy-2026-demo`, `slovenia-2024`, `us-canada-mex-2024`)
|
||||||
|
|
||||||
|
**Repoint the two container pages so nothing errors** (keep the folders, retire the view):
|
||||||
|
|
||||||
|
- `01.dailies/dailies.md` and `04.stories/stories.md`: change `template:` off the deleted templates (e.g. to `default`), and mark the container non-routable/non-visible so its own URL is inert while children stay reachable.
|
||||||
|
|
||||||
|
**Also update / remove any dangling reference to the deleted pages found during work** (e.g. the `link_href: … ~ '/map'` line lived inside `dailies.html.twig`, which is being deleted — confirm no *other* template links to `/map`, `/stats`, `/dailies`, `/stories` as a destination).
|
||||||
|
|
||||||
|
### Phase 1 acceptance criteria
|
||||||
|
|
||||||
|
- Site renders with no Twig errors on: home (active-trip + between-trips), `/trips`, every trip page, and an individual entry and story detail page.
|
||||||
|
- Trip page + home still show the inline map, filter bar, and stats correctly (child-fetch via `find(...).children` still resolves).
|
||||||
|
- `grep` for `feed-map.html.twig`, `map.html.twig`, `stats.html.twig`, `dailies.html.twig`, `stories.html.twig` returns **no remaining `include`/`import`/link references**.
|
||||||
|
- No content lost: journal entries and stories still present and reachable at their detail URLs.
|
||||||
|
- Only one non-`initEntryMap` map path removed — confirm `map.css`/`map.js` and `macros/stats` are untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Optimization (finish the shared-logic arc)
|
||||||
|
|
||||||
|
`trip.html.twig` and `home.html.twig` still hand-write near-identical map markup (`home-map-col` → `home-map`/`trip-map` div → fullscreen button) plus a thin inline `<script>` calling `MapUtils.initEntryMap(...)`. Extract the shared shape.
|
||||||
|
|
||||||
|
- Create `user/themes/intotheeast/templates/partials/entry-map.html.twig` taking parameters for: container id, fullscreen button id, entries array, and gpx config (urls / use / autoconnect / sourcePrefix / journeyId), plus the fit config.
|
||||||
|
- `trip.html.twig` and `home.html.twig` (active branch) both `{% include … with {…} only %}` the new partial instead of their inline markup + script.
|
||||||
|
- Keep the JS engine (`initEntryMap`) as the single source of truth — the partial only supplies markup + the thin invocation.
|
||||||
|
|
||||||
|
### Phase 2 acceptance criteria
|
||||||
|
|
||||||
|
- Trip and home maps render and behave identically to pre-Phase-2 (markers, popups, click-to-scroll-and-highlight, GPX journey, fullscreen toggle).
|
||||||
|
- The map-div markup + invocation exist in exactly one place (`entry-map.html.twig`); no copy-paste twin remains in trip/home.
|
||||||
|
- Existing map-alignment tests (that assert `window.tripMap` / `window.homeMap` globals) still pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-goals / scope boundaries
|
||||||
|
|
||||||
|
- **Not** deleting the `01.dailies/` or `04.stories/` container folders or any content inside them.
|
||||||
|
- **Not** adding redirects for old URLs (owner deferred; 404 is acceptable).
|
||||||
|
- **Not** touching the keeper pages' behavior or the `trip-feed-col` / `home-predeparture` partials, the stats/cycling macros, or `map.css`/`map.js`.
|
||||||
|
- **Not** changing the GPX-manager, post form, or trip-switching config.
|
||||||
|
|
||||||
|
## Risks & verification
|
||||||
|
|
||||||
|
- **Risk:** container repoint leaves child entries unreachable. **Mitigation:** verify `routable: false` on a parent does not unroute children in this Grav version — load an entry and a story detail URL after the change.
|
||||||
|
- **Risk:** a stray reference to a deleted template elsewhere (e.g. `trips.html.twig` counts, sitemap, feed). **Mitigation:** repo-wide grep before declaring Phase 1 done (acceptance criterion above).
|
||||||
|
- **Verification path:** dev server at `http://localhost:8081`; walk home (both modes), `/trips`, each trip, one entry, one story; then run the map-alignment test suite for Phase 2.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- None blocking. (Container repoint mechanism — `routable:false` vs a minimal redirect — is an implementation detail for planning; owner has already ruled old-URL behavior don't-care.)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
title: Photo Editor for Journal Entries (media-API) — Plan
|
||||||
|
date: 2026-07-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# Photo Editor for Journal Entries (media-API) — Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-07-08). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean. Shipped with `feat/journal-post-form` — **merged to `main` in both repos and deployed** (outer pin `f4ab730` == `user/` `main` == `origin/main`; content pushed to Gitea → prod). Owner-session UI QA (add incl. HEIC, inline-confirm delete, mouse reorder, combined, feed cover=first, regressions a/b/c) and on-device touch-drag both passed 2026-07-08. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred).
|
||||||
|
|
||||||
|
## Why this exists (the honest reason)
|
||||||
|
|
||||||
|
M2 tried to edit an entry's photos by reusing the `/post` **create** form + FilePond + the abandoned `add-page-by-form` plugin. Two distinct failure classes came out of that, and it matters not to blur them into one root cause:
|
||||||
|
|
||||||
|
- **FilePond-widget bugs** — `text/html` previews and broken touch-drag. FilePond is built to upload new files to a fresh entry, not to load/preview/reorder existing server files; these are the widget used against its grain.
|
||||||
|
- **PHP-side bugs** — the header-cast fatal and the rename-reconcile gymnastics live in `add-page-by-form` / `cache-on-save`, **not** in FilePond. This plan **reuses that same rename-reconcile logic** (see the reorder route below), so it must be validated on its own merits — a "different foundation" does not make the carried-forward reconcile code automatically safe.
|
||||||
|
|
||||||
|
This plan replaces the **photo UI** with the **proven `gpx-manager` pattern**: our own UI talking straight to the Grav media API.
|
||||||
|
|
||||||
|
## Foundation status (what's proven vs still assumed)
|
||||||
|
|
||||||
|
**Proven on 2026-07-05 (not assumed):**
|
||||||
|
|
||||||
|
- `POST /api/v1/pages<entry-route>/media` (FormData `file`, owner session) → **201**, file on disk ✓
|
||||||
|
- `DELETE /api/v1/pages<entry-route>/media/<filename>` → **204**, removed ✓
|
||||||
|
- Owner session auth works on entry routes ✓
|
||||||
|
|
||||||
|
**Still assumed (novel, load-bearing, NOT yet proven — this is where the 4-day risk lives):**
|
||||||
|
|
||||||
|
- The custom reorder route (rename to `photo-01..NN`) — no stock endpoint exists.
|
||||||
|
- Live reorder-rename behaviour under real add/delete ops.
|
||||||
|
- Three independent live mutations interacting cleanly with the edit session's text-field Save.
|
||||||
|
- HEIC→JPEG conversion at real photo sizes/counts on a phone.
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
1. **Live, not on-submit.** Add / delete / reorder each persist **immediately** via the API — decoupled from the `/post` form's text-field Save. No flash, no submit-time reconcile. This sidesteps `add-page-by-form` for the photo path entirely (the text-field save still uses it + our committed patch). *(Edit-then-leave / no-undo behaviour for the destructive delete path is unresolved — see Open Questions.)*
|
||||||
|
2. **Inline on `/post?edit`.** In edit mode, hide the FilePond section and render the photo-editor component from the media list. **Create mode keeps FilePond, untouched** (out of scope). Hiding the section alone is **not** enough — see the FilePond decommission step in the Client section.
|
||||||
|
3. **Own thumbnail grid, SortableJS for drag.** Square `<img>` thumbnails in a grid. Reorder via **SortableJS** — exactly what FilePond couldn't do reliably here. SortableJS is **not yet a theme dependency**: install `sortablejs` and import it into `js/src/post-form.js` so esbuild bundles it into `js/post`. This is a task, not existing foundation.
|
||||||
|
4. **Cover = first.** After any add/delete/reorder, files are renumbered **`photo-01..NN`** (zero-padded, wide enough for the expected max) in display order; the client sorts thumbnails **numerically**, and the feed renders `media.images|first` as cover. Zero-padding is required so lexicographic media order equals numeric order past 10 photos (otherwise photo-1, photo-10, photo-2…). The shared renumber helper must also normalise any pre-existing un-padded `photo-N` files on first reorder. **This helper also runs on create-mode reconcile**, so create-mode entries will now emit `photo-01..NN` too — an intentional, accepted change (see Scope boundaries). Existing published entries keep their un-padded names harmlessly (they have <10 photos and the client sorts numerically).
|
||||||
|
5. **Add/delete via stock media API; reorder via one custom scope-guarded route.** Stock `POST`/`DELETE …/media` are already proven on entry routes, so add + delete use the **stock media API** (client-side, session-auth). Only the missing **reorder** (rename to `photo-01..NN`) is a custom route in the **`entry-actions`** plugin using `EntryScopeGuard` (owner-username + direct-child-of-active-dailies, the R6 guard). **Accepted tradeoff:** server-side scope enforcement on photo **add/delete** is a **known R6 gap** — any account with `api.media.write` can reach the un-scoped stock endpoint directly, and the client UI gate is **not** an access-control boundary. For a solo-owner blog this is accepted for launch and tracked as a **documented fast-follow** (promote add/delete onto scope-guarded custom routes later). HEIC→JPEG happens client-side before upload (reuse the existing converter).
|
||||||
|
|
||||||
|
## Server — `entry-actions` plugin, 1 custom route (+ stock media API for add/delete)
|
||||||
|
|
||||||
|
**Add / delete — stock media API (client-side, session-auth):**
|
||||||
|
|
||||||
|
- `POST /api/v1/pages<entry-route>/media` — upload (stock endpoint). **No SVG support for now:** add `svg` to `security.uploads_dangerous_extensions` (or reject `.svg` in the upload path) so SVGs are **blocked, not sanitized** — this removes the stored-XSS-via-SVG vector without depending on `security.sanitize_svg` staying enabled. Other executable types (html/js/php) are already blocked by Grav's default dangerous-extension denylist, which is the **actual** control on this stock path — there is no positive MIME allowlist or on-disk extension rewrite here. Allowed image types: **jpg/jpeg/png/webp**; the client file input accepts those **plus HEIC** (converted client-side to JPEG before upload) and excludes SVG. If stronger positive-MIME validation is ever wanted, it moves add onto the scope-guarded custom route (the same place the R6 add/delete fast-follow lands).
|
||||||
|
- `DELETE /api/v1/pages<entry-route>/media/<filename>` — remove.
|
||||||
|
- **After every stock add and every stock delete, immediately call the reorder route (below) to re-establish `photo-01..NN`.** Stock upload keeps the file's original (slugified) name — not the next `photo-N` — and stock delete leaves a numbering gap without renumbering; without a follow-up renumber, `cover = first` breaks until the next manual drag. The reorder route is the single owner of the `photo-N` invariant.
|
||||||
|
|
||||||
|
**Reorder — one custom route (owner + scope guarded):**
|
||||||
|
|
||||||
|
- `POST /api/v1/entry/<slug>/photos/order` — body: ordered filenames → two-phase rename to `photo-01..NN` (reuse the proven cache-on-save rename logic; factor it into a shared helper — and validate that helper on its own, per "Why this exists").
|
||||||
|
|
||||||
|
Handler: `EntryScopeGuard::isOwnerUser` + `resolveActiveDailyChild` (reject 403/400 otherwise), then filesystem op, then `cache->deleteAll()`. Reject filenames containing `/` or `..`. **Operate only on filenames that already exist as image media** in the entry folder — any name in the ordered list that isn't a current image file is ignored, so the entry `.md`, a `.gpx`, or a `.meta.yaml` can never be renamed or clobbered by a crafted order body.
|
||||||
|
|
||||||
|
**Deploy note:** the new `/entry/<slug>/photos/order` route only registers after the API route-map cache is rebuilt, so a cache clear must run on deploy. The existing `DELETE /entry/<slug>` route confirms the nested-static-after-param pattern registers fine.
|
||||||
|
|
||||||
|
## Client — new `photo-editor.js` (bundled into the post-form entry)
|
||||||
|
|
||||||
|
In edit mode only:
|
||||||
|
|
||||||
|
- **Decommission the FilePond photo path (hiding it is not enough).** Skip `editLoadPhotos()` and the FilePond `photo_order` submit-handler wiring entirely — do not initialise/populate FilePond. Otherwise the stale `photo_order` manifest posted on text Save drives `cache-on-save.reconcilePhotos()` → `deleteUnlistedImages()`, which **silently deletes any photo added live after page-open**. With an empty manifest the reconcile leaves the live-managed folder untouched.
|
||||||
|
- Hide the FilePond `.photos-collapse`; render `.photo-editor` from `GET …/media` (image files, numeric-sorted). Show a **loading placeholder** during the fetch and an **empty state** for zero-photo entries that keeps the "Add photos" button visible ("No photos yet — add some").
|
||||||
|
- Each cell: `<img>` thumbnail + ✕ delete. **Inline confirm:** ✕ swaps the cell to "Delete? [Confirm] [Cancel]" (Confirm disabled while the DELETE is in flight) → `DELETE …/media/<file>` → renumber → re-render; Cancel reverts.
|
||||||
|
- "Add photos" button → hidden file input → HEIC→JPEG → `POST …/media` (one per file) → renumber → re-render. **Upload progress:** disable the button while a batch is in flight and show "Uploading N of M…", clearing per file.
|
||||||
|
- **Add is a two-write op (stock upload, then reorder).** On a multi-file add, upload each file (stock `POST …/media`) and call the reorder route **once after the whole batch** — not per file — so there is one renumber pass and only the final numbering matters. The client passes the stock-uploaded basenames into that reorder manifest. If an upload succeeds (201) but the follow-up reorder fails, auto-retry the reorder — it is idempotent, since `renumberPhotos` skips files not on disk — or roll back by `DELETE`-ing the just-uploaded file(s), and surface a single inline error. Never leave an orphan stock-named file in the folder: it is a real image, so it would break `cover = first` and the numeric sort until the next successful drag.
|
||||||
|
- `Sortable` on the grid → on drop, `POST …/photos/order` with the new filename order → re-render. First cell = cover.
|
||||||
|
- **Failure path (every op).** On non-2xx / network error: show an inline error near the affected control (reuse gpx-manager's `.gpx-status.error`), keep the item in place — for reorder, **revert the SortableJS move to the last-known-good order** — re-enable the control for retry, and do **not** silently re-render. Displayed order/cover must never disagree with disk without an error shown.
|
||||||
|
- All live; independent of the form's Save button (which continues to handle title/date/content/etc.).
|
||||||
|
|
||||||
|
## Scope boundaries (non-goals)
|
||||||
|
|
||||||
|
- **Create flow (new-entry FilePond) untouched** — *except* that the shared renumber helper is now zero-padded, so create-mode entries also emit `photo-01..NN`. That is the only create-path side effect; the FilePond UI itself is unchanged. Two photo UIs for now (FilePond on create, this on edit); unifying them is a follow-up.
|
||||||
|
- **Text-field editing unchanged** (`/post` form + `add-page-by-form` + our patch).
|
||||||
|
- No captions, no crop/rotate, no bulk ops.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **I verify in-harness:** add (incl. HEIC), delete, reorder-by-**mouse**, and combined — each persists to disk + shows in the feed immediately; cover = first after reorder; the reorder route's owner/scope-guard rejects non-owner + out-of-scope.
|
||||||
|
- **Regression checks:** (a) a text-field Save *after* a live photo add does **not** delete the added photo (FilePond decommission); (b) an entry with **10+ photos** keeps arranged order and the correct cover (zero-padding); (c) each op's failure path shows an inline error and leaves UI and disk consistent.
|
||||||
|
- **You verify on-device (the one thing I can't simulate):** touch-drag reorder on a phone.
|
||||||
|
|
||||||
|
## Estimate
|
||||||
|
|
||||||
|
One focused implementation push — 1 custom reorder handler + stock add/delete reuse + one JS component + CSS + the SortableJS dependency (install + import). Not another multi-day cycle. Residual risk concentrated in the "still assumed" list above.
|
||||||
|
|
||||||
|
## Deferred / Open Questions
|
||||||
|
|
||||||
|
### From 2026-07-05 review
|
||||||
|
|
||||||
|
- **No undo / cancel model for destructive live edits (P1).** Add/delete/reorder persist immediately and delete is a destructive `unlink`; the Save button trains the user that leaving without saving discards changes, but live deletes are already gone with no undo and no "permanent" signal. Decide between: (a) accept live-is-permanent + add a "saves immediately" affordance and a real delete confirm (cheapest for the deadline); (b) soft-delete to a trash subfolder purged on Save/leave; (c) stage deletes client-side and commit on Save. Resolve before implementing the delete path.
|
||||||
|
|
||||||
|
### Deferred during implementation (2026-07-05)
|
||||||
|
|
||||||
|
- **Server-side SVG block deferred to the R6 add/delete fast-follow.** The plan
|
||||||
|
called for adding `svg` to `security.uploads_dangerous_extensions`, but
|
||||||
|
`user/config/security.yaml` is **gitignored** (a Grav 1.7-era rule from when the
|
||||||
|
HMAC `salt` lived there; obsolete in 2.0.7 where the secret moved to the
|
||||||
|
still-ignored `security-private.php`). Tracking it would mean un-ignoring a
|
||||||
|
security-namespace file from another work session's era — out of scope for this
|
||||||
|
push. Instead: **SVG is excluded client-side** in the photo-editor file input
|
||||||
|
`accept` (jpg/jpeg/png/webp + HEIC only). The **server-side** block is a
|
||||||
|
documented fast-follow that lands together with promoting photo add/delete onto
|
||||||
|
the scope-guarded custom route (the same R6 gap already accepted above) — both
|
||||||
|
concern the un-scoped stock media endpoint, which only the solo owner can reach.
|
||||||
|
|
||||||
|
### Resolved at review close (2026-07-05) — recorded for the implementer
|
||||||
|
|
||||||
|
- **Reorder-route filename safety** — *resolved:* the handler operates only on filenames already present as image media in the folder, so a crafted order body can't rename/clobber the entry `.md`, a `.gpx`, or a `.meta.yaml`. (Now in the Server reorder-route spec.)
|
||||||
|
- **Multi-photo add — reorder cadence** — *resolved:* call the reorder route **once after the whole batch** of uploads, not once per file. (Now in the Client "Add is a two-write op" spec.)
|
||||||
|
- **New route 404 until cache rebuild** — *resolved:* deploy must clear the API route-map cache so `/entry/<slug>/photos/order` registers; the existing `DELETE /entry/<slug>` proves the nested-route pattern works. (Now a deploy note in the Server section.)
|
||||||
|
- **`.meta.yaml` sidecars not renamed by `renumberPhotos`** — *deferred (genuine future work):* no effect today because per-image captions are deferred. When captions ship, the shared renumber helper must rename each image's `.meta.yaml` sidecar alongside it (and clean up orphans), or per-image metadata will drift on reorder/delete.
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
---
|
||||||
|
title: Trip Description, One-liner & Hero Image - Plan
|
||||||
|
type: feat
|
||||||
|
date: 2026-07-05
|
||||||
|
topic: trip-description-and-hero
|
||||||
|
artifact_contract: ce-unified-plan/v1
|
||||||
|
artifact_readiness: implementation-ready
|
||||||
|
product_contract_source: ce-brainstorm
|
||||||
|
execution: code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Trip Description, One-liner & Hero Image - Plan
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (2026-07-06)
|
||||||
|
|
||||||
|
## Goal Capsule
|
||||||
|
|
||||||
|
- **Objective:** Give each trip an optional one-liner and description, surface them on the trip list and trip page, and fix the low-resolution trip cover image — all editable from the admin panel.
|
||||||
|
- **Product authority:** Mischa (site owner).
|
||||||
|
- **Open blockers:** None. Ready for planning.
|
||||||
|
|
||||||
|
## Product Contract
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Add an optional one-liner and an optional description to trips, and render them where they help: the one-liner on both the trip-list cards and the trip page, the description on the trip page only. On the trip page, extend the **existing in-column header** — `home-trip-header` in the shared `trip-feed-col` partial, which already shows title + dates/counts — with the one-liner (below the title) and the description, plus a thin banner image strip (~180–220px) directly below the header text and above the filter bar. No new header is introduced above the map+journal split, and the split itself is unchanged. Because `trip-feed-col` is shared with the homepage active-trip view, these additions are gated to the trip-page caller so that view is unaffected. Fix the trip cover image so it renders sharp (larger derivative + retina `srcset`) and is chosen via an admin media picker, with the current auto-pick fallback retained.
|
||||||
|
|
||||||
|
### Problem Frame
|
||||||
|
|
||||||
|
Trips currently carry no human-readable summary anywhere the reader sees. The trip-list cards (`user/themes/intotheeast/templates/trips.html.twig`) show only title, dates, and counts; the trip page (`user/themes/intotheeast/templates/trip.html.twig`) shows the title, dates, and counts only *inside* the feed column — via the shared `trip-feed-col` partial's `home-trip-header` block — and no one-liner, description, or banner image. A `header.tagline` field already exists in the trip blueprint but is used only on homepage highlight cards, and a markdown `content` field (labeled "Description" in admin) exists but is never rendered. Separately, the trip-list cover image auto-picks the first journal entry's first photo and crops it to 720×240, which looks soft — especially on high-DPI screens — and the author has no easy way to choose a better shot.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
- **Reuse `header.tagline` as the single one-liner.** The existing tagline field becomes the one source for the short subtitle across all three surfaces (homepage highlight cards, trip list, trip page). Rejected a separate new field: two fields to keep in sync for one concept.
|
||||||
|
- **Reuse the markdown `content` field as the description.** It is already editable in admin and labeled "Description"; it is simply not rendered on the trip page yet. Rejected adding a new short-text field.
|
||||||
|
- **Extend the existing in-column header; no new header above the split.** The one-liner and description are added to the existing `home-trip-header` block (in the shared `trip-feed-col` partial, which already renders title + dates/counts), with a thin banner strip (~180–220px) directly below the header text and above the filter bar — not a full-bleed hero, and not a separate header above the map+journal split. A slim banner plus text is forgiving of source-photo quality; the full-bleed story-style hero was rejected for pushing primary content below the fold and making the page hostage to photo quality, and a new above-split header was rejected because it would duplicate the title/dates/counts the feed column already shows. Because `trip-feed-col` is shared with the homepage active-trip view, the additions are gated (via a partial parameter) to the trip-page caller so that view is unchanged.
|
||||||
|
- **One-liner on the list, one-liner + description on the page.** The list stays scannable (short subtitle only); the fuller description lives on the trip page.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
**Trip data & admin**
|
||||||
|
|
||||||
|
- R1. A trip's one-liner is stored in the existing `header.tagline` field and remains editable in the admin trip form.
|
||||||
|
- R2. A trip's description is stored in the existing markdown `content` field and remains editable in the admin trip form.
|
||||||
|
- R3. The admin `header.cover_image` control is a media picker that lets the author select an uploaded image on the trip page, replacing the current type-in-a-filename text field. The picker selects from images uploaded to the trip page's own media; if a trip has none yet, the author uploads one there first, and the R7 auto-pick remains the fallback until a cover is chosen.
|
||||||
|
- R4. The one-liner and description are both optional.
|
||||||
|
|
||||||
|
**Trip list card**
|
||||||
|
|
||||||
|
- R5. When a trip's one-liner is set, the trip-list card displays it (between title and the dates/counts meta line); when unset, no one-liner line renders.
|
||||||
|
- R6. The trip-list card cover image renders sharply on standard and high-DPI displays via a larger derivative (rendered at 1440×480) plus a retina `srcset` (720w and 1440w candidates). Sharpness depends on adequate source resolution — see Dependencies / Assumptions.
|
||||||
|
- R7. The card cover image source is the author-selected `cover_image` when set; when unset, it falls back to the first journal entry's first image (current behavior).
|
||||||
|
|
||||||
|
**Trip page header**
|
||||||
|
|
||||||
|
- R8. On the trip page, the existing in-column header (`home-trip-header` in the shared `trip-feed-col` partial) renders — each only when set — the one-liner (directly below the title) and the description (below the dates/counts), in addition to the title, dates, and counts it already shows.
|
||||||
|
- R9. Directly below the header text and above the filter bar, the trip page renders a thin banner image strip (~180–220px) using the same cover-image source and fallback as the list card (R7), rendered sharply per R6 as a fixed-height center-crop (no focal-point control); when no image is available, the header renders text-only with no banner strip.
|
||||||
|
- R10. No new header is added above the map+journal split, and the map + journal two-column split is unchanged in structure and position.
|
||||||
|
- R11. If a set `cover_image` no longer resolves (file deleted or moved), the trip-list card and the trip-page banner fall back to the R7 auto-pick rather than rendering a broken image.
|
||||||
|
- R12. The one-liner, description, and banner strip are added for the trip-page caller of `trip-feed-col` only (via a partial parameter); the homepage active-trip view's header is unchanged.
|
||||||
|
- R13. The one-liner is plain text (soft cap ~120 characters). The description is markdown; the header shows the first ~2–3 lines with the remainder collapsed behind an expand control, so the map+journal split stays above the fold by default while the full description remains readable on demand.
|
||||||
|
- R14. The cover/banner image's alt text is the trip title.
|
||||||
|
- R15. On narrow/mobile viewports the banner strip and header text reflow without pushing the map+journal split off-screen (e.g. reduced banner height); exact breakpoints are decided during planning.
|
||||||
|
|
||||||
|
### Acceptance Examples
|
||||||
|
|
||||||
|
- AE1. **Covers R4, R5, R8.** Given a trip with neither one-liner nor description set, when a reader views the trip list and the trip page, then no one-liner line and no description block render on either surface, and the in-column header still shows the title (and dates/counts if present).
|
||||||
|
- AE2. **Covers R8.** Given a trip with a one-liner but no description, when a reader views the trip page, then the in-column header shows the title, one-liner, and dates/counts, and renders no description block.
|
||||||
|
- AE3. **Covers R7, R9.** Given a trip with no `cover_image` set but at least one journal entry with an image, when a reader views the list card and the trip-page banner strip, then both show the first entry's first image (sharp per R6).
|
||||||
|
- AE4. **Covers R9.** Given a trip with no `cover_image` and no journal-entry images, when a reader views the trip page, then the header renders text-only with no banner strip.
|
||||||
|
- AE5. **Covers R6.** Given a trip cover image, when a reader views the trip-list card or the trip-page banner strip on a high-DPI (retina) display, then the larger derivative and retina `srcset` apply and the image renders sharply.
|
||||||
|
- AE6. **Covers R10.** Given any trip, when a reader views the trip page, then the map + journal two-column split renders unchanged in structure and position, with no new header inserted above it.
|
||||||
|
- AE7. **Covers R12.** Given the active trip, when a reader views the homepage active-trip view, then its in-column header is unchanged — no description block and no banner strip are added there.
|
||||||
|
|
||||||
|
### Scope Boundaries
|
||||||
|
|
||||||
|
- The full-bleed, story-style hero banner treatment for trips.
|
||||||
|
- A new header rendered above the map+journal split (the one-liner, description, and banner extend the existing in-column header instead).
|
||||||
|
- Any change to the map/journal two-column split (layout, columns, feed order, filter bar).
|
||||||
|
- Any change to the homepage active-trip view's header (the trip-page additions are gated to the trip-page caller of the shared `trip-feed-col` partial).
|
||||||
|
- A separate one-liner field distinct from `header.tagline`, or a separate description field distinct from the markdown `content`.
|
||||||
|
- Showing the full description on the trip-list cards.
|
||||||
|
- Author-adjustable crop / focal-point control for the banner (fixed center-crop only).
|
||||||
|
|
||||||
|
### Dependencies / Assumptions
|
||||||
|
|
||||||
|
- Confirmed (2026-07-05): `header.tagline` and the markdown `content` field are already present and editable in the admin trip form (`trip.yaml`), so R1/R2 need no new admin fields. `header.cover_image` is currently a plain `text` field.
|
||||||
|
- Resolved (2026-07-05): the media-picker for `cover_image` (R3) uses Grav core's `pagemediaselect` field type. Confirmed present in the Admin2 v2.0.11 compiled field-type registry (`app/_app/immutable/chunks/DzO1nmNX.js`), where `pagemediaselect`, `mediapicker`, and `filepicker` all route to the same picker component. It binds to the page's own media and stores the selected filename — the same value shape `header.cover_image` holds today — so the `trip.media[cover_image]` template lookups need no change and no text-field fallback is required.
|
||||||
|
- Grav's image derivative + `srcset` helpers are available in Twig for producing the larger and retina cover renditions.
|
||||||
|
- Cover source photos are assumed ≥1440px wide. A smaller source cannot be sharpened by a larger derivative (Grav upscales), so the R6 sharpness goal depends on adequate source resolution, not just a bigger render box.
|
||||||
|
- Before enabling description rendering, grep existing `user/pages/01.trips/*/trip.md` for non-empty `content` bodies and confirm each reads as a public description or is intentionally cleared. Verified empty across the four current `trip.md` files as of 2026-07-05; the check guards future/other trips.
|
||||||
|
- Reusing one `header.tagline` across the homepage highlight card, the trip-list card, and the trip-page header assumes the existing per-trip tagline copy reads acceptably on all three; per-surface opt-out is out of scope. Audit current taglines before shipping.
|
||||||
|
- Reusing the markdown `content` body as the description means a future long-form trip article distinct from the short summary would require splitting the field — accepted tradeoff.
|
||||||
|
|
||||||
|
### Follow-up (post-implementation)
|
||||||
|
|
||||||
|
- Backfill one-liners and descriptions for the active and past trips so the reader-facing summary goal is actually realized — the four current `trip.md` files have empty `content` bodies, so shipping the plumbing alone leaves existing trips showing title/dates only.
|
||||||
|
|
||||||
|
### Sources / Research
|
||||||
|
|
||||||
|
- `user/themes/intotheeast/templates/trips.html.twig` — current trip-list card markup and `cropResize(720, 240)` cover logic with first-entry fallback.
|
||||||
|
- `user/themes/intotheeast/templates/trip.html.twig` — current trip page (map+feed via `entry-map` and `trip-feed-col` partials; no dedicated header above the split).
|
||||||
|
- `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` — the shared feed-column header (`home-trip-header`: title, dates, counts, filter bar, panel toggles) that this plan extends with the one-liner, description, and banner; **also included by `home.html.twig`'s active-trip branch**, hence the trip-page gating in R12.
|
||||||
|
- `user/themes/intotheeast/blueprints/trip.yaml` — existing `header.tagline` (homepage-card copy) and markdown `content` ("Description") fields; `header.cover_image` as a text field.
|
||||||
|
- `user/themes/intotheeast/templates/story.html.twig` — existing hero pattern (the rejected full-bleed reference).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning Contract
|
||||||
|
|
||||||
|
**Product Contract preservation:** changed — R13 (description is now expandable rather than a fixed clamp) and the `cover_image` picker assumption (resolved: `pagemediaselect` confirmed renderable in Admin2 v2.0.11, text-field fallback dropped), both per owner decision on 2026-07-05. All other Product Contract IDs unchanged.
|
||||||
|
|
||||||
|
### Key Technical Decisions
|
||||||
|
|
||||||
|
- KTD1. **`pagemediaselect` for the cover field, no fallback.** Change `header.cover_image` in `trip.yaml` from `type: text` to `type: pagemediaselect`. Confirmed renderable in Admin2 v2.0.11 (see the resolved dependency note above). Because it stores the selected filename — the value shape `cover_image` already holds — the existing `trip.media[trip.header.cover_image]` lookups in the templates are unchanged. Rejected the text-field fallback: unnecessary once the field type was verified to render.
|
||||||
|
- KTD2. **One shared cover macro, not duplicated resolution.** The trip-list card and the trip-page banner need the same three-step cover resolution (author-selected → first journal entry's first image → none, per R7/R11) and the same retina rendering (R6/R14). Put both in a new `macros/cover.html.twig` so the two surfaces cannot drift. Rejected copy-pasting the current inline `trips.html.twig` logic into the partial: two copies of R7/R11 to keep in sync.
|
||||||
|
- KTD3. **Retina via two explicit `cropResize` derivatives + `srcset`, not Grav's native helper.** Render a 1× and a 2× derivative with `cropResize` and emit an explicit `srcset` (e.g. `720w`, `1440w` for the card). This mirrors the existing working `cropResize(720, 240)` call and gives exact control, with no dependency on Grav's auto-`srcset`/`derivatives` config. The CSS crop (`object-fit: cover`, fixed `aspect-ratio`) is unchanged — only the derivative resolution and the `srcset` attribute change. Rejected `Medium.derivatives()`: adds a config dependency for no gain here.
|
||||||
|
- KTD4. **Gate the header extras with a partial parameter that defaults off.** Add a `trip_header_extras` parameter to `trip-feed-col.html.twig`, defaulted to `false`. `trip.html.twig` passes it `true`; `home.html.twig` is left untouched, so its `include ... only` omits the parameter and the active-trip header renders exactly as today (satisfies R12/AE7 with zero edits to the home template). Rejected a positive flag on the home caller: more edits, more regression surface, on the branch the plan must not change.
|
||||||
|
- KTD5. **Expandable description as inline progressive enhancement.** Render the 2–3-line preview and the full body in markup, and toggle an expanded class with a small inline `<script>` in the partial — the same pattern the partial already uses for `initTripStats`. Avoids touching `js/src/main.js` and the `make build-assets` step. Rejected a fixed CSS-only clamp: it would make the full description unreadable anywhere (owner decision). Rejected a `<details>`/`<summary>` element: harder to style the collapsed state as a clean N-line preview.
|
||||||
|
|
||||||
|
### High-Level Technical Design
|
||||||
|
|
||||||
|
The trip-page in-column header (`.home-trip-header`), when `trip_header_extras` is true, stacks in this order. Everything from the filter bar down is unchanged; the home active-trip caller renders only the unshaded rows.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
T["h1 title (existing)"]
|
||||||
|
O["one-liner — header.tagline (R8, new)"]
|
||||||
|
D["dates (existing)"]
|
||||||
|
C["counts (existing)"]
|
||||||
|
DESC["description — content, 2-3 line preview + expand (R8/R13, new)"]
|
||||||
|
B["banner strip ~180-220px — cover macro (R9, new)"]
|
||||||
|
F["filter bar (existing, unchanged)"]
|
||||||
|
P["panel toggles (existing, unchanged)"]
|
||||||
|
T --> O --> D --> C --> DESC --> B --> F --> P
|
||||||
|
SPLIT["map + journal two-column split — unchanged, stays above the fold (R10/R15)"]
|
||||||
|
P -.-> SPLIT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assumptions & Constraints
|
||||||
|
|
||||||
|
- Only `css/style.css` and the `.html.twig` templates are hand-edited; both are loaded directly (`base.html.twig` links `css/style.css`), so no build step is needed for this work. `css-compiled/main.css` is esbuild output and is not touched.
|
||||||
|
- Banner dimensions (1× render box and mobile height) are tunable during implementation within the R9 ~180–220px envelope; the plan fixes the approach, not the exact pixel values.
|
||||||
|
- Source photos are assumed ≥1440px wide (Product Contract dependency); a smaller source cannot be sharpened by a larger derivative.
|
||||||
|
|
||||||
|
### Sequencing
|
||||||
|
|
||||||
|
U1 and U2 are independent and can land first in either order. U3 and U4 both consume the U2 macro. U5 (CSS) supports U3 and U4 and should land with them for meaningful visual verification. Order: U1 → U2 → (U3, U4) → U5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
### U1. Cover field → `pagemediaselect`
|
||||||
|
|
||||||
|
- **Goal:** Replace the type-in-a-filename cover control with an Admin2 media picker (R3).
|
||||||
|
- **Requirements:** R3.
|
||||||
|
- **Dependencies:** none.
|
||||||
|
- **Files:** `user/themes/intotheeast/blueprints/trip.yaml`
|
||||||
|
- **Approach:** Change `header.cover_image` from `type: text` to `type: pagemediaselect`. Keep the label, refresh the help text (pick from images uploaded to this trip page). The stored value stays a filename, so no template change is required here.
|
||||||
|
- **Patterns to follow:** existing field definitions in `trip.yaml`; field type verified against the Admin2 v2.0.11 registry.
|
||||||
|
- **Test scenarios:** Test expectation: none — admin-only blueprint config with no automated test surface. Verified manually in U-level verification: the picker renders in the Admin2 trip form, lists the page's uploaded images, and saves the chosen filename into `header.cover_image`.
|
||||||
|
- **Verification:** In Admin2, the trip form shows a media dropdown (not a text box); selecting an image and saving writes its filename to the page header.
|
||||||
|
|
||||||
|
### U2. Shared cover macro
|
||||||
|
|
||||||
|
- **Goal:** Centralize cover resolution + retina rendering for reuse by the list card and the trip-page banner (R6, R7, R11, R14).
|
||||||
|
- **Requirements:** R6, R7, R11, R14.
|
||||||
|
- **Dependencies:** none (U1 not required — resolution reads the same `header.cover_image` filename regardless of how it was set).
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/macros/cover.html.twig` (new)
|
||||||
|
- **Approach:** Two macros.
|
||||||
|
- `resolve(trip_page)` → returns a Medium or null: if `trip_page.header.cover_image` is set and `trip_page.media[...]` resolves, return it; else look up `grav.pages.find(trip_page.route ~ '/dailies')`, take the first published entry's first image if present; else null. This encodes R7 (fallback) and R11 (a set-but-missing `cover_image` falls through to the auto-pick rather than returning a broken reference).
|
||||||
|
- `img(medium, alt, w, h)` → emits `<img src=cropResize(w,h).url srcset="…(w)w, …(2w)w" sizes=… alt=alt loading="lazy">` using `cropResize(w, h)` and `cropResize(w*2, h*2)` (R6, R14).
|
||||||
|
- **Patterns to follow:** the existing inline resolution in `trips.html.twig:16-29`; the existing `cropResize(...).url` calls in the theme; other macros under `user/themes/intotheeast/templates/macros/`.
|
||||||
|
- **Test scenarios:** the macro has no standalone harness; these are asserted through the rendered DOM in U3/U4 specs — `cover_image` set + resolvable returns that image; `cover_image` set but file missing falls back to the first-entry image (R11); no `cover_image` but an entry image exists returns the first-entry image (R7/AE3); no `cover_image` and no entry images returns null (drives AE4); rendered `<img>` carries both `srcset` candidates (R6/AE5) and `alt` equal to the trip title (R14).
|
||||||
|
- **Verification:** both U3 and U4 render covers through this macro with identical fallback behavior; no inline cover-resolution logic remains in either caller.
|
||||||
|
|
||||||
|
### U3. Trip-list card: one-liner + retina cover
|
||||||
|
|
||||||
|
- **Goal:** Show the one-liner on list cards and render the cover sharply, via the shared macro (R5, R6, R7).
|
||||||
|
- **Requirements:** R5, R6, R7, R11, R14.
|
||||||
|
- **Dependencies:** U2.
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/trips.html.twig`, `tests/ui/trip/trips-list.spec.js` (new)
|
||||||
|
- **Approach:** Import `macros/cover.html.twig`. Replace the inline cover block (`trips.html.twig:16-29`) with `cover.resolve(trip)` + `cover.img(cover, trip.title, 720, 240)` inside the existing `.trip-card-cover` wrapper (keeps the 3:1 aspect + `object-fit: cover`). Add a one-liner line rendering `trip.header.tagline`, between `.trip-card-title` and `.trip-card-meta`, only when the tagline is set (R5).
|
||||||
|
- **Patterns to follow:** existing card markup and classes in `trips.html.twig`; `.trip-card-cover` CSS at `css/style.css:1089`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Covers R5. A trip with a tagline renders a one-liner element between the title and the meta line.
|
||||||
|
- Covers R5/AE1. A trip with no tagline renders no one-liner element.
|
||||||
|
- Covers R6/AE5. The card cover `<img>` exposes a `srcset` with 720w and 1440w candidates.
|
||||||
|
- Covers R7/AE3. With no `cover_image` set, the card cover uses the first journal entry's first image.
|
||||||
|
- Covers R11. With a `cover_image` pointing at a missing file, the card falls back to the auto-pick and renders no broken image.
|
||||||
|
- Covers R14. The cover `alt` equals the trip title.
|
||||||
|
- **Verification:** the past-trips list shows one-liners where set and sharp covers on a 2× DPR emulation.
|
||||||
|
|
||||||
|
### U4. Trip-page header extras (gated)
|
||||||
|
|
||||||
|
- **Goal:** Extend the in-column header with the one-liner, expandable description, and banner strip — for the trip-page caller only (R8, R9, R10, R12, R13).
|
||||||
|
- **Requirements:** R8, R9, R10, R12, R13.
|
||||||
|
- **Dependencies:** U2.
|
||||||
|
- **Files:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`, `user/themes/intotheeast/templates/trip.html.twig`, `tests/ui/trip/trip-header.spec.js` (new), `tests/ui/home/home.spec.js` (extend for AE7)
|
||||||
|
- **Approach:** Add a `trip_header_extras` parameter to the partial, `|default(false)`. In `trip.html.twig`'s `include`, pass `trip_header_extras: true`; leave `home.html.twig` untouched (its `include ... only` omits the parameter → default false → unchanged, per KTD4/R12). Inside `.home-trip-header`, gated on the flag and on each value's presence, render in the HTD order: one-liner (`trip_page.header.tagline`) directly below the title (R8); description (`trip_page.content|raw`) below the counts as a 2–3-line preview plus an expand control (R8/R13); banner strip below the description and above the filter bar using `cover.resolve(trip_page)` + `cover.img(...)` at banner dimensions, omitted entirely when resolve returns null (R9/AE4). Add a small inline `<script>` (alongside the existing `initTripStats` script) that toggles the expanded class on the description. The map+journal split and everything from the filter bar down are not touched (R10).
|
||||||
|
- **Patterns to follow:** the existing `.home-trip-header` block and inline `<script>` in `trip-feed-col.html.twig`; the `include ... with {...} only` calls in `trip.html.twig` and `home.html.twig`.
|
||||||
|
- **Test scenarios:**
|
||||||
|
- Covers R8/AE2. Trip page with a tagline and no description shows the one-liner below the title and no description block.
|
||||||
|
- Covers R8/R13. Trip page with a description shows a clamped preview plus an expand control that reveals the full text.
|
||||||
|
- Covers R8/R9. Trip page with tagline + description + cover shows one-liner, description, and a banner strip positioned above the filter bar.
|
||||||
|
- Covers R9/AE3. Trip with no `cover_image` but an entry image shows the banner using the first-entry image.
|
||||||
|
- Covers R9/AE4. Trip with no cover and no entry images renders a text-only header with no banner element.
|
||||||
|
- Covers R10/AE6. The map + journal two-column split renders unchanged with no new header inserted above it.
|
||||||
|
- Covers R12/AE7. The homepage active-trip view renders no description block and no banner strip (assertion added to `home.spec.js`).
|
||||||
|
- **Verification:** trip page shows the extras in HTD order and expands the description; the homepage active-trip header is visually identical to before.
|
||||||
|
|
||||||
|
### U5. Header + banner CSS
|
||||||
|
|
||||||
|
- **Goal:** Style the one-liner, expandable description, and banner strip, and keep the split above the fold on narrow viewports (R6 display, R9, R13, R15).
|
||||||
|
- **Requirements:** R9, R13, R15.
|
||||||
|
- **Dependencies:** U3, U4 (styles the markup they add).
|
||||||
|
- **Files:** `user/themes/intotheeast/css/style.css`
|
||||||
|
- **Approach:** Add rules for the trip-card one-liner, the header one-liner, the description preview/expanded states, the expand control, and `.trip-header-banner` (full width, fixed height in the ~180–220px envelope, `object-fit: cover`, matching radius/spacing of the header). Collapse the description preview with a fixed `max-height` + `overflow: hidden` (the expanded state lifts the cap), **not** `-webkit-line-clamp`: `content|raw` renders multi-paragraph markdown (multiple `<p>`), and line-clamp reliably clamps only a single block box, so it would not hold the 2–3-line preview across paragraphs. Add a mobile `@media` block that reduces banner height and reflows the header text so the map+journal split is not pushed off-screen (R15). The existing `.trip-card-cover` needs no change — `object-fit: cover` + `aspect-ratio: 3/1` already crop the larger derivative.
|
||||||
|
- **Patterns to follow:** existing `.home-trip-header`, `.trip-dates`, `.home-trip-counts` (`css/style.css:926-951`) and `.trip-card-cover` (`css/style.css:1089`); the theme's CSS custom properties (`--space-*`, `--text-*`, `--color-*`).
|
||||||
|
- **Test scenarios:** Test expectation: none — presentational CSS; structural correctness (element presence, expand toggle) is asserted by U3/U4 specs, and appearance/reflow is verified visually including a narrow-viewport check.
|
||||||
|
- **Verification:** on desktop and a mobile viewport, the banner and header text render cleanly and the map+journal split remains visible without scrolling past a wall of header content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Contract
|
||||||
|
|
||||||
|
Dev server: the worktree's Docker dev server at `http://localhost:8081` (`docker compose ... up`). Playwright specs live in the outer repo under `tests/ui/` and run against that server.
|
||||||
|
|
||||||
|
| Gate | Command / action | Applies to |
|
||||||
|
|---|---|---|
|
||||||
|
| New + extended UI specs pass | `npx playwright test tests/ui/trip/trips-list.spec.js tests/ui/trip/trip-header.spec.js tests/ui/home/home.spec.js` | U3, U4 |
|
||||||
|
| No regression in related suites | `npx playwright test tests/ui/trip tests/ui/home tests/ui/maps` | U4 (shared partial), U5 |
|
||||||
|
| Admin picker renders + saves | Manual: Admin2 → trip form → cover field is a media picker → select → save → confirm filename stored | U1 |
|
||||||
|
| Retina sharpness | Manual: DevTools at 2× DPR on `/trips` and a trip page → cover/banner load the 1440w derivative | U2, U3, U4 |
|
||||||
|
| Acceptance examples | Manual walkthrough of AE1–AE7 against a trip with/without tagline, description, and cover | all |
|
||||||
|
|
||||||
|
No lint/build step applies — the edited `css/style.css` and templates are served directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
**Global**
|
||||||
|
|
||||||
|
- AE1–AE7 all verified against real trip content (with and without tagline, description, and cover).
|
||||||
|
- New specs (`trips-list.spec.js`, `trip-header.spec.js`) and the `home.spec.js` AE7 assertion pass; existing `tests/ui/trip`, `tests/ui/home`, and `tests/ui/maps` suites still pass.
|
||||||
|
- Admin2 renders the `pagemediaselect` cover field and persists the selected filename.
|
||||||
|
- The homepage active-trip view is visually unchanged (no description block, no banner).
|
||||||
|
- No abandoned/experimental markup, CSS, or scripts left in the diff.
|
||||||
|
- Content backfill of one-liners and descriptions for existing trips remains a post-implementation follow-up (per the Product Contract) and is **not** required for done.
|
||||||
|
|
||||||
|
**Per unit**
|
||||||
|
|
||||||
|
| Unit | Done when |
|
||||||
|
|---|---|
|
||||||
|
| U1 | Cover field is a working Admin2 media picker storing a filename. |
|
||||||
|
| U2 | Both callers resolve and render covers through the macro; no inline cover logic remains. |
|
||||||
|
| U3 | List cards show one-liners where set and sharp retina covers with correct fallback; U3 specs pass. |
|
||||||
|
| U4 | Trip-page header shows one-liner, expandable description, and gated banner in HTD order; home view unchanged; U4 specs pass. |
|
||||||
|
| U5 | Header/banner styled; description expands; split stays above the fold on mobile. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-review follow-up (2026-07-07)
|
||||||
|
|
||||||
|
A structured code review of the finished diff produced fixes and two
|
||||||
|
intentionally-deferred findings.
|
||||||
|
|
||||||
|
**Applied**
|
||||||
|
|
||||||
|
- Cover picker restricted to images (`accept:` on the `cover_image`
|
||||||
|
`pagemediaselect` field) + macro resolves against `media.images`, so a
|
||||||
|
non-image selection (e.g. a `.gpx` from the trip page media) can no longer
|
||||||
|
route a non-image Medium into `cropResize`. Also hardens R11.
|
||||||
|
- Test quality: replaced a vacuous `toContainText` in the description-clamp
|
||||||
|
spec with real clamp/un-clamp assertions; corrected an R11 over-claim in the
|
||||||
|
trips-list spec header comment.
|
||||||
|
|
||||||
|
**Follow-up (2026-07-07)**
|
||||||
|
|
||||||
|
- **Banner/card cover quality fix.** The macro used `cropResize`, which
|
||||||
|
*fits-inside* preserving aspect ratio — so a portrait fallback source was
|
||||||
|
handed back as a ~165px sliver that the `object-fit:cover` box then upscaled
|
||||||
|
into a blur (reported on `us-canada-mex-2024`). Switched to **`cropZoom`**
|
||||||
|
(crop-to-fill → a real w×h cover strip). Retina is now **all-or-nothing**: the
|
||||||
|
2x `srcset` descriptor is emitted only when the source is genuinely ≥2×w
|
||||||
|
(`cover.width >= 2w`), else 1x-only — no upscaling, no intermediate widths.
|
||||||
|
Note: imported pixelfed photos cap at ~1440px wide, so auto-picked covers are
|
||||||
|
usually 1x-only; see `docs/working/backlog.md` (full-res re-import, luxury).
|
||||||
|
- **AE4 fixture removed.** The `no-photos-demo` fixture (and its browser test)
|
||||||
|
was deleted at the user's request — it surfaced as stray demo content in the
|
||||||
|
trip list. AE4 (no cover + no images → no banner) is a trivial else-branch of
|
||||||
|
the shared macro's `{% if cover %}` guard, covered by construction alongside
|
||||||
|
the R7/AE3 fallback tests. A regression test for the reported portrait-blur
|
||||||
|
bug now lives in `trip-header.spec.js` against `us-canada-mex-2024`.
|
||||||
|
|
||||||
|
**Intentionally deferred — explicit plan override (do not re-flag)**
|
||||||
|
|
||||||
|
- **Macro re-queries dailies/first-entry (reviewer: efficiency/maintainability).**
|
||||||
|
Deferred by design: **KTD2** puts cover resolution *inside* the shared macro
|
||||||
|
precisely so the list card and trip banner cannot drift. Moving resolution
|
||||||
|
out to callers reopens that drift; the extra `grav.pages.find()` is cached and
|
||||||
|
negligible.
|
||||||
|
- **Inline `<script>` for the description toggle should be bundled into
|
||||||
|
`js/src/main.js` (reviewer: convention).** Deferred by design: **U4's
|
||||||
|
Approach** explicitly specifies "a small inline `<script>` (alongside the
|
||||||
|
existing `initTripStats` script)." The inline placement is the plan's chosen
|
||||||
|
approach for a self-contained ~15-line toggle, not an oversight.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user