feat(site): filter the wall by every interest an item has

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:24:36 +01:00
1 parent 5feaea30a6
commit 6da355dc39
15 files changed
+93 -31

No files matched your search

+10 -10
View File
@@ -48,15 +48,15 @@ The three card changes are pure Astro/CSS on top of existing islands (`EmbedOnCl
**Interfaces:**
- Produces: `Item.interests: string[]` (non-empty after a valid load), `Item.interest === Item.interests[0]`.
- [ ] **Step 1: Failing tests** in `describe('loadContent')`:
- [x] **Step 1: Failing tests** in `describe('loadContent')`:
- `'accepts an interest list, primary first'`: item `{ interest: ['comics', 'writing'] }` → `items[0].interests` equals `['comics','writing']`, `interest` is `'comics'`.
- `'a single interest becomes a one-element list'`: default item → `interests` equals `['writing']`.
- `'reports bad interest lists'`: three items, `interest: []`, `['comics','comics']`, `['comics','nowhere']` → each yields an error with field `interest`; messages contain `must not be empty`, `repeats "comics"`, `unknown interest "nowhere"`.
- Change `'rejects unsupported contract'` to use `contract: 3`; add `'accepts contract 2'` (no errors, `site.contract === 2`).
- [ ] **Step 2:** `bun test test/library/load.test.ts` → the new tests FAIL.
- [ ] **Step 3: Implement.** `interestList(v: unknown, report: Report): string[]` in `load.ts`: string → `[v]`; array of non-empty strings → itself (report `must not be empty` / `repeats "<id>"`); anything else → `required`'s message. The unknown-id check in `loadContent` loops over `i.interests`.
- [ ] **Step 4:** `bun test` → all PASS. `bun run typecheck` clean.
- [ ] **Step 5:** Docs as listed, then commit `feat(library): items take one interest or a list; accept contract 2`.
- [x] **Step 2:** `bun test test/library/load.test.ts` → the new tests FAIL.
- [x] **Step 3: Implement.** `interestList(v: unknown, report: Report): string[]` in `load.ts`: string → `[v]`; array of non-empty strings → itself (report `must not be empty` / `repeats "<id>"`); anything else → `required`'s message. The unknown-id check in `loadContent` loops over `i.interests`.
- [x] **Step 4:** `bun test` → all PASS. `bun run typecheck` clean.
- [x] **Step 5:** Docs as listed, then commit `feat(library): items take one interest or a list; accept contract 2`.
### Task 2: One `data-interests` attribute, chips filter by membership, meta names the extra interests
@@ -71,11 +71,11 @@ The three card changes are pure Astro/CSS on top of existing islands (`EmbedOnCl
- Consumes: `Item.interests` (Task 1).
- Produces: `Entry.interests: string[]`, `Entry.also: string[]` (lower-case names of `interests.slice(1)`, minus any equal to `kindLabel(entry)`).
- [ ] **Step 1: Failing tests.** In build.test `'wall renders one card per personal entry…'`: expect `data-interests="${it.interests.join(' ')}"` and `expect(html).not.toContain('data-interest=')`. New test `'a two-interest item counts under both chips'`: the chips island props in `off-the-clock/index.html` give film-tv and music counts that include `fixture-soundtrack-film`, and the card's meta contains `film &amp; tv` then `music`. e2e `'chips filter the wall by interest'`: read `dataset.interests`; every shown card's list includes `music`; then `film & tv` also shows `fixture-soundtrack-film`; "everything" count equals the number of cards.
- [ ] **Step 2:** `bun test test/site/build.test.ts` → FAIL.
- [ ] **Step 3: Implement** as in Interfaces; `toEntries(personal, interests)` derives `also` from the `interests` names. Posts: `interests: [interest]`, `also: []`. Live cards: `data-interests={interest}` when set.
- [ ] **Step 4:** `bun test` PASS; `bun run typecheck` clean.
- [ ] **Step 5:** Commit `feat(site): filter the wall by every interest an item has`.
- [x] **Step 1: Failing tests.** In build.test `'wall renders one card per personal entry…'`: expect `data-interests="${it.interests.join(' ')}"` and `expect(html).not.toContain('data-interest=')`. New test `'a two-interest item counts under both chips'`: the chips island props in `off-the-clock/index.html` give film-tv and music counts that include `fixture-soundtrack-film`, and the card's meta contains `film &amp; tv` then `music`. e2e `'chips filter the wall by interest'`: read `dataset.interests`; every shown card's list includes `music`; then `film & tv` also shows `fixture-soundtrack-film`; "everything" count equals the number of cards.
- [x] **Step 2:** `bun test test/site/build.test.ts` → FAIL.
- [x] **Step 3: Implement** as in Interfaces; `toEntries(personal, interests)` derives `also` from the `interests` names. Posts: `interests: [interest]`, `also: []`. Live cards: `data-interests={interest}` when set.
- [x] **Step 4:** `bun test` PASS; `bun run typecheck` clean.
- [x] **Step 5:** Commit `feat(site): filter the wall by every interest an item has`.
### Task 3: Credit line on comics and songs; a single-cover comic shows the whole cover (mockup H)
+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 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.
+1 -1
View File
@@ -23,7 +23,7 @@ const ai = mode === 'wall' && entry.ai !== null ? entry.ai : undefined;
<article
class:list={['card', entry.kind, 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)));
@@ -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">
@@ -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 -3
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,
};
+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)))];
+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, 12 items)');
});
it('validate lists every error and exits 1', () => {
+1 -1
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(12);
expect(content!.interests.map((i) => i.id)).toEqual([
'comics', 'games', 'music', 'film-tv', 'books', 'made-by-me', 'writing', 'life',
]);
+28 -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',
];
// 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,18 @@ 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('shares carry no data-ai', async () => {
const { content } = await loadContent('fixtures/content');
const cards = cardTags(page(out, 'off-the-clock/index.html'));
+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);