mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
- admin/index.php: replace <div class="admin-stats"> with <dl>; inner <div class="admin-stat__number"> → <dd>, <div class="admin-stat__label"> → <dt>; use CSS order to keep number visually first; add scope="col" to all 9 <th> cells - admin/thanks.php: replace all four <div class="admin-thesis-info"> wrappers with <section> elements; remove the class entirely; add scope="col" to the files table <th> cells - admin/tags.php: add scope="col" to all 3 <th> cells - admin/pages.php: add scope="col" to all 4 <th> cells - admin.css: rename .admin-thesis-info selectors to .admin-main > section (element + context selector — no class needed); add display:flex + flex-direction:column to .admin-stat so CSS order property works correctly Addresses TODO items: section X (admin-stats dl, th scope), XI (tags th scope), XII (admin-thesis-info → section), XIII (pages.php th scope)
1400 lines
81 KiB
Markdown
1400 lines
81 KiB
Markdown
# TODO
|
||
|
||
## Template Simplification — Remove Custom Classes Where Semantic HTML Suffices
|
||
|
||
### CSS class audit: replace with semantic selectors
|
||
- [ ] **`admin.css`**: Replace `.admin-main` with `.admin-body main` — only one `<main>` per page
|
||
- [ ] **`admin.css`**: Replace `.admin-page-title` with `.admin-body main > h1` — always the first `h1` in `<main>`
|
||
- [ ] **`admin.css`**: Replace `.admin-alert` / `.admin-alert--error` / `.admin-alert--success` with `[role="alert"]` or `.admin-body main > .alert` using `data-type="error|success"` attribute instead of modifier classes
|
||
- [ ] **`admin.css`**: Replace `.admin-form-row` with `.admin-body form > div` or `.admin-body form > .row` — form rows are always direct `<div>` children of `<form>`
|
||
- [ ] **`admin.css`**: Replace `.admin-label` with `.admin-body form label` — every label in admin forms
|
||
- [ ] **`admin.css`**: Replace `.admin-input` / `.admin-select` / `.admin-textarea` with `.admin-body form input[type="text"]`, `.admin-body form select`, `.admin-body form textarea` — leverage native element selectors
|
||
- [ ] **`admin.css`**: Replace `.admin-hint` with `.admin-body form small` — use `<small>` instead of `<p class="admin-hint">`
|
||
- [ ] **`admin.css`**: Replace `.admin-table` with `.admin-body table` — only one table per admin page
|
||
- [ ] **`admin.css`**: Replace `.admin-fieldset` / `.admin-fieldset-legend` with `.admin-body fieldset` / `.admin-body legend`
|
||
- [ ] **`main.css`**: Replace `.card__caption` with `.home-body .cards-container li p` or target `li > a > p` directly
|
||
- [ ] **`main.css`**: Replace `.card__media` with `.home-body figure` — already uses `<figure>` elements
|
||
- [ ] **`tfe.css`**: Replace `.tfe-meta-list` selectors with `article dl`, `article dt`, `article dd` — already using `<dl>` inside `<article>`
|
||
- [ ] **`tfe.css`**: Replace `.tfe-media-block` with `aside figure` — already wrapped in `<figure>` inside `<aside>`
|
||
- [ ] **`tfe.css`**: Replace `.tfe-file-caption` with `aside figcaption` — native `<figcaption>` element
|
||
- [ ] **`search.css`**: Replace `.repertoire-col > h2` styling — already targets `section > h2`, can use `.repertoire-index section > h2`
|
||
- [x] **`common.css`**: Replace `.site-search__icon` with `header form[role="search"] svg`
|
||
- [x] **`common.css`**: Replace `.site-search__input` with `header form[role="search"] input`
|
||
- [x] **`common.css`**: Replace `.site-search` with `header form[role="search"]`
|
||
- [ ] **`system.php`**: Move inline `<style>` block to `system.css` (already in TODO, reinforced here)
|
||
|
||
### Template HTML changes to match
|
||
- [ ] In all admin templates, replace `<p class="admin-hint">` with `<small>` elements
|
||
- [ ] In `tfe.php`, remove `class="tfe-meta-list"` — target via `article dl`
|
||
- [ ] In `tfe.php`, remove `class="tfe-media-block"` — target via `aside figure`
|
||
- [ ] In `tfe.php`, remove `class="tfe-file-caption"` — target via `aside figcaption`
|
||
- [ ] In `index.php`, remove `class="card__caption"` — target via `li > a > p`
|
||
- [x] In `search-bar.php` and `header.php`, remove `class="site-search"`, `class="site-search__icon"` and `class="site-search__input"`
|
||
|
||
## PHP Components (Reusable Partials/Includes)
|
||
|
||
PHP has no component system, but `include`/`require` with variable scoping works as partials. These are already used (`head.php`, `header.php`, `footer.php`, `flash-messages.php`). New partials to extract:
|
||
|
||
### Form field partials — `templates/partials/form/`
|
||
- [ ] **`text-field.php`** — accepts `$name`, `$label`, `$value`, `$required`, `$placeholder`, `$hint`; renders the `<div>…<label>…<input>…<small>` pattern used ~15 times across `add.php` and `edit.php`
|
||
- [ ] **`select-field.php`** — accepts `$name`, `$label`, `$options[]`, `$selected`, `$required`; renders `<div>…<label>…<select>…</div>` pattern used ~6 times
|
||
- [ ] **`checkbox-list.php`** — accepts `$name`, `$label`, `$options[]`, `$checked[]`; renders the checkbox group pattern (languages, formats) used ~4 times across `add.php` and `edit.php`
|
||
- [ ] **`file-field.php`** — accepts `$name`, `$label`, `$accept`, `$hint`, `$multiple`; renders file input pattern used 3 times
|
||
- [ ] **`jury-fieldset.php`** — the entire jury composition fieldset + JS is duplicated verbatim between `add.php` and `edit.php`; extract into one partial accepting `$juryPresident`, `$juryPromoteur`, `$juryPromoteurExt`, `$juryLecteurs[]`
|
||
|
||
### Shared UI partials — `templates/partials/`
|
||
- [ ] **`pagination.php`** — pagination nav is duplicated between `index.php` and `search.php` with minor variations; unify into one partial accepting `$page`, `$totalPages`, `$baseParams[]`
|
||
- [ ] **`status-badge.php`** — the published/pending badge + access badge pattern is repeated in `index.php` admin table rows; extract into a partial
|
||
- [ ] **`admin-alert.php`** — rename/merge `flash-messages.php` to also handle the 3 different legacy flash key patterns (`$_SESSION['error']`, `$_SESSION['admin_error']`, `$_SESSION['edit_error']`, etc.) that pages still consume manually instead of via `App::consumeFlash()`
|
||
|
||
## System Page Caching — Database-Backed Status Cache
|
||
|
||
### Problem
|
||
The admin system page (`/admin/system.php`) runs expensive operations on every load:
|
||
- `systemctl` subprocess calls (~4 checks × ~100ms each)
|
||
- `curl` HTTP self-check (~200-500ms)
|
||
- `disk_total_space()`/`disk_free_space()` (fast but unnecessary per-request)
|
||
- Log file `tail` + `filesize` + `filemtime` (I/O bound)
|
||
- Nginx config file reading
|
||
|
||
### Solution: `system_cache` table + background refresh
|
||
- [ ] **Add `system_cache` table** to schema: `CREATE TABLE system_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)` — stores JSON-encoded status snapshots keyed by section
|
||
- [ ] **Add migration** `storage/migrations/007_system_cache.sql` to create the table
|
||
- [ ] **Add `SystemCache` class** (`src/SystemCache.php`) with methods:
|
||
- `get(string $key, int $maxAgeSec = 60): ?array` — returns cached JSON data if fresh, null if stale
|
||
- `set(string $key, array $data): void` — upserts cache row
|
||
- `isStale(string $key, int $maxAgeSec = 60): bool`
|
||
- [ ] **Refactor `system.php` status section** to:
|
||
1. Check `SystemCache::get('system_status', 120)` — 2-minute TTL
|
||
2. If cache hit → render from cache, show "mis en cache il y a X sec" label
|
||
3. If cache miss → run checks, store in cache, render
|
||
4. Add `?refresh=1` GET param to force-bypass cache
|
||
- [ ] **Refactor `system.php` log sections** to not cache (logs should always be live) but avoid re-reading on every tab switch — only read the active tab's log
|
||
- [ ] **Cache disk info** separately with 5-minute TTL: `SystemCache::get('disk_info', 300)`
|
||
- [ ] **Cache PHP info** separately with 1-hour TTL: `SystemCache::get('php_info', 3600)` — PHP config doesn't change at runtime
|
||
|
||
## In Progress (from previous plan)
|
||
- [ ] Extract `SearchController` — most complex public page (§2 step 4)
|
||
- [ ] Extract `SystemController` — biggest single-file win, 500→8 lines (§2 step 3, §5)
|
||
- [ ] Extract `ThesisEditController` — merges edit.php + actions/edit.php, deduplicate jury fieldset (§2 step 5)
|
||
- [ ] Extract remaining controllers one by one (§2 step 6)
|
||
- [ ] Consolidate action handlers into controller methods (§4)
|
||
- [ ] Introduce pagination partial `templates/partials/pagination.php` (§6)
|
||
- [ ] Introduce admin form partials: select-field, checkbox-list, jury-fieldset (§6)
|
||
- [ ] Unify flash message keys project-wide to `_flash_error` / `_flash_success` (§7)
|
||
- [ ] Move OG tag construction into controller logic (§8)
|
||
- [ ] Extract inline CSS/JS from `system.php` into separate assets (§5)
|
||
|
||
## Completed
|
||
- [x] Create `src/App.php` — boot, adminGuard, verifyCsrf, rotateCsrf, redirect, flash, consumeFlash, render
|
||
- [x] Auto-load `App.php` from `config/bootstrap.php`
|
||
- [x] Create `templates/partials/flash-messages.php` — unified flash partial with legacy key drain
|
||
- [x] Merge public and admin head/nav templates into unified `templates/head.php` and `templates/header.php`
|
||
- `templates/head.php` — outputs `<!DOCTYPE html>…</head><body class="…">`, reads `$bodyClass`, `$isAdmin`; handles admin title suffix, admin.css prepend, and OG tag suppression internally
|
||
- `templates/header.php` — outputs `<header>…</header>` with public nav + search bar or admin nav depending on `$isAdmin`
|
||
- Deleted: `templates/public/head.php`, `templates/admin/head.php`, `templates/nav.php`, `templates/admin/nav.php`
|
||
- All 11 admin pages and 5 public pages updated to set `$bodyClass` / `$isAdmin` and include new templates
|
||
- [x] Replace nav/header BEM custom classes with semantic HTML targeting in CSS
|
||
- `common.css`: `.site-nav` → `header nav`, `.site-nav__logo` → `header nav > a`, etc.
|
||
- `admin.css`: `.admin-nav` → `.admin-body header nav`, logout via `[data-nav-logout]` attribute
|
||
- [x] PHP vs Flask architecture analysis (`ANALYSIS_PHP_VS_FLASK.md`)
|
||
- [x] Refactoring recommendations for controller/template separation (`REFACTORING_RECOMMENDATIONS.md`)
|
||
|
||
---
|
||
|
||
# Historical TODO (pre-2026-03-31 — recovered from commit kkmmrrrkkyrs)
|
||
|
||
|
||
## 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
|
||
|
||
---
|
||
|
||
## Analysis / Reports
|
||
|
||
- [x] ORM assessment written → `docs/ORM_ASSESSMENT.md`
|
||
|
||
---
|
||
|
||
## 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 5-7 cover findOrCreateTag, getUsedTags, alias
|
||
- [x] `tests/Integration/SearchTest.php`: tests 4-6 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.
|
||
|
||
- [x] **`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. Replaced with `Database::getPublishedAuthors(): array` that queries
|
||
`thesis_authors JOIN authors` directly (only `id` + `authors` columns), avoiding the view entirely.
|
||
`getAllPublishedTheses()` kept but marked `@deprecated`.
|
||
|
||
- [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
|
||
|
||
- [x] **Dead CRUD helpers** - `getOrientationId()`, `getAPProgramId()`, `getFinalityId()`,
|
||
`getLanguageId()`, `getFormatId()` removed from `Database.php`; `import.php` updated to
|
||
use inline PDO queries in their place.
|
||
|
||
- [x] **Alias proliferation** - collapsed 13 alias methods: canonical names are the `getAllXxx`
|
||
variants (`getAllOrientations`, `getAllAPPrograms`, `getAllFinalityTypes`, `getAllFormatTypes`,
|
||
`getAllLanguages`, `getAllLicenseTypes`) plus `getUsedTags` and `findOrCreateTag`; all
|
||
call-sites updated (`search.php`, `import.php`); Database.php reduced from 948 → 848 lines.
|
||
|
||
- [x] **`getPDO()` / `getConnection()` leaking to callers** (partial - `tfe.php`, `index.php`, `media.php`, `system.php` cleaned up):
|
||
- [x] `tfe.php`: raw `SELECT access_type_id` → `getThesisAccessTypeId(int $id): ?int`
|
||
- [x] `index.php`: raw `SELECT thesis_id, file_path FROM thesis_files WHERE ... IN (...)` → `getCoverPathsForTheses(array $ids): array`
|
||
- [x] `media.php`: raw visibility join → `getFileVisibility(string $path): ?int`
|
||
- [x] `edit.php` (line 155): unparameterised `"... WHERE id = $thesisId"` SQL injection → fixed; raw `SELECT banner_path` → `getThesisBannerPath(int $id): ?string`
|
||
- [x] `edit.php`: raw `SELECT license_id, access_type_id, context_note` → `getThesisRawFields(int $id): ?array`
|
||
- [x] `system.php`: raw `SELECT COUNT(*) FROM theses` → `getThesisCount(): int`
|
||
- [x] `formulaire.php`: raw identifier-generation query + all junction-table INSERTs → encapsulate in `Database::createThesis(array $data): int`
|
||
|
||
- [x] **`sanitize_string()` in `formulaire.php` applies `htmlspecialchars` at write time** -
|
||
HTML-escaping belongs at render time (in the template), not at storage time. Storing
|
||
`&` or `<` in the DB means search, export, and any non-HTML consumer sees corrupt data.
|
||
Remove `htmlspecialchars` from `sanitize_string()`; keep only `strip_tags(trim())`. The templates already
|
||
call `htmlspecialchars()` on output.
|
||
|
||
- [x] **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).
|
||
Deleted.
|
||
|
||
- [x] **`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).
|
||
|
||
- [x] **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
|
||
|
||
- [x] **`edit.php` does too much** - POST handler extracted to `public/admin/actions/edit.php`;
|
||
`edit.php` is now display-only (loads data, renders form, reads flash messages from session).
|
||
Added `Database::updateThesis()`, `Database::setThesisAuthors()`,
|
||
`Database::getThesisLanguageIds()`, `Database::getThesisFormatIds()` to remove all raw PDO
|
||
from both files. Matches the pattern of `formulaire.php`, `tag.php`, `page.php`.
|
||
|
||
- [x] **`formulaire.php` duplicates banner-upload logic verbatim from `edit.php`** - extracted to
|
||
`Database::handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string`; both action
|
||
files now call the single method.
|
||
|
||
- [x] **Junction-table INSERTs are open-coded in every action** - added
|
||
`Database::setThesisLanguages()`, `setThesisFormats()`, `setThesisTags()` following the
|
||
delete-then-reinsert pattern of `setThesisJury()`; `formulaire.php` and `edit.php` updated.
|
||
|
||
- [x] Fix `fgetcsv()` deprecation warnings in `import.php` - added explicit `$escape = ''` parameter to all 5 calls
|
||
- [x] Run all pending DB migrations (001–006) on `storage/posterg.db` - `tags`/`thesis_tags` tables now exist
|
||
- [x] **`RateLimit` cache dir moved to `storage/cache/rate_limit/`** — default path changed from
|
||
`src/cache/rate_limit` (inside source tree) to `storage/cache/rate_limit` (via `dirname(__DIR__)`
|
||
relative to `src/RateLimit.php`). `.gitignore` updated to ignore `storage/cache/` instead of
|
||
the old `src/cache/rate_limit/`. `justfile` deploy rsync now excludes `storage/cache/*`.
|
||
Old `src/cache/` directory removed.
|
||
|
||
- [x] **`__wakeup()` singleton guard throws from a public method** - changed to
|
||
`trigger_error('Cannot unserialize singleton ...', E_USER_ERROR)` with explicit `void` return
|
||
type; eliminates the PHP 8.x deprecation notice.
|
||
|
||
---
|
||
|
||
## Refactor & Maintenance - Templates & Frontend (audit 2026-03-26)
|
||
|
||
### D - Template structure / boilerplate duplication
|
||
|
||
- [x] **Every public page duplicates its own `<head>`** - extracted to `templates/public/head.php`
|
||
accepting `$pageTitle` and `$extraCss`; all 5 public pages updated to use the partial.
|
||
Mirrors the pattern `templates/admin/head.php` already uses.
|
||
|
||
- [x] **Live-reload snippet copy-pasted into 6 files** - consolidated into `templates/public/head.php`;
|
||
removed from `index.php`, `search.php`, `tfe.php`, `apropos.php`, `licence.php`.
|
||
|
||
- [x] **`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.
|
||
|
||
- [x] **`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
|
||
|
||
- [x] **`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>`.
|
||
|
||
- [x] **No `font-display` on the `Combinedd.otf` custom font** - added `font-display: swap`
|
||
to `@font-face` in `common.css`; eliminates FOIT on first load.
|
||
|
||
- [x] **Search results pagination is fully inline-styled** - replaced inline styles in `search.php`
|
||
with `.pagination-wrap` / `.pagination-btn` / `.pagination-info` classes; added matching rules
|
||
to `search.css`; added `aria-disabled` + `tabindex="-1"` on disabled links; added `aria-label`
|
||
on prev/next links.
|
||
|
||
- [ ] **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, 170-172, 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`
|
||
- [x] `index.php` line 146: `style="padding:2rem;color:#666;"` → `.cards-empty` in `main.css`
|
||
|
||
- [x] **`.site-nav__right` is a duplicate of `.site-nav__link`** - removed `.site-nav__right` block
|
||
from `common.css`; updated `nav.php` to use `.site-nav__link` on the À Propos link.
|
||
|
||
- [x] **`.site-nav__link--active` is applied in `nav.php` but never defined in CSS** - added
|
||
`opacity:1; border-bottom:1px solid rgba(255,255,255,.6); padding-bottom:1px` rule to
|
||
`common.css`; active nav link is now visually distinct.
|
||
|
||
### F - Template logic / PHP in templates
|
||
|
||
- [x] **Rate-limit 429 response in `search.php` emits unstyled bare HTML** - replaced bare echo with
|
||
a properly structured HTML document (lang="fr", viewport meta, inline dark styles matching
|
||
`maintenance.php`); `$retrySeconds` injected into the user-facing message.
|
||
|
||
- [ ] **`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.
|
||
|
||
- [x] **`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. Added `.apropos-single` class + removed layout wrapper; content now uses full width
|
||
(max-width: 720px) without an empty right column.
|
||
|
||
### G - Accessibility & semantics
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **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; also add `role="search"`, a visually-hidden `<label>` linked via `for`/`id`.
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **No Open Graph tags** - `tfe.php` now emits `og:type=article`, `og:title`, `og:description`,
|
||
`og:url`, `og:image` (banner → first image file → none), `og:image:alt`, `og:site_name`,
|
||
`article:author`, `article:published_time`, plus `twitter:card`/`twitter:title`/`twitter:description`/
|
||
`twitter:image`. All other public pages (`index`, `search`, `apropos`, `licence`) emit basic
|
||
`og:type=website` tags. OG rendering is centralised in `templates/public/head.php` via `$ogTags` array.
|
||
|
||
### H - Minor / low-hanging fruit
|
||
|
||
- [x] **`admin/thanks.php` duplicates `getThesisFiles()` with a raw PDO query** - lines 34-40
|
||
manually prepare `SELECT ... FROM thesis_files WHERE thesis_id = ?` instead of calling
|
||
`$db->getThesisFiles($thesisId)` which already exists. Replace with the DB method.
|
||
|
||
- [x] **`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`
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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`
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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>`.
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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`.
|
||
|
||
- [x] **`<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`
|
||
|
||
- [x] **`<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`.
|
||
|
||
- [x] **`<span class="search-filter-label">`** inside the filter group - deleted once the `<label>`
|
||
approach is taken (see above).
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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).
|
||
|
||
- [x] **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`.
|
||
|
||
- [x] **`.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>`.
|
||
|
||
- [x] **`<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`
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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).
|
||
|
||
- [x] **`<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`.
|
||
|
||
- [x] **`<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`.
|
||
|
||
- [x] **`<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`
|
||
|
||
- [x] **`<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 class="apropos-aside">` (contacts, credits = supplementary info).
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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 `<span>`,
|
||
`<span class="apropos-contact-email">` → `<a href="mailto:...">`. Three classes removed.
|
||
|
||
- [x] **Outer `<div>` wrappers around each section in `.apropos-right`** (`<div><h2>...</h2></div>`,
|
||
`<div><h2>Contacts</h2>...</div>`, `<div><h2>Crédits</h2>...</div>`) - replaced with
|
||
`<section>`. CSS targets `aside section > h2`.
|
||
|
||
### VI - `public/licence.php`
|
||
|
||
- [x] **`<div class="apropos-right"></div>`** - always-empty right column. Removed entirely;
|
||
`licence.php` uses `<div class="prose apropos-single">` without the two-column layout.
|
||
|
||
### 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)
|
||
|
||
- [x] **Admin nav links are bare `<a>` tags in a flat `<nav>`** - replaced with `<ul class="admin-nav__list">/<li>` children.
|
||
Active state `.active` class → `aria-current="page"` on the `<a>`. Removed `.admin-nav__link`
|
||
selector; CSS now uses `.admin-nav__list a` scoped to the list. The `Déconnexion` link
|
||
`style="margin-left:auto;opacity:.6;"` → `.admin-nav__logout` utility class, inline style removed.
|
||
|
||
- [x] **`<nav class="admin-nav">` has no `aria-label`** - `aria-label="Navigation admin"` was already present; confirmed.
|
||
|
||
### 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)
|
||
|
||
- [x] **`<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.
|
||
|
||
- [x] **`<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`).
|
||
|
||
- [x] **`<table>` with `<th>` cells lacking `scope="col"`** - same as `index.php`.
|
||
|
||
### XII - `public/admin/thanks.php`
|
||
|
||
- [x] **`<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
|
||
`.admin-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`
|
||
|
||
- [x] **`<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)
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **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
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **Search filter `<select>` elements have no associated `<label>`** - replaced the three
|
||
`<span class="search-filter-label">` elements with `<label for="filter-year">`,
|
||
`<label for="filter-orientation">`, `<label for="filter-ap">`; added matching `id` attributes
|
||
to the `<select>` elements. Visual appearance unchanged (same CSS class).
|
||
|
||
- [ ] **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>`.
|
||
|
||
- [x] **`<target="_blank">` links give no warning** — `tfe.php` external links (baiu_link) now
|
||
include `<span class="sr-only">(ouvre dans un nouvel onglet)</span>` after the link text.
|
||
|
||
#### 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
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **`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.
|
||
|
||
- [x] **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`.
|
||
|
||
- [x] **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 240-250° (blue-indigo) pass AA. Every warm hue (0-230°) 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).
|
||
|
||
- [x] **`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)
|
||
|
||
- [x] **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).
|
||
|
||
- [x] **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
|
||
|
||
- [x] **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`.
|
||
|
||
- [x] **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
|
||
|
||
- [x] **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
|
||
|
||
- [x] **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
|
||
|
||
- [x] **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
|
||
|
||
- [x] **`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.
|
||
|
||
- [x] **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
|
||
|
||
- [x] **`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).
|
||
|
||
- [x] **`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>`.
|
||
`index.php` now has `<h1 class="sr-only">Mémoires de l'ERG</h1>` inside `<main>`.
|
||
|
||
#### 2.4.7 Focus visible
|
||
|
||
- [x] **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)`.
|
||
|
||
- [x] **`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. ✓
|
||
- [x] **`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
|
||
|
||
- [x] **`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
|
||
|
||
- [x] **`pages-edit.php` has a `<link>` element inside `<body>`** - invalid HTML. Confirmed in
|
||
the semantic audit (section XV). The EasyMDE CSS was already moved to `$extraCss` (rendered
|
||
in `<head>`). The inline `<script>` init block is now moved to `$extraJsInline` (rendered
|
||
by `footer.php` before `</body>`). CDN JS URL uses `$extraJs`. All scripts/styles valid.
|
||
|
||
#### 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.
|
||
|
||
- [x] **`<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
|
||
|
||
- [x] **`prefers-reduced-motion` is not respected** - global `transition-duration/animation-duration`
|
||
guard already in `common.css`; `main.css` now also suppresses the card hover
|
||
`transform: scale(1.02)` via a dedicated `@media (prefers-reduced-motion: reduce)` block.
|
||
|
||
- [ ] **`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`.
|