Files
xamxam/TODO.md
Pontoporeia f37069720a schema: add composite index (is_published, year DESC) + fix stale migration 005
- Add idx_theses_pub_year composite index on theses(is_published, year DESC) to
  schema.sql; replaces the need for the query planner to pick between the two
  separate idx_theses_published / idx_theses_year indexes and sort with a temp
  B-tree. Every public query filters on is_published=1 and orders/filters by year,
  so this covering index eliminates the sort pass for those queries.

- Create storage/migrations/006_add_composite_index.sql and apply to both
  posterg.db and test.db.

- Fix storage/migrations/005_add_banner.sql: the view recreation in that file
  still referenced the pre-migration-001 table/column names (thesis_keywords,
  keywords.keyword). Updated to use thesis_tags / tags tg to match the canonical
  schema.sql. The live DB was unaffected (migration 001 ran before 005), but the
  file was misleading and would fail if ever re-run from scratch.
2026-03-27 13:48:22 +01:00

1322 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TODO
## Styling Redesign (matching design images)
- [x] Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
- [x] Redesign shared search bar (full-width, icon, bottom border only, white bg)
- [x] Rewrite `common.css` (nav + search bar components)
- [x] Rewrite `main.css` (home page — white bg, media card grid, label below)
- [x] Rewrite `search.css` (répertoire index — 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS)
- [x] Rewrite `tfe.css` (TFE page — 2-col, large author/title left, media right)
- [x] Add `apropos.css` (À Propos — 2-col, large monospace text)
- [x] Rewrite `admin.css` (dark bg, purple gradient nav, bottom-border-only form inputs)
- [x] Update `templates/nav.php` (new shared nav partial)
- [x] Update `templates/search-bar.php` (new shared search bar partial)
- [x] Rewrite `public/index.php` (home page with new layout)
- [x] Rewrite `public/search.php` (répertoire index view + search results view)
- [x] Rewrite `public/tfe.php` (individual TFE page)
- [x] Create `public/apropos.php` (À Propos page)
- [x] Rewrite `templates/admin/head.php` (admin nav)
- [x] Rewrite `templates/admin/footer.php` (clean close)
- [x] Rewrite `public/admin/add.php` (form with row layout)
- [x] Rewrite `public/admin/index.php` (dark table)
- [x] Rewrite `public/admin/edit.php` (form with row layout)
- [x] Rewrite `public/admin/login.php` (centered dark login box)
- [x] Rewrite `public/admin/thanks.php` (dark info cards)
- [x] Rewrite `public/admin/import.php` (clean dark form)
## Justfile / Ops
- [x] Simplify `serve` and `deploy` to one recipe each
- [x] Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
- [x] Extract server scripts to `scripts/` (deploy-server.sh, manage-admin-users.sh)
- [x] Guard `deploy-db` against overwriting existing remote database
- [x] Update README.md and docs/SERVER_SETUP.md to reflect current structure
---
## NEW FEATURES
### 1 — License page (public)
Create a public-facing `/licence.php` page, styled consistently with `apropos.php`.
- [x] **`public/licence.php`** — new public page; fetches content from `pages` table
(slug `'licenses'`); renders with Parsedown Markdown; uses `apropos.css` layout
- [x] **`templates/nav.php`** — add "Licence" link between "Répertoire" and "À Propos";
apply `site-nav__link--active` when `$currentNav === 'licence'`
- [x] The `pages` table row for slug `'licenses'` verified in live DB
---
### 2 — Admin: WYSIWYG/Markdown editors for static pages
Allow admins to edit the content of the "À propos" and "Licence" pages from the admin
panel, stored in the existing `pages` table.
#### 2a — `src/Database.php`
- [x] `getPage(string $slug): array|null``SELECT * FROM pages WHERE slug = ?`
- [x] `savePage(string $slug, string $content): void` — throws if slug not found
- [x] `getAllPages(): array` — for listing in admin
#### 2b — Admin pages editor UI
- [x] **`public/admin/pages.php`** — list all editable pages; links to edit each one
- [x] **`public/admin/pages-edit.php`** — EasyMDE WYSIWYG Markdown editor via CDN
#### 2c — `public/admin/actions/page.php`
- [x] Auth guard + CSRF check + slug validation + length validation + savePage + redirect
#### 2d — Public pages render Markdown
- [x] **`public/apropos.php`** — renders `$db->getPage('about')` via Parsedown (bundled `src/Parsedown.php`)
- [x] **`public/licence.php`** — renders `$db->getPage('licenses')` via Parsedown
- [x] Parsedown bundled as `src/Parsedown.php` (zero-dependency, MIT)
#### 2e — Nav links in admin
- [x] **`templates/admin/head.php`** — "Pages statiques" nav item added
---
### 3 — License field on TFE forms
Add a "Licence" dropdown to the Add and Edit TFE forms. The `license_types` table
already exists in the schema with an `id`, `name`, `description` structure but has
no seed data yet.
#### 3a — Schema / DB
- [x] **`storage/schema.sql`** — seed `INSERT OR IGNORE` for 8 CC licence types added
- [x] **`storage/migrations/003_seed_license_types.sql`** — migration created + applied
- [x] Verified live DB has `license_types` with 8 rows
#### 3b — `src/Database.php`
- [x] `getLicenseTypes(): array`
- [x] `getAllLicenseTypes(): array` — alias
#### 3c — Add form (`public/admin/add.php`)
- [x] Loads `$licenseTypes`; "Licence" `<select name="license_id">` added before duration
#### 3d — Add action (`public/admin/actions/formulaire.php`)
- [x] `$licenseId` parsed + included in INSERT
#### 3e — Edit form (`public/admin/edit.php`)
- [x] Loads `$licenseTypes`; raw `license_id` FK fetched directly; select pre-populated
- [x] POST handler: `license_id` included in UPDATE
#### 3f — View update
- [x] **`storage/schema.sql`** — `v_theses_full` now exposes `t.license_id` raw FK (done as part of 004/005 view rebuild)
#### 3g — TFE public page
- [x] **`public/tfe.php`** — "Licence :" meta row added, shown when non-null
---
### 4 — Jury composition section in Add/Edit forms
Replace the current flat "promoteur interne / externe" fields with a structured
**Composition du jury** section: président·e, promoteur·ice, lecteur·ices (mixed
internal/external).
#### 4a — Schema / DB
Current state: `supervisors` table + `thesis_supervisors` junction with a bare
`supervisor_order` integer. No role distinction.
- [x] **`storage/migrations/004_jury_roles.sql`**:
- `ALTER TABLE thesis_supervisors ADD COLUMN role TEXT NOT NULL DEFAULT 'promoteur'`
— role values: `'president'`, `'promoteur'`, `'lecteur'`
- `ALTER TABLE thesis_supervisors ADD COLUMN is_external INTEGER NOT NULL DEFAULT 0`
— 1 = external, 0 = internal (replaces the old "promoteur externe" free-text field)
- No data loss: existing rows get `role = 'promoteur'`, `is_external = 0`
- [x] **`storage/schema.sql`** — add the two new columns to `thesis_supervisors`
definition; update `v_theses_full` to expose jury members grouped by role:
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='president' THEN s.name END) as jury_president`
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as jury_promoteurs`
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as jury_lecteurs`
- Keep the existing `supervisors` column (all names) for backwards compat
- Migration SQL must DROP + CREATE the view
#### 4b — `src/Database.php`
- [x] `getThesisJury(int $thesisId): array` — fetch all supervisors for a thesis with
their role and is_external flag
- [x] `setThesisJury(int $thesisId, array $juryMembers): void` — delete + re-insert
#### 4c — Add form (`public/admin/add.php`)
- [x] Remove `promoteurice` and `promoteurice_externe` fields
- [x] Add **"Composition du jury"** fieldset: président·e, promoteur·ice + externe checkbox, dynamic lecteur·ices list with JS add/remove
#### 4d — Add action (`public/admin/actions/formulaire.php`)
- [x] Removed old promoteurice parsing; parse jury fields; call `$db->setThesisJury()`
#### 4e — Edit form (`public/admin/edit.php`)
- [x] Load `$jury = $db->getThesisJury($thesisId)`; jury fieldset pre-populated from DB
- [x] POST handler calls `$db->setThesisJury()`
#### 4f — TFE public page (`public/tfe.php`)
- [x] Three conditional jury rows: Président·e, Promoteur·ice, Lecteur·ices
---
### 5 — Banner image upload for home page cards
Each TFE can have an optional banner image used as its home page card thumbnail.
This is distinct from the existing "couverture" file concept (which goes into
`thesis_files` as type `'cover'`) — the banner is specifically optimised for the
home grid (wider, shorter aspect ratio).
#### 5a — Schema / DB
- [x] **`storage/migrations/005_add_banner.sql`** — `ALTER TABLE theses ADD COLUMN banner_path TEXT`
- [x] **`storage/schema.sql`** — `banner_path TEXT` in `theses`; `t.banner_path` in view
#### 5b — Add form (`public/admin/add.php`)
- [x] "Image bannière" file input added after couverture row
#### 5c — Add action (`public/admin/actions/formulaire.php`)
- [x] Banner upload: MIME check, 5 MB cap, save to `banners/`, call `setBannerPath()`
#### 5d — Edit form (`public/admin/edit.php`)
- [x] Banner preview img shown; remove_banner checkbox; new banner upload input
- [x] POST handler: unlinks old file on remove; processes new upload via `setBannerPath()`
#### 5e — `src/Database.php`
- [x] `banner_path` exposed via view — verified; `getThesisById()` / `getPublishedTheses()` pick it up automatically
- [x] `setBannerPath(int $thesisId, ?string $path): void` added
---
### 6 — Home page: gradient placeholder cards & random ordering
#### 6a — Gradient placeholder for cards without a banner
- [x] **`public/index.php`** — gradient placeholder using HSL hue from thesis ID
- [x] **`public/assets/main.css`** — `.card__media--gradient`, `.card__gradient-author`,
`.card__gradient-title` styles added
#### 6b — Banner image as card thumbnail
- [x] **`public/index.php`** — checks `banner_path` first, falls through to gradient
#### 6c — Random ordering from the latest year
- [x] **`src/Database.php`** — `getLatestYearTheses(int $limit = 24)` + `getLatestPublishedYear()`
- [x] **`public/index.php`** — default home view uses random latest-year selection;
paginated view for `?year=X` and `?page=N`; info label shown
---
## Refactor: M2M tags via `tags` + `thesis_tags` junction table
The current schema stores keywords in a `keywords` table joined via `thesis_keywords`.
The field column is named `keyword` (not `name`), breaking the naming convention used by
every other lookup table (`orientations.name`, `format_types.name`, etc.).
More critically, `buildSearchConditions` and the view `v_theses_full` filter keywords
through `GROUP_CONCAT` strings with `LIKE`, bypassing the junction table entirely.
Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis_tags`,
`tags.name`), add the missing index, and rewrite all tag queries to use a proper JOIN.
### 1 — Schema migration (`storage/schema.sql` + live DB)
- [x] Rename table `keywords``tags`; column `keyword``name`
- [x] Rename junction `thesis_keywords``thesis_tags`; FK `keyword_id``tag_id`
- [x] PK on `thesis_tags(tag_id, thesis_id)`; `idx_tags_name`; updated index names
- [x] Views `v_theses_full`/`v_theses_public` use `thesis_tags`/`tags.name`
- [x] Migration `storage/migrations/001_rename_keywords_to_tags.sql` written and applied
### 2 — `src/Database.php`
- [x] `findOrCreateTag()` added; `findOrCreateKeyword()` is a backwards-compat alias
- [x] `getUsedTags()` rewritten with proper M2M JOIN; `getUsedKeywords()` alias kept
- [x] `buildSearchConditions`: keyword/query use `EXISTS` subquery on `thesis_tags`/`tags`
- [x] All conditions prefixed with `vp.` to match view alias; `vp` alias added to search queries
### 3 — Admin write paths
- [x] `public/admin/actions/formulaire.php`: uses `findOrCreateTag` + `thesis_tags`
- [x] `public/admin/edit.php`: `DELETE FROM thesis_tags` + `findOrCreateTag` + `thesis_tags`
### 4 — Public read paths
- [x] `public/search.php`: fixed `$kw['keyword']``$kw['name']` (tag column rename)
- [x] `getUsedKeywords()` alias delegates to `getUsedTags()` — no functional change needed
- [x] `public/tfe.php`: `$data['keywords']` still works — view column name unchanged
- [x] `templates/search-bar.php`: no keyword param refs — verified
### 5 — Admin tag management UI (`/admin/tags.php`)
#### 5a — `src/Database.php`
- [x] `getAllTagsWithCount()`, `renameTag()`, `mergeTag()`, `deleteTag()`
#### 5b — `public/admin/tags.php`
- [x] Auth guard, CSRF, table with rename/merge/delete per row, inline forms
#### 5c — `public/admin/actions/tag.php`
- [x] Routes on `$_POST['action']`: rename, merge, delete
#### 5d — Nav & routing
- [x] `templates/admin/head.php`: "Mots-clés" nav link added
#### 5e — Propagation safety
- [x] mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK
### 6 — Tests
- [x] `tests/Unit/DatabaseTest.php`: tests 57 cover findOrCreateTag, getUsedTags, alias
- [x] `tests/Integration/SearchTest.php`: tests 46 cover tag-filter subquery, full-text query, count consistency
### 6 — Fixtures / seed data
- [x] `storage/fixtures/CreateTestDatabase.php`: updated to `tags`/`thesis_tags`/`findOrCreateTag()`
---
## Feature: Mode Maintenance
- [x] Storage flag file `storage/maintenance.flag` (created on demand)
- [x] Public gate in `config/bootstrap.php` — blocks non-admin routes when flag exists
- [x] `public/maintenance.php` (503 page, minimal dark UI)
- [x] `public/admin/actions/maintenance.php` (POST: enable/disable)
- [x] Admin UI toggle in `public/admin/index.php` (bar with status + action button)
---
## Feature: TFE Visibility States (publique / interne / interdit)
- [x] DB migration `002_add_visibility.sql` — seeds access_types rows (already existed)
- [x] `src/Database.php``setVisibility()`, `bulkSetVisibility()`, `getAccessTypes()`
- [x] `public/media.php` — blocks thesis files when access_type_id = 3 (Interdit)
- [x] `public/tfe.php` — shows access type, context_note, hides files for Interdit
- [x] `public/admin/edit.php` — access_type_id select + context_note textarea
- [x] `public/admin/index.php` — three-state access badge per row
- [x] `public/admin/actions/visibility.php` — single + bulk visibility update
---
## Fixes
- [x] Fix `tests/Security/SecurityTest.php`: update SQL injection test to call `searchTheses(['query' => $string])` instead of bare string — `searchTheses()` signature was updated to `array $params` but the test was never updated, causing a fatal `TypeError` that prevented the security suite from running at all
## Pending
- [x] Add flake.nix for Nix-based PHP dev environment
- [x] Add favicon (`<link rel="icon">` → admin_favicon.svg) to all pages; nginx 204 for /favicon.ico
- [x] Remove 100-item cap from répertoire student index: `getAllPublishedTheses()` fetches all published theses; search results remain paginated at 30/page
- [x] Cover image fallback for home grid cards: batch-load `thesis_files` covers for theses without `banner_path`; resolution order: banner → cover → gradient
## Admin / Server
- [x] Create `scripts/setup-server.sh` (one-time server setup: group, ownership, setgid 2775 on dirs)
- [x] Add `just setup-server` recipe (rsync + run setup-server.sh on remote)
- [x] Exclude `.claude` and `.pi` from rsync deploy
- [x] Update `docs/SERVER_SETUP.md` with correct permissions rationale and troubleshooting
- [x] Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
- [x] Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
- [x] Add nginx config viewer to admin panel: "nginx — config" tab in `system.php` reads `/etc/nginx/sites-available/posterg` (live, with badge) or falls back to local `nginx/posterg.conf`; line-numbered, syntax-coloured, copy-to-clipboard
- [x] Add admin user management UI — password change/set for PHP auth layer (`public/admin/account.php` + `actions/account.php`; "Compte" nav link; account CSS)
- [x] Merge `status.php` and `logs.php` into a single `system.php` page; remove "Statut" and "Journaux" nav links, add single "Système" link; preserve all existing content in their respective sections
- [x] Rework logs UI: replace the select-then-click-Afficher flow with instant tabs (nginx access, nginx error, php-fpm); switching tabs loads the selected log immediately without a form submit; add a copy-to-clipboard button per log view
---
## Refactor & Maintenance (backend audit 2026-03-26)
### A — SQLite / Query performance
- [x] **WAL mode** — set `PRAGMA journal_mode = WAL` and `PRAGMA synchronous = NORMAL` in `Database::__construct()` after `foreign_keys = ON`.
Eliminates full-database read-locks on every write; makes concurrent PHP-FPM workers safe.
Also add `PRAGMA cache_size = -8000` (≈8 MB page cache) while there.
- [x] **Composite index `(is_published, year DESC)`** on `theses` — every public query filters on both.
Currently `idx_theses_published` and `idx_theses_year` are separate; the query planner picks one
and sorts the other with a temp B-tree. A single covering index eliminates the sort:
`CREATE INDEX IF NOT EXISTS idx_theses_pub_year ON theses(is_published, year DESC);`
Add to `schema.sql` + a migration.
- [ ] **`v_theses_full` is always fully materialised** — every query against `v_theses_public` forces
SQLite to expand the entire view CTE (all 15 JOINs + 8 GROUP_CONCAT temp B-trees) before
applying the `WHERE`/`LIMIT`. The view cannot be used with an index. Fix: **replace `v_theses_public`
with a plain table query in `searchTheses` and `getPublishedTheses`** — build the minimal JOIN set
per query and let indexes filter `theses` first. Keep the views for reporting/admin use only.
- [ ] **`getAllPublishedTheses()` in `search.php`** — fetches every published thesis (all columns,
all JOINs) just to build the student-name index on the Répertoire page. This is a full table
scan through the fat view. Replace with a dedicated lean query:
`SELECT id, authors FROM v_theses_public ORDER BY authors ASC` — or add
`Database::getPublishedAuthors(): array` that queries `thesis_authors JOIN authors` directly,
avoiding the view entirely.
- [x] **`migration 005` view is stale in the file** — `005_add_banner.sql` recreates the view still
referencing `thesis_keywords` / `keywords.keyword` (the old pre-migration-001 names).
The file is already applied to the live DB (correctly, since 001 ran first), but the migration
file itself is wrong and misleading. Fix: rewrite it to reference `thesis_tags` / `tags.name`,
or drop the view recreation from it (schema.sql is the canonical source).
### B — PHP / Database.php
- [ ] **Dead CRUD helpers**`getOrientationId()`, `getAPProgramId()`, `getFinalityId()`,
`getLanguageId()`, `getFormatId()` are defined in `Database.php` but **never called** anywhere
(forms now pass IDs directly from selects). Remove them to reduce surface area.
- [ ] **Alias proliferation**`getOrientations()`/`getAllOrientations()`, `getApPrograms()`/`getAllAPPrograms()`,
`getFinalityTypes()`/`getAllFinalityTypes()`, `getLanguages()`/`getAllLanguages()`,
`getFormatTypes()`/`getAllFormatTypes()`, `getLicenseTypes()`/`getAllLicenseTypes()`,
`findOrCreateKeyword()`, `getUsedKeywords()`**13 alias methods** pointing at 6 real ones.
Pick the canonical name for each pair, update all call-sites (there are few), and delete aliases.
Reduces Database.php from ~945 lines significantly.
- [ ] **`getPDO()` / `getConnection()` leaking to callers** — `edit.php`, `formulaire.php`,
`thanks.php`, `import.php`, `tfe.php`, `index.php`, `media.php`, `system.php` all call
`$db->getPDO()` or `$db->getConnection()` to run raw queries that belong in `Database.php`.
Each is a missed encapsulation:
- `tfe.php`: raw `SELECT access_type_id FROM theses WHERE id = ?` → add `getThesisAccessTypeId(int $id): ?int`
- `index.php`: raw `SELECT thesis_id, file_path FROM thesis_files WHERE … IN (…)` → add `getCoverPathsForTheses(array $ids): array`
- `media.php`: raw visibility join → move into `Database::getFileVisibility(string $path): ?int`
- `edit.php` (line 155): unparameterised `"… WHERE id = $thesisId"` **SQL injection risk** — fix immediately; also move to a DB method
- `edit.php`: raw `SELECT license_id, access_type_id, context_note FROM theses WHERE id = ?` → expose these via `getThesis()` (already returns `v_theses_full` which has `license_id`)
- `formulaire.php`: raw identifier-generation query + all junction-table INSERTs → encapsulate in `Database::createThesis(array $data): int`
- [ ] **`sanitize_string()` in `formulaire.php` applies `htmlspecialchars` at write time** —
HTML-escaping belongs at render time (in the template), not at storage time. Storing
`&amp;` or `&lt;` in the DB means search, export, and any non-HTML consumer sees corrupt data.
Remove `htmlspecialchars` from `sanitize_string()`; keep only `trim()`. The templates already
call `htmlspecialchars()` on output.
- [ ] **Dead variable `$problematique`**`formulaire.php` line 84 reads `$_POST["problématique"]`
into `$problematique` but the value is **never used** (no matching column, no INSERT reference).
Delete it.
- [ ] **`setThesisJury()` not wrapped in a transaction** — the method does a DELETE then multiple
INSERTs with no transaction guard of its own. If called from outside a transaction (e.g. a
future API endpoint) a partial failure leaves orphaned rows. Wrap the body in
`BEGIN … COMMIT / ROLLBACK` (check `$this->pdo->inTransaction()` to avoid nesting).
- [ ] **DB config auto-detection is fragile**`src/config.php` switches to `test.db` whenever the
file exists locally, which means a developer who ran tests and forgot to delete `test.db` will
silently hit test data on a local production-mirror. Make the default `prod`; require explicit
`DB_ENV=test` to use the test database.
### C — Code organisation / maintainability
- [ ] **`edit.php` does too much** — 530 lines combining form display, POST handling, file upload,
and all reference-data loading in one file. Extract the POST handler to
`public/admin/actions/edit.php` (matching the pattern already used by `formulaire.php`,
`tag.php`, `page.php`, etc.).
- [ ] **`formulaire.php` duplicates banner-upload logic verbatim from `edit.php`** — the MIME check,
size cap, `random_bytes` name, `chmod`, and `setBannerPath()` call are copy-pasted.
Extract a private `Database`-level helper or a standalone `uploadBanner(array $file): ?string`
utility function (returns the relative path or null) shared by both action files.
- [ ] **Junction-table INSERTs are open-coded in every action**`formulaire.php` and `edit.php`
both manually loop `INSERT INTO thesis_languages`, `thesis_formats`, `thesis_tags`.
Add `Database::setThesisLanguages(int $id, array $ids)`, `setThesisFormats(int $id, array $ids)`,
`setThesisTags(int $id, array $names)` — following the same delete-then-reinsert pattern
already used by `setThesisJury()`.
- [ ] **`RateLimit` uses per-file JSON on disk** — reads, writes, and `glob()`s the filesystem on
every public request. For a low-traffic art-school site this is fine, but it creates a
write-on-every-hit pattern. Consider switching to APCu (if available) or SQLite (single INSERT)
to avoid filesystem churn. At minimum, move the cache dir to `/tmp` or a dedicated
`storage/cache/` path that is excluded from deploy rsync.
- [ ] **`__wakeup()` singleton guard throws from a public method** — PHP 8.x deprecates
throwing exceptions from `__wakeup`. Change to `trigger_error(…, E_USER_ERROR)` or implement
`__serialize()`/`__unserialize()` that always throw.
---
## Refactor & Maintenance — Templates & Frontend (audit 2026-03-26)
### D — Template structure / boilerplate duplication
- [ ] **Every public page duplicates its own `<head>`**`index.php`, `search.php`, `tfe.php`,
`apropos.php`, `licence.php` each contain an identical block: `<!DOCTYPE html>`,
`<html lang="fr">`, `<meta charset>`, `<meta viewport>`, `<link rel="icon">`,
`<link modern-normalize>`, `<link common.css>`, live-reload script. Only `<title>` and one
extra CSS `<link>` differ. Extract a `templates/public/head.php` partial accepting
`$pageTitle` and `$extraCss` — mirrors the pattern `templates/admin/head.php` already uses.
- [ ] **Live-reload snippet copy-pasted into 6 files**`index.php`, `search.php`, `tfe.php`,
`apropos.php`, `licence.php`, `templates/admin/head.php` all contain the same 6-line
`(function poll(){…})()` block. Consolidate into the shared head partials.
- [ ] **`templates/header.php` and `templates/head.php` are dead files** — neither is `include`d
anywhere in the codebase. Both contain outdated markup from a previous design iteration
(`lang="en"`, empty author meta, a broken nav with double-quoted `href` attributes inside
`href`). Delete both to remove confusion.
- [ ] **`public/assets/icons.svg` is dead** — it is the full TrumboWYG editor icon sprite (40+
symbols) referenced **nowhere** in the codebase. The only WYSIWYG editor in use (EasyMDE
in `pages-edit.php`) loads from CDN. Delete `icons.svg` (~15 KB of noise).
- [ ] **`admin_favicon.svg` used as the public-facing favicon** — every public page links
`/assets/admin_favicon.svg`. Rename or create a distinct `favicon.svg` so admin and public
can diverge without naming confusion.
### E — CSS architecture
- [ ] **`html, body { margin:0; padding:0; height:100% }` repeated in 4 page stylesheets** —
`main.css`, `search.css`, `tfe.css`, `apropos.css` all open with this identical block.
Move it to `common.css` once; delete from the four files. Same for the body-level flex-column
shell (`display:flex; flex-direction:column; background:var(--white)`) which only differs in
the BEM class name applied to `<body>`.
- [ ] **No `font-display` on the `Combinedd.otf` custom font**`common.css` declares `@font-face`
with no `font-display` property; the browser blocks text rendering until the font loads (FOIT).
Add `font-display: swap`. Also add a `<link rel="preload">` for the font file in the shared
head partial once it exists.
- [ ] **Search results pagination is fully inline-styled**`search.php` lines 159164 apply
`style="padding:.25rem .7rem;border:1px solid #ddd;…"` and hardcoded `#ddd`/`#666`. The home
page (`index.php`) already has `.pagination-btn` / `.pagination-info` in `main.css`. Reuse
those classes in `search.php` and remove the inline styles.
- [ ] **Scattered inline styles in templates** — notable instances that should become named classes:
- `tfe.php` line 146: `style="align-items:start;"``.tfe-meta-item--top` in `tfe.css`
- `tfe.php` lines 148, 170172, 193: `font-style:italic`, `margin-top:1.5rem`,
`font-size:.88rem;color:#666`, `color:#999;font-style:italic``.tfe-note-value`,
`.tfe-back-link`, `.tfe-restricted` in `tfe.css`
- `admin/edit.php`: multiple `style=` on `.admin-form-row` and banner preview → modifier
classes in `admin.css`
- `index.php` line 146: `style="padding:2rem;color:#666;"``.cards-empty` in `main.css`
- [ ] **`.site-nav__right` is a duplicate of `.site-nav__link`** — `common.css` defines both with
identical declarations (font-size, letter-spacing, text-transform, color, opacity, transition).
The only difference is DOM position. Merge `.site-nav__right` into `.site-nav__link`; let the
flex layout position it via `margin-left:auto` or DOM order.
- [ ] **`.site-nav__link--active` is applied in `nav.php` but never defined in CSS** — the class
is set conditionally but has no corresponding rule in `common.css`, so the active state is
invisible. Add a visible style (e.g. `opacity:1; border-bottom:1px solid rgba(255,255,255,.6)`)
or remove the conditional.
### F — Template logic / PHP in templates
- [ ] **Rate-limit 429 response in `search.php` emits unstyled bare HTML** — the early-exit block
outputs `<!DOCTYPE html><html><body><h1>Trop de requêtes</h1>…` with no stylesheet, no lang,
no viewport meta. Style it inline-minimally or redirect to a consistent `429.php` page (like
`maintenance.php`).
- [ ] **`apropos.php` contacts and credits are hardcoded in the template** — names, roles, emails
(Laurent Leprince, Xavier Gorgol, Brigitte Ledune) and credits text live in PHP/HTML and
require a code deploy to change. Either move them into the `about` page Markdown (admin-
editable) or extract to a config array so they are in one place.
- [ ] **`licence.php` wastes half the viewport with an always-empty right column** — the page
reuses the two-column `.apropos-layout` but `<div class="apropos-right"></div>` is always
empty. Add a `.apropos-layout--single` variant (or just `grid-template-columns:1fr` when
the right child is empty) to use the full width for content.
### G — Accessibility & semantics
- [ ] **`<nav>` in `nav.php` has no `aria-label`** — pages have multiple landmark regions (main
nav, search `<form>`, pagination). Add `aria-label="Navigation principale"` to the `<nav>`
and `aria-label="Pagination"` to pagination wrappers so screen readers can distinguish them.
- [ ] **Search bar `<form>` has no accessible name**`search-bar.php` has no `aria-label` on the
`<form>` and no `<label>` for the input (only a placeholder). Add `aria-label="Recherche"` to
the `<form>` element.
- [ ] **No `<meta name="description">` on any public page** — all public pages omit the description
meta tag (the dead `templates/head.php` had `content=""`). Add per-page descriptions:
site blurb for `index.php`, synopsis excerpt for `tfe.php`, page content intro for
`apropos.php`/`licence.php`. Necessary for search indexing and link preview cards.
- [ ] **No Open Graph tags**`tfe.php` is the ideal candidate for `og:title`, `og:description`
(synopsis), `og:image` (banner or cover path through `/media.php`), `og:type=article`.
Without them, sharing a thesis link on social media or messaging apps shows a blank preview.
### H — Minor / low-hanging fruit
- [ ] **`admin/thanks.php` duplicates `getThesisFiles()` with a raw PDO query** — lines 3440
manually prepare `SELECT … FROM thesis_files WHERE thesis_id = ?` instead of calling
`$db->getThesisFiles($thesisId)` which already exists. Replace with the DB method.
- [ ] **`admin/index.php` stats computed via PHP `array_filter` on full result set** — "total",
"publiés", "en attente" counts are derived by filtering the already-fetched `$theses` array
in PHP. When a filter is active the stats reflect only filtered rows, which is misleading.
Add `Database::getThesesStats(): array` returning three counts from SQL
(`COUNT(*)`, `SUM(is_published)`, `SUM(1-is_published)`) so they always reflect the full DB.
---
## Semantic HTML audit (2026-03-26)
Goal: replace presentational wrappers with the element that already carries the correct meaning,
removing classes where the element name itself is sufficient as a CSS selector.
The design does **not** need to change — only the vocabulary of the markup.
### I — `templates/nav.php` & `templates/search-bar.php`
- [ ] **`<div class="site-nav__links">`** wraps the centre nav links purely for flex grouping.
Replace with `<ul>` + `<li>` children (links inside a nav belong in a list — standard
pattern). The `<a>` elements stay; CSS targets `nav ul` / `nav li` / `nav a` directly,
removing `.site-nav__links`, `.site-nav__link`, `.site-nav__right` classes entirely.
`nav a[aria-current="page"]` replaces the missing `.site-nav__link--active` rule and is
self-documenting.
- [ ] **`<form class="site-search">`** is already a `<form>` — good. Add
`role="search"` and `aria-label="Recherche"`. The SVG icon should get
`aria-hidden="true"` (it's decorative). The `<input>` should have an associated
`<label>` (visually hidden via `.sr-only` is fine, or `aria-label` on the input).
### II — `public/index.php`
- [ ] **`<div class="filter-info">`** is a status/notice banner. Use `<p role="status">` or
`<output>` — both carry live-region semantics for screen readers without extra ARIA.
- [ ] **`<div class="cards-container">`** is a list of navigable items. Replace with `<ul>`
removing the wrapper div and making each card an `<li>`. `.cards-container` → target `main > ul`
or a single class on `<ul>`.
- [ ] **`<a class="card-link"><div class="card">…</div></a>`** — the outer `<a>` wrapping a `<div>`
makes the div redundant. The `<a>` is already a block element (set `display:block`). The
`.card` div can be removed; CSS targets `ul li a` directly. The `<li>` inside the `<ul>`
becomes the card container.
- [ ] **`<div class="card__media">`** — this is the image/media wrapper inside each card.
When it contains an `<img>`, use `<figure>` (a self-contained media unit). When it shows
the gradient placeholder (no real image), a plain `<div>` is fine since it's presentational.
- [ ] **`<div class="card__info"><p class="authors">…</p></div>`** — the `.card__info` wrapper
exists only to add padding. Move the padding to the `<p>` or `<li>` directly; remove the
div. The `<p>` stays. `.authors` class → either keep it or target `li > p`.
- [ ] **`<div class="pagination-wrap">`** with `<a class="pagination-btn">` and
`<span class="pagination-info">` — replace with `<nav aria-label="Pagination"><ul>…</ul></nav>`.
Each button becomes an `<li>`. The disabled state uses `aria-disabled="true"` +
`tabindex="-1"` instead of a `.disabled` class alone (which has no keyboard semantics).
`<span class="pagination-info">``<li aria-current="page">1 / 5</li>`.
### III — `public/search.php`
- [ ] **`<div class="search-filter-group">`** wraps each label+select pair. Replace with
`<label>` directly wrapping `<select>` — one element instead of two, and the label/control
association is implicit. Remove `.search-filter-group` and `.search-filter-label` (the
`<label>` element is the label). CSS targets `form label` and `form select`.
- [ ] **`<span class="search-filter-label">`** inside the filter group — deleted once the `<label>`
approach is taken (see above).
- [ ] **`<div class="search-results-view">`** is unnecessary nesting inside `<main>`. `<main>` is
already the landmark. Remove the wrapper; apply padding directly to `<main>` or its direct
children.
- [ ] **`<div class="results-grid">`** is a list of search results. Replace with `<ul class="results-grid">`.
Each `<a class="result-card">` becomes a `<li><a>` — the link text is made up of child `<span>`s
which is correct. However `.result-card__authors` and `.result-card__title` `<span>`s would be
better as `<strong>` (author, emphasis) and the title as plain text or `<span>`. The year/meta
`<span class="result-card__meta">``<small>` (ancillary metadata).
- [ ] **Répertoire index: `<div class="repertoire-index">`** — replace with `<div>` kept but its
four children are semantic candidates: each `.repertoire-col` is an independent index with a
heading. Replace `<div class="repertoire-col">` with `<section>`. The heading
(`<h2 class="repertoire-col__header">`) is already correct — `<h2>` is right. Remove
`.repertoire-col__header`; CSS targets `section > h2` scoped inside `.repertoire-index`.
- [ ] **`.year-index-item`, `.cat-index-item`, `.student-index-item`, `.keyword-index-item`** — all
four are sequences of `<a>` links with `display:block`. They are lists. Wrap each group in
`<ul>`; each link becomes `<li><a>`. The four custom classes collapse to a single `ul a`
selector per column (or no class at all, scoped via `section`). The `.active` class on links
`aria-current="page"` on the `<a>`.
- [ ] **`<p class="search-results-header">`** count line — remove `.search-results-header`; this is
a plain `<p>` styled with `.search-main p:first-child` or just keep a lightweight class. Or use
`<output>` since it is a computed result count.
### IV — `public/tfe.php`
- [ ] **`<div class="tfe-layout">`** — the two-column grid container. Replace with `<article>`
a single thesis is genuinely a self-contained piece of content. Remove `.tfe-layout`;
CSS targets `main > article` for the grid. Remove `.tfe-main`; CSS targets `main` directly.
- [ ] **`<div class="tfe-left">`** — the info/metadata column. Replace with `<header>` of the
article (it contains the author name, title, and all metadata — the article header). Or
simply remove and target `article > :first-child` if that is too strong. Actually
`<header>` is semantically correct here: it is the identifying header of the article.
- [ ] **`<div class="tfe-meta-list">`** with `<div class="tfe-meta-item"><span class="label">…</span><span class="value">…</span></div>` — this is
a description list by definition. Replace with `<dl>` / `<dt>` / `<dd>`:
- `<dl class="tfe-meta-list">` → just `<dl>` (class optional)
- `<div class="tfe-meta-item">` → remove; `<dt>`+`<dd>` are direct children of `<dl>`
(or grouped in `<div>` inside `<dl>` which is valid HTML — the spec allows it for styling)
- `<span class="label">``<dt>`
- `<span class="value">``<dd>`
- CSS: `.tfe-meta-list``dl`; `.label``dl dt`; `.value``dl dd`
This removes ~5 classes and ~30 wrapper divs from the metadata section.
- [ ] **`<div class="tfe-synopsis-text">`** — the synopsis paragraph(s). Replace with `<p>` (or
keep as `<section class="synopsis">` if multi-paragraph, but a single `<p>` suffices for
most cases). Remove the wrapper div.
- [ ] **`<div style="margin-top:1.5rem;"><a href="index.php" style="…">← Retour</a></div>`** —
remove the wrapper div; move margin to the `<a>` itself as a class. The back link is
better as `<a rel="up" href="index.php" class="back-link">← Retour</a>` (no wrapper needed).
- [ ] **`<div class="tfe-right">`** — the media column. Replace with `<aside>` — it contains
supplementary files (media, PDFs) that are related but secondary to the descriptive content.
Remove `.tfe-right`; CSS targets `article > aside`.
- [ ] **`<div class="tfe-media-block">`** — each file display unit. Replace with `<figure>`.
Image and video files become `<figure><img></figure>` and `<figure><video></video></figure>`.
The existing `<p class="tfe-file-caption">``<figcaption>`. PDF `<embed>` stays in a
`<figure>` (valid). Remove `.tfe-media-block`; CSS targets `aside figure`.
- [ ] **`<h1 class="tfe-author">` and `<h2 class="tfe-title">`** — the heading hierarchy makes
the author the primary heading and the title secondary, which is backwards semantically.
The *title* of the work is the `<h1>`; the *author* is metadata (could be a `<p>` or a `<dt>`
in the `<dl>` above). Swap: `<h1>` = title, author moves into the `<dl>`. Keeps the visual
design (CSS controls size) but fixes the document outline.
### V — `public/apropos.php`
- [ ] **`<div class="apropos-layout">`** — two-column grid. Replace with `<div>` kept but the
children are semantic: left is the main content, right is supplementary.
Left `<div class="apropos-left">` → remove (redundant wrapper around already-styled content).
Right `<div class="apropos-right">``<aside>` (contacts, credits = supplementary info).
- [ ] **`<div class="apropos-description apropos-page-content">`** inside the left col —
the Parsedown output already generates `<p>`, `<h1>``<h3>`, `<ul>` etc.
The wrapping `<div>` is only needed for the `.apropos-page-content` scoped CSS rules.
Keep it but as a single class — `<div class="prose">` — and scope all Markdown content
styles under `.prose`. This is the standard prose-container pattern.
- [ ] **`<div class="apropos-contact">`** — each contact entry. Replace with `<address>`:
the HTML spec defines `<address>` for contact information related to the document or section.
Each contact is literally an address entry. `<span class="apropos-contact-name">`
`<strong>`, `<span class="apropos-contact-role">` → plain text or `<span>`,
`<span class="apropos-contact-email">``<a href="mailto:…">`. Three classes removed.
- [ ] **Outer `<div>` wrappers around each section in `.apropos-right`** (`<div><h2>…</h2></div>`,
`<div><h2>Contacts</h2>…</div>`, `<div><h2>Crédits</h2>…</div>`) — replace each with
`<section>`. Remove the anonymous `<div>` wrappers; CSS targets `aside section > h2`.
### VI — `public/licence.php`
- [ ] **`<div class="apropos-right"></div>`** — always-empty right column. Remove entirely; the
`licence.php` page is full-width content. Update `licence.php` to not use `.apropos-layout`
at all — just `<main class="apropos-main"><div class="prose">…</div></main>`.
No class changes needed to `apropos.css`; the layout simply is not applied.
### VII — Summary of class deletions enabled by semantic changes
Once the above is applied, the following classes become deletable (element name carries the meaning):
| Class removed | Replaced by |
|---|---|
| `.site-nav__links` | `nav ul` |
| `.site-nav__link` | `nav li a` |
| `.site-nav__right` | `nav li:last-child a` (or `[aria-label]` target) |
| `.site-nav__link--active` | `[aria-current="page"]` |
| `.card-link` | `ul li a` (block `<a>` inside `<li>`) |
| `.card` | `ul li` |
| `.tfe-layout` | `main > article` |
| `.tfe-left` | `article > header` |
| `.tfe-right` | `article > aside` |
| `.tfe-meta-list` | `dl` |
| `.tfe-meta-item` | `div` inside `dl` (or removed) |
| `.label` / `.value` | `dt` / `dd` |
| `.tfe-media-block` | `figure` |
| `.tfe-file-caption` | `figcaption` |
| `.tfe-synopsis-text` | `p` (direct child of `article > header`) |
| `.search-filter-label` | `label` |
| `.search-filter-group` | `label` (wrapping approach) |
| `.repertoire-col` | `section` |
| `.repertoire-col__header` | `section > h2` |
| `.year-index-item` etc. | `ul a` (scoped per section) |
| `.result-card__meta` | `small` |
| `.results-grid` | `ul.results-grid` (only class needed) |
| `.apropos-left` | removed (direct child of grid) |
| `.apropos-right` | `aside` |
| `.apropos-contact` | `address` |
| `.apropos-contact-name` | `strong` inside `address` |
| `.apropos-contact-email` | `a[href^="mailto:"]` inside `address` |
| `.apropos-description apropos-page-content` | `.prose` (single class) |
---
## Semantic HTML audit — Admin section (2026-03-26)
### VIII — `templates/admin/head.php` (admin nav)
- [ ] **Admin nav links are bare `<a>` tags in a flat `<nav>`** — identical problem as the public
nav. All seven nav links (`Liste des TFE`, `Ajouter`, `Importer`, `Pages statiques`,
`Mots-clés`, `Système`, `Compte`) plus the conditional `Modifier` and `Déconnexion` links
are direct children of `<nav>`. Replace with `<ul>/<li>` children.
Active state `.active` class → `aria-current="page"` on the `<a>`. Remove `.admin-nav__link`
as a selector; use `nav ul a` scoped to `.admin-nav`. The `Déconnexion` link with
`style="margin-left:auto;opacity:.6;"` → becomes `nav ul li:last-child a` or a utility
class, removing the inline style.
- [ ] **`<nav class="admin-nav">` has no `aria-label`** — add `aria-label="Navigation admin"` to
distinguish it from any other landmark on the page.
### IX — `public/admin/add.php` & `public/admin/edit.php` (TFE forms)
- [ ] **`.admin-form-row` is a `<div>` used to lay out a `<label>` beside an `<input>`** — for
every field where the `<label>` has a `for=` attribute (i.e. all single-control rows), the
`<div class="admin-form-row">` is a pure layout wrapper. It can be replaced with a CSS
grid applied directly to the `<form>` children, or more practically: the `<label>` and its
control remain direct children of the `<form>` and CSS grid spans them with
`grid-template-columns: 260px 1fr`. This removes one `<div>` per form field — about
**20 divs** from `add.php` and **22 divs** from `edit.php`.
- [ ] **Multi-control rows (checkboxes, file inputs with hint text) wrap their controls in an
anonymous `<div>`** — e.g. `<div class="admin-form-row"><label>…</label><div><input><p class="admin-hint">…</p></div></div>`.
The inner `<div>` only exists to stack the input above the hint. Replace the hint `<p>` with
`<small>` (ancillary text) and remove the wrapper div — `<small>` stacks naturally below its
sibling `<input>`.
- [ ] **`<div class="admin-checkbox-list">` wrapping `<label><input type=checkbox></label>` items**
— this is a list of options; replace with `<ul>` (no class needed, or a single utility
class). Each `<label class="admin-checkbox-label">` is an `<li>` containing the `<label>`.
Removes `.admin-checkbox-list` and `.admin-checkbox-label` classes (the `li label` selector
is sufficient).
- [ ] **Jury fieldset is good**`<fieldset>` + `<legend>` is correct semantic HTML. No change
needed. The inner `<div class="admin-jury-row">` and `<div class="admin-jury-entry">` are
acceptable layout helpers for the dynamic row pattern; they are harder to replace without
JS complications.
- [ ] **`<div class="admin-submit-wrap">` at the bottom of every form** — wraps only a `<button>`
(and sometimes a cancel link). Remove the div; apply top margin and padding directly to the
`<button>` with a class or as the last-child `form > button` selector.
- [ ] **`<div class="admin-alert admin-alert--error">` and `..--success`** — these are notices.
Replace with `<p role="alert">` (errors) and `<p role="status">` (success messages).
Both carry live-region semantics natively. Removes two block-level divs per page load.
- [ ] **`<input type="hidden">` fields for CSRF** — correct, no change. But they sit as bare
siblings inside the `<form>` before the grid rows. Fine.
### X — `public/admin/index.php` (TFE list)
- [ ] **`<div class="admin-stats">` with `<div class="admin-stat">` children** — the stats
(total, published, pending) are a set of key-value pairs. Replace with `<dl>`:
`<div class="admin-stats">``<dl class="admin-stats">`;
each `<div class="admin-stat">``<div>` kept (valid `<dl>` child for grouping, per spec);
`<div class="admin-stat__number">``<dd>`;
`<div class="admin-stat__label">``<dt>`.
Removes two classes; makes the numbers machine-readable as defined terms.
- [ ] **`<div class="admin-maintenance-bar">` (status banner)** — this is a status notice + action
form. Replace the outer `<div>` with `<aside role="status">` or `<p role="status">` for the
text portion. The form inside stays as `<form>`. Removes one class.
- [ ] **`<div class="admin-bulk-actions">` bar** — a toolbar that appears conditionally. Replace
with `<div role="toolbar" aria-label="Actions groupées">`. Not a full semantic element
replacement, but adds correct ARIA role for the keyboard/AT pattern of a toolbar.
- [ ] **`<table class="admin-table">` is correct** — tabular data, right element. No change
needed. The `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>` structure is correct.
Minor: `<th>` cells have no `scope="col"` attribute — add it for screen reader column
association (`<th scope="col">`).
- [ ] **Status badges `<span class="status-badge status-published">Publié</span>`** — these are
inline state labels. Semantically fine as `<span>` but could benefit from
`role="status"` or at minimum a visually-hidden text prefix (e.g. "Statut :") so screen
readers don't just announce "Publié" without context.
### XI — `public/admin/tags.php`
- [ ] **Three `<form>` elements per table row (rename, merge, delete)** — structurally correct
(each action is a separate form submission). No semantic issue. Minor: the inline
`style="margin-top:.35rem;"` on two of the three forms → move to CSS (e.g.
`.admin-inline-form + .admin-inline-form` selector in `admin.css`).
- [ ] **`<table>` with `<th>` cells lacking `scope="col"`** — same as `index.php`.
### XII — `public/admin/thanks.php`
- [ ] **`<div class="admin-thesis-info">` blocks** — each is a labelled section with a `<dl>`
inside it (already using `<dl>/<dt>/<dd>` correctly — good!). The outer wrapper `<div>`
could be a `<section>` with the `<h2>` as its heading, making the structure
`<section><h2>…</h2><dl>…</dl></section>`. Removes `.admin-thesis-info` class; CSS targets
`main > section`.
### XIII — `public/admin/account.php`
- [ ] **`<div class="admin-account-status">` with `<div class="admin-account-status__row">` children**
— each row is a key-value pair (label + status badge). Replace with `<dl>`:
`.admin-account-status``<dl>`;
`.admin-account-status__row``<div>` (valid `<dl>` child);
`.admin-account-status__label``<dt>`;
the badge/code stays as `<dd>` content.
Removes three classes.
- [ ] **`<h2 class="admin-section-title">` is correct** — scoped subsection headings inside
`<main>` at level 2 are right. The `style="margin-top:3rem;"` on the danger zone heading
→ move to CSS (`.admin-section-title + .admin-danger-zone` or a modifier class).
- [ ] **`<div class="admin-danger-zone">` containing a `<div class="admin-danger-zone__description">`
and a `<form>`** — the description div wrapping a `<strong>` and a `<span>` is over-wrapped.
The `<strong>` and following text are already inline; they don't need a block wrapper.
Replace `<div class="admin-danger-zone__description">` with `<p>` (it is a paragraph of
warning text). Removes one class and one div.
### XIV — `public/admin/login.php`
- [ ] **Login page has the correct structure overall**`<form>`, `<label for>`, `<input>` are
properly associated. The `<div class="admin-login-wrap">` and `<div class="admin-login-box">`
are layout wrappers with no semantic equivalent — they can stay (centering a login box has
no semantic HTML counterpart). Minor improvement: wrap the whole login box in `<main>` so
it is the page's main landmark (currently there is none on the login page).
- [ ] **Inline styles on the login form rows**`style="grid-template-columns:1fr;border:none;padding:.4rem 0;"`
on `.admin-form-row` and `style="margin-top:1rem;padding-top:.5rem;"` on `.admin-submit-wrap`
→ extract as a `.admin-form-row--compact` modifier and use in `admin.css`.
### XV — `public/admin/pages-edit.php`
- [ ] **`<link rel="stylesheet">` injected after `<main>` opens** — the EasyMDE stylesheet CDN
link is placed after the `</head>` has already closed (after `head.php` is included). It
sits directly inside `<body>` before `<main>`. This is invalid HTML — `<link>` is a head
element. Move it into the `<head>` by passing it to the head template via a `$extraCss`
variable (the mechanism already exists in the dead `templates/head.php`). Same for the
EasyMDE `<script>` tag which currently floats after `</main>`.
### XVI — Summary of admin class deletions enabled by semantic changes
| Class removed | Replaced by |
|---|---|
| `.admin-nav__link` | `nav ul a` |
| `.admin-nav__link.active` | `[aria-current="page"]` |
| `.admin-form-row` | direct `form > label + input` grid (or keep as minimal layout class) |
| `.admin-label` | `label` (scoped to `.admin-form`) |
| `.admin-checkbox-list` | `ul` inside form row |
| `.admin-checkbox-label` | `li label` |
| `.admin-submit-wrap` | `form > button:last-child` or slim `.submit` class |
| `.admin-alert--error` / `--success` | `p[role="alert"]` / `p[role="status"]` |
| `.admin-stat` | `div` inside `<dl>` |
| `.admin-stat__number` | `dd` |
| `.admin-stat__label` | `dt` |
| `.admin-thesis-info` | `section` |
| `.admin-account-status__row` | `div` inside `<dl>` |
| `.admin-account-status__label` | `dt` |
| `.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:
- [x] **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.
- [x] **Add skip-to-content link in all page templates** — added to all 5 public pages and
admin head template; `id="main-content"` added to every `<main>` in the codebase.
- [x] **Add global `:focus-visible` rule in `common.css` and `admin.css`** — consistent
2px purple outline with 2px offset; `prefers-reduced-motion` guard also added.
- [x] **Remove all `outline: none` declarations that have no replacement focus style** —
removed from `common.css`, `admin.css` (×2), and `search.css`.