diff --git a/.gitignore b/.gitignore
index 007f44b..8eca655 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/plugins/
+!/plugins/cache-on-save/
/data/
diff --git a/accounts/.htaccess b/accounts/.htaccess
new file mode 100644
index 0000000..9fc26b8
--- /dev/null
+++ b/accounts/.htaccess
@@ -0,0 +1,10 @@
+# Deny all direct web access to this folder and everything beneath it.
+# Grav reads these files server-side; they must never be served over HTTP.
+# This is a defense-in-depth backup for the rules in the site root .htaccess.
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/blueprints/entry.yaml b/blueprints/entry.yaml
index c5c6e67..f26a14f 100644
--- a/blueprints/entry.yaml
+++ b/blueprints/entry.yaml
@@ -41,20 +41,54 @@ form:
title: Location
fields:
+ header.location_city:
+ type: text
+ label: City
+ placeholder: 'e.g. Kyoto'
+
+ header.location_country:
+ type: text
+ label: Country
+ placeholder: 'e.g. Japan'
+
header.lat:
type: number
label: Latitude
- help: 'GPS latitude (for map, Milestone 2)'
+ help: 'GPS latitude (for map)'
placeholder: '48.8566'
step: any
header.lng:
type: number
label: Longitude
- help: 'GPS longitude (for map, Milestone 2)'
+ help: 'GPS longitude (for map)'
placeholder: '2.3522'
step: any
+ weather:
+ type: tab
+ title: Weather
+
+ fields:
+ header.weather_temp_c:
+ type: number
+ label: 'Temperature (°C)'
+ help: 'Auto-filled from post form. Edit if needed.'
+ step: 1
+
+ header.weather_desc:
+ type: select
+ label: 'Weather Condition'
+ options:
+ Sunny: '☀️ Sunny'
+ 'Partly cloudy': '⛅ Partly cloudy'
+ Cloudy: '☁️ Cloudy'
+ Foggy: '🌫️ Foggy'
+ Drizzle: '🌦️ Drizzle'
+ Rain: '🌧️ Rain'
+ Snow: '❄️ Snow'
+ Thunderstorm: '⛈️ Thunderstorm'
+
publishing:
type: tab
title: Publishing
diff --git a/config/.htaccess b/config/.htaccess
new file mode 100644
index 0000000..9fc26b8
--- /dev/null
+++ b/config/.htaccess
@@ -0,0 +1,10 @@
+# Deny all direct web access to this folder and everything beneath it.
+# Grav reads these files server-side; they must never be served over HTTP.
+# This is a defense-in-depth backup for the rules in the site root .htaccess.
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/config/plugins/login.yaml b/config/plugins/login.yaml
index f801626..b42eb69 100644
--- a/config/plugins/login.yaml
+++ b/config/plugins/login.yaml
@@ -8,5 +8,5 @@ user_registration:
route: /login
built_in_css: true
built_in_js: true
-redirect_after_login: /tracker
+redirect_after_login: ''
site_host: https://intotheeast.com
diff --git a/config/system.yaml b/config/system.yaml
index 802fbb9..a63d8ec 100644
--- a/config/system.yaml
+++ b/config/system.yaml
@@ -125,10 +125,10 @@ cache:
server: null
port: null
twig:
- cache: true
+ cache: false
debug: true
auto_reload: true
- autoescape: true
+ autoescape: false
undefined_functions: true
undefined_filters: true
safe_functions: { }
@@ -195,7 +195,7 @@ media:
unsupported_inline_types: null
allowed_fallback_types: null
auto_metadata_exif: false
- upload_limit: 2097152
+ upload_limit: 104857600
session:
enabled: true
initialize: true
diff --git a/docs/bugs-and-fixes.md b/docs/bugs-and-fixes.md
new file mode 100644
index 0000000..d93b94e
--- /dev/null
+++ b/docs/bugs-and-fixes.md
@@ -0,0 +1,136 @@
+# Bugs & Fixes
+
+Backlog of confirmed bugs with root cause analysis and implementation spec for the fix.
+
+---
+
+## BUG-001 — New entry not visible after form submission
+
+**Status:** fixed 2026-06-18
+**Reported:** 2026-06-18
+
+### Symptom
+
+After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/tracker` feed or in the Grav Admin panel until the cache is manually flushed.
+
+### Root cause
+
+Grav's page-tree cache (`cache/doctrine/`) is not invalidated when `add-page-by-form` writes a new page to disk. The tracker template uses `page.children`, which Grav serves from cache — so the new child page is invisible until the cache is cleared.
+
+### Workaround (manual)
+
+Run in terminal after each submission:
+
+```bash
+docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
+```
+
+### Fix spec
+
+Wire cache-clear into the form process so it happens automatically on every successful submission.
+
+**Approach — custom Grav plugin event hook:**
+
+1. Create a small plugin `user/plugins/cache-on-save/` with one event listener:
+ - Listen on `onFormProcessed`
+ - When the form name is `new-entry`, call `$this->grav['cache']->deleteAll()` (note: `clear()` does not exist on `Grav\Common\Cache` in Grav 1.7)
+2. Enable the plugin in `user/config/plugins/cache-on-save.yaml`
+
+This is the cleanest approach: it fires exactly once per successful submission, requires no changes to `post-form.md`, and works for any future forms too.
+
+**Alternative — disable page cache entirely:**
+
+Set `cache: { enabled: false }` in `system.yaml`. Simpler but degrades frontend performance; not recommended for production.
+
+### Files to create/modify
+
+| File | Change |
+|------|--------|
+| `user/plugins/cache-on-save/cache-on-save.php` | New plugin, ~30 lines |
+| `user/plugins/cache-on-save/cache-on-save.yaml` | Plugin manifest, enabled: true |
+| `user/config/plugins/cache-on-save.yaml` | Runtime config, enabled: true |
+
+### Acceptance criteria
+
+1. Submit a new post via `/post`
+2. Navigate to `/tracker` — the new entry is visible immediately, no manual cache flush needed
+3. Grav Admin also shows the new page immediately
+
+---
+
+## BUG-002 — Stale Twig cache after theme file changes
+
+**Status:** fixed 2026-06-18
+**Reported:** 2026-06-18
+
+### Symptom
+
+After theme template files are added or modified (e.g., creating `partials/base.html.twig`), Grav's Twig compiled-template cache still holds the old compiled version. Pages that extend the changed file throw 500 errors like "Template partials/base.html.twig is not defined" even though the file exists on disk.
+
+### Root cause
+
+Grav caches compiled Twig templates in `cache/twig/`. When a new file is added, existing templates that reference it don't know to recompile — their cache entries are still valid from their own mtime perspective.
+
+### Workaround (manual)
+
+Run after any theme file is added or changed:
+
+```bash
+docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
+```
+
+### Fix spec
+
+Disable Twig template caching in development via `user/config/system.yaml`:
+
+```yaml
+twig:
+ cache: false
+```
+
+Acceptable for a single-user dev setup — eliminates both BUG-001's side-effect and this bug entirely. Performance cost is negligible at one-user scale. On production, leave Twig cache enabled (it's fine there because template files don't change at runtime).
+
+**Files to change:**
+
+| File | Change |
+|------|--------|
+| `user/config/system.yaml` | Add `twig: { cache: false }` under development section |
+
+### Acceptance criteria
+
+1. Add a new theme template file
+2. Reload any page — no 500 error, template works immediately without manual cache flush
+
+---
+
+## BUG-003 — One post per day limit; silent failure on duplicate date
+
+**Status:** fixed 2026-06-18
+**Reported:** 2026-06-18
+
+### Symptom
+
+Submitting a second post with the same date as an existing entry shows "Entry posted successfully!" but creates no file. The user's post is silently discarded.
+
+### Root cause
+
+The `add-page-by-form` plugin built the page slug from date only (`Y-m-d`), producing folder names like `2026-06-18.entry`. With `overwrite_mode: false`, if that folder already exists the plugin skips page creation but does not abort — the `message` process step runs regardless, showing a false success.
+
+### Fix
+
+Change the slug template in `user/pages/02.post/post-form.md` to include time and title:
+
+```twig
+{{ form.value.date|date('Y-m-d-Hi') }}-{{ form.value.title|lower|regex_replace('/[^a-z0-9]+/', '-')|trim('-') }}
+```
+
+Example: title "Arrived in Tokyo" at 14:30 on 2026-06-18 → `2026-06-18-1430-arrived-in-tokyo`
+
+The slug is locked at creation time. Renaming the title afterwards does not change the URL.
+
+### Acceptance criteria
+
+1. Submit two posts on the same day with different times or titles — both appear in `/tracker` as separate entries
+2. Renaming a post's title in the frontmatter does not break its URL
+
+---
diff --git a/docs/demo/README.md b/docs/demo/README.md
new file mode 100644
index 0000000..4ca4009
--- /dev/null
+++ b/docs/demo/README.md
@@ -0,0 +1,68 @@
+# Demo Content
+
+Seven sample journal entries for a fictional Japan + South Korea trip (March–April 2026). Used to showcase a populated site during development, design review, and QA — without touching real trip content.
+
+---
+
+## What's included
+
+| Entry | Date | Location | Weather | GPS |
+|---|---|---|---|---|
+| Wheels Down at Narita | 25 Mar · 15:40 | Tokyo, Japan | Sunny 16°C | 35.772, 140.393 |
+| Sakura in Ueno Park | 26 Mar · 10:00 | Tokyo, Japan | Partly cloudy 14°C | 35.716, 139.775 |
+| Summit Clouds and Snow | 27 Mar · 07:15 | Kawaguchiko, Japan | **Snow 1°C** | 35.510, 138.765 |
+| A Thousand Torii Gates | 28 Mar · 11:30 | Kyoto, Japan | Sunny 18°C | 34.967, 135.773 |
+| The Deer of Nara | 29 Mar · 14:00 | Nara, Japan | Partly cloudy 17°C | 34.685, 135.805 |
+| Dotonbori After Dark | 30 Mar · 18:00 | Osaka, Japan | Cloudy 19°C | 34.669, 135.502 |
+| Seoul Calling | 01 Apr · 09:00 | Seoul, South Korea | Rain 10°C | 37.564, 126.985 |
+
+**Features demonstrated:**
+- Feed: 7 entry cards (all text-only; add photos to entries to test the photo card variant)
+- Map: full route from Tokyo → Osaka → Seoul, with visible polyline
+- Stats: 7 entries, 2 countries, ~1,300 km traveled (Tokyo → Seoul straight-line sum)
+- Weather variety: Sunny, Partly cloudy, Snow, Rain, Cloudy
+- Multi-day, multi-city, multi-country trip structure
+
+---
+
+## How to deploy demo content
+
+```bash
+make demo-load
+```
+
+Open `http://localhost:8081/tracker` — all 7 entries should appear.
+
+---
+
+## How to reset (remove demo content)
+
+Removes all demo entries. Real entries with different slugs are left untouched.
+
+```bash
+make demo-reset
+```
+
+---
+
+## Adding photos to demo entries
+
+The demo entries have no photos (binary files aren't tracked in git). To test the photo card layout, drop one or more JPEG/PNG files into an entry folder:
+
+```bash
+user/docs/demo/tracker/2026-03-27-0715-summit-clouds-and-snow.entry/
+ entry.md
+ fuji-lake.jpg ← add any image here
+```
+
+Then copy the folder to the pages tree as above. The first image found will be used as the hero on the feed card and the entry hero.
+
+---
+
+## Notes
+
+- Entries use the same frontmatter format as real posts created via `/post`
+- Slug format matches the current system: `YYYY-MM-DD-HHmm-title-slug`
+- GPS coordinates are real and will show on the map
+- The snow entry (Kawaguchiko, 27 Mar) demonstrates snow weather badge rendering
+- Seoul entry crosses into a second country — verifies the Stats country count logic
diff --git a/docs/demo/tracker/2026-03-25-1540-wheels-down-narita.entry/entry.md b/docs/demo/tracker/2026-03-25-1540-wheels-down-narita.entry/entry.md
new file mode 100644
index 0000000..ac6ad6e
--- /dev/null
+++ b/docs/demo/tracker/2026-03-25-1540-wheels-down-narita.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'Wheels Down at Narita'
+date: '2026-03-25 15:40'
+template: entry
+published: true
+hero_image: ''
+lat: '35.7720'
+lng: '140.3929'
+location_city: 'Tokyo'
+location_country: 'Japan'
+weather_temp_c: 16
+weather_desc: 'Sunny'
+---
+
+Eleven hours of flight time, two mediocre films, and one surprisingly good noodle dish from the trolley. Then the descent through scattered cloud, the first glimpse of grey-green patchwork below, and that particular feeling when the wheels finally touch down on a continent you have never stood on before.
+
+Narita is large and orderly and very, very calm. Immigration moved faster than any airport I have ever been through. The officer looked at my passport, looked at me, stamped it once, and handed it back without a word. That was it. Entry to Japan.
+
+The Narita Express runs direct to Shinjuku. I found a window seat and spent 90 minutes watching the city materialise from the outside in — rice fields giving way to low housing, then arterial roads, then the sudden verticality of central Tokyo rising up all at once as if someone just switched a setting.
+
+The hotel is small but perfect. A room roughly the width of my arms outstretched, a window looking onto a grey concrete wall, and a bed that feels like sleeping on a cloud. I went out for ramen at a place around the corner where you order from a vending machine and sit at a counter alone with a small wooden partition between you and the next person. Nobody spoke. It was the best meal I have had in months.
+
+Tomorrow: Ueno. The forecast says the cherry blossoms may finally be open.
diff --git a/docs/demo/tracker/2026-03-26-1000-sakura-in-ueno-park.entry/entry.md b/docs/demo/tracker/2026-03-26-1000-sakura-in-ueno-park.entry/entry.md
new file mode 100644
index 0000000..0aab01f
--- /dev/null
+++ b/docs/demo/tracker/2026-03-26-1000-sakura-in-ueno-park.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'Sakura in Ueno Park'
+date: '2026-03-26 10:00'
+template: entry
+published: true
+hero_image: ''
+lat: '35.7155'
+lng: '139.7753'
+location_city: 'Tokyo'
+location_country: 'Japan'
+weather_temp_c: 14
+weather_desc: 'Partly cloudy'
+---
+
+I arrived at Ueno Park at ten in the morning thinking I would beat the crowds. I was wrong. Several thousand people had the same idea, and the same Instagram instinct. But here is the thing about cherry blossom season in Japan — the crowds are almost part of it. Families with picnic sheets. Couples with matching outfits. Office workers in suits sitting on blue tarps eating convenience-store onigiri. Everyone doing the same thing: looking up at the same trees.
+
+The blossoms were at maybe seventy percent. Enough to understand what the fuss is about.
+
+I walked the park from one end to the other and then sat under a particularly generous tree for about an hour just watching people react to something beautiful. There is a Japanese word for it — *hanami* — which translates roughly as "flower viewing" and is more or less an entire cultural practice. You do not rush past the blossoms. You sit with them.
+
+Later I found the Tokyo National Museum at the top of the park. Three floors of Japanese history, almost entirely in Japanese, which I cannot read, but context is its own language. A display case of Edo-period swords. Painted screens showing mountains I now recognise. A reconstructed tea house in the garden, closed for the season but visible through the glass.
+
+Dinner: tonkatsu on a side street off Ueno-Okachimachi station. The woman who runs the counter has been there for at least thirty years by the look of it. She refilled my miso soup without being asked, twice.
diff --git a/docs/demo/tracker/2026-03-27-0715-summit-clouds-and-snow.entry/entry.md b/docs/demo/tracker/2026-03-27-0715-summit-clouds-and-snow.entry/entry.md
new file mode 100644
index 0000000..2b6feea
--- /dev/null
+++ b/docs/demo/tracker/2026-03-27-0715-summit-clouds-and-snow.entry/entry.md
@@ -0,0 +1,27 @@
+---
+title: 'Summit Clouds and Snow'
+date: '2026-03-27 07:15'
+template: entry
+published: true
+hero_image: ''
+lat: '35.5095'
+lng: '138.7646'
+location_city: 'Kawaguchiko'
+location_country: 'Japan'
+weather_temp_c: 1
+weather_desc: 'Snow'
+---
+
+Nobody told me it would snow.
+
+I took the early bus from Shinjuku at 6:45am because the forecast for the Fuji Five Lakes region said "clear morning, clouds by noon." That is the window you want — Fuji is notorious for hiding inside its own weather system, and most visitors spend an entire day staring at a blank white sky where a mountain ought to be.
+
+I got the mountain. For about forty minutes.
+
+By the time the bus pulled into Kawaguchiko, the first flakes were already coming down. Light at first — the decorative kind that you hold your hand out for. Then, steadily, not decorative at all. I walked down to the lake with my bag under my jacket and stood at the water's edge while the snow thickened and Fuji turned from a sharply defined white cone into a suggestion, and then into nothing.
+
+The lake surface was perfectly still. The snow fell straight down. There were no other tourists on the path, or if there were I could not see them. It was one of those moments of completely accidental solitude that you cannot plan for and would not trade.
+
+I sat on a wooden bench on the lakefront for longer than made any meteorological sense. The snow kept falling. A single cormorant sat on a rock offshore and did not move the entire time I was there.
+
+Caught the bus back to Shinjuku in the afternoon. The mountain never reappeared. I do not mind even slightly.
diff --git a/docs/demo/tracker/2026-03-28-1130-thousand-torii-gates.entry/entry.md b/docs/demo/tracker/2026-03-28-1130-thousand-torii-gates.entry/entry.md
new file mode 100644
index 0000000..e1dd138
--- /dev/null
+++ b/docs/demo/tracker/2026-03-28-1130-thousand-torii-gates.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'A Thousand Torii Gates'
+date: '2026-03-28 11:30'
+template: entry
+published: true
+hero_image: ''
+lat: '34.9671'
+lng: '135.7727'
+location_city: 'Kyoto'
+location_country: 'Japan'
+weather_temp_c: 18
+weather_desc: 'Sunny'
+---
+
+The Shinkansen from Tokyo to Kyoto takes two hours and twelve minutes. You travel at 285km/h. At one point Fuji appears out the right-hand window, clear and enormous and completely snow-covered, and the entire carriage rotates slightly to look at it. The mountain is visible for about four minutes. Then it is gone.
+
+Kyoto is everything Tokyo is not: low, slow, wooden. The streets around Fushimi Inari were already warm with tourists at 11am but the shrine itself is large enough to absorb them. You walk under a tunnel of orange torii gates — thousands of them, each donated by a business and engraved with the donor's name — up a hillside through cedar forest, and the further you climb the more the crowd thins out.
+
+I walked for two hours. Most visitors turn back at the first lookout. I kept going, past smaller shrines and stone fox statues and mossy steps worn down by a century of feet. Near the top the path was almost empty. The air smelled of pine and incense.
+
+The city below spread out in all directions. Very few tall buildings — there are strict height regulations to preserve the sightlines. The Kamo River was a thin silver line running south. Distant mountains still wearing snow.
+
+Dinner at a kaiseki restaurant in Gion, the old entertainment district. Eight small courses, each plated like a small still life. I ate slowly and said nothing and it was the right approach.
diff --git a/docs/demo/tracker/2026-03-29-1400-deer-of-nara.entry/entry.md b/docs/demo/tracker/2026-03-29-1400-deer-of-nara.entry/entry.md
new file mode 100644
index 0000000..bd94b17
--- /dev/null
+++ b/docs/demo/tracker/2026-03-29-1400-deer-of-nara.entry/entry.md
@@ -0,0 +1,21 @@
+---
+title: 'The Deer of Nara'
+date: '2026-03-29 14:00'
+template: entry
+published: true
+hero_image: ''
+lat: '34.6851'
+lng: '135.8048'
+location_city: 'Nara'
+location_country: 'Japan'
+weather_temp_c: 17
+weather_desc: 'Partly cloudy'
+---
+
+The deer at Nara are not afraid of you. This is the first thing you notice — not just that they tolerate humans, but that they regard you with a kind of benign indifference that borders on contempt. They walk into traffic. They push their noses into your pockets. They bow, which sounds enchanting and is, in practice, a manoeuvre to knock crackers out of your hand faster.
+
+I bought a small bundle of *shika senbei* — deer crackers — from a vendor at the park entrance. They were gone in about forty-five seconds to a small gang of deer who appeared from nowhere and surrounded me in a tight semicircle. One bit my sleeve. Another headbutted a woman walking past who was not even involved.
+
+Todai-ji temple is at the far end of the park and contains the largest bronze Buddha in Japan. The building is immense — apparently it was rebuilt at two-thirds the original size in the 18th century and is still the largest wooden structure in the world. The Buddha sits in the dim interior looking calm about this. There is a wooden pillar near the back with a hole cut through its base the same width as one of the Buddha's nostrils. Schoolchildren queue to crawl through it. Wisdom awaits on the other side.
+
+The train back to Kyoto takes 45 minutes through flat agricultural land. The deer do not follow you.
diff --git a/docs/demo/tracker/2026-03-30-1800-dotonbori-after-dark.entry/entry.md b/docs/demo/tracker/2026-03-30-1800-dotonbori-after-dark.entry/entry.md
new file mode 100644
index 0000000..236e8af
--- /dev/null
+++ b/docs/demo/tracker/2026-03-30-1800-dotonbori-after-dark.entry/entry.md
@@ -0,0 +1,25 @@
+---
+title: 'Dotonbori After Dark'
+date: '2026-03-30 18:00'
+template: entry
+published: true
+hero_image: ''
+lat: '34.6687'
+lng: '135.5017'
+location_city: 'Osaka'
+location_country: 'Japan'
+weather_temp_c: 19
+weather_desc: 'Cloudy'
+---
+
+Osaka is louder than Kyoto and prouder of it. Kyoto has temples and restraint. Osaka has neon and takoyaki and a sign the size of a building advertising a restaurant with a mechanical crab on the front. Both are correct.
+
+I arrived from Kyoto mid-afternoon, dropped my bag, and went directly to Dotonbori to get my bearings before the evening crowd descended. The canal runs through the entertainment district, and on both sides there are restaurants stacked six floors high with illuminated signs competing for your attention so aggressively that after ten minutes you start to tune out the sensory overload and just walk.
+
+At six in the evening the neon started properly. The famous running man billboard. The Glico sign. Streets full of people eating while walking — takoyaki (octopus balls, better than they sound), skewered meats, cones of spicy shrimp. Osaka has a word for its own food philosophy: *kuidaore*, which means "eat until you drop."
+
+I took it as guidance.
+
+Three hours of eating across four separate establishments. Kushikatsu — battered and deep-fried everything — at a counter in an alley so narrow that diners on opposite sides can shake hands across the table. Soft-serve matcha ice cream on the street. Okonomiyaki from a woman who pressed the pancake flat with a heavy iron tool and would not let me touch anything.
+
+The canal was dark and the lights were reflected in it and for a while I just stood on the bridge watching people eat.
diff --git a/docs/demo/tracker/2026-04-01-0900-seoul-calling.entry/entry.md b/docs/demo/tracker/2026-04-01-0900-seoul-calling.entry/entry.md
new file mode 100644
index 0000000..1322725
--- /dev/null
+++ b/docs/demo/tracker/2026-04-01-0900-seoul-calling.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'Seoul Calling'
+date: '2026-04-01 09:00'
+template: entry
+published: true
+hero_image: ''
+lat: '37.5635'
+lng: '126.9851'
+location_city: 'Seoul'
+location_country: 'South Korea'
+weather_temp_c: 10
+weather_desc: 'Rain'
+---
+
+The flight from Osaka to Seoul takes one hour and forty minutes. Shorter than some commutes I have had. At Incheon I changed SIM cards, changed currency, changed alphabet, and walked out into a grey April morning with rain coming in off the Yellow Sea.
+
+Korea hits differently than Japan. Japan felt deliberate and enclosed, every surface managed, every system timed to the second. Seoul feels faster and more argumentative, as if things are still being decided. The streets around Myeongdong were already busy at 9am: coffee shops the size of ballrooms, street vendors selling *hotteok* (sweet pancakes) from portable griddles, and the particular energy of a city that moves at one speed regardless of the weather.
+
+My guesthouse is in Mapo-gu, a neighbourhood that turns out to be significantly cooler than anywhere the guidebooks sent me. Independent coffee roasters. Record shops. A gallery in a converted printing house showing black-and-white photography of the Han River in the 1970s.
+
+I spent the afternoon walking the Han River itself — a massive green ribbon running through the city with dedicated cycling paths, outdoor fitness equipment, and Koreans doing every possible outdoor activity despite the rain. A group of older men playing badminton with very serious expressions. Two people kayaking. A family of five sharing a communal barbecue under an umbrella.
+
+Dinner: Korean fried chicken at a place that opened at 5pm and was full by 5:05. Beer so cold it was almost painful. Outside, the rain kept up steadily. I stayed longer than I meant to.
diff --git a/docs/design/design-spec.md b/docs/design/design-spec.md
new file mode 100644
index 0000000..b6519d9
--- /dev/null
+++ b/docs/design/design-spec.md
@@ -0,0 +1,368 @@
+# Into the East — Design Spec
+
+**Date:** 2026-06-18
+**Status:** Approved for implementation
+
+---
+
+## 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
+
+| Token | Hex | Usage |
+|---|---|---|
+| `--color-ink` | `#17171A` | Primary text (near-black with cool undertone, like ink) |
+| `--color-ink-2` | `#4A4850` | Secondary text, body paragraphs |
+| `--color-ink-muted` | `#9896A0` | Labels, timestamps, captions, placeholder text |
+| `--color-paper` | `#F7F5F2` | Page background (warm paper white, not blue-white) |
+| `--color-canvas` | `#FFFFFF` | Card backgrounds, modals, form surfaces |
+| `--color-border` | `#E8E6E3` | Standard dividers, card borders |
+| `--color-border-soft` | `#F0EDEA` | Subtle section dividers |
+| `--color-accent` | `#1F6B5A` | Deep teal — brand color, links, CTAs, active states |
+| `--color-accent-hover` | `#185647` | Darkened accent for hover/pressed states |
+| `--color-accent-light` | `#EBF5F2` | Pale teal for highlight backgrounds |
+| `--color-accent-on` | `#FFFFFF` | Text on accent-colored surfaces |
+
+### Rationale for accent color
+
+Deep teal `#1F6B5A` was chosen over:
+- Blue (#0066cc current): too generic, too tech
+- Orange/saffron: clichéd for "Asia" travel design
+- Terracotta/cream: the most common default for lifestyle/travel blogs
+
+Teal evokes bamboo, celadon porcelain, ancient jade, the color of temple gardens — all without being literal or kitsch. It works cleanly against both the warm paper background and white card surfaces.
+
+---
+
+## 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` (white), 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:
+- Leaflet 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 `/tracker` (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 /tracker (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)
+- Leaflet: 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 | Leaflet.js (current) | Already in use, no reason to change |
+| 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) |
diff --git a/docs/milestone-1-spec.md b/docs/milestone-1-spec.md
new file mode 100644
index 0000000..6413fff
--- /dev/null
+++ b/docs/milestone-1-spec.md
@@ -0,0 +1,193 @@
+# 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.
+
+---
+
+## 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 `
` 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
diff --git a/docs/milestone-2-spec.md b/docs/milestone-2-spec.md
new file mode 100644
index 0000000..2e7337b
--- /dev/null
+++ b/docs/milestone-2-spec.md
@@ -0,0 +1,166 @@
+# 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.
+
+---
+
+## 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 `
+{% endblock %}
+```
+
+- [ ] **Step 3: Replace the Post form CSS section**
+
+Find `/* ── Post form ──` in style.css. Replace it entirely with:
+
+```css
+/* ── Post form ──────────────────────────────────────────────────────────────── */
+
+.post-form-wrap h1 {
+ font-family: var(--font-display);
+ font-size: var(--text-xl);
+ font-weight: 400;
+ margin-bottom: var(--space-6);
+ color: var(--color-ink);
+}
+
+/* Hide GPS coordinate fields — filled by JS, not user-facing */
+.gps-hidden-field { display: none !important; }
+
+/* Grav form field wrappers */
+.form-field { margin-bottom: var(--space-5); }
+.form-label label {
+ display: block;
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--color-ink);
+ margin-bottom: var(--space-2);
+}
+
+.form-field input[type="text"],
+.form-field input[type="email"],
+.form-field input[type="datetime-local"],
+.form-field textarea {
+ width: 100%;
+ font-family: var(--font-ui);
+ font-size: var(--text-base);
+ padding: 0.875rem 1rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-canvas);
+ color: var(--color-ink);
+ min-height: 44px;
+ transition: border-color 0.15s;
+ -webkit-appearance: none;
+}
+
+.form-field input:focus,
+.form-field textarea:focus {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 1px;
+ border-color: var(--color-accent);
+}
+
+.form-field textarea { resize: vertical; min-height: 160px; line-height: var(--leading-normal); }
+
+/* Submit button — Grav renders it as .btn or input[type=submit] */
+.form-actions input[type="submit"],
+.form-actions .btn,
+.form-actions button[type="submit"] {
+ display: block;
+ width: 100%;
+ padding: 1rem;
+ min-height: 52px;
+ background: var(--color-accent);
+ color: var(--color-accent-on);
+ border: none;
+ border-radius: var(--radius-md);
+ font-family: var(--font-ui);
+ font-size: var(--text-base);
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+ margin-top: var(--space-6);
+}
+
+.form-actions input[type="submit"]:hover,
+.form-actions button[type="submit"]:hover { background: var(--color-accent-hover); }
+
+/* Action buttons row (Get Location, Get Weather) */
+.form-action-row {
+ display: flex;
+ gap: var(--space-3);
+ margin-top: var(--space-5);
+}
+
+.btn-action {
+ flex: 1;
+ padding: 0.75rem var(--space-3);
+ min-height: 44px;
+ background: var(--color-canvas);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ font-family: var(--font-ui);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ cursor: pointer;
+ color: var(--color-ink);
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.btn-action:hover { background: var(--color-paper); border-color: var(--color-accent); }
+
+/* Status feedback lines */
+.form-status {
+ font-size: var(--text-sm);
+ color: var(--color-ink-muted);
+ margin-top: var(--space-2);
+ min-height: 1.4em;
+}
+
+.form-status--ok { color: var(--color-accent); }
+.form-status--err { color: #B44A2A; }
+```
+
+- [ ] **Step 4: Verify post form**
+
+Open `http://100.96.115.96:8081/post` (logged in). Verify:
+- Lat/lng inputs not visible (`.gps-hidden-field` hidden via CSS)
+- Inputs have rounded corners, proper padding, focus ring in teal
+- "Get Location" and "Get Weather" buttons side by side, same width
+- "Post Entry" (or whatever the submit label is) in teal, full-width
+- Tap "Get Location" → status line shows "✓ Location captured · lat, lng" in teal
+- Mobile at 375px: all inputs and buttons are thumb-friendly
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user
+git add pages/02.post/post-form.md themes/intotheeast/templates/post-form.html.twig themes/intotheeast/css/style.css
+git commit -m "feat: redesign post form — hide GPS fields, teal CTA, better mobile UX"
+```
+
+---
+
+### Task 7: Stats + map + mini-map styling
+
+**Files:**
+- Modify: `user/themes/intotheeast/templates/stats.html.twig`
+- Modify: `user/themes/intotheeast/css/style.css` (Map, Stats, Mini-map sections)
+
+**Interfaces:**
+- Produces: styled stats page and map page using design tokens
+
+- [ ] **Step 1: Update stats page heading**
+
+In `user/themes/intotheeast/templates/stats.html.twig`, replace the `
` tag with:
+
+```twig
+
+
Trip Statistics
+```
+
+(Remove the inline `style` attribute from the existing h1.)
+
+- [ ] **Step 2: Replace Stats + Map + Mini-map CSS sections**
+
+Find `/* ── Map page ──` through end of `/* ── Mini-map on tracker feed ──`. Replace all three sections with:
+
+```css
+/* ── Map page ───────────────────────────────────────────────────────────────── */
+
+.map-page .site-main { max-width: none; padding: 0; }
+
+.map-container {
+ height: calc(100vh - var(--site-header-height));
+ width: 100%;
+}
+
+.map-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--color-ink-muted);
+ font-style: italic;
+}
+
+/* ── Stats page ─────────────────────────────────────────────────────────────── */
+
+.stats-heading {
+ font-family: var(--font-display);
+ font-size: var(--text-2xl);
+ font-weight: 400;
+ margin-bottom: var(--space-8);
+ color: var(--color-ink);
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-4);
+ margin-bottom: var(--space-8);
+}
+
+.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);
+}
+
+.stat-value {
+ display: block;
+ font-family: var(--font-display);
+ font-size: var(--text-3xl);
+ font-weight: 400;
+ color: var(--color-accent);
+ line-height: 1.1;
+ margin-bottom: var(--space-2);
+}
+
+.stat-label {
+ display: block;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--color-ink-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+}
+
+.stats-countries {
+ font-size: var(--text-sm);
+ color: var(--color-ink-2);
+ text-align: center;
+ line-height: 1.9;
+}
+
+.stats-countries-label {
+ font-weight: 600;
+ display: block;
+ margin-bottom: var(--space-2);
+ color: var(--color-ink);
+ text-transform: uppercase;
+ font-size: var(--text-xs);
+ letter-spacing: 0.07em;
+}
+
+.stats-note {
+ font-size: var(--text-xs);
+ color: var(--color-ink-muted);
+ text-align: center;
+ margin-top: var(--space-6);
+}
+
+/* ── Mini-map on tracker feed ────────────────────────────────────────────────── */
+
+.feed-map-wrap {
+ margin-bottom: var(--space-10);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-sm);
+}
+
+.feed-map {
+ height: 240px;
+ width: 100%;
+}
+
+@media (min-width: 520px) {
+ .feed-map { height: 300px; }
+}
+
+.feed-map-link {
+ display: block;
+ text-align: right;
+ font-size: var(--text-xs);
+ font-weight: 500;
+ color: var(--color-accent);
+ text-decoration: none;
+ padding: var(--space-2) var(--space-4);
+ background: var(--color-paper);
+ border-top: 1px solid var(--color-border);
+}
+
+.feed-map-link:hover { color: var(--color-accent-hover); }
+```
+
+- [ ] **Step 3: Update Leaflet marker colors**
+
+In `tracker.html.twig`, find the JS that sets marker colors. Update from `#0066cc` to `#1F6B5A` and from `#0044aa` to `#155244`:
+
+```js
+var color = isLatest ? '#155244' : '#1F6B5A';
+```
+
+Also update the polyline color:
+```js
+L.polyline(latLngs, { color: '#1F6B5A', weight: 3, opacity: 0.7 }).addTo(map);
+```
+
+Do the same in `map.html.twig` — update any hardcoded `#0066cc` colors to `#1F6B5A`.
+
+- [ ] **Step 4: Verify stats and map pages**
+
+Open `http://100.96.115.96:8081/stats`. Verify:
+- Heading in DM Serif Display
+- Numbers in DM Serif Display, teal color
+- Stat cards on warm white background with subtle shadow
+- Labels uppercase, muted gray
+
+Open `http://100.96.115.96:8081/map`. Verify:
+- Map fills viewport below header
+- Markers are teal circles
+- Route line is teal
+- No horizontal scroll or layout issues
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user
+git add themes/intotheeast/templates/stats.html.twig themes/intotheeast/templates/tracker.html.twig themes/intotheeast/templates/map.html.twig themes/intotheeast/css/style.css
+git commit -m "feat: apply design tokens to stats, map, and mini-map"
+```
+
+---
+
+### Task 8: Mobile polish + reduced motion + final QA
+
+**Files:**
+- Modify: `user/themes/intotheeast/css/style.css` (add responsive + motion CSS)
+
+**Interfaces:**
+- Produces: fully responsive, accessible design at 320px–1440px viewport widths
+
+- [ ] **Step 1: Add reduced-motion support**
+
+At the bottom of `style.css`, append:
+
+```css
+/* ── Accessibility ───────────────────────────────────────────────────────────── */
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* Keyboard focus ring: visible on all interactive elements */
+:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+```
+
+- [ ] **Step 2: Verify header on 320px (smallest phone)**
+
+Set browser devtools to 320px viewport. Verify:
+- Site title and nav links both visible and not overlapping
+- If title overflows, add this to style.css:
+ ```css
+ @media (max-width: 380px) {
+ .site-title { font-size: var(--text-md); }
+ .site-nav a { padding: var(--space-2); font-size: 0.8rem; }
+ }
+ ```
+
+- [ ] **Step 3: Verify post form on mobile**
+
+On a real phone (or devtools 375px), open `/post` and verify:
+- Inputs do not trigger zoom on focus (font-size is 1rem ≥ 16px — already set)
+- "Post Entry" button is thumb-reachable (full-width, 52px min height)
+- Get Location / Get Weather buttons are side-by-side, each at least 44px tall
+- Status feedback visible after tapping location/weather
+
+- [ ] **Step 4: Cross-page smoke test checklist**
+
+Check each of these manually in the browser:
+
+| Page | Check |
+|---|---|
+| `/tracker` | Feed loads, entry cards show hero photos with overlays |
+| `/tracker` | Text-only cards (no photo) show date+location meta above title |
+| `/tracker` | Mini-map renders, teal markers and route |
+| `/map` | Full-height map, teal markers, route polyline |
+| `/map` | Tap marker → popup with date, title, "Read entry →" link |
+| `/stats` | 2×2 grid of stat blocks, teal numbers, correct counts |
+| `/tracker/
` | Hero image full-width at top (if photos exist) |
+| `/tracker/` | Title in DM Serif Display, large |
+| `/tracker/` | Photo gallery (2/3-col grid), lightbox opens on tap |
+| `/post` | Lat/lng fields NOT visible |
+| `/post` | Tap Post Entry → success message → redirects to /tracker |
+| Mobile 375px | All pages usable without horizontal scroll |
+| Mobile 375px | No font size < 14px for readable text |
+
+- [ ] **Step 5: Final commit**
+
+```bash
+cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user
+git add themes/intotheeast/css/style.css
+git commit -m "feat: add reduced-motion support, keyboard focus, mobile polish"
+```
+
+---
+
+### Task 9: Update documentation
+
+**Files:**
+- Modify: `user/docs/qa-test-plan.md` (add visual QA section)
+
+- [ ] **Step 1: Add visual design QA section to qa-test-plan.md**
+
+Append a new section to `user/docs/qa-test-plan.md`:
+
+```markdown
+## Visual Design QA — Redesign Checklist
+
+**Design spec:** `user/docs/design/design-spec.md`
+
+### Typography
+- [ ] DM Serif Display loads on entry titles, page headings, stats numbers, site title
+- [ ] DM Sans loads on all body text, nav, labels, form fields
+- [ ] No fallback font (Georgia/system-sans) visible in place of custom fonts
+
+### Colors
+- [ ] Page background is warm paper (#F7F5F2), not pure white
+- [ ] All links and CTAs use teal (#1F6B5A), not blue (#0066cc)
+- [ ] Active nav link is teal
+- [ ] Map markers and route polyline are teal
+
+### Entry cards
+- [ ] Cards with photos show full-bleed 16:9 image
+- [ ] Date + location overlay visible on photo gradient
+- [ ] Entry title below photo in DM Serif Display
+- [ ] Cards without photos show date/location meta row above title
+- [ ] Photo zoom on hover (desktop only)
+
+### Header
+- [ ] 3px teal bar at top of header
+- [ ] "into the east" title in DM Serif Display
+- [ ] Sticky on scroll
+
+### Post form
+- [ ] Lat/lng inputs not visible
+- [ ] "✓ Location captured" feedback in teal on success
+- [ ] Submit button full-width, teal, 52px+ height
+
+### Mobile
+- [ ] All interactive elements ≥ 44px touch target
+- [ ] No horizontal scroll at 375px
+- [ ] iOS: no font-size zoom on input focus
+```
+
+- [ ] **Step 2: Commit documentation**
+
+```bash
+cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user
+git add docs/qa-test-plan.md docs/design/design-spec.md docs/superpowers/plans/2026-06-18-ui-redesign.md
+git commit -m "docs: add UI redesign spec, plan, and visual QA checklist"
+```
diff --git a/pages/01.tracker/2026-03-25-1540-wheels-down-narita.entry/entry.md b/pages/01.tracker/2026-03-25-1540-wheels-down-narita.entry/entry.md
new file mode 100644
index 0000000..ac6ad6e
--- /dev/null
+++ b/pages/01.tracker/2026-03-25-1540-wheels-down-narita.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'Wheels Down at Narita'
+date: '2026-03-25 15:40'
+template: entry
+published: true
+hero_image: ''
+lat: '35.7720'
+lng: '140.3929'
+location_city: 'Tokyo'
+location_country: 'Japan'
+weather_temp_c: 16
+weather_desc: 'Sunny'
+---
+
+Eleven hours of flight time, two mediocre films, and one surprisingly good noodle dish from the trolley. Then the descent through scattered cloud, the first glimpse of grey-green patchwork below, and that particular feeling when the wheels finally touch down on a continent you have never stood on before.
+
+Narita is large and orderly and very, very calm. Immigration moved faster than any airport I have ever been through. The officer looked at my passport, looked at me, stamped it once, and handed it back without a word. That was it. Entry to Japan.
+
+The Narita Express runs direct to Shinjuku. I found a window seat and spent 90 minutes watching the city materialise from the outside in — rice fields giving way to low housing, then arterial roads, then the sudden verticality of central Tokyo rising up all at once as if someone just switched a setting.
+
+The hotel is small but perfect. A room roughly the width of my arms outstretched, a window looking onto a grey concrete wall, and a bed that feels like sleeping on a cloud. I went out for ramen at a place around the corner where you order from a vending machine and sit at a counter alone with a small wooden partition between you and the next person. Nobody spoke. It was the best meal I have had in months.
+
+Tomorrow: Ueno. The forecast says the cherry blossoms may finally be open.
diff --git a/pages/01.tracker/2026-03-26-1000-sakura-in-ueno-park.entry/entry.md b/pages/01.tracker/2026-03-26-1000-sakura-in-ueno-park.entry/entry.md
new file mode 100644
index 0000000..0aab01f
--- /dev/null
+++ b/pages/01.tracker/2026-03-26-1000-sakura-in-ueno-park.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'Sakura in Ueno Park'
+date: '2026-03-26 10:00'
+template: entry
+published: true
+hero_image: ''
+lat: '35.7155'
+lng: '139.7753'
+location_city: 'Tokyo'
+location_country: 'Japan'
+weather_temp_c: 14
+weather_desc: 'Partly cloudy'
+---
+
+I arrived at Ueno Park at ten in the morning thinking I would beat the crowds. I was wrong. Several thousand people had the same idea, and the same Instagram instinct. But here is the thing about cherry blossom season in Japan — the crowds are almost part of it. Families with picnic sheets. Couples with matching outfits. Office workers in suits sitting on blue tarps eating convenience-store onigiri. Everyone doing the same thing: looking up at the same trees.
+
+The blossoms were at maybe seventy percent. Enough to understand what the fuss is about.
+
+I walked the park from one end to the other and then sat under a particularly generous tree for about an hour just watching people react to something beautiful. There is a Japanese word for it — *hanami* — which translates roughly as "flower viewing" and is more or less an entire cultural practice. You do not rush past the blossoms. You sit with them.
+
+Later I found the Tokyo National Museum at the top of the park. Three floors of Japanese history, almost entirely in Japanese, which I cannot read, but context is its own language. A display case of Edo-period swords. Painted screens showing mountains I now recognise. A reconstructed tea house in the garden, closed for the season but visible through the glass.
+
+Dinner: tonkatsu on a side street off Ueno-Okachimachi station. The woman who runs the counter has been there for at least thirty years by the look of it. She refilled my miso soup without being asked, twice.
diff --git a/pages/01.tracker/2026-03-27-0715-summit-clouds-and-snow.entry/entry.md b/pages/01.tracker/2026-03-27-0715-summit-clouds-and-snow.entry/entry.md
new file mode 100644
index 0000000..2b6feea
--- /dev/null
+++ b/pages/01.tracker/2026-03-27-0715-summit-clouds-and-snow.entry/entry.md
@@ -0,0 +1,27 @@
+---
+title: 'Summit Clouds and Snow'
+date: '2026-03-27 07:15'
+template: entry
+published: true
+hero_image: ''
+lat: '35.5095'
+lng: '138.7646'
+location_city: 'Kawaguchiko'
+location_country: 'Japan'
+weather_temp_c: 1
+weather_desc: 'Snow'
+---
+
+Nobody told me it would snow.
+
+I took the early bus from Shinjuku at 6:45am because the forecast for the Fuji Five Lakes region said "clear morning, clouds by noon." That is the window you want — Fuji is notorious for hiding inside its own weather system, and most visitors spend an entire day staring at a blank white sky where a mountain ought to be.
+
+I got the mountain. For about forty minutes.
+
+By the time the bus pulled into Kawaguchiko, the first flakes were already coming down. Light at first — the decorative kind that you hold your hand out for. Then, steadily, not decorative at all. I walked down to the lake with my bag under my jacket and stood at the water's edge while the snow thickened and Fuji turned from a sharply defined white cone into a suggestion, and then into nothing.
+
+The lake surface was perfectly still. The snow fell straight down. There were no other tourists on the path, or if there were I could not see them. It was one of those moments of completely accidental solitude that you cannot plan for and would not trade.
+
+I sat on a wooden bench on the lakefront for longer than made any meteorological sense. The snow kept falling. A single cormorant sat on a rock offshore and did not move the entire time I was there.
+
+Caught the bus back to Shinjuku in the afternoon. The mountain never reappeared. I do not mind even slightly.
diff --git a/pages/01.tracker/2026-03-28-1130-thousand-torii-gates.entry/entry.md b/pages/01.tracker/2026-03-28-1130-thousand-torii-gates.entry/entry.md
new file mode 100644
index 0000000..e1dd138
--- /dev/null
+++ b/pages/01.tracker/2026-03-28-1130-thousand-torii-gates.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'A Thousand Torii Gates'
+date: '2026-03-28 11:30'
+template: entry
+published: true
+hero_image: ''
+lat: '34.9671'
+lng: '135.7727'
+location_city: 'Kyoto'
+location_country: 'Japan'
+weather_temp_c: 18
+weather_desc: 'Sunny'
+---
+
+The Shinkansen from Tokyo to Kyoto takes two hours and twelve minutes. You travel at 285km/h. At one point Fuji appears out the right-hand window, clear and enormous and completely snow-covered, and the entire carriage rotates slightly to look at it. The mountain is visible for about four minutes. Then it is gone.
+
+Kyoto is everything Tokyo is not: low, slow, wooden. The streets around Fushimi Inari were already warm with tourists at 11am but the shrine itself is large enough to absorb them. You walk under a tunnel of orange torii gates — thousands of them, each donated by a business and engraved with the donor's name — up a hillside through cedar forest, and the further you climb the more the crowd thins out.
+
+I walked for two hours. Most visitors turn back at the first lookout. I kept going, past smaller shrines and stone fox statues and mossy steps worn down by a century of feet. Near the top the path was almost empty. The air smelled of pine and incense.
+
+The city below spread out in all directions. Very few tall buildings — there are strict height regulations to preserve the sightlines. The Kamo River was a thin silver line running south. Distant mountains still wearing snow.
+
+Dinner at a kaiseki restaurant in Gion, the old entertainment district. Eight small courses, each plated like a small still life. I ate slowly and said nothing and it was the right approach.
diff --git a/pages/01.tracker/2026-03-29-1400-deer-of-nara.entry/entry.md b/pages/01.tracker/2026-03-29-1400-deer-of-nara.entry/entry.md
new file mode 100644
index 0000000..bd94b17
--- /dev/null
+++ b/pages/01.tracker/2026-03-29-1400-deer-of-nara.entry/entry.md
@@ -0,0 +1,21 @@
+---
+title: 'The Deer of Nara'
+date: '2026-03-29 14:00'
+template: entry
+published: true
+hero_image: ''
+lat: '34.6851'
+lng: '135.8048'
+location_city: 'Nara'
+location_country: 'Japan'
+weather_temp_c: 17
+weather_desc: 'Partly cloudy'
+---
+
+The deer at Nara are not afraid of you. This is the first thing you notice — not just that they tolerate humans, but that they regard you with a kind of benign indifference that borders on contempt. They walk into traffic. They push their noses into your pockets. They bow, which sounds enchanting and is, in practice, a manoeuvre to knock crackers out of your hand faster.
+
+I bought a small bundle of *shika senbei* — deer crackers — from a vendor at the park entrance. They were gone in about forty-five seconds to a small gang of deer who appeared from nowhere and surrounded me in a tight semicircle. One bit my sleeve. Another headbutted a woman walking past who was not even involved.
+
+Todai-ji temple is at the far end of the park and contains the largest bronze Buddha in Japan. The building is immense — apparently it was rebuilt at two-thirds the original size in the 18th century and is still the largest wooden structure in the world. The Buddha sits in the dim interior looking calm about this. There is a wooden pillar near the back with a hole cut through its base the same width as one of the Buddha's nostrils. Schoolchildren queue to crawl through it. Wisdom awaits on the other side.
+
+The train back to Kyoto takes 45 minutes through flat agricultural land. The deer do not follow you.
diff --git a/pages/01.tracker/2026-03-30-1800-dotonbori-after-dark.entry/entry.md b/pages/01.tracker/2026-03-30-1800-dotonbori-after-dark.entry/entry.md
new file mode 100644
index 0000000..236e8af
--- /dev/null
+++ b/pages/01.tracker/2026-03-30-1800-dotonbori-after-dark.entry/entry.md
@@ -0,0 +1,25 @@
+---
+title: 'Dotonbori After Dark'
+date: '2026-03-30 18:00'
+template: entry
+published: true
+hero_image: ''
+lat: '34.6687'
+lng: '135.5017'
+location_city: 'Osaka'
+location_country: 'Japan'
+weather_temp_c: 19
+weather_desc: 'Cloudy'
+---
+
+Osaka is louder than Kyoto and prouder of it. Kyoto has temples and restraint. Osaka has neon and takoyaki and a sign the size of a building advertising a restaurant with a mechanical crab on the front. Both are correct.
+
+I arrived from Kyoto mid-afternoon, dropped my bag, and went directly to Dotonbori to get my bearings before the evening crowd descended. The canal runs through the entertainment district, and on both sides there are restaurants stacked six floors high with illuminated signs competing for your attention so aggressively that after ten minutes you start to tune out the sensory overload and just walk.
+
+At six in the evening the neon started properly. The famous running man billboard. The Glico sign. Streets full of people eating while walking — takoyaki (octopus balls, better than they sound), skewered meats, cones of spicy shrimp. Osaka has a word for its own food philosophy: *kuidaore*, which means "eat until you drop."
+
+I took it as guidance.
+
+Three hours of eating across four separate establishments. Kushikatsu — battered and deep-fried everything — at a counter in an alley so narrow that diners on opposite sides can shake hands across the table. Soft-serve matcha ice cream on the street. Okonomiyaki from a woman who pressed the pancake flat with a heavy iron tool and would not let me touch anything.
+
+The canal was dark and the lights were reflected in it and for a while I just stood on the bridge watching people eat.
diff --git a/pages/01.tracker/2026-04-01-0900-seoul-calling.entry/entry.md b/pages/01.tracker/2026-04-01-0900-seoul-calling.entry/entry.md
new file mode 100644
index 0000000..1322725
--- /dev/null
+++ b/pages/01.tracker/2026-04-01-0900-seoul-calling.entry/entry.md
@@ -0,0 +1,23 @@
+---
+title: 'Seoul Calling'
+date: '2026-04-01 09:00'
+template: entry
+published: true
+hero_image: ''
+lat: '37.5635'
+lng: '126.9851'
+location_city: 'Seoul'
+location_country: 'South Korea'
+weather_temp_c: 10
+weather_desc: 'Rain'
+---
+
+The flight from Osaka to Seoul takes one hour and forty minutes. Shorter than some commutes I have had. At Incheon I changed SIM cards, changed currency, changed alphabet, and walked out into a grey April morning with rain coming in off the Yellow Sea.
+
+Korea hits differently than Japan. Japan felt deliberate and enclosed, every surface managed, every system timed to the second. Seoul feels faster and more argumentative, as if things are still being decided. The streets around Myeongdong were already busy at 9am: coffee shops the size of ballrooms, street vendors selling *hotteok* (sweet pancakes) from portable griddles, and the particular energy of a city that moves at one speed regardless of the weather.
+
+My guesthouse is in Mapo-gu, a neighbourhood that turns out to be significantly cooler than anywhere the guidebooks sent me. Independent coffee roasters. Record shops. A gallery in a converted printing house showing black-and-white photography of the Han River in the 1970s.
+
+I spent the afternoon walking the Han River itself — a massive green ribbon running through the city with dedicated cycling paths, outdoor fitness equipment, and Koreans doing every possible outdoor activity despite the rain. A group of older men playing badminton with very serious expressions. Two people kayaking. A family of five sharing a communal barbecue under an umbrella.
+
+Dinner: Korean fried chicken at a place that opened at 5pm and was full by 5:05. Beer so cold it was almost painful. Outside, the rain kept up steadily. I stayed longer than I meant to.
diff --git a/pages/01.tracker/2026-06-17.entry/entry.md b/pages/01.tracker/2026-06-17.entry/entry.md
index f91533f..262745b 100644
--- a/pages/01.tracker/2026-06-17.entry/entry.md
+++ b/pages/01.tracker/2026-06-17.entry/entry.md
@@ -4,8 +4,12 @@ date: '2026-06-17 10:00'
template: entry
published: true
hero_image: ''
-lat: ''
-lng: ''
+lat: '52.3676'
+lng: '4.9041'
+location_city: 'Amsterdam'
+location_country: 'Netherlands'
+weather_temp_c: 19
+weather_desc: 'Partly cloudy'
---
First entry. Bags are packed, passport is ready, the adventure starts here.
diff --git a/pages/02.post/post-form.md b/pages/02.post/post-form.md
index 9a18860..2fa34ea 100644
--- a/pages/02.post/post-form.md
+++ b/pages/02.post/post-form.md
@@ -3,7 +3,16 @@ title: 'New Entry'
template: post-form
access:
site.login: true
-login_redirect_here: true
+
+pageconfig:
+ parent: '/tracker'
+ slug_field: 'date,title'
+ overwrite_mode: false
+
+pagefrontmatter:
+ template: entry
+ published: true
+
form:
name: new-entry
action: /post
@@ -37,9 +46,9 @@ form:
-
name: photos
label: Photos (max 4)
- type: file
+ type: filepond
multiple: true
- destination: 'user://pages/01.tracker'
+ destination: '@self'
limit: 4
accept:
- 'image/*'
@@ -56,23 +65,37 @@ form:
type: text
placeholder: ''
+ -
+ name: location_city
+ label: City
+ type: text
+ placeholder: 'e.g. Kyoto'
+
+ -
+ name: location_country
+ label: Country
+ type: text
+ placeholder: 'e.g. Japan'
+
+ -
+ name: weather_temp_c
+ type: hidden
+
+ -
+ name: weather_desc
+ type: hidden
+
+ novalidate: true
+
buttons:
-
type: submit
- value: Post Entry
+ value: Post Daily
classes: btn-post
process:
-
- add-page-by-form:
- parent: '/tracker'
- slug: "{{ form.value.date|date('Y-m-d') }}"
- template: 'entry'
- frontmatter:
- title: '{{ form.value.title }}'
- date: '{{ form.value.date }}'
- lat: '{{ form.value.lat }}'
- lng: '{{ form.value.lng }}'
+ add_page: true
-
message: 'Entry posted successfully!'
-
diff --git a/pages/03.map/map.md b/pages/03.map/map.md
new file mode 100644
index 0000000..af33e43
--- /dev/null
+++ b/pages/03.map/map.md
@@ -0,0 +1,4 @@
+---
+title: 'Trip Map'
+template: map
+---
diff --git a/pages/04.stats/stats.md b/pages/04.stats/stats.md
new file mode 100644
index 0000000..1a772e7
--- /dev/null
+++ b/pages/04.stats/stats.md
@@ -0,0 +1,4 @@
+---
+title: 'Trip Stats'
+template: stats
+---
diff --git a/plugins/cache-on-save/cache-on-save.php b/plugins/cache-on-save/cache-on-save.php
new file mode 100644
index 0000000..3b91101
--- /dev/null
+++ b/plugins/cache-on-save/cache-on-save.php
@@ -0,0 +1,26 @@
+ ['onFormProcessed', 0],
+ ];
+ }
+
+ public function onFormProcessed(Event $event): void
+ {
+ $form = $event['form'];
+ if (!$form) {
+ return;
+ }
+ if ($form->getName() === 'new-entry') {
+ $this->grav['cache']->deleteAll();
+ }
+ }
+}
diff --git a/plugins/cache-on-save/cache-on-save.yaml b/plugins/cache-on-save/cache-on-save.yaml
new file mode 100644
index 0000000..d4ca941
--- /dev/null
+++ b/plugins/cache-on-save/cache-on-save.yaml
@@ -0,0 +1 @@
+enabled: true
diff --git a/themes/intotheeast/css/style.css b/themes/intotheeast/css/style.css
index 4a5ecde..4fe2c71 100644
--- a/themes/intotheeast/css/style.css
+++ b/themes/intotheeast/css/style.css
@@ -1,112 +1,634 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- font-size: 1rem;
- line-height: 1.6;
- color: #1a1a1a;
- background: #fff;
+ font-family: var(--font-ui);
+ font-size: var(--text-base);
+ line-height: var(--leading-normal);
+ color: var(--color-ink);
+ background: var(--color-paper);
+ -webkit-font-smoothing: antialiased;
}
+.site-main {
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: var(--space-8) var(--space-5);
+}
+
+@media (min-width: 520px) {
+ .site-main { padding: var(--space-10) var(--space-6); }
+}
+
+/* ── Header ──────────────────────────────────────────────────────────────────── */
+
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
- padding: 1rem 1.25rem;
- border-bottom: 1px solid #e5e5e5;
+ padding: 0 var(--space-5);
+ height: var(--site-header-height);
+ background: var(--color-canvas);
+ border-top: 3px solid var(--color-accent);
+ border-bottom: 1px solid var(--color-border);
+ position: sticky;
+ top: 0;
+ z-index: 100;
}
.site-title {
- font-weight: 700;
- font-size: 1.1rem;
+ font-family: var(--font-display);
+ font-size: var(--text-lg);
+ font-weight: 400;
+ letter-spacing: -0.01em;
text-decoration: none;
- color: inherit;
+ color: var(--color-ink);
+ line-height: 1;
+}
+
+.site-nav {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
}
.site-nav a {
- color: inherit;
+ font-family: var(--font-ui);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-ink-2);
text-decoration: none;
- font-size: 0.95rem;
- padding: 0.5rem;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
min-height: 44px;
display: inline-flex;
align-items: center;
+ transition: color 0.15s, background 0.15s;
}
-.site-main {
- max-width: 680px;
- margin: 0 auto;
- padding: 1.5rem 1.25rem;
+.site-nav a:hover { color: var(--color-ink); background: var(--color-paper); }
+.site-nav a[aria-current="page"] { color: var(--color-accent); font-weight: 600; }
+
+@media (max-width: 380px) {
+ .site-title { font-size: var(--text-md); }
+ .site-nav a { padding: var(--space-2); font-size: 0.8rem; }
}
-/* Feed */
-.feed { display: flex; flex-direction: column; gap: 2rem; }
+/* ── Feed ────────────────────────────────────────────────────────────────────── */
-.entry-card {
- border-bottom: 1px solid #e5e5e5;
- padding-bottom: 2rem;
-}
+.feed { display: flex; flex-direction: column; gap: var(--space-12); }
+.feed-empty { color: var(--color-ink-muted); font-style: italic; }
-.entry-date {
+.entry-card { border-bottom: 1px solid var(--color-border); padding-bottom: var(--space-12); }
+
+.entry-card-inner {
display: block;
- font-size: 0.8rem;
- color: #666;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- margin-bottom: 0.4rem;
+ text-decoration: none;
+ color: inherit;
}
-.entry-card .entry-title { font-size: 1.3rem; margin-bottom: 0.75rem; }
-.entry-card .entry-title a { color: inherit; text-decoration: none; }
-.entry-card .entry-title a:hover { text-decoration: underline; }
+/* Card: photo variant */
-.entry-thumb { margin-bottom: 0.75rem; }
-.entry-thumb img { width: 100%; height: 200px; object-fit: cover; border-radius: 4px; }
+.entry-card-photo {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ background: var(--color-border);
+ margin-bottom: var(--space-5);
+}
-.entry-excerpt { color: #444; margin-bottom: 0.75rem; }
+.entry-card-photo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ transition: transform 0.45s ease;
+}
+
+.entry-card-inner:hover .entry-card-photo img { transform: scale(1.04); }
+
+.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;
+}
+
+/* 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);
+}
+
+/* Card body */
+
+.entry-card .entry-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-3);
+ transition: color 0.15s;
+}
+
+.entry-card-inner:hover .entry-title { color: var(--color-accent); }
+
+.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: 0.9rem;
- color: #0066cc;
- text-decoration: none;
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-accent);
}
-.feed-empty { color: #666; font-style: italic; }
+/* Location & weather badges (single entry page) */
-/* Single entry */
-.entry-header { margin-bottom: 1.5rem; }
-.entry-header .entry-date { margin-bottom: 0.5rem; }
-.entry .entry-title { font-size: 1.8rem; }
-.entry-body { margin-bottom: 2rem; }
-.entry-body p { margin-bottom: 1em; }
-.entry-body img { max-width: 100%; height: auto; border-radius: 4px; }
-.entry-footer { border-top: 1px solid #e5e5e5; padding-top: 1rem; }
-.entry-footer a { color: #0066cc; text-decoration: none; font-size: 0.9rem; }
+.entry-location {
+ font-size: var(--text-sm);
+ color: var(--color-ink-2);
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+}
-/* Login form */
-.login-form { max-width: 400px; margin: 2rem auto; padding: 0 1rem; }
-.login-form .form-field { margin-bottom: 1.25rem; }
-.login-form .form-label label { display: block; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.4rem; }
+.entry-weather {
+ font-size: var(--text-sm);
+ color: var(--color-ink-2);
+}
+
+/* ── Single entry ────────────────────────────────────────────────────────────── */
+
+.entry-hero {
+ width: 100%;
+ max-height: 480px;
+ overflow: hidden;
+ margin-bottom: var(--space-8);
+ border-radius: var(--radius-md);
+}
+
+.entry-hero img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.entry-header { margin-bottom: var(--space-8); }
+
+.entry-header-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-3);
+ margin-bottom: var(--space-4);
+}
+
+.entry-header .entry-date {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-ink-muted);
+ display: inline;
+ margin-bottom: 0;
+}
+
+.entry .entry-title {
+ font-family: var(--font-display);
+ font-size: var(--text-2xl);
+ font-weight: 400;
+ line-height: var(--leading-snug);
+ color: var(--color-ink);
+ margin-bottom: var(--space-4);
+}
+
+@media (min-width: 520px) {
+ .entry .entry-title { font-size: var(--text-3xl); }
+}
+
+.entry-title-rule {
+ height: 1px;
+ background: var(--color-border);
+ margin-bottom: var(--space-8);
+}
+
+.entry-body { margin-bottom: var(--space-10); }
+.entry-body p { margin-bottom: 1.1em; font-size: var(--text-md); line-height: var(--leading-normal); color: var(--color-ink-2); }
+.entry-body img { max-width: 100%; height: auto; border-radius: var(--radius-sm); }
+
+.entry-footer { border-top: 1px solid var(--color-border); padding-top: var(--space-5); }
+.entry-footer a { color: var(--color-accent); text-decoration: none; font-size: var(--text-sm); font-weight: 500; }
+.entry-footer a:hover { color: var(--color-accent-hover); }
+
+/* ── Photo gallery ───────────────────────────────────────────────────────────── */
+
+.entry-gallery {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 3px;
+ margin-bottom: var(--space-10);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+@media (min-width: 520px) {
+ .entry-gallery { grid-template-columns: repeat(3, 1fr); }
+}
+
+.gallery-thumb {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ display: block;
+ aspect-ratio: 1;
+ overflow: hidden;
+}
+
+.gallery-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ transition: opacity 0.15s;
+}
+
+.gallery-thumb:hover img,
+.gallery-thumb:focus img { opacity: 0.82; }
+
+.gallery-thumb:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+
+/* ── Lightbox ────────────────────────────────────────────────────────────────── */
+
+.lightbox {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.94);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.lightbox[hidden] { display: none; }
+
+.lightbox-img {
+ max-width: 92vw;
+ max-height: 90vh;
+ object-fit: contain;
+ border-radius: var(--radius-sm);
+ display: block;
+}
+
+.lightbox-close,
+.lightbox-prev,
+.lightbox-next {
+ position: absolute;
+ background: rgba(255,255,255,0.12);
+ border: none;
+ color: #fff;
+ cursor: pointer;
+ border-radius: 50%;
+ width: 44px;
+ height: 44px;
+ font-size: 1.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s;
+}
+
+.lightbox-close { top: 1rem; right: 1rem; }
+.lightbox-prev { left: 0.75rem; top: 50%; transform: translateY(-50%); }
+.lightbox-next { right: 0.75rem; top: 50%; transform: translateY(-50%); }
+.lightbox-close:hover,
+.lightbox-prev:hover,
+.lightbox-next:hover { background: rgba(255,255,255,0.26); }
+
+/* ── Map page ────────────────────────────────────────────────────────────────── */
+
+.map-page .site-main { max-width: none; padding: 0; }
+
+.map-container {
+ height: calc(100vh - var(--site-header-height));
+ width: 100%;
+}
+
+.map-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--color-ink-muted);
+ font-style: italic;
+}
+
+/* ── Stats page ──────────────────────────────────────────────────────────────── */
+
+.stats-heading {
+ font-family: var(--font-display);
+ font-size: var(--text-2xl);
+ font-weight: 400;
+ margin-bottom: var(--space-8);
+ color: var(--color-ink);
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-4);
+ margin-bottom: var(--space-8);
+}
+
+.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);
+}
+
+.stat-value {
+ display: block;
+ font-family: var(--font-display);
+ font-size: var(--text-3xl);
+ font-weight: 400;
+ color: var(--color-accent);
+ line-height: 1.1;
+ margin-bottom: var(--space-2);
+}
+
+.stat-label {
+ display: block;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--color-ink-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+}
+
+.stats-countries {
+ font-size: var(--text-sm);
+ color: var(--color-ink-2);
+ text-align: center;
+ line-height: 1.9;
+}
+
+.stats-countries-label {
+ font-weight: 600;
+ display: block;
+ margin-bottom: var(--space-2);
+ color: var(--color-ink);
+ text-transform: uppercase;
+ font-size: var(--text-xs);
+ letter-spacing: 0.07em;
+}
+
+.stats-note {
+ font-size: var(--text-xs);
+ color: var(--color-ink-muted);
+ text-align: center;
+ margin-top: var(--space-6);
+}
+
+/* ── Mini-map on tracker feed ────────────────────────────────────────────────── */
+
+.feed-map-wrap {
+ margin-bottom: var(--space-10);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-sm);
+}
+
+.feed-map {
+ height: 240px;
+ width: 100%;
+}
+
+@media (min-width: 520px) {
+ .feed-map { height: 300px; }
+}
+
+.feed-map-link {
+ display: block;
+ text-align: right;
+ font-size: var(--text-xs);
+ font-weight: 500;
+ color: var(--color-accent);
+ text-decoration: none;
+ padding: var(--space-2) var(--space-4);
+ background: var(--color-paper);
+ border-top: 1px solid var(--color-border);
+}
+
+.feed-map-link:hover { color: var(--color-accent-hover); }
+
+/* ── Login form ─────────────────────────────────────────────────────────────── */
+
+.login-form { max-width: 400px; margin: var(--space-8) auto; padding: 0 var(--space-4); }
+.login-form .form-field { margin-bottom: var(--space-5); }
+.login-form .form-label label { display: block; font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--space-2); }
.login-form input[type="text"],
.login-form input[type="password"],
.login-form input[type="email"] {
- width: 100%; box-sizing: border-box;
- font-size: 1rem; padding: 0.75rem 1rem;
- border: 1px solid #ccc; border-radius: 6px;
+ width: 100%;
+ font-family: var(--font-ui);
+ font-size: var(--text-base);
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-canvas);
+ color: var(--color-ink);
min-height: 44px;
}
-.login-form .form-actions { margin-top: 1.5rem; display: flex; flex-direction: column; gap: 0.75rem; }
-.login-form .button { display: block; width: 100%; text-align: center; padding: 0.85rem 1rem; min-height: 44px; border-radius: 6px; font-size: 1rem; cursor: pointer; border: none; }
-.login-form .button.primary { background: #0066cc; color: #fff; }
-.login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; }
-.login-form .rememberme { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
-
-/* Post form */
-.post-form-wrap h1 { font-size: 1.4rem; margin-bottom: 1.5rem; }
-.post-form-wrap .btn-location {
- display: block; width: 100%; margin-top: 1rem;
- padding: 0.85rem 1rem; min-height: 44px;
- background: #f0f0f0; border: 1px solid #ccc;
- border-radius: 6px; font-size: 1rem; cursor: pointer;
+.login-form input:focus {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 1px;
+ border-color: var(--color-accent);
+}
+.login-form .form-actions { margin-top: var(--space-6); display: flex; flex-direction: column; gap: var(--space-3); }
+.login-form .button { display: block; width: 100%; text-align: center; padding: 0.85rem 1rem; min-height: 44px; border-radius: var(--radius-md); font-size: var(--text-base); font-family: var(--font-ui); font-weight: 600; cursor: pointer; border: none; }
+.login-form .button.primary { background: var(--color-accent); color: var(--color-accent-on); }
+.login-form .button.primary:hover { background: var(--color-accent-hover); }
+.login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; }
+.login-form .rememberme { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); }
+
+/* ── Post form ───────────────────────────────────────────────────────────────── */
+
+.post-form-wrap h1 {
+ font-family: var(--font-display);
+ font-size: var(--text-xl);
+ font-weight: 400;
+ margin-bottom: var(--space-6);
+ color: var(--color-ink);
+}
+
+/* Hide GPS coordinate fields — filled by JS, not user-facing */
+.post-form-wrap .form-field:has(input[name="data[lat]"]),
+.post-form-wrap .form-field:has(input[name="data[lng]"]) { display: none !important; }
+
+/* Grav form field inputs */
+.post-form-wrap .form-field { margin-bottom: var(--space-5); }
+.post-form-wrap .form-label label {
+ display: block;
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--color-ink);
+ margin-bottom: var(--space-2);
+}
+
+.post-form-wrap input[type="text"],
+.post-form-wrap input[type="datetime-local"],
+.post-form-wrap textarea {
+ width: 100%;
+ font-family: var(--font-ui);
+ font-size: var(--text-base);
+ padding: 0.875rem 1rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-canvas);
+ color: var(--color-ink);
+ min-height: 44px;
+ transition: border-color 0.15s;
+ -webkit-appearance: none;
+}
+
+.post-form-wrap input:focus,
+.post-form-wrap textarea:focus {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 1px;
+ border-color: var(--color-accent);
+}
+
+.post-form-wrap textarea { resize: vertical; min-height: 160px; line-height: var(--leading-normal); }
+
+/* Submit button */
+.post-form-wrap .btn-post {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 1.1rem 1rem;
+ min-height: 56px;
+ background: var(--color-accent);
+ color: var(--color-accent-on);
+ border: none;
+ border-radius: var(--radius-md);
+ font-family: var(--font-ui);
+ font-size: var(--text-md);
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+ margin-top: var(--space-6);
+ text-align: center;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.post-form-wrap .btn-post:hover { background: var(--color-accent-hover); }
+
+/* Inline field validation */
+.post-form-wrap .field-invalid {
+ border-color: #c0392b !important;
+ outline-color: #c0392b !important;
+}
+
+.post-form-wrap .field-error {
+ display: block;
+ color: #c0392b;
+ font-size: var(--text-sm);
+ margin-top: var(--space-1);
+}
+
+/* Location / weather action buttons */
+.form-action-row {
+ display: flex;
+ gap: var(--space-3);
+ margin-top: var(--space-5);
+}
+
+.btn-action {
+ flex: 1;
+ padding: 0.75rem var(--space-3);
+ min-height: 44px;
+ background: var(--color-canvas);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ font-family: var(--font-ui);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ cursor: pointer;
+ color: var(--color-ink);
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.btn-action:hover { background: var(--color-paper); border-color: var(--color-accent); }
+
+/* Status feedback */
+.form-status {
+ font-size: var(--text-sm);
+ color: var(--color-ink-muted);
+ margin-top: var(--space-2);
+ min-height: 1.4em;
+}
+
+.form-status--ok { color: var(--color-accent); }
+.form-status--err { color: #B44A2A; }
+
+/* ── Accessibility & motion ──────────────────────────────────────────────────── */
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
}
-.post-form-wrap .location-status { font-size: 0.85rem; color: #666; margin-top: 0.5rem; text-align: center; }
diff --git a/themes/intotheeast/css/tokens.css b/themes/intotheeast/css/tokens.css
new file mode 100644
index 0000000..ce58230
--- /dev/null
+++ b/themes/intotheeast/css/tokens.css
@@ -0,0 +1,60 @@
+:root {
+ /* ── Colors ─────────────────────────────────────────────── */
+ --color-ink: #17171A;
+ --color-ink-2: #4A4850;
+ --color-ink-muted: #9896A0;
+ --color-paper: #F7F5F2;
+ --color-canvas: #FFFFFF;
+ --color-border: #E8E6E3;
+ --color-border-soft: #F0EDEA;
+ --color-accent: #1F6B5A;
+ --color-accent-hover: #185647;
+ --color-accent-light: #EBF5F2;
+ --color-accent-on: #FFFFFF;
+
+ /* ── Fonts ───────────────────────────────────────────────── */
+ --font-display: 'DM Serif Display', Georgia, serif;
+ --font-ui: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
+
+ /* ── Type scale ──────────────────────────────────────────── */
+ --text-xs: 0.75rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-md: 1.125rem;
+ --text-lg: 1.375rem;
+ --text-xl: 1.75rem;
+ --text-2xl: 2.25rem;
+ --text-3xl: 3rem;
+
+ /* ── Leading ─────────────────────────────────────────────── */
+ --leading-tight: 1.2;
+ --leading-snug: 1.35;
+ --leading-normal: 1.65;
+
+ /* ── Spacing (4px grid) ──────────────────────────────────── */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+
+ /* ── Radius ──────────────────────────────────────────────── */
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-full: 9999px;
+
+ /* ── Shadows ─────────────────────────────────────────────── */
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.14);
+
+ /* ── Layout ──────────────────────────────────────────────── */
+ --content-width: 720px;
+ --site-header-height: 60px;
+}
diff --git a/themes/intotheeast/templates/entry.html.twig b/themes/intotheeast/templates/entry.html.twig
index 3f231bb..9961092 100644
--- a/themes/intotheeast/templates/entry.html.twig
+++ b/themes/intotheeast/templates/entry.html.twig
@@ -1,16 +1,125 @@
{% extends 'default.html.twig' %}
{% block content %}
+{% set weather_icons = {
+ 'Sunny': '☀️', 'Partly cloudy': '⛅', 'Cloudy': '☁️',
+ 'Foggy': '🌫️', 'Drizzle': '🌦️', 'Rain': '🌧️',
+ 'Snow': '❄️', 'Thunderstorm': '⛈️'
+} %}
+
+{% set hero = null %}
+{% if page.header.hero_image and page.media[page.header.hero_image] is defined %}
+ {% set hero = page.media[page.header.hero_image] %}
+{% elseif page.media.images|length > 0 %}
+ {% set hero = page.media.images|first %}
+{% endif %}
+
-
-
- {{ page.content }}
+ {% if hero %}
+
+
+ {% endif %}
+
+
+
+
+ {{ page.content|raw }}
+
+
+ {% set images = page.media.images %}
+ {% if images|length > 0 %}
+
+ {% for image in images %}
+
+ {% endfor %}
+
+
+
+
+
+
![]()
+
+
+
+
+ {% endif %}
+
diff --git a/themes/intotheeast/templates/map.html.twig b/themes/intotheeast/templates/map.html.twig
new file mode 100644
index 0000000..3342bf8
--- /dev/null
+++ b/themes/intotheeast/templates/map.html.twig
@@ -0,0 +1,86 @@
+{% extends 'partials/base.html.twig' %}
+
+{% block content %}
+{% set tracker_page = grav.pages.find('/tracker') %}
+{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
+
+{% 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
+ }]) %}
+ {% endif %}
+{% endfor %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/themes/intotheeast/templates/partials/base.html.twig b/themes/intotheeast/templates/partials/base.html.twig
index c5d276e..12650b1 100644
--- a/themes/intotheeast/templates/partials/base.html.twig
+++ b/themes/intotheeast/templates/partials/base.html.twig
@@ -4,17 +4,26 @@
{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}
+
+
+
+
+ {{ assets.css()|raw }}
+ {{ assets.js()|raw }}
-
+
{% block content %}{% endblock %}
+ {{ assets.js('bottom')|raw }}
diff --git a/themes/intotheeast/templates/post-form.html.twig b/themes/intotheeast/templates/post-form.html.twig
index 950ca4e..bd4e6ac 100644
--- a/themes/intotheeast/templates/post-form.html.twig
+++ b/themes/intotheeast/templates/post-form.html.twig
@@ -2,31 +2,125 @@
{% block content %}
-
{{ page.title }}
+
New Entry
{% include 'forms/form.html.twig' ignore missing %}
-
-
+
+
+
+
+
+
+
+
+
{% endblock %}
diff --git a/themes/intotheeast/templates/stats.html.twig b/themes/intotheeast/templates/stats.html.twig
new file mode 100644
index 0000000..5a4eb34
--- /dev/null
+++ b/themes/intotheeast/templates/stats.html.twig
@@ -0,0 +1,107 @@
+{% extends 'partials/base.html.twig' %}
+
+{% block content %}
+{% set tracker_page = grav.pages.find('/tracker') %}
+{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
+
+{# Basic counts #}
+{% set entry_count = all_entries|length %}
+
+{# Days on the road — find earliest entry timestamp by iterating #}
+{% set days_on_road = 0 %}
+{% 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 now_ts = "now"|date('U') %}
+ {% set diff_seconds = now_ts - first_ts %}
+ {% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
+ {% set days_on_road = days_raw < 1 ? 1 : days_raw %}
+{% endif %}
+
+{# Countries — unique, case-insensitive dedup, preserve original casing #}
+{% set seen_lower = [] %}
+{% set country_display = [] %}
+{% for entry in all_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 %}
+
+{# GPS points for distance — collect as JSON for JS computation #}
+{% set gps_points = [] %}
+{% for entry in all_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 %}
+
+
+
Trip Statistics
+
+
+
+ {{ days_on_road }}
+ {{ days_on_road == 1 ? 'day' : 'days' }} on the road
+
+
+ {{ entry_count }}
+ {{ entry_count == 1 ? 'entry' : 'entries' }} posted
+
+
+ {{ country_display|length }}
+ {{ country_display|length == 1 ? 'country' : 'countries' }} visited
+
+
+ —
+ km traveled
+
+
+
+ {% if country_display|length > 0 %}
+
+ Countries visited
+ {{ country_display|join(' · ') }}
+
+ {% endif %}
+
+
Distance is approximate — straight lines between entry locations.
+
+
+
+{% endblock %}
diff --git a/themes/intotheeast/templates/tracker.html.twig b/themes/intotheeast/templates/tracker.html.twig
index 84fced8..17269f0 100644
--- a/themes/intotheeast/templates/tracker.html.twig
+++ b/themes/intotheeast/templates/tracker.html.twig
@@ -1,26 +1,117 @@
{% extends 'default.html.twig' %}
{% block content %}
+{% set entries = page.collection() %}
+
+{# Collect GPS entries for mini-map #}
+{% set map_entries = [] %}
+{% for entry in entries %}
+ {% if entry.header.lat is not empty and entry.header.lng is not empty %}
+ {% set map_entries = map_entries|merge([{
+ 'lat': entry.header.lat,
+ 'lng': entry.header.lng,
+ 'title': entry.title,
+ 'url': entry.url
+ }]) %}
+ {% endif %}
+{% endfor %}
+
+{% if map_entries|length > 0 %}
+
+
+
+
+
+{% endif %}
+