The jury composition fieldset (président·e, promoteur·ice + external checkbox, dynamic
lecteur·ices list with JS add/remove) was copy-pasted verbatim between the two longest
admin forms.
- Created templates/partials/form/jury-fieldset.php
- Consumes $juryPresident, $juryPromoteur, $juryPromoteurExt, $juryLecteurs[]
- Handles both add-mode (falls back to old()/wasSelected() flash helpers) and
edit-mode (pre-populates from DB-loaded variables)
- $juryIdx initialised from max(count($juryLecteurs), 1) — correct for both modes
- add.php: 311 → 251 lines (-60); entire fieldset + <script> replaced with one require
- edit.php: 359 → 289 lines (-70); PHP variable extraction kept inline before require
81 KiB
TODO
Template Simplification — Remove Custom Classes Where Semantic HTML Suffices
CSS class audit: replace with semantic selectors
admin.css: Replace.admin-mainwith.admin-body main— only one<main>per pageadmin.css: Replace.admin-page-titlewith.admin-body main > h1— always the firsth1in<main>admin.css: Replace.admin-alert/.admin-alert--error/.admin-alert--successwith[role="alert"]or.admin-body main > .alertusingdata-type="error|success"attribute instead of modifier classesadmin.css: Replace.admin-form-rowwith.admin-body form > divor.admin-body form > .row— form rows are always direct<div>children of<form>admin.css: Replace.admin-labelwith.admin-body form label— every label in admin formsadmin.css: Replace.admin-input/.admin-select/.admin-textareawith.admin-body form input[type="text"],.admin-body form select,.admin-body form textarea— leverage native element selectorsadmin.css: Replace.admin-hintwith.admin-body form small— use<small>instead of<p class="admin-hint">admin.css: Replace.admin-tablewith.admin-body table— only one table per admin pageadmin.css: Replace.admin-fieldset/.admin-fieldset-legendwith.admin-body fieldset/.admin-body legendmain.css: Replace.card__captionwith.home-body .cards-container li por targetli > a > pdirectlymain.css: Replace.card__mediawith.home-body figure— already uses<figure>elementstfe.css: Replace.tfe-meta-listselectors witharticle dl,article dt,article dd— already using<dl>inside<article>tfe.css: Replace.tfe-media-blockwithaside figure— already wrapped in<figure>inside<aside>tfe.css: Replace.tfe-file-captionwithaside figcaption— native<figcaption>elementsearch.css: Replace.repertoire-col > h2styling — already targetssection > h2, can use.repertoire-index section > h2common.css: Replace.site-search__iconwithheader form[role="search"] svgcommon.css: Replace.site-search__inputwithheader form[role="search"] inputcommon.css: Replace.site-searchwithheader form[role="search"]system.php: Move inline<style>block tosystem.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, removeclass="tfe-meta-list"— target viaarticle dl - In
tfe.php, removeclass="tfe-media-block"— target viaaside figure - In
tfe.php, removeclass="tfe-file-caption"— target viaaside figcaption - In
index.php, removeclass="card__caption"— target viali > a > p - In
search-bar.phpandheader.php, removeclass="site-search",class="site-search__icon"andclass="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 acrossadd.phpandedit.phpselect-field.php— accepts$name,$label,$options[],$selected,$required; renders<div>…<label>…<select>…</div>pattern used ~6 timescheckbox-list.php— accepts$name,$label,$options[],$checked[]; renders the checkbox group pattern (languages, formats) used ~4 times acrossadd.phpandedit.phpfile-field.php— accepts$name,$label,$accept,$hint,$multiple; renders file input pattern used 3 timesjury-fieldset.php— the entire jury composition fieldset + JS is duplicated verbatim betweenadd.phpandedit.php; extract into one partial accepting$juryPresident,$juryPromoteur,$juryPromoteurExt,$juryLecteurs[]
Shared UI partials — templates/partials/
pagination.php— pagination nav is duplicated betweenindex.phpandsearch.phpwith minor variations; unify into one partial accepting$page,$totalPages,$baseParams[]status-badge.php— the published/pending badge + access badge pattern is repeated inindex.phpadmin table rows; extract into a partialadmin-alert.php— rename/mergeflash-messages.phpto 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 viaApp::consumeFlash()
System Page Caching — Database-Backed Status Cache
Problem
The admin system page (/admin/system.php) runs expensive operations on every load:
systemctlsubprocess calls (~4 checks × ~100ms each)curlHTTP 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_cachetable 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.sqlto create the table - Add
SystemCacheclass (src/SystemCache.php) with methods:get(string $key, int $maxAgeSec = 60): ?array— returns cached JSON data if fresh, null if staleset(string $key, array $data): void— upserts cache rowisStale(string $key, int $maxAgeSec = 60): bool
- Refactor
system.phpstatus section to:- Check
SystemCache::get('system_status', 120)— 2-minute TTL - If cache hit → render from cache, show "mis en cache il y a X sec" label
- If cache miss → run checks, store in cache, render
- Add
?refresh=1GET param to force-bypass cache
- Check
- Refactor
system.phplog 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.phpinto separate assets (§5)
Completed
- Create
src/App.php— boot, adminGuard, verifyCsrf, rotateCsrf, redirect, flash, consumeFlash, render - Auto-load
App.phpfromconfig/bootstrap.php - Create
templates/partials/flash-messages.php— unified flash partial with legacy key drain - Merge public and admin head/nav templates into unified
templates/head.phpandtemplates/header.phptemplates/head.php— outputs<!DOCTYPE html>…</head><body class="…">, reads$bodyClass,$isAdmin; handles admin title suffix, admin.css prepend, and OG tag suppression internallytemplates/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/$isAdminand include new templates
- 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
- PHP vs Flask architecture analysis (
ANALYSIS_PHP_VS_FLASK.md) - Refactoring recommendations for controller/template separation (
REFACTORING_RECOMMENDATIONS.md)
Historical TODO (pre-2026-03-31 — recovered from commit kkmmrrrkkyrs)
Styling Redesign (matching design images)
- Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
- Redesign shared search bar (full-width, icon, bottom border only, white bg)
- Rewrite
common.css(nav + search bar components) - Rewrite
main.css(home page - white bg, media card grid, label below) - Rewrite
search.css(répertoire index - 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS) - Rewrite
tfe.css(TFE page - 2-col, large author/title left, media right) - Add
apropos.css(À Propos - 2-col, large monospace text) - Rewrite
admin.css(dark bg, purple gradient nav, bottom-border-only form inputs) - Update
templates/nav.php(new shared nav partial) - Update
templates/search-bar.php(new shared search bar partial) - Rewrite
public/index.php(home page with new layout) - Rewrite
public/search.php(répertoire index view + search results view) - Rewrite
public/tfe.php(individual TFE page) - Create
public/apropos.php(À Propos page) - Rewrite
templates/admin/head.php(admin nav) - Rewrite
templates/admin/footer.php(clean close) - Rewrite
public/admin/add.php(form with row layout) - Rewrite
public/admin/index.php(dark table) - Rewrite
public/admin/edit.php(form with row layout) - Rewrite
public/admin/login.php(centered dark login box) - Rewrite
public/admin/thanks.php(dark info cards) - Rewrite
public/admin/import.php(clean dark form)
Justfile / Ops
- Simplify
serveanddeployto one recipe each - Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
- Extract server scripts to
scripts/(deploy-server.sh, manage-admin-users.sh) - Guard
deploy-dbagainst overwriting existing remote database - Update README.md and docs/SERVER_SETUP.md to reflect current structure
Analysis / Reports
- 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.
public/licence.php- new public page; fetches content frompagestable (slug'licenses'); renders with Parsedown Markdown; usesapropos.csslayouttemplates/nav.php- add "Licence" link between "Répertoire" and "À Propos"; applysite-nav__link--activewhen$currentNav === 'licence'- The
pagestable 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
getPage(string $slug): array|null-SELECT * FROM pages WHERE slug = ?savePage(string $slug, string $content): void- throws if slug not foundgetAllPages(): array- for listing in admin
2b - Admin pages editor UI
public/admin/pages.php- list all editable pages; links to edit each onepublic/admin/pages-edit.php- EasyMDE WYSIWYG Markdown editor via CDN
2c - public/admin/actions/page.php
- Auth guard + CSRF check + slug validation + length validation + savePage + redirect
2d - Public pages render Markdown
public/apropos.php- renders$db->getPage('about')via Parsedown (bundledsrc/Parsedown.php)public/licence.php- renders$db->getPage('licenses')via Parsedown- Parsedown bundled as
src/Parsedown.php(zero-dependency, MIT)
2e - Nav links in admin
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
storage/schema.sql- seedINSERT OR IGNOREfor 8 CC licence types addedstorage/migrations/003_seed_license_types.sql- migration created + applied- Verified live DB has
license_typeswith 8 rows
3b - src/Database.php
getLicenseTypes(): arraygetAllLicenseTypes(): array- alias
3c - Add form (public/admin/add.php)
- Loads
$licenseTypes; "Licence"<select name="license_id">added before duration
3d - Add action (public/admin/actions/formulaire.php)
$licenseIdparsed + included in INSERT
3e - Edit form (public/admin/edit.php)
- Loads
$licenseTypes; rawlicense_idFK fetched directly; select pre-populated - POST handler:
license_idincluded in UPDATE
3f - View update
storage/schema.sql-v_theses_fullnow exposest.license_idraw FK (done as part of 004/005 view rebuild)
3g - TFE public page
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.
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 getrole = 'promoteur',is_external = 0storage/schema.sql- add the two new columns tothesis_supervisorsdefinition; updatev_theses_fullto 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 existingsupervisorscolumn (all names) for backwards compat - Migration SQL must DROP + CREATE the view
4b - src/Database.php
getThesisJury(int $thesisId): array- fetch all supervisors for a thesis with their role and is_external flagsetThesisJury(int $thesisId, array $juryMembers): void- delete + re-insert
4c - Add form (public/admin/add.php)
- Remove
promoteuriceandpromoteurice_externefields - 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)
- Removed old promoteurice parsing; parse jury fields; call
$db->setThesisJury()
4e - Edit form (public/admin/edit.php)
- Load
$jury = $db->getThesisJury($thesisId); jury fieldset pre-populated from DB - POST handler calls
$db->setThesisJury()
4f - TFE public page (public/tfe.php)
- 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
storage/migrations/005_add_banner.sql-ALTER TABLE theses ADD COLUMN banner_path TEXTstorage/schema.sql-banner_path TEXTintheses;t.banner_pathin view
5b - Add form (public/admin/add.php)
- "Image bannière" file input added after couverture row
5c - Add action (public/admin/actions/formulaire.php)
- Banner upload: MIME check, 5 MB cap, save to
banners/, callsetBannerPath()
5d - Edit form (public/admin/edit.php)
- Banner preview img shown; remove_banner checkbox; new banner upload input
- POST handler: unlinks old file on remove; processes new upload via
setBannerPath()
5e - src/Database.php
banner_pathexposed via view - verified;getThesisById()/getPublishedTheses()pick it up automaticallysetBannerPath(int $thesisId, ?string $path): voidadded
6 - Home page: gradient placeholder cards & random ordering
6a - Gradient placeholder for cards without a banner
public/index.php- gradient placeholder using HSL hue from thesis IDpublic/assets/main.css-.card__media--gradient,.card__gradient-author,.card__gradient-titlestyles added
6b - Banner image as card thumbnail
public/index.php- checksbanner_pathfirst, falls through to gradient
6c - Random ordering from the latest year
src/Database.php-getLatestYearTheses(int $limit = 24)+getLatestPublishedYear()public/index.php- default home view uses random latest-year selection; paginated view for?year=Xand?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)
- Rename table
keywords→tags; columnkeyword→name - Rename junction
thesis_keywords→thesis_tags; FKkeyword_id→tag_id - PK on
thesis_tags(tag_id, thesis_id);idx_tags_name; updated index names - Views
v_theses_full/v_theses_publicusethesis_tags/tags.name - Migration
storage/migrations/001_rename_keywords_to_tags.sqlwritten and applied
2 - src/Database.php
findOrCreateTag()added;findOrCreateKeyword()is a backwards-compat aliasgetUsedTags()rewritten with proper M2M JOIN;getUsedKeywords()alias keptbuildSearchConditions: keyword/query useEXISTSsubquery onthesis_tags/tags- All conditions prefixed with
vp.to match view alias;vpalias added to search queries
3 - Admin write paths
public/admin/actions/formulaire.php: usesfindOrCreateTag+thesis_tagspublic/admin/edit.php:DELETE FROM thesis_tags+findOrCreateTag+thesis_tags
4 - Public read paths
public/search.php: fixed$kw['keyword']→$kw['name'](tag column rename)getUsedKeywords()alias delegates togetUsedTags()- no functional change neededpublic/tfe.php:$data['keywords']still works - view column name unchangedtemplates/search-bar.php: no keyword param refs - verified
5 - Admin tag management UI (/admin/tags.php)
5a - src/Database.php
getAllTagsWithCount(),renameTag(),mergeTag(),deleteTag()
5b - public/admin/tags.php
- Auth guard, CSRF, table with rename/merge/delete per row, inline forms
5c - public/admin/actions/tag.php
- Routes on
$_POST['action']: rename, merge, delete
5d - Nav & routing
templates/admin/head.php: "Mots-clés" nav link added
5e - Propagation safety
- mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK
6 - Tests
tests/Unit/DatabaseTest.php: tests 5-7 cover findOrCreateTag, getUsedTags, aliastests/Integration/SearchTest.php: tests 4-6 cover tag-filter subquery, full-text query, count consistency
6 - Fixtures / seed data
storage/fixtures/CreateTestDatabase.php: updated totags/thesis_tags/findOrCreateTag()
Feature: Mode Maintenance
- Storage flag file
storage/maintenance.flag(created on demand) - Public gate in
config/bootstrap.php- blocks non-admin routes when flag exists public/maintenance.php(503 page, minimal dark UI)public/admin/actions/maintenance.php(POST: enable/disable)- Admin UI toggle in
public/admin/index.php(bar with status + action button)
Feature: TFE Visibility States (publique / interne / interdit)
- DB migration
002_add_visibility.sql- seeds access_types rows (already existed) src/Database.php-setVisibility(),bulkSetVisibility(),getAccessTypes()public/media.php- blocks thesis files when access_type_id = 3 (Interdit)public/tfe.php- shows access type, context_note, hides files for Interditpublic/admin/edit.php- access_type_id select + context_note textareapublic/admin/index.php- three-state access badge per rowpublic/admin/actions/visibility.php- single + bulk visibility update
Fixes
- Fix
tests/Security/SecurityTest.php: update SQL injection test to callsearchTheses(['query' => $string])instead of bare string -searchTheses()signature was updated toarray $paramsbut the test was never updated, causing a fatalTypeErrorthat prevented the security suite from running at all
Pending
- Add flake.nix for Nix-based PHP dev environment
- Add favicon (
<link rel="icon">→ admin_favicon.svg) to all pages; nginx 204 for /favicon.ico - Remove 100-item cap from répertoire student index:
getAllPublishedTheses()fetches all published theses; search results remain paginated at 30/page - Cover image fallback for home grid cards: batch-load
thesis_filescovers for theses withoutbanner_path; resolution order: banner → cover → gradient
Admin / Server
- Create
scripts/setup-server.sh(one-time server setup: group, ownership, setgid 2775 on dirs) - Add
just setup-serverrecipe (rsync + run setup-server.sh on remote) - Exclude
.claudeand.pifrom rsync deploy - Update
docs/SERVER_SETUP.mdwith correct permissions rationale and troubleshooting - Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
- Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
- Add nginx config viewer to admin panel: "nginx - config" tab in
system.phpreads/etc/nginx/sites-available/posterg(live, with badge) or falls back to localnginx/posterg.conf; line-numbered, syntax-coloured, copy-to-clipboard - Add admin user management UI - password change/set for PHP auth layer (
public/admin/account.php+actions/account.php; "Compte" nav link; account CSS) - Merge
status.phpandlogs.phpinto a singlesystem.phppage; remove "Statut" and "Journaux" nav links, add single "Système" link; preserve all existing content in their respective sections - 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
-
WAL mode - set
PRAGMA journal_mode = WALandPRAGMA synchronous = NORMALinDatabase::__construct()afterforeign_keys = ON. Eliminates full-database read-locks on every write; makes concurrent PHP-FPM workers safe. Also addPRAGMA cache_size = -8000(≈8 MB page cache) while there. -
Composite index
(is_published, year DESC)ontheses- every public query filters on both. Currentlyidx_theses_publishedandidx_theses_yearare 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 toschema.sql+ a migration. -
v_theses_fullis always fully materialised - every query againstv_theses_publicforces SQLite to expand the entire view CTE (all 15 JOINs + 8 GROUP_CONCAT temp B-trees) before applying theWHERE/LIMIT. The view cannot be used with an index. Fix: replacev_theses_publicwith a plain table query insearchThesesandgetPublishedTheses- build the minimal JOIN set per query and let indexes filterthesesfirst. Keep the views for reporting/admin use only. -
getAllPublishedTheses()insearch.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 withDatabase::getPublishedAuthors(): arraythat queriesthesis_authors JOIN authorsdirectly (onlyid+authorscolumns), avoiding the view entirely.getAllPublishedTheses()kept but marked@deprecated. -
migration 005view is stale in the file -005_add_banner.sqlrecreates the view still referencingthesis_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 referencethesis_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()removed fromDatabase.php;import.phpupdated to use inline PDO queries in their place. -
Alias proliferation - collapsed 13 alias methods: canonical names are the
getAllXxxvariants (getAllOrientations,getAllAPPrograms,getAllFinalityTypes,getAllFormatTypes,getAllLanguages,getAllLicenseTypes) plusgetUsedTagsandfindOrCreateTag; all call-sites updated (search.php,import.php); Database.php reduced from 948 → 848 lines. -
getPDO()/getConnection()leaking to callers (partial -tfe.php,index.php,media.php,system.phpcleaned up): - [x]tfe.php: rawSELECT access_type_id→getThesisAccessTypeId(int $id): ?int- [x]index.php: rawSELECT 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; rawSELECT banner_path→getThesisBannerPath(int $id): ?string- [x]edit.php: rawSELECT license_id, access_type_id, context_note→getThesisRawFields(int $id): ?array- [x]system.php: rawSELECT COUNT(*) FROM theses→getThesisCount(): int- [x]formulaire.php: raw identifier-generation query + all junction-table INSERTs → encapsulate inDatabase::createThesis(array $data): int -
sanitize_string()informulaire.phpapplieshtmlspecialcharsat 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. Removehtmlspecialcharsfromsanitize_string(); keep onlystrip_tags(trim()). The templates already callhtmlspecialchars()on output. -
Dead variable
$problematique-formulaire.phpline 84 reads$_POST["problématique"]into$problematiquebut the value is never used (no matching column, no INSERT reference). Deleted. -
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 inBEGIN ... COMMIT / ROLLBACK(check$this->pdo->inTransaction()to avoid nesting). -
DB config auto-detection is fragile -
src/config.phpswitches totest.dbwhenever the file exists locally, which means a developer who ran tests and forgot to deletetest.dbwill silently hit test data on a local production-mirror. Make the defaultprod; require explicitDB_ENV=testto use the test database.
C - Code organisation / maintainability
-
edit.phpdoes too much - POST handler extracted topublic/admin/actions/edit.php;edit.phpis now display-only (loads data, renders form, reads flash messages from session). AddedDatabase::updateThesis(),Database::setThesisAuthors(),Database::getThesisLanguageIds(),Database::getThesisFormatIds()to remove all raw PDO from both files. Matches the pattern offormulaire.php,tag.php,page.php. -
formulaire.phpduplicates banner-upload logic verbatim fromedit.php- extracted toDatabase::handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string; both action files now call the single method. -
Junction-table INSERTs are open-coded in every action - added
Database::setThesisLanguages(),setThesisFormats(),setThesisTags()following the delete-then-reinsert pattern ofsetThesisJury();formulaire.phpandedit.phpupdated. -
Fix
fgetcsv()deprecation warnings inimport.php- added explicit$escape = ''parameter to all 5 calls -
Run all pending DB migrations (001–006) on
storage/posterg.db-tags/thesis_tagstables now exist -
RateLimitcache dir moved tostorage/cache/rate_limit/— default path changed fromsrc/cache/rate_limit(inside source tree) tostorage/cache/rate_limit(viadirname(__DIR__)relative tosrc/RateLimit.php)..gitignoreupdated to ignorestorage/cache/instead of the oldsrc/cache/rate_limit/.justfiledeploy rsync now excludesstorage/cache/*. Oldsrc/cache/directory removed. -
__wakeup()singleton guard throws from a public method - changed totrigger_error('Cannot unserialize singleton ...', E_USER_ERROR)with explicitvoidreturn type; eliminates the PHP 8.x deprecation notice.
Refactor & Maintenance - Templates & Frontend (audit 2026-03-26)
D - Template structure / boilerplate duplication
-
Every public page duplicates its own
<head>- extracted totemplates/public/head.phpaccepting$pageTitleand$extraCss; all 5 public pages updated to use the partial. Mirrors the patterntemplates/admin/head.phpalready uses. -
Live-reload snippet copy-pasted into 6 files - consolidated into
templates/public/head.php; removed fromindex.php,search.php,tfe.php,apropos.php,licence.php. -
templates/header.phpandtemplates/head.phpare dead files - neither isincluded anywhere in the codebase. Both contain outdated markup from a previous design iteration (lang="en", empty author meta, a broken nav with double-quotedhrefattributes insidehref). Delete both to remove confusion. -
public/assets/icons.svgis dead - it is the full TrumboWYG editor icon sprite (40+ symbols) referenced nowhere in the codebase. The only WYSIWYG editor in use (EasyMDE inpages-edit.php) loads from CDN. Deleteicons.svg(~15 KB of noise). -
admin_favicon.svgused as the public-facing favicon - every public page links/assets/admin_favicon.svg. Rename or create a distinctfavicon.svgso 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.cssall open with this identical block. Move it tocommon.cssonce; 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-displayon theCombinedd.otfcustom font - addedfont-display: swapto@font-faceincommon.css; eliminates FOIT on first load. -
Search results pagination is fully inline-styled - replaced inline styles in
search.phpwith.pagination-wrap/.pagination-btn/.pagination-infoclasses; added matching rules tosearch.css; addedaria-disabled+tabindex="-1"on disabled links; addedaria-labelon prev/next links. -
Scattered inline styles in templates - notable instances that should become named classes: -
tfe.phpline 146:style="align-items:start;"→.tfe-meta-item--topintfe.css-tfe.phplines 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-restrictedintfe.css- [x]admin/edit.php+add.php+pages-edit.php:style="align-items:start;"on.admin-form-rowremoved (already default in CSS); banner preview →.admin-banner-preview+.admin-banner-preview img; jury add-button →.admin-add-jury-btn; cancel links →.admin-cancel-link- [x]index.phpline 146:style="padding:2rem;color:#666;"→.cards-emptyinmain.css -
.site-nav__rightis a duplicate of.site-nav__link- removed.site-nav__rightblock fromcommon.css; updatednav.phpto use.site-nav__linkon the À Propos link. -
.site-nav__link--activeis applied innav.phpbut never defined in CSS - addedopacity:1; border-bottom:1px solid rgba(255,255,255,.6); padding-bottom:1pxrule tocommon.css; active nav link is now visually distinct.
F - Template logic / PHP in templates
-
Rate-limit 429 response in
search.phpemits unstyled bare HTML - replaced bare echo with a properly structured HTML document (lang="fr", viewport meta, inline dark styles matchingmaintenance.php);$retrySecondsinjected into the user-facing message. -
apropos.phpcontacts 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 theaboutpage Markdown (admin- editable) or extract to a config array so they are in one place. -
licence.phpwastes half the viewport with an always-empty right column - the page reuses the two-column.apropos-layoutbut<div class="apropos-right"></div>is always empty. Added.apropos-singleclass + removed layout wrapper; content now uses full width (max-width: 720px) without an empty right column.
G - Accessibility & semantics
-
<nav>innav.phphas noaria-label- pages have multiple landmark regions (main nav, search<form>, pagination). Addaria-label="Navigation principale"to the<nav>andaria-label="Pagination"to pagination wrappers so screen readers can distinguish them. -
Search bar
<form>has no accessible name -search-bar.phphas noaria-labelon the<form>and no<label>for the input (only a placeholder). Addaria-label="Recherche"to the<form>element; also addrole="search", a visually-hidden<label>linked viafor/id. -
No
<meta name="description">on any public page - all public pages omit the description meta tag (the deadtemplates/head.phphadcontent=""). Add per-page descriptions: site blurb forindex.php, synopsis excerpt fortfe.php, page content intro forapropos.php/licence.php. Necessary for search indexing and link preview cards. -
No Open Graph tags -
tfe.phpnow emitsog: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, plustwitter:card/twitter:title/twitter:description/twitter:image. All other public pages (index,search,apropos,licence) emit basicog:type=websitetags. OG rendering is centralised intemplates/public/head.phpvia$ogTagsarray.
H - Minor / low-hanging fruit
-
admin/thanks.phpduplicatesgetThesisFiles()with a raw PDO query - lines 34-40 manually prepareSELECT ... FROM thesis_files WHERE thesis_id = ?instead of calling$db->getThesisFiles($thesisId)which already exists. Replace with the DB method. -
admin/index.phpstats computed via PHParray_filteron full result set - "total", "publiés", "en attente" counts are derived by filtering the already-fetched$thesesarray in PHP. When a filter is active the stats reflect only filtered rows, which is misleading. AddDatabase::getThesesStats(): arrayreturning 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 targetsnav ul/nav li/nav adirectly, removing.site-nav__links,.site-nav__link,.site-nav__rightclasses entirely.nav a[aria-current="page"]replaces the missing.site-nav__link--activerule and is self-documenting. -
<form class="site-search">is already a<form>- good. Addrole="search"andaria-label="Recherche". The SVG icon should getaria-hidden="true"(it's decorative). The<input>should have an associated<label>(visually hidden via.sr-onlyis fine, oraria-labelon 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→ targetmain > ulor 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 (setdisplay:block). The.carddiv can be removed; CSS targetsul li adirectly. 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__infowrapper exists only to add padding. Move the padding to the<p>or<li>directly; remove the div. The<p>stays..authorsclass → either keep it or targetli > 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 usesaria-disabled="true"+tabindex="-1"instead of a.disabledclass 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-groupand.search-filter-label(the<label>element is the label). CSS targetsform labelandform 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__authorsand.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-colis 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 targetssection > h2scoped inside.repertoire-index. -
.year-index-item,.cat-index-item,.student-index-item,.keyword-index-item- all four are sequences of<a>links withdisplay:block. They are lists. Wrap each group in<ul>; each link becomes<li><a>. The four custom classes collapse to a singleul aselector per column (or no class at all, scoped viasection). The.activeclass 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-childor 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 targetsmain > articlefor the grid. Remove.tfe-main; CSS targetsmaindirectly. -
<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 targetarticle > :first-childif 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 ddThis 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 targetsarticle > 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 targetsaside 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 class="apropos-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-contentscoped 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<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>) - replaced with<section>. CSS targetsaside section > h2.
VI - public/licence.php
<div class="apropos-right"></div>- always-empty right column. Removed entirely;licence.phpuses<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)
-
Admin nav links are bare
<a>tags in a flat<nav>- replaced with<ul class="admin-nav__list">/<li>children. Active state.activeclass →aria-current="page"on the<a>. Removed.admin-nav__linkselector; CSS now uses.admin-nav__list ascoped to the list. TheDéconnexionlinkstyle="margin-left:auto;opacity:.6;"→.admin-nav__logoututility class, inline style removed. -
<nav class="admin-nav">has noaria-label-aria-label="Navigation admin"was already present; confirmed.
IX - public/admin/add.php & public/admin/edit.php (TFE forms)
-
.admin-form-rowis a<div>used to lay out a<label>beside an<input>- for every field where the<label>has afor=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 withgrid-template-columns: 260px 1fr. This removes one<div>per form field - about 20 divs fromadd.phpand 22 divs fromedit.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-listand.admin-checkbox-labelclasses (theli labelselector 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-childform > buttonselector. -
<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 noscope="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 fromrole="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 inlinestyle="margin-top:.35rem;"on two of the three forms → move to CSS (e.g..admin-inline-form + .admin-inline-formselector inadmin.css). -
<table>with<th>cells lackingscope="col"- same asindex.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-infoclass; 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 —style="margin-top:3rem;"on the danger zone heading moved to.admin-section-title--dangermodifier rule inadmin.css. -
Inline muted description text in
.admin-danger-zone__description—<span style="color:…">replaced with<small>element; CSS rule.admin-danger-zone__description smallprovides the muted colour/size. (Full replacement of wrapper div with<p>tracked separately as structural change.)
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;…"andstyle="font-size:…"removed (already handled by.admin-login-box .admin-form-row/.admin-login-box .admin-labelin CSS). Submit-wrap spacing and full-width button extracted to.admin-login-box .admin-submit-wrapand.admin-login-box .admin-btnrules inadmin.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 (afterhead.phpis 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$extraCssvariable (the mechanism already exists in the deadtemplates/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 likea3f8bc12.jpgis meaningless to a screen reader user. Use the thesis title or a stored description field. If thedescriptioncolumn inthesis_filesis 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 insearch-bar.phpis purely decorative (the<input>carries all meaning). Addaria-hidden="true"andfocusable="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 inadd.php/edit.php, addaria-label="Supprimer ce membre du jury". For "✕ Réinitialiser" inindex.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.phpis 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>- replaced the three<span class="search-filter-label">elements with<label for="filter-year">,<label for="filter-orientation">,<label for="filter-ap">; added matchingidattributes to the<select>elements. Visual appearance unchanged (same CSS class). -
Admin form rows:
<label class="admin-label" for="X">is correct - theforattribute is present on all single-input rows inadd.phpandedit.php. Good. However, the multi-input rows (languages, formats) use<label class="admin-label">without aforbecause 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.phpconvey 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 witharia-hidden="true") andaria-label="Statut : Publié"on the badge<span>. -
<target="_blank">links give no warning —tfe.phpexternal 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
autocompleteattributes on personal data fields -add.php/edit.phpfields likeauteurice(person name),mail(contact) lackautocompletehints. Addautocomplete="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--activeis 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, oraria-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.92on 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 theopacity: 0.92applied to.site-nav__linkdrops it to 4.05:1. Fix: remove the opacity reduction, or increase purple darkness slightly. -
filter-infopurple text#9557b5on purple-light backgroundrgba(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. Usevar(--purple-dark)(#7b3fa0) instead for the text, which would reach ~5.7:1. -
Placeholder text
#aaaon white: 2.32:1 - fails AA (and fails for large text too). Placeholder text is explicitly included in WCAG 1.4.3. Change to#767676minimum (~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 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 toL=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#888onadmin-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
#9557b5used 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 placevar(--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
bodyhas nofont-sizebaseline set - relies on browser default (16px). Most.card__info,.search-filter-labeletc. useremvalues which scale correctly. However, a few admin elements use absolutepxsizes (font-size: 0.78remis fine as it's rem-relative, butwidth: 14px; height: 14pxon checkboxes does not scale). Minor - ensure no text is set inpx.
1.4.10 Reflow (320px viewport)
-
Répertoire index 4-column grid has no mobile breakpoint -
search.cssdefines the 4-column grid at1fr 2fr 2fr 1.5frwith no@mediafallback. 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.cssat 900px and 600px. Good. Verify the PDF<embed>at 700px height also reflows — currentlyheight: 700pxis fixed and causes horizontal overflow on small screens. Change toheight: clamp(300px, 80vh, 700px).
1.4.11 Non-text contrast
-
Search filter
<select>border is#dddon white - 1.6:1 - theborder: 1px solid var(--border-color)where--border-color: #dddgives a 1.6:1 contrast ratio for the UI component boundary. WCAG 1.4.11 requires 3:1. Change to#949494minimum or use#767676. -
Admin form inputs:
border-bottom: 1px solid #333on#1a1a1abackground - 1.8:1 - the bottom-border-only inputs haveborder-bottom: 1px solid var(--admin-border)(#333) on#1a1a1abackground: 1.8:1. Fails 1.4.11. Raise to#555minimum (~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: hiddenon.card__mediaand the tightaspect-ratio: 4/3on 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: addtabindex="-1"andaria-disabled="true"when$page <= 1or$page >= $totalPages. Same issue insearch.phpinline-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; addingaria-labelalso fixes keyboard discoverability. -
Bulk-action JS buttons in
admin/index.phpcallbulkAction()viaonclick- 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. Addid="main-content"to<main>. Add.skip-linkstyles tocommon.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), buttfe.phpuses just the thesis title without author: add author - "[Titre] - [Auteur] - Posterg".
2.4.3 Focus order
-
Search filter form on
search.phpappears 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$hasSearchis true. This is fine for focus order (source order = visual order). No issue. -
On
tfe.phpthe back link← Retouris 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. Addaria-labelto 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.phpheading 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.phpré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.phpnow has<h1 class="sr-only">Mémoires de l'ERG</h1>inside<main>.
2.4.7 Focus visible
-
No
:focus-visiblestyle defined anywhere in the public CSS -common.css,main.css,search.css,tfe.css, andapropos.csscontain zero:focusor:focus-visiblerules.modern-normalizedoes not add any either. The browser's default focus ring is the only indicator, and it is suppressed byoutline: noneon.site-search__inputincommon.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; }incommon.css. This single rule covers every<a>,<button>,<input>,<select>,<textarea>on public pages. For admin: same usingvar(--admin-purple). -
outline: noneon.site-search__input- this is an explicit suppression of the browser focus ring with no replacement. Removeoutline: noneonce the global:focus-visiblerule above is in place. Same foroutline: noneon.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 bearia-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 tomin-height: 2.75rem(44px) andmin-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.php429 response emits<html>with nolangattribute - 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
onchangeredirects. ✓
3.3.1 Error identification
-
add.php/formulaire.phpvalidation 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: addrole="alert"to the error div (so it is announced as a live region on injection), and addautofocusto the first invalid field when re-rendering the form with session error data. -
Client-side validation (
requiredattributes) - native browser validation is present on some fields (requiredon 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.phpinput has no<label>- onlyplaceholder="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>andid="site-search-input"on the input. Or usearia-label="Recherche"on the input directly. -
Admin jury "Lecteur·ices" label has no
forattribute -<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.phphas 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 byfooter.phpbefore</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. Usearia-label="[Nom du promoteur] est externe"set dynamically via JS when the name field is filled, or add a staticaria-describedbypointing to the adjacent name input. -
<video>elements ontfe.phphave 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 inedit.phpuses 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 atitleattribute), 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 norole="status"orrole="alert". A screen reader will not announce it unless focus moves to it. Addrole="alert"to error messages androle="status"to success messages across all admin pages.
5 - Additional: motion & user preferences
-
prefers-reduced-motionis not respected - globaltransition-duration/animation-durationguard already incommon.css;main.cssnow also suppresses the card hovertransform: scale(1.02)via a dedicated@media (prefers-reduced-motion: reduce)block. -
prefers-color-schemeis not respected - the site has a fixed white public theme and a fixed dark admin theme. Users who have set their OS to dark mode will receive the white public site regardless. Not a WCAG failure (SC does not require dark-mode support) but worth noting as a quality-of-life improvement.
6 - Missing global infrastructure
These are things that must be added once and apply everywhere:
-
Add
.sr-onlyutility class tocommon.css- needed for skip links, visually-hidden labels, and screen-reader-only context text referenced throughout this audit. -
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. -
Add global
:focus-visiblerule incommon.cssandadmin.css- consistent 2px purple outline with 2px offset;prefers-reduced-motionguard also added. -
Remove all
outline: nonedeclarations that have no replacement focus style - removed fromcommon.css,admin.css(×2), andsearch.css.