Full analysis of every public-facing page and partial against semantic HTML.
Currently only one semantic element exists across the entire public frontend (<nav>).
Key findings mapped to concrete replacements:
nav.php: <div class=site-nav__links> → <ul>/<li>; active class → aria-current=page
search <form> needs role=search, aria-label, hidden SVG icon
index.php: <div class=cards-container> → <ul>; card <div>s → <li>; <a> wraps directly
card__media → <figure> for image cards; pagination divs → <nav><ul>
disabled pagination links need aria-disabled + tabindex=-1, not just a class
search.php: filter label+div groups → <label> wrapping <select> (removes 2 classes per group)
.search-results-view wrapper → remove (redundant inside <main>)
results-grid <div> → <ul>; result-card__meta <span> → <small>
repertoire columns <div> → <section>; link lists → <ul>/<li>
active links → aria-current=page
tfe.php: heading hierarchy is backwards — author is h1, title is h2; should be reversed
.tfe-layout → <article>; .tfe-left → <header> inside article
.tfe-meta-list div+span soup → <dl>/<dt>/<dd> (removes ~30 wrapper divs + 5 classes)
.tfe-right → <aside>; .tfe-media-block → <figure>; caption → <figcaption>
.tfe-synopsis-text <div> → <p>; back link wrapper div → remove
apropos.php: .apropos-right <div> → <aside>; contact divs → <address>
section wrapper divs → <section>; two CSS classes → strong + a[href^=mailto:]
double-class .apropos-description.apropos-page-content → single .prose
licence.php: remove always-empty right column and two-column layout entirely
Summary table: 25+ classes that become deletable once semantic elements carry the meaning
42 KiB
TODO
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
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. Replace with a dedicated lean query:SELECT id, authors FROM v_theses_public ORDER BY authors ASC— or addDatabase::getPublishedAuthors(): arraythat queriesthesis_authors JOIN authorsdirectly, avoiding the view entirely. -
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()are defined inDatabase.phpbut never called anywhere (forms now pass IDs directly from selects). Remove them to reduce surface area. -
Alias proliferation —
getOrientations()/getAllOrientations(),getApPrograms()/getAllAPPrograms(),getFinalityTypes()/getAllFinalityTypes(),getLanguages()/getAllLanguages(),getFormatTypes()/getAllFormatTypes(),getLicenseTypes()/getAllLicenseTypes(),findOrCreateKeyword(),getUsedKeywords()— 13 alias methods pointing at 6 real ones. Pick the canonical name for each pair, update all call-sites (there are few), and delete aliases. Reduces Database.php from ~945 lines significantly. -
getPDO()/getConnection()leaking to callers —edit.php,formulaire.php,thanks.php,import.php,tfe.php,index.php,media.php,system.phpall call$db->getPDO()or$db->getConnection()to run raw queries that belong inDatabase.php. Each is a missed encapsulation: -tfe.php: rawSELECT access_type_id FROM theses WHERE id = ?→ addgetThesisAccessTypeId(int $id): ?int-index.php: rawSELECT thesis_id, file_path FROM thesis_files WHERE … IN (…)→ addgetCoverPathsForTheses(array $ids): array-media.php: raw visibility join → move intoDatabase::getFileVisibility(string $path): ?int-edit.php(line 155): unparameterised"… WHERE id = $thesisId"SQL injection risk — fix immediately; also move to a DB method -edit.php: rawSELECT license_id, access_type_id, context_note FROM theses WHERE id = ?→ expose these viagetThesis()(already returnsv_theses_fullwhich haslicense_id) -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 onlytrim(). 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). Delete it. -
setThesisJury()not wrapped in a transaction — the method does a DELETE then multiple INSERTs with no transaction guard of its own. If called from outside a transaction (e.g. a future API endpoint) a partial failure leaves orphaned rows. Wrap the body 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 — 530 lines combining form display, POST handling, file upload, and all reference-data loading in one file. Extract the POST handler topublic/admin/actions/edit.php(matching the pattern already used byformulaire.php,tag.php,page.php, etc.). -
formulaire.phpduplicates banner-upload logic verbatim fromedit.php— the MIME check, size cap,random_bytesname,chmod, andsetBannerPath()call are copy-pasted. Extract a privateDatabase-level helper or a standaloneuploadBanner(array $file): ?stringutility function (returns the relative path or null) shared by both action files. -
Junction-table INSERTs are open-coded in every action —
formulaire.phpandedit.phpboth manually loopINSERT INTO thesis_languages,thesis_formats,thesis_tags. AddDatabase::setThesisLanguages(int $id, array $ids),setThesisFormats(int $id, array $ids),setThesisTags(int $id, array $names)— following the same delete-then-reinsert pattern already used bysetThesisJury(). -
RateLimituses per-file JSON on disk — reads, writes, andglob()s the filesystem on every public request. For a low-traffic art-school site this is fine, but it creates a write-on-every-hit pattern. Consider switching to APCu (if available) or SQLite (single INSERT) to avoid filesystem churn. At minimum, move the cache dir to/tmpor a dedicatedstorage/cache/path that is excluded from deploy rsync. -
__wakeup()singleton guard throws from a public method — PHP 8.x deprecates throwing exceptions from__wakeup. Change totrigger_error(…, E_USER_ERROR)or implement__serialize()/__unserialize()that always throw.
Refactor & Maintenance — Templates & Frontend (audit 2026-03-26)
D — Template structure / boilerplate duplication
-
Every public page duplicates its own
<head>—index.php,search.php,tfe.php,apropos.php,licence.phpeach contain an identical block:<!DOCTYPE html>,<html lang="fr">,<meta charset>,<meta viewport>,<link rel="icon">,<link modern-normalize>,<link common.css>, live-reload script. Only<title>and one extra CSS<link>differ. Extract atemplates/public/head.phppartial accepting$pageTitleand$extraCss— mirrors the patterntemplates/admin/head.phpalready uses. -
Live-reload snippet copy-pasted into 6 files —
index.php,search.php,tfe.php,apropos.php,licence.php,templates/admin/head.phpall contain the same 6-line(function poll(){…})()block. Consolidate into the shared head partials. -
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 —common.cssdeclares@font-facewith nofont-displayproperty; the browser blocks text rendering until the font loads (FOIT). Addfont-display: swap. Also add a<link rel="preload">for the font file in the shared head partial once it exists. -
Search results pagination is fully inline-styled —
search.phplines 159–164 applystyle="padding:.25rem .7rem;border:1px solid #ddd;…"and hardcoded#ddd/#666. The home page (index.php) already has.pagination-btn/.pagination-infoinmain.css. Reuse those classes insearch.phpand remove the inline styles. -
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-admin/edit.php: multiplestyle=on.admin-form-rowand banner preview → modifier classes inadmin.css-index.phpline 146:style="padding:2rem;color:#666;"→.cards-emptyinmain.css -
.site-nav__rightis a duplicate of.site-nav__link—common.cssdefines both with identical declarations (font-size, letter-spacing, text-transform, color, opacity, transition). The only difference is DOM position. Merge.site-nav__rightinto.site-nav__link; let the flex layout position it viamargin-left:autoor DOM order. -
.site-nav__link--activeis applied innav.phpbut never defined in CSS — the class is set conditionally but has no corresponding rule incommon.css, so the active state is invisible. Add a visible style (e.g.opacity:1; border-bottom:1px solid rgba(255,255,255,.6)) or remove the conditional.
F — Template logic / PHP in templates
-
Rate-limit 429 response in
search.phpemits unstyled bare HTML — the early-exit block outputs<!DOCTYPE html><html><body><h1>Trop de requêtes</h1>…with no stylesheet, no lang, no viewport meta. Style it inline-minimally or redirect to a consistent429.phppage (likemaintenance.php). -
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. Add a.apropos-layout--singlevariant (or justgrid-template-columns:1frwhen the right child is empty) to use the full width for content.
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. -
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.phpis the ideal candidate forog:title,og:description(synopsis),og:image(banner or cover path through/media.php),og:type=article. Without them, sharing a thesis link on social media or messaging apps shows a blank preview.
H — Minor / low-hanging fruit
-
admin/thanks.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>(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 or<span>,<span class="apropos-contact-email">→<a href="mailto:…">. Three classes removed. -
Outer
<div>wrappers around each section in.apropos-right(<div><h2>…</h2></div>,<div><h2>Contacts</h2>…</div>,<div><h2>Crédits</h2>…</div>) — replace each with<section>. Remove the anonymous<div>wrappers; CSS targetsaside section > h2.
VI — public/licence.php
<div class="apropos-right"></div>— always-empty right column. Remove entirely; thelicence.phppage is full-width content. Updatelicence.phpto not use.apropos-layoutat all — just<main class="apropos-main"><div class="prose">…</div></main>. No class changes needed toapropos.css; the layout simply is not applied.
VII — Summary of class deletions enabled by semantic changes
Once the above is applied, the following classes become deletable (element name carries the meaning):
| Class removed | Replaced by |
|---|---|
.site-nav__links |
nav ul |
.site-nav__link |
nav li a |
.site-nav__right |
nav li:last-child a (or [aria-label] target) |
.site-nav__link--active |
[aria-current="page"] |
.card-link |
ul li a (block <a> inside <li>) |
.card |
ul li |
.tfe-layout |
main > article |
.tfe-left |
article > header |
.tfe-right |
article > aside |
.tfe-meta-list |
dl |
.tfe-meta-item |
div inside dl (or removed) |
.label / .value |
dt / dd |
.tfe-media-block |
figure |
.tfe-file-caption |
figcaption |
.tfe-synopsis-text |
p (direct child of article > header) |
.search-filter-label |
label |
.search-filter-group |
label (wrapping approach) |
.repertoire-col |
section |
.repertoire-col__header |
section > h2 |
.year-index-item etc. |
ul a (scoped per section) |
.result-card__meta |
small |
.results-grid |
ul.results-grid (only class needed) |
.apropos-left |
removed (direct child of grid) |
.apropos-right |
aside |
.apropos-contact |
address |
.apropos-contact-name |
strong inside address |
.apropos-contact-email |
a[href^="mailto:"] inside address |
.apropos-description apropos-page-content |
.prose (single class) |