From f329e0ca6e9c19cf980bff9b7640cc40c5138671 Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Sat, 22 Aug 2026 23:12:40 +0200 Subject: [PATCH 1/8] feat: self-host Fraunces, Public Sans and Plex Mono, and repaint the frontend in buckram green --- frontend/design/tokens.test.ts | 73 +++++++++++++ frontend/package-lock.json | 30 ++++++ frontend/package.json | 3 + frontend/src/main.tsx | 14 +++ frontend/src/styles.css | 183 ++++++++++++++++++++++++--------- frontend/tsconfig.node.json | 2 +- frontend/vitest.config.ts | 4 +- 7 files changed, 258 insertions(+), 51 deletions(-) create mode 100644 frontend/design/tokens.test.ts diff --git a/frontend/design/tokens.test.ts b/frontend/design/tokens.test.ts new file mode 100644 index 0000000..0e95519 --- /dev/null +++ b/frontend/design/tokens.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/* + * This lives outside src/ on purpose. It reads the stylesheet off disk, which makes it Node code, + * and src/ is compiled by tsconfig.app.json with the node types deliberately withheld so that a + * stray `process` in browser code is a compile error. Importing the file as `?raw` from inside src/ + * does not work either: Vitest stubs CSS out and hands back an empty string. + * + * Resolved from the working directory rather than import.meta.url, which Vitest serves over http + * and fileURLToPath will not take. + */ +const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8') + +/** + * The identity is a contract, not a suggestion. + * + * Every component in the app is written once against token *names*, so a half-applied palette does + * not break anything loudly - it just quietly renders the old product in a few places. These + * assertions are the thing that notices. See docs/superpowers/specs/2026-08-22-direction-c-spine.md. + */ +const lower = styles.toLowerCase() + +describe('the direction C token layer', () => { + it('declares the five named colours of the palette', () => { + const palette = { + stacks: '#18241e', + desk: '#f4f5f2', + 'shelf light': '#e8e6de', + interactive: '#1d4235', + overdue: '#b4322a', + } + + for (const [role, hex] of Object.entries(palette)) { + expect(lower, `${role} (${hex}) is missing`).toContain(hex) + } + }) + + it('names the stacks and the desk as scopes, so no component is written twice', () => { + expect(styles).toMatch(/--stacks:/) + // `.stacks` opens a rule of its own or shares one - the navigation is always in the stacks. + expect(styles).toMatch(/^\.stacks[\s,{]/m) + }) + + it('declares three type roles and no fourth', () => { + expect(styles).toContain("--font-display: 'Fraunces Variable'") + expect(styles).toContain("--font-body: 'Public Sans Variable'") + expect(styles).toContain("--font-mono: 'IBM Plex Mono'") + }) + + it('pins the display axes the spec calls for', () => { + expect(styles).toContain("--display-axes: 'opsz' 120, 'wght' 500, 'SOFT' 40, 'WONK' 1") + }) + + it('turns off faux bold, which a variable face does not need', () => { + expect(styles).toMatch(/font-synthesis:\s*none/) + }) + + it('keeps no trace of the blue-and-grey palette it replaced', () => { + for (const retired of ['#2f5fd0', '#7aa2f7', '#2850b4', '#93b4ff']) { + expect(lower, `${retired} is still here`).not.toContain(retired) + } + }) + + it('still answers to a dark colour scheme', () => { + expect(styles).toMatch(/@media \(prefers-color-scheme: dark\)/) + }) + + it('offers the screen-reader-only utility the shelf is built on', () => { + expect(styles).toMatch(/^\.sr-only\s*\{/m) + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c0df3ce..faeaf7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fontsource-variable/fraunces": "^5.3.0", + "@fontsource-variable/public-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.2" @@ -343,6 +346,33 @@ } } }, + "node_modules/@fontsource-variable/fraunces": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.3.0.tgz", + "integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/public-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/public-sans/-/public-sans-5.3.0.tgz", + "integrity": "sha512-AVfkmAt50BMXWpOO21FAntiJFKGX6xTc2dSL8dxtDteONe9IuRXJWGbs0EbG955vAMCq23ENeuopuW87cGWDSQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c21fc4..e8b5041 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,9 @@ "vitest": "^4.1.10" }, "dependencies": { + "@fontsource-variable/fraunces": "^5.3.0", + "@fontsource-variable/public-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.2" diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3043bfd..58c8b2c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,20 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' + +/* + * The three faces of direction C, self-hosted and bundled by Vite: the app owes nothing to a font + * CDN, works offline, and works from the GitHub Pages subdirectory. + * + * Fraunces needs `full.css` specifically - the SOFT and WONK axes the display setting turns up are + * not in the package's default build. Plex Mono is not a variable face, so its three weights are + * asked for by name. + */ +import '@fontsource-variable/fraunces/full.css' +import '@fontsource-variable/public-sans' +import '@fontsource/ibm-plex-mono/400.css' +import '@fontsource/ibm-plex-mono/500.css' +import '@fontsource/ibm-plex-mono/600.css' import './styles.css' const container = document.getElementById('root') diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3a61561..d0e9519 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,58 +1,112 @@ /* --------------------------------------------------------------------------- - Design tokens. Light is the default; dark is a token swap so every component - below is written once. The server-rendered pages in + Direction C - Spine. "The stacks and the desk." + + Two zones, one product. The *desk* is the light working surface: tables, + forms, modals, settings. The *stacks* is the dark buckram-green zone: the + navigation everywhere, and the shelf on /books. + + Both are expressed in the same token names, so every component below is + written once and comes out right in either zone. Light is the default and + dark is a token swap; `.stacks` is a scope, not a theme. + + The design, and the reasoning behind it: + ../../docs/superpowers/specs/2026-08-22-direction-c-spine.md + + The server-rendered pages in ../../Library-Management-System-Version-2/src/main/resources/static/css/pages.css - use the same palette so the two halves of the app look like one product. + carry the same palette, so the two halves of the app look like one product. --------------------------------------------------------------------------- */ :root { - --bg: #f5f6f8; - --surface: #ffffff; - --surface-2: #f0f2f5; - --border: #e3e6ea; - --text: #171a1f; - --muted: #667085; - --accent: #2f5fd0; - --accent-hover: #2850b4; - --accent-soft: #e8eefc; - --accent-contrast: #ffffff; - --ok-bg: #e6f5ec; - --ok-text: #16794c; - --warn-bg: #fdf1dc; - --warn-text: #9a6100; - --danger-bg: #fdecea; - --danger-text: #b4291f; - --ring: rgba(47, 95, 208, 0.35); - --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05); - --shadow-md: 0 8px 24px rgba(16, 24, 40, 0.09); - --glow: rgba(47, 95, 208, 0.13); + /* The desk. Paper, not white: #ffffff next to buckram reads as a hole. */ + --bg: #e9ece6; + --surface: #f4f5f2; + --surface-2: #e2e6de; + --border: #d5d9cf; + --text: #191c1a; + --muted: #5c665f; + --accent: #1d4235; + --accent-hover: #16342a; + --accent-soft: #dde7e0; + --accent-contrast: #f4f5f2; + --ok-bg: #dde7e0; + --ok-text: #1d4235; + --warn-bg: #f2e8d8; + --warn-text: #7a5c3e; + --danger-bg: #f8e2e0; + --danger-text: #b4322a; + --ring: rgba(29, 66, 53, 0.42); + --shadow-sm: 0 1px 2px rgba(24, 36, 30, 0.07); + --shadow-md: 0 10px 28px rgba(24, 36, 30, 0.13); + --glow: rgba(29, 66, 53, 0.1); + + /* The stacks. Fixed points: these do not swap with the colour scheme, because + the dark zone is a chosen material rather than an absence of light. */ + --stacks: #18241e; + --stacks-2: #1e2b24; + --stacks-line: rgba(232, 230, 222, 0.14); + --shelf-light: #e8e6de; + + /* The only saturated colour in the product. Spend it on one thing. */ + --overdue: #b4322a; + + /* Type - three roles, no fourth. Display carries the voice, body carries the + prose, mono carries anything a machine wrote: ISBNs, dates, counts, stamps. */ + --font-display: 'Fraunces Variable', Georgia, 'Times New Roman', serif; + --font-body: 'Public Sans Variable', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + --display-axes: 'opsz' 120, 'wght' 500, 'SOFT' 40, 'WONK' 1; } @media (prefers-color-scheme: dark) { :root { - --bg: #101216; - --surface: #191c22; - --surface-2: #21252d; - --border: #2b303a; - --text: #e9ebef; - --muted: #9aa2ae; - --accent: #7aa2f7; - --accent-hover: #93b4ff; - --accent-soft: #1d2941; - --accent-contrast: #101216; - --ok-bg: #16301f; - --ok-text: #6ed08d; - --warn-bg: #33270f; - --warn-text: #e3b341; - --danger-bg: #341b1a; - --danger-text: #f08a80; - --ring: rgba(122, 162, 247, 0.4); - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); - --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45); - --glow: rgba(122, 162, 247, 0.1); + /* The desk after hours - still the desk, still distinct from the stacks. */ + --bg: #101613; + --surface: #171f1b; + --surface-2: #1e2a24; + --border: #2a352f; + --text: #e8e6de; + --muted: #98a49c; + --accent: #8cc0a5; + --accent-hover: #a5d3ba; + --accent-soft: #1c2f27; + --accent-contrast: #101613; + --ok-bg: #1a2f25; + --ok-text: #8cc0a5; + --warn-bg: #2e2618; + --warn-text: #d3ac7a; + --danger-bg: #33191a; + --danger-text: #e8867d; + --ring: rgba(140, 192, 165, 0.45); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45); + --shadow-md: 0 10px 28px rgba(0, 0, 0, 0.5); + --glow: rgba(140, 192, 165, 0.12); } } +/* + * The stacks, as a scope. Anything rendered inside - a button, the search box, + * the empty state - reads the same token names and comes out dark, so none of + * those components needs a second, darker copy of itself. + */ +.stacks { + --surface: #1e2b24; + --surface-2: #24322a; + --border: rgba(232, 230, 222, 0.14); + --text: #e8e6de; + --muted: rgba(232, 230, 222, 0.6); + --accent: #a9cdb9; + --accent-hover: #c2ddcf; + --accent-soft: rgba(232, 230, 222, 0.1); + --accent-contrast: #18241e; + --ring: rgba(232, 230, 222, 0.55); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45); + --shadow-md: 0 12px 30px rgba(0, 0, 0, 0.55); + + background: var(--stacks); + color: var(--text); +} + * { box-sizing: border-box; } @@ -61,14 +115,20 @@ body { margin: 0; background: var(--bg); color: var(--text); - font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 400 15px/1.55 var(--font-body); + /* Three variable faces, every weight real. Nothing here needs faking. */ + font-synthesis: none; -webkit-font-smoothing: antialiased; } h1, h2, h3 { - letter-spacing: -0.015em; + font-family: var(--font-display); + font-variation-settings: var(--display-axes); + font-weight: 500; + letter-spacing: -0.012em; + line-height: 1.05; } a { @@ -80,6 +140,23 @@ a { outline-offset: 2px; } +/* + * Read aloud, never shown. The shelf leans on this: a spine's visible text is + * its title turned ninety degrees, and everything else a screen reader needs - + * the author, whether the book is out, when it is due - rides along here. + */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + /* --------------------------------- shell --------------------------------- */ .app { @@ -112,7 +189,13 @@ a { display: flex; align-items: center; gap: 0.55rem; - font-weight: 650; + /* The wordmark is the display face at reading size, so it takes the optical + size meant for it rather than the headline setting shrunk down. */ + font-family: var(--font-display); + font-variation-settings: 'opsz' 24, 'wght' 600, 'SOFT' 30, 'WONK' 1; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: -0.005em; } .brand-mark { @@ -638,7 +721,7 @@ select:focus-visible { } .mono { - font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace; + font-family: var(--font-mono); font-size: 0.86em; } @@ -1469,8 +1552,10 @@ button.user-chip[aria-expanded='true'] { /* Tabular figures so the four tiles do not jitter as the numbers gain digits. */ .stat-value { - font-size: 1.75rem; - font-weight: 650; + /* A figure is something a machine counted, so it is set in the data face. */ + font-family: var(--font-mono); + font-size: 1.6rem; + font-weight: 600; line-height: 1.1; font-variant-numeric: tabular-nums; } diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index bc17c40..e4993eb 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -21,5 +21,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e"] + "include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e", "design"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 5f1e356..ce549b8 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - include: ['src/**/*.test.{ts,tsx}'], + // design/ holds the stylesheet contract test, which reads styles.css off disk and so runs as + // Node code rather than browser code. See design/tokens.test.ts. + include: ['src/**/*.test.{ts,tsx}', 'design/**/*.test.ts'], }, }) From e7fb8f50a7af69a8a558afa01adbf59121bdf291 Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Sat, 22 Aug 2026 23:12:40 +0200 Subject: [PATCH 2/8] feat: carry the same palette into the server-rendered login and home pages --- .../src/main/resources/static/css/pages.css | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/Library-Management-System-Version-2/src/main/resources/static/css/pages.css b/Library-Management-System-Version-2/src/main/resources/static/css/pages.css index 0bc71f2..d452d9a 100644 --- a/Library-Management-System-Version-2/src/main/resources/static/css/pages.css +++ b/Library-Management-System-Version-2/src/main/resources/static/css/pages.css @@ -2,32 +2,32 @@ React client's palette in ../../../../frontend/src/styles.css. */ :root { - --bg: #f6f7f9; - --surface: #ffffff; - --border: #e2e5ea; - --text: #1c2024; - --muted: #6b7280; - --accent: #2f5fd0; - --accent-text: #ffffff; - --ok-bg: #e7f5ec; - --ok-text: #1a7f42; - --error-bg: #fdeceb; - --error-text: #b4291f; + --bg: #e9ece6; + --surface: #f4f5f2; + --border: #d5d9cf; + --text: #191c1a; + --muted: #5c665f; + --accent: #1d4235; + --accent-text: #f4f5f2; + --ok-bg: #dde7e0; + --ok-text: #1d4235; + --error-bg: #f8e2e0; + --error-text: #b4322a; } @media (prefers-color-scheme: dark) { :root { - --bg: #14161a; - --surface: #1c1f24; - --border: #2c313a; - --text: #e8eaed; - --muted: #9aa2ae; - --accent: #7aa2f7; - --accent-text: #14161a; - --ok-bg: #16301f; - --ok-text: #6ed08d; - --error-bg: #351b1a; - --error-text: #f08a80; + --bg: #101613; + --surface: #171f1b; + --border: #2a352f; + --text: #e8e6de; + --muted: #98a49c; + --accent: #8cc0a5; + --accent-text: #101613; + --ok-bg: #1a2f25; + --ok-text: #8cc0a5; + --error-bg: #33191a; + --error-text: #e8867d; } } From f7219c85277de3cd2234fa20e1e406af01d97b0c Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Sat, 22 Aug 2026 23:12:56 +0200 Subject: [PATCH 3/8] feat: bind each book to one of eight cloths and a spine height hashed from its ISBN --- frontend/src/lib/cloth.test.ts | 80 +++++++++++++++++++++++++++++++++ frontend/src/lib/cloth.ts | Bin 0 -> 2051 bytes 2 files changed, 80 insertions(+) create mode 100644 frontend/src/lib/cloth.test.ts create mode 100644 frontend/src/lib/cloth.ts diff --git a/frontend/src/lib/cloth.test.ts b/frontend/src/lib/cloth.test.ts new file mode 100644 index 0000000..e2efdcc --- /dev/null +++ b/frontend/src/lib/cloth.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { CLOTHS, SPINE_MAX_HEIGHT, SPINE_MIN_HEIGHT, clothFor, spineHeight } from './cloth' + +/** Enough distinct seeds to say something about the spread rather than about one book. */ +const seeds = Array.from({ length: 400 }, (_, index) => `97814${String(index).padStart(6, '0')}`) + +describe('binding cloth', () => { + it('offers exactly the eight cloths the design specifies', () => { + expect(CLOTHS).toHaveLength(8) + expect(new Set(CLOTHS).size).toBe(8) + for (const cloth of CLOTHS) { + expect(cloth).toMatch(/^#[0-9a-f]{6}$/) + } + }) + + it('binds the same book the same way every time', () => { + expect(clothFor('9780441478125')).toBe(clothFor('9780441478125')) + expect(spineHeight('9780441478125')).toBe(spineHeight('9780441478125')) + }) + + it('only ever uses a cloth from the palette', () => { + for (const seed of seeds) { + expect(CLOTHS).toContain(clothFor(seed)) + } + }) + + it('uses all eight cloths rather than favouring a few', () => { + const used = new Set(seeds.map(clothFor)) + expect(used.size).toBe(8) + }) + + it('keeps every spine inside the height band', () => { + for (const seed of seeds) { + const height = spineHeight(seed) + expect(height).toBeGreaterThanOrEqual(SPINE_MIN_HEIGHT) + expect(height).toBeLessThanOrEqual(SPINE_MAX_HEIGHT) + expect(Number.isInteger(height)).toBe(true) + } + }) + + it('reaches both ends of the band, so the shelf is not flat', () => { + const heights = seeds.map(spineHeight) + expect(Math.min(...heights)).toBe(SPINE_MIN_HEIGHT) + expect(Math.max(...heights)).toBe(SPINE_MAX_HEIGHT) + }) + + /** + * The point of drawing height separately: if it were derived from the same number as the colour, + * every book in one cloth would be exactly as tall as every other, and the shelf would visibly + * repeat in eight-book blocks. + */ + it('draws height independently of colour', () => { + for (const cloth of CLOTHS) { + const heights = new Set(seeds.filter((seed) => clothFor(seed) === cloth).map(spineHeight)) + expect(heights.size).toBeGreaterThan(1) + } + }) + + it('survives a book with no ISBN to hash', () => { + expect(CLOTHS).toContain(clothFor('')) + expect(spineHeight('')).toBeGreaterThanOrEqual(SPINE_MIN_HEIGHT) + }) + + /** + * The catalogue holds ISBNs both hyphenated and bare - the ISBN lookup writes one form, a typed + * entry the other. They are the same book, so they must be the same binding. + */ + it('binds one book one way however its ISBN is punctuated', () => { + expect(clothFor('978-0-441-47812-5')).toBe(clothFor('9780441478125')) + expect(spineHeight('978-0-441-47812-5')).toBe(spineHeight('9780441478125')) + expect(clothFor('978 0 441 47812 5')).toBe(clothFor('9780441478125')) + }) + + /** A book with no ISBN is seeded from its id instead, which is a UUID and mostly letters. */ + it('keeps letters in the seed, so an id works where an ISBN is missing', () => { + const id = '9f8c1b2a-7d3e-4c5f-a6b7-c8d9e0f1a2b3' + expect(CLOTHS).toContain(clothFor(id)) + expect(clothFor(id)).not.toBe(clothFor('9f8c1b2a')) + }) +}) diff --git a/frontend/src/lib/cloth.ts b/frontend/src/lib/cloth.ts new file mode 100644 index 0000000000000000000000000000000000000000..aed7da71b00999793e305e5fbc4b374e142f026d GIT binary patch literal 2051 zcmZuyYfs}w6z%8yiYv7h3B=Be@M=NYrEEdkrB#8d5@L79_9V8jXRMiVLb~F=_uLs9 z7N|so=Xvir=bk&G?d=|I)45)gphW9?DlGmtDvL4COJ^%-He~BUNz#g3F3IMyT#%A7 zvpALNQa6T#$}q@fvCQ4jk9cdQD^Vz_bS6vEi-j~62Ma8jnBHrba|mH1w%>iRbYa~EQBNMmQ z=AKefmOf>Lj0&|Rr^z~PWJkg^paf_nRSGab5w^yJHRKQ4aA7T$5`;9$5l}M$9(mwH zkV714S~82OE;k%ZaE7H;ODuT_*19P(N+kF!3rP_bxDK)uD#QLEho($85!02Fd^nCr z;V;vv9oeRqrq*k4J+igL%yc~6i^d1hbQ)!ak*O=Riqgo78dqNGQLi@&wZK@tid{fT zOJKDzeJm0qPz*_v-WySo5kP1FQz)d$p`2-|BgVafpeyNlMDj{LIR2Ipfb+$5I0vaT ziYinSOQIVG6`06f1WmY>4iu zqIaZ(F)&Vo(mz67=w_Ld$u}-X32QP^z!B^ak1)vA?s~DD=EAu64aBQIFD}o%e7d;& za(;I4`}sA%?oD5^&~AD6&-0dp-4{ZpD3ekz8SOg)}=^ge{}nK z936`2-@DPW7}D0(Aa?quhAB=3E*tb7gMc3{|BfbNNV92_6cFGF(aIrZvX&}?%{S4) z$d(gZvz!2wa}9<}EZljI^h1Z@9;3>Q*YODgceU5qtENg&{hWO%9j!zOb&hHLa4?yq zhgmwu!a^JB^C~PaA9(eb_Vd?(wUlb<@@{hHm?i^%BenN^J`Vf1wocKn-nqWNYJkhT zJ;(G329ArWDf>Q)A(9wR#^dqiaK;-S@oNbVS)QDn(3t6Kz4(OFcZd(#yv7=c`d+DE z+*?n~OMwRi`nmOW9N^vkUnt+S&V7S=9ql`2LziG@g1k32iMpw0bM4RsXh$R1+De?x zhMw?H3TU@Rte+nzZwfBTnEsND Date: Sat, 22 Aug 2026 23:12:57 +0200 Subject: [PATCH 4/8] feat: share one cover resolver between Discover and the shelf's face-out book --- frontend/src/components/BookCover.test.tsx | 95 ++++++++++++++++++++++ frontend/src/components/BookCover.tsx | 49 +++++++++++ frontend/src/pages/DiscoverPage.tsx | 41 ++-------- 3 files changed, 151 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/BookCover.test.tsx create mode 100644 frontend/src/components/BookCover.tsx diff --git a/frontend/src/components/BookCover.test.tsx b/frontend/src/components/BookCover.test.tsx new file mode 100644 index 0000000..e0ef6d3 --- /dev/null +++ b/frontend/src/components/BookCover.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import { BookCover } from './BookCover' + +/** The cover is decorative (`alt=""`), so it has no role to find it by. */ +function coverIn(container: HTMLElement): HTMLImageElement | null { + return container.querySelector('img') +} + +/** Open Library answers a missing cover with a 1x1 image, so a successful load is not proof. */ +function loadWithWidth(image: HTMLImageElement, naturalWidth: number) { + Object.defineProperty(image, 'naturalWidth', { value: naturalWidth, configurable: true }) + fireEvent.load(image) +} + +describe('BookCover', () => { + it('asks by cover id first, which resolves faster than an ISBN', () => { + const { container } = render() + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/id/12345-M.jpg', + ) + }) + + it('falls back to the ISBN when the cover id does not resolve', () => { + const { container } = render() + + fireEvent.error(coverIn(container)!) + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/isbn/9780441478125-M.jpg', + ) + }) + + it('strips punctuation out of the ISBN address', () => { + const { container } = render() + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/isbn/9780441478125-M.jpg', + ) + }) + + it("treats Open Library's 1x1 placeholder as a miss, not a hit", () => { + const { container } = render() + + loadWithWidth(coverIn(container)!, 1) + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/isbn/9780441478125-M.jpg', + ) + }) + + it('keeps a cover that actually arrived', () => { + const { container } = render() + + loadWithWidth(coverIn(container)!, 180) + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/id/12345-M.jpg', + ) + }) + + it('shows the fallback rather than a broken image once every source has failed', () => { + const { container } = render( + } + />, + ) + + fireEvent.error(coverIn(container)!) + fireEvent.error(coverIn(container)!) + + expect(coverIn(container)).toBeNull() + expect(container.querySelector('[data-testid="no-cover"]')).toBeInTheDocument() + }) + + it('renders nothing at all when there is no fallback to render', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('passes its class through, so each caller sizes its own cover', () => { + const { container } = render() + + expect(coverIn(container)).toHaveClass('discover-cover') + }) +}) diff --git a/frontend/src/components/BookCover.tsx b/frontend/src/components/BookCover.tsx new file mode 100644 index 0000000..155920d --- /dev/null +++ b/frontend/src/components/BookCover.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import type { ReactNode } from 'react' + +interface BookCoverProps { + isbn: string + /** The catalogue's own cover id, where it gave one: it resolves faster than an ISBN lookup. */ + coverId?: number | null + className?: string + /** + * What to render once no source has resolved. Left null on the shelf, where the cloth behind the + * cover is a perfectly good answer; Discover passes its placeholder so cards stay aligned. + */ + fallback?: ReactNode +} + +/** + * A cover, addressed by cover id where the catalogue gave one and by ISBN otherwise - the search + * endpoint often omits `cover_i` for editions that do have a cover. Falls back rather than leaving + * a broken image when neither resolves. + * + * Which source it is on is per-mount state, so callers must keep this under a key that changes with + * the book. Both of them already do: Discover keys its cards, and the shelf only ever mounts one of + * these, inside the selected spine. + */ +export function BookCover({ isbn, coverId = null, className, fallback = null }: BookCoverProps) { + const sources = [ + coverId ? `https://covers.openlibrary.org/b/id/${coverId}-M.jpg` : null, + isbn ? `https://covers.openlibrary.org/b/isbn/${isbn.replace(/[^0-9Xx]/g, '')}-M.jpg` : null, + ].filter((url): url is string => Boolean(url)) + + const [attempt, setAttempt] = useState(0) + + if (attempt >= sources.length) return <>{fallback} + + return ( + setAttempt((n) => n + 1)} + onLoad={(event) => { + if (event.currentTarget.naturalWidth <= 1) setAttempt((n) => n + 1) + }} + /> + ) +} diff --git a/frontend/src/pages/DiscoverPage.tsx b/frontend/src/pages/DiscoverPage.tsx index e57fad4..31738eb 100644 --- a/frontend/src/pages/DiscoverPage.tsx +++ b/frontend/src/pages/DiscoverPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import type { FormEvent } from 'react' import { booksApi } from '../api/services' +import { BookCover } from '../components/BookCover' import { Pagination } from '../components/Pagination' import { EmptyState } from '../components/TableStates' import { useApiCall } from '../hooks/useApiCall' @@ -12,39 +13,6 @@ const PAGE_SIZE = 20 * The rest of the world's books, not the library's. Anything found here can be put on the shelves * by whoever wants to read it - the catalogue belongs to the members, not to the desk. */ -/** - * A cover, addressed by cover id where the catalogue gave one and by ISBN otherwise - the search - * endpoint often omits `cover_i` for editions that do have a cover. Falls back to the placeholder - * rather than a broken image when neither resolves. - */ -function BookCover({ coverId, isbn }: { coverId: number | null; isbn: string }) { - const sources = [ - coverId ? `https://covers.openlibrary.org/b/id/${coverId}-M.jpg` : null, - isbn ? `https://covers.openlibrary.org/b/isbn/${isbn.replace(/[^0-9Xx]/g, '')}-M.jpg` : null, - ].filter((url): url is string => Boolean(url)) - - const [attempt, setAttempt] = useState(0) - - if (attempt >= sources.length) { - return