diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md
index e3dc921..05eb3fe 100644
--- a/docs/AI_USAGE.md
+++ b/docs/AI_USAGE.md
@@ -533,6 +533,13 @@
- **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README.
- **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). After that, asked to move the frontend tests to `src/tests/` as well: moved all 29 with `git mv` into the same folders as the code they test, changed their relative imports to the `@/` alias (so a test does not depend on where it is), and updated the paths that `gymnastTheme.test.ts` reads files from.
+## The go-to-top link on every page (`src/components/goToTop/GoToTop.tsx`, `src/App.tsx`)
+
+- **Tool:** Claude Code
+- **What the team did:** First asked for the link on the About page, then decided that it belongs at the bottom of every page and in its own issue (#165) and pull request. Decided that it is read by a screen reader and not seen until it has the focus.
+- **What Claude did:** Wrote the tests first (the link is hidden until it has the focus, it scrolls to the top and puts the focus on the first thing that can be reached, and it does not change the address), then the component, and put it once in `App.tsx` after the page content. Checked in a real browser on several pages that the link is 1 by 1 pixels when it does not have the focus, that it shows with an outline when it has, that Enter goes to the top and puts the focus on "Skip to main content", and that axe finds no violations in light and dark mode. The first version still had padding when it was hidden, and it sat against the left edge when it was shown; both were fixed.
+- **What the team changed:** asked for the link to be moved from the About page to every page.
+
## The Gymnast page (`src/pages/GymnastPage.tsx`, `src/components/gymnasts/`, `src/components/ConfirmDialog.tsx`, `src/components/Error*.tsx`, `src/lib/gymnastDetail*.ts`, and the parts of the element page that grew out of it)
- **Tool:** Claude Code
@@ -573,4 +580,4 @@
- The result did not make the code shorter: the files under `src/components/gymnasts/` have more lines than before, because of the new files and their comments. What changed is that each decision now has one place.
- 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.
+- **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.
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 558b1f2..dd8dd65 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { Navbar } from './components/navbar/Navbar'
import { SkipLink } from './components/skipLink/SkipLink'
+import { GoToTop } from './components/goToTop/GoToTop'
import { ProtectedRoute } from './components/ProtectedRoute'
import { AuthProvider } from './components/auth/AuthProvider'
@@ -60,6 +61,8 @@ export function App() {
} />
+ {/* The last thing on every page: a link back to the start, for the keyboard and screen readers. */}
+
)
}
diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx
new file mode 100644
index 0000000..87664dc
--- /dev/null
+++ b/src/components/goToTop/GoToTop.tsx
@@ -0,0 +1,24 @@
+/**
+ * A link at the end of every page that takes a keyboard or screen reader user back to the
+ * start of the page, so they do not have to go back through everything. It is read by a
+ * screen reader and not seen, until it has the focus (like the skip link), and then it shows.
+ *
+ * It scrolls to the top and puts the focus on the first thing that can be reached, which is
+ * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would
+ * stay at the bottom. It does not follow the link, so the address and the history do not change.
+ */
+export const GoToTop = () => {
+ return (
+ {
+ event.preventDefault()
+ window.scrollTo({ top: 0 })
+ document.querySelector('a[href], button')?.focus()
+ }}
+ className="sr-only rounded-sm font-text font-semibold text-(--text-color) underline underline-offset-4 focus:not-sr-only focus:block focus:w-fit focus:my-4 focus:ml-5 focus:px-md focus:py-sm sm:focus:ml-8 lg:focus:ml-12 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)"
+ >
+ Go to the top of the page
+
+ )
+}
diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx
index 73ccfc4..eaa3906 100644
--- a/src/tests/App.test.tsx
+++ b/src/tests/App.test.tsx
@@ -86,6 +86,22 @@ describe('App', () => {
expect(screen.getAllByRole('main')).toHaveLength(1)
})
+ it('ends every page with the link back to the top, which comes after the page', () => {
+ renderApp()
+
+ for (const name of ['Search', 'About', 'Calculator']) {
+ fireEvent.click(screen.getByRole('link', { name }))
+
+ const top = screen.getByRole('link', {
+ name: 'Go to the top of the page',
+ })
+ expect(
+ screen.getByRole('main').compareDocumentPosition(top) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy()
+ }
+ })
+
it('gives every page its own title, so the tab and the history say where you are', () => {
renderApp()
expect(document.title).toBe('Elements – WAG CoP Planner')
diff --git a/src/tests/components/goToTop/GoToTop.test.tsx b/src/tests/components/goToTop/GoToTop.test.tsx
new file mode 100644
index 0000000..6937aa9
--- /dev/null
+++ b/src/tests/components/goToTop/GoToTop.test.tsx
@@ -0,0 +1,46 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { GoToTop } from '@/components/goToTop/GoToTop'
+
+afterEach(cleanup)
+
+const LINK = { name: 'Go to the top of the page' }
+
+describe('GoToTop', () => {
+ it('is a link that is read by a screen reader but not seen until it has the focus', () => {
+ render()
+
+ const link = screen.getByRole('link', LINK)
+ expect(link.className).toContain('sr-only')
+ expect(link.className).toContain('focus:not-sr-only')
+ })
+
+ it('scrolls to the top and puts the focus on the first thing that can be reached', () => {
+ const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
+ render(
+ <>
+ Start of the page
+
+ >,
+ )
+
+ fireEvent.click(screen.getByRole('link', LINK))
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0 })
+ expect(document.activeElement).toBe(
+ screen.getByRole('link', { name: 'Start of the page' }),
+ )
+ scrollTo.mockRestore()
+ })
+
+ it('does not follow the link, so the address and the history stay as they are', () => {
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
+ render()
+
+ const notCancelled = fireEvent.click(screen.getByRole('link', LINK))
+
+ // fireEvent gives false when something called preventDefault.
+ expect(notCancelled).toBe(false)
+ })
+})