From 57b9b15ebba87050f58caf16bdfd69937b37c3f6 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Fri, 30 Oct 2026 05:29:40 +0100 Subject: [PATCH 1/2] feat(web): show each game's cover on the Games page The games service has sent each game's IGDB cover since #186 (decision 306). The Games page now shows it in a column between the day and the title, as the background of a framed blank, so a game without a cover, a cover still loading and one that fails to load all leave the same blank, and screen readers skip it (decision 308). --- apps/web/e2e/demo.ts | 33 ------------ apps/web/e2e/games-covers.spec.ts | 73 ++++++++++++++++++++++++++ apps/web/e2e/games.spec.ts | 12 ++--- apps/web/e2e/games.ts | 76 ++++++++++++++++++++++++++++ apps/web/e2e/ink.ts | 15 ++++-- apps/web/src/games/GamesPage.svelte | 41 ++++++++++++++- apps/web/src/games/GamesPage.test.ts | 13 +++++ apps/web/src/games/games.test.ts | 13 +++++ apps/web/src/games/games.ts | 7 +++ docs/plan.md | 14 ++++- 10 files changed, 246 insertions(+), 51 deletions(-) create mode 100644 apps/web/e2e/games-covers.spec.ts create mode 100644 apps/web/e2e/games.ts diff --git a/apps/web/e2e/demo.ts b/apps/web/e2e/demo.ts index 75b4d58..21d769c 100644 --- a/apps/web/e2e/demo.ts +++ b/apps/web/e2e/demo.ts @@ -1,4 +1,3 @@ -import type { Snapshot as RecentGames } from '../src/games/games'; import type { Home } from '../src/home/api'; import type { Snapshot } from '../src/jira/tickets'; import type { State } from '../src/nudge/nudge'; @@ -173,35 +172,3 @@ export const report: Report = { week: { loggedHours: 15.75, targetHours: 40 }, searched: '2026-10-21T00:38:00Z', }; - -/** - * The games a made-up user played in the last 14 days, the last played first, as the games service found them ten - * minutes before the tests' time: one game today, one yesterday on a Nintendo console, which has no trophies, one with - * every trophy, and one with no playtime. - */ -export const games: RecentGames = { - games: [ - { - title: 'Moon Garden', - platforms: ['PS5'], - lastPlayed: '2026-10-21T00:05:00Z', - playtimeMinutes: 754, - trophies: { earned: 31, total: 52, percent: 59.6 }, - }, - { title: 'Tower Climb', platforms: ['Switch'], lastPlayed: '2026-10-20T12:30:00Z', playtimeMinutes: 1290 }, - { - title: 'Harbour Lights', - platforms: ['PS4', 'PS5'], - lastPlayed: '2026-10-17T10:00:00Z', - playtimeMinutes: 2475, - trophies: { earned: 40, total: 40, percent: 100 }, - }, - { - title: 'Sky Rally', - platforms: ['PC'], - lastPlayed: '2026-10-12T09:00:00Z', - trophies: { earned: 3, total: 35, percent: 8.57 }, - }, - ], - searched: '2026-10-21T00:31:00Z', -}; diff --git a/apps/web/e2e/games-covers.spec.ts b/apps/web/e2e/games-covers.spec.ts new file mode 100644 index 0000000..9640246 --- /dev/null +++ b/apps/web/e2e/games-covers.spec.ts @@ -0,0 +1,73 @@ +import type { Page } from '@playwright/test'; + +import { bounds } from './bounds'; +import { home } from './demo'; +import { expect, test } from './fixtures'; +import { answerCovers, answerGames, games } from './games'; +import { group } from './groups'; +import { colour } from './ink'; + +const played = (page: Page) => group(page, 'Recently played'); + +test.beforeEach(async ({ page }) => { + await page.route('/api/home', (route) => route.fulfill({ json: home })); + await answerCovers(page); + await answerGames(page, games); +}); + +test("each game's cover loads from IGDB into a framed blank, which a game without one keeps", async ({ page }) => { + const loaded: string[] = []; + page.on('requestfinished', (request) => { + if (request.url().startsWith('https://images.igdb.com/')) { + loaded.push(request.url()); + } + }); + await page.goto('/games'); + + const covers = played(page).locator('.cover'); + await expect(covers).toHaveCount(4); + // The gateway's Content Security Policy lets them in: fixtures.ts fails the test on a violation. + const wanted = games.games.flatMap((game) => game.cover ?? []); + await expect.poll(() => loaded.toSorted()).toEqual(wanted.toSorted()); + const [fill, rule] = [await colour(page, '--fill'), await colour(page, '--rule')]; + const looks = await covers.evaluateAll((all) => + all.map((cover) => { + const { backgroundColor, backgroundImage, backgroundPosition, backgroundSize, borderTopColor, borderTopWidth } = + getComputedStyle(cover); + return { backgroundColor, backgroundImage, backgroundPosition, backgroundSize, borderTopColor, borderTopWidth }; + }) + ); + expect(looks).toEqual( + games.games.map((game) => ({ + backgroundColor: fill, + backgroundImage: game.cover ? `url("${game.cover}")` : 'none', + backgroundPosition: '50% 50%', + backgroundSize: 'cover', + borderTopColor: rule, + borderTopWidth: '1px', + })) + ); +}); + +test('the cover sits between the day and the title, or under the day on a phone', async ({ page }, testInfo) => { + await page.goto('/games'); + + const rows = played(page).locator('.list-row'); + await expect(rows).toHaveCount(4); + for (const row of await rows.all()) { + const [box, label, cover, text] = await Promise.all( + [row, row.locator('.list-label'), row.locator('.cover'), row.locator('.list-text')].map(bounds) + ); + expect([cover.width, cover.height]).toEqual([48, 68]); + expect(text.x - (cover.x + cover.width)).toBe(20); + if (testInfo.project.name === 'phone') { + // The day shares the first line with the playtime; the cover starts the second. + expect(cover.x - box.x).toBe(20); + expect(cover.y - (label.y + label.height)).toBeCloseTo(6, 1); + } else { + // After the row's 20 px of padding, the label's 120 px column and the 20 px gap. + expect(cover.x - box.x).toBe(160); + expect(cover.y + cover.height / 2).toBeCloseTo(box.y + box.height / 2, 1); + } + } +}); diff --git a/apps/web/e2e/games.spec.ts b/apps/web/e2e/games.spec.ts index 10cf92f..31e0bab 100644 --- a/apps/web/e2e/games.spec.ts +++ b/apps/web/e2e/games.spec.ts @@ -1,24 +1,18 @@ import type { Page } from '@playwright/test'; -import type { Snapshot } from '../src/games/games'; import { bounds } from './bounds'; import { chipLooks } from './chips'; -import { games, home } from './demo'; +import { home } from './demo'; import { expect, test } from './fixtures'; +import { answerCovers, answerGames, games } from './games'; import { group } from './groups'; -/** Answers the Games page's call for the games with the snapshot, or with the status. */ -async function answerGames(page: Page, answer: Snapshot | number) { - await page.route('/api/games/recent', (route) => - typeof answer === 'number' ? route.fulfill({ status: answer }) : route.fulfill({ json: answer }) - ); -} - const played = (page: Page) => group(page, 'Recently played'); const failure = 'list games: Get "https://www.exophase.com/public/player/1/games": 403 Forbidden'; test.beforeEach(async ({ page }) => { await page.route('/api/home', (route) => route.fulfill({ json: home })); + await answerCovers(page); }); test('the Games page lists the games I played in the last 14 days', async ({ page }, testInfo) => { diff --git a/apps/web/e2e/games.ts b/apps/web/e2e/games.ts new file mode 100644 index 0000000..d688c4d --- /dev/null +++ b/apps/web/e2e/games.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test'; + +import type { Snapshot } from '../src/games/games'; + +// Where IGDB serves the covers at the size that the games service gives (decision 305). +const igdb = 'https://images.igdb.com/igdb/image/upload/t_cover_big'; + +/** A made-up cover at IGDB's size, a disc on a ground, since no game's real art can be in the repo. */ +function art(ground: string, disc: string): string { + return [ + '', + ``, + '', + ].join(''); +} + +const arts = new Map([ + [`${igdb}/moon.jpg`, art('#22305a', '#f2d16b')], + [`${igdb}/tower.jpg`, art('#7d2a1e', '#f4b183')], + [`${igdb}/harbour.jpg`, art('#14504f', '#a5dbd0')], +]); + +/** + * The games a made-up user played in the last 14 days, the last played first, as the games service found them ten + * minutes before the tests' time: one game today, one yesterday on a Nintendo console, which has no trophies, one with + * every trophy, and one with no playtime and no cover. + */ +export const games: Snapshot = { + games: [ + { + title: 'Moon Garden', + platforms: ['PS5'], + lastPlayed: '2026-10-21T00:05:00Z', + playtimeMinutes: 754, + trophies: { earned: 31, total: 52, percent: 59.6 }, + cover: `${igdb}/moon.jpg`, + }, + { + title: 'Tower Climb', + platforms: ['Switch'], + lastPlayed: '2026-10-20T12:30:00Z', + playtimeMinutes: 1290, + cover: `${igdb}/tower.jpg`, + }, + { + title: 'Harbour Lights', + platforms: ['PS4', 'PS5'], + lastPlayed: '2026-10-17T10:00:00Z', + playtimeMinutes: 2475, + trophies: { earned: 40, total: 40, percent: 100 }, + cover: `${igdb}/harbour.jpg`, + }, + { + title: 'Sky Rally', + platforms: ['PC'], + lastPlayed: '2026-10-12T09:00:00Z', + trophies: { earned: 3, total: 35, percent: 8.57 }, + }, + ], + searched: '2026-10-21T00:31:00Z', +}; + +/** Answers the Games page's call for the games with the snapshot, or with the status. */ +export async function answerGames(page: Page, answer: Snapshot | number) { + await page.route('/api/games/recent', (route) => + typeof answer === 'number' ? route.fulfill({ status: answer }) : route.fulfill({ json: answer }) + ); +} + +/** Answers the browser's requests for the demo games' covers on IGDB with their made-up art, and any other with a 404. */ +export async function answerCovers(page: Page) { + await page.route(`${igdb}/*`, (route) => { + const body = arts.get(route.request().url()); + return body ? route.fulfill({ contentType: 'image/svg+xml', body }) : route.fulfill({ status: 404 }); + }); +} diff --git a/apps/web/e2e/ink.ts b/apps/web/e2e/ink.ts index 788b5c8..644252e 100644 --- a/apps/web/e2e/ink.ts +++ b/apps/web/e2e/ink.ts @@ -1,12 +1,17 @@ import type { Page } from '@playwright/test'; -// The colour --ink-muted gives text in the page's theme. -export function inkMuted(page: Page) { - return page.evaluate(() => { +/** The colour that the token, such as --fill, gives text in the page's theme. */ +export function colour(page: Page, token: string) { + return page.evaluate((name) => { const probe = document.body.appendChild(document.createElement('span')); - probe.style.color = 'var(--ink-muted)'; + probe.style.color = `var(${name})`; const { color } = getComputedStyle(probe); probe.remove(); return color; - }); + }, token); +} + +// The colour --ink-muted gives text in the page's theme. +export function inkMuted(page: Page) { + return colour(page, '--ink-muted'); } diff --git a/apps/web/src/games/GamesPage.svelte b/apps/web/src/games/GamesPage.svelte index 7f48a8f..0fe324e 100644 --- a/apps/web/src/games/GamesPage.svelte +++ b/apps/web/src/games/GamesPage.svelte @@ -3,7 +3,16 @@ import { onMount } from 'svelte'; import PageHead from '../shell/PageHead.svelte'; import PolledSection from '../shell/PolledSection.svelte'; -import { foundGames, type Games, loadGames, platformsLabel, playedLabel, playtimeLabel, trophiesLabel } from './games'; +import { + coverImage, + foundGames, + type Games, + loadGames, + platformsLabel, + playedLabel, + playtimeLabel, + trophiesLabel, +} from './games'; // The games I played lately (decision 307), loaded each time the page shows; now tells which day is today. let { now }: { now: Date } = $props(); @@ -32,8 +41,10 @@ onMount(() => { markStale > {#snippet row(game)} -
+
+ + {game.title} {platformsLabel(game.platforms)} @@ -50,3 +61,29 @@ onMount(() => { {/snippet} {/if} + + diff --git a/apps/web/src/games/GamesPage.test.ts b/apps/web/src/games/GamesPage.test.ts index ba1fa5f..50f73bc 100644 --- a/apps/web/src/games/GamesPage.test.ts +++ b/apps/web/src/games/GamesPage.test.ts @@ -17,6 +17,7 @@ function answer(response: () => Promise) { // Saturday 31 October 2026, 09:41 in Tokyo, where the tests run. const now = new Date('2026-10-31T09:41:00+09:00'); const searched = '2026-10-31T00:30:00Z'; +const cover = 'https://images.igdb.com/igdb/image/upload/t_cover_big/co1abc.jpg'; const recent: Snapshot = { games: [ { @@ -25,6 +26,7 @@ const recent: Snapshot = { lastPlayed: '2026-10-31T00:20:00Z', playtimeMinutes: 750, trophies: { earned: 12, total: 40, percent: 30 }, + cover, }, { title: 'Tower Climb', platforms: ['Switch'], lastPlayed: '2026-10-30T11:00:00Z', playtimeMinutes: 45 }, { title: 'Harbour Lights', platforms: ['PC'], lastPlayed: '2026-10-19T08:00:00Z' }, @@ -53,6 +55,17 @@ test('lists the games I played lately as it shows, a row each, none a link', asy expect(fetchMock).toHaveBeenCalledExactlyOnceWith('/api/games/recent'); }); +test("shows each game's cover as its blank's background, which screen readers skip", async () => { + answer(loaded(recent)); + render(GamesPage, { props: { now } }); + + await screen.findByRole('list'); + const blanks = rows().map((row) => row.querySelector('.cover')); + expect(blanks.map((blank) => blank?.style.backgroundImage)).toEqual([`url("${cover}")`, '', '']); + expect(blanks.every((blank) => blank?.childNodes.length === 0)).toBe(true); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); +}); + test('marks the games stale when the latest fetch failed, and says when they are from', async () => { answer(loaded({ ...recent, failure: 'list games: 403 Forbidden' })); render(GamesPage, { props: { now } }); diff --git a/apps/web/src/games/games.test.ts b/apps/web/src/games/games.test.ts index e48100e..1ae1ccd 100644 --- a/apps/web/src/games/games.test.ts +++ b/apps/web/src/games/games.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { + coverImage, foundGames, loadGames, platformsLabel, @@ -41,6 +42,18 @@ test.each<{ name: string; snapshot: Snapshot; want?: Snapshot['games'] }>([ expect(foundGames(snapshot)).toEqual(want); }); +test.each([ + { cover: undefined, want: undefined }, + { + cover: 'https://images.igdb.com/igdb/image/upload/t_cover_big/co1abc.jpg', + want: 'url("https://images.igdb.com/igdb/image/upload/t_cover_big/co1abc.jpg")', + }, + // A quote or a backslash would end or bend the CSS string, so they're escaped. + { cover: 'https://example.com/a"b\\c.jpg', want: 'url("https://example.com/a\\"b\\\\c.jpg")' }, +])('draws the cover $cover as the background image $want', ({ cover, want }) => { + expect(coverImage(cover)).toBe(want); +}); + // The tests run in Tokyo, 9 hours ahead of UTC, where 31 October begins at 2026-10-30T15:00:00Z. test.each([ { lastPlayed: '2026-10-30T15:00:00Z', want: 'Today' }, diff --git a/apps/web/src/games/games.ts b/apps/web/src/games/games.ts index 9d4e387..4abab06 100644 --- a/apps/web/src/games/games.ts +++ b/apps/web/src/games/games.ts @@ -20,6 +20,8 @@ export interface Game { playtimeMinutes?: number; /** None for a game without trophies, such as Nintendo's. */ trophies?: Trophies; + /** The address of its cover on IGDB, at most 264 by 374 px; none when IGDB has none or isn't set up (decision 306). */ + cover?: string; } /** The body of GET /api/games/recent: what the games service last found on Exophase (decision 303). */ @@ -41,6 +43,11 @@ export function foundGames(snapshot: Snapshot): Game[] | undefined { return snapshot.searched === undefined ? undefined : snapshot.games; } +/** A game's cover as a CSS background image, `url("…")`, or none when the game has none. */ +export function coverImage(cover: string | undefined): string | undefined { + return cover === undefined ? undefined : `url(${JSON.stringify(cover)})`; +} + const dayMs = 86_400_000; const dateFormat = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short' }); diff --git a/docs/plan.md b/docs/plan.md index 3478cdf..71523b7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -200,6 +200,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `igdb-search` | The `games` service's IGDB client finds a title's cover: the top result of a search filtered to main games, expansions, remakes, remasters, expanded games and ports, or a game named exactly the title when the top result has another name; it asks one request at a time, at least 250 ms apart, and with a new token once when IGDB refuses one. The sixth of `games-exophase`'s eleven steps (decision 305) | Done (#183) | | `games-covers` | Each game in the `games` service's answer carries its IGDB cover when `BAKHYARD_TWITCH_CLIENT_ID` and the Keychain's `twitch-client-secret` are set: each title's cover, or that IGDB has none, is kept a week, the first failure stops the asking until the next fetch, and a missing cover never fails the list. The seventh of `games-exophase`'s eleven steps (decision 306) | Done (#186) | | `games-page` | The Games page lists the games I played in the last 14 days from `GET /api/games/recent`, each time it shows: for each, the day I last played it, its title, its platforms, its trophies and its playtime. A list kept from an earlier fetch, after the latest failed, gets a Stale chip after its count, and the page says that Exophase isn't set up until the gateway forwards to `games`. The Games card says "Recent games and stats from Exophase". The tenth of `games-exophase`'s eleven steps (decision 307) | Done (#190) | +| `games-page-covers` | The Games page shows each game's cover from IGDB, 48 by 68 px, between the day and the title, and on phones at the start of the row's second line. A game without a cover, a cover still loading and one that fails to load all leave the same blank, framed as a cover is, and screen readers skip the covers. The eleventh of `games-exophase`'s eleven steps (decision 308) | In progress | | `vault-edit-sync` | Editing the vault from Bakhyard (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one and then does `copilot-planner` (decision 195) | In progress | | `vault-plan` | The vault session's first steps: where a save goes waits on the user's answer to §12's question on how edits get back to the Mac, so the version that a save checks and the editor come first; a note's version is the SHA-256 of its bytes, sent as its `ETag`, and a save is a `PUT` that sends it back in `If-Match` (decision 400) | Done (#167) | | `note-etag` | notes answers each note with its version as a strong `ETag`, the SHA-256 of its bytes, and a matching `If-None-Match` with a 304 (decision 400) | Done (#169) | @@ -1008,7 +1009,7 @@ Cards are solid `--surface`, so they sit on the grain like paper on a desk. Thes - My open Jira tickets have a section of their own after the priority groups, or after "Couldn't load the todos.", once the todos show or fail to load (built in `jira-list`, decision 124): "Jira *tickets*", with the count as the note, "1 ticket" or "3 tickets". Its card holds a list row per ticket, in the service's order, that links to the ticket in Jira: the key as the row's mono label, the summary as its title, and the status at its end in a chip, filled for the In Progress category and outlined for the others, at most 160 px wide and wrapping inside. With no ticket, the card says "No open tickets." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These tickets are from 21 October at 09:38." when it has tickets from an earlier search. Any other failure to load: "Couldn't load the tickets." under the head. Without Jira, no section. - Pull requests, built in `pulls-page` (decision 149): "Pull requests", with no summary. Under the head, my open PRs sit in a section, "Your *PRs*", with the count as the note, "1 PR" or "3 PRs". Its card holds a list row per PR, in the service's order, the latest updated first, that links to the PR on Bitbucket: the number as the row's mono label ("#128"), the title as its title, the repository's full name ("acme/web") under it as its detail, and, on a draft, a Draft chip at its end, outlined as the Jira section's chips that aren't In Progress. Under the repository, built in `approvals-page` (decision 174), a second detail line says where the PR stands with its reviewers, a group for each verdict that someone has given, the most urgent first, split by " · ": "Changes requested by Noor Aziz · Waiting for Tom Berg and Leo Hart · Approved by Mia Chen"; with no reviewers, "No reviewers". A PR without a link shows in a row that isn't a link. With no PR, the card says "No open pull requests." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These PRs are from 21 October at 09:38." when it has PRs from an earlier search. Any other failure to load: "Couldn't load the pull requests." under the section's head. Under it, built in `reviews-page` (decision 172), the PRs that wait for my review sit in a section of their own, "Review *requests*", 64 px below my PRs' card (48 px on a phone), with the same rows, without the review line, and the same note and failed-search lines. With none, its card says "No pull requests to review."; any other failure to load: "Couldn't load the review requests." under its head. Each section shows once its own list loads, so neither waits for the other, and nothing shows for a list while it loads. Without Bitbucket, when both lists answer 404, "Bitbucket isn't set up." in `--ink-muted` under the page head, with no section; when only one does, the page leaves its section out. - Time, built in `time-page` (decision 184) and `fill-day` (decision 194): "Time", with no summary. Under the head, a form fills a day (below). Under it, this week's hours sit in a section, "This *week*", on the card that the Home's "This week" shows (below), without its link. Under it, 64 px below the card (48 px on a phone), the last 7 days sit in a section, "Last 7 *days*", with the missing days' count as the note, "1 day missing" or "3 days missing", left out when none is. Its card holds a list row per day, in the report's order, the earliest first, and none is a link: the date as the row's mono label ("15 Oct"), the weekday as its title ("Thursday"), or "Today" for the last day, and at its end the hours logged ("7h 30m", "0h"), in the end's mono capitals, in `--ink` when any are logged and `--ink-muted` when none are. A weekday before today with no hours has a Missing chip before them, filled as a late item's chip. Nothing shows under the head while the report loads, which it does each time the page shows. When the last search failed, a muted line under the head says "The last search failed: .", then "These hours are from 21 October at 09:38." when it has days from an earlier search, 30 px above the form or the first section (24 px on a phone), as the Todos page's form; with no days yet, the line alone. Without Jira, "Jira isn't set up." in `--ink-muted` under the head, with no section; any other failure to load: "Couldn't load the time report.". The form shows once the report's days and my open tickets have loaded, 30 px above the first section (24 px on a phone): three selects in the ticket select's look, with our chevron, Day ("Friday 16 Oct", or "Today"), Ticket ("Pick a ticket", then "WEB-9 · Fix the date picker in Safari") and Hours ("8h"), then a Log button, in a row that wraps with its lines 12 px apart, and the status line under it ("Logging…", "Logged", the service's reason, "Log failed"). The day select is as wide as its longest day, the ticket select takes most of the room to spare, and the hours and Log wrap together: one line from about 720 px wide, Day and Ticket over Hours and Log down to about 500 px, a line each for Day and Ticket below that; the page never scrolls sideways, down to 320 px wide. Log opens a modal dialog in the delete dialog's look: "Log these hours?", then "8h on WEB-9 · Fix the date picker in Safari, from 09:00 on Friday 16 Oct.", with Cancel and the primary Log. In the form's place, in `--ink-muted`: "Couldn't load your Jira tickets." or "No open Jira tickets to log hours on.". -- Games, built in `games-page` (decision 307): "Games", with no summary. Under the head, the games I played in the last 14 days sit in a section, "Recently *played*", with the count as the note, "1 game" or "3 games". Its card holds a list row per game, in the service's order, the last played first, and none is a link: the day I last played it as the row's mono label, in the device's time zone ("Today", "Yesterday", "19 Oct"), the title as its title, the platforms under it as its detail ("PS4 · PS5"), then, when the game has trophies, a second detail line ("31 of 52 trophies · 59%", "1 of 1 trophy · 100%"), with the percentage rounded down, so only all of them make 100%, and at its end the playtime ("12h 34m", "45m", "3h") in the end's mono capitals, left out when Exophase doesn't know it. Trophies are "trophies" on every platform, since the service doesn't say which call them achievements. With no game, the card says "No games played in the last 14 days." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These games are from 21 October at 09:38." when it has games from an earlier search, and a Stale chip, outlined, follows the count in the section's head (decision 67); before any search has succeeded, the line alone, with no card, count or chip. Nothing shows under the head while the games load, which they do each time the page shows. Without Exophase, when the games answer 404, "Exophase isn't set up." in `--ink-muted` under the page head, with no section; any other failure to load: "Couldn't load the games." under the section's head. A long title, platform or failure wraps, and the page never scrolls sideways, down to 320 px wide. +- Games, built in `games-page` (decision 307) and `games-page-covers` (decision 308): "Games", with no summary. Under the head, the games I played in the last 14 days sit in a section, "Recently *played*", with the count as the note, "1 game" or "3 games". Its card holds a list row per game, in the service's order, the last played first, and none is a link: the day I last played it as the row's mono label, in the device's time zone ("Today", "Yesterday", "19 Oct"), then the game's cover from IGDB, 48 by 68 px in a 1 px `--rule` frame, cropped to fill it, or a `--fill` blank when the game has none, while the cover loads and when it fails to load, then the title as its title, the platforms under it as its detail ("PS4 · PS5"), then, when the game has trophies, a second detail line ("31 of 52 trophies · 59%", "1 of 1 trophy · 100%"), with the percentage rounded down, so only all of them make 100%, and at its end the playtime ("12h 34m", "45m", "3h") in the end's mono capitals, left out when Exophase doesn't know it. The cover makes every row as high as one with trophies; on a phone it starts the row's second line, under the day, beside the title. Screen readers skip the cover, since the title names the game. Trophies are "trophies" on every platform, since the service doesn't say which call them achievements. With no game, the card says "No games played in the last 14 days." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These games are from 21 October at 09:38." when it has games from an earlier search, and a Stale chip, outlined, follows the count in the section's head (decision 67); before any search has succeeded, the line alone, with no card, count or chip. Nothing shows under the head while the games load, which they do each time the page shows. Without Exophase, when the games answer 404, "Exophase isn't set up." in `--ink-muted` under the page head, with no section; any other failure to load: "Couldn't load the games." under the section's head. A long title, platform or failure wraps, and the page never scrolls sideways, down to 320 px wide. - More: "More", with the summary "The apps without a tab of their own.", then the cards of Time, GitHub, Games and Planner as in "Your apps", with their numbers (04 to 07) and counts. The cards' names are `h2`s, under the page's `h1`. The grid ends 64 px above the page's end (48 on phones), as a section does. - Any other path: "Page not found", with "There's no page at /elsewhere." as the summary. @@ -1294,7 +1295,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `homelab-games`: in the homelab repo, `games` runs on the laptop with its settings and secret, and the gateway's peers name it. - `games-gateway`: the gateway forwards `/api/games` to `games`. - `games-page`: the Games page lists the games, and marks them stale when the latest fetch failed (decisions 67, 307); the Games card's description in the app becomes §7's. Done (#190). - - `games-page-covers`: the Games page shows each game's cover. + - `games-page-covers`: the Games page shows each game's cover (decision 308). **Phase 6: vault edits and Copilot** @@ -1849,6 +1850,15 @@ Decisions 300 to 399 are the `games-exophase` session's. - **Before the first success**: the service answers `games: []` with the failure until a fetch succeeds (decision 303), so the page counts the games only once `searched` is set, and shows the failure line alone, with no card, count or chip, as the Time page does with no days yet. - **Rejected**: a Stale chip on each row, which says one thing as many times as there are games; the failure line alone, short of decision 67's badge; the platform as the row's label, which can be two or three platforms long and would widen the label column for every row; the percentage rounded to the nearest, which shows 100% with a trophy to go; the Time page's `formatHours`, which writes 45 minutes as "0h 45m", as a time sheet does, and would tie the games to the Time page's code; the Stale chip on every polled list, which changes pages that this step doesn't own. +308. **The games' covers** (`games-page-covers`): the lead's choices (2026-10-30), for the user to review. + - **The size**: 48 by 68 px, IGDB's `cover_big`, at most 264 by 374 (decision 305), scaled so that inside its 1 px frame the picture is 66 px high, as a title and two detail lines are. Every row is then 88 px high on a desktop, where one with trophies was 86 and one without 65. At 2x the cover still has more pixels than the screen shows. + - **The place**: a column of its own between the label and the text, 20 px from each, as the list rows space theirs. On a phone, where the label and the end share the row's first line, the cover starts the second, beside the text. A cover that IGDB made shorter than 374 px is cropped to fill the frame, centred. + - **The blank**: the cover is the background of a blank, `--fill` in a `--rule` frame, so a game without a cover, a cover still loading and one that fails to load all look the same, and the page needs no state for them. An `` shows a broken-image icon when its load fails, even with an empty `alt`, in Chromium and in WebKit. + - **Screen readers**: a CSS background is decoration (WCAG technique C9), so they skip the cover, and each row reads as it did, since the title names the game. + - **IGDB**: the browser loads the covers from IGDB's image host, which the gateway's Content Security Policy allows under `img-src https:`; the gateway's `Referrer-Policy: same-origin` keeps Bakhyard's address from IGDB. + - **The demo**: the end-to-end tests and the screenshots show covers drawn as SVG, since IGDB's are third-party material and the repo is public. With the demo games, they live in `e2e/games.ts`, since `e2e/demo.ts` had reached its 200 lines. + - **Rejected**: the cover before the day, where every other list starts its rows with the label; a bigger cover, such as 64 by 91, which would make every row taller than its text; leaving the column out when no game has a cover, as when IGDB isn't set up, which would change the rows' shape with the service's settings; greyscale covers, since §7's monochrome is for Bakhyard's own colours; an ``, with its broken-image icon, or with an error handler that swaps in the blank, which adds state for what a background does alone; the title as the cover's `alt`, which a screen reader would read twice; IGDB's `cover_small`, 90 by 128, which is blurry at 2x and would have the page rewrite the service's address; `loading="lazy"`, which a background doesn't have and a list of 14 days doesn't need. + Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-planner`. 400. **Editing notes, the first steps** (`vault-plan`, `note-etag`, `note-editor`): the lead's choices (2026-10-30), for the user to review. From fde51b57683f885286a111263ca225199f8171de Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Fri, 30 Oct 2026 05:30:17 +0100 Subject: [PATCH 2/2] docs: mark games-page-covers done (#193) --- docs/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index 71523b7..050640e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -200,7 +200,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `igdb-search` | The `games` service's IGDB client finds a title's cover: the top result of a search filtered to main games, expansions, remakes, remasters, expanded games and ports, or a game named exactly the title when the top result has another name; it asks one request at a time, at least 250 ms apart, and with a new token once when IGDB refuses one. The sixth of `games-exophase`'s eleven steps (decision 305) | Done (#183) | | `games-covers` | Each game in the `games` service's answer carries its IGDB cover when `BAKHYARD_TWITCH_CLIENT_ID` and the Keychain's `twitch-client-secret` are set: each title's cover, or that IGDB has none, is kept a week, the first failure stops the asking until the next fetch, and a missing cover never fails the list. The seventh of `games-exophase`'s eleven steps (decision 306) | Done (#186) | | `games-page` | The Games page lists the games I played in the last 14 days from `GET /api/games/recent`, each time it shows: for each, the day I last played it, its title, its platforms, its trophies and its playtime. A list kept from an earlier fetch, after the latest failed, gets a Stale chip after its count, and the page says that Exophase isn't set up until the gateway forwards to `games`. The Games card says "Recent games and stats from Exophase". The tenth of `games-exophase`'s eleven steps (decision 307) | Done (#190) | -| `games-page-covers` | The Games page shows each game's cover from IGDB, 48 by 68 px, between the day and the title, and on phones at the start of the row's second line. A game without a cover, a cover still loading and one that fails to load all leave the same blank, framed as a cover is, and screen readers skip the covers. The eleventh of `games-exophase`'s eleven steps (decision 308) | In progress | +| `games-page-covers` | The Games page shows each game's cover from IGDB, 48 by 68 px, between the day and the title, and on phones at the start of the row's second line. A game without a cover, a cover still loading and one that fails to load all leave the same blank, framed as a cover is, and screen readers skip the covers. The eleventh of `games-exophase`'s eleven steps (decision 308) | Done (#193) | | `vault-edit-sync` | Editing the vault from Bakhyard (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one and then does `copilot-planner` (decision 195) | In progress | | `vault-plan` | The vault session's first steps: where a save goes waits on the user's answer to §12's question on how edits get back to the Mac, so the version that a save checks and the editor come first; a note's version is the SHA-256 of its bytes, sent as its `ETag`, and a save is a `PUT` that sends it back in `If-Match` (decision 400) | Done (#167) | | `note-etag` | notes answers each note with its version as a strong `ETag`, the SHA-256 of its bytes, and a matching `If-None-Match` with a 304 (decision 400) | Done (#169) | @@ -1295,7 +1295,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `homelab-games`: in the homelab repo, `games` runs on the laptop with its settings and secret, and the gateway's peers name it. - `games-gateway`: the gateway forwards `/api/games` to `games`. - `games-page`: the Games page lists the games, and marks them stale when the latest fetch failed (decisions 67, 307); the Games card's description in the app becomes §7's. Done (#190). - - `games-page-covers`: the Games page shows each game's cover (decision 308). + - `games-page-covers`: the Games page shows each game's cover (decision 308). Done (#193). **Phase 6: vault edits and Copilot**