From eca6cf20f5c2f3d0cf4d9f5e0799df27752dd838 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:18:42 +0100 Subject: [PATCH 1/4] feat(about): add the About page Introduce the site and the sport for people who do not know gymnastics, link the official Code of Points PDF, say that the content is not ours, and explain that the site is made for trainers and why it has a login. Plain text with links, in light and dark mode. - add a glossary: apparatus, routine, element with the A-J values, vault, D-score, E-score, final score and Code of Points - say what the calculator does not support yet: composition requirements, connection values, bonuses and the E-score - add component tests for the PDF link, the rights text, the heading order and that the page has no buttons - add the About page to docs/AI_USAGE.md Closes #146 --- docs/AI_USAGE.md | 8 ++ src/components/about/AboutSection.tsx | 22 +++ src/lib/codeOfPoints.ts | 10 ++ src/pages/AboutPage.tsx | 134 ++++++++++++++++- src/styles/global.css | 8 +- src/tests/pages/AboutPage.test.tsx | 200 ++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 src/components/about/AboutSection.tsx create mode 100644 src/lib/codeOfPoints.ts create mode 100644 src/tests/pages/AboutPage.test.tsx diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..4f1daf2 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,11 @@ - **Tool:** Claude Code - **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 About page (`src/pages/AboutPage.tsx`, `src/components/about/`, `src/lib/codeOfPoints.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #146 and decided what the page is for: an introduction for people who do not know gymnastics, the PDF it comes from with a link, a clear statement that the Code of Points is not ours, the mission (the knowledge of the PDF for search and for calculating scores), and that the site is made especially for trainers, which is why there is a login. Decided that the page must be very simple, work in light and dark mode, and have no buttons of its own. +- **What Claude did:** Found the official PDF on gymnastics.sport and checked that the link answers with a PDF (23.8 MB). Read the front pages of the PDF to check what the page says: that FIG owns the Code, that the final score is the D-score plus the E-score, and that the D-score has difficulty, composition requirements and connection value. Wrote the tests first (the link, the rights text, the glossary, the trainers and login text, the limits of the calculator, headings in order, no buttons), then the page as plain text with links, using only the theme colors. Checked it with axe in both themes and at 320 px. +- **What was found:** The PDF says "Translation and copying are prohibited without prior written approval by FIG". The page says that we do not own the Code of Points, but whether the team may show the elements and drawings is a question for the team and the course staff, and it is not answered here. +- **What was not done by Claude:** nothing was committed or posted. diff --git a/src/components/about/AboutSection.tsx b/src/components/about/AboutSection.tsx new file mode 100644 index 0000000..69a918f --- /dev/null +++ b/src/components/about/AboutSection.tsx @@ -0,0 +1,22 @@ +import { useId, type ReactNode } from 'react' + +/** + * One part of the About page: a heading (h2) and plain text under it. The `
` is named + * by its heading, so a screen reader can list the parts and jump between them. + */ +export default function AboutSection({ + title, + children, +}: { + title: string + children: ReactNode +}) { + const headingId = useId() + + return ( +
+

{title}

+ {children} +
+ ) +} diff --git a/src/lib/codeOfPoints.ts b/src/lib/codeOfPoints.ts new file mode 100644 index 0000000..f51ffd5 --- /dev/null +++ b/src/lib/codeOfPoints.ts @@ -0,0 +1,10 @@ +// Where the Code of Points that the catalog comes from can be found. The links were checked on +// 2026-11-06: the file answered 200 with `application/pdf` (23.8 MB), and the page of the +// rules lists it as "1.1 - WAG COP 2025-2028". + +/** The official PDF of FIG: Women's Artistic Gymnastics Code of Points 2025-2028 (English). */ +export const COP_PDF_URL = + 'https://www.gymnastics.sport/publicdir/rules/files/en_1.1%20-%20WAG%20COP%202025-2028.pdf' + +/** The page of FIG with all the rules documents, for the day the file is moved or renamed. */ +export const COP_RULES_URL = 'https://www.gymnastics.sport/rules/' diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index 6085613..8295447 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -1,11 +1,139 @@ +import { Link } from 'react-router-dom' +import AboutSection from '@/components/about/AboutSection' import PageTitle from '@/components/PageTitle' +import { COP_PDF_URL, COP_RULES_URL } from '@/lib/codeOfPoints' -// A placeholder until the page is built. It has its own
, like the real pages. +// A link in the text, underlined so it is not told by its color alone (WCAG 1.4.1). The colors +// are the theme colors, so the page reads in light and in dark mode. +const LINK = + 'font-medium underline underline-offset-4 hover:no-underline focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)' + +/** The words of the site, in the order a newcomer meets them. */ +const GLOSSARY: { term: string; description: string }[] = [ + { + term: 'Apparatus', + description: + 'One of the four events: vault, uneven bars, balance beam and floor exercise.', + }, + { + term: 'Routine', + description: + 'The whole exercise a gymnast performs on one apparatus. On uneven bars, balance beam and floor it is a series of elements. On vault it is one vault (some competitions require two).', + }, + { + term: 'Element', + description: + 'One skill in a routine, such as a turn or a salto. Each has a code, like 2.305, and a drawing. The grade is a letter with a fixed value: A = 0.1, B = 0.2, C = 0.3, D = 0.4, E = 0.5, F = 0.6, G = 0.7, H = 0.8, I = 0.9, J = 1.0. The code tells the grade: in 2.305 the 3 after the dot means C.', + }, + { + term: 'Vault', + description: + 'A vault has no letter. A letter (0.1 to 1.0) is one part of a D-score, which on the other apparatus is built from eight elements. A vault stands for the whole exercise: its value, between 1.6 and 6.4, is the D-score. So one vault is worth about as much as a whole routine of eight elements, and a routine on vault can be just one vault.', + }, + { + term: 'D-score', + description: + 'The difficulty score. It is made of the values of the eight highest elements, plus composition requirements, connection values and bonuses. On vault it is just the value of the vault.', + }, + { + term: 'E-score', + description: + 'The execution score. It starts at 10.0, and the judges subtract points for faults. We do not support subtracting the E-score yet: the calculator only gives the D-score.', + }, + { + term: 'Final score', + description: 'The D-score plus the E-score, minus any penalties.', + }, + { + term: 'Code of Points', + description: + 'The rulebook of FIG, the international gymnastics federation. It lists all elements and what they are worth. A new one is made every four years.', + }, +] + +/** A plain introduction to the site, to the sport and to the Code of Points the data comes from. */ export default function AboutPage() { return ( -
+
About -

About

+ +
+

About

+ + +

+ The knowledge about elements and their values is in a long PDF. Our + mission is to make it available for search and for calculating + scores, for trainers and their gymnasts. +

+
+ + +

+ A gymnast performs an exercise on each of four apparatus, and judges + score it. These are the words you will meet here. +

+
+ {GLOSSARY.map(({ term, description }) => ( +
+
{term}
+
{description}
+
+ ))} +
+
+ + +

+ All elements and values here come from the{' '} + + Women’s Artistic Gymnastics Code of Points 2025–2028 (PDF, about + 24 MB) + + , published by FIG. If the link stops working, see the{' '} + + rules page of FIG + + . +

+

+ We do not own the Code of Points. It is the property of the FIG, and + all rights belong to FIG. We use it for education in a student + project that is not affiliated with FIG. The catalog may not have + the latest changes, so the official document always decides. +

+
+ + +

+ The site is made especially for trainers. Anyone can{' '} + + search the elements + + . A trainer can{' '} + + log in + {' '} + to keep a profile for each gymnast, with routines and the elements + each can do. That is why there is a login. For now it is a test + login: anyone can log in as any coach, so do not enter real + information. +

+

+ Our{' '} + + calculator + {' '} + adds the eight highest elements of a routine, each counted once. It + does not include composition requirements, connection values or + bonuses (for example the dismount bonus), so it is not an official + score. +

+
+
) } diff --git a/src/styles/global.css b/src/styles/global.css index 7feca8b..f6b6cc7 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -9,9 +9,11 @@ border-inline: 0; } -/* The titles of the Elements, gymnasts and login pages are the same: the size +/* The titles of the Elements, gymnasts, About and login pages are the same: the size of h1 comes from index.css (56px, 36px on a narrow window) for all of them. */ -#root :is(.elements-page, .your-gymnasts-page, .login-page) :is(h1, h2) { +#root + :is(.elements-page, .your-gymnasts-page, .about-page, .login-page) + :is(h1, h2) { color: var(--text-color); font-weight: 700; letter-spacing: -0.01em; @@ -83,7 +85,7 @@ margin-bottom: 0.75rem; } -#root .your-gymnasts-page h2 { +#root :is(.your-gymnasts-page, .about-page) h2 { font-size: clamp(1.375rem, 4vw, 1.75rem); line-height: 1.2; margin: 0; diff --git a/src/tests/pages/AboutPage.test.tsx b/src/tests/pages/AboutPage.test.tsx new file mode 100644 index 0000000..8c8f8c3 --- /dev/null +++ b/src/tests/pages/AboutPage.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment jsdom +// The About page is static text. These tests say what it must tell a visitor, so a rewrite +// of the wording cannot drop the link to the PDF, the notice that the Code of Points is not +// ours, or the limits of the calculator. + +import { cleanup, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import AboutPage from '@/pages/AboutPage' +import { COP_PDF_URL, COP_RULES_URL } from '@/lib/codeOfPoints' + +afterEach(cleanup) + +const renderPage = () => + render( + + + , + ) + +/** The section with this heading. */ +const section = (name: string) => + screen + .getByRole('heading', { level: 2, name }) + .closest('section') as HTMLElement + +describe('AboutPage: the page', () => { + it('has a title (WCAG 2.4.2) and one h1', () => { + renderPage() + + expect(document.title).toBe('About – WAG CoP Planner') + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('About') + }) + + it('has one main landmark and the headings in order: h1, then h2 only', () => { + renderPage() + + expect(screen.getAllByRole('main')).toHaveLength(1) + const levels = screen + .getAllByRole('heading') + .map((heading) => Number(heading.tagName[1])) + expect(levels[0]).toBe(1) + expect(levels.slice(1).every((level) => level === 2)).toBe(true) + expect(levels.length).toBe(5) + }) + + it('has every section named by its heading, so a screen reader can list them', () => { + renderPage() + + for (const heading of screen.getAllByRole('heading', { level: 2 })) { + const region = heading.closest('section')! + expect(region.getAttribute('aria-labelledby')).toBe(heading.id) + } + }) +}) + +describe('AboutPage: what the site is for', () => { + it('says the mission: the knowledge of the PDF for search and for calculating scores, especially for trainers', () => { + renderPage() + + const mission = section('Our mission').textContent ?? '' + expect(mission).toMatch(/search/i) + expect(mission).toMatch(/calculat/i) + expect(mission).toMatch(/trainers/i) + expect(mission).toMatch(/gymnasts/i) + }) + + it('explains the domain for a visitor who does not know gymnastics', () => { + renderPage() + + const intro = section('Gymnastics in two minutes') + // The four apparatus are named in the description of the first word. + for (const apparatus of ['vault', 'uneven bars', 'balance beam', 'floor']) { + expect(intro.textContent?.toLowerCase()).toContain(apparatus) + } + // The words of the site, each explained. + const terms = within(intro) + .getAllByRole('term') + .map((term) => term.textContent) + for (const term of [ + 'Apparatus', + 'Element', + 'D-score', + 'E-score', + 'Final score', + 'Code of Points', + ]) { + expect(terms).toContain(term) + } + }) + + it('gives each term a description', () => { + renderPage() + + const intro = section('Gymnastics in two minutes') + expect(within(intro).getAllByRole('definition').length).toBe( + within(intro).getAllByRole('term').length, + ) + }) +}) + +describe('AboutPage: the PDF and the rights', () => { + it('links to the PDF of FIG, and tells what the link is and how big the file is', () => { + renderPage() + + const link = screen.getByRole('link', { + name: /Women.s Artistic Gymnastics Code of Points 2025–2028/, + }) + expect(link.getAttribute('href')).toBe(COP_PDF_URL) + expect(COP_PDF_URL).toMatch(/^https:\/\/www\.gymnastics\.sport\/.*\.pdf$/) + expect(link.textContent).toMatch(/PDF/) + expect(section('The Code of Points and our data').textContent).toMatch( + /24 MB/, + ) + }) + + it('links to the page of FIG with the rules, in case the file moves', () => { + renderPage() + + const link = screen.getByRole('link', { name: /rules page/i }) + expect(link.getAttribute('href')).toBe(COP_RULES_URL) + }) + + it('says that the Code of Points is not ours and belongs to FIG', () => { + renderPage() + + const text = section('The Code of Points and our data').textContent ?? '' + expect(text).toMatch(/do not own/i) + expect(text).toMatch(/property of the FIG/i) + expect(text).toMatch(/not affiliated/i) + }) + + it('says that the catalog can be behind the official document, and that the PDF decides', () => { + renderPage() + + const text = section('The Code of Points and our data').textContent ?? '' + expect(text).toMatch(/latest changes/i) + expect(text).toMatch(/official/i) + }) +}) + +describe('AboutPage: trainers and the login', () => { + it('says that the site is made for trainers, and why there is a login', () => { + renderPage() + + const trainers = section('For trainers') + expect(trainers.textContent).toMatch(/log in/i) + expect(trainers.textContent).toMatch(/gymnast/i) + expect(trainers.textContent).toMatch(/routines/i) + }) + + it('is honest that the login is a test login that anyone can use as anyone', () => { + renderPage() + + expect(section('For trainers').textContent).toMatch( + /anyone can log in as any coach/i, + ) + }) + + it('has a link to the login page', () => { + renderPage() + + const link = within(section('For trainers')).getByRole('link', { + name: /^log in$/i, + }) + expect(link.getAttribute('href')).toBe('/login') + }) +}) + +describe('AboutPage: the calculator and the links', () => { + it('says what the calculator counts and what not, so nobody takes the number for an official score', () => { + renderPage() + + const text = section('For trainers').textContent ?? '' + expect(text).toMatch(/eight highest/i) + expect(text).toMatch(/composition requirements/i) + expect(text).toMatch(/connection values/i) + expect(text).toMatch(/not an official score/i) + }) + + it('has links to the search and the calculator', () => { + renderPage() + + expect( + screen + .getByRole('link', { name: /search the elements/i }) + .getAttribute('href'), + ).toBe('/elements') + expect( + screen.getByRole('link', { name: /calculator/i }).getAttribute('href'), + ).toBe('/calculator') + }) + + it('has no buttons: it is text with links', () => { + renderPage() + + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) +}) From bc446f2e9cffa9af0b940d8d3435584d707c0665 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:34:01 +0100 Subject: [PATCH 2/4] test(about): remove the tests of the wording of the page Keep the tests of the structure, the login text, the limits of the calculator and the links. The tests of the mission, the glossary and the PDF and rights text only checked that words were present, so they broke on any rewording. --- src/tests/pages/AboutPage.test.tsx | 92 +----------------------------- 1 file changed, 3 insertions(+), 89 deletions(-) diff --git a/src/tests/pages/AboutPage.test.tsx b/src/tests/pages/AboutPage.test.tsx index 8c8f8c3..3ddac85 100644 --- a/src/tests/pages/AboutPage.test.tsx +++ b/src/tests/pages/AboutPage.test.tsx @@ -1,13 +1,12 @@ // @vitest-environment jsdom -// The About page is static text. These tests say what it must tell a visitor, so a rewrite -// of the wording cannot drop the link to the PDF, the notice that the Code of Points is not -// ours, or the limits of the calculator. +// The About page is static text. These tests check its structure and accessibility, and the +// parts that must not be lost in a rewrite of the wording: the login text and the limits of +// the calculator. import { cleanup, render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, describe, expect, it } from 'vitest' import AboutPage from '@/pages/AboutPage' -import { COP_PDF_URL, COP_RULES_URL } from '@/lib/codeOfPoints' afterEach(cleanup) @@ -55,91 +54,6 @@ describe('AboutPage: the page', () => { }) }) -describe('AboutPage: what the site is for', () => { - it('says the mission: the knowledge of the PDF for search and for calculating scores, especially for trainers', () => { - renderPage() - - const mission = section('Our mission').textContent ?? '' - expect(mission).toMatch(/search/i) - expect(mission).toMatch(/calculat/i) - expect(mission).toMatch(/trainers/i) - expect(mission).toMatch(/gymnasts/i) - }) - - it('explains the domain for a visitor who does not know gymnastics', () => { - renderPage() - - const intro = section('Gymnastics in two minutes') - // The four apparatus are named in the description of the first word. - for (const apparatus of ['vault', 'uneven bars', 'balance beam', 'floor']) { - expect(intro.textContent?.toLowerCase()).toContain(apparatus) - } - // The words of the site, each explained. - const terms = within(intro) - .getAllByRole('term') - .map((term) => term.textContent) - for (const term of [ - 'Apparatus', - 'Element', - 'D-score', - 'E-score', - 'Final score', - 'Code of Points', - ]) { - expect(terms).toContain(term) - } - }) - - it('gives each term a description', () => { - renderPage() - - const intro = section('Gymnastics in two minutes') - expect(within(intro).getAllByRole('definition').length).toBe( - within(intro).getAllByRole('term').length, - ) - }) -}) - -describe('AboutPage: the PDF and the rights', () => { - it('links to the PDF of FIG, and tells what the link is and how big the file is', () => { - renderPage() - - const link = screen.getByRole('link', { - name: /Women.s Artistic Gymnastics Code of Points 2025–2028/, - }) - expect(link.getAttribute('href')).toBe(COP_PDF_URL) - expect(COP_PDF_URL).toMatch(/^https:\/\/www\.gymnastics\.sport\/.*\.pdf$/) - expect(link.textContent).toMatch(/PDF/) - expect(section('The Code of Points and our data').textContent).toMatch( - /24 MB/, - ) - }) - - it('links to the page of FIG with the rules, in case the file moves', () => { - renderPage() - - const link = screen.getByRole('link', { name: /rules page/i }) - expect(link.getAttribute('href')).toBe(COP_RULES_URL) - }) - - it('says that the Code of Points is not ours and belongs to FIG', () => { - renderPage() - - const text = section('The Code of Points and our data').textContent ?? '' - expect(text).toMatch(/do not own/i) - expect(text).toMatch(/property of the FIG/i) - expect(text).toMatch(/not affiliated/i) - }) - - it('says that the catalog can be behind the official document, and that the PDF decides', () => { - renderPage() - - const text = section('The Code of Points and our data').textContent ?? '' - expect(text).toMatch(/latest changes/i) - expect(text).toMatch(/official/i) - }) -}) - describe('AboutPage: trainers and the login', () => { it('says that the site is made for trainers, and why there is a login', () => { renderPage() From 555c8bc797ea079d0d7e0a46230e511f83c376c9 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:34:01 +0100 Subject: [PATCH 3/4] docs(ai-usage): note the changes the team asked for on the About page Replace the line that said nothing was committed or posted with what the team changed after the first version: the trainers and the login, the A-J mapping, routines and vaults, and what the calculator does not support yet. --- docs/AI_USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 4f1daf2..1144ab1 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -539,4 +539,4 @@ - **What the team did:** Wrote issue #146 and decided what the page is for: an introduction for people who do not know gymnastics, the PDF it comes from with a link, a clear statement that the Code of Points is not ours, the mission (the knowledge of the PDF for search and for calculating scores), and that the site is made especially for trainers, which is why there is a login. Decided that the page must be very simple, work in light and dark mode, and have no buttons of its own. - **What Claude did:** Found the official PDF on gymnastics.sport and checked that the link answers with a PDF (23.8 MB). Read the front pages of the PDF to check what the page says: that FIG owns the Code, that the final score is the D-score plus the E-score, and that the D-score has difficulty, composition requirements and connection value. Wrote the tests first (the link, the rights text, the glossary, the trainers and login text, the limits of the calculator, headings in order, no buttons), then the page as plain text with links, using only the theme colors. Checked it with axe in both themes and at 320 px. - **What was found:** The PDF says "Translation and copying are prohibited without prior written approval by FIG". The page says that we do not own the Code of Points, but whether the team may show the elements and drawings is a question for the team and the course staff, and it is not answered here. -- **What was not done by Claude:** nothing was committed or posted. +- **What the team changed afterwards:** The team asked for changes in the text and for information that Claude had missed in the first version: that the site is especially for trainers and why it has a login, the full mapping from the letters A to J to their numbers, the difference between a routine and a vault and why a vault has no letter, and that the calculator does not support bonuses or subtracting the E-score yet. The team also asked for a very simple page without buttons, in light and dark mode, and checked what Claude wrote against the PDF. From 5a95d87aee4c216f8cac58408c2a0edaab609c34 Mon Sep 17 00:00:00 2001 From: Ronja Date: Tue, 10 Nov 2026 14:14:27 +0100 Subject: [PATCH 4/4] fix(about): say difficulty instead of grade The rest of the site calls the letter of an element its difficulty. Refs: #146 --- src/pages/AboutPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index 8295447..7a08e98 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -23,7 +23,7 @@ const GLOSSARY: { term: string; description: string }[] = [ { term: 'Element', description: - 'One skill in a routine, such as a turn or a salto. Each has a code, like 2.305, and a drawing. The grade is a letter with a fixed value: A = 0.1, B = 0.2, C = 0.3, D = 0.4, E = 0.5, F = 0.6, G = 0.7, H = 0.8, I = 0.9, J = 1.0. The code tells the grade: in 2.305 the 3 after the dot means C.', + 'One skill in a routine, such as a turn or a salto. Each has a code, like 2.305, and a drawing. The difficulty is a letter with a fixed value: A = 0.1, B = 0.2, C = 0.3, D = 0.4, E = 0.5, F = 0.6, G = 0.7, H = 0.8, I = 0.9, J = 1.0. The code tells the difficulty: in 2.305 the 3 after the dot means C.', }, { term: 'Vault',