Files
xamxam/TODO.md
Pontoporeia 10b07393fe Extract jury-fieldset.php partial; deduplicate jury section from add.php and edit.php
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
2026-04-02 12:26:44 +02:00

81 KiB
Raw Blame History

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
  • common.css: Replace .site-search__icon with header form[role="search"] svg
  • common.css: Replace .site-search__input with header form[role="search"] input
  • 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
  • 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

  • Create src/App.php — boot, adminGuard, verifyCsrf, rotateCsrf, redirect, flash, consumeFlash, render
  • Auto-load App.php from config/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.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
  • Replace nav/header BEM custom classes with semantic HTML targeting in CSS
    • common.css: .site-navheader nav, .site-nav__logoheader 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 serve and deploy to 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-db against 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 from pages table (slug 'licenses'); renders with Parsedown Markdown; uses apropos.css layout
  • templates/nav.php - add "Licence" link between "Répertoire" and "À Propos"; apply site-nav__link--active when $currentNav === 'licence'
  • 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

  • getPage(string $slug): array|null - SELECT * FROM pages WHERE slug = ?
  • savePage(string $slug, string $content): void - throws if slug not found
  • getAllPages(): array - for listing in admin

2b - Admin pages editor UI

  • public/admin/pages.php - list all editable pages; links to edit each one
  • public/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 (bundled src/Parsedown.php)
  • public/licence.php - renders $db->getPage('licenses') via Parsedown
  • Parsedown bundled as src/Parsedown.php (zero-dependency, MIT)
  • 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 - seed INSERT OR IGNORE for 8 CC licence types added
  • storage/migrations/003_seed_license_types.sql - migration created + applied
  • Verified live DB has license_types with 8 rows

3b - src/Database.php

  • getLicenseTypes(): array
  • getAllLicenseTypes(): 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)

  • $licenseId parsed + included in INSERT

3e - Edit form (public/admin/edit.php)

  • Loads $licenseTypes; raw license_id FK fetched directly; select pre-populated
  • POST handler: license_id included in UPDATE

3f - View update

  • 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

  • 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 get role = 'promoteur', is_external = 0
  • 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

  • getThesisJury(int $thesisId): array - fetch all supervisors for a thesis with their role and is_external flag
  • setThesisJury(int $thesisId, array $juryMembers): void - delete + re-insert

4c - Add form (public/admin/add.php)

  • Remove promoteurice and promoteurice_externe fields
  • 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 TEXT
  • storage/schema.sql - banner_path TEXT in theses; t.banner_path in 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/, call setBannerPath()

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_path exposed via view - verified; getThesisById() / getPublishedTheses() pick it up automatically
  • setBannerPath(int $thesisId, ?string $path): void added

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 ID
  • public/assets/main.css - .card__media--gradient, .card__gradient-author, .card__gradient-title styles added

6b - Banner image as card thumbnail

  • public/index.php - checks banner_path first, 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=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)

  • Rename table keywordstags; column keywordname
  • Rename junction thesis_keywordsthesis_tags; FK keyword_idtag_id
  • PK on thesis_tags(tag_id, thesis_id); idx_tags_name; updated index names
  • Views v_theses_full/v_theses_public use thesis_tags/tags.name
  • Migration storage/migrations/001_rename_keywords_to_tags.sql written and applied

2 - src/Database.php

  • findOrCreateTag() added; findOrCreateKeyword() is a backwards-compat alias
  • getUsedTags() rewritten with proper M2M JOIN; getUsedKeywords() alias kept
  • buildSearchConditions: keyword/query use EXISTS subquery on thesis_tags/tags
  • All conditions prefixed with vp. to match view alias; vp alias added to search queries

3 - Admin write paths

  • public/admin/actions/formulaire.php: uses findOrCreateTag + thesis_tags
  • public/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 to getUsedTags() - no functional change needed
  • public/tfe.php: $data['keywords'] still works - view column name unchanged
  • templates/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, alias
  • tests/Integration/SearchTest.php: tests 4-6 cover tag-filter subquery, full-text query, count consistency

6 - Fixtures / seed data

  • storage/fixtures/CreateTestDatabase.php: updated to tags/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 Interdit
  • public/admin/edit.php - access_type_id select + context_note textarea
  • public/admin/index.php - three-state access badge per row
  • public/admin/actions/visibility.php - single + bulk visibility update

Fixes

  • 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

  • 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_files covers for theses without banner_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-server recipe (rsync + run setup-server.sh on remote)
  • Exclude .claude and .pi from rsync deploy
  • Update docs/SERVER_SETUP.md with 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.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
  • 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.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
  • 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 = 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.

  • Composite index (is_published, year DESC) on theses - every public query filters on both. Currently idx_theses_published and idx_theses_year are separate; the query planner picks one and sorts the other with a temp B-tree. A single covering index eliminates the sort: CREATE INDEX IF NOT EXISTS idx_theses_pub_year ON theses(is_published, year DESC); Add to schema.sql + a migration.

  • v_theses_full is always fully materialised - every query against v_theses_public forces SQLite to expand the entire view CTE (all 15 JOINs + 8 GROUP_CONCAT temp B-trees) before applying the WHERE/LIMIT. The view cannot be used with an index. Fix: replace v_theses_public with a plain table query in searchTheses and getPublishedTheses - build the minimal JOIN set per query and let indexes filter theses first. Keep the views for reporting/admin use only.

  • getAllPublishedTheses() in search.php - fetches every published thesis (all columns, all JOINs) just to build the student-name index on the Répertoire page. This is a full table scan through the fat view. 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.

  • migration 005 view is stale in the file - 005_add_banner.sql recreates the view still referencing thesis_keywords / keywords.keyword (the old pre-migration-001 names). The file is already applied to the live DB (correctly, since 001 ran first), but the migration file itself is wrong and misleading. Fix: rewrite it to reference thesis_tags / tags.name, or drop the view recreation from it (schema.sql is the canonical source).

B - PHP / Database.php

  • Dead CRUD helpers - getOrientationId(), getAPProgramId(), getFinalityId(), getLanguageId(), getFormatId() removed from Database.php; import.php updated to use inline PDO queries in their place.

  • 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.

  • getPDO() / getConnection() leaking to callers (partial - tfe.php, index.php, media.php, system.php cleaned up): - [x] tfe.php: raw SELECT access_type_idgetThesisAccessTypeId(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_pathgetThesisBannerPath(int $id): ?string - [x] edit.php: raw SELECT license_id, access_type_id, context_notegetThesisRawFields(int $id): ?array - [x] system.php: raw SELECT COUNT(*) FROM thesesgetThesisCount(): int - [x] formulaire.php: raw identifier-generation query + all junction-table INSERTs → encapsulate in Database::createThesis(array $data): int

  • sanitize_string() in formulaire.php applies htmlspecialchars at write time - HTML-escaping belongs at render time (in the template), not at storage time. Storing &amp; or &lt; in the DB means search, export, and any non-HTML consumer sees corrupt data. Remove htmlspecialchars from sanitize_string(); keep only strip_tags(trim()). The templates already call htmlspecialchars() on output.

  • Dead variable $problematique - formulaire.php line 84 reads $_POST["problématique"] into $problematique but the value is never used (no matching column, no INSERT reference). 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 in BEGIN ... COMMIT / ROLLBACK (check $this->pdo->inTransaction() to avoid nesting).

  • DB config auto-detection is fragile - src/config.php switches to test.db whenever the file exists locally, which means a developer who ran tests and forgot to delete test.db will silently hit test data on a local production-mirror. Make the default prod; require explicit DB_ENV=test to use the test database.

C - Code organisation / maintainability

  • edit.php does too much - 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.

  • 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.

  • 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.

  • Fix fgetcsv() deprecation warnings in import.php - added explicit $escape = '' parameter to all 5 calls

  • Run all pending DB migrations (001006) on storage/posterg.db - tags/thesis_tags tables now exist

  • 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.

  • __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

  • 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.

  • 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.

  • templates/header.php and templates/head.php are dead files - neither is included anywhere in the codebase. Both contain outdated markup from a previous design iteration (lang="en", empty author meta, a broken nav with double-quoted href attributes inside href). Delete both to remove confusion.

  • public/assets/icons.svg is dead - it is the full TrumboWYG editor icon sprite (40+ symbols) referenced nowhere in the codebase. The only WYSIWYG editor in use (EasyMDE in pages-edit.php) loads from CDN. Delete icons.svg (~15 KB of noise).

  • admin_favicon.svg used as the public-facing favicon - every public page links /assets/admin_favicon.svg. Rename or create a distinct favicon.svg so admin and public can diverge without naming confusion.

E - CSS architecture

  • html, body { margin:0; padding:0; height:100% } repeated in 4 page stylesheets - main.css, search.css, tfe.css, apropos.css all open with this identical block. Move it to common.css once; delete from the four files. Same for the body-level flex-column shell (display:flex; flex-direction:column; background:var(--white)) which only differs in the BEM class name applied to <body>.

  • No font-display on the Combinedd.otf custom font - added font-display: swap to @font-face in common.css; eliminates FOIT on first load.

  • 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 - [x] admin/edit.php + add.php + pages-edit.php: style="align-items:start;" on .admin-form-row removed (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.php line 146: style="padding:2rem;color:#666;".cards-empty in main.css

  • .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.

  • .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

  • 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.

  • 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

  • <nav> in nav.php has no aria-label - pages have multiple landmark regions (main nav, search <form>, pagination). Add aria-label="Navigation principale" to the <nav> and aria-label="Pagination" to pagination wrappers so screen readers can distinguish them.

  • Search bar <form> has no accessible name - search-bar.php has no aria-label on the <form> and no <label> for the input (only a placeholder). Add aria-label="Recherche" to the <form> element; also add role="search", a visually-hidden <label> linked via for/id.

  • No <meta name="description"> on any public page - all public pages omit the description meta tag (the dead templates/head.php had content=""). Add per-page descriptions: site blurb for index.php, synopsis excerpt for tfe.php, page content intro for apropos.php/licence.php. Necessary for search indexing and link preview cards.

  • No Open Graph tags - tfe.php 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

  • 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.

  • admin/index.php stats computed via PHP array_filter on full result set - "total", "publiés", "en attente" counts are derived by filtering the already-fetched $theses array in PHP. When a filter is active the stats reflect only filtered rows, which is misleading. Add Database::getThesesStats(): array returning three counts from SQL (COUNT(*), SUM(is_published), SUM(1-is_published)) so they always reflect the full DB.


Semantic HTML audit (2026-03-26)

Goal: replace presentational wrappers with the element that already carries the correct meaning, removing classes where the element name itself is sufficient as a CSS selector. The design does not need to change - only the vocabulary of the markup.

I - templates/nav.php & templates/search-bar.php

  • <div class="site-nav__links"> wraps the centre nav links purely for flex grouping. Replace with <ul> + <li> children (links inside a nav belong in a list - standard pattern). The <a> elements stay; CSS targets nav ul / nav li / nav a directly, removing .site-nav__links, .site-nav__link, .site-nav__right classes entirely. nav a[aria-current="page"] replaces the missing .site-nav__link--active rule and is self-documenting.

  • <form class="site-search"> is already a <form> - good. Add role="search" and aria-label="Recherche". The SVG icon should get aria-hidden="true" (it's decorative). The <input> should have an associated <label> (visually hidden via .sr-only is fine, or aria-label on the input).

II - public/index.php

  • <div class="filter-info"> is a status/notice banner. Use <p role="status"> or <output> - both carry live-region semantics for screen readers without extra ARIA.

  • <div class="cards-container"> is a list of navigable items. Replace with <ul> - removing the wrapper div and making each card an <li>. .cards-container → target main > ul or a single class on <ul>.

  • <a class="card-link"><div class="card">...</div></a> - the outer <a> wrapping a <div> makes the div redundant. The <a> is already a block element (set display:block). The .card div can be removed; CSS targets ul li a directly. The <li> inside the <ul> becomes the card container.

  • <div class="card__media"> - this is the image/media wrapper inside each card. When it contains an <img>, use <figure> (a self-contained media unit). When it shows the gradient placeholder (no real image), a plain <div> is fine since it's presentational.

  • <div class="card__info"><p class="authors">...</p></div> - the .card__info wrapper exists only to add padding. Move the padding to the <p> or <li> directly; remove the div. The <p> stays. .authors class → either keep it or target li > p.

  • <div class="pagination-wrap"> with <a class="pagination-btn"> and <span class="pagination-info"> - replace with <nav aria-label="Pagination"><ul>...</ul></nav>. Each button becomes an <li>. The disabled state uses aria-disabled="true" + tabindex="-1" instead of a .disabled class alone (which has no keyboard semantics). <span class="pagination-info"><li aria-current="page">1 / 5</li>.

III - public/search.php

  • <div class="search-filter-group"> wraps each label+select pair. Replace with <label> directly wrapping <select> - one element instead of two, and the label/control association is implicit. Remove .search-filter-group and .search-filter-label (the <label> element is the label). CSS targets form label and form select.

  • <span class="search-filter-label"> inside the filter group - deleted once the <label> approach is taken (see above).

  • <div class="search-results-view"> is unnecessary nesting inside <main>. <main> is already the landmark. Remove the wrapper; apply padding directly to <main> or its direct children.

  • <div class="results-grid"> is a list of search results. Replace with <ul class="results-grid">. Each <a class="result-card"> becomes a <li><a> - the link text is made up of child <span>s which is correct. However .result-card__authors and .result-card__title <span>s would be better as <strong> (author, emphasis) and the title as plain text or <span>. The year/meta <span class="result-card__meta"><small> (ancillary metadata).

  • Répertoire index: <div class="repertoire-index"> - replace with <div> kept but its four children are semantic candidates: each .repertoire-col is an independent index with a heading. Replace <div class="repertoire-col"> with <section>. The heading (<h2 class="repertoire-col__header">) is already correct - <h2> is right. Remove .repertoire-col__header; CSS targets section > h2 scoped inside .repertoire-index.

  • .year-index-item, .cat-index-item, .student-index-item, .keyword-index-item - all four are sequences of <a> links with display:block. They are lists. Wrap each group in <ul>; each link becomes <li><a>. The four custom classes collapse to a single ul a selector per column (or no class at all, scoped via section). The .active class on links → aria-current="page" on the <a>.

  • <p class="search-results-header"> count line - remove .search-results-header; this is a plain <p> styled with .search-main p:first-child or just keep a lightweight class. Or use <output> since it is a computed result count.

IV - public/tfe.php

  • <div class="tfe-layout"> — the two-column grid container. Replace with <article> — a single thesis is genuinely a self-contained piece of content. Remove .tfe-layout; CSS targets main > article for the grid. Remove .tfe-main; CSS targets main directly.

  • <div class="tfe-left"> — the info/metadata column. Replace with <header> of the article (it contains the author name, title, and all metadata — the article header). Or simply remove and target article > :first-child if that is too strong. Actually <header> is semantically correct here: it is the identifying header of the article.

  • <div class="tfe-meta-list"> with <div class="tfe-meta-item"><span class="label">…</span><span class="value">…</span></div> — this is a description list by definition. Replace with <dl> / <dt> / <dd>: - <dl class="tfe-meta-list"> → just <dl> (class optional) - <div class="tfe-meta-item"> → remove; <dt>+<dd> are direct children of <dl> (or grouped in <div> inside <dl> which is valid HTML — the spec allows it for styling) - <span class="label"><dt> - <span class="value"><dd> - CSS: .tfe-meta-listdl; .labeldl dt; .valuedl dd This removes ~5 classes and ~30 wrapper divs from the metadata section.

  • <div class="tfe-synopsis-text"> — the synopsis paragraph(s). Replace with <p> (or keep as <section class="synopsis"> if multi-paragraph, but a single <p> suffices for most cases). Remove the wrapper div.

  • <div style="margin-top:1.5rem;"><a href="index.php" style="…">← Retour</a></div> — remove the wrapper div; move margin to the <a> itself as a class. The back link is better as <a rel="up" href="index.php" class="back-link">← Retour</a> (no wrapper needed).

  • <div class="tfe-right"> — the media column. Replace with <aside> — it contains supplementary files (media, PDFs) that are related but secondary to the descriptive content. Remove .tfe-right; CSS targets article > aside.

  • <div class="tfe-media-block"> — each file display unit. Replace with <figure>. Image and video files become <figure><img></figure> and <figure><video></video></figure>. The existing <p class="tfe-file-caption"><figcaption>. PDF <embed> stays in a <figure> (valid). Remove .tfe-media-block; CSS targets aside figure.

  • <h1 class="tfe-author"> and <h2 class="tfe-title"> — the heading hierarchy makes the author the primary heading and the title secondary, which is backwards semantically. The title of the work is the <h1>; the author is metadata (could be a <p> or a <dt> in the <dl> above). Swap: <h1> = title, author moves into the <dl>. Keeps the visual design (CSS controls size) but fixes the document outline.

V - public/apropos.php

  • <div class="apropos-layout"> - two-column grid. Replace with <div> kept but the children are semantic: left is the main content, right is supplementary. Left <div class="apropos-left"> → remove (redundant wrapper around already-styled content). Right <div class="apropos-right"><aside 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-content scoped CSS rules. Keep it but as a single class - <div class="prose"> - and scope all Markdown content styles under .prose. This is the standard prose-container pattern.

  • <div class="apropos-contact"> - each contact entry. Replace with <address>: the HTML spec defines <address> for contact information related to the document or section. Each contact is literally an address entry. <span class="apropos-contact-name"><strong>, <span class="apropos-contact-role"> → plain text <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 targets aside section > h2.

VI - public/licence.php

  • <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)

  • 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.

  • <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)

  • <div class="admin-stats"> with <div class="admin-stat"> children - the stats (total, published, pending) are a set of key-value pairs. Replace with <dl>: <div class="admin-stats"><dl class="admin-stats">; each <div class="admin-stat"><div> kept (valid <dl> child for grouping, per spec); <div class="admin-stat__number"><dd>; <div class="admin-stat__label"><dt>. Removes two classes; makes the numbers machine-readable as defined terms.

  • <div class="admin-maintenance-bar"> (status banner) - this is a status notice + action form. Replace the outer <div> with <aside role="status"> or <p role="status"> for the text portion. The form inside stays as <form>. Removes one class.

  • <div class="admin-bulk-actions"> bar - a toolbar that appears conditionally. Replace with <div role="toolbar" aria-label="Actions groupées">. Not a full semantic element replacement, but adds correct ARIA role for the keyboard/AT pattern of a toolbar.

  • <table class="admin-table"> is correct - tabular data, right element. No change needed. The <thead>, <tbody>, <tr>, <th>, <td> structure is correct. Minor: <th> cells have no scope="col" attribute - add it for screen reader column association (<th scope="col">).

  • Status badges <span class="status-badge status-published">Publié</span> - these are inline state labels. Semantically fine as <span> but could benefit from role="status" or at minimum a visually-hidden text prefix (e.g. "Statut :") so screen readers don't just announce "Publié" without context.

XI - public/admin/tags.php

  • Three <form> elements per table row (rename, merge, delete) - structurally correct (each action is a separate form submission). No semantic issue. Minor: the inline style="margin-top:.35rem;" on two of the three forms → move to CSS (e.g. .admin-inline-form + .admin-inline-form selector in admin.css).

  • <table> with <th> cells lacking scope="col" - same as index.php.

XII - public/admin/thanks.php

  • <div class="admin-thesis-info"> blocks - each is a labelled section with a <dl> inside it (already using <dl>/<dt>/<dd> correctly - good!). The outer wrapper <div> could be a <section> with the <h2> as its heading, making the structure <section><h2>...</h2><dl>...</dl></section>. Removes .admin-thesis-info class; CSS targets .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 correctstyle="margin-top:3rem;" on the danger zone heading moved to .admin-section-title--danger modifier rule in admin.css.

  • Inline muted description text in .admin-danger-zone__description<span style="color:…"> replaced with <small> element; CSS rule .admin-danger-zone__description small provides 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 rowsstyle="grid-template-columns:1fr;…" and style="font-size:…" removed (already handled by .admin-login-box .admin-form-row / .admin-login-box .admin-label in CSS). Submit-wrap spacing and full-width button extracted to .admin-login-box .admin-submit-wrap and .admin-login-box .admin-btn rules in admin.css.

XV - public/admin/pages-edit.php

  • <link rel="stylesheet"> injected after <main> opens - the EasyMDE stylesheet CDN link is placed after the </head> has already closed (after head.php is included). It sits directly inside <body> before <main>. This is invalid HTML - <link> is a head element. Move it into the <head> by passing it to the head template via a $extraCss variable (the mechanism already exists in the dead templates/head.php). Same for the EasyMDE <script> tag which currently floats after </main>.

XVI - Summary of admin class deletions enabled by semantic changes

Class removed Replaced by
.admin-nav__link nav ul a
.admin-nav__link.active [aria-current="page"]
.admin-form-row direct form > label + input grid (or keep as minimal layout class)
.admin-label label (scoped to .admin-form)
.admin-checkbox-list ul inside form row
.admin-checkbox-label li label
.admin-submit-wrap form > button:last-child or slim .submit class
.admin-alert--error / --success p[role="alert"] / p[role="status"]
.admin-stat div inside <dl>
.admin-stat__number dd
.admin-stat__label dt
.admin-thesis-info section
.admin-account-status__row div inside <dl>
.admin-account-status__label dt
.admin-danger-zone__description p

Accessibility audit (2026-03-26)

WCAG 2.1 AA is the baseline. Issues are grouped by criterion number for traceability. Current state: zero ARIA attributes, zero skip links, zero focus-visible styles, zero prefers-reduced-motion guards anywhere in the live codebase.


1 - Perceivable

1.1.1 Non-text content (alt text)

  • Home card images use the thesis title as alt - alt="<?= $item['title'] ?>" is a reasonable fallback, but the title alone provides no context about what the image depicts. Prefer "Couverture - [titre] par [auteurs]" for cover images, or "" (empty) for purely decorative banners where the caption below already carries all the text information. For gradient placeholder cards there is no <img> at all - correct, no alt needed on a CSS gradient div.

  • TFE page file images use the raw filename as altalt="<?= $file['file_name'] ?>". A filename like a3f8bc12.jpg is meaningless to a screen reader user. Use the thesis title or a stored description field. If the description column in thesis_files is populated, that should be the alt text; fall back to the thesis title.

  • Search bar SVG icon has no aria-hidden - the magnifying glass SVG in search-bar.php is purely decorative (the <input> carries all meaning). Add aria-hidden="true" and focusable="false" to the SVG.

  • Admin <nav> logo is a text link - fine. But "✕ Réinitialiser" and "✕" remove buttons use a bare Unicode as their visible label with no accessible name alternative. For the "✕" jury-remove buttons in add.php/edit.php, add aria-label="Supprimer ce membre du jury". For "✕ Réinitialiser" in index.php, the text is adequate; the symbol is decorative there and should be wrapped in <span aria-hidden="true">✕</span>.

1.3.1 Info and relationships

  • The metadata list on tfe.php is a <div>/<span> soup — a screen reader traversing the page hears "Orientation : Arts Numériques" as a flat run of text with no structure. There is no programmatic association between label and value. Replacing with <dl>/<dt>/<dd> (already flagged in the semantic audit) directly fixes this criterion.

  • Search filter <select> elements have no associated <label> - 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>.

  • <target="_blank"> links give no warningtfe.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

  • Nav links at opacity: 0.92 on purple background: 4.05:1 - fails AA (4.5:1 required for normal text). At full opacity the white-on-purple ratio is 4.87:1 (just passes), but the opacity: 0.92 applied to .site-nav__link drops it to 4.05:1. Fix: remove the opacity reduction, or increase purple darkness slightly.

  • filter-info purple text #9557b5 on purple-light background rgba(149,87,181,0.12) over white: 4.08:1 - fails AA. The filter info banner ("Année : 2024" or "Découvrez les TFE de 2024") uses purple text on a light purple tint. Use var(--purple-dark) (#7b3fa0) instead for the text, which would reach ~5.7:1.

  • Placeholder text #aaa on white: 2.32:1 - fails AA (and fails for large text too). Placeholder text is explicitly included in WCAG 1.4.3. Change to #767676 minimum (~4.54:1) or preferably #6b6b6b.

  • Gradient card placeholder: white text on L=65% HSL backgrounds - most hues fail - measured across the full hue range at hsl(H, 60%, 65%) (the lighter end of the gradient): only hues 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).

  • admin-text-muted #888 on admin-bg-alt #242424: 4.38:1 - fails AA for normal text. This combination appears in table cell muted text, form hints, and sub-labels across the admin. Darken to #909090 (~4.5:1) or use #959595.

  • Admin purple #9557b5 used as large-heading colour on dark #1a1a1a: 3.57:1 - passes AA for large text (≥18pt/24px bold ≥14pt) but fails for normal text. Audit every place var(--admin-purple) appears as text colour on the dark background; ensure it is only used at sizes where 3:1 suffices (large text), not on body copy or small labels.

1.4.4 Resize text

  • body has no font-size baseline set - relies on browser default (16px). Most .card__info, .search-filter-label etc. use rem values which scale correctly. However, a few admin elements use absolute px sizes (font-size: 0.78rem is fine as it's rem-relative, but width: 14px; height: 14px on checkboxes does not scale). Minor - ensure no text is set in px.

1.4.10 Reflow (320px viewport)

  • Répertoire index 4-column grid has no mobile breakpoint - search.css defines the 4-column grid at 1fr 2fr 2fr 1.5fr with no @media fallback. At 320px the columns become ~50px wide each - unusable. Add a breakpoint to stack columns vertically below ~600px (or 768px).

  • TFE page two-column grid collapses at 900px — responsive breakpoints exist for tfe.css at 900px and 600px. Good. Verify the PDF <embed> at 700px height also reflows — currently height: 700px is fixed and causes horizontal overflow on small screens. Change to height: clamp(300px, 80vh, 700px).

1.4.11 Non-text contrast

  • Search filter <select> border is #ddd on white - 1.6:1 - the border: 1px solid var(--border-color) where --border-color: #ddd gives a 1.6:1 contrast ratio for the UI component boundary. WCAG 1.4.11 requires 3:1. Change to #949494 minimum or use #767676.

  • Admin form inputs: border-bottom: 1px solid #333 on #1a1a1a background - 1.8:1 - the bottom-border-only inputs have border-bottom: 1px solid var(--admin-border) (#333) on #1a1a1a background: 1.8:1. Fails 1.4.11. Raise to #555 minimum (~3.1:1).

1.4.12 Text spacing

  • No text-spacing override test done - verify that when users apply the WCAG 1.4.12 bookmarklet (line-height 1.5×, letter-spacing 0.12em, word-spacing 0.16em, paragraph spacing 2em) the card grid and TFE metadata list do not overflow their containers. The overflow: hidden on .card__media and the tight aspect-ratio: 4/3 on card images can cause content clipping.

2 - Operable

2.1.1 Keyboard

  • Disabled pagination links are keyboard-reachable - <a class="pagination-btn disabled"> uses .disabled { pointer-events: none } in CSS which does not remove keyboard focus. A keyboard user can still Tab to these links and press Enter (which follows the href to page 0 or page N+1 unnecessarily). Fix: add tabindex="-1" and aria-disabled="true" when $page <= 1 or $page >= $totalPages. Same issue in search.php inline-styled pagination links.

  • Jury "✕" remove buttons (admin/add.php, admin/edit.php) are only reachable via Tab - no issue per se, but they have no visible label (just ). Confirmed already in 1.1.1; adding aria-label also fixes keyboard discoverability.

  • Bulk-action JS buttons in admin/index.php call bulkAction() via onclick - these are <button type="button"> elements so they are keyboard-accessible. Confirm Enter and Space both trigger the action. Fine - no structural issue.

2.1.2 No keyboard trap

  • EasyMDE editor in pages-edit.php - CodeMirror-based editors are known keyboard traps; Tab inside the editor inserts a tab character rather than moving focus out. EasyMDE provides an escape route (Escape key exits the editor). Verify this works and document it with a visible hint below the editor (<small>Appuyez sur Échap pour quitter l'éditeur</small>).
  • No skip-to-main-content link exists on any page - every page loads with focus on the browser chrome, then Tab cycles through the nav and search bar before reaching <main>. On the home page that means tabbing through 4 nav links before reaching 24 thesis cards. Add <a href="#main-content" class="skip-link">Aller au contenu principal</a> as the first element inside <body>, visually hidden by default, visible on focus. Add id="main-content" to <main>. Add .skip-link styles to common.css.

2.4.2 Page titled

  • index.php <title> is just "Posterg" - no description of the page content. Change to "Posterg - Mémoires de l'ERG" or similar. Each page title should be unique and descriptive first: "Répertoire - Posterg", "À Propos - Posterg" (already good), but tfe.php uses just the thesis title without author: add author - "[Titre] - [Auteur] - Posterg".

2.4.3 Focus order

  • Search filter form on search.php appears above <main> in the DOM but is rendered between the search bar and results visually - the filter <form class="search-controls"> comes before <main> in source order when $hasSearch is true. This is fine for focus order (source order = visual order). No issue.

  • On tfe.php the back link ← Retour is at the bottom of the left column in DOM order - a keyboard user must tab through the entire metadata list and synopsis before reaching it. Consider moving it to the top of the column (above <h1>), or adding a second copy near the top, so keyboard users can quickly exit. This is a UX recommendation, not a hard WCAG failure, but it affects 2.4.3 and 2.4.7.

  • Home page cards: the link text is author - title - adequate. However, if two theses share the same title (possible), two identical link texts exist. Consider adding the year: author - title (year) in a visually-hidden <span class="sr-only"> appended to the link.

  • Search results cards: same issue - <span class="result-card__authors"> + title + meta inside <a>. The combined text read by screen readers will be "Author · Title · Year · Orientation" which is actually quite good. No hard failure here.

  • Pagination links use Unicode arrows «, , , » as their only text - these are announced by screen readers as "double left-pointing angle quotation mark" or similar gibberish. Add aria-label to each: aria-label="Première page", aria-label="Page précédente", aria-label="Page suivante", aria-label="Dernière page".

2.4.6 Headings and labels

  • tfe.php heading hierarchy is inverted — author is <h1>, thesis title is <h2>. The work's title is the primary topic of the page and should be <h1>. The author name is a label/metadata, not a heading. This is flagged in the semantic audit but it is also directly a WCAG 2.4.6 failure (heading does not describe the topic of the page).

  • search.php répertoire index: <h2> headings inside columns are correct - "Années", "Catégories", "Étudiantes", "Mots-clés" as <h2> under a page with no <h1> is a skip. Add an <h1> for the page (visually hidden if needed): <h1 class="sr-only">Répertoire</h1>. index.php now has <h1 class="sr-only">Mémoires de l'ERG</h1> inside <main>.

2.4.7 Focus visible

  • No :focus-visible style defined anywhere in the public CSS - common.css, main.css, search.css, tfe.css, and apropos.css contain zero :focus or :focus-visible rules. modern-normalize does not add any either. The browser's default focus ring is the only indicator, and it is suppressed by outline: none on .site-search__input in common.css. This is a clear WCAG 2.4.7 failure. Define a consistent focus style for all interactive elements: css :focus-visible { outline: 2px solid var(--purple); outline-offset: 2px; } in common.css. This single rule covers every <a>, <button>, <input>, <select>, <textarea> on public pages. For admin: same using var(--admin-purple).

  • outline: none on .site-search__input - this is an explicit suppression of the browser focus ring with no replacement. Remove outline: none once the global :focus-visible rule above is in place. Same for outline: none on .admin-input, .admin-select, .admin-textarea, and .search-filter-select.

2.5.3 Label in name

  • <a class="clear-filter">✕ Réinitialiser</a> - the visible label starts with a symbol. Fine as long as "Réinitialiser" is in the accessible name, which it is (it's text content). No failure here, but the should be aria-hidden="true".

  • Admin jury remove buttons - the visible label is only. The accessible name must contain (or start with) the visible label text. Since has no speech equivalent, aria-label="Supprimer ce lecteur" replaces it entirely, which satisfies 2.5.3.

2.5.5 Target size (advisory in WCAG 2.1, required in WCAG 2.2)

  • Pagination buttons are 2rem (32px) height - below the 44×44px recommended target. Increase to min-height: 2.75rem (44px) and min-width: 2.75rem.

  • Admin .admin-btn-sm (~28px height) - used for Voir/Éditer/Publier/Dépublier in the TFE table. Well below 44px. Since these are in a dense table, 44px may not be practical; increase to at minimum 32px and add padding.

  • Admin bulk action buttons and jury remove buttons (~28px) - same issue.


3 - Understandable

3.1.1 Language of page

  • All public pages have <html lang="fr"> - correct. ✓
  • search.php 429 response emits <html> with no lang attribute - fails 3.1.1. Fix: echo '<!DOCTYPE html><html lang="fr">...'.

3.2.1 On focus / 3.2.2 On input

  • No unexpected context changes on focus or input detected - standard links and forms, no onchange redirects. ✓

3.3.1 Error identification

  • add.php / formulaire.php validation errors are shown as a single flash message at the top of the page after a full round-trip - the error says e.g. "Le champ 'Synopsis' est requis" but focus is not moved to the <div class="admin-alert--error"> nor to the offending field. A screen reader user who has already moved past the alert region will not hear the error. Fix: add role="alert" to the error div (so it is announced as a live region on injection), and add autofocus to the first invalid field when re-rendering the form with session error data.

  • Client-side validation (required attributes) - native browser validation is present on some fields (required on title, synopsis, etc.). The browser's native error popups are accessible but vary across browsers. No issue here, though the error messages cannot be styled consistently.

3.3.2 Labels or instructions

  • search-bar.php input has no <label> - only placeholder="Recherche..." - Placeholders disappear on focus and are not a substitute for labels. WCAG 3.3.2 requires labels or instructions for all inputs. Add a visually-hidden <label for="site-search-input" class="sr-only">Recherche</label> and id="site-search-input" on the input. Or use aria-label="Recherche" on the input directly.

  • Admin jury "Lecteur·ices" label has no for attribute - <label class="admin-label">Lecteur·ices :</label> references no control (because the control is a dynamic list). The label should be a <legend> inside the enclosing <fieldset>, or the lecteur rows should be wrapped in their own <fieldset>/<legend>.


4 - Robust

4.1.1 Parsing

  • pages-edit.php has a <link> element inside <body> - invalid HTML. Confirmed in the semantic audit (section XV). 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.

  • <embed> for PDFs has no accessible alternative<embed type="application/pdf"> is not accessible to screen readers or keyboard users who cannot operate PDF viewers in-browser. Add a fallback download link below every embed: <a href="/media.php?path=…&download=1">Télécharger le PDF</a>.

  • Admin <select> for visibility/access in edit.php uses truncated option text - mb_strimwidth($at['description'], 0, 60, '...') truncates the access type description to 60 chars with an ellipsis. The truncated text becomes the accessible name of the option. Use the full description in the option text (or a title attribute), and keep the truncated text only for visual display.

  • Bulk publish/unpublish JS does not announce result to screen readers - after bulkAction() submits the form and the page reloads, the success/error message appears in a <div class="admin-alert"> with no role="status" or role="alert". A screen reader will not announce it unless focus moves to it. Add role="alert" to error messages and role="status" to success messages across all admin pages.


5 - Additional: motion & user preferences

  • prefers-reduced-motion is not respected - 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:

  • 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.

  • 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-visible rule in common.css and admin.css - consistent 2px purple outline with 2px offset; prefers-reduced-motion guard also added.

  • Remove all outline: none declarations that have no replacement focus style - removed from common.css, admin.css (×2), and search.css.