docs: accessibility audit — add WCAG 2.1 AA analysis to TODO.md

Measured contrast ratios, traced every interactive element, checked all four
WCAG principles across public and admin surfaces. Current state confirmed:
zero ARIA attributes, zero skip links, zero focus-visible styles, zero
prefers-reduced-motion guards in the live codebase.

Key failures by criterion:

1.1.1: TFE file images use raw filename as alt; search bar SVG not aria-hidden;
       jury remove buttons have no accessible name (bare ✕)

1.3.1: TFE metadata is div/span soup with no programmatic label-value association;
       search filter selects have no associated label; checkbox groups need fieldset/legend

1.4.1: Status badges distinguish state by colour only; active nav link has no
       non-colour indicator and its CSS class has no rule at all

1.4.3 (measured failures):
  - Nav links at opacity:0.92 on purple: 4.05:1 (fails AA 4.5:1)
  - filter-info purple text on purple-light bg: 4.08:1 (fails AA)
  - Placeholder #aaa on white: 2.32:1 (fails AA)
  - Gradient cards: white text on L=65% HSL — every warm hue fails AA,
    some as low as 1.46:1 (yellow). Only blue/indigo hues pass.
  - admin-text-muted #888 on bg-alt #242424: 4.38:1 (fails AA)
  - admin-purple on dark bg: 3.57:1 (fails for normal text size)

1.4.10: Répertoire 4-column grid has no mobile breakpoint
1.4.11: Search select border #ddd on white: 1.6:1; admin input border: 1.8:1

2.1.1: Disabled pagination links have pointer-events:none but remain keyboard-focusable
2.4.1: No skip-to-main link anywhere in the site
2.4.4: Pagination arrows (« ‹ › ») have no aria-label
2.4.6: tfe.php h1=author h2=title is inverted; index/search have no h1 at all
2.4.7: No :focus-visible defined anywhere; outline:none suppresses browser default
       on search input with no replacement

3.1.1: 429 response has no lang attribute
3.3.1: Form errors not announced as live regions; no autofocus on invalid field
3.3.2: Search input has no label, only placeholder

4.1.1: pages-edit.php has <link> in <body>
4.1.2: <video> has no caption track; <embed> PDF has no fallback download link;
       bulk action results not announced to AT

Motion: no prefers-reduced-motion guard on any transition or animation

Infrastructure gaps: no .sr-only class, no skip link, no :focus-visible,
four explicit outline:none suppressions with no replacement
This commit is contained in:
Pontoporeia
2026-03-26 23:04:21 +01:00
parent 22fabeb447
commit a9877b1d1d

413
TODO.md
View File

@@ -919,3 +919,416 @@ Once the above is applied, the following classes become deletable (element name
| `.admin-account-status__row` | `div` inside `<dl>` | | `.admin-account-status__row` | `div` inside `<dl>` |
| `.admin-account-status__label` | `dt` | | `.admin-account-status__label` | `dt` |
| `.admin-danger-zone__description` | `p` | | `.admin-danger-zone__description` | `p` |
---
## Accessibility audit (2026-03-26)
WCAG 2.1 AA is the baseline. Issues are grouped by criterion number for traceability.
Current state: **zero ARIA attributes, zero skip links, zero focus-visible styles, zero
`prefers-reduced-motion` guards** anywhere in the live codebase.
---
### 1 — Perceivable
#### 1.1.1 Non-text content (alt text)
- [ ] **Home card images use the thesis title as `alt`**`alt="<?= $item['title'] ?>"` is a
reasonable fallback, but the title alone provides no context about what the image depicts.
Prefer `"Couverture — [titre] par [auteurs]"` for cover images, or `""` (empty) for purely
decorative banners where the caption below already carries all the text information.
For gradient placeholder cards there is no `<img>` at all — correct, no alt needed on a
CSS gradient div.
- [ ] **TFE page file images use the raw filename as `alt`**`alt="<?= $file['file_name'] ?>"`.
A filename like `a3f8bc12.jpg` is meaningless to a screen reader user. Use the thesis title
or a stored description field. If the `description` column in `thesis_files` is populated,
that should be the alt text; fall back to the thesis title.
- [ ] **Search bar SVG icon has no `aria-hidden`** — the magnifying glass SVG in
`search-bar.php` is purely decorative (the `<input>` carries all meaning). Add
`aria-hidden="true"` and `focusable="false"` to the SVG.
- [ ] **Admin `<nav>` logo is a text link — fine. But "✕ Réinitialiser" and "✕" remove buttons**
use a bare Unicode `✕` as their visible label with no accessible name alternative.
For the "✕" jury-remove buttons in `add.php`/`edit.php`, add `aria-label="Supprimer ce membre du jury"`.
For "✕ Réinitialiser" in `index.php`, the text is adequate; the `✕` symbol is decorative
there and should be wrapped in `<span aria-hidden="true">✕</span>`.
#### 1.3.1 Info and relationships
- [ ] **The metadata list on `tfe.php` is a `<div>/<span>` soup** — a screen reader traversing
the page hears "Orientation : Arts Numériques" as a flat run of text with no
structure. There is no programmatic association between label and value. Replacing with
`<dl>/<dt>/<dd>` (already flagged in the semantic audit) directly fixes this criterion.
- [ ] **Search filter `<select>` elements have no associated `<label>`** — each select is
preceded by `<span class="search-filter-label">Année</span>` but this span is not a
`<label>` and has no `for` attribute. Screen readers cannot associate it with the control.
Fix: replace `<span>` with `<label for="filter-year">` and add `id="filter-year"` to
the select (or use the wrapping-label pattern).
- [ ] **Admin form rows: `<label class="admin-label" for="X">` is correct** — the `for` attribute
is present on all single-input rows in `add.php` and `edit.php`. Good. However, the
multi-input rows (languages, formats) use `<label class="admin-label">` *without* a `for`
because they label a group of checkboxes. These should use `<fieldset>/<legend>` instead
so the group label is programmatically associated with all its checkboxes.
- [ ] **Status badges in `admin/index.php` convey state by colour alone** — "Publié" (green) /
"En attente" (yellow) / "Libre" (green) / "Interne" (blue) / "Interdit" (red) all rely
entirely on colour to distinguish states. This fails **1.4.1 Use of Colour**. Add a
visible non-colour distinction (e.g. a prefix icon character with `aria-hidden="true"`)
and `aria-label="Statut : Publié"` on the badge `<span>`.
- [ ] **`<target="_blank">` links give no warning** — `tfe.php` and `apropos.php` open external
links in a new tab with no indication. Screen reader users and keyboard users are
disoriented when their context silently shifts. Add a visually-hidden `<span class="sr-only">(ouvre dans un nouvel onglet)</span>` after the link text, or append the information to the
`aria-label`.
#### 1.3.4 / 1.3.5 Orientation & Input purpose
- [ ] **No `autocomplete` attributes on personal data fields**`add.php`/`edit.php` fields
like `auteurice` (person name), `mail` (contact) lack `autocomplete` hints. Add
`autocomplete="name"`, `autocomplete="email"` where applicable so password managers and
autofill can assist (WCAG 1.3.5).
#### 1.4.1 Use of colour (see also 1.3.1 above)
- [ ] **Admin status badges distinguish states by colour only** — covered above.
- [ ] **Active nav link has no non-colour indicator**`.site-nav__link--active` is applied in
PHP but has no CSS rule at all (flagged in the semantic/CSS audit). Even if a rule existed,
if it only changes colour it would still fail this criterion for users with colour blindness.
The active indicator must include a non-colour signal: underline, border, weight change,
or `aria-current="page"` (which is announced by screen readers regardless of visual styling).
#### 1.4.3 Contrast (minimum) — confirmed failures from measurement
- [ ] **Nav links at `opacity: 0.92` on purple background: 4.05:1** — fails AA (4.5:1 required
for normal text). At full opacity the white-on-purple ratio is 4.87:1 (just passes), but
the `opacity: 0.92` applied to `.site-nav__link` drops it to 4.05:1. Fix: remove the
opacity reduction, or increase purple darkness slightly.
- [ ] **`filter-info` purple text `#9557b5` on purple-light background `rgba(149,87,181,0.12)`
over white: 4.08:1** — fails AA. The filter info banner ("Année : 2024" or "Découvrez les
TFE de 2024") uses purple text on a light purple tint. Use `var(--purple-dark)` (#7b3fa0)
instead for the text, which would reach ~5.7:1.
- [ ] **Placeholder text `#aaa` on white: 2.32:1** — fails AA (and fails for large text too).
Placeholder text is explicitly included in WCAG 1.4.3. Change to `#767676` minimum
(~4.54:1) or preferably `#6b6b6b`.
- [ ] **Gradient card placeholder: white text on L=65% HSL backgrounds — most hues fail**
measured across the full hue range at `hsl(H, 60%, 65%)` (the lighter end of the gradient):
only hues 240250° (blue-indigo) pass AA. Every warm hue (0230°) and most cool hues fail,
with ratios as low as 1.46:1 at yellow (hue=60°). Since hue is derived from `$item['id'] % 360`,
any thesis ID will produce a random hue. Fix: either darken the gradient to `L=45%` on the
lighter end (would raise almost all hues above 3:1 for large text), or drop the text overlay
inside the gradient entirely (the card caption below already shows title/author).
- [ ] **`admin-text-muted` `#888` on `admin-bg-alt` `#242424`: 4.38:1** — fails AA for normal
text. This combination appears in table cell muted text, form hints, and sub-labels across
the admin. Darken to `#909090` (~4.5:1) or use `#959595`.
- [ ] **Admin purple `#9557b5` used as large-heading colour on dark `#1a1a1a`: 3.57:1** — passes
AA for large text (≥18pt/24px bold ≥14pt) but fails for normal text. Audit every place
`var(--admin-purple)` appears as text colour on the dark background; ensure it is only
used at sizes where 3:1 suffices (large text), not on body copy or small labels.
#### 1.4.4 Resize text
- [ ] **`body` has no `font-size` baseline set** — relies on browser default (16px). Most
`.card__info`, `.search-filter-label` etc. use `rem` values which scale correctly. However,
a few admin elements use absolute `px` sizes (`font-size: 0.78rem` is fine as it's rem-relative,
but `width: 14px; height: 14px` on checkboxes does not scale). Minor — ensure no text
is set in `px`.
#### 1.4.10 Reflow (320px viewport)
- [ ] **Répertoire index 4-column grid has no mobile breakpoint**`search.css` defines the
4-column grid at `1fr 2fr 2fr 1.5fr` with no `@media` fallback. At 320px the columns
become ~50px wide each — unusable. Add a breakpoint to stack columns vertically below
~600px (or 768px).
- [ ] **TFE page two-column grid collapses at 900px** — responsive breakpoints exist for `tfe.css`
at 900px and 600px. Good. Verify the PDF `<embed>` at 700px height also reflows — currently
`height: 700px` is fixed and causes horizontal overflow on small screens. Change to
`height: clamp(300px, 80vh, 700px)`.
#### 1.4.11 Non-text contrast
- [ ] **Search filter `<select>` border is `#ddd` on white — 1.6:1** — the `border: 1px solid var(--border-color)` where `--border-color: #ddd` gives a 1.6:1 contrast ratio for the UI component boundary. WCAG 1.4.11 requires 3:1. Change to `#949494` minimum or use `#767676`.
- [ ] **Admin form inputs: `border-bottom: 1px solid #333` on `#1a1a1a` background — 1.8:1**
the bottom-border-only inputs have `border-bottom: 1px solid var(--admin-border)` (#333) on
`#1a1a1a` background: 1.8:1. Fails 1.4.11. Raise to `#555` minimum (~3.1:1).
#### 1.4.12 Text spacing
- [ ] **No text-spacing override test done** — verify that when users apply the WCAG 1.4.12
bookmarklet (line-height 1.5×, letter-spacing 0.12em, word-spacing 0.16em, paragraph spacing
2em) the card grid and TFE metadata list do not overflow their containers. The `overflow: hidden`
on `.card__media` and the tight `aspect-ratio: 4/3` on card images can cause content clipping.
---
### 2 — Operable
#### 2.1.1 Keyboard
- [ ] **Disabled pagination links are keyboard-reachable**`<a class="pagination-btn disabled">`
uses `.disabled { pointer-events: none }` in CSS which does not remove keyboard focus.
A keyboard user can still Tab to these links and press Enter (which follows the href to
page 0 or page N+1 unnecessarily). Fix: add `tabindex="-1"` and `aria-disabled="true"`
when `$page <= 1` or `$page >= $totalPages`. Same issue in `search.php` inline-styled
pagination links.
- [ ] **Jury "✕" remove buttons (`admin/add.php`, `admin/edit.php`) are only reachable via Tab**
— no issue per se, but they have no visible label (just `✕`). Confirmed already in 1.1.1;
adding `aria-label` also fixes keyboard discoverability.
- [ ] **Bulk-action JS buttons in `admin/index.php` call `bulkAction()` via `onclick`** — these
are `<button type="button">` elements so they are keyboard-accessible. Confirm Enter and
Space both trigger the action. Fine — no structural issue.
#### 2.1.2 No keyboard trap
- [ ] **EasyMDE editor in `pages-edit.php`** — CodeMirror-based editors are known keyboard
traps; Tab inside the editor inserts a tab character rather than moving focus out. EasyMDE
provides an escape route (Escape key exits the editor). Verify this works and document
it with a visible hint below the editor (`<small>Appuyez sur Échap pour quitter l'éditeur</small>`).
#### 2.4.1 Bypass blocks — skip link
- [ ] **No skip-to-main-content link exists on any page** — every page loads with focus on
the browser chrome, then Tab cycles through the nav and search bar before reaching `<main>`.
On the home page that means tabbing through 4 nav links before reaching 24 thesis cards.
Add `<a href="#main-content" class="skip-link">Aller au contenu principal</a>` as the
first element inside `<body>`, visually hidden by default, visible on focus.
Add `id="main-content"` to `<main>`. Add `.skip-link` styles to `common.css`.
#### 2.4.2 Page titled
- [ ] **`index.php` `<title>` is just "Posterg"** — no description of the page content.
Change to "Posterg — Mémoires de l'ERG" or similar. Each page title should be unique and
descriptive first: "Répertoire Posterg", "À Propos Posterg" (already good), but
`tfe.php` uses just the thesis title without author: add author — "[Titre] — [Auteur] Posterg".
#### 2.4.3 Focus order
- [ ] **Search filter form on `search.php` appears above `<main>` in the DOM but is rendered
between the search bar and results visually** — the filter `<form class="search-controls">`
comes before `<main>` in source order when `$hasSearch` is true. This is fine for focus
order (source order = visual order). No issue.
- [ ] **On `tfe.php` the back link `← Retour` is at the bottom of the left column in DOM order**
— a keyboard user must tab through the entire metadata list and synopsis before reaching
it. Consider moving it to the top of the column (above `<h1>`), or adding a second copy
near the top, so keyboard users can quickly exit. This is a UX recommendation, not a hard
WCAG failure, but it affects 2.4.3 and 2.4.7.
#### 2.4.4 Link purpose
- [ ] **Home page cards: the link text is `author title`** — adequate. However, if two theses
share the same title (possible), two identical link texts exist. Consider adding the year:
`author title (year)` in a visually-hidden `<span class="sr-only">` appended to the link.
- [ ] **Search results cards: same issue**`<span class="result-card__authors">` + title +
meta inside `<a>`. The combined text read by screen readers will be "Author · Title · Year · Orientation"
which is actually quite good. No hard failure here.
- [ ] **Pagination links use Unicode arrows `«`, ``, ``, `»` as their only text** — these are
announced by screen readers as "double left-pointing angle quotation mark" or similar
gibberish. Add `aria-label` to each: `aria-label="Première page"`, `aria-label="Page précédente"`,
`aria-label="Page suivante"`, `aria-label="Dernière page"`.
#### 2.4.6 Headings and labels
- [ ] **`tfe.php` heading hierarchy is inverted** — author is `<h1>`, thesis title is `<h2>`.
The work's title is the primary topic of the page and should be `<h1>`. The author name is
a label/metadata, not a heading. This is flagged in the semantic audit but it is also
directly a WCAG 2.4.6 failure (heading does not describe the topic of the page).
- [ ] **`search.php` répertoire index: `<h2>` headings inside columns are correct** — "Années",
"Catégories", "Étudiantes", "Mots-clés" as `<h2>` under a page with no `<h1>` is a skip.
Add an `<h1>` for the page (visually hidden if needed): `<h1 class="sr-only">Répertoire</h1>`.
Same for `index.php` which has no heading at all.
#### 2.4.7 Focus visible
- [ ] **No `:focus-visible` style defined anywhere in the public CSS**`common.css`,
`main.css`, `search.css`, `tfe.css`, and `apropos.css` contain zero `:focus` or
`:focus-visible` rules. `modern-normalize` does not add any either. The browser's default
focus ring is the only indicator, and it is suppressed by `outline: none` on
`.site-search__input` in `common.css`. This is a clear WCAG 2.4.7 failure.
Define a consistent focus style for all interactive elements:
```css
:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
```
in `common.css`. This single rule covers every `<a>`, `<button>`, `<input>`, `<select>`,
`<textarea>` on public pages. For admin: same using `var(--admin-purple)`.
- [ ] **`outline: none` on `.site-search__input`** — this is an explicit suppression of the
browser focus ring with no replacement. Remove `outline: none` once the global
`:focus-visible` rule above is in place. Same for `outline: none` on `.admin-input`,
`.admin-select`, `.admin-textarea`, and `.search-filter-select`.
#### 2.5.3 Label in name
- [ ] **`<a class="clear-filter">✕ Réinitialiser</a>`** — the visible label starts with a
symbol. Fine as long as "Réinitialiser" is in the accessible name, which it is (it's text
content). No failure here, but the `` should be `aria-hidden="true"`.
- [ ] **Admin jury remove buttons ``** — the visible label is `` only. The accessible name
must contain (or start with) the visible label text. Since `` has no speech equivalent,
`aria-label="Supprimer ce lecteur"` replaces it entirely, which satisfies 2.5.3.
#### 2.5.5 Target size (advisory in WCAG 2.1, required in WCAG 2.2)
- [ ] **Pagination buttons are `2rem` (32px) height** — below the 44×44px recommended target.
Increase to `min-height: 2.75rem` (44px) and `min-width: 2.75rem`.
- [ ] **Admin `.admin-btn-sm` (~28px height)** — used for Voir/Éditer/Publier/Dépublier in the
TFE table. Well below 44px. Since these are in a dense table, 44px may not be practical;
increase to at minimum 32px and add padding.
- [ ] **Admin bulk action buttons and jury remove `` buttons (~28px)** — same issue.
---
### 3 — Understandable
#### 3.1.1 Language of page
- [ ] **All public pages have `<html lang="fr">`** — correct. ✓
- [ ] **`search.php` 429 response emits `<html>` with no `lang` attribute** — fails 3.1.1.
Fix: `echo '<!DOCTYPE html><html lang="fr">…'`.
#### 3.2.1 On focus / 3.2.2 On input
- [ ] **No unexpected context changes on focus or input detected** — standard links and forms,
no `onchange` redirects. ✓
#### 3.3.1 Error identification
- [ ] **`add.php` / `formulaire.php` validation errors are shown as a single flash message at
the top of the page after a full round-trip** — the error says e.g. "Le champ 'Synopsis'
est requis" but focus is not moved to the `<div class="admin-alert--error">` nor to the
offending field. A screen reader user who has already moved past the alert region will not
hear the error. Fix: add `role="alert"` to the error div (so it is announced as a live
region on injection), and add `autofocus` to the first invalid field when re-rendering the
form with session error data.
- [ ] **Client-side validation (`required` attributes)** — native browser validation is present
on some fields (`required` on title, synopsis, etc.). The browser's native error popups are
accessible but vary across browsers. No issue here, though the error messages cannot be
styled consistently.
#### 3.3.2 Labels or instructions
- [ ] **`search-bar.php` input has no `<label>` — only `placeholder="Recherche..."`** —
Placeholders disappear on focus and are not a substitute for labels. WCAG 3.3.2 requires
labels or instructions for all inputs. Add a visually-hidden `<label for="site-search-input" class="sr-only">Recherche</label>` and `id="site-search-input"` on the input. Or use `aria-label="Recherche"` on the input directly.
- [ ] **Admin jury "Lecteur·ices" label has no `for` attribute** — `<label class="admin-label">Lecteur·ices :</label>` references no control (because the control is a dynamic list). The label should be a `<legend>` inside the enclosing `<fieldset>`, or the lecteur rows should be wrapped in their own `<fieldset>/<legend>`.
---
### 4 — Robust
#### 4.1.1 Parsing
- [ ] **`pages-edit.php` has a `<link>` element inside `<body>`** — invalid HTML. Confirmed in
the semantic audit (section XV). Browsers tolerate it but validators flag it and some AT
may misinterpret the document structure.
#### 4.1.2 Name, role, value
- [ ] **Custom checkbox "Externe" for jury members has no group label** — the checkbox
`<input type="checkbox" name="jury_promoteur_ext">` is labelled by the adjacent
`<label class="admin-checkbox-label admin-jury-ext">Externe</label>` which does wrap it.
Good. But the word "Externe" alone provides no context about *what* is external. A screen
reader user hears "Externe, checkbox, not checked" with no reference to the jury member.
Use `aria-label="[Nom du promoteur] est externe"` set dynamically via JS when the name
field is filled, or add a static `aria-describedby` pointing to the adjacent name input.
- [ ] **`<video>` elements on `tfe.php` have no captions** — `<video controls>` with no `<track kind="captions">`. For publicly uploaded video content, captions are required under WCAG 1.2.2
(Captions — Prerecorded). This is a content/upload-time concern rather than a template fix,
but the template should at minimum include a `<track>` slot and the admin upload form
should document the requirement.
- [ ] **`<embed>` for PDFs has no accessible alternative** — `<embed type="application/pdf">` is
not accessible to screen readers or keyboard users who cannot operate PDF viewers in-browser.
Add a fallback download link below every embed:
`<a href="/media.php?path=…&download=1">Télécharger le PDF</a>`.
- [ ] **Admin `<select>` for visibility/access in `edit.php` uses truncated option text** —
`mb_strimwidth($at['description'], 0, 60, '…')` truncates the access type description to
60 chars with an ellipsis. The truncated text becomes the accessible name of the option.
Use the full description in the option text (or a `title` attribute), and keep the truncated
text only for visual display.
- [ ] **Bulk publish/unpublish JS does not announce result to screen readers** — after
`bulkAction()` submits the form and the page reloads, the success/error message appears
in a `<div class="admin-alert">` with no `role="status"` or `role="alert"`. A screen reader
will not announce it unless focus moves to it. Add `role="alert"` to error messages and
`role="status"` to success messages across all admin pages.
---
### 5 — Additional: motion & user preferences
- [ ] **`prefers-reduced-motion` is not respected** — `main.css` has `transition: transform 0.3s ease`
on card hover images (scale animation). `common.css`, `search.css`, and `admin.css` all
have `transition: opacity/color/background 0.15s` rules. None are guarded by
`@media (prefers-reduced-motion: reduce)`. Add:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
```
in `common.css`. The card `transform: scale(1.02)` on hover is the most noticeable motion
and should also be gated.
- [ ] **`prefers-color-scheme` is not respected** — the site has a fixed white public theme and
a fixed dark admin theme. Users who have set their OS to dark mode will receive the white
public site regardless. Not a WCAG failure (SC does not require dark-mode support) but
worth noting as a quality-of-life improvement.
---
### 6 — Missing global infrastructure
These are things that must be added once and apply everywhere:
- [ ] **Add `.sr-only` utility class to `common.css`** — needed for skip links, visually-hidden
labels, and screen-reader-only context text referenced throughout this audit:
```css
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
```
- [ ] **Add skip-to-content link in all page templates** — as described in 2.4.1 above. This
one change has the highest impact-per-line-of-code ratio of any item in this audit.
- [ ] **Add global `:focus-visible` rule in `common.css` and `admin.css`** — as described in
2.4.7. Second highest impact item.
- [ ] **Remove all `outline: none` declarations that have no replacement focus style** —
`common.css:125`, `admin.css:121`, `admin.css:323`, `search.css:241`.