Compare commits
3
Commits
7c9c140a1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb22c368ba | ||
|
|
143ec135c0 | ||
|
|
e106da0206 |
@@ -6,7 +6,7 @@ Rules, gotchas, and entry points — the things that must change what you do *be
|
|||||||
|---|---|
|
|---|---|
|
||||||
| How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
|
| How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
|
||||||
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
|
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
|
||||||
| Doing something operational — posting, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
| Doing something operational — posting, writing stories, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
||||||
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
|
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||||
| A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/) — grep the `module`/`tags`/`problem_type` frontmatter; check when working in a documented area |
|
| A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/) — grep the `module`/`tags`/`problem_type` frontmatter; check when working in a documented area |
|
||||||
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
|
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
|
||||||
@@ -20,7 +20,8 @@ The site is Grav (flat-file PHP CMS, no database) in Docker, with content and th
|
|||||||
- **Never SSH to a server directly** — use the `make remote-*` targets, since credentials live in `.env`. If no target covers what you need, ask the user to run it or propose a new target.
|
- **Never SSH to a server directly** — use the `make remote-*` targets, since credentials live in `.env`. If no target covers what you need, ask the user to run it or propose a new target.
|
||||||
- **Never hand-edit build output** — sources and outputs share folders under `user/themes/intotheeast/` (paths below are relative to it), so know which is which. Run `make build-assets` after editing any source.
|
- **Never hand-edit build output** — sources and outputs share folders under `user/themes/intotheeast/` (paths below are relative to it), so know which is which. Run `make build-assets` after editing any source.
|
||||||
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
|
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
|
||||||
- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`); so is `templates/partials/weather-icons.html.twig` (source: `scripts/gen-weather-icons.js`).
|
- `css-compiled/` and `fonts/` are generated by esbuild from the `js/src/` entrypoints' CSS and font imports (fontsource, photoswipe, maplibre-gl) — **not** from `css/`. `css/style.css` and `css/tokens.css` are hand-authored and served directly (`partials/base.html.twig`), so editing them needs no rebuild.
|
||||||
|
- `templates/partials/weather-icons.html.twig` is generated (source: `scripts/gen-weather-icons.js`).
|
||||||
- **Never toggle dev↔prod mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than flipping a mode flag — mode switches leave inconsistent state and make bugs harder to reproduce.
|
- **Never toggle dev↔prod mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than flipping a mode flag — mode switches leave inconsistent state and make bugs harder to reproduce.
|
||||||
|
|
||||||
## Dev environment
|
## Dev environment
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Two git repos:
|
|||||||
| `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) |
|
| `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||||
| `php/` | Local PHP ini overrides |
|
| `php/` | Local PHP ini overrides |
|
||||||
| `docs/` | All project documentation — start at [`docs/README.md`](docs/README.md) |
|
| `docs/` | All project documentation — start at [`docs/README.md`](docs/README.md) |
|
||||||
| `docs/guides/` | Operational how-tos (posting, GPX, trip switching, setup, deploy cycle) |
|
| `docs/guides/` | Operational how-tos (posting, story authoring, GPX, trip switching, setup, deploy cycle) |
|
||||||
| `docs/reference/` | Stable facts: architecture, design system, testing |
|
| `docs/reference/` | Stable facts: architecture, design system, testing |
|
||||||
| `docs/solutions/` | Write-ups of bugs and workflow traps already hit, with YAML frontmatter (`module`, `tags`, `problem_type`) |
|
| `docs/solutions/` | Write-ups of bugs and workflow traps already hit, with YAML frontmatter (`module`, `tags`, `problem_type`) |
|
||||||
| `docs/working/` | Specs, plans, backlog, QA — work in flight |
|
| `docs/working/` | Specs, plans, backlog, QA — work in flight |
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Writing a Story
|
||||||
|
|
||||||
|
A Story is a long-form, hand-crafted piece with an immersive layout — distinct from an Entry, which is a quick dated post from the road (see [`CONCEPTS.md`](../../CONCEPTS.md)). Stories get a Ken Burns hero, scroll-driven sections, galleries and pull quotes.
|
||||||
|
|
||||||
|
The admin editor gives you a **plain markdown textarea** for the body. There is no block picker — the layout vocabulary is a set of shortcodes you type by hand. This guide is that vocabulary; the story edit form also carries a condensed copy of it on its **Blocks** tab, so you don't need this file open while writing.
|
||||||
|
|
||||||
|
Admin lives at **`/admin`** (the plugin slug is `admin2`, but the route is `/admin`). A story's edit URL looks like `/admin/pages/edit/trips/<trip>/stories/<slug>`.
|
||||||
|
|
||||||
|
Stories live at:
|
||||||
|
|
||||||
|
```
|
||||||
|
user/pages/01.trips/<trip>/04.stories/<slug>/story.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`04.stories/stories.md` is a `routable: false` container — its children are aggregated onto the trip page. There is no standalone `/stories` view; don't create one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Create the page
|
||||||
|
|
||||||
|
1. Admin → **Pages** → add a page under the trip's **Stories** folder
|
||||||
|
2. Set page template to **story** — this loads [`user/themes/intotheeast/blueprints/story.yaml`](../../user/themes/intotheeast/blueprints/story.yaml). You get the story-specific **Content / Blocks / Location / Publishing** tabs plus the inherited **Options / Advanced / Security** tabs
|
||||||
|
3. Fill in Title and Start Date (both required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Upload the images first
|
||||||
|
|
||||||
|
Upload every image the story needs via the **Images** field at the bottom of the Content tab, before writing the body.
|
||||||
|
|
||||||
|
> There is no separate *Media* tab — the uploader is a field on the Content tab, labelled **Images**. It arrives via inheritance: `story.yaml` declares `'@extends': {type: default, context: blueprints://pages}`, which merges in Grav's default page form. `story.yaml` and `trip.yaml` did not originally extend it, so neither form could upload anything, which is why the demo story images had to be placed on the filesystem. Both now inherit, matching [`entry.yaml`](../../user/themes/intotheeast/blueprints/entry.yaml). `home.yaml` is still standalone, deliberately — the home page has no per-page media.
|
||||||
|
|
||||||
|
Every shortcode refers to images by **bare filename** — the `story-blocks` plugin prefixes the page URL at render time ([`story-blocks.php:22`](../../user/plugins/story-blocks/story-blocks.php)), so you write:
|
||||||
|
|
||||||
|
```
|
||||||
|
image="photo-1.jpg" ✅
|
||||||
|
image="/images/photo-1.jpg" ❌ don't path it
|
||||||
|
```
|
||||||
|
|
||||||
|
The demo stories use a `hero.jpg` / `photo-1.jpg` / `photo-2.jpg` naming convention. Worth copying — it keeps the shortcodes readable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontmatter fields
|
||||||
|
|
||||||
|
All of these come from the form tabs, so you rarely type them by hand. Listed here because the body shortcodes are *not* the whole story — the hero in particular is frontmatter, not a tag.
|
||||||
|
|
||||||
|
| Field | Tab | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | Content | Required |
|
||||||
|
| `date` | Content | Required. Start date |
|
||||||
|
| `end_date` | Content | Optional — leave blank for a single-day story |
|
||||||
|
| `hero_image` | Content | **The hero. Filename only**, from the Images field. Missing → grey placeholder, story still renders |
|
||||||
|
| `hero_alt` | Content | Falls back to the title if empty |
|
||||||
|
| `location_name`, `location_country` | Location | Shown in the hero meta line and the opener |
|
||||||
|
| `lat`, `lng` | Location | Decimal degrees — places the story marker on the trip map |
|
||||||
|
| `transport_mode` | Location | walking / bicycle / bus / train / car |
|
||||||
|
| `force_connect` | Location | Always draw a connector line from the previous marker |
|
||||||
|
| `published` | Options | Grav's standard toggle, from the inherited form |
|
||||||
|
| `featured` | Publishing | Show as a homepage highlight when not travelling |
|
||||||
|
|
||||||
|
The Ken Burns pan on the hero is automatic — no parameter for it.
|
||||||
|
|
||||||
|
If you hand-write frontmatter, use `date: '2026-09-03'`. Admin2 saves its own serialization (`29-07-2026 19:51`); both parse fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The body: six shortcodes
|
||||||
|
|
||||||
|
Defined in [`user/plugins/story-blocks/shortcodes/`](../../user/plugins/story-blocks/shortcodes/). Plain prose between them renders as a normal reading column — you don't need a shortcode to write paragraphs.
|
||||||
|
|
||||||
|
### Wrapping tags
|
||||||
|
|
||||||
|
**`scrolly-section`** — text panels scroll over a pinned, slowly panning image. The centrepiece block.
|
||||||
|
|
||||||
|
```
|
||||||
|
[scrolly-section image="hero.jpg" alt="Description of the image" caption="Optional caption"]
|
||||||
|
The first panel. Scrolls into view over the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The second panel. A markdown `---` starts a new panel.
|
||||||
|
[/scrolly-section]
|
||||||
|
```
|
||||||
|
|
||||||
|
Panels are split on the `<hr>` that `---` produces ([`story.html.twig:234`](../../user/themes/intotheeast/templates/story.html.twig)). `caption` is optional. Under `prefers-reduced-motion` all panels render active with no pinning.
|
||||||
|
|
||||||
|
**`pull-quote`** — large extracted quote, optionally over a background image.
|
||||||
|
|
||||||
|
```
|
||||||
|
[pull-quote image="photo-1.jpg" alt="Description of the image"]
|
||||||
|
The quote itself. Markdown works in here.
|
||||||
|
[/pull-quote]
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop `image`/`alt` entirely for the plain no-image variant.
|
||||||
|
|
||||||
|
### Self-closing tags
|
||||||
|
|
||||||
|
Note the ` /]` — these take no content.
|
||||||
|
|
||||||
|
**`chapter-break`** — full-width section transition over a background image.
|
||||||
|
|
||||||
|
```
|
||||||
|
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Description" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
`number` is optional; the demo stories use roman numerals.
|
||||||
|
|
||||||
|
**`snap-gallery`** — swipeable multi-image carousel with dots.
|
||||||
|
|
||||||
|
```
|
||||||
|
[snap-gallery images="hero.jpg,photo-1.jpg" captions="First caption,Second caption" alts="First alt,Second alt" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ See the comma gotcha below.
|
||||||
|
|
||||||
|
**`full-bleed`** — single image edge-to-edge, as a visual pause.
|
||||||
|
|
||||||
|
```
|
||||||
|
[full-bleed image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`image-caption`** — photo at a chosen width with caption beneath.
|
||||||
|
|
||||||
|
```
|
||||||
|
[image-caption image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" width="column" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
`width` accepts `column` (default), `full`, `bleed`. Anything else falls back to `column`.
|
||||||
|
|
||||||
|
`full-bleed` and `image-caption` are implemented but not yet used by any story — the demos only exercise the other four.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
### snap-gallery splits on commas — captions cannot contain them
|
||||||
|
|
||||||
|
`images`, `captions` and `alts` are each split on `,` and zipped by index. **A comma inside a caption shifts every caption after it.**
|
||||||
|
|
||||||
|
This is already live in the demo content. `04.stories/04.florence-without-a-map/story.md` has:
|
||||||
|
|
||||||
|
```
|
||||||
|
captions="The Arno at noon — greener than expected, the bridges older than you remember,Via dei Servi: …"
|
||||||
|
```
|
||||||
|
|
||||||
|
Two images, but three comma-separated pieces — so slide 1 gets "The Arno at noon — greener than expected", slide 2 gets " the bridges older than you remember", and the Via dei Servi text is silently dropped.
|
||||||
|
|
||||||
|
Use em dashes or semicolons in gallery captions. There is no escaping mechanism.
|
||||||
|
|
||||||
|
### Self-closing tags need the space before `/]`
|
||||||
|
|
||||||
|
`[chapter-break … /]` — not `[chapter-break …/]` or `[chapter-break …]`.
|
||||||
|
|
||||||
|
### A typo'd shortcode fails silently
|
||||||
|
|
||||||
|
An unrecognised tag name or a malformed parameter list renders as literal text or vanishes — no error, no warning. Preview the page after every block; there is no in-editor validation.
|
||||||
|
|
||||||
|
### `---` outside a scrolly-section is just a horizontal rule
|
||||||
|
|
||||||
|
The panel-splitting behaviour only applies *inside* `[scrolly-section]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
The best reference to copy from is [`user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`](../../user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md) — it combines `scrolly-section`, `chapter-break` and `pull-quote` in one story.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Sorano: Rock and Time'
|
||||||
|
date: '2026-09-03'
|
||||||
|
location_name: Sorano
|
||||||
|
location_country: Italy
|
||||||
|
lat: 42.683
|
||||||
|
lng: 11.715
|
||||||
|
hero_image: hero.jpg
|
||||||
|
hero_alt: Medieval town of Sorano clinging to pale tufa cliffs at dusk
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Opening prose. Renders as a normal reading column.
|
||||||
|
|
||||||
|
[scrolly-section image="hero.jpg" alt="Sorano seen from the approach road" caption="Sorano — tufa cliff town"]
|
||||||
|
First panel over the pinned image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Second panel.
|
||||||
|
[/scrolly-section]
|
||||||
|
|
||||||
|
More prose between blocks.
|
||||||
|
|
||||||
|
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Narrow medieval alley at dusk" /]
|
||||||
|
|
||||||
|
[pull-quote image="photo-1.jpg" alt="Stone alley lit by a single lantern"]
|
||||||
|
A town built on rock, carved from rock, returning slowly to rock.
|
||||||
|
[/pull-quote]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Publish
|
||||||
|
|
||||||
|
1. Set **Published** on the Publishing tab
|
||||||
|
2. Optionally set **Featured highlight** to surface it on the homepage between trips
|
||||||
|
3. Push the content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
|
|
||||||
|
That commits and pushes `user/` to Gitea, which triggers the production pull. See [`deploy-cycle.md`](deploy-cycle.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Blocks tab
|
||||||
|
|
||||||
|
The story edit form has a **Blocks** tab holding a paste-ready example of each shortcode plus the comma warning. It's built from `type: spacer` fields in `story.yaml`, whose `text` admin2 renders as HTML (confirmed against admin2 2.0.12 — see its CHANGELOG entry for issue #91).
|
||||||
|
|
||||||
|
If you edit those fields, note two things: the `text` values are YAML double-quoted scalars, so HTML attribute quotes must be escaped (`\"`) — and `<h4>` is flattened by admin2's CSS reset, which is why the headings use `<strong>` in a styled `<p>` instead. Long `<pre>` content needs `white-space:pre-wrap`, or it overflows underneath the Page Info sidebar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why it's a raw textarea
|
||||||
|
|
||||||
|
The admin2 story editor is a plain markdown field by design-so-far, not by limitation. Background and the options considered: [`docs/research/story-editing.md`](../research/story-editing.md). Note that its conclusion that admin2 cannot host a custom editor is **out of date** — admin-next ships a plugin field-component surface (`admin-next/fields/{type}.js` + `onApiBlueprintResolved`) that the installed api 1.0.9 / admin2 2.0.12 support. Nobody has built it yet.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Tests: S1–S7 — story mode rendering and navigation
|
// Tests: S1–S9 — story mode rendering and navigation
|
||||||
// Requires demo data: run `make demo-load` before this suite.
|
// Requires demo data: run `make demo-load` before this suite.
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
@@ -85,3 +85,74 @@ test('S7: story body back link has back-pill class', async ({ page }) => {
|
|||||||
await expect(bodyBack).toBeAttached();
|
await expect(bodyBack).toBeAttached();
|
||||||
await expect(bodyBack).toHaveText(/← Back/);
|
await expect(bodyBack).toHaveText(/← Back/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── S8: Scrolly-section text panels actually render beside the pinned image ───
|
||||||
|
// The server ships the panel text inside .scrolly__steps-content, which CSS hides
|
||||||
|
// (style.css: `display: none`). Only the inline Scrollama block in story.html.twig
|
||||||
|
// splits it into visible .scrolly-step divs — and it early-returns silently if the
|
||||||
|
// main.js bundle (which sets window.scrollama) hasn't executed yet. S3 asserted the
|
||||||
|
// image column exists; nothing asserted the text column was non-empty.
|
||||||
|
test('S8: scrolly-section builds visible step panels from its slot content', async ({ page }) => {
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
|
||||||
|
// The bundle must have published scrollama before the inline block ran
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => typeof window.scrollama !== 'undefined'),
|
||||||
|
'window.scrollama published by main.js bundle'
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Every scrolly-section must have produced at least one step
|
||||||
|
const sections = page.locator('.scrolly');
|
||||||
|
const sectionCount = await sections.count();
|
||||||
|
expect(sectionCount, 'Two scrolly-sections').toBe(2);
|
||||||
|
|
||||||
|
for (let i = 0; i < sectionCount; i++) {
|
||||||
|
const section = sections.nth(i);
|
||||||
|
const steps = section.locator('.scrolly-step');
|
||||||
|
expect(
|
||||||
|
await steps.count(),
|
||||||
|
`scrolly-section ${i} split its slot content into steps`
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and the text must be readable, not left hidden in the raw slot.
|
||||||
|
// Scroll each step into view so its reveal transition completes.
|
||||||
|
const firstStep = page.locator('.scrolly').first().locator('.scrolly-step').first();
|
||||||
|
await firstStep.scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(firstStep.locator('.scrolly-step__inner')).toBeVisible();
|
||||||
|
const text = (await firstStep.innerText()).trim();
|
||||||
|
expect(text.length, 'First step panel renders non-empty text').toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S9: Back-to-top is wired once, by main.js, and pushes a history entry ─────
|
||||||
|
// The inline duplicate in story.html.twig was removed; initBackToTop() in
|
||||||
|
// js/src/main.js now solely owns #story-totop. That makes the button depend on
|
||||||
|
// the bundle having loaded, so assert the observable behaviour end to end.
|
||||||
|
test('S9: story back-to-top reveals on scroll, returns to top, and pushes history', async ({ page }) => {
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
|
||||||
|
const btn = page.locator('#story-totop');
|
||||||
|
await expect(btn).toBeAttached();
|
||||||
|
|
||||||
|
// Hidden until scrolled past the 0.8 * viewport threshold
|
||||||
|
await expect(btn).not.toHaveClass(/is-visible/);
|
||||||
|
|
||||||
|
const historyBefore = await page.evaluate(() => history.length);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.scrollTo(0, window.innerHeight * 2));
|
||||||
|
await expect(btn).toHaveClass(/is-visible/, { timeout: 3000 });
|
||||||
|
|
||||||
|
await btn.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.scrollY), { timeout: 3000 })
|
||||||
|
.toBeLessThan(10);
|
||||||
|
|
||||||
|
// main.js's variant pushes a history entry; the removed inline copy did not
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => history.length),
|
||||||
|
'Back-to-top pushed a history entry'
|
||||||
|
).toBeGreaterThan(historyBefore);
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
Submodule user updated: dd19995973...1b9e51baf7
Reference in New Issue
Block a user