release: v0.11.0 (#459)

Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not,
so this is a clean sync.

## Headline changes since v0.10.0

- **Journal & Journal Studio** — photo essays at `/journal`,
split-screen block editor with live preview, drafts, per-entry
passwords, cover images
- **First-run setup wizard at `/install`** — configure a fresh
deployment from the browser; credentials are verified against Immich
before anything is written, and the wizard is gated by a one-time token
- **About page editor and toggle** in the admin panel
- **Per-album photo order** independent of Immich, with a drag & drop
editor
- **Custom favicon upload**
- **Experimental portfolio features** — justified grid, `cover` hero,
unlisted subpages, per-album grid overrides, cover focal points,
external nav links
- **Security** — asset tokens length-capped before decoding (#423),
journal author markdown escaped rather than filtered, journal file paths
contained, admin session key derived with scrypt (#432), Next.js 16.3.0
for CVE-2025-13465
- **Fixed** — client proofing honours its own configuration again
(#454); journal photo rendering (#432)

Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15).

## Verified

`npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run
build` succeeds · lint 0 errors · `prettier --check` clean · local
`docker build` of the release tree.

## After merge

Tag `v0.11.0` on `main` and publish the release — that triggers
`docker-publish.yml`, which pushes the image and moves `latest`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-15 09:09:18 +02:00
committed by GitHub
parent 6eda34cc50
commit 2f546f440d
61 changed files with 3953 additions and 1115 deletions

168
scripts/header.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* README Header Collage Generator
*
* Composes the single hero image at the top of README.md out of screenshots
* that already exist in docs/screenshots/. One picture that answers "what is
* this?" in the three ways a visitor cares about: the public gallery, the
* photo grid, and the admin panel behind it.
*
* Usage:
* npx tsx scripts/header.ts
*
* Run scripts/screenshots.ts first if the source shots are missing or stale —
* this script only composes, it never captures.
*/
import fs from 'fs';
import path from 'path';
const SCREENSHOT_DIR = path.join(process.cwd(), 'docs', 'screenshots');
const OUTPUT = path.join(SCREENSHOT_DIR, 'header.png');
/** Canvas and spacing. Sized so the result stays legible at README width. */
const CANVAS = { width: 2400, height: 1000 };
const PADDING = 28;
const GAP = 20;
/** Near-black, matching the studio-modern preset the shots are taken in. */
const BACKGROUND = { r: 13, g: 13, b: 13, alpha: 1 };
/** Hairline around each tile, so neighbouring dark screenshots stay distinct. */
const BORDER = { r: 42, g: 42, b: 42, alpha: 1 };
interface Tile {
file: string;
left: number;
top: number;
width: number;
height: number;
/** Which part survives the crop — screenshots read best from the top. */
position: 'top' | 'centre';
/**
* Optional region of the source to use instead of the whole page. A dense
* UI shrunk to tile size turns into unreadable mush; taking a smaller region
* and scaling it less keeps the interface recognisable.
*/
crop?: { left: number; top: number; width: number; height: number };
}
function layout(): Tile[] {
const innerHeight = CANVAS.height - PADDING * 2;
// One large tile carrying the gallery, two stacked beside it. The large one
// takes roughly the golden share of the width; the rest goes to the column.
const largeWidth = 1480;
const columnLeft = PADDING + largeWidth + GAP;
const columnWidth = CANVAS.width - columnLeft - PADDING;
const columnTileHeight = Math.floor((innerHeight - GAP) / 2);
return [
{
file: 'theme-studio-modern-home.png',
left: PADDING,
top: PADDING,
width: largeWidth,
height: innerHeight,
position: 'top',
},
{
file: 'theme-studio-modern-grid.png',
left: columnLeft,
top: PADDING,
width: columnWidth,
height: columnTileHeight,
position: 'top',
},
{
file: 'admin-page-builder.png',
left: columnLeft,
top: PADDING + columnTileHeight + GAP,
width: columnWidth,
height: columnTileHeight,
position: 'top',
// The page builder is the densest shot in the set. Full width, but only
// the upper region: tab bar, save bar and the hero thumbnails. Scaling
// ~1000px of source instead of 1440 keeps the labels readable.
crop: { left: 0, top: 0, width: 1030, height: 564 },
},
];
}
/**
* sharp ships with Next.js but is not a declared dependency, and 0.35 moved
* from `export =` to ESM — the namespace stopped being callable and the
* factory moved to `.default`. Derive the shape from the installed package
* rather than pinning it, exactly as scripts/screenshots.ts does.
*/
async function loadSharp() {
type SharpFactory = typeof import('sharp') extends { default: infer Factory }
? Factory
: typeof import('sharp');
const mod = (await import('sharp')) as unknown as { default?: SharpFactory };
return mod.default ?? (mod as unknown as SharpFactory);
}
async function main() {
const tiles = layout();
const missing = tiles
.map((t) => t.file)
.filter((f) => !fs.existsSync(path.join(SCREENSHOT_DIR, f)));
if (missing.length > 0) {
console.error(
`\n❌ Missing source screenshots:\n ${missing.join('\n ')}\n\n` +
` Run \`npx tsx scripts/screenshots.ts\` against a running site first.\n`,
);
process.exit(1);
}
let sharp: Awaited<ReturnType<typeof loadSharp>>;
try {
sharp = await loadSharp();
} catch {
console.error('\n❌ sharp is not available — cannot compose the header.\n');
process.exit(1);
}
const composites = await Promise.all(
tiles.map(async (tile) => {
// Reserve a pixel on every side for the hairline, so the visible
// screenshot lands exactly on the tile geometry above.
const pipeline = sharp(path.join(SCREENSHOT_DIR, tile.file));
if (tile.crop) pipeline.extract(tile.crop);
const inner = await pipeline
.resize(tile.width - 2, tile.height - 2, {
fit: 'cover',
position: tile.position === 'top' ? 'top' : 'centre',
})
.toBuffer();
const bordered = await sharp(inner)
.extend({ top: 1, bottom: 1, left: 1, right: 1, background: BORDER })
.toBuffer();
return { input: bordered, left: tile.left, top: tile.top };
}),
);
await sharp({
create: {
width: CANVAS.width,
height: CANVAS.height,
channels: 4,
background: BACKGROUND,
},
})
.composite(composites)
.png({ compressionLevel: 9, effort: 10 })
.toFile(OUTPUT);
const kb = Math.round(fs.statSync(OUTPUT).size / 1024);
console.log(`\n🎉 Header written to docs/screenshots/header.png (${kb} KB)`);
console.log(` ${CANVAS.width}x${CANVAS.height}, composed from:`);
for (const tile of tiles) console.log(` · ${tile.file}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,10 +1,11 @@
/**
* Documentation Screenshot Generator
*
* Captures every screenshot referenced by README.md and docs/theming.md:
* theme presets, hero styles, grid layouts, light mode, the standard pages
* (subpage overview, about, map), the lightbox with its EXIF panel, a mobile
* viewport and — if ADMIN_PASSWORD is set — the admin panel.
* Captures every screenshot referenced by README.md, docs/theming.md and
* docs/journal.md: theme presets, hero styles, grid layouts, light mode, the
* standard pages (subpage overview, about, map), the lightbox with its EXIF
* panel, a mobile viewport and — if ADMIN_PASSWORD is set — the admin panel
* and the journal.
*
* Uses Playwright to wait for images to fully load (not just blurhash placeholders).
*
@@ -13,6 +14,21 @@
* SCREENSHOT_GRID_PATH=/japan/koyasan-2023 npx tsx scripts/screenshots.ts
* SCREENSHOT_PORT=3100 npx tsx scripts/screenshots.ts
* SCREENSHOT_ONLY=admin,lightbox npx tsx scripts/screenshots.ts
* SCREENSHOT_ONLY=journal SCREENSHOT_JOURNAL_ALBUM="Yufuin" npx tsx scripts/screenshots.ts
*
* The journal section writes a throwaway entry built from a real album's
* photos, shoots the public index, the entry and the studio, then deletes it.
* SCREENSHOT_JOURNAL_ALBUM picks the album by name; without it the largest one
* is used — which is usually an import dump rather than a presentable set, so
* set it whenever the output is going to be committed. Whatever it names ends
* up in a published screenshot at full size: pick an album you would show a
* stranger, not one that happens to be large.
*
* Three further entries are written from albums matched by name, so the index
* shot shows a list rather than one lonely card. One of them is a draft and
* one carries a password, so the admin list shows the states the guide
* describes. They are skipped on an installation without those albums — see
* EXTRA_JOURNAL_ENTRIES.
*
* The album used for the grid shots is discovered by walking the running site,
* since album slugs come from Immich album names. SCREENSHOT_GRID_PATH picks a
@@ -83,6 +99,44 @@ const ABOUT_MD = path.join(CONTENT_DIR, 'about.md');
const MANAGED_FILES = [SETTINGS_YAML, GALLERY_YAML, ABOUT_MD];
const backupPath = (file: string) => `${file}.screenshot-bak`;
const JOURNAL_DIR = path.join(CONTENT_DIR, 'journal');
/**
* Slug of the throwaway entry written for the journal screenshots. It is
* created before the shots and deleted afterwards, so a run never leaves a
* demo story published on a real site. Namespaced to make an orphan from a
* killed run obvious.
*/
const DEMO_JOURNAL_SLUG = 'screenshot-demo-entry';
/**
* Album to pull demo photos from, matched against the Immich album name.
* Unset picks the album with the most photos, which reliably gives the entry
* enough frames to fill a fullbleed, a pair and a contained block — but the
* largest album on a real library is typically an unsorted import, so a run
* whose output gets committed should always name one. The photos are published
* at full size in the screenshots; nothing about them is anonymised, unlike the
* site identity in DEMO_SETTINGS.
*/
const JOURNAL_ALBUM = process.env.SCREENSHOT_JOURNAL_ALBUM;
/**
* Companion entries, so the index shot shows a list of stories rather than a
* single card on an otherwise empty page. Unlike the main entry — whose album
* is chosen at run time and whose prose is therefore deliberately about
* photographing rather than about a place — each of these is bound to one
* album and its text describes that place, which only works while the two are
* kept together. An installation without a matching album simply skips them
* and the index shows the main entry alone.
*/
const EXTRA_JOURNAL_ENTRIES: {
slug: string;
/** Matched against the Immich album name, case-insensitive substring. */
album: string;
markdown: (ids: string[]) => string;
}[] = [
{ slug: 'screenshot-demo-kurokawa', album: 'kurokawa', markdown: kurokawaJournalMarkdown },
{ slug: 'screenshot-demo-kada', album: 'kada', markdown: kadaJournalMarkdown },
{ slug: 'screenshot-demo-koyasan', album: 'koyasan', markdown: koyasanJournalMarkdown },
];
/** Pages that never carry a photo grid — skipped while looking for an album. */
const EXCLUDED_PATHS = new Set(['/', '/about', '/map', '/impressum', '/admin']);
const VIEWPORT = { width: 1440, height: 900 };
@@ -237,6 +291,12 @@ async function main() {
await applySettings({ 'theme.preset': SHOWCASE_THEME });
await captureAdmin(browser);
}
// ── Journal (public views + studio) ──
if (wanted('journal')) {
await applySettings({ 'theme.preset': SHOWCASE_THEME });
await captureJournal(browser);
}
} finally {
await browser?.close();
await stopDevServer();
@@ -417,9 +477,7 @@ async function captureAdmin(browser: Browser): Promise<void> {
await page.locator('.admin-login-card').waitFor({ timeout: 15000 });
await capture(page, 'admin-login');
await page.locator('.admin-login-card input[type="password"]').fill(ADMIN_PASSWORD);
await page.locator('.admin-login-card button[type="submit"]').click();
await page.locator('.admin-tabs').waitFor({ timeout: 20000 });
await adminLogin(page);
await page.waitForTimeout(2500);
await waitForImages(page);
await capture(page, 'admin-page-builder');
@@ -436,6 +494,363 @@ async function captureAdmin(browser: Browser): Promise<void> {
});
}
/**
* Journal: the public index and entry, plus the studio behind them.
*
* Needs an admin session for two separate reasons — the studio shots are
* behind the login, and the demo entry can only be written with *raw* Immich
* asset UUIDs, which the public site never exposes (it hands out encrypted
* tokens). The admin API is the only place those UUIDs are reachable, so the
* entry is generated from a real album after logging in.
*
* The entry is deleted again in the `finally`, including when a capture throws.
*/
async function captureJournal(browser: Browser): Promise<void> {
section('Journal: index, entry, studio');
if (!ADMIN_PASSWORD) {
console.log(' ⏭ ADMIN_PASSWORD not set — skipping the journal screenshots.');
return;
}
const entryPath = path.join(JOURNAL_DIR, `${DEMO_JOURNAL_SLUG}.md`);
if (fs.existsSync(entryPath)) {
console.log(` ⏭ ${DEMO_JOURNAL_SLUG}.md already exists — leaving it alone.`);
return;
}
const written: string[] = [];
try {
await withPage(browser, VIEWPORT, async (page) => {
await adminLogin(page);
const assetIds = await demoAssetIds(page);
if (assetIds.length < 4) {
console.log(' ⏭ Not enough photos found for a demo entry — skipping.');
return;
}
fs.mkdirSync(JOURNAL_DIR, { recursive: true });
fs.writeFileSync(entryPath, demoJournalMarkdown(assetIds));
written.push(entryPath);
await writeExtraEntries(page, written);
await sleep(CONFIG_RELOAD_DELAY);
// Studio first: the session is already open on this page.
await capturePage(page, '/admin/journal', 'admin-journal-list', 1500);
await capturePage(page, `/admin/journal/${DEMO_JOURNAL_SLUG}`, 'admin-journal-studio', 3000);
await captureStudioModals(page);
});
// Public views in a fresh context, so the admin cookie cannot leak in and
// reveal draft-only chrome in a shot meant to show what a visitor sees.
await withPage(browser, VIEWPORT, async (page) => {
await capturePage(page, '/journal', 'journal-index', 2500);
await captureJournalEntry(page);
});
} finally {
for (const file of written) {
if (fs.existsSync(file)) {
fs.rmSync(file, { force: true });
console.log(` 🧹 Removed the demo entry (${path.basename(file)})`);
}
}
}
}
/**
* The frontmatter form, which the studio hides behind its Story Settings
* button and which no other shot reaches. Best effort: a renamed button costs
* this one shot, not the run.
*
* The studio's other dialog, the photo picker, is deliberately not captured.
* Its tabs are Favorites and All Photos — both show the operator's own library
* rather than a chosen album, and these screenshots are published.
*/
async function captureStudioModals(page: Page): Promise<void> {
try {
await page
.getByRole('button', { name: /story settings/i })
.first()
.click();
await page.locator('.journal-modal-card').waitFor({ timeout: 5000 });
await waitForImages(page);
await page.waitForTimeout(800);
await capture(page, 'admin-journal-settings');
await page
.getByRole('button', { name: /^done$/i })
.first()
.click();
await page.locator('.journal-modal-card').waitFor({ state: 'hidden', timeout: 5000 });
} catch {
console.log(' ⏭ Story Settings dialog did not open — skipping that shot.');
}
}
/**
* The album-bound companion entries. Each is skipped without failing the run
* when its album is missing (another installation) or when a file with that
* slug already exists (an author's own entry is never overwritten).
*/
async function writeExtraEntries(page: Page, written: string[]): Promise<void> {
for (const extra of EXTRA_JOURNAL_ENTRIES) {
const file = path.join(JOURNAL_DIR, `${extra.slug}.md`);
if (fs.existsSync(file)) {
console.log(` ⏭ ${extra.slug}.md already exists — leaving it alone.`);
continue;
}
const photos = await demoAlbumPhotos(page, extra.album);
if (!photos || photos.ids.length < 4) continue;
const picked = spreadSample(photos.ids, 4);
if (photos.coverId) picked[0] = photos.coverId;
fs.writeFileSync(file, extra.markdown(picked));
written.push(file);
}
}
/**
* The rendered entry, scrolled to the quote so the shot shows the block types
* the guide describes. The top of the page is only a title and a cover image,
* which says nothing about the journal that the index shot does not already
* show; the quote sits between the prose and a photo block, so framing it puts
* three of the block types in one viewport.
*/
async function captureJournalEntry(page: Page): Promise<void> {
await page.goto(`${BASE_URL}/journal/${DEMO_JOURNAL_SLUG}`, { waitUntil: 'networkidle' });
await revealDeferredContent(page);
await waitForImages(page);
const quote = page.locator('.essay-quote').first();
if ((await quote.count()) > 0) {
await quote.scrollIntoViewIfNeeded();
// Lift the quote to the upper third, so the photo block below it is in frame.
await page.evaluate(() => window.scrollBy(0, -180));
await waitForImages(page);
}
await page.waitForTimeout(3000);
await capture(page, 'journal-entry');
}
/** Log in and wait for the panel chrome. Shared by the admin and journal runs. */
async function adminLogin(page: Page): Promise<void> {
await page.goto(`${BASE_URL}/admin`, { waitUntil: 'networkidle' });
await page.locator('.admin-login-card input[type="password"]').fill(ADMIN_PASSWORD!);
await page.locator('.admin-login-card button[type="submit"]').click();
await page.locator('.admin-tabs').waitFor({ timeout: 20000 });
}
/**
* Raw asset UUIDs for the demo entry, via the admin API. `page.request` shares
* the context's cookies, so the session from adminLogin() carries over.
*/
async function demoAssetIds(page: Page, albumName = JOURNAL_ALBUM): Promise<string[]> {
return (await demoAlbumPhotos(page, albumName))?.ids ?? [];
}
/**
* The matched album's photos plus the cover its owner picked in Immich, which
* is a better opening image for an entry than whatever happens to be first in
* capture order.
*/
async function demoAlbumPhotos(
page: Page,
albumName = JOURNAL_ALBUM,
): Promise<{ ids: string[]; coverId?: string } | null> {
type AdminAlbum = {
id: string;
albumName: string;
assetCount: number;
thumbnailAssetId?: string | null;
};
const albumsRes = await page.request.get(`${BASE_URL}/api/admin/albums`);
if (!albumsRes.ok()) return null;
const albums = (await albumsRes.json()) as { albums?: AdminAlbum[] } | AdminAlbum[];
const list = Array.isArray(albums) ? albums : (albums.albums ?? []);
if (list.length === 0) return null;
const wantedName = albumName?.toLowerCase();
const album = wantedName
? list.find((a) => a.albumName.toLowerCase().includes(wantedName))
: [...list].sort((a, b) => (b.assetCount ?? 0) - (a.assetCount ?? 0))[0];
if (!album) {
console.log(` ⏭ No album matching "${albumName}" — skipping that entry.`);
return null;
}
console.log(` 🔎 Demo entry photos from: ${album.albumName}`);
const assetsRes = await page.request.get(`${BASE_URL}/api/admin/albums/${album.id}/assets`);
if (!assetsRes.ok()) return null;
const payload = (await assetsRes.json()) as
{ assets?: { id: string; type?: string }[] } | { id: string; type?: string }[];
const assets = Array.isArray(payload) ? payload : (payload.assets ?? []);
// Videos render a player, not a photo block.
const ids = assets.filter((a) => (a.type ?? 'IMAGE').toUpperCase() === 'IMAGE').map((a) => a.id);
return { ids, coverId: album.thumbnailAssetId ?? undefined };
}
/**
* A demo entry exercising every block type the parser knows — heading,
* paragraph, quote with attribution, fullbleed photo, photo pair, contained
* photo — so the studio and the rendered page both show something
* representative rather than a single paragraph.
*
* The prose is deliberately about *photographing* rather than about any
* particular place. The source album is chosen with SCREENSHOT_JOURNAL_ALBUM
* and could be a coastline, a city or a mountain range; text naming a
* landscape would contradict the photos beside it in whatever album the
* operator picks. Captions are written the same way.
*/
function demoJournalMarkdown(ids: string[]): string {
const [cover, fullbleed, pairA, pairB, ...rest] = ids;
const contained = rest[0];
return `---
title: "Notes from a Week of Photographing"
subtitle: "Seven days, one camera, and what came back from it"
author: "Jane Doe"
date: "2026-03-14"
coverAssetId: "${cover}"
---
# Setting Out
Packing for a week means deciding in advance what kind of pictures you intend
to make. One body, two lenses, and the quiet certainty that both choices will
turn out to have been slightly wrong by the second afternoon.
![${fullbleed}:fullbleed](The first frame of the trip, an hour after sunrise)
## Finding the Light
The early hours were the only reliable ones. By midday the contrast had
collapsed and every exposure came back flatter than it had looked through the
viewfinder — so the middle of each day became the part spent walking, looking,
and not raising the camera at all.
> A photograph does not record what you saw. It records what was there, which
> is a slower and more honest thing. -- A note from the second evening
![${pairA}, ${pairB}](Two mornings, photographed from nearly the same spot)
### What Stayed
Not the wide views, in the end, but the repetitions — the same twenty minutes
of light, the same short walk back, the same handful of frames worth keeping
out of a few hundred.
${contained ? `\n![${contained}](The last morning, before packing up)\n` : ''}`;
}
/**
* `count` photos spread over the album rather than its first few. Albums are
* in capture order, so the opening frames are the journey there — a station,
* a train window — which makes a poor cover for an entry about the place. The
* first fifth is skipped for the same reason.
*/
function spreadSample(ids: string[], count: number): string[] {
const start = Math.floor(ids.length / 5);
const usable = ids.slice(start);
const step = Math.max(1, Math.floor(usable.length / count));
return Array.from({ length: count }, (_, i) => usable[Math.min(i * step, usable.length - 1)]);
}
/**
* Companion entry for the Kurokawa Onsen album. Shorter than the main one: it
* exists to fill the index, and only its card is ever screenshot. The prose
* stays at the level of the place and the season, since which frames it gets
* depends on the album's order.
*/
function kurokawaJournalMarkdown(ids: string[]): string {
const [cover, fullbleed, pairA, pairB] = ids;
return `---
title: "Three Days in Kurokawa Onsen"
subtitle: "A village built into a river gorge, photographed between baths"
author: "Jane Doe"
date: "2025-11-22"
coverAssetId: "${cover}"
---
# Down to the River
The village sits in the cut of a small river, dark wood and tiled roofs
stacked up both banks, and every path eventually leads back down to the water.
Late November had the maples going over at different speeds — one tree still
green, the next one already halfway to bare.
![${fullbleed}:fullbleed](Kurokawa Onsen, late November)
## Between Baths
You buy a wooden pass and it lets you into three of the baths, so the days
arrange themselves around walking from one to the next. Most of the frames here
were made on those walks, in the hour before it got dark and the lanterns came
on along the river.
![${pairA}, ${pairB}](Two frames from the same short walk)
`;
}
/**
* Companion entry for the Kada album — see kurokawaJournalMarkdown(). Carries
* a password so the admin list shows the lock marker its documentation
* describes. `demo` is plaintext on purpose: the entry lives for the length of
* one run, and a scrypt hash here would only hide what the shot is meant to
* show. Real entries should store a hash.
*/
function kadaJournalMarkdown(ids: string[]): string {
const [cover, fullbleed, pairA, pairB] = ids;
return `---
title: "Kada, Out of Season"
subtitle: "A fishing harbour on the Wakayama coast, mostly at 200mm"
author: "Jane Doe"
date: "2023-10-19"
password: "demo"
coverAssetId: "${cover}"
---
# The Harbour Road
Kada is a working harbour before it is anything else: crates stacked against
the sea wall, rope coiled where it was dropped, boats tied up in the middle of
a weekday. Nothing is arranged for a visitor, which is most of why it is worth
photographing.
![${fullbleed}:fullbleed](Kada, October light)
## At 200mm
A long lens turned out to be the right choice here. It kept the distance
honest — the roof tiles, the bare tree behind them, the cats asleep on warm
concrete all stayed where they were instead of being walked up to.
![${pairA}, ${pairB}](Two frames from the harbour road)
`;
}
/**
* Companion entry for the Koyasan album, kept as a draft. The admin list
* documents a Draft pill and the public index documents that drafts are hidden
* from it — neither is visible while every demo entry is published. Being a
* draft it never reaches the public index shot, so it also does not crowd it.
*/
function koyasanJournalMarkdown(ids: string[]): string {
const [cover, fullbleed] = ids;
return `---
title: "Koyasan, First Notes"
subtitle: "Rough cut — still choosing which frames survive"
author: "Jane Doe"
date: "2023-10-08"
draft: true
coverAssetId: "${cover}"
---
# Under the Cedars
The old graveyard runs for two kilometres under cedars big enough to make the
path feel indoors, and the stone figures along it are dressed and looked after
by someone. Notes for now — the light changed every few minutes and I want to
see the frames on a proper screen before deciding what this is about.
![${fullbleed}:fullbleed](Along the path, mid-morning)
`;
}
/** Best effort: the picker is a modal behind a button whose label may change. */
async function captureAlbumPicker(page: Page): Promise<void> {
const trigger = page.getByRole('button', { name: /add album/i }).first();