Merge branch 'feat/dual-interests-and-content': spec 002, items in more than one interest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
cc1782f7b8
35 files changed
+542
-61
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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,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[];
|
||||
|
||||
@@ -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)}`}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)))];
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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(/"/g, '"').replace(/&/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 & 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(/"/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'));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user