Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
73 changes: 73 additions & 0 deletions frontend/design/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
30 changes: 30 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/BookCover.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BookCover coverId={12345} isbn="9780441478125" />)

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(<BookCover coverId={12345} isbn="9780441478125" />)

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(<BookCover isbn="978-0-441-47812-5" />)

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(<BookCover coverId={12345} isbn="9780441478125" />)

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(<BookCover coverId={12345} isbn="9780441478125" />)

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(
<BookCover
coverId={12345}
isbn="9780441478125"
fallback={<span data-testid="no-cover" />}
/>,
)

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(<BookCover isbn="" />)

expect(container).toBeEmptyDOMElement()
})

it('passes its class through, so each caller sizes its own cover', () => {
const { container } = render(<BookCover isbn="9780441478125" className="discover-cover" />)

expect(coverIn(container)).toHaveClass('discover-cover')
})
})
49 changes: 49 additions & 0 deletions frontend/src/components/BookCover.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<img
className={className}
src={sources[attempt]}
alt=""
loading="lazy"
decoding="async"
// Open Library answers with a 1x1 placeholder for a missing cover, so a load is not proof.
onError={() => setAttempt((n) => n + 1)}
onLoad={(event) => {
if (event.currentTarget.naturalWidth <= 1) setAttempt((n) => n + 1)
}}
/>
)
}
Loading
Loading