From 800387902c378bae77407db2e512d65e732b4cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Fri, 9 Oct 2026 09:48:03 +0200 Subject: [PATCH 1/5] fix: change an incorrect color tag in BookCard.module.css --- client/src/components/BookCard/BookCard.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/BookCard/BookCard.module.css b/client/src/components/BookCard/BookCard.module.css index f67485c..2c96345 100644 --- a/client/src/components/BookCard/BookCard.module.css +++ b/client/src/components/BookCard/BookCard.module.css @@ -107,7 +107,7 @@ } .starOff { - fill: var(--color-line); + fill: var(--color-star-off); } /* ===== Ribbon position (the look lives in ShelfRibbon.module.css) ===== */ From f0e2824e4e9e169b4bfd79049d366f6da2ea8c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Fri, 9 Oct 2026 11:01:10 +0200 Subject: [PATCH 2/5] feat: add skeleton book cards to show loading state in book grid New: - `BookCardSkeleton.tsx` - `BookCardSkeleton.module.css` Modified: - `BookCard.module.css`: add comment to inform that the skeleton uses and is dependent on that file to keep the format identical between real and skeleton book cards - `BookGrid.tsx` now handles the actual rendering of skeleton books, takes isLoading as a prop from the page who calls it. The props `savedIds` and `ratings` were replaced by `shelf` to reflect current data shape. - `BrowsePage.tsx` now uses BookGrid to render results, where before it did this itself using BookCard. TEMPORARY: hardcoded `isLoading` to ensure that BookGrid is called on correctly. Must be swapped for real loading logic. - `BrowsePage.module.css`: remove .grid rules since this page no longer does this itself. --- .../components/BookCard/BookCard.module.css | 2 + .../BookCardSkeleton.module.css | 45 +++++++++++++++++++ .../BookCardSkeleton/BookCardSkeleton.tsx | 23 ++++++++++ client/src/components/BookGrid/BookGrid.tsx | 42 ++++++++++------- .../pages/BrowsePage/BrowsePage.module.css | 14 ------ client/src/pages/BrowsePage/BrowsePage.tsx | 21 +++------ 6 files changed, 102 insertions(+), 45 deletions(-) create mode 100644 client/src/components/BookCardSkeleton/BookCardSkeleton.module.css create mode 100644 client/src/components/BookCardSkeleton/BookCardSkeleton.tsx diff --git a/client/src/components/BookCard/BookCard.module.css b/client/src/components/BookCard/BookCard.module.css index 2c96345..752b6fe 100644 --- a/client/src/components/BookCard/BookCard.module.css +++ b/client/src/components/BookCard/BookCard.module.css @@ -1,3 +1,5 @@ +/* NOTE: “BookCardSkeleton mirrors .item/.card/.text” */ + /* ===== List item ===== */ .item { min-width: 0; diff --git a/client/src/components/BookCardSkeleton/BookCardSkeleton.module.css b/client/src/components/BookCardSkeleton/BookCardSkeleton.module.css new file mode 100644 index 0000000..560c8e5 --- /dev/null +++ b/client/src/components/BookCardSkeleton/BookCardSkeleton.module.css @@ -0,0 +1,45 @@ +/* ===== Shared look of every placeholder piece ===== */ +.cover, +.title, +.meta { + background: var(--color-line); +} + +/* ===== Cover: same box as BookCover ===== */ +.cover { + width: 100%; + aspect-ratio: 2 / 3; + border-radius: 3px 7px 7px 3px; +} + +/* ===== Title bar: one line of BookCard's title ===== */ +/* Height = font size x line-height, taken from the same tokens as .title */ +.title { + height: calc(var(--text-lg) * 1.2); + width: 80%; + border-radius: var(--radius-sm); +} + +/* ===== Meta bar: one line of BookCard's meta ===== */ +.meta { + height: calc(var(--text-sm) * var(--leading)); + width: 50%; + border-radius: var(--radius-sm); +} + +/* ===== Pulse ===== */ +/* Only animates for users who haven't asked for reduced motion; + everyone else sees static boxes */ +@media (prefers-reduced-motion: no-preference) { + .cover, + .title, + .meta { + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes pulse { + 50% { + opacity: 0.5; + } +} diff --git a/client/src/components/BookCardSkeleton/BookCardSkeleton.tsx b/client/src/components/BookCardSkeleton/BookCardSkeleton.tsx new file mode 100644 index 0000000..3092b8a --- /dev/null +++ b/client/src/components/BookCardSkeleton/BookCardSkeleton.tsx @@ -0,0 +1,23 @@ +import cardStyles from '../BookCard/BookCard.module.css'; +import skeletonStyles from './BookCardSkeleton.module.css'; + +// ===== Props ===== +// TODO 1: No props for now (a `showShelfPlank` prop can come when My shelf needs skeletons) + +// ===== BookCardSkeleton: placeholder in the shape of a BookCard ===== +export function BookCardSkeleton() { + return ( + + ); +} diff --git a/client/src/components/BookGrid/BookGrid.tsx b/client/src/components/BookGrid/BookGrid.tsx index 91d1b6f..e2c5040 100644 --- a/client/src/components/BookGrid/BookGrid.tsx +++ b/client/src/components/BookGrid/BookGrid.tsx @@ -1,38 +1,48 @@ import { BookCard } from '../BookCard/BookCard'; import type { Book } from '../../types/book'; import styles from './BookGrid.module.css'; +import type { ShelfEntry } from '../../data/mockBooks'; +import { BookCardSkeleton } from '../BookCardSkeleton/BookCardSkeleton'; -/* ---------- Props: what a page must provide ---------- */ +// ===== How many placeholders to show while loading ===== +const SKELETON_COUNT = 8; + +// ===== Props ===== type BookGridProps = { books: Book[]; - savedIds: Set; // which books are on My shelf - ratings: Record; // bookId → the user's own rating (missing = unrated) + shelf: Map; // bookId → shelf entry (rating, note), present = on My shelf onToggleSaved: (bookId: string) => void; - showShelfPlank?: boolean; // decorative shelf plank under each row; off on Browse, on for My shelf + showShelfPlank?: boolean; + isLoading?: boolean; }; /* ---------- BookGrid: one list, one card per book ---------- */ // Doesn't know which page it's on. The page decides which books to pass and whether to show the shelf plank. export function BookGrid({ books, - savedIds, - ratings, + shelf, onToggleSaved, showShelfPlank = false, + isLoading = false, }: BookGridProps) { return ( // The modifier class holds the CSS that only the shelf needs (clipping)
    - {books.map((book) => ( - onToggleSaved(book.id)} // the grid binds the id, so a card doesn't need to know it - showShelfPlank={showShelfPlank} - /> - ))} + {isLoading + ? Array.from({ length: SKELETON_COUNT }, (_, i) => ) + : books.map((book) => { + const entry = shelf.get(book.id); + return ( + onToggleSaved(book.id)} + showShelfPlank={showShelfPlank} + /> + ); + })}
); } diff --git a/client/src/pages/BrowsePage/BrowsePage.module.css b/client/src/pages/BrowsePage/BrowsePage.module.css index c4685ac..7c6a346 100644 --- a/client/src/pages/BrowsePage/BrowsePage.module.css +++ b/client/src/pages/BrowsePage/BrowsePage.module.css @@ -25,23 +25,9 @@ opacity: 0.75; } -.grid { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1.5rem 1rem; -} - @media (min-width: 40rem) { /*tablets and bigger devices*/ .page { padding: 2rem 1.5rem; } - - .grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 2rem 1.5rem; - } } diff --git a/client/src/pages/BrowsePage/BrowsePage.tsx b/client/src/pages/BrowsePage/BrowsePage.tsx index 013ebc0..9ecc626 100644 --- a/client/src/pages/BrowsePage/BrowsePage.tsx +++ b/client/src/pages/BrowsePage/BrowsePage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { Book } from '../../types/book'; -import { BookCard } from '../../components/BookCard/BookCard'; +import { BookGrid } from '../../components/BookGrid/BookGrid'; import { mockBooks, mockShelfEntries, type ShelfEntry } from '../../data/mockBooks'; import styles from './BrowsePage.module.css'; @@ -23,6 +23,9 @@ export function BrowsePage() { const books = mockBooks; + // ===== TEMPORARY: fake loading state, replace with the query's `loading` ===== + const isLoading = false; + return (
@@ -31,20 +34,8 @@ export function BrowsePage() {

{books.length} books

-
    - {books.map((book) => { - const entry = shelf.get(book.id); - return ( - toggleSaved(book.id)} - /> - ); - })} -
+ +
); } From 8f702071549adb4c5f0a7f39c678738a4500e85e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Fri, 9 Oct 2026 11:18:08 +0200 Subject: [PATCH 3/5] feat: add loading state, visual + screenreader New: - `LoadingState.tsx` and `.module.css`: a role="status" wrapper with a visually hidden label, plus one pulsing dot to show loading state. Uses `announceOnly` to allow different visual loading (skeleton book cards) but retain the screen reader message. Modified: - `BrowsePage.tsx` updated with the new `` - `BookDetailPage.tsx` updated with the pulsing dot + label. Note: Both of these use a temporary, hardcoded "isLoading" that has to be replaced with real loading logic. --- .../LoadingState/LoadingState.module.css | 36 +++++++++++++++++++ .../components/LoadingState/LoadingState.tsx | 17 +++++++++ .../pages/BookDetailPage/BookDetailPage.tsx | 7 ++++ client/src/pages/BrowsePage/BrowsePage.tsx | 3 +- 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 client/src/components/LoadingState/LoadingState.module.css create mode 100644 client/src/components/LoadingState/LoadingState.tsx diff --git a/client/src/components/LoadingState/LoadingState.module.css b/client/src/components/LoadingState/LoadingState.module.css new file mode 100644 index 0000000..5b491ed --- /dev/null +++ b/client/src/components/LoadingState/LoadingState.module.css @@ -0,0 +1,36 @@ +/* ===== Layout ===== */ +.wrapper { + display: flex; + justify-content: center; + padding: var(--space-8) 0; +} + +/* ===== Dot (pulses only if motion is allowed) ===== */ +.dot { + width: 1rem; + height: 1rem; + border-radius: 50%; + background: var(--color-accent); +} + +@media (prefers-reduced-motion: no-preference) { + .dot { + animation: pulse 1.2s ease-in-out infinite; + } +} + +@keyframes pulse { + 50% { + opacity: 0.3; + } +} + +/* ===== Hidden from sight, not from screen readers ===== */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/client/src/components/LoadingState/LoadingState.tsx b/client/src/components/LoadingState/LoadingState.tsx new file mode 100644 index 0000000..fd057a5 --- /dev/null +++ b/client/src/components/LoadingState/LoadingState.tsx @@ -0,0 +1,17 @@ +import styles from './LoadingState.module.css'; + +// ===== Props ===== +type LoadingStateProps = { + label?: string; // read aloud by screen readers, never shown + announceOnly?: boolean; // no dot: for places that already show book card skeletons +}; + +// ===== LoadingState ===== +export function LoadingState({ label = 'Loading…', announceOnly = false }: LoadingStateProps) { + return ( +
+ {!announceOnly &&
+ ); +} diff --git a/client/src/pages/BookDetailPage/BookDetailPage.tsx b/client/src/pages/BookDetailPage/BookDetailPage.tsx index 9dc872b..c3a0a9e 100644 --- a/client/src/pages/BookDetailPage/BookDetailPage.tsx +++ b/client/src/pages/BookDetailPage/BookDetailPage.tsx @@ -7,6 +7,7 @@ import { MyNotesPanel } from '../../components/MyNotesPanel/MyNotesPanel'; import { ShelfRibbon } from '../../components/ShelfRibbon/ShelfRibbon'; import { type ShelfEntry, mockBooks, mockShelfEntries } from '../../data/mockBooks'; import styles from './BookDetailPage.module.css'; +import { LoadingState } from '../../components/LoadingState/LoadingState'; // Which confirm popup is open (null = none) type PendingConfirm = 'removeFromShelf' | 'deleteNote' | null; @@ -25,6 +26,12 @@ export function BookDetailPage() { const [pendingConfirm, setPendingConfirm] = useState(null); + // ===== TEMPORARY: fake loading state, replace with the query's `loading` ===== + const isLoading = false; + + // ===== Loading / not found ===== + // Order matters: while loading, `book` doesn't exist yet, and that isn't "not found" + if (isLoading) return ; if (!book) return

Book not found.

; // ===== Shelf actions ===== diff --git a/client/src/pages/BrowsePage/BrowsePage.tsx b/client/src/pages/BrowsePage/BrowsePage.tsx index 9ecc626..aa86efc 100644 --- a/client/src/pages/BrowsePage/BrowsePage.tsx +++ b/client/src/pages/BrowsePage/BrowsePage.tsx @@ -3,6 +3,7 @@ import type { Book } from '../../types/book'; import { BookGrid } from '../../components/BookGrid/BookGrid'; import { mockBooks, mockShelfEntries, type ShelfEntry } from '../../data/mockBooks'; import styles from './BrowsePage.module.css'; +import { LoadingState } from '../../components/LoadingState/LoadingState'; export function BrowsePage() { const [shelf, setShelf] = useState>( @@ -34,7 +35,7 @@ export function BrowsePage() {

{books.length} books

- + {isLoading && } ); From a85aa5f80bb191cee89d4391db7d7d46adba8841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Fri, 9 Oct 2026 11:32:00 +0200 Subject: [PATCH 4/5] style: change the names of button variants for clarity primary -> default secondary -> emphasis Need these for further work on this branch. Kept bumping into misunderstandings of which one was the filled, brightly colored one, and which one was the quiet, outlined one. Should make more sense now. --- client/src/components/Button/Button.module.css | 4 ++-- client/src/components/Button/Button.tsx | 4 ++-- client/src/components/ConfirmDialog/ConfirmDialog.tsx | 2 +- client/src/components/MyNotesPanel/MyNotesPanel.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/components/Button/Button.module.css b/client/src/components/Button/Button.module.css index 9e1eb1a..7001c0f 100644 --- a/client/src/components/Button/Button.module.css +++ b/client/src/components/Button/Button.module.css @@ -7,13 +7,13 @@ cursor: pointer; } -.primary { +.default { border-color: var(--color-focus); background: transparent; color: var(--color-focus); } -.secondary { +.emphasis { border-color: var(--color-accent); background: var(--color-accent); color: var(--color-on-accent); diff --git a/client/src/components/Button/Button.tsx b/client/src/components/Button/Button.tsx index bffcf9c..a73d30c 100644 --- a/client/src/components/Button/Button.tsx +++ b/client/src/components/Button/Button.tsx @@ -2,7 +2,7 @@ import styles from './Button.module.css'; type ButtonProps = { children: React.ReactNode; - variant?: 'primary' | 'secondary' | 'text'; + variant?: 'default' | 'emphasis' | 'text'; size?: 'narrow' | 'wide'; type?: 'button' | 'submit'; onClick?: () => void; @@ -12,7 +12,7 @@ type ButtonProps = { export function Button({ children, - variant = 'primary', + variant = 'default', size = 'narrow', type = 'button', onClick, diff --git a/client/src/components/ConfirmDialog/ConfirmDialog.tsx b/client/src/components/ConfirmDialog/ConfirmDialog.tsx index eec9d0c..18c70ad 100644 --- a/client/src/components/ConfirmDialog/ConfirmDialog.tsx +++ b/client/src/components/ConfirmDialog/ConfirmDialog.tsx @@ -47,7 +47,7 @@ export function ConfirmDialog({

{message}

-
- } +
+ ); +} diff --git a/client/src/components/ErrorState/ErrorState.module.css b/client/src/components/ErrorState/ErrorState.module.css new file mode 100644 index 0000000..68113a1 --- /dev/null +++ b/client/src/components/ErrorState/ErrorState.module.css @@ -0,0 +1,23 @@ +/* ===== Layout ===== */ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-8) 0; + text-align: center; +} + +/* ===== Text ===== */ +.title { + margin: 0; + font-family: var(--font-heading); + font-size: var(--text-lg); + font-weight: 600; + line-height: 1.2; +} + +.message { + margin: 0; + color: var(--color-text-soft); +} diff --git a/client/src/components/ErrorState/ErrorState.tsx b/client/src/components/ErrorState/ErrorState.tsx new file mode 100644 index 0000000..065da3b --- /dev/null +++ b/client/src/components/ErrorState/ErrorState.tsx @@ -0,0 +1,22 @@ +import { Button } from '../Button/Button'; +import styles from './ErrorState.module.css'; + +// ===== Props ===== +type ErrorStateProps = { + message: string; + onRetry: () => void; + headingLevel?: 1 | 2; // 1 when this replaces the whole page (book detail), otherwise 2 +}; + +// ===== ErrorState: the query failed, offer a way to try again ===== +export function ErrorState({ message, onRetry, headingLevel = 2 }: ErrorStateProps) { + const Heading = headingLevel === 1 ? 'h1' : 'h2'; + + return ( +
+ Something went wrong +

{message}

+ +
+ ); +} diff --git a/client/src/pages/BookDetailPage/BookDetailPage.tsx b/client/src/pages/BookDetailPage/BookDetailPage.tsx index c3a0a9e..bc1dbe8 100644 --- a/client/src/pages/BookDetailPage/BookDetailPage.tsx +++ b/client/src/pages/BookDetailPage/BookDetailPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { BookCover } from '../../components/BookCover/BookCover'; import { ConfirmDialog } from '../../components/ConfirmDialog/ConfirmDialog'; import { Eyebrow } from '../../components/Eyebrow/Eyebrow'; @@ -8,12 +8,15 @@ import { ShelfRibbon } from '../../components/ShelfRibbon/ShelfRibbon'; import { type ShelfEntry, mockBooks, mockShelfEntries } from '../../data/mockBooks'; import styles from './BookDetailPage.module.css'; import { LoadingState } from '../../components/LoadingState/LoadingState'; +import { ErrorState } from '../../components/ErrorState/ErrorState'; +import { EmptyState } from '../../components/EmptyState/EmptyState'; // Which confirm popup is open (null = none) type PendingConfirm = 'removeFromShelf' | 'deleteNote' | null; export function BookDetailPage() { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); // ===== TEMPORARY! TODO: replace with a query by id once the backend is ready ===== const book = mockBooks.find((b) => b.id === id); @@ -26,13 +29,34 @@ export function BookDetailPage() { const [pendingConfirm, setPendingConfirm] = useState(null); - // ===== TEMPORARY: fake loading state, replace with the query's `loading` ===== + // ===== TEMPORARY: fake loading and error states, replace with the query's `loading` and `error` ===== const isLoading = false; + const isError = false; - // ===== Loading / not found ===== - // Order matters: while loading, `book` doesn't exist yet, and that isn't "not found" + // ===== Page-level states ===== + // Order matters: while loading there is no `book` yet, and a failed query has none either. + // Neither is "not found", so those two are checked first. if (isLoading) return ; - if (!book) return

Book not found.

; + + if (isError) { + return ( + console.log('TEMPORARY: replace with the query refetch')} + /> + ); + } + + if (!book) { + return ( + navigate('/') }} + /> + ); + } // ===== Shelf actions ===== // Ribbon: add an empty entry, or remove it. Removing deletes the rating and note too,