diff --git a/README.md b/README.md
index 7207bae..e5cc0a2 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ We deliberately left out reviews from other users. There is no user login and no
*Preview:*
-
+
The mockup is a single HTML file. GitHub shows HTML files as source code, so **download the file (or clone the repository) and open it in a web browser**. It works offline, but the fonts are loaded from Google Fonts, so without internet it uses fallback fonts.
@@ -45,24 +45,8 @@ Links between the pages work in the mockup. Buttons do not. The ratings, notes a
**Source:** the [Google Books API](https://developers.google.com/books). We fetch books once and store a cleaned copy in our own database, so the app does not depend on Google while running. The app will be built to be able to handle larger amounts of data than this, which allows us to fetch more books for the app in the future, if we want to.
-**What a book looks like** (our contract between backend and frontend):
-
-```ts
-type Book = {
- id: string; // Google Books volume id
- title: string;
- authors: string[];
- publishedYear: number | null;
- genres: string[]; // from a fixed list we define
- description: string | null; // plain text
- coverUrl: string | null; // a placeholder cover is shown if missing
- pageCount: number | null;
- language: string | null;
- myEntry: ShelfEntry | null; // this user's rating and note, if any
-};
-
-type ShelfEntry = { rating: number | null; note: string | null; updatedAt: string };
-```
+**What a book looks like**
+See `docs/decisions.md`
**Two kinds of stored data:**
1. **Books:** read-only, filled in by a seed script.
@@ -107,18 +91,19 @@ type ShelfEntry = { rating: number | null; note: string | null; updatedAt: strin
| State | A state management library (to be decided) |
| Quality | ESLint and Prettier, component tests with Vitest, API tests, and an automated end-to-end test |
| Deployment | Apache serving the built app under `/project2`, backend on port 3001 |
+| Styling | Plain CSS with shared design tokens, and CSS Modules per component |
We will reuse existing libraries wherever they make sense and explain the important choices in this README as the project grows.
-**Accessibility:** semantic HTML, real buttons and labels, keyboard use, visible focus, enough text contrast, and touch targets of at least 44 px. The bookmark ribbon is a real button with a pressed state, and the star rating is built so it can be used with keyboard and screen readers.
+**Accessibility:** semantic HTML, real buttons and labels, keyboard use, visible focus, enough text contrast, and touch targets of at least 44 px. The bookmark ribbon is a real button with a pressed state, and the star rating is built so it can be used with keyboard and screen readers. Each book card contains one link, separate from the bookmark button, so there are no nested buttons.
-**Sustainability:** results load in small batches, search waits before it sends requests, covers are small and loaded lazily, and we use few fonts and few font weights.
+**Sustainability:** Search is sent only when the user presses Enter or the search button, never while typing. Results load in small batches, covers are small and loaded lazily, and we use few fonts and few font weights.
## 6. How we work
- **Four roles, two pairs.** A (Data) and B (API) build the backend. C (Browse and Shelf) and D (Design and Detail) build the frontend.
- **We agree on contracts, not code.** The data shapes and operations between the backend and the frontend are fixed in [`docs/decisions.md`](docs/decisions.md). Each side is free to build its part its own way, as long as the results match. Changing a contract needs its own pull request.
-- **Git.** Every task is an issue, work happens on branches, and every change goes through a pull request with a review from someone else. Our git wiki has a "Git conventions" page that is ment to help us keep git clean and informational.
+- **Git.** Every task is an issue, work happens on branches, and every change goes through a pull request with a review from someone else. Our git wiki has a "Git conventions" page that is meant to help us keep git clean and informational.
- **Checklist.** The project requirements and open tasks are in [`docs/project-checklist.md`](docs/project-checklist.md).
- **Secrets** are never committed. They live in a `.env` file on the server.
- Time logs and each member's contribution are delivered separately in the course's submission system, not in this repository.
diff --git a/client/eslint.config.js b/client/eslint.config.js
index 356db8d..8d061c7 100644
--- a/client/eslint.config.js
+++ b/client/eslint.config.js
@@ -1,10 +1,10 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
-import { defineConfig, globalIgnores } from 'eslint/config'
-import prettier from 'eslint-config-prettier'
+import js from '@eslint/js';
+import globals from 'globals';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import tseslint from 'typescript-eslint';
+import { defineConfig, globalIgnores } from 'eslint/config';
+import prettier from 'eslint-config-prettier';
export default defineConfig([
globalIgnores(['dist']),
@@ -21,4 +21,4 @@ export default defineConfig([
},
},
prettier,
-])
+]);
diff --git a/client/package-lock.json b/client/package-lock.json
index a7b98c2..1ff170e 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -12,6 +12,7 @@
"graphql": "^16.14.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-router-dom": "^7.18.4",
"rxjs": "^7.8.2",
"vitest": "^5.0.1"
},
@@ -1563,6 +1564,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2762,6 +2776,44 @@
"react": "^19.3.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
+ "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.4"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/rolldown": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
@@ -2820,6 +2872,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/client/package.json b/client/package.json
index 67d1e61..fa3082c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -16,6 +16,7 @@
"graphql": "^16.14.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-router-dom": "^7.18.4",
"rxjs": "^7.8.2",
"vitest": "^5.0.1"
},
diff --git a/client/src/App.tsx b/client/src/App.tsx
index c91e73b..7c3ec04 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,7 +1,5 @@
function App() {
- return (
-
π₯Project 2π₯
- )
+ return
π₯Project 2π₯
;
}
-export default App
\ No newline at end of file
+export default App;
diff --git a/client/src/components/BookCard/BookCard.module.css b/client/src/components/BookCard/BookCard.module.css
new file mode 100644
index 0000000..51b0e68
--- /dev/null
+++ b/client/src/components/BookCard/BookCard.module.css
@@ -0,0 +1,132 @@
+/* ===== List item ===== */
+.item {
+ min-width: 0; /* lets long titles shrink instead of stretching the grid */
+}
+
+/* ===== Card container ===== */
+.card {
+ position: relative; /* anchor for the stretched link and the ribbon */
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+/* ===== Cover sits above the shelf ===== */
+/* So the cover's shadow falls onto the shelf instead of being painted over
+ by a neighbouring card's plank */
+.coverWrap {
+ position: relative;
+ z-index: 1;
+}
+
+/* ===== Shelf plank (only rendered when showShelf is true) ===== */
+/* The plank itself is just a 10px spacer. The visible shelf is its ::before,
+ stretched far to both sides. Every card in a row draws the same shelf at the
+ same height, so they overlap into one continuous line.
+ BookGrid's .withShelf clips it at the edge of the grid. */
+.plank {
+ position: relative;
+ z-index: 0;
+ height: 10px;
+}
+.plank::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -100vw;
+ right: -100vw;
+ background: linear-gradient(var(--color-shelf-edge) 0 2px, var(--color-shelf) 2px);
+ border-bottom: 2px solid rgb(0 0 0 / 0.25); /* opaque "shadow": a real box-shadow would stack across cards */
+ pointer-events: none; /* otherwise it blocks clicks on neighbouring cards */
+}
+
+/* ===== Text ===== */
+.text {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.title {
+ margin: 0;
+ font-family: var(--font-heading);
+ font-weight: 600;
+ font-size: var(--text-lg); /* mockup uses 18px; adjust if you like */
+ line-height: 1.2;
+}
+.meta {
+ font-size: var(--text-sm);
+ color: var(--color-text-soft);
+}
+
+/* ===== Clickable card: the "stretched link" trick ===== */
+.link {
+ color: inherit;
+ text-decoration: none;
+}
+.link::after {
+ /* invisible layer over the whole card */
+ content: '';
+ position: absolute;
+ inset: 0;
+}
+.card:hover .link {
+ text-decoration: underline;
+}
+
+/* Focus ring around the whole card instead of only the title text */
+.link:focus-visible {
+ outline: none;
+}
+.card:has(.link:focus-visible) {
+ outline: 2px solid var(--color-focus);
+ outline-offset: 4px;
+ border-radius: var(--radius-sm);
+}
+
+/* ===== Stars ===== */
+.stars {
+ display: flex;
+ gap: 2px;
+ align-items: center;
+}
+.starOn {
+ fill: var(--color-star);
+}
+.starOff {
+ fill: var(--color-line);
+}
+
+/* ===== Ribbon ===== */
+.ribbon {
+ position: absolute;
+ top: 0;
+ right: 4px;
+ z-index: 2; /* above the cover and the stretched link, so it stays clickable */
+ width: 44px; /* 44px-wide tap area; the drawn ribbon is only 24px */
+ height: 48px;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: pointer;
+}
+.ribbon:focus-visible {
+ outline: 2px solid var(--color-focus);
+ outline-offset: -2px;
+}
+
+/* Not saved: pale outline ribbon */
+.ribbon path {
+ fill: rgb(255 253 248 / 0.9);
+ stroke: var(--color-ribbon);
+ stroke-width: 1.8;
+}
+/* Saved: solid ribbon. Styled from aria-pressed, so no extra class is needed */
+.ribbon[aria-pressed='true'] path {
+ fill: var(--color-ribbon);
+ stroke: rgb(255 255 255 / 0.8);
+ stroke-width: 1;
+}
diff --git a/client/src/components/BookCard/BookCard.tsx b/client/src/components/BookCard/BookCard.tsx
new file mode 100644
index 0000000..6461e67
--- /dev/null
+++ b/client/src/components/BookCard/BookCard.tsx
@@ -0,0 +1,107 @@
+import { Link } from 'react-router-dom';
+import type { Book } from '../../types/book';
+import { BookCover } from '../BookCover/BookCover';
+import styles from './BookCard.module.css';
+
+/* ---------- Types: the contract with BookGrid and the pages ---------- */
+type BookCardProps = {
+ book: Book;
+ isSaved: boolean; // is this book on My shelf?
+ myRating?: number; // 1β5; undefined = not rated (no stars shown)
+ onToggleSaved: () => void;
+ showShelf?: boolean; // draw the decorative shelf plank under the cover
+};
+
+/* ---------- BookCard: one book ---------- */
+// Renders an
, so it must be used inside BookGrid's
+export function BookCard({
+ book,
+ isSaved,
+ myRating,
+ onToggleSaved,
+ showShelf = false,
+}: BookCardProps) {
+ // Meta line: "Jane Austen Β· 1813". Year is optional, so build it defensively
+ const meta = [book.authors.join(', '), book.publishedYear].filter(Boolean).join(' Β· ');
+
+ return (
+
+
+ {/* Cover + optional shelf plank */}
+
+ {/* coverWrap lifts the cover above the shelf so its shadow lands on the plank */}
+
+
+
+ {showShelf && }
+
+
+ {/* Text block */}
+
+
+ {/* The only link. Its ::after covers the whole card (see CSS) */}
+
+ {book.title}
+
+
+ {meta}
+ {myRating !== undefined && }
+
+
+ {/* Ribbon: sibling of the link, NOT inside it */}
+
+
+
+ );
+}
diff --git a/client/src/components/BookGrid/BookGrid.module.css b/client/src/components/BookGrid/BookGrid.module.css
new file mode 100644
index 0000000..a206665
--- /dev/null
+++ b/client/src/components/BookGrid/BookGrid.module.css
@@ -0,0 +1,19 @@
+/* ===== Base grid (both pages) ===== */
+/* auto-fill: as many columns as fit, each at least 10rem wide.
+ No media queries; gives about 2 columns on a phone and 4 beside the sidebar. */
+.grid {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+ gap: 2.75rem 1.5rem; /* row gap, column gap */
+}
+
+/* ===== Only when the shelf is shown ===== */
+/* Each card's .plank::before (in BookCard.module.css) is stretched far to both
+ sides; this clips it at the edge of the grid */
+.withShelf {
+ overflow-x: clip;
+ padding-inline: var(--space-2); /* room so focus rings and shadows aren't clipped */
+}
diff --git a/client/src/components/BookGrid/BookGrid.tsx b/client/src/components/BookGrid/BookGrid.tsx
new file mode 100644
index 0000000..c345139
--- /dev/null
+++ b/client/src/components/BookGrid/BookGrid.tsx
@@ -0,0 +1,38 @@
+import { BookCard } from '../BookCard/BookCard';
+import type { Book } from '../../types/book';
+import styles from './BookGrid.module.css';
+
+/* ---------- Props: what a page must provide ---------- */
+type BookGridProps = {
+ books: Book[];
+ savedIds: Set; // which books are on My shelf
+ ratings: Record; // bookId β the user's own rating (missing = unrated)
+ onToggleSaved: (bookId: string) => void;
+ showShelf?: boolean; // decorative shelf under each row; off on Browse, on for My shelf
+};
+
+/* ---------- 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.
+export function BookGrid({
+ books,
+ savedIds,
+ ratings,
+ onToggleSaved,
+ showShelf = 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
+ showShelf={showShelf}
+ />
+ ))}
+
+ );
+}
diff --git a/client/src/components/SearchField/SearchField.module.css b/client/src/components/SearchField/SearchField.module.css
new file mode 100644
index 0000000..b7d1e2d
--- /dev/null
+++ b/client/src/components/SearchField/SearchField.module.css
@@ -0,0 +1,122 @@
+/* ===== Form: the visible "pane" (border, corners, background) ===== */
+.form {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ max-width: 520px;
+ height: var(--tap-target);
+ padding: 3px;
+ /* space around the button so it sits inside the pane */
+ border: 1px solid color-mix(in srgb, var(--color-header-tint) 40%, transparent);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--color-header-tint) 10%, transparent);
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+}
+
+/* Focus: gold border (thin, no glow) */
+.form:has(.input:focus-visible) {
+ border-color: var(--color-highlight);
+}
+
+/* ===== Input: borderless and transparent, the form draws the field ===== */
+.input {
+ flex: 1;
+ min-width: 0;
+ /* lets the input shrink on narrow screens */
+ height: 100%;
+ padding: 0 var(--space-2) 0 var(--space-4);
+ border: 0;
+ background: transparent;
+ color: var(--color-header-soft);
+ caret-color: var(--color-highlight);
+ font-size: var(--text-md);
+ /* keep at 16px or more, or iOS zooms in on focus */
+ appearance: none;
+ /* removes Safari's built-in search-field styling */
+ color-scheme: dark;
+ /* makes the browser's built-in clear (x) button light */
+}
+
+.input::placeholder {
+ color: color-mix(in srgb, var(--color-header-text) 80%, transparent);
+ transition: opacity 0.2s ease;
+}
+
+.input:focus-visible {
+ outline: none;
+ /* the form's border shows focus instead */
+}
+
+.input:focus-visible::placeholder {
+ opacity: 0.6;
+ /* placeholder steps back once you are typing */
+}
+
+/* ===== Search button: a small rounded key inset in the field ===== */
+.button {
+ position: relative;
+ flex: none;
+ display: grid;
+ place-items: center;
+ height: 100%;
+ aspect-ratio: 1;
+ /* square: as wide as the field is tall */
+ padding: 0;
+ border: 0;
+ border-radius: calc(var(--radius-md) - 3px);
+ /* form radius minus the 3px padding = corners that nest evenly */
+ background: color-mix(in srgb, var(--color-header-soft) 15%, transparent);
+ color: var(--color-header-soft);
+ cursor: pointer;
+ transition:
+ background-color 0.18s ease,
+ color 0.18s ease,
+ transform 0.12s ease;
+}
+
+/* Grows the clickable area to 44px without changing how the button looks */
+.button::after {
+ content: '';
+ position: absolute;
+ inset: -4px;
+}
+
+/* Click: small physical press, slightly deeper gold */
+.button:active {
+ background: color-mix(in srgb, var(--color-highlight) 85%, black);
+ transform: scale(0.92);
+}
+
+/* Keyboard focus: ring around button */
+.button:focus-visible {
+ outline: 2px solid var(--color-highlight);
+ outline-offset: -2px;
+}
+
+/* Respect "reduce motion" system settings */
+@media (prefers-reduced-motion: reduce) {
+ .form,
+ .input::placeholder,
+ .button {
+ transition: none;
+ }
+
+ .button:active {
+ transform: none;
+ }
+}
+
+@media (hover: hover) {
+ /* Form hover: the border brightens a touch */
+ .form:hover {
+ border-color: color-mix(in srgb, var(--color-chip-bg) 80%, transparent);
+ }
+
+ /* Button hover: fills gold, icon turns plum */
+ .button:hover {
+ background: var(--color-highlight);
+ color: var(--color-header);
+ }
+}
diff --git a/client/src/components/SearchField/SearchField.tsx b/client/src/components/SearchField/SearchField.tsx
new file mode 100644
index 0000000..8ae089e
--- /dev/null
+++ b/client/src/components/SearchField/SearchField.tsx
@@ -0,0 +1,63 @@
+import { useId, useState } from 'react';
+import styles from './SearchField.module.css';
+
+/* ---------- Props ---------- */
+type Props = {
+ value: string; // the currently applied search (from the URL)
+ onSearch: (query: string) => void;
+};
+
+/* ---------- SearchField: submits on Enter or the search button, never while typing ---------- */
+export function SearchField({ value, onSearch }: Props) {
+ const id = useId();
+ const [draft, setDraft] = useState(value); // what is typed but not yet applied
+ const [prevValue, setPrevValue] = useState(value); // the applied value we last saw
+
+ /* If the applied search changed elsewhere (Clear all, back button), reset the draft. */
+ if (value !== prevValue) {
+ setPrevValue(value);
+ setDraft(value);
+ }
+
+ return (
+ // A
+ );
+}
diff --git a/client/src/index.css b/client/src/index.css
deleted file mode 100644
index 5fb3313..0000000
--- a/client/src/index.css
+++ /dev/null
@@ -1,111 +0,0 @@
-:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
- --social-bg: rgba(244, 243, 236, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
-
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, monospace;
-
- font: 18px/145% var(--sans);
- letter-spacing: 0.18px;
- color-scheme: light dark;
- color: var(--text);
- background: var(--bg);
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-
- @media (max-width: 1024px) {
- font-size: 16px;
- }
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
- --social-bg: rgba(47, 48, 58, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
- }
-
- #social .button-icon {
- filter: invert(1) brightness(2);
- }
-}
-
-#root {
- width: 1126px;
- max-width: 100%;
- margin: 0 auto;
- text-align: center;
- border-inline: 1px solid var(--border);
- min-height: 100svh;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
-}
-
-h1,
-h2 {
- font-family: var(--heading);
- font-weight: 500;
- color: var(--text-h);
-}
-
-h1 {
- font-size: 56px;
- letter-spacing: -1.68px;
- margin: 32px 0;
- @media (max-width: 1024px) {
- font-size: 36px;
- margin: 20px 0;
- }
-}
-h2 {
- font-size: 24px;
- line-height: 118%;
- letter-spacing: -0.24px;
- margin: 0 0 8px;
- @media (max-width: 1024px) {
- font-size: 20px;
- }
-}
-p {
- margin: 0;
-}
-
-code,
-.counter {
- font-family: var(--mono);
- display: inline-flex;
- border-radius: 4px;
- color: var(--text-h);
-}
-
-code {
- font-size: 15px;
- line-height: 135%;
- padding: 4px 8px;
- background: var(--code-bg);
-}
diff --git a/client/src/styles/base.css b/client/src/styles/base.css
new file mode 100644
index 0000000..3c272e9
--- /dev/null
+++ b/client/src/styles/base.css
@@ -0,0 +1,39 @@
+body {
+ background: var(--color-page);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ font-size: var(--text-md);
+ line-height: var(--leading);
+}
+
+h1,
+h2,
+h3 {
+ font-family: var(--font-heading);
+ line-height: 1.2;
+}
+
+a {
+ color: var(--color-accent);
+}
+
+:focus-visible {
+ outline: 3px solid var(--color-focus);
+ outline-offset: 2px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ transition-duration: 0.001ms !important;
+ animation-duration: 0.001ms !important;
+ }
+}
diff --git a/client/src/styles/index.css b/client/src/styles/index.css
new file mode 100644
index 0000000..fb9eb84
--- /dev/null
+++ b/client/src/styles/index.css
@@ -0,0 +1,3 @@
+@import './tokens.css';
+@import './reset.css';
+@import './base.css';
diff --git a/client/src/styles/reset.css b/client/src/styles/reset.css
new file mode 100644
index 0000000..a09d676
--- /dev/null
+++ b/client/src/styles/reset.css
@@ -0,0 +1,20 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+}
+
+img {
+ display: block;
+ max-width: 100%;
+}
+
+button,
+input {
+ font: inherit;
+ color: inherit;
+}
diff --git a/client/src/styles/tokens.css b/client/src/styles/tokens.css
new file mode 100644
index 0000000..ed40073
--- /dev/null
+++ b/client/src/styles/tokens.css
@@ -0,0 +1,69 @@
+:root {
+ --color-page: #faf3ee;
+ --color-card: #fffcfa;
+ --color-text: #2a1a26;
+ --color-text-soft: #6a5560;
+ --color-line: #e8d9d3;
+ --color-input-border: #8a6f7c;
+ --color-header: #4a1f4f;
+ --color-header-text: #fbeef3;
+ --color-accent: #a3264f;
+ --color-ribbon: #c2810f;
+ --color-highlight: #eeb34c;
+ --color-star: #c78a12;
+ --color-chip-bg: #f8bbd2;
+ --color-chip-text: #7a1f45;
+ --color-shelf: #7b5535;
+ --color-shelf-edge: #9a7050;
+ --color-header-tint: #f3b2cb;
+ --color-header-soft: #ffd8e6;
+
+ /* TODO:check contrast */
+ --color-on-accent: #ffffff; /* text on accent buttons */
+ --color-accent-hover: #8a1f42; /* darker accent for hover */
+ --color-focus: #4a1f4f; /* focus ring on light areas; use --color-highlight inside the header */
+ --color-danger: #b3261e; /* destructive actions, e.g. delete review */
+
+ /* Generated book cover tones (bg / text pairs) */
+ --cover-0-bg: #6a2a5e;
+ --cover-0-fg: #fbeaf3;
+ --cover-1-bg: #8a1f45;
+ --cover-1-fg: #fdeaf0;
+ --cover-2-bg: #2d1b3d;
+ --cover-2-fg: #efe3f3;
+ --cover-3-bg: #34507a;
+ --cover-3-fg: #eaf1fb;
+ --cover-4-bg: #5a2a6e;
+ --cover-4-fg: #f5e9fb;
+ --cover-5-bg: #4a2a4a;
+ --cover-5-fg: #f5e6f2;
+
+ /* Fonts (self-hosting comes later; fallbacks work meanwhile) */
+ /* TODO: Figure out self-hosting fonts */
+ --font-heading: 'Fraunces', Georgia, serif;
+ --font-body: 'Figtree', system-ui, sans-serif;
+
+ /* Type scale */
+ --text-sm: 0.875rem;
+ --text-md: 1rem;
+ --text-lg: 1.25rem;
+ --text-xl: 1.75rem;
+ --leading: 1.5;
+
+ /* Spacing (4px base) */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+
+ /* Shape and depth */
+ --radius-sm: 6px;
+ --radius-md: 12px;
+ --shadow-card: 0 1px 3px rgb(42 26 38 / 0.12);
+
+ /* Layout */
+ --tap-target: 2.75rem; /* 44px minimum touch size */
+ --content-width: 72rem;
+}
diff --git a/client/src/types/book.ts b/client/src/types/book.ts
new file mode 100644
index 0000000..9f7069e
--- /dev/null
+++ b/client/src/types/book.ts
@@ -0,0 +1,14 @@
+/* ---------- Book: mirrors the GraphQL schema / Mongoose model ---------- */
+// TODO: check field names against the backend's typeDefs once they exist
+export type Book = {
+ id: string; // TODO: agree with backend: googleBooksId or Mongo _id
+ title: string;
+ subtitle: string; // "" when none
+ authors: string[]; // [] when none
+ publishedYear: number | null; // null when unknown
+ genres: string[];
+ description: string; // "" when none
+ coverURL: string; // "" when none
+ pageCount: number | null;
+ language: string; // "" when unknown
+};
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 1ffef60..d32ff68 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -1,7 +1,4 @@
{
"files": [],
- "references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
diff --git a/client/vite.config.ts b/client/vite.config.ts
index d789a7c..7236772 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -1,11 +1,8 @@
-import react, { reactCompilerPreset } from '@vitejs/plugin-react'
-import babel from '@rolldown/plugin-babel'
-import { defineConfig } from 'vite'
+import react, { reactCompilerPreset } from '@vitejs/plugin-react';
+import babel from '@rolldown/plugin-babel';
+import { defineConfig } from 'vite';
// https://vite.dev/config/
export default defineConfig({
- plugins: [
- react(),
- babel({ presets: [reactCompilerPreset()] })
- ],
-})
+ plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
+});
diff --git a/docs/decisions-and-contracts.md b/docs/decisions-and-contracts.md
index fd2e873..d05477a 100644
--- a/docs/decisions-and-contracts.md
+++ b/docs/decisions-and-contracts.md
@@ -2,23 +2,26 @@
v1: created by Ilse, 29.09.2026
-Mockups: `docs/mockups/` (palette C, light mode).
+Mockup: `docs/bookish-mockups.html/` (light mode)
## The contract between backend and frontend
```ts
type Book = {
- id: string; // Google volume id
+ id: string; // UNCERTAIN: Google Books id (googleBooksId) or Mongo _id? Must be stable across re-seeds
title: string;
- authors: string[];
- publishedYear: number | null;
- genres: string[]; // from a fixed list (see `genres` query)
- description: string | null; // plain text, never HTML
- coverUrl: string | null; // null -> the client shows a placeholder cover
- pageCount: number | null;
- language: string | null;
- myEntry: ShelfEntry | null; // for the browser ID sent with the request
+ subtitle: string; // "" when none
+ authors: string[]; // [] when none
+ publishedYear: number | null; // null when unknown
+ genres: string[]; // UNCERTAIN: from a fixed list (see `genres` query), exact list not decided
+ description: string; // "" when none; plain text, never HTML
+ coverURL: string; // "" when none -> the client shows a generated cover
+ pageCount: number | null; // null when unknown
+ language: string; // "" when unknown
};
+
+// UNCERTAIN: how the user's own data reaches the client is not decided.
+// Option 1: a `myEntry` field on each Book. Option 2: separate lookups (saved ids + ratings).
type ShelfEntry = { rating: number | null; note: string | null; updatedAt: string };
```
@@ -40,10 +43,10 @@ type Mutation {
setNote(bookId: ID!, note: String): Book! # max 1000 characters, null deletes
}
```
-
Genre filter: a book matches if it has **any** of the selected genres. Search: matches title and author, case-insensitive. Results follow the chosen sort.
UserID: Client only knows its own ID. Server stores IDs along with shelf entries, but the client never needs to know about any other IDs than its own, so it's not part of the contract.
+
## Decisions that affect several parts
| # | Decision | Affects | Consequence |
@@ -56,12 +59,34 @@ UserID: Client only knows its own ID. Server stores IDs along with shelf entries
| 6 | **Tie-break rule:** every sort ends with `id` as a last tie-breaker (for example: year, then id). The cursor is an opaque string that only the server understands. `totalCount` counts the filtered set. | A, B, C | Without a fixed order for equal values, "Load more" can repeat or skip books. C only passes the cursor back and never builds one. |
| 7 | **The data is cleaned when seeded.** Descriptions are plain text, genres come from a fixed list, `publishedDate` becomes `publishedYear`, one edition per book. | A, B, C, D | The client never handles HTML from Google and never has to guess genre names. |
| 8 | **Notes are plain text**, max 1000 characters, shown with line breaks kept, never as HTML. | B, D | The server enforces the limit. The client shows a counter. |
-| 9 | **Design tokens and shared components.** Colors, spacing, radius and fonts come from `tokens.css`. BookCover, BookCard, ShelfButton and StarRating are reused, not copied. | C, D | Need a new value or variant? Add it to the shared file in a pull request. |
+| 9 | **Design tokens and shared components.** Colors, spacing, radius and fonts come from `tokens.css`. Styling is plain CSS: global `tokens.css`, `reset.css` and `base.css`, plus one CSS Module per component. `BookCover`, `BookCard`, `BookGrid`, `SearchField`, `Button`, `AppShell` (and later `ShelfButton` and `StarRating`) are reused, not copied. | C, D | Need a new value or variant? Add it to the shared file in a pull request. | |
| 10 | **Secrets** live only in `server/.env` (git-ignored, never committed). `server/.env.example` lists the names. Nothing secret in the client. Anything starting with `VITE_` is public. | Everyone | If a secret is ever committed, tell the group and replace it. Deleting the file later does not help. |
| 11 | **Contract changes** (the block above) go in their own pull request, with a heads-up to the people they touch. | Everyone | |
+| 12 | **Search is submitted with Enter or the search button, never while typing.** The applied query lives in the URL as `q`. | C, D | No debouncing needed. The search field in the shared header also works on the detail page by navigating to `/?q=β¦`. A new `q` resets the paging. |
+| 13 | **The URL holds the browse state:** `q`, genre(s), `yearFrom`, `yearTo`, `sort`. | C, D | Reload and Back give the same results. The filter panel, sort select and search all read and write the URL. |
+
+## Pages and routes
+
+| Route | Page | Owner | Notes |
+|---|---|---|---|
+| `/` | Browse | C | Search, filters and sort live in the URL |
+| `/book/:id` | Book detail | D | `:id` is the book `id` (see open questions) |
+| `/shelf` | My shelf | C | Sort RECENT / TITLE_ASC / RATING_DESC, no search |
## Open questions
- Database engine (A + B decide).
-- CSS approach (Modules or plain files) and fonts (C + D decide).
-- User ID handoff between client and server
\ No newline at end of file
+- Fonts: self-hosting approach for Fraunces and Figtree (C + D decide).
+- User ID handoff between client and server.
+- **Book `id`:** Google Books id or Mongo `_id`? It becomes the URL, the saved-books key, and the key for ratings and notes, so it must be stable across re-seeds.
+- **How the user's own data reaches the client:** a `myEntry` field on each `Book` (the mutations already return `Book!`, which points this way), or separate lookups (saved ids and ratings). Decides the props of `BookGrid`.
+- **Removal confirmation:** decision 3 says the client asks before removing a book with a rating or note. Does this apply on the cards too, or only on the detail page? Dialog or inline?
+- **Year filter edge case:** what happens if `yearFrom` is greater than `yearTo`? Validate on the client, the server, or both?
+- **Genre list:** who fixes the final list, and does it live in the seed script or in its own table?
+- **Empty results from filters:** can a filter combination give zero results, or does `books` return counts per genre so the client can hide empty ones?
+- **`updatedAt` on `ShelfEntry`:** needed for the RECENT sort on My shelf. Check that the schema exposes it.
+
+## Changelog
+
+- v1 (29.09.2026): created by Ilse.
+- v2 (date): `Book` type aligned with the backend model. CSS approach decided. Added decisions 12 and 13, the routes table, and more open questions.
\ No newline at end of file
diff --git a/docs/styles_documentation.md b/docs/styles_documentation.md
new file mode 100644
index 0000000..c6f6acd
--- /dev/null
+++ b/docs/styles_documentation.md
@@ -0,0 +1,33 @@
+# Rules and information regarding styles, design, components
+
+## 1. Folder structure
+```
+src/
+βββ styles/ tokens.css, reset.css, base.css, index.css
+βββ components/
+β βββ Button/ 3 variants used throughout app
+β βββ BookCover/ image, fallback, color pick
+β βββ BookCard/ BookCard + ribbon + starsdisplay
+β βββ BookGrid/ render auto grid of BookCard, with shelf option
+β βββ SearchField/ draft state, submit on Enter or button
+β βββ FilterPanel/ genre, year, clear, mobile toggle
+β βββ RatingInput/ radio group styled as stars
+β βββ MyNotesPanel/ empty / saved / editing
+β βββ StateMessage/ empty, error, (loading)
+β βββ AppShell/ shell + header + nav + footer
+βββ pages/
+ βββ BrowsePage/ includes "Load more" + results count
+ βββ BookDetailPage/ includes hero, facts, description, ribbon, tags
+ βββ ShelfPage/ includes page heading + sort
+```
+
+- Global CSS holds only tokens and element defaults.
+- Every component owns its own `.module.css` and reads only `var(--token)` values, never raw hex.
+
+## Global CSS
+|File|Purpose|Rules|
+|----|-------|-----|
+|index.css|Imports the three below in order (tokens, reset, base)|No rules of its own. Order matters because base uses tokens|
+|tokens.css|Design decisions, as CSS variables|Only :root{--...}, with no selectors or layout. The only place for raw hex/px values. Name by role (`--color-accent`), not appearance (`--color-purple`). Add a token only when it's a real decision or reused. Keep it tidy and readable!|
+|reset.css|Removes browser inconsistencies (box-sixing, margins, image behavior)|Tiny and generic. No colors or fonts, since "brand" values belong to tokens|
+|base.css|Default look of plain HTML elements: body, headings, links, focus ring, `.visually-hidden`|Element selectors plus a few utilities only. If you write a specific class here, it belongs in a component module instead!|