Merge branch 'feat/dual-interests-and-content': spec 002, items in more than one interest
agent-docs / check (push) Successful in 4s
ci / test (push) Successful in 3m33s
ci / image (push) Successful in 12s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-10 12:51:16 +01:00
commit cc1782f7b8
35 files changed
+542 -61

No files matched your search

+2
View File
@@ -53,6 +53,8 @@ Hardcover ──────────┘ │ writes <cache>/sources.
- Every page is complete HTML without JavaScript; islands only enhance. Nothing moves until the visitor acts.
- No invented facts about the owner: unknown stays out or `draft: true`; an unknown AI level is shown as unknown.
- The AI filter contract: `data-ai` only on elements whose level is not null; never `display: … !important` on `[data-ai]`.
- The interest filter contract: every wall card carries `data-interests` (space-separated ids, primary first; live cards
their one id). `Chips.tsx` filters by membership and the server-side chip counts count a card under each id.
## Known rough edges
Tracked in `docs/TODO.md`: unverified live API shapes, low-contrast mockup tokens, `/everything/` filter
+21 -6
View File
@@ -1,4 +1,4 @@
# Content contract (version 1)
# Content contract (version 2)
How to write the content repository (`portfolio-next-content`). The site reads it; nothing you write here
needs a code change. Normative source: `docs/specs/001-site.md` §7.1, §10, §11. A change to this format is a
@@ -29,7 +29,7 @@ To show a media file inside a Markdown body, link it by its URL, `/media/<file>`
## `site.yml`
```yaml
contract: 1 # must be 1
contract: 2 # 1 or 2; declare 2 when any item uses an interest list
name: Example Person
tagline: One line under the name
intro: [First line, Second line] # the /work/ heading (first line, second line greyed)
@@ -105,7 +105,7 @@ newest first.
title: Fixture Comic # required
date: 2025-11-02 # required
kind: comic # required: comic | song | game | film | book | art | essay | life | music
interest: comics # required: an id from interests.yml
interest: comics # required: an id from interests.yml, or a list: [film-tv, music]
size: l # optional s | m | l (default by kind, below)
line: Your one-liner. # optional
media: # optional
@@ -113,7 +113,7 @@ media: # optional
- { url: 'https://example.invalid/video', alt: Handcam set } # or an external link
rating: 4 # optional, 0 to 5
link: https://example.invalid # optional
credit: Artist Name # commissions: the artist
credit: Artist Name # the artist/author of someone else's work; shown as "by …" on art, comic, song
kickstarter: true # optional (default false)
ai: by-hand # own work only
draft: true # optional
@@ -121,7 +121,22 @@ draft: true # optional
Markdown body.
```
- `interest` is one id or a list of ids, each from `interests.yml`, none repeated. The item shows under every chip
it names; the first is its primary interest. The others are named in the card's small meta line.
Lists need the site from spec 002 on; declare `contract: 2` in `site.yml` (see `docs/DEPLOY.md`, app first).
- Each `media` entry needs `alt` and exactly one of `file` (must exist in `media/`) or `url` (http/https).
- **Order in an `interest` list matters:** the first id is the primary one (it picks the chip a live card joins, and
the meta line names only the others). Put the interest that matches the kind first: `[film-tv, music]` for a film.
- **Shapes some kinds take from their media:**
- `film` with `music` among its interests: the first `media` entry with a `url` is its soundtrack, shown as a dark
tray across the card's foot (its `alt` is the tray's title). YouTube, Vimeo, Spotify (album/track/playlist) and
Bandcamp `EmbeddedPlayer` URLs play there after a click; any other URL is a "listen at …" link. A second image
in `media` is the album art (the first stays the poster). No `url`, no tray.
- `music` with an image in `media`: the first image replaces the striped header (the recording, player or link
still works from it). A portrait photo is cropped to 16:9, keeping the upper part.
- `comic` with exactly one image: treated as a cover, shown whole in a narrow card (`size` doesn't widen it).
Two or three images show as panels.
- Images are served as they are (no resizing at build): export them at the size they're shown, e.g. ≤ 1000 px wide.
- **Size defaults:** `l` for `comic` and `essay`; `m` for `song`, `game`, `film`, `art`, `music`; `s` for
`book` and `life`.
- **AI level:** only on things Lucas made: kinds `essay`, `life`, `music`, and `art` without `credit`. They
@@ -153,7 +168,7 @@ embeds become links, and only images hosted on the Ghost site itself are shown i
## What the validator checks
`contract` is 1; IDs match `a-z0-9-`; dates are real calendar days as `YYYY-MM-DD`; required fields exist;
enums are valid (including `showcase`); `interest` is in `interests.yml`; `related` ids exist; media paths stay
`contract` is 1 or 2; IDs match `a-z0-9-`; dates are real calendar days as `YYYY-MM-DD`; required fields exist;
enums are valid (including `showcase`); every `interest` id is in `interests.yml` (a list is non-empty, without repeats); `related` ids exist; media paths stay
inside `media/` and name files; `ai` rules above. The fixtures in
`fixtures/content/` (synthetic) show every kind; `fixtures/sparse/` is a minimal valid repo.
+33
View File
@@ -241,3 +241,36 @@ preference applies afresh). The start page's collage items and "Latest" link car
**Alternatives:** resetting the preference (surprising: it changes every other page); a server-rendered count
(wrong without knowing the preference).
**Consequences:** Without JavaScript there is no note (and nothing is hidden, since `data-pref` is not set).
## 2026-11-10 — Items carry a list of interests; content contract 2
**Status:** accepted
**Context:** Spec 002. Some shares belong to two interests (a musical film; the band, which is music and made by the
owner). An item had exactly one `interest`, so it could only show under one chip.
**Decision:** `interest` takes one id or a list; the loader normalises to `Item.interests` (primary first) and keeps
`interest = interests[0]` for code that needs one (e.g. the live cards' interest). Wall cards carry only
`data-interests`; `data-interest` is gone, so there is one attribute to filter on. The meta line names the extra
interests. The content repo declares `contract: 2` when it uses a list. Deploy order: app first, then content
(`docs/DEPLOY.md`).
**Alternatives:** keeping both attributes (two sources of truth); accepting lists under contract 1 (an older image
would fail on the list with an unclear "interest required" error instead of "unsupported contract 2").
**Consequences:** A two-interest card is counted under both chips and once under "everything".
## 2026-11-10 — Spec 002 card designs: soundtrack tray, band photo, whole cover
**Status:** accepted
**Context:** Two mockup rounds on the real CSS (`docs/inspiration.md`, "Spec 002 card mockups").
**Decision:** A film that is also music takes its soundtrack from the first `url` in `media` (alt = title, second image
= album art) and shows it as a dark tray across the card's foot (mockup C); Spotify plays in its 152 px player after a
click. A music item's first image replaces the stripes in every state (D). A comic with exactly one image shows it
whole as a 2:3 ink-framed panel in a 3-column card (H; the owner accepted F, G or H). `credit` shows as "by …" on
comics and songs.
**Alternatives:** B (record behind the poster) and G (framed cover beside the text), both kept in `docs/TODO.md`;
a new `soundtrack` field (unneeded: `media` already holds urls and alt text).
**Consequences:** No new content fields. A trailer URL on a film tagged `music` would become its "soundtrack".
## 2026-11-10 — An item's date is the day it went on the wall
**Status:** accepted
**Context:** The first real items (spec 002 §4) had no dates from the owner, and inventing "watched on" dates breaks
the no-invented-facts rule.
**Decision:** For shares without a known date, `date` is the day the item is put on the wall. That is true by construction.
**Consequences:** Several items can share a date; the owner can change any of them to a real date later.
+1
View File
@@ -119,6 +119,7 @@ both stacks to `site.yml` and the payload table in `server/README.md` like the o
| Publish a content change now | `docker exec portfolio-next site refresh` (otherwise within `POLL_INTERVAL`). On an unchanged commit it rebuilds anyway, refetching Ghost, Trakt and Hardcover (e.g. after rotating a token). |
| Update the app | Merge to `main`; once CI is green, deploy with `-e pull=always`. A new app version rebuilds the current content. |
| Roll back the app | Pin `image:` to `...portfolio-next:<sha>` of a good commit and deploy. |
| Bump the content contract | **App first, then content.** Deploy the app version that supports the new `contract` before pushing content that declares it; an older app refuses it ("unsupported contract") and keeps serving its last good build. Contract 2 (an `interest` list) needs the app from spec 002 on. |
| Rotate a secret | Update it in OpenBao (same `bao kv put`, all four fields), deploy (re-renders `.env`, recreates the container). |
## Troubleshooting
+10 -10
View File
@@ -1,18 +1,18 @@
# Handoff — 2026-11-10
# Handoff — 2026-11-10 (after spec 002)
Where development stopped, and how to pick it up. Delete or rewrite this file when the next session starts.
## State
- **Spec 001 and the review follow-ups are merged into `main` and pushed to Gitea** (2026-11-10, owner-approved).
Follow-ups: Ghost HTML sanitizer rebuilt as an allowlist on Bun's `HTMLRewriter` in `src/site/post-html.ts`
(`docs/DECISIONS.md`, 2026-11-10); hidden-count notes count each entry once; issue cover lines follow the AI
filter; the start page's work half notes what the filter hides.
- **Not deployed.** The owner wires the homelab config repo, OpenBao secrets and DNS himself (`docs/DEPLOY.md`).
- **Content repo** `portfolio-next-content` (`~/portfolio-next-content`, pushed, head `a895087`): 13 old projects with
screenshots; AU projects, "this site" and three Bluesky comic shares are `draft: true`.
- **Verified on `main`:** `bun run typecheck` clean, `bun test` 156 pass, `bunx playwright test`
130 pass / 8 skipped, `docker build` + `scripts/smoke-test.sh` ok.
- **Spec 002 (items in more than one interest, soundtrack tray, band photo, whole-cover comics) is built** on branch
`feat/dual-interests-and-content`, **not merged** (owner approves merges). `bun test` 168 pass, typecheck clean,
`bunx playwright test` 129 pass / 9 skipped (phone-only keyboard tests) before the final fix commit.
- **Content repo** `portfolio-next-content` (`~/portfolio-next-content`) branch `feat/first-items`, **not pushed**:
`contract: 2`, five new items (Dear Evan Hansen, A Whisker Away, Always Never, Summer Blooms Without You) and
Saltlakridserne (photo only, no link: the owner said the Facebook URL isn't important; a landscape header photo
would crop better than the portrait one).
Dear Evan Hansen's tray plays "Part Of Me" (Broadway Cast Recording, Deluxe) on Spotify, the owner's pick. Deploy order: app first, then this content (`docs/DEPLOY.md`).
- Spec 001 and its follow-ups are merged into `main` and pushed; not deployed (owner wires homelab, OpenBao, DNS).
## Next steps
+6
View File
@@ -22,6 +22,12 @@ Small, specific, independently actionable. Remove only when actually done.
## Next
- [ ] Revisit the start page's "or wander through everything at once" pill (and its G shortcut). Hidden for now on the owner's request: `SHOW_EVERYTHING_PILL` in `src/site/pages/index.astro`. Its CSS, the seam positioning in `Gate.tsx` and the seam check in `e2e/gate.spec.ts` are kept; `/everything/` is still reachable from `/simple`.
- [ ] Personal wall: a chip filter is not reflected in the URL, so a filtered view can't be shared.
- [ ] Trakt: show the last watched item *alongside* "recently rated" (owner, 2026-11-10: "there is space for both"; design it when we get there). The owner doesn't scrobble directly to Trakt, so history may land late: don't present it as live "watching now". Needs its own spec and mockups.
- [ ] Film soundtrack, alternative design: mockup B (the soundtrack as a record tucked behind the poster, slides out on hover; `mockups/shots/spec-002-r1-cards.png`). The owner kept it as an idea for later or as an alternative to the tray.
- [ ] Single-cover comic, alternative design: mockup G (ink-framed cover beside the text). J (cover leaning on the paper with the one-liner) "could be interesting but doesn't work yet". I (cover plus two panels) only with real, different page images — today's panel strip already does that when an item has three images.
- [ ] Media images ship as-is: the build copies `media/` verbatim and cards use plain `<img>` (no `astro:assets`). Resize/convert at build (e.g. `sharp`, a new runtime dependency: ask the owner) or keep resizing by hand in the content repo.
- [ ] Spec 002 review minors: the tray's "plays from … when you press play" note is wrong without JavaScript (it's a link out then); focus drops to `<body>` when `EmbedOnClick` swaps in the iframe; `interestFor` should prefer an interest matching its pattern over the primary (a film listed `[music, film-tv]` files the Trakt card under music); `Music.astro` embed label uses `video?.alt` though the embed plays `entry.link`; `.panels.cover` shares the global `.cover` class name.
- [ ] A film's soundtrack as an audio *file* in `media/` (only a `url` makes a tray today).
- [ ] Personal wall: Bandcamp page URLs link out instead of embedding (only `EmbeddedPlayer` URLs embed); consider an optional `embed` field.
## Review follow-ups (deferred minors, by area)
+13
View File
@@ -181,6 +181,19 @@ Brief: `mockups/BRIEF-personal-r2.md`. `personal-r2-a` (shelves of things I love
**Decision (2026-11-09): the personal space is `personal-r2-b` (one big, rich wall)**, plus **a half-yearly version of the zine/newspaper** (from personal-r2-c) as a roundup. nav-d's "come in" now links to personal-r2-b. tech-r3-c's "off the clock" edge links to personal-r2-b, and personal-r2-b's "work" edge links to tech-r3-c.
## Spec 002 card mockups (2026-11-10)
Mocked with the real site CSS and real artwork. Screenshots: `mockups/shots/spec-002-r1-cards.png`, `spec-002-r2-comic.png`.
- **Film that is also music (Dear Evan Hansen):** A player bar in the text column, B the soundtrack as a record tucked
behind the poster (slides out on hover), C a full-width dark "tray" at the card's foot. **Picked C.** B is kept as an idea
for later or as an alternative.
- **Band photo on a music card:** D the photo as it is, E toned plum. **Picked D.**
- **A comic shared with only its cover (Always Never):** today's crop to a wide strip is **unacceptable**. F (cover stands
up on the paper), G (ink-framed cover beside the text) and H (tall card, the cover is the panel) **all work**. I (cover
plus two panels) is interesting, but only with real, different page images, never crops of the cover. J (cover leaning
on the paper with the one-liner) could be interesting but doesn't work yet. Builder's call: H, G as the alternative.
## Decisions so far (summary, 2026-11-09)
- **Start page:** `mockups/nav-d.html`, with two gates (work on the left, off the clock on the right) and a small "wander through everything at once" link to the garden (`nav-c.html` idea).
@@ -0,0 +1,138 @@
---
status: implemented
---
# 002 — Items in more than one interest, three card refinements, and the first real items
Designed with the owner in conversation, 2026-11-10; card designs picked from two mockup rounds the same day
(`docs/inspiration.md`, "Spec 002 card mockups"). Normative extension to `docs/specs/001-site.md` and
`docs/CONTENT-CONTRACT.md`.
Labels: **[D]** decided by the owner. **[C]** builder's call; a sensible builder could settle it differently without
changing what gets built.
## 1. Intent
The off-the-clock wall shares what Lucas loves and makes. Some things belong to two interests: *Dear Evan Hansen* is a
film and a soundtrack, and his band *Saltlakridserne* is music and something he made. Today an item has exactly one
interest, so it can only show under one chip. [D]
This spec lets an item carry several interests. It adds a soundtrack "tray" to film cards that are also music, a
header photo on music cards, and a whole-cover layout for comics shared with only a cover. It also seeds the content
repo with the first real items. [D]
**Non-goals:** new item kinds (no `album` kind; an album shared as music is a `song` [C]); a context-aware card that
changes with the active chip (rejected in the first round); embedding Facebook.
**Anti-goals:** no invented facts or opinions in content; nothing that moves before the visitor acts; no change to how
single-interest items look.
## 2. Content format (contract 2)
### 2.1 `interest` takes one id or a list [D]
- `interest: comics` stays valid. `interest: [film-tv, music]` is also valid. The first id is the item's **primary**
interest, which counts as "the" interest wherever one is needed (e.g. picking the interest for a live card).
- Every id must exist in `interests.yml`. A list must not be empty and must not repeat an id; each problem is reported
with the existing `items/<id>.md:interest` error key. [C]
### 2.2 Contract version 2 [C]
- The loader accepts `contract: 1` and `contract: 2` (`SUPPORTED_CONTRACTS = [1, 2]`); both read the same way.
- The content repo declares `contract: 2` once it uses a list. Without the bump, a site image older than this spec
rejects the list as an unclear "interest" error. With it, the old image says "unsupported contract 2" and keeps
serving its last good build. **Deploy order:** site first, then content. Document this in `docs/CONTENT-CONTRACT.md`
and `docs/DEPLOY.md`.
### 2.3 `credit` on shares [C]
- `credit` is already allowed on any kind but only shown on art. It is now shown as "by <credit>" under the title on
`comic` and `song` cards. It doesn't change whether the item is the owner's own work; only art without `credit` is.
### 2.4 Where a film's soundtrack comes from [C]
No new field. On a film whose interests include `music`, the soundtrack is the **first `media` entry with a `url`**:
- If `embedFor(url)` recognises it (YouTube, Vimeo, Spotify, Bandcamp `EmbeddedPlayer`), it becomes a click-to-load player.
- Otherwise it is a plain "listen at <host> ↗" link.
- The tray title is that entry's `alt`. The album art is the **second image** in `media`, if there is one; the first
image stays the poster.
A film with `music` but no `url` media renders as an ordinary film card. An audio *file* on a film is out of scope
(`docs/TODO.md`).
## 3. Site
### 3.1 Data
- `Item` gains `interests: string[]`; `interest` stays and equals `interests[0]`. [D]
- `Entry` gains `interests: string[]` and `also: string[]`. `also` holds the lower-cased names of the interests after the
primary one, in order, for display. Ghost posts get `interests: [interest]` and `also: []`.
### 3.2 One filter attribute [C]
- Every wall card (item cards, older-year cards, live cards) carries `data-interests="<space-separated ids>"` and no
longer carries `data-interest`. That gives one source of truth.
- `Chips.tsx` counts and filters by membership (`dataset.interests.split(' ').includes(id)`). A two-interest item shows
under both chips and counts once under "everything".
- The server-side chip counts in `off-the-clock/index.astro` count each entry under each of its interests, so the
no-JavaScript counts match the hydrated ones.
### 3.3 Meta line [C]
The small mono line names the kind, then any `also` interests whose name differs from the kind label:
`film & tv · music · 10 Nov`, `music · made by me · 10 Nov`.
### 3.4 Film card: the soundtrack tray [D: mockup C]
When §2.4 finds a soundtrack, the film card (poster and text unchanged) gets a full-width dark strip (`--plum`) across
its foot, reaching both side edges and the bottom edge. The strip holds:
- the album art as a small square on the left (it falls back to the entry's swatch colour when there's no second image);
- the tray title in the serif, with a mono note under it ("plays from Spotify when you press play", or "listen at
<host> ↗");
- the yellow round play button (`--acc`) on the right.
Pressing play loads the player in place of the strip, as `EmbedOnClick` does on song cards; Spotify uses its compact
152 px height. Nothing loads or moves before the press. It looks the same on the wall, opened, and on the item's page.
Without JavaScript, the strip is a plain link to the soundtrack.
Mockup B (the record tucked behind the poster) is kept in `docs/TODO.md` as a future alternative. [D]
### 3.5 Music card: header photo [D: mockup D]
When a `music` entry has an image in `media`, that photo takes the place of the striped 16:9 area, at full colour,
`object-fit: cover`, with a dark gradient at the foot so the link label stays legible. It does this in all three
existing states:
- a recording file: the photo sits inside `SongPlayer`, with the play button over it;
- an embeddable link: the photo sits inside `EmbedOnClick`, with the play button over it;
- a plain link: the photo is the link, labelled "<media alt> · <host> ↗" as today.
Without an image, nothing changes.
### 3.6 Comic card: a single cover [D: F, G or H; builder picked H]
When a comic has exactly one image, the wall teaser shows it whole as one tall panel at 2:3, with the comic ink border
(mockup H). The card spans 3 columns at every `size`. Comics with two or three images keep today's panel strip. The
opened view is unchanged (one page shows as `.pgbig.solo`). Mockup G (the framed cover beside the text) is the recorded
alternative; I (cover plus two real pages) already happens with today's strip when the item has three distinct
images. [C]
## 4. Starter content (`portfolio-next-content`) [D]
All dates are **2026-11-10, the day each item was put on the wall**. That date is true by construction; the owner gave
no "watched/read on" dates. [C] Bodies hold only public facts about the work, sourced from Wikipedia, the publisher or
the artist. No `line`, `rating` or opinion is written for the owner. Artwork is downloaded into `media/`.
| File | kind | interest | Media | Link / soundtrack |
|---|---|---|---|---|
| `dear-evan-hansen.md` | film | `[film-tv, music]` | poster, soundtrack cover (Wikipedia) | soundtrack: Spotify album URL from the owner, else the YouTube Music/YouTube playlist the owner gives; without either, no tray |
| `a-whisker-away.md` | film | film-tv | poster (Wikipedia) | Wikipedia article |
| `always-never.md` | comic | comics | cover (publisher or Open Library), `credit: Jordi Lafebre` | Dark Horse product page |
| `summer-blooms-without-you.md` | song | music | album art, `credit: rachie` | the owner's link, else the album trailer on YouTube (`CwqANihSHdU`) |
| `saltlakridserne.md` | music | `[music, made-by-me]` | the band's Facebook header photo, from the owner | the band's Facebook page, from the owner; `ai: by-hand` |
If the owner hasn't supplied the band photo and page URL by the content step, `saltlakridserne.md` is committed as
`draft: true`. Content-repo changes go on a branch there and are not pushed without the owner. [C]
## 5. Acceptance criteria
- Unit: `loadItem` accepts a string and a list; it reports an unknown id, an empty list and a repeated id; `interest`
equals the first of `interests`; contract 1 and 2 both load, 3 is rejected.
- Unit: `toEntries` carries `interests` and `also`; posts get one interest.
- Build tests: every wall card has `data-interests` and none has `data-interest`; a fixture film with `[film-tv, music]`
and a Spotify media URL renders the tray; a fixture music item with an image renders the photo; a single-image comic
renders the cover panel at span 3; chip counts in the HTML count a two-interest item under both chips.
- e2e: picking "music" shows the two-interest film; picking "film & tv" shows it too; "everything" counts it once.
- `bun src/cli.ts validate ~/portfolio-next-content` exits 0 (4 or 5 items, depending on the band item; 13 projects).
- `bun run typecheck`, `bun test` and `bunx playwright test` pass. The off-the-clock page, built with the real content,
is screenshotted and checked against the chosen mockups (C, D, H).
- Docs: `CONTENT-CONTRACT.md` (contract 2, list `interest`, `credit`, soundtrack rule, single-cover comics),
`ARCHITECTURE.md` if the data flow note changes, `DECISIONS.md`, `TODO.md` (mockup B, audio file on a film), `DEPLOY.md` (deploy order).
+1
View File
@@ -13,6 +13,7 @@ export const P = {
draft: 'draft-unfinished-thing',
};
export const I = {
soundtrackFilm: 'fixture-soundtrack-film', // [film-tv, music], spotify media url
song: 'example-song', // youtube link, no ai (a "share")
byHand: 'own-drawing',
unknown: 'fixture-life',
+15 -6
View File
@@ -11,14 +11,23 @@ test('chips filter the wall by interest', async ({ page }) => {
await live(page);
const cards = page.locator('[data-wall-card]');
const all = await cards.count();
const shownIds = () => page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0)
.map((c) => ({ id: (c as HTMLElement).dataset.id, interests: ((c as HTMLElement).dataset.interests ?? '').split(' ') })));
await page.getByRole('button', { name: /^music/ }).click();
const shown = await page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0).map((c) => (c as HTMLElement).dataset.interest));
expect(shown.length).toBeGreaterThan(0);
expect(shown.length).toBeLessThan(all);
expect(new Set(shown)).toEqual(new Set(['music']));
const music = await shownIds();
expect(music.length).toBeGreaterThan(0);
expect(music.length).toBeLessThan(all);
for (const c of music) expect(c.interests).toContain('music');
expect(music.map((c) => c.id)).toContain(I.soundtrackFilm);
// A two-interest card shows under both of its chips.
await page.getByRole('button', { name: /^film & tv/ }).click();
expect((await shownIds()).map((c) => c.id)).toContain(I.soundtrackFilm);
await page.getByRole('button', { name: /^everything/ }).click();
const back = await page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0).length);
expect(back).toBeGreaterThanOrEqual(shown.length + 1);
const back = await shownIds();
expect(back.length).toBeGreaterThanOrEqual(music.length + 1);
// ...and is counted once under "everything".
const everything = await page.getByRole('button', { name: /^everything/ }).locator('sup').textContent();
expect(Number(everything)).toBe(back.length);
});
test('opening a card updates the URL; Esc closes it', async ({ page }) => {
@@ -0,0 +1,11 @@
---
title: Fixture Band Photo
date: 2025-08-01
kind: music
interest: [music, made-by-me]
ai: by-hand
link: https://example.invalid/band
media:
- { file: fixture-5.svg, alt: Fixture band photo }
---
A synthetic band with a photo and a page elsewhere.
@@ -0,0 +1,11 @@
---
title: Fixture Cover Comic
date: 2025-08-20
kind: comic
interest: comics
size: l
credit: Fixture Artist
media:
- { file: fixture-6.svg, alt: Fixture comic cover }
---
A synthetic comic shared with only its cover.
@@ -0,0 +1,11 @@
---
title: Fixture Soundtrack Film
date: 2025-07-25
kind: film
interest: [film-tv, music]
media:
- { file: fixture-pixel.png, alt: Fixture poster }
- { file: fixture-3.svg, alt: Fixture soundtrack cover }
- { url: 'https://open.spotify.com/album/fixture0000', alt: Fixture Soundtrack }
---
A synthetic film that is also a soundtrack.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+24 -5
View File
@@ -12,7 +12,7 @@ import type {
AiPart, Content, Interest, Item, ItemKind, LoadResult, Link, Media, Project, Site,
} from './types.ts';
export const SUPPORTED_CONTRACTS = [1];
export const SUPPORTED_CONTRACTS = [1, 2];
export const ID_PATTERN = /^[a-z0-9-]+$/;
/** Ids an item may not take: their URL under /off-the-clock/ belongs to another page. */
export const RESERVED_PERSONAL_IDS: readonly string[] = ['issues'];
@@ -57,6 +57,23 @@ function required(v: unknown, field: string, report: Report): string {
return s ?? '';
}
// `interest: id` or `interest: [id, id]`; the first is the primary interest.
function interestList(v: unknown, report: Report): string[] {
if (!Array.isArray(v)) {
const one = required(v, 'interest', report);
return one ? [one] : [];
}
if (v.length === 0) report('interest', 'must not be empty');
const out: string[] = [];
for (const x of v) {
const id = str(x);
if (!id) report('interest', 'each entry must be an interest id');
else if (out.includes(id)) report('interest', `repeats "${id}"`);
else out.push(id);
}
return out;
}
function textList(v: unknown, field: string, report: Report): string[] {
if (v === undefined || v === null) return [];
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string' && x.trim())) {
@@ -135,7 +152,7 @@ function loadSite(raw: unknown, mediaDir: string, report: Report): Site {
const personalIntro = d.personal_intro === undefined || d.personal_intro === null ? undefined : str(d.personal_intro);
if (d.personal_intro !== undefined && d.personal_intro !== null && !personalIntro) report('personal_intro', 'must be text');
const site: Site = {
contract: 1,
contract: d.contract === 2 ? 2 : 1,
name: required(d.name, 'name', report),
tagline: required(d.tagline, 'tagline', report),
intro: textList(d.intro, 'intro', report),
@@ -354,12 +371,14 @@ function loadItem(doc: Doc, mediaDir: string, report: Report): Item {
});
}
}
const interests = interestList(f.interest, report);
const item: Item = {
id: doc.id,
title: required(f.title, 'title', report),
date: dateField(f.date, report),
kind: k,
interest: required(f.interest, 'interest', report),
interest: interests[0] ?? '',
interests,
size,
media,
kickstarter: f.kickstarter === true,
@@ -414,8 +433,8 @@ export async function loadContent(dir: string): Promise<LoadResult> {
}
const interestIds = new Set(interests.map((i) => i.id));
for (const i of items) {
if (i.interest && !interestIds.has(i.interest)) {
reporter(`items/${i.id}.md`)('interest', `unknown interest "${i.interest}"`);
for (const id of i.interests) {
if (!interestIds.has(id)) reporter(`items/${i.id}.md`)('interest', `unknown interest "${id}"`);
}
}
+3 -2
View File
@@ -3,7 +3,7 @@ import type { ValidationError } from './errors.ts';
export type Link = { label: string; url: string };
export type Site = {
contract: 1;
contract: 1 | 2;
name: string;
intro: string[];
personalIntro?: string; // site.yml personal_intro: the lede off the clock
@@ -40,7 +40,8 @@ export type Item = {
title: string;
date: string;
kind: ItemKind;
interest: string;
interest: string; // the primary interest, interests[0]
interests: string[];
size: 's' | 'm' | 'l';
line?: string;
media: Media[];
+3 -3
View File
@@ -10,7 +10,7 @@ import Art from './cards/Art.astro';
import Essay from './cards/Essay.astro';
import Life from './cards/Life.astro';
import Music from './cards/Music.astro';
import { spanOf, type Entry } from './entries.ts';
import { isCoverComic, spanOf, type Entry } from './entries.ts';
interface Props { entry: Entry; mode: 'wall' | 'page'; now: number; signature?: string }
const { entry, mode, now, signature } = Astro.props;
const KIND = { comic: Comic, song: Song, game: Game, film: Film, book: Book, art: Art, essay: Essay, life: Life, music: Music };
@@ -21,9 +21,9 @@ const dark = entry.kind === 'song' || entry.kind === 'music';
const ai = mode === 'wall' && entry.ai !== null ? entry.ai : undefined;
---
<article
class:list={['card', entry.kind, dark && 'dark', mode === 'page' && 'open page']}
class:list={['card', entry.kind, isCoverComic(entry) && 'cover-comic', dark && 'dark', mode === 'page' && 'open page']}
data-id={entry.id}
data-interest={entry.interest}
data-interests={entry.interests.join(' ')}
data-wall-card={mode === 'wall' ? '' : undefined}
data-ai={ai}
style={`--span:${spanOf(entry)}`}
+6 -3
View File
@@ -1,4 +1,5 @@
// Interest chips in the sticky bar. They filter the wall in the page (cards carry data-interest),
// Interest chips in the sticky bar. They filter the wall in the page (cards carry data-interests,
// space-separated ids; a card shows under every chip it names),
// fold away older years with nothing left, and retitle the wall. Without JavaScript the chips are
// hidden by CSS (html:not(.js)) and the whole wall shows.
import { useEffect, useState } from 'react';
@@ -11,6 +12,8 @@ const ALL = 'all';
// Cards the visitor's "projects made" preference leaves on the page (or "show all" revealed),
// so counts never claim hidden things.
const has = (card: HTMLElement, id: string) => (card.dataset.interests ?? '').split(' ').includes(id);
function visibleCards(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[data-wall-card]')].filter(isShown);
}
@@ -25,7 +28,7 @@ export default function Chips({ chips, total }: Props) {
const recount = () => {
const cards = visibleCards();
const next: Record<string, number> = { [ALL]: cards.length };
for (const c of chips) next[c.id] = cards.filter((k) => k.dataset.interest === c.id).length;
for (const c of chips) next[c.id] = cards.filter((k) => has(k, c.id)).length;
setCounts(next);
};
recount();
@@ -37,7 +40,7 @@ export default function Chips({ chips, total }: Props) {
setOn(id);
const all = id === ALL;
const cards = [...document.querySelectorAll<HTMLElement>('[data-wall-card]')];
for (const c of cards) c.toggleAttribute('data-off', !all && c.dataset.interest !== id);
for (const c of cards) c.toggleAttribute('data-off', !all && !has(c, id));
const shown = visibleCards().filter((c) => !c.hasAttribute('data-off'));
for (const d of document.querySelectorAll<HTMLElement>('.older-year')) {
d.toggleAttribute('data-off', !all && !shown.some((c) => d.contains(c)));
@@ -2,7 +2,7 @@
// makes no third-party request until the visitor asks. Without JavaScript the poster is a plain link out.
import { useState, type ReactNode } from 'react';
const NAME = { youtube: 'YouTube', vimeo: 'Vimeo', spotify: 'Spotify', bandcamp: 'Bandcamp' } as const;
export const NAME = { youtube: 'YouTube', vimeo: 'Vimeo', spotify: 'Spotify', bandcamp: 'Bandcamp' } as const;
type Props = {
provider: keyof typeof NAME;
@@ -15,7 +15,7 @@ const things = (n: number) => `${n} ${n === 1 ? 'thing' : 'things'}`;
<summary><span>{year} · {things(list.length)}</span><i aria-hidden="true">↓</i></summary>
<div class="wall older-wall" data-wall>
{list.map((e) => (
<article class:list={['card', 'arc', e.kind]} data-id={e.id} data-interest={e.interest} data-wall-card data-ai={e.ai ?? undefined} style="--span:3">
<article class:list={['card', 'arc', e.kind]} data-id={e.id} data-interests={e.interests.join(' ')} data-wall-card data-ai={e.ai ?? undefined} style="--span:3">
<a class="arc-link" href={`/off-the-clock/${e.id}/`}>
{images(e).length > 0 && <span class="th"><Cover entry={e} titled={false} /></span>}
<span class="pad">
@@ -1,10 +1,11 @@
---
// comic: two or three pages as panels and Lucas's line; opened, a small page reader beside the note.
// comic: two or three pages as panels and Lucas's line; a single image is a cover, shown whole as one tall
// panel. Opened, a small page reader beside the note.
import ComicReader from '../ComicReader.tsx';
import Meta from './Meta.astro';
import Title from './Title.astro';
import Cover from './Cover.astro';
import { images, mediaUrl, type Entry } from '../entries.ts';
import { images, isCoverComic, mediaUrl, type Entry } from '../entries.ts';
interface Props { entry: Entry; mode: 'wall' | 'page'; now: number }
const { entry, mode, now } = Astro.props;
const pages = images(entry).map((m) => ({ src: mediaUrl(m), alt: m.alt, ...(m.caption ? { caption: m.caption } : {}) }));
@@ -14,11 +15,12 @@ const host = entry.link ? new URL(entry.link).hostname.replace(/^www\./, '') : n
{mode === 'wall' && (
<div class="teaser">
{panels.length > 0
? <div class={`panels n${panels.length}`}>{panels.map((p) => <div><img src={p.src} alt={p.alt} loading="lazy" decoding="async" /></div>)}</div>
? <div class={isCoverComic(entry) ? 'panels cover' : `panels n${panels.length}`}>{panels.map((p) => <div><img src={p.src} alt={p.alt} loading="lazy" decoding="async" /></div>)}</div>
: <div class="panels n1"><div><Cover entry={entry} /></div></div>}
<div class="pad">
<Meta entry={entry} now={now} />
<Title entry={entry} mode={mode} part="teaser" />
{entry.credit && <div class="by">by {entry.credit}</div>}
{entry.line && <p class="said">“{entry.line}”</p>}
<div class="open-hint" aria-hidden="true">open ↘</div>
</div>
@@ -36,6 +38,7 @@ const host = entry.link ? new URL(entry.link).hostname.replace(/^www\./, '') : n
<div class="prose">
<Meta entry={entry} now={now} />
<Title entry={entry} mode={mode} part="full" class="big" />
{entry.credit && <div class="by">by {entry.credit}</div>}
{entry.line && <p class="said">“{entry.line}”</p>}
<div class="body" set:html={entry.bodyHtml} />
{entry.link && <a class="src" href={entry.link} rel="noopener">read it at {host} ↗</a>}
+23 -2
View File
@@ -1,13 +1,17 @@
---
// film & tv: poster and rating; opened, the write-up.
// film & tv: poster and rating; opened, the write-up. A film that is also music carries its soundtrack in a
// dark tray across the card's foot (spec 002, mockup C); pressing play loads the player in its place.
import Meta from './Meta.astro';
import Title from './Title.astro';
import Cover from './Cover.astro';
import Rating from './Rating.astro';
import type { Entry } from '../entries.ts';
import EmbedOnClick, { NAME } from '../EmbedOnClick.tsx';
import { mediaUrl, soundtrackOf, swatch, type Entry } from '../entries.ts';
interface Props { entry: Entry; mode: 'wall' | 'page'; now: number }
const { entry, mode, now } = Astro.props;
const host = entry.link ? new URL(entry.link).hostname.replace(/^www\./, '') : null;
const track = soundtrackOf(entry);
const trackHost = track ? new URL(track.url).hostname.replace(/^www\./, '') : '';
---
<div class="film-grid">
<div class="po"><Cover entry={entry} /></div>
@@ -23,3 +27,20 @@ const host = entry.link ? new URL(entry.link).hostname.replace(/^www\./, '') : n
{mode === 'wall' && <div class="open-hint" aria-hidden="true">open ↘</div>}
</div>
</div>
{track && (() => {
const inner = (note: string) => (
<>
{track.art
? <img class="tray-art" src={mediaUrl(track.art)} alt="" loading="lazy" decoding="async" />
: <span class="tray-art" style={`background:${swatch(entry.id).bg}`} aria-hidden="true"></span>}
<span class="tray-t"><b>{track.title}</b><small>{note}</small></span>
</>
);
return track.embed
? <div class="tray-host">
<EmbedOnClick client:visible provider={track.embed.provider} src={track.embed.src} link={track.url} title={track.title} height={track.embed.height}>
<div class="tray">{inner(`plays from ${NAME[track.embed.provider]} when you press play`)}</div>
</EmbedOnClick>
</div>
: <a class="tray" href={track.url} rel="noopener">{inner(`listen at ${trackHost} ↗`)}<span class="tray-go" aria-hidden="true">↗</span></a>;
})()}
@@ -14,7 +14,7 @@ const first = source === 'hardcover' ? (hc.reading[0]?.url ?? hc.recent[0]?.url)
const origin = first ? new URL(first) : null;
const short = (t: string) => (t.length > 30 ? `${t.slice(0, 28).trimEnd()}…` : t);
---
<article class="card live" data-live={source} data-wall-card data-interest={interest} style={`--span:${span}`}>
<article class="card live" data-live={source} data-wall-card data-interests={interest} style={`--span:${span}`}>
<div class="pad">
<div class="meta">
<span class="live-src"><i aria-hidden="true"></i>{source === 'hardcover' ? 'Hardcover' : 'Trakt'}</span>
@@ -8,5 +8,6 @@ const sameYear = entry.date.slice(0, 4) === new Date(now).toISOString().slice(0,
<div class="meta">
{isFresh(entry.date, now) && <span class="new">new</span>}
<span>{label ?? kindLabel(entry)}</span><span aria-hidden="true">·</span>
{!label && entry.also.map((n) => <><span>{n}</span><span aria-hidden="true">·</span></>)}
<time datetime={entry.date}>{sameYear ? shortDate(entry.date) : longDate(entry.date)}</time>
</div>
+11 -6
View File
@@ -1,13 +1,14 @@
---
// music (Lucas's own, e.g. the band): a recording plays here (SongPlayer), a YouTube/Vimeo/Spotify/
// Bandcamp link is a click-to-load player, a handcam video elsewhere is a link out. Opened, the
// note and setlist (the Markdown body).
// note and setlist (the Markdown body). A photo in media takes the place of the stripes in every state
// (spec 002, mockup D).
import AiMark from '../../AiMark.astro';
import SongPlayer from '../SongPlayer.tsx';
import EmbedOnClick from '../EmbedOnClick.tsx';
import Meta from './Meta.astro';
import Title from './Title.astro';
import { audioOf, embedFor, mediaUrl, type Entry } from '../entries.ts';
import { audioOf, embedFor, images, mediaUrl, type Entry } from '../entries.ts';
interface Props { entry: Entry; mode: 'wall' | 'page'; now: number }
const { entry, mode, now } = Astro.props;
const audio = audioOf(entry);
@@ -15,14 +16,18 @@ const embed = audio ? null : embedFor(entry.link);
const video = entry.media.find((m) => m.url);
const out = embed ? null : (video?.url ?? entry.link);
const host = (u: string) => new URL(u).hostname.replace(/^www\./, '');
const photo = images(entry)[0];
const area = photo ? 'video photo' : 'video';
---
{audio
? <SongPlayer client:visible src={mediaUrl(audio)} title={entry.title}><div class="video"><span>{audio.alt}</span></div></SongPlayer>
? <SongPlayer client:visible src={mediaUrl(audio)} title={entry.title}><div class={area}>{photo && <img class="cover" src={mediaUrl(photo)} alt={photo.alt} loading="lazy" decoding="async" />}<span>{audio.alt}</span></div></SongPlayer>
: embed
? <EmbedOnClick client:visible provider={embed.provider} src={embed.src} link={entry.link!} title={entry.title} height={embed.height}><div class="video"><span>{entry.media[0]?.alt ?? entry.title}</span></div></EmbedOnClick>
? <EmbedOnClick client:visible provider={embed.provider} src={embed.src} link={entry.link!} title={entry.title} height={embed.height}><div class={area}>{photo && <img class="cover" src={mediaUrl(photo)} alt={photo.alt} loading="lazy" decoding="async" />}<span>{video?.alt ?? entry.title}</span></div></EmbedOnClick>
: out
? <a class="video" href={out} rel="noopener"><span class="vp" aria-hidden="true">▶</span><span>{video?.alt ?? 'watch'} · {host(out)} ↗</span></a>
: <div class="video"><span>{entry.title}</span></div>}
? photo
? <a class={area} href={out} rel="noopener"><img class="cover" src={mediaUrl(photo!)} alt={photo!.alt} loading="lazy" decoding="async" /><span>{video ? `${video.alt} · ` : ''}{host(out)} ↗</span></a>
: <a class="video" href={out} rel="noopener"><span class="vp" aria-hidden="true">▶</span><span>{video?.alt ?? 'watch'} · {host(out)} ↗</span></a>
: <div class={area}>{photo && <img class="cover" src={mediaUrl(photo)} alt={photo.alt} loading="lazy" decoding="async" />}<span>{entry.title}</span></div>}
<div class="pad">
<Meta entry={entry} now={now} />
<Title entry={entry} mode={mode} />
@@ -27,6 +27,7 @@ const host = entry.link ? new URL(entry.link).hostname.replace(/^www\./, '') : n
<div class="pad">
<Meta entry={entry} now={now} />
<Title entry={entry} mode={mode} />
{entry.credit && <div class="by">by {entry.credit}</div>}
{entry.line && <p class="said">“{entry.line}”</p>}
<div class="full prose">
<div class="body" set:html={entry.bodyHtml} />
+28 -4
View File
@@ -10,7 +10,9 @@ export type Entry = {
title: string;
date: string;
kind: ItemKind;
interest: string;
interest: string; // primary, interests[0]
interests: string[];
also: string[]; // lower-case names of the interests after the primary, for the meta line
size: 's' | 'm' | 'l';
line?: string;
media: Media[];
@@ -33,13 +35,19 @@ const firstPara = (html: string) => strip(html.match(/<p>([\s\S]*?)<\/p>/i)?.[1]
export function toEntries(personal: Array<Item | Post>, interests: Interest[]): Entry[] {
const known = new Set(interests.map((i) => i.id));
const names = new Map(interests.map((i) => [i.id, i.name.toLowerCase()]));
return personal.map((e): Entry => {
if (!isPost(e)) return { ...e, excerpt: firstPara(e.bodyHtml) };
if (!isPost(e)) {
const entry: Entry = { ...e, also: [], excerpt: firstPara(e.bodyHtml) };
const label = kindLabel(entry);
entry.also = e.interests.slice(1).map((id) => names.get(id) ?? id).filter((n) => n !== label);
return entry;
}
const kind = e.kind === 'life' ? 'life' : 'essay';
const interest = kind === 'life' && known.has('life') ? 'life' : 'writing';
const bodyHtml = e.html; // already made safe in site data
return {
id: e.slug, title: e.title, date: e.date, kind, interest, size: kind === 'life' ? 's' : 'l', media: [],
id: e.slug, title: e.title, date: e.date, kind, interest, interests: [interest], also: [], size: kind === 'life' ? 's' : 'l', media: [],
kickstarter: false, ownWork: true, ai: e.ai, bodyHtml, excerpt: e.excerpt.trim() || firstPara(bodyHtml),
postUrl: e.url,
};
@@ -82,6 +90,20 @@ export function embedFor(link: string | undefined): Embed | null {
return null;
}
/* ---------- a film's soundtrack (spec 002 §2.4) ---------- */
// On a film that is also music: the first url in media, its alt as the title, the second image as album art.
// Spotify plays in its compact 152 px player.
export type Soundtrack = { url: string; title: string; art?: Media; embed: Embed | null };
export function soundtrackOf(e: Entry): Soundtrack | null {
if (e.kind !== 'film' || !e.interests.includes('music')) return null;
const m = e.media.find((x) => x.url);
if (!m?.url) return null;
const found = embedFor(m.url);
const embed = found?.provider === 'spotify' ? { ...found, height: 152 } : found;
const art = images(e)[1];
return { url: m.url, title: m.alt, ...(art ? { art } : {}), embed };
}
/* ---------- labels and formatting ---------- */
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export const shortDate = (iso: string) => `${Number(iso.slice(8, 10))} ${MONTHS[Number(iso.slice(5, 7)) - 1]}`;
@@ -132,7 +154,9 @@ const SPAN: Record<ItemKind, Record<Entry['size'], number>> = {
book: { s: 3, m: 4, l: 5 }, art: { s: 3, m: 4, l: 6 }, essay: { s: 3, m: 3, l: 4 }, life: { s: 3, m: 4, l: 5 },
music: { s: 4, m: 5, l: 6 },
};
export const spanOf = (e: Entry) => SPAN[e.kind][e.size];
// A comic shared with only its cover stands it up whole in a narrow card (spec 002, mockup H).
export const isCoverComic = (e: Entry) => e.kind === 'comic' && images(e).length === 1;
export const spanOf = (e: Entry) => (isCoverComic(e) ? 3 : SPAN[e.kind][e.size]);
// The wall holds the newest years until it has at least LEAD_MIN entries; older years fold away.
export const LEAD_MIN = 6;
+4 -1
View File
@@ -23,7 +23,10 @@ if (entries.length > 0 && sources.trakt.recent.length) {
}
const counts = new Map<string, number>();
for (const x of [...entries, ...live]) if (x.interest) counts.set(x.interest, (counts.get(x.interest) ?? 0) + 1);
// A card counts once under each interest it carries, matching what the chips filter on.
const tally = (id: string) => counts.set(id, (counts.get(id) ?? 0) + 1);
for (const e of entries) e.interests.forEach(tally);
for (const l of live) if (l.interest) tally(l.interest);
const chips: Chip[] = interests.filter((i) => counts.has(i.id)).map((i) => ({ ...i, count: counts.get(i.id)! }));
const leadYears = [...new Set(lead.map((e) => e.date.slice(0, 4)))];
+21 -1
View File
@@ -7,6 +7,7 @@
}
.wall[data-filtered] > .card { --fspan: 4; }
.wall[data-few] > .card { --fspan: 6; }
.wall[data-filtered] > .card.cover-comic, .wall[data-few] > .card.cover-comic { --fspan: 3; } /* a single cover stays narrow */
.card[data-off] { display: none; }
@media (hover: hover) {
.card:not(.open):hover { transform: translateY(-3px); box-shadow: 0 14px 30px -12px rgba(42,26,31,.3); border-color: var(--line2); }
@@ -58,6 +59,9 @@
/* comic */
.panels { display: grid; gap: 6px; padding: 10px; background: #f6efe3; height: 210px; }
.card[style*='--span:6'] .panels { height: 240px; }
/* one image: the cover, whole, at 2:3 in the ink frame */
.panels.cover { display: block; height: auto; padding: 12px; }
.panels.cover > div { aspect-ratio: 2 / 3; max-width: 280px; margin-inline: auto; }
.panels > div { border: 2px solid #3a3f4a; overflow: hidden; background: #fff; }
.panels img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; }
.panels.n2 { grid-template-columns: 1.2fr 1fr; }
@@ -120,6 +124,10 @@ html:not(.js) .sp .mini, html:not(.js) .sp .bigpp { display: none; }
background: repeating-linear-gradient(135deg, #3a252c 0 10px, #33212a 10px 20px);
}
.video > span:last-child { position: absolute; left: 12px; bottom: 10px; right: 12px; }
.video.photo { background: #2b1a1f; overflow: hidden; }
.video.photo .cover { position: absolute; inset: 0; object-position: center 30%; }
.video.photo::after { content: ""; position: absolute; inset: auto 0 0 0; height: 45%; background: linear-gradient(transparent, rgba(43,26,31,.85)); pointer-events: none; }
.video.photo > span:last-child { z-index: 1; }
.video .vp { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; background: var(--acc); color: var(--plum); font-size: 16px; }
.card.music .embed-go .bigpp, .card.music .sp-art .bigpp { left: 50%; top: 50%; bottom: auto; transform: translate(-50%, -50%); width: 56px; height: 56px; }
.card.music .embed.on { margin: 0; border-radius: 0; }
@@ -143,6 +151,17 @@ html:not(.js) .sp .mini, html:not(.js) .sp .bigpp { display: none; }
/* film + book */
.film-grid { display: grid; grid-template-columns: 40% 1fr; }
.film-grid .po { aspect-ratio: 2 / 3; overflow: hidden; }
/* a film's soundtrack: a dark tray across the card's foot (spec 002, mockup C) */
.tray { display: flex; align-items: center; gap: 12px; padding: 10px 64px 10px 14px; background: var(--plum); color: #f1e6d6; text-decoration: none; position: relative; }
.tray-art { flex: 0 0 auto; width: 46px; height: 46px; border-radius: 2px; object-fit: cover; box-shadow: 0 4px 10px rgba(0,0,0,.4); }
.tray-t { min-width: 0; }
.tray-t b { display: block; font: 500 15px/1.25 var(--serif); overflow-wrap: anywhere; }
.tray-t small { display: block; font: 10.5px var(--mono); color: #b8a592; margin-top: 2px; }
.tray-go { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: var(--acc); color: var(--plum); font-size: 13px; }
.tray-host .embed-note { display: none; }
.tray-host .embed-go .bigpp { left: auto; right: 14px; top: 50%; bottom: auto; width: 34px; height: 34px; font-size: 11px; transform: translateY(-50%); }
@media (hover: hover) { .tray-host .embed-go:hover .bigpp { transform: translateY(-50%) scale(1.07); } }
.tray-host .embed.on { margin: 0; border-radius: 0; }
.book-grid { display: grid; grid-template-columns: 36% 1fr; align-items: start; }
.bk-cv { position: relative; margin: 16px 0 16px 16px; aspect-ratio: 2 / 3; border-radius: 2px 4px 4px 2px; overflow: hidden; box-shadow: 0 8px 16px -6px rgba(42,26,31,.4); }
.bk-cv::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; z-index: 1; background: rgba(0,0,0,.18); }
@@ -156,7 +175,8 @@ html:not(.js) .sp .mini, html:not(.js) .sp .bigpp { display: none; }
.card.art .im .cover.blank { aspect-ratio: 4 / 3; }
.card.art .teaser .pad { padding: 12px 2px 4px; }
.card.art .teaser .ttl { font-size: 19px; }
.card.art .by { font: 11px var(--mono); color: var(--mute); margin-top: 4px; }
.card .by { font: 11px var(--mono); color: var(--mute); margin-top: 4px; }
.card.dark .by { color: #b8a592; }
.card.art.open { background: var(--card); border: 1px solid var(--line2); }
.art-full { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 32px; padding: 18px; }
+1 -1
View File
@@ -9,7 +9,7 @@ const cli = (...args: string[]) => Bun.spawnSync(['bun', 'src/cli.ts', ...args],
it('validate passes on the fixture content', () => {
const r = cli('validate', 'fixtures/content');
expect(r.exitCode).toBe(0);
expect(r.stdout.toString()).toContain('Content OK (5 projects, 11 items)');
expect(r.stdout.toString()).toContain('Content OK (5 projects, 14 items)');
});
it('validate lists every error and exits 1', () => {
+34 -2
View File
@@ -14,7 +14,7 @@ describe('loadContent', () => {
const { content, errors } = await loadContent(`${FIXTURES}/content`);
expect(errors).toEqual([]);
expect(content!.projects).toHaveLength(5);
expect(content!.items).toHaveLength(11);
expect(content!.items).toHaveLength(14);
expect(content!.interests.map((i) => i.id)).toEqual([
'comics', 'games', 'music', 'film-tv', 'books', 'made-by-me', 'writing', 'life',
]);
@@ -32,8 +32,40 @@ describe('loadContent', () => {
expect(content!.items).toHaveLength(0);
});
test('rejects unsupported contract', async () => {
test('accepts contract 2', async () => {
const { content, errors } = await load({ 'site.yml': baseFiles['site.yml']!.replace('contract: 1', 'contract: 2') });
expect(errors).toEqual([]);
expect(content!.site.contract).toBe(2);
});
test('accepts an interest list, primary first', async () => {
const { content, errors } = await load({ 'items/a.md': item({ interest: ['comics', 'writing'] }) });
expect(errors).toEqual([]);
expect(content!.items[0]!.interests).toEqual(['comics', 'writing']);
expect(content!.items[0]!.interest).toBe('comics');
});
test('a single interest becomes a one-element list', async () => {
const { content } = await load({ 'items/a.md': item() });
expect(content!.items[0]!.interests).toEqual(['writing']);
expect(content!.items[0]!.interest).toBe('writing');
});
test('reports bad interest lists', async () => {
const { content, errors } = await load({
'items/a.md': item({ interest: [] }),
'items/b.md': item({ interest: ['comics', 'comics'] }),
'items/c.md': item({ interest: ['comics', 'nowhere'] }),
});
expect(content).toBeNull();
const msg = (f: string) => errors.filter((e) => e.file === f && e.field === 'interest').map((e) => e.message).join(' | ');
expect(msg('items/a.md')).toContain('must not be empty');
expect(msg('items/b.md')).toContain('repeats "comics"');
expect(msg('items/c.md')).toContain('unknown interest "nowhere"');
});
test('rejects unsupported contract', async () => {
const { content, errors } = await load({ 'site.yml': baseFiles['site.yml']!.replace('contract: 1', 'contract: 3') });
expect(content).toBeNull();
expect(errors).toContainEqual(expect.objectContaining({ file: 'site.yml', field: 'contract' }));
});
+68 -2
View File
@@ -168,8 +168,21 @@ describe('personal space', () => {
}
const ITEM_IDS = [
'fixture-comic', 'example-song', 'sample-game', 'kickstarted-game', 'demo-film', 'fixture-book', 'own-drawing',
'commissioned-art', 'edge-long-title', 'fixture-life', 'example-set',
'commissioned-art', 'edge-long-title', 'fixture-life', 'example-set', 'fixture-soundtrack-film', 'fixture-cover-comic', 'fixture-band-photo',
];
// A whole wall card (opening tag to its closing tag) by entry id.
function card(html: string, id: string): string {
const m = html.match(new RegExp(`<article\\b[^>]*\\bdata-id="${id}"[\\s\\S]*?</article>`));
if (!m) throw new Error(`no card ${id}`);
return m[0];
}
// The counts the server renders into the chips island's props, keyed by interest id.
function chipCounts(html: string): Record<string, number> {
const props = html.match(/component-url="[^"]*Chips[^"]*"[^>]*props="([^"]*)"/)![1]!.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
const out: Record<string, number> = {};
for (const m of props.matchAll(/"id":\[0,"([^"]+)"\],"name":\[0,"[^"]*"\],"count":\[0,(\d+)\]/g)) out[m[1]!] = Number(m[2]);
return out;
}
const POST_IDS = ['synthetic-essay', 'synthetic-life-note'];
test('wall renders one card per personal entry with its kind class', async () => {
@@ -179,12 +192,13 @@ describe('personal space', () => {
expect([...cards.keys()].sort()).toEqual([...ITEM_IDS, ...POST_IDS].sort());
for (const it of content!.items) {
expect(cards.get(it.id)).toMatch(new RegExp(`class="card\\b[^"]*\\b${it.kind}\\b`));
expect(cards.get(it.id)).toContain(`data-interest="${it.interest}"`);
expect(cards.get(it.id)).toContain(`data-interests="${it.interests.join(' ')}"`);
expect(existsSync(join(out, 'off-the-clock', it.id, 'index.html'))).toBe(true);
}
expect(cards.get('synthetic-essay')).toMatch(/class="card\b[^"]*\bessay\b/);
expect(cards.get('synthetic-life-note')).toMatch(/class="card\b[^"]*\blife\b/);
expect(html).not.toContain('draft-item');
expect(html).not.toContain('data-interest=');
// Ghost posts get their own page too, with the post body made safe.
const post = page(out, 'off-the-clock/synthetic-essay/index.html');
expect(post).toContain('<p>Body of an essay.</p>');
@@ -192,6 +206,58 @@ describe('personal space', () => {
expect(page(out, 'off-the-clock/fixture-comic/index.html')).toMatch(/<h1[^>]*>Fixture Comic<\/h1>/);
});
test('a two-interest item counts under both chips and names both in its meta', () => {
const html = page(out, 'off-the-clock/index.html');
const counts = chipCounts(html);
const cards = [...html.matchAll(/<article\b[^>]*\bdata-wall-card\b[^>]*>/g)].map((m) => m[0]); // live cards have no data-id
const under = (id: string) => cards.filter((t) => new RegExp(`data-interests="[^"]*\\b${id}\\b`).test(t)).length;
expect(counts['music']).toBe(under('music'));
expect(counts['film-tv']).toBe(under('film-tv'));
expect(cardTags(html).get('fixture-soundtrack-film')).toContain('data-interests="film-tv music"');
expect(card(html, 'fixture-soundtrack-film')).toMatch(/<span>film &amp; tv<\/span><span aria-hidden="true">·<\/span><span>music<\/span>/);
expect(card(html, 'demo-film')).not.toContain('<span>music</span>');
});
test('a comic with one image shows the whole cover at span 3, with its credit', () => {
const html = page(out, 'off-the-clock/index.html');
expect(cardTags(html).get('fixture-cover-comic')).toContain('style="--span:3"');
// Filtered walls widen cards (--fspan); a single cover stays narrow there too.
expect(cardTags(html).get('fixture-cover-comic')).toMatch(/class="card comic cover-comic\b/);
const css = readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => readFileSync(join(out, '_astro', f), 'utf8')).join('');
expect(css).toMatch(/\.wall\[data-filtered\]>\.card\.cover-comic[^{]*\{--fspan:3\}/);
const c = card(html, 'fixture-cover-comic');
expect(c).toContain('class="panels cover"');
expect(c).toContain('by Fixture Artist');
expect(card(html, 'fixture-comic')).toContain('class="panels n2"');
expect(page(out, 'off-the-clock/fixture-cover-comic/index.html')).toContain('by Fixture Artist');
});
test('a film that is also music gets a soundtrack tray that loads nothing until pressed', () => {
const html = page(out, 'off-the-clock/index.html');
const c = card(html, 'fixture-soundtrack-film');
expect(c).toContain('class="tray"');
expect(c).toContain('Fixture Soundtrack');
// The art repeats the title, so it is decorative: the play link's name is the title alone.
expect(c).toMatch(/<img class="tray-art" src="\/media\/fixture-3.svg" alt=""/);
const props = c.replace(/&quot;/g, '"');
expect(props).toContain('https://open.spotify.com/embed/album/fixture0000');
expect(props).toContain('"height":[0,152]');
expect(c).not.toContain('<iframe');
expect(card(html, 'demo-film')).not.toContain('tray');
expect(page(out, 'off-the-clock/fixture-soundtrack-film/index.html')).toContain('class="tray"');
});
test('a music item with a photo shows it in place of the stripes', () => {
const html = page(out, 'off-the-clock/index.html');
const c = card(html, 'fixture-band-photo');
expect(c).toContain('class="video photo"');
expect(c).toContain('src="/media/fixture-5.svg"');
expect(c).toContain('href="https://example.invalid/band"');
expect(c).toContain('example.invalid ↗');
expect(c).toMatch(/<span>music<\/span><span aria-hidden="true">·<\/span><span>made by me<\/span>/);
expect(page(out, 'off-the-clock/example-set/index.html')).not.toContain('video photo');
});
test('shares carry no data-ai', async () => {
const { content } = await loadContent('fixtures/content');
const cards = cardTags(page(out, 'off-the-clock/index.html'));
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test';
import { soundtrackOf, type Entry } from '../../src/site/components/personal/entries.ts';
const film = (over: Partial<Entry> = {}): Entry => ({
id: 'f', title: 'F', date: '2025-01-01', kind: 'film', interest: 'film-tv', interests: ['film-tv', 'music'], also: ['music'],
size: 'm', media: [], kickstarter: false, ownWork: false, ai: null, bodyHtml: '', excerpt: '', ...over,
});
describe('soundtrackOf', () => {
test('a Spotify album on a film that is also music is a compact click-to-load player', () => {
const t = soundtrackOf(film({ media: [
{ file: 'poster.jpg', alt: 'Poster' }, { file: 'ost.jpg', alt: 'Album' },
{ url: 'https://open.spotify.com/album/abc123', alt: 'The soundtrack' },
] }));
expect(t).toEqual({
url: 'https://open.spotify.com/album/abc123', title: 'The soundtrack', art: { file: 'ost.jpg', alt: 'Album' },
embed: { provider: 'spotify', src: 'https://open.spotify.com/embed/album/abc123', height: 152 },
});
});
test('an unknown host is a plain link, and art is optional', () => {
const t = soundtrackOf(film({ media: [{ url: 'https://example.invalid/ost', alt: 'OST' }] }));
expect(t).toEqual({ url: 'https://example.invalid/ost', title: 'OST', embed: null });
});
test('no tray without a url, or without music, or on another kind', () => {
expect(soundtrackOf(film({ media: [{ file: 'poster.jpg', alt: 'Poster' }] }))).toBeNull();
expect(soundtrackOf(film({ interests: ['film-tv'], media: [{ url: 'https://example.invalid/x', alt: 'X' }] }))).toBeNull();
expect(soundtrackOf(film({ kind: 'game', media: [{ url: 'https://example.invalid/x', alt: 'X' }] }))).toBeNull();
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { coverline, pageRef, spreads } from '../../src/site/components/issues/is
import type { Entry } from '../../src/site/components/personal/entries.ts';
const e = (id: string, size: Entry['size']): Entry => ({
id, title: id, date: '2026-08-01', kind: 'essay', interest: 'writing', size, media: [],
id, title: id, date: '2026-08-01', kind: 'essay', interest: 'writing', interests: ['writing'], also: [], size, media: [],
kickstarter: false, ownWork: true, ai: null, bodyHtml: '', excerpt: '',
});
const rows = (ws: number[]) => ws.reduce((n, w) => n + w, 0);