diff --git a/README.md b/README.md
index 9473ce2..631877e 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,20 @@ The calculator and About pages are placeholders. Adding items from the catalog t
### How to try it
- **VM:** connect to the NTNU network or NTNU VPN, then open the [running application](http://it2810-29.idi.ntnu.no/project2). Click **Log in** in the top navigation to expose the Gymnasts route, open **Your gymnasts**, and use the **Log in as test coach** form with any email address. The navigation Log in control is only a demo gate; the test login form performs the API login. Anyone can log in using any email, so use a non-sensitive test address and do not enter real or sensitive data. See [Test login on the virtual machine](docs/VM_SETUP.md#6-test-login-on-the-virtual-machine).
-- **Locally:** follow [Getting started](#getting-started). To try the API-backed pages, also configure PostgreSQL and the development test login as described in [Database (local setup)](#database-local-setup) and [Login for development in docs/API.md](docs/API.md#login-for-development).
+- **Locally:** follow [Getting started](#getting-started). To try the API-backed pages, also configure PostgreSQL and the development test login as described in [Database (local setup)](docs/DATABASE_SETUP.md) and [Login for development in docs/API.md](docs/API.md#login-for-development).
+
+## Where is what
+
+| What | Where it starts | More |
+| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
+| The catalog search in the database | `backend/src/catalog/search.ts` (words in `words.ts`, sorts in `sorts.ts`, cursors in `cursor.ts`), called by `backend/src/server/resolvers/catalog.ts` | [Catalog search](docs/CATALOG_SEARCH.md), [API](docs/API.md) |
+| The Elements page (search, filters, sort) | `src/pages/ElementsPage.tsx`, with the query in `src/lib/searchOperations.ts` | [The Elements page](docs/ELEMENTS_PAGE.md) |
+| The element page | `src/pages/ElementDetailPage.tsx`, `src/components/elements/` | |
+| The gymnast pages | `src/pages/YourGymnastsPage.tsx`, `src/pages/GymnastPage.tsx`, `src/components/gymnasts/`, and `backend/src/server/resolvers/userData.ts` | [State management](docs/STATE_MANAGEMENT.md) |
+| Login and sessions | `src/components/auth/`, `src/lib/apollo/session.ts`, `backend/src/auth/` | [Authentication](docs/AUTHENTICATION.md) |
+| The D-score of a routine | `backend/src/dscore.ts` | [API](docs/API.md) |
+| The database schema and the seed | `backend/prisma/`, `backend/src/seed/` | [Data model](docs/DATA_MODEL.md), [Database setup](docs/DATABASE_SETUP.md) |
+| The tests | `src/tests/`, next to the code in `backend/src/`, and `e2e/` | [Where the tests are](#where-the-tests-are), [End-to-end tests](docs/E2E_TESTS.md) |
## The dataset
@@ -225,188 +238,11 @@ fetchMore({ variables: { after: data.searchCatalog.pageInfo.endCursor } })
## The Elements page: search, filters and sort
-The Elements page (`/elements`, `src/pages/ElementsPage.tsx`) is where a coach searches the catalog. It sends one `searchCatalog` request with the search text, the filters and the sort, and shows the result as a table (cards on a phone) with "Load more elements". The search, the filters and the sort are all in the address (`?search=salto&apparatus=UB&group=UB-6&difficulty=A,B&sort=difficulty&dir=desc`), so a result can be shared and the back button works.
-
-### What the user can do
-
-- **Search** for words in the code, name or description (see [Catalog search](#catalog-search)). The request is sent when the user has stopped typing for 300 ms, or at once on Enter.
-- **Filter** by apparatus, by element group of the chosen apparatus, and by difficulty. Several difficulty values can be chosen. "Clear filters" takes them all away.
-- **Sort** by code, difficulty or name, in both directions.
-- Everything can be combined, and a change of any of them starts again from the first page. Filtering, sorting and counting run on the server over the whole catalog, never only on what the client has loaded (a requirement of the assignment).
-
-### Choices and why
-
-- **Ready-made parts only.** The menus are the Base UI `Menu` and `Tooltip` that shadcn/ui is built on (already a dependency), so the arrow keys, Enter, Space, Escape, the type-ahead and "checked" are handled by a library that is tested for it. The address is `useSearchParams` from React Router. The Apollo cache already keeps one list per `search`, `filter` and `sort` (`keyArgs` in `src/lib/apollo/cache.ts`), so a new filter is a new list and "Load more" adds to the right one.
-- **Scales with the catalog.** What the filters choose from is small and fixed: the 4 apparatus with their groups and difficulty values, fetched once (`CatalogStructure`, kept by the Apollo cache and by the server in memory for ten minutes) and the letters A-J. Nothing in the interface grows with the number of elements, and no filter or sort option is read from the results.
-- **Difficulty is a letter for elements and a band for vault.** An element has a value from 0.1 to 1.0, which the coach knows as a letter (A = 0.1 ... J = 1.0). A vault has a D-value from 1.6 to 6.4 and no letter. There are 23 different D-values, which is too many to choose between one by one, so the menu offers bands of one whole number (2.0-2.9, 3.0-3.9 ...). A band is sent to the API as the D-values that exist inside it, which `Apparatus.difficultyValues` lists, so the API needs no ranges. Only bands that have a vault are offered, and the chosen difficulty is dropped when the apparatus changes between vault and the others, because the scales differ.
-- **The sort is one list of outcomes.** "Code: low to high", "Difficulty: high to low", "Name: A to Z" ... instead of a field and a separate "Ascending"/"Descending". Practitioner guidance on sort menus (for example Vitaly Friedman's) is to name what the user gets and not the jargon, and the ARIA Authoring Practices put the sort state on the column header (`aria-sort`) for tables. Difficulty needs an apparatus, because element values and vault D-scores cannot be compared (see "Why difficulty is only sorted within an apparatus" in [docs/API.md](docs/API.md)), so its two choices are disabled until one is chosen, and the menu says why. On vault the difficulty sort is by the D-score.
-- **"Element group" is a section of the table of elements** (for example "1.000 Mounts" on uneven bars; `ElementGroup` in the database), not one of the five composition requirements of the Code of Points that are worth 0.5 each. The first digit of an element's code is the number of its group. A vault group has a long title, so it is shown as "Group N", as in the results list.
-- **The address is cleaned before it is sent.** A filter that the API would refuse (an apparatus or group that does not exist, a group of another apparatus, a difficulty that is not one of the options) is left out, so an old or edited link never gives `BAD_USER_INPUT`. An address with a filter waits for the structure; if the structure cannot be loaded the page says so and does not search, because searching without the filters would show results that do not match the address.
-- **Each change is built on the last one.** Two quick choices in the difficulty menu both count. React Router's function form of `setSearchParams` only sees the address of the last render, so the page keeps the latest address in a ref.
-
-### Accessibility
-
-- **A filter that cannot be used is disabled, but still reachable.** The group filter before an apparatus is chosen uses `aria-disabled` and not the `disabled` attribute: a disabled button is skipped by the keyboard, and then nobody can find out why it is disabled. The reason is a tooltip (on hover, on keyboard focus, and when the button is pressed, because a tooltip does not open on a tap) and also its `aria-describedby`, so a screen reader says it. This is the pattern that PatternFly, Salt and Vivid recommend for a disabled control that has to explain itself.
-- **Each button says its name and its choice** ("Apparatus: Uneven bars", "Difficulty: A, B"), the menus have names, a chosen item is `aria-checked`, and the focus goes back to the button when a menu closes. "Clear filters" removes itself, so it moves the focus to the first filter.
-- **The number of results is announced** (`role="status"`, "Showing 20 of 178"), and the results section is `aria-busy` while it loads.
-- **Every page has a title** that says what the page is (`PageTitle`, "Elements – WAG CoP Planner"; WCAG 2.4.2). React 19 moves a `
` to the head wherever it is rendered, so no library is needed. The element page names the element and the apparatus, also while it is loading or has failed.
-- **Checked** with axe-core (no violations in light and dark mode, with the menus open and at phone width), Lighthouse (100), keyboard-only use, contrast (about 15.9:1), touch target sizes, reflow at 320 px, text spacing and reduced motion. **Not checked** with a screen reader such as NVDA or VoiceOver: that has to be done by a person.
-
-### Testing
-
-- `src/tests/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it.
-- The backend tests (`backend/src/catalog/search.test.ts`, `backend/src/server/server.test.ts`) cover the vault filter, the range of the values and `Apparatus.difficultyValues`, and need the seeded database like the rest.
-- Not everything can be tested in jsdom (for example the tooltip, which Base UI opens from real pointer events). Those parts were checked in a real browser against the real backend.
-
-### Known limits
-
-- The tooltip and the focus behavior of the menus are Base UI's. Axe lists their internal focus guards and `aria-controls` as "needs review", and reports the portal of an open menu as "content not in a landmark" (best practice, not a WCAG failure).
-- The backend has to be updated together with the frontend: the page asks for `Apparatus.difficultyValues`, which an older backend does not have, and then the filters cannot be loaded.
+The search text, the filters and the sort are kept in the address, so a result can be shared and the back button works. What the user can do, the choices and their reasons, the accessibility and the limits are in [docs/ELEMENTS_PAGE.md](docs/ELEMENTS_PAGE.md).
## Database (local setup)
-The backend uses PostgreSQL 18. Everyone installs it directly on their own machine, without Docker, and everyone uses version 18 so that migrations behave the same for all of us.
-
-### 1. Install PostgreSQL 18
-
-**Mac**, with one of these:
-
-- **Postgres.app** (recommended): download it from [postgresapp.com](https://postgresapp.com/), move it to Applications and open it. Choose PostgreSQL 18 if you are asked, and click _Initialize_. To use `psql` in the terminal, run this once and open a new terminal:
-
- ```bash
- sudo mkdir -p /etc/paths.d && echo /Applications/Postgres.app/Contents/Versions/latest/bin | sudo tee /etc/paths.d/postgresapp
- ```
-
-- **Homebrew:**
-
- ```bash
- brew install postgresql@18
- brew services start postgresql@18
- ```
-
-**Windows:** download the installer from EDB through [postgresql.org/download/windows](https://www.postgresql.org/download/windows/) and choose version 18. Keep the default port 5432, and write down the password you choose for the `postgres` user. `psql` is available as _SQL Shell (psql)_ in the Start menu.
-
-### 2. Create a user and a database
-
-Open `psql` as the administrator:
-
-- Mac: `psql postgres`
-- Windows: _SQL Shell (psql)_, log in as `postgres`
-
-Then create a user and a database, both called `cop`. Choose your own password:
-
-```sql
-CREATE USER cop WITH PASSWORD 'choose-a-password' CREATEDB;
-CREATE DATABASE cop OWNER cop;
-```
-
-The user needs `CREATEDB` because `prisma migrate dev` creates a temporary _shadow database_ to check the migrations, and deletes it again afterwards.
-
-### 3. Connect the backend to the database
-
-Copy the example file and fill in your password:
-
-```bash
-cp backend/.env.example backend/.env
-```
-
-On Windows: `copy backend\.env.example backend\.env`.
-
-Open `backend/.env` and replace `your-password` in `DATABASE_URL`. The format is `postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public`. The file is ignored by Git, so your password is never committed.
-
-### 4. Install and run
-
-From the repository root:
-
-```bash
-npm ci
-npm run generate
-npm run db:migrate
-npm run db:seed
-```
-
-`npm run db:migrate` creates the tables in your database by applying the migrations in `backend/prisma/migrations/`. Run it again whenever someone has added a new migration.
-
-| Command | What it does |
-| ----------------------------- | ----------------------------------------------------------------------------------------------- |
-| `npm run generate` | Runs both generators below. `typecheck` and `build` in `backend` run it first |
-| `npm run db:generate` | Generates the Prisma client from `backend/prisma/schema.prisma` |
-| `npm run codegen` | Generates the TypeScript types for the resolvers from `backend/src/schema.graphql` |
-| `npm run db:migrate` | Creates a migration from changes in the schema and applies it to your database |
-| `npm run db:deploy` | Applies existing migrations without creating new ones. Used on the server |
-| `npm run db:seed` | Loads the catalog from `data/` into the database. See "Seeding" below |
-| `npm run db:synthetic -- add` | Adds 100 000 fake items for scalability tests. `remove` deletes them. See "Synthetic test data" |
-| `npm run db:studio` | Opens Prisma Studio in the browser, to look at the data in the database |
-
-### 5. Check the connection
-
-To check that the backend can reach your database:
-
-1. Make sure PostgreSQL is running. In Postgres.app the server should show as running. With Homebrew, run `brew services start postgresql@18`. On Windows the service starts automatically.
-2. Open a terminal in the repository root and go to the backend folder:
-
- ```bash
- cd backend
- ```
-
-3. Run:
-
- ```bash
- npx prisma migrate status
- ```
-
-4. Look for these lines in the output. The first one means that the connection works, and the last one that your tables are up to date:
-
- ```
- Datasource "db": PostgreSQL database "cop", schema "public" at "localhost:5432"
- ...
- Database schema is up to date!
- ```
-
-5. Go back to the repository root:
-
- ```bash
- cd ..
- ```
-
-If the output says "Following migration have not yet been applied", the connection works but your tables are missing or out of date. Go back to the repository root and run `npm run db:migrate`. If the connection fails, Prisma prints an error such as "Can't reach database server" or "Authentication failed" instead.
-
-### 6. Seeding
-
-The seed script loads the catalog (apparatus, element groups, variant groups, elements and vaults) from the JSON files in `data/` into your database, and rebuilds the search tables. Run it from the repository root:
-
-```bash
-npm run db:seed
-```
-
-Run it again:
-
-- **after migrations**, when you have run `npm run db:migrate` or `npm run db:deploy`
-- **whenever `data/` changes**, for example after a new run of the extraction script
-
-It is safe to run as often as you like:
-
-- **Everything happens in one transaction.** If something fails, nothing is changed.
-- **A second run with the same data changes nothing.** It prints a table of what was inserted, updated, marked as removed and deleted.
-- **It checks the data first.** If something in `data/` breaks a rule in `docs/DATA_MODEL.md`, for example a variant group with different codes or a missing image, it stops with a message that names the row and the rule, and changes nothing.
-- **It never touches user data.** Coaches, gymnasts, routines and their lists are left alone.
-- **Removed elements are kept while they are in use.** An element or vault that is no longer in `data/` is marked as removed. It is deleted only if no routine or gymnast list uses it.
-
-The code is in `backend/src/seed/`. The rules for search words, including the written-out abbreviations such as `handstand` for `hstd`, are in `backend/src/catalog/words.ts`. The search uses the same rules.
-
-### 7. Look at the data
-
-To see what is in your database, open Prisma Studio from the repository root:
-
-```bash
-npm run db:studio
-```
-
-The terminal prints the address, for example "Prisma Studio is running at: http://localhost:51212". The port can change between runs. Open the address in your browser if it does not open by itself. The page has a list of all the tables. Click a table to see its rows, filter and sort them, and follow the relations, for example from an element to its variant group or from a routine to its items. Stop it with Ctrl+C in the terminal.
-
-Prisma Studio uses the connection in `backend/.env`, so there is nothing more to set up. You can also edit and delete rows there, so be careful: changes to the catalog are overwritten the next time you run `npm run db:seed`, and changes to user data are not undone.
-
-To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (on Windows, use _SQL Shell (psql)_) and enter the password from `backend/.env`. The tables have snake_case names in the database, such as `catalog_entries` and `element_group_id`. `\dt` lists the tables, `\d elements` shows the columns of a table, and `\q` quits.
+The backend needs PostgreSQL 18, two database users (one that migrates and one that the app uses), a `backend/.env`, the migrations and the seed. The steps, in order, are in [docs/DATABASE_SETUP.md](docs/DATABASE_SETUP.md).
## GraphQL server
@@ -486,107 +322,11 @@ Everyone who uses the demo coach shares the same data, and anyone can log in as
## Catalog search
-The catalog search is in `backend/src/catalog/search.ts`. It finds elements and vaults by the start of their words, with filters, sorting and cursor pagination, using Prisma only. How it works and why is described in "Search without raw SQL" in [docs/API.md](docs/API.md#search-without-raw-sql).
-
-The tests in `backend/src/catalog/` run with `npm test`. The search tests use your local database and need the seeded catalog, so run `npm run db:seed` first. They only read from the database.
-
-**Without `DATABASE_URL` in `backend/.env`, the database tests are skipped.** Vitest then reports them as skipped and the run still passes, so a passing run without a database does not mean the search was tested. Check that the output says 0 skipped.
-
-### Synthetic test data and measurements
-
-The real catalog has about 560 items, which is too small to see whether the search scales. `npm run db:synthetic` adds fake items for that:
-
-```bash
-npm run db:synthetic -- add # 100 000 items (use --count for another number)
-npm run db:synthetic -- remove # deletes all synthetic data again
-```
-
-The items copy descriptions from the real catalog, so the words are spread like in real data. They are spread over the four apparatus (vault items on VT, elements on the others), about 5 % have a name, and the elements have a difficulty value from 0.1 to 1.0. Every id starts with `SYN-` and every item is in a synthetic group of its own (`SYN-UB-1001` and so on), so the real catalog is never mixed up with them. The same input gives the same rows. `npm run db:seed` never adds synthetic data, and it leaves the `SYN-` rows alone. `add` stops if synthetic data already exists, and runs in one transaction, so it either adds everything or nothing. `remove` deletes only rows with a `SYN-` id. Use a local database, never the one on the virtual machine.
-
-**Measured on 2026-10-17** with 100 562 catalog entries and 1 415 215 words (562 entries are real), PostgreSQL 18 in a Docker container on a laptop, after `ANALYZE`. The time is the median of 7 calls to `searchCatalog` from Node, including Prisma, with 20 items per page and without `totalCount`. "Without indexes" means that all indexes on `catalog_entries` and `catalog_words` except the primary keys were dropped (and created again afterwards).
-
-| Query | With indexes (ms) | Without indexes (ms) |
-| --------------------------------- | ----------------: | -------------------: |
-| browse, sort CODE | 1.6 | 20.8 |
-| browse, sort NAME | 1.2 | 20.2 |
-| browse, sort DIFFICULTY (UB) | 1.1 | 12.6 |
-| filter apparatus UB | 1.4 | 12.5 |
-| filter difficulty 0.1/0.2 (BB) | 1.1 | 12.6 |
-| search "a" (one letter) | 1.6 | 48.2 |
-| search "t" (one letter) | 1.4 | 47.7 |
-| search "forward" (common word) | 1.3 | 48.0 |
-| search "kip" + UB | 10.1 | 35.1 |
-| search "forward turn" (two words) | 1.8 | 52.1 |
-| search "syn5003" (rare word) | 1.1 | 29.6 |
-| search "1001.5" (code) | 4.6 | 48.7 |
-
-Pages far into a result (100 items per page; the cursor for page N comes from following the pages before it):
-
-| Query | Page | With indexes (ms) | Without indexes (ms) |
-| ----------------- | ---: | ----------------: | -------------------: |
-| browse, sort CODE | 1 | 1.5 | 18.9 |
-| browse, sort CODE | 101 | 2.2 | 39.2 |
-| browse, sort CODE | 501 | 1.9 | 34.5 |
-| search "a" | 1 | 1.8 | 49.8 |
-| search "a" | 101 | 4.0 | 75.7 |
-| search "a" | 501 | 1.5 | 31.1 |
-
-`EXPLAIN (ANALYZE, BUFFERS)` on the queries Prisma sends shows that the indexes are used:
-
-- **Page 501 of the list sorted by code:** an index scan on `catalog_entries_code_sort_id_idx` reads 99 rows and takes 0.11 ms. A page far into the list costs the same as the first page, because the cursor is a condition on the index and nothing is skipped (no `OFFSET`).
-- **A one-letter search ("a"):** an index scan on `catalog_entries_code_sort_id_idx` walks the entries in order and checks each one against `catalog_words` through its primary key (63 entries checked for 20 results). It stops as soon as the page is full, so the search does not read all 1.4 million words.
-- **Without the indexes** the same queries read the whole table: 12–52 ms for a page, and 30–76 ms for a page far into a result, and the time grows with the number of items.
-
-The tables take 63 MB (`catalog_entries`) and 222 MB (`catalog_words`) with their indexes, 185 MB of it in indexes. That is the price of the speed, at this size. Adding the synthetic data takes about 70 seconds for 100 000 items.
-
-**Reading the numbers.** These are one machine and one run, so use them to compare, not as promises. The slowest case with indexes is a search for a common word together with a filter ("kip" on uneven bars, 10 ms), where PostgreSQL has to check many words. A one-letter search that is only a trailing "s" ("s") finds nothing on purpose, because the trailing "s" is removed from a search word.
-
-Run `ANALYZE` after adding or removing many rows, or PostgreSQL plans from old statistics (`npm run db:synthetic` does not run it). To repeat the measurement, call `searchCatalog` from a small script and use `EXPLAIN (ANALYZE, BUFFERS)` on the queries Prisma logs (`log: [{ emit: 'event', level: 'query' }]`). The application code never uses raw SQL, but `EXPLAIN` is typed by hand in `psql`.
+The search is done in PostgreSQL through Prisma (no raw SQL), with words matched by prefix and cursor pagination. How it works, why, and the measurements on synthetic data are in [docs/CATALOG_SEARCH.md](docs/CATALOG_SEARCH.md). The code is in `backend/src/catalog/` (see "Where is what" above).
## End-to-end tests
-The assignment asks for "a form of automated end-to-end testing" of "a longer sequence of interactions", and for testing of the API. The component tests (Vitest) and the API tests in `backend/src/server/` already exist; the end-to-end tests drive the **whole application in a real browser**, against the real backend and the real database, the way a user does. They are in `e2e/` and use [Playwright](https://playwright.dev), with [axe-core](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright) for the accessibility checks.
-
-| File | What it tests |
-| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `e2e/elements-flow.e2e.ts` | One long flow: search, filter by apparatus and group, "Load more", sort, open an element, go back, "Clear filters". It checks the address, the results, the title and the focus at each step. |
-| `e2e/vault.e2e.ts` | The vault difficulty bands, and sorting vault by its D-score in both directions. |
-| `e2e/atypical.e2e.ts` | Atypical use: an address with filters that do not exist, a search without a result, the 80-character limit, a structure that fails to load (the request is made to fail), the disabled group filter, and keyboard-only use. |
-| `e2e/accessibility.e2e.ts` | axe-core on the page in its main states: filtered and sorted, a menu open, the reason for a disabled filter shown, vault, no result, dark mode, a phone (375 px) and reflow at 320 px. A violation fails the test. |
-| `e2e/api.e2e.ts` | The GraphQL API over HTTP: search, filter, sort, cursors, the error codes and `Apparatus.difficultyValues`. The same requests can be sent to the virtual machine. |
-| `e2e/gymnast-page.e2e.ts` | The Gymnast page and "Your gymnasts" logged in as a test coach: axe in light and dark mode, on a phone and with the remove dialog open; no sideways scrolling at 280, 320, 360 and 390 px (also with long names and the text spacing of WCAG 1.4.12); the size of what is pressed on a phone; the tabs with the arrow keys, Home and End; where the focus goes around the remove dialog; and the colors of a row and of the trash icon under the pointer. |
-| `e2e/element-page.e2e.ts` | The page of an element and of a vault: axe in light and dark mode and on a phone, and "Add to gymnast" (a link to the login for a visitor, a menu of the gymnasts for a coach, and the element in the list afterwards). |
-| `e2e/gymnast-helpers.ts` | Not a test: the login through the API, the test data, axe and the colors as the browser works them out, for the two files above. |
-
-### How to run them
-
-They need the database to be running and seeded (see "Database (local setup)" above), and a browser:
-
-```bash
-npm ci
-npm run generate # the Prisma and GraphQL types, once
-npx playwright install chromium # the browser, once (about 150 MB)
-npm run e2e
-```
-
-`npm run e2e` starts the backend and the frontend itself (and uses them if they are already running), runs the tests, and prints the result. `npm run e2e:report` opens the report with a screenshot and a trace of every test that failed. The tests of the catalog only read it. The tests of the gymnast pages log in through the API (`devLogin`) as a test coach of their own (`e2e-gymnast-page@example.test`, `e2e-element-page@example.test`), make gymnasts for it, and delete them at the start and at the end, so all of them can be run again and again. The backend that the tests start has `DEV_LOGIN` turned on for this (`playwright.config.ts`); an address given with `E2E_BASE_URL` must have it too (the virtual machine has, see `docs/VM_SETUP.md`).
-
-Settings, all optional, as environment variables:
-
-| Variable | Meaning |
-| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `E2E_BASE_URL` | An address that is already running, for example `http://it2810-29.idi.ntnu.no/project2`. Nothing is started, and the same tests check the real deployment (including the Apache proxy of `/project2/graphql`). |
-| `E2E_FRONTEND_PORT`, `E2E_BACKEND_PORT` | The ports that the tests start the app on, 5173 and 4000 by default. Change them if another program uses them, or the tests would use that program. |
-| `E2E_CHANNEL` | A browser that is already installed, for example `msedge`, instead of the Chromium that Playwright downloads. |
-| `E2E_API_URL` | The GraphQL address of `e2e/api.e2e.ts`, if it is not the one that follows from the others. |
-
-### Choices and limits
-
-- **Playwright, not Cypress.** It is made for end-to-end tests, drives real Chromium, waits for the page by itself (a test says what it expects, and Playwright retries until it is true), and has an official axe-core integration. The tests are TypeScript like the rest of the project, and the files end in `.e2e.ts` so that Vitest does not pick them up.
-- **The tests depend on the seeded catalog** (the data in `data/`). They avoid exact numbers where a relation is enough ("fewer than the whole catalog", "every code starts with the number of the group"), but they do assume that uneven bars, floor and vault exist with their groups and difficulty values.
-- **A test that has never failed is not known to work.** The tests of the gymnast pages were checked by breaking the code on purpose (the grid column that made a page wider than a phone, the dark hover color, the size of the trash button, the dark background of the element page, the arrow keys of the tabs, and the focus after the dialog) and seeing the right test fail. A new test of this kind should be checked the same way.
-- **The accessibility tests are automated checks, not a full audit.** axe-core finds what a program can find. What needs a person, such as a screen reader (NVDA, VoiceOver) and whether the order of the focus makes sense, has to be tested by hand. `region` is turned off for a state with a menu open: the menus are in a portal (the Base UI default), which axe reports as best practice, not as a WCAG failure.
-- **They are not run by a server on every pull request.** The course git server has no runners (see issue #76), so they are run by the person who makes the change, and before each delivery against the virtual machine with `E2E_BASE_URL`.
+`npm run e2e` starts the backend and the frontend and runs the Playwright tests in `e2e/` (whole flows, accessibility with axe, and the API over HTTP). How to run them, what each file checks, and their limits are in [docs/E2E_TESTS.md](docs/E2E_TESTS.md).
## Project structure
diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md
index 3e7719c..44e324f 100644
--- a/docs/AI_USAGE.md
+++ b/docs/AI_USAGE.md
@@ -589,3 +589,10 @@
- Changed outside the Gymnast page: the element page (colors, the badge, "Try in a routine", "Add to gymnast"), "Your gymnasts" (the wrapping of the title and the list), `variables.css` (`--card-hover`), and two shared parts that other pages use too: `Button` (the `icon` prop, which changes nothing when it is not given) and `DifficultyBadge` (its colors now follow the theme, also in the results of the Elements page).
- Not done on purpose: moving the error messages of the other pages (the elements results, the element page, the login, "Your gymnasts") to the three layers, because those files belong to other pull requests and the change would conflict with them. The menus of the Elements page still have their own copies of the menu classes that `menuStyles.ts` now holds.
- **Limits:** The end-to-end tests need the database and the backend, and are run by hand: the course git server has no runners. A screen reader was not used: that has to be done by a person. Lighthouse was started on a production build in the last round, but the run did not finish (the computer was low on memory), so there are no Lighthouse scores from that round. "Your gymnasts" was fixed after the last check in a browser and has not been measured again, and the results table of the Elements page, which uses the changed badge, has not been looked at in dark mode. The test gymnast was made with the test login in the local database and deleted again.
+
+## A shorter README (`README.md`, `docs/`)
+
+- **Tool:** Claude Code
+- **What the team did:** Took the peer feedback that the README (about 59 kB) is too long to find your way in, wrote issue #182, and asked for the reasoning to be kept.
+- **What Claude did:** Moved four long sections unchanged to their own documents (`ELEMENTS_PAGE.md`, `DATABASE_SETUP.md`, `CATALOG_SEARCH.md`, `E2E_TESTS.md`) with a short summary and a link in the README (now about half the size). Added a "Where is what" table that points from each feature to the file where it starts, and a list of contents at the top of `docs/API.md`. A script checked that every relative link and anchor in the changed files works.
+- **Limits:** The text of the moved sections was not shortened or checked again, so what was outdated in them is still outdated (that is issue #148). A first run of the move emptied `README.md` because a formatting command was cut off in a pipe; it was restored from git and run again.
diff --git a/docs/API.md b/docs/API.md
index 9ff9193..d514799 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -4,6 +4,8 @@ This document describes the API between the React client and the backend: why we
The schema itself is in [`backend/src/schema.graphql`](../backend/src/schema.graphql). It is a draft: the backend is not implemented yet. The data behind it is described in [DATA_MODEL.md](DATA_MODEL.md).
+**Contents:** [Why GraphQL](#why-graphql-and-not-rest) · [The schema and the database](#the-schema-and-the-database-are-different) · [Overview of the schema](#overview-of-the-schema) · [Search, filter, sort and pagination](#search-filter-sort-and-pagination) · [Scalability](#scalability) · [Images](#images) · [Identifying the user](#identifying-the-user) · [Error handling](#error-handling) · [The server](#the-server) · [Tools](#tools) · [Configuration](#configuration) · [Testing the API](#testing-the-api) · [Open questions](#open-questions)
+
## Why GraphQL and not REST
GraphQL is required by the assignment, but it also suits this application:
diff --git a/docs/CATALOG_SEARCH.md b/docs/CATALOG_SEARCH.md
new file mode 100644
index 0000000..37103e6
--- /dev/null
+++ b/docs/CATALOG_SEARCH.md
@@ -0,0 +1,58 @@
+# Catalog search
+
+The catalog search is in `backend/src/catalog/search.ts`. It finds elements and vaults by the start of their words, with filters, sorting and cursor pagination, using Prisma only. How it works and why is described in "Search without raw SQL" in [docs/API.md](./API.md#search-without-raw-sql).
+
+The tests in `backend/src/catalog/` run with `npm test`. The search tests use your local database and need the seeded catalog, so run `npm run db:seed` first. They only read from the database.
+
+**Without `DATABASE_URL` in `backend/.env`, the database tests are skipped.** Vitest then reports them as skipped and the run still passes, so a passing run without a database does not mean the search was tested. Check that the output says 0 skipped.
+
+## Synthetic test data and measurements
+
+The real catalog has about 560 items, which is too small to see whether the search scales. `npm run db:synthetic` adds fake items for that:
+
+```bash
+npm run db:synthetic -- add # 100 000 items (use --count for another number)
+npm run db:synthetic -- remove # deletes all synthetic data again
+```
+
+The items copy descriptions from the real catalog, so the words are spread like in real data. They are spread over the four apparatus (vault items on VT, elements on the others), about 5 % have a name, and the elements have a difficulty value from 0.1 to 1.0. Every id starts with `SYN-` and every item is in a synthetic group of its own (`SYN-UB-1001` and so on), so the real catalog is never mixed up with them. The same input gives the same rows. `npm run db:seed` never adds synthetic data, and it leaves the `SYN-` rows alone. `add` stops if synthetic data already exists, and runs in one transaction, so it either adds everything or nothing. `remove` deletes only rows with a `SYN-` id. Use a local database, never the one on the virtual machine.
+
+**Measured on 2026-10-17** with 100 562 catalog entries and 1 415 215 words (562 entries are real), PostgreSQL 18 in a Docker container on a laptop, after `ANALYZE`. The time is the median of 7 calls to `searchCatalog` from Node, including Prisma, with 20 items per page and without `totalCount`. "Without indexes" means that all indexes on `catalog_entries` and `catalog_words` except the primary keys were dropped (and created again afterwards).
+
+| Query | With indexes (ms) | Without indexes (ms) |
+| --------------------------------- | ----------------: | -------------------: |
+| browse, sort CODE | 1.6 | 20.8 |
+| browse, sort NAME | 1.2 | 20.2 |
+| browse, sort DIFFICULTY (UB) | 1.1 | 12.6 |
+| filter apparatus UB | 1.4 | 12.5 |
+| filter difficulty 0.1/0.2 (BB) | 1.1 | 12.6 |
+| search "a" (one letter) | 1.6 | 48.2 |
+| search "t" (one letter) | 1.4 | 47.7 |
+| search "forward" (common word) | 1.3 | 48.0 |
+| search "kip" + UB | 10.1 | 35.1 |
+| search "forward turn" (two words) | 1.8 | 52.1 |
+| search "syn5003" (rare word) | 1.1 | 29.6 |
+| search "1001.5" (code) | 4.6 | 48.7 |
+
+Pages far into a result (100 items per page; the cursor for page N comes from following the pages before it):
+
+| Query | Page | With indexes (ms) | Without indexes (ms) |
+| ----------------- | ---: | ----------------: | -------------------: |
+| browse, sort CODE | 1 | 1.5 | 18.9 |
+| browse, sort CODE | 101 | 2.2 | 39.2 |
+| browse, sort CODE | 501 | 1.9 | 34.5 |
+| search "a" | 1 | 1.8 | 49.8 |
+| search "a" | 101 | 4.0 | 75.7 |
+| search "a" | 501 | 1.5 | 31.1 |
+
+`EXPLAIN (ANALYZE, BUFFERS)` on the queries Prisma sends shows that the indexes are used:
+
+- **Page 501 of the list sorted by code:** an index scan on `catalog_entries_code_sort_id_idx` reads 99 rows and takes 0.11 ms. A page far into the list costs the same as the first page, because the cursor is a condition on the index and nothing is skipped (no `OFFSET`).
+- **A one-letter search ("a"):** an index scan on `catalog_entries_code_sort_id_idx` walks the entries in order and checks each one against `catalog_words` through its primary key (63 entries checked for 20 results). It stops as soon as the page is full, so the search does not read all 1.4 million words.
+- **Without the indexes** the same queries read the whole table: 12–52 ms for a page, and 30–76 ms for a page far into a result, and the time grows with the number of items.
+
+The tables take 63 MB (`catalog_entries`) and 222 MB (`catalog_words`) with their indexes, 185 MB of it in indexes. That is the price of the speed, at this size. Adding the synthetic data takes about 70 seconds for 100 000 items.
+
+**Reading the numbers.** These are one machine and one run, so use them to compare, not as promises. The slowest case with indexes is a search for a common word together with a filter ("kip" on uneven bars, 10 ms), where PostgreSQL has to check many words. A one-letter search that is only a trailing "s" ("s") finds nothing on purpose, because the trailing "s" is removed from a search word.
+
+Run `ANALYZE` after adding or removing many rows, or PostgreSQL plans from old statistics (`npm run db:synthetic` does not run it). To repeat the measurement, call `searchCatalog` from a small script and use `EXPLAIN (ANALYZE, BUFFERS)` on the queries Prisma logs (`log: [{ emit: 'event', level: 'query' }]`). The application code never uses raw SQL, but `EXPLAIN` is typed by hand in `psql`.
diff --git a/docs/DATABASE_SETUP.md b/docs/DATABASE_SETUP.md
new file mode 100644
index 0000000..23b8819
--- /dev/null
+++ b/docs/DATABASE_SETUP.md
@@ -0,0 +1,144 @@
+# Database (local setup)
+
+The backend uses PostgreSQL 18. Everyone installs it directly on their own machine, without Docker, and everyone uses version 18 so that migrations behave the same for all of us.
+
+## 1. Install PostgreSQL 18
+
+**Mac**, with one of these:
+
+- **Postgres.app** (recommended): download it from [postgresapp.com](https://postgresapp.com/), move it to Applications and open it. Choose PostgreSQL 18 if you are asked, and click _Initialize_. To use `psql` in the terminal, run this once and open a new terminal:
+
+ ```bash
+ sudo mkdir -p /etc/paths.d && echo /Applications/Postgres.app/Contents/Versions/latest/bin | sudo tee /etc/paths.d/postgresapp
+ ```
+
+- **Homebrew:**
+
+ ```bash
+ brew install postgresql@18
+ brew services start postgresql@18
+ ```
+
+**Windows:** download the installer from EDB through [postgresql.org/download/windows](https://www.postgresql.org/download/windows/) and choose version 18. Keep the default port 5432, and write down the password you choose for the `postgres` user. `psql` is available as _SQL Shell (psql)_ in the Start menu.
+
+## 2. Create a user and a database
+
+Open `psql` as the administrator:
+
+- Mac: `psql postgres`
+- Windows: _SQL Shell (psql)_, log in as `postgres`
+
+Then create a user and a database, both called `cop`. Choose your own password:
+
+```sql
+CREATE USER cop WITH PASSWORD 'choose-a-password' CREATEDB;
+CREATE DATABASE cop OWNER cop;
+```
+
+The user needs `CREATEDB` because `prisma migrate dev` creates a temporary _shadow database_ to check the migrations, and deletes it again afterwards.
+
+## 3. Connect the backend to the database
+
+Copy the example file and fill in your password:
+
+```bash
+cp backend/.env.example backend/.env
+```
+
+On Windows: `copy backend\.env.example backend\.env`.
+
+Open `backend/.env` and replace `your-password` in `DATABASE_URL`. The format is `postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public`. The file is ignored by Git, so your password is never committed.
+
+## 4. Install and run
+
+From the repository root:
+
+```bash
+npm ci
+npm run generate
+npm run db:migrate
+npm run db:seed
+```
+
+`npm run db:migrate` creates the tables in your database by applying the migrations in `backend/prisma/migrations/`. Run it again whenever someone has added a new migration.
+
+| Command | What it does |
+| ----------------------------- | ----------------------------------------------------------------------------------------------- |
+| `npm run generate` | Runs both generators below. `typecheck` and `build` in `backend` run it first |
+| `npm run db:generate` | Generates the Prisma client from `backend/prisma/schema.prisma` |
+| `npm run codegen` | Generates the TypeScript types for the resolvers from `backend/src/schema.graphql` |
+| `npm run db:migrate` | Creates a migration from changes in the schema and applies it to your database |
+| `npm run db:deploy` | Applies existing migrations without creating new ones. Used on the server |
+| `npm run db:seed` | Loads the catalog from `data/` into the database. See "Seeding" below |
+| `npm run db:synthetic -- add` | Adds 100 000 fake items for scalability tests. `remove` deletes them. See "Synthetic test data" |
+| `npm run db:studio` | Opens Prisma Studio in the browser, to look at the data in the database |
+
+## 5. Check the connection
+
+To check that the backend can reach your database:
+
+1. Make sure PostgreSQL is running. In Postgres.app the server should show as running. With Homebrew, run `brew services start postgresql@18`. On Windows the service starts automatically.
+2. Open a terminal in the repository root and go to the backend folder:
+
+ ```bash
+ cd backend
+ ```
+
+3. Run:
+
+ ```bash
+ npx prisma migrate status
+ ```
+
+4. Look for these lines in the output. The first one means that the connection works, and the last one that your tables are up to date:
+
+ ```
+ Datasource "db": PostgreSQL database "cop", schema "public" at "localhost:5432"
+ ...
+ Database schema is up to date!
+ ```
+
+5. Go back to the repository root:
+
+ ```bash
+ cd ..
+ ```
+
+If the output says "Following migration have not yet been applied", the connection works but your tables are missing or out of date. Go back to the repository root and run `npm run db:migrate`. If the connection fails, Prisma prints an error such as "Can't reach database server" or "Authentication failed" instead.
+
+## 6. Seeding
+
+The seed script loads the catalog (apparatus, element groups, variant groups, elements and vaults) from the JSON files in `data/` into your database, and rebuilds the search tables. Run it from the repository root:
+
+```bash
+npm run db:seed
+```
+
+Run it again:
+
+- **after migrations**, when you have run `npm run db:migrate` or `npm run db:deploy`
+- **whenever `data/` changes**, for example after a new run of the extraction script
+
+It is safe to run as often as you like:
+
+- **Everything happens in one transaction.** If something fails, nothing is changed.
+- **A second run with the same data changes nothing.** It prints a table of what was inserted, updated, marked as removed and deleted.
+- **It checks the data first.** If something in `data/` breaks a rule in `docs/DATA_MODEL.md`, for example a variant group with different codes or a missing image, it stops with a message that names the row and the rule, and changes nothing.
+- **It never touches user data.** Coaches, gymnasts, routines and their lists are left alone.
+- **Removed elements are kept while they are in use.** An element or vault that is no longer in `data/` is marked as removed. It is deleted only if no routine or gymnast list uses it.
+
+The code is in `backend/src/seed/`. The rules for search words, including the written-out abbreviations such as `handstand` for `hstd`, are in `backend/src/catalog/words.ts`. The search uses the same rules.
+
+## 7. Look at the data
+
+To see what is in your database, open Prisma Studio from the repository root:
+
+```bash
+npm run db:studio
+```
+
+The terminal prints the address, for example "Prisma Studio is running at: http://localhost:51212". The port can change between runs. Open the address in your browser if it does not open by itself. The page has a list of all the tables. Click a table to see its rows, filter and sort them, and follow the relations, for example from an element to its variant group or from a routine to its items. Stop it with Ctrl+C in the terminal.
+
+Prisma Studio uses the connection in `backend/.env`, so there is nothing more to set up. You can also edit and delete rows there, so be careful: changes to the catalog are overwritten the next time you run `npm run db:seed`, and changes to user data are not undone.
+
+To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (on Windows, use _SQL Shell (psql)_) and enter the password from `backend/.env`. The tables have snake_case names in the database, such as `catalog_entries` and `element_group_id`. `\dt` lists the tables, `\d elements` shows the columns of a table, and `\q` quits.
diff --git a/docs/E2E_TESTS.md b/docs/E2E_TESTS.md
new file mode 100644
index 0000000..727b3a7
--- /dev/null
+++ b/docs/E2E_TESTS.md
@@ -0,0 +1,44 @@
+# End-to-end tests
+
+The assignment asks for "a form of automated end-to-end testing" of "a longer sequence of interactions", and for testing of the API. The component tests (Vitest) and the API tests in `backend/src/server/` already exist; the end-to-end tests drive the **whole application in a real browser**, against the real backend and the real database, the way a user does. They are in `e2e/` and use [Playwright](https://playwright.dev), with [axe-core](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright) for the accessibility checks.
+
+| File | What it tests |
+| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `e2e/elements-flow.e2e.ts` | One long flow: search, filter by apparatus and group, "Load more", sort, open an element, go back, "Clear filters". It checks the address, the results, the title and the focus at each step. |
+| `e2e/vault.e2e.ts` | The vault difficulty bands, and sorting vault by its D-score in both directions. |
+| `e2e/atypical.e2e.ts` | Atypical use: an address with filters that do not exist, a search without a result, the 80-character limit, a structure that fails to load (the request is made to fail), the disabled group filter, and keyboard-only use. |
+| `e2e/accessibility.e2e.ts` | axe-core on the page in its main states: filtered and sorted, a menu open, the reason for a disabled filter shown, vault, no result, dark mode, a phone (375 px) and reflow at 320 px. A violation fails the test. |
+| `e2e/api.e2e.ts` | The GraphQL API over HTTP: search, filter, sort, cursors, the error codes and `Apparatus.difficultyValues`. The same requests can be sent to the virtual machine. |
+| `e2e/gymnast-page.e2e.ts` | The Gymnast page and "Your gymnasts" logged in as a test coach: axe in light and dark mode, on a phone and with the remove dialog open; no sideways scrolling at 280, 320, 360 and 390 px (also with long names and the text spacing of WCAG 1.4.12); the size of what is pressed on a phone; the tabs with the arrow keys, Home and End; where the focus goes around the remove dialog; and the colors of a row and of the trash icon under the pointer. |
+| `e2e/element-page.e2e.ts` | The page of an element and of a vault: axe in light and dark mode and on a phone, and "Add to gymnast" (a link to the login for a visitor, a menu of the gymnasts for a coach, and the element in the list afterwards). |
+| `e2e/gymnast-helpers.ts` | Not a test: the login through the API, the test data, axe and the colors as the browser works them out, for the two files above. |
+
+## How to run them
+
+They need the database to be running and seeded (see "Database (local setup)" above), and a browser:
+
+```bash
+npm ci
+npm run generate # the Prisma and GraphQL types, once
+npx playwright install chromium # the browser, once (about 150 MB)
+npm run e2e
+```
+
+`npm run e2e` starts the backend and the frontend itself (and uses them if they are already running), runs the tests, and prints the result. `npm run e2e:report` opens the report with a screenshot and a trace of every test that failed. The tests of the catalog only read it. The tests of the gymnast pages log in through the API (`devLogin`) as a test coach of their own (`e2e-gymnast-page@example.test`, `e2e-element-page@example.test`), make gymnasts for it, and delete them at the start and at the end, so all of them can be run again and again. The backend that the tests start has `DEV_LOGIN` turned on for this (`playwright.config.ts`); an address given with `E2E_BASE_URL` must have it too (the virtual machine has, see `docs/VM_SETUP.md`).
+
+Settings, all optional, as environment variables:
+
+| Variable | Meaning |
+| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `E2E_BASE_URL` | An address that is already running, for example `http://it2810-29.idi.ntnu.no/project2`. Nothing is started, and the same tests check the real deployment (including the Apache proxy of `/project2/graphql`). |
+| `E2E_FRONTEND_PORT`, `E2E_BACKEND_PORT` | The ports that the tests start the app on, 5173 and 4000 by default. Change them if another program uses them, or the tests would use that program. |
+| `E2E_CHANNEL` | A browser that is already installed, for example `msedge`, instead of the Chromium that Playwright downloads. |
+| `E2E_API_URL` | The GraphQL address of `e2e/api.e2e.ts`, if it is not the one that follows from the others. |
+
+## Choices and limits
+
+- **Playwright, not Cypress.** It is made for end-to-end tests, drives real Chromium, waits for the page by itself (a test says what it expects, and Playwright retries until it is true), and has an official axe-core integration. The tests are TypeScript like the rest of the project, and the files end in `.e2e.ts` so that Vitest does not pick them up.
+- **The tests depend on the seeded catalog** (the data in `data/`). They avoid exact numbers where a relation is enough ("fewer than the whole catalog", "every code starts with the number of the group"), but they do assume that uneven bars, floor and vault exist with their groups and difficulty values.
+- **A test that has never failed is not known to work.** The tests of the gymnast pages were checked by breaking the code on purpose (the grid column that made a page wider than a phone, the dark hover color, the size of the trash button, the dark background of the element page, the arrow keys of the tabs, and the focus after the dialog) and seeing the right test fail. A new test of this kind should be checked the same way.
+- **The accessibility tests are automated checks, not a full audit.** axe-core finds what a program can find. What needs a person, such as a screen reader (NVDA, VoiceOver) and whether the order of the focus makes sense, has to be tested by hand. `region` is turned off for a state with a menu open: the menus are in a portal (the Base UI default), which axe reports as best practice, not as a WCAG failure.
+- **They are not run by a server on every pull request.** The course git server has no runners (see issue #76), so they are run by the person who makes the change, and before each delivery against the virtual machine with `E2E_BASE_URL`.
diff --git a/docs/ELEMENTS_PAGE.md b/docs/ELEMENTS_PAGE.md
new file mode 100644
index 0000000..5373dea
--- /dev/null
+++ b/docs/ELEMENTS_PAGE.md
@@ -0,0 +1,39 @@
+# The Elements page: search, filters and sort
+
+The Elements page (`/elements`, `src/pages/ElementsPage.tsx`) is where a coach searches the catalog. It sends one `searchCatalog` request with the search text, the filters and the sort, and shows the result as a table (cards on a phone) with "Load more elements". The search, the filters and the sort are all in the address (`?search=salto&apparatus=UB&group=UB-6&difficulty=A,B&sort=difficulty&dir=desc`), so a result can be shared and the back button works.
+
+## What the user can do
+
+- **Search** for words in the code, name or description (see [Catalog search](./CATALOG_SEARCH.md)). The request is sent when the user has stopped typing for 300 ms, or at once on Enter.
+- **Filter** by apparatus, by element group of the chosen apparatus, and by difficulty. Several difficulty values can be chosen. "Clear filters" takes them all away.
+- **Sort** by code, difficulty or name, in both directions.
+- Everything can be combined, and a change of any of them starts again from the first page. Filtering, sorting and counting run on the server over the whole catalog, never only on what the client has loaded (a requirement of the assignment).
+
+## Choices and why
+
+- **Ready-made parts only.** The menus are the Base UI `Menu` and `Tooltip` that shadcn/ui is built on (already a dependency), so the arrow keys, Enter, Space, Escape, the type-ahead and "checked" are handled by a library that is tested for it. The address is `useSearchParams` from React Router. The Apollo cache already keeps one list per `search`, `filter` and `sort` (`keyArgs` in `src/lib/apollo/cache.ts`), so a new filter is a new list and "Load more" adds to the right one.
+- **Scales with the catalog.** What the filters choose from is small and fixed: the 4 apparatus with their groups and difficulty values, fetched once (`CatalogStructure`, kept by the Apollo cache and by the server in memory for ten minutes) and the letters A-J. Nothing in the interface grows with the number of elements, and no filter or sort option is read from the results.
+- **Difficulty is a letter for elements and a band for vault.** An element has a value from 0.1 to 1.0, which the coach knows as a letter (A = 0.1 ... J = 1.0). A vault has a D-value from 1.6 to 6.4 and no letter. There are 23 different D-values, which is too many to choose between one by one, so the menu offers bands of one whole number (2.0-2.9, 3.0-3.9 ...). A band is sent to the API as the D-values that exist inside it, which `Apparatus.difficultyValues` lists, so the API needs no ranges. Only bands that have a vault are offered, and the chosen difficulty is dropped when the apparatus changes between vault and the others, because the scales differ.
+- **The sort is one list of outcomes.** "Code: low to high", "Difficulty: high to low", "Name: A to Z" ... instead of a field and a separate "Ascending"/"Descending". Practitioner guidance on sort menus (for example Vitaly Friedman's) is to name what the user gets and not the jargon, and the ARIA Authoring Practices put the sort state on the column header (`aria-sort`) for tables. Difficulty needs an apparatus, because element values and vault D-scores cannot be compared (see "Why difficulty is only sorted within an apparatus" in [docs/API.md](./API.md)), so its two choices are disabled until one is chosen, and the menu says why. On vault the difficulty sort is by the D-score.
+- **"Element group" is a section of the table of elements** (for example "1.000 Mounts" on uneven bars; `ElementGroup` in the database), not one of the five composition requirements of the Code of Points that are worth 0.5 each. The first digit of an element's code is the number of its group. A vault group has a long title, so it is shown as "Group N", as in the results list.
+- **The address is cleaned before it is sent.** A filter that the API would refuse (an apparatus or group that does not exist, a group of another apparatus, a difficulty that is not one of the options) is left out, so an old or edited link never gives `BAD_USER_INPUT`. An address with a filter waits for the structure; if the structure cannot be loaded the page says so and does not search, because searching without the filters would show results that do not match the address.
+- **Each change is built on the last one.** Two quick choices in the difficulty menu both count. React Router's function form of `setSearchParams` only sees the address of the last render, so the page keeps the latest address in a ref.
+
+## Accessibility
+
+- **A filter that cannot be used is disabled, but still reachable.** The group filter before an apparatus is chosen uses `aria-disabled` and not the `disabled` attribute: a disabled button is skipped by the keyboard, and then nobody can find out why it is disabled. The reason is a tooltip (on hover, on keyboard focus, and when the button is pressed, because a tooltip does not open on a tap) and also its `aria-describedby`, so a screen reader says it. This is the pattern that PatternFly, Salt and Vivid recommend for a disabled control that has to explain itself.
+- **Each button says its name and its choice** ("Apparatus: Uneven bars", "Difficulty: A, B"), the menus have names, a chosen item is `aria-checked`, and the focus goes back to the button when a menu closes. "Clear filters" removes itself, so it moves the focus to the first filter.
+- **The number of results is announced** (`role="status"`, "Showing 20 of 178"), and the results section is `aria-busy` while it loads.
+- **Every page has a title** that says what the page is (`PageTitle`, "Elements – WAG CoP Planner"; WCAG 2.4.2). React 19 moves a `` to the head wherever it is rendered, so no library is needed. The element page names the element and the apparatus, also while it is loading or has failed.
+- **Checked** with axe-core (no violations in light and dark mode, with the menus open and at phone width), Lighthouse (100), keyboard-only use, contrast (about 15.9:1), touch target sizes, reflow at 320 px, text spacing and reduced motion. **Not checked** with a screen reader such as NVDA or VoiceOver: that has to be done by a person.
+
+## Testing
+
+- `src/tests/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it.
+- The backend tests (`backend/src/catalog/search.test.ts`, `backend/src/server/server.test.ts`) cover the vault filter, the range of the values and `Apparatus.difficultyValues`, and need the seeded database like the rest.
+- Not everything can be tested in jsdom (for example the tooltip, which Base UI opens from real pointer events). Those parts were checked in a real browser against the real backend.
+
+## Known limits
+
+- The tooltip and the focus behavior of the menus are Base UI's. Axe lists their internal focus guards and `aria-controls` as "needs review", and reports the portal of an open menu as "content not in a landmark" (best practice, not a WCAG failure).
+- The backend has to be updated together with the frontend: the page asks for `Apparatus.difficultyValues`, which an older backend does not have, and then the filters cannot be loaded.