fix(toc): add trailing bottom spacing when the TOC scrolls

The desktop TOC is its own scroll container (overflow-y:auto + max-height),
but unlike article content — which gets trailing space via
.page-content > article::after — it had nothing below the last link. A long
TOC scrolled to the bottom left the final link flush against the edge.

Add .toc::after as a block pseudo-element inside the desktop media query,
using the same technique (and the same Firefox-clips-padding-bottom reason)
as the article. Shared component, so public (about/charte/licence) and admin
(#admin-toc) both get it. Verified with Playwright: 0px -> ~60px gap;
mobile unaffected (TOC is not a scroll container there).
This commit is contained in:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent aa72aa5bc5
commit 626970770f
4 changed files with 212 additions and 54 deletions
+14 -44
View File
@@ -1,53 +1,14 @@
# TODO
> Last updated: 2026-08-31
> Context: CSS waste strategy — full stream DEFERRED (tasks 10/u/w/x/y/z): unusedSymbols report showed only ~6.2KB/2.9% of 216KB reclaimable; pruning + per-page split parked (docs/css-split-analysis.md). #t (Interdit admin route) confirmed already implemented via /admin/media.php + media-viewer.php + recapitulatif link. No pending live tasks.
> Last updated: 2026-09-10
> Context: CSS waste strategy: run unusedSymbols report-only (10-14) first to get go/no-go reclaimable-byte data; per-page split (u-x) second, reusing template inventory in docs/css-split-analysis.md.
## In Progress
## Pending
- [x] #admin-index-toolbar-table-gap Reduce space between admin toolbar and index table. Gap was triple-stacked: `.admin-list-toolbar--list` bottom padding (2ch ≈ 16px) + `#admin-table-wrap > table` margin-top (--space-m ≈ 24px) + `.admin-search-indicator` margin-top (--space-2xs). Fix in app/public/assets/css/admin.css: toolbar padding → `2ch 2ch 0`, table margin-top → `--space-3xs` (~5px), indicator margin-top → 0. Total gap ~40px → ~5px.
- [x] #admin-index-column-spacing Narrow the admin index table columns. Root cause: table lives inside `#admin-table-wrap`, so the `.admin-body main > table th/td` override never matched (direct-child selector) and cells kept base `--space-xs` (~15px) sideways padding from components/tables.css → ~30px between columns. Fix: `.admin-body #admin-table-wrap th/td { padding: var(--space-3xs) var(--space-2xs) }` + fixed the sibling margin selector in app/public/assets/css/admin.css → gap now ~19px.
- [x] #admin-index-badges-nowrap Fix text wrapping in admin index table badges (Accès, Publié). `.status-badge` and `.status-access` had no `white-space: nowrap`, so "En attente"/"Interne" broke mid-word in narrow columns. Added `white-space: nowrap` to both in app/public/assets/css/components/badges.css (served directly, no build step).
## Pending
- [x] #justfile-deploy-code-nginx-split [!medium] de-dup deploy-code: it bundled nginx config + server-side setup (full duplicate of deploy-nginx). Now code-only; deploy depends on deploy-nginx; provision-server's duplicate deploy-nginx removed; var/{cache,logs,tmp} mkdir moved into scripts/deploy-server.sh.
## In Progress
- [x] #cover-home-lazy-intersect-fix [!high] Fix lazy covers not loading after initial images. Root cause: home grid scrolls inside <main class="home-main"> (overflow-y:auto, body height:100vh flex), but htmx `revealed` listens to window scroll only — so below-fold covers never revealed. Fix: hx-trigger="intersect threshold:0.05 once" (IntersectionObserver works inside any scroll container). VERIFIED LIVE: progressive scroll loads covers 5→11→15→20→22/22 (before: stuck ~5). Committed fix(home): use intersect trigger.
- [x] #cover-image-webp-avif-serve-lite [!medium] Serve lightweight webp cover thumbnails instead of full-res PNG originals (~99% on worst cases). DONE VERIFIED LIVE 2026-08-31: `cwebp -resize 800 q80` sidecars (+ GD fallback for CMYK JPEGs) generated by ThesisFileHandler at upload (FilePond + legacy); /cover-fragment emits <picture> (webp source + original fallback); backfill scripts/generate-cover-webp.php + `just deploy-cover-webp`. PROD: tools installed (docs), 46/46 covers backfilled (44 cwebp + 2 GD/CMYK), app code deployed, /cover-fragment + /media?path=*.webp verified 200 image/webp 61KB (vs 15.8MB orig). AVIF dropped (no viable prod resize; WEBP sufficient). NOTE: just deploy-code's final nginx/permissions step needs sudo password (operational, pre-existing — not a regression).
## In Progress
- [x] #fix-toc-scrollbar-overlap-content-pages Fix TOC scrollbar overlapping text on about/charte/licence pages (desktop sticky sidebar). Root cause: `.toc` scroll container (overflow-y:auto, 180px col) had no right gutter, so native scrollbar sat on top of link text. Fix: padding-right (not margin) on the `.toc` scroll container + matching padding on summary.
## Pending
- [x] #home-htmx-lazy-cover-images [!medium] Add htmx lazyloading on home page cover images: `revealed`-triggered cover-fragment endpoint, swap-in `<img>`, settle fade CSS, load htmx on home
## In Progress
- [x] #cleanup-toc-fix-duplicate-id [+admin] Clear TOC links after cleanup restructure. Root cause: admin-toc.js assigned `sec.id = headingId` to sections, and on rebuild-because-of-htmx-swap the section + h2 ended up sharing the same id, so getElementById returned the <section> whose textContent dragged the whole data table into the TOC label. Fix: anchor links to the heading id and never give the section that id (no duplicate ids); verified via Playwright — TOC now lists exactly Tiléversements abandonnés / Corbeille / Vidéos PeerTube with no table text.
## In Progress
- [x] #cleanup-stats-under-headings [+admin] On the cleanup page, move each section's stat/count out of the heading to a line directly under it, and merge PeerTube into a single heading. cleanup-stats-fragment: "Téléversements abandonnés" / "Corbeille" h2 now bare, stat emitted as `<p class=n-meta>` under the h2. peertube-orphans-fragment: removed the separate "Vidéos orphelines" h3; the orphan count now appears as the stat line under the static "Vidéos PeerTube" h2. Verified via Playwright: TOC still lists exactly 3 clean entries; stats render under headings.
## In Progress
- [x] #cleanup-stat-card [+admin] Render the cleanup-page section stats as a proper card box (`cleanup-stat-card`, bigger than the old inline n-meta): padded panel (bg-secondary + border, radius 10px), step-1/700/accent text. Applied to the filepond, trash and PeerTube stat lines. Verified computed style via Playwright.
## In Progress
- [x] #cleanup-desc-and-tooltips [+admin] Cleanup page: add short French descriptions under each section heading (Téléversements abandonnés / Corbeille / Vidéos PeerTube) explaining what the data is and how safe deletion is (`.cleanup-section-desc`), and native title tooltips on the Supprimer and Restaurer buttons of each row. Verified via Playwright (descriptions + tooltips render on all rows).
## In Progress
- [x] #cleanup-auto-gc-uploads [+scripts/deploy] Abandoned FilePond uploads are now garbage-collected automatically (they are risk-free staging dirs never linked to a TFE). Added `scripts/cleanup-tmp-uploads.php` (dry-run by default, mirrors the admin cleanup filepond logic: missing-session via manifest.json + 2h fallback), hourly cron `deploy/xamxam-tmp-cleanup.cron`, `just deploy-tmp-cleanup-cron` + `just deploy-check-tmp-cleanup-log`, wired into `deploy-all-first`, and documented in docs/deployment.md. Tested dry-run + --no-dry-run + active-session protection locally.
## In Progress
- [x] #cleanup-remove-filepond-section [+admin] Since abandoned FilePond uploads are GC'd by cron, removed the "Téléversements abandonnés" section from the cleanup page and the index "Nettoyer" badge now counts trash only. cleanup-stats-fragment.php renders only the Corbeille section (and "Corbeille vide" when empty); index.php badge is trash-only; admin-cleanup-bulk.js simplified to trash_files[] only. Verified via Playwright: only Corbeille + Vidéos PeerTube sections/TOC, no filepond checkboxes, tooltips intact.
## In Progress
- [x] #cleanup-page-title [+admin] Simplify cleanup page title from "Nettoyer les fichiers temporaires" to "Nettoyage" (page now mostly manages the Corbeille). Updated `app/public/admin/cleanup.php` pageTitle and `app/templates/admin/cleanup.php` h1.
## In Progress
- [x] #docs-cleanup-changes [docs] Update docs for the cleanup-page + GC changes made: admin README cleanup.php entry -> "Cleanup page (Corbeille — restore/delete trashed files)"; deployment.md adds a paragraph explaining abandoned uploads are auto-GC'd hourly and the page (titled Nettoyage) now only shows the Corbeille; added `just cleanup-tmp-uploads` recipe and documented it in development.md alongside cleanup-drafts.
- [ ] #test-env-deploy-code-chown-regression test-env deploy-code --chown regression documented in README --context test-env
## Completed
- [x] #fix-content-page-bottom-spacing-firefox [!medium] Fix missing bottom spacing at scroll-end on apropos/licence/charte in Firefox. Root cause: `.page-content` is a nested scroll container (overflow-y:auto) inside html/body {height:100%; overflow:hidden} flex; Firefox drops/clips that container's own `padding-bottom` (and last-child margin) from the scrollable area while Chromium honours it — FF scrollHeight came out ~80px SHORTER and the last ~80px of content was unreachable (clipped). Fix: moved trailing space off the container padding (`padding-bottom: 0` at all breakpoints) onto a real content spacer `.page-content > article::after { display:block; height: var(--space-2xl) }` (`var(--space-xl)` below 768px), which both browsers count as scrollable content. VERIFIED via Playwright (Chromium + Firefox): before FF delta=−80 (clipped) vs Chrome 0; after both FF & Chrome delta=0 on licence/apropos and identical on charte; not-found page unaffected (loads its own not-found.min.css). 294 PHPUnit tests pass.
- [x] #cleanup-toc [+admin] Add sidebar TOC to admin cleanup page; drop the "Fichiers temporaires" section level and promote "Téléversements abandonnés" / "Corbeille" from h3 to h2 top-level sections (now TOC entries). Made admin-toc.js rebuild on htmx swap/settle so async-loaded fragment sections enter the TOC.
- [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale
- [x] #rewrite-development-md-to-match [!high] Rewrite development.md to match current just dev / app/ layout / PHPUnit
- [x] #rewrite-deployment-md-to-match [!high] Rewrite deployment.md to match just deploy / /var/www/xamxam/ / backup
@@ -81,6 +42,15 @@
- [x] #write-report-script-to [!medium] Write report script to compute unused selectors + reclaimable bytes — Built scripts/css-unused-report.mjs + just css-report recipe. Extracts class/id symbols per dist bundle, matches against buildCorpus() corpus + safelists, measures reclaimable bytes via lightningcss transform unusedSymbols (no stripping to disk). FULL RESULT: 216,383B total, ~6.2KB (2.9%) reclaimable. See docs/css-split-analysis.md table. Feeds task 14 go/no-go.
- [x] #flag-or-safelist-dynamic [!medium] Flag or safelist dynamic classes before trusting the report — Vendor + dynamic safelists shipped. Dynamic: 22 exact classes (status-*, log-*, input-error, active, disabled, btn--*, fhb-*, admin-icon-btn--*, status-badge/published/pending) + 5 DB/state suffix prefixes (status-access--*, toc-level-*, admin-import-log__item--*, admin-body, student-body). VENDOR: filepond--* and htmx-* prefixes excluded (runtime vendor JS assembly) — without these the report inflated to 26% (56KB); with them honest 2.9%. Report surfaces needs-review (kept, not flagged removable).
- [x] #summarize-findings-and-decide [!low] Summarize findings and decide whether to proceed to pruning — Decision recorded in docs/css-split-analysis.md. Data: 216,383B total, ~6.2KB (2.9%) reclaimable; base.min.css only 484B. SPLIT: NO-GO (base.css already well-used; split adds complexity for ~2% of one bundle) -> defer u-w-x-y. PRUNING: CONDITIONAL-GO on hand-verifiable dead set only, from SOURCE css files, re-run just css-report after each edit, never touch needs-review/vendor-prefix classes.
- [x] #archive-non-documentation-one-offs-from Archive non-documentation one-offs from docs/ (plans, incidents, proposals, assessments); keep only reference docs
- [x] #verify-every-retained-docs Verify every retained docs/ file against current repo; fix inaccuracies found
- [x] #document-app-key-env-first Document APP_KEY/.env + first admin setup in dev and deploy docs
- [x] #add-idempotent-provisioning-scripts Add idempotent provisioning scripts (local + server) for APP_KEY/.env and just provision/provision-server
- [x] #podman-compose-test-env podman compose test env for fresh Debian server --context test-env
- [x] #deploy-permissions-fix-ownership-break deploy-permissions: fix ownership break so just deploy-code can't cause HTTP 500
- [x] #fix-desync-deploy-code-exit-23 Fix desync: deploy-code exit-23 'Operation not permitted' — drop -p/-t (destination owned by www-data:xamxam, setgid; SSH user can't chmod), use -rlDz --size-only --context deploy
- [x] #deploy-permissions-sudo-prompt-can-t deploy-permissions sudo prompt can't accept input: ssh -t drops pty when local stdin isn't a TTY. Add scoped NOPASSWD sudo drop-in (deploy/xamxam-fix-permissions.sudoers) + deploy-sudoers recipe and wire into deploy --context deploy
- [x] #fix-raw-markdown-leaking Fix raw markdown leaking into TOC labels on licence/charte/about pages
## Deferred / Blocked
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
+9
View File
@@ -136,6 +136,15 @@
padding-right: var(--space-3xs);
}
/* Trailing bottom spacing when the TOC scrolls (long lists). Pseudo-element
(real content in the scroll area) rather than the container's
padding-bottom, which Firefox clips in this nested flex layout. */
.toc::after {
content: "";
display: block;
height: var(--space-xl);
}
.toc summary {
pointer-events: none;
}
+51 -10
View File
@@ -8,8 +8,11 @@ class MarkdownHelper
/**
* Extract h1–h3 headings from raw markdown content as TOC items.
*
* Each heading gets an anchor id matching CommonMark's default slugification
* (lowercase, spaces → hyphens, punctuation stripped).
* The heading is parsed with CommonMark so inline markup (`*em*`,
* `**strong**`, `` `code` ``, `[links]()`) is resolved instead of leaking
* raw markdown into the TOC. The anchor id is slugified from the parsed
* inline text, which is what CommonMark uses to build the rendered heading
* ids — so TOC links and heading anchors always match.
*
* @return array<int, array{label: string, href: string, level: int}>
*/
@@ -17,19 +20,57 @@ class MarkdownHelper
{
$items = [];
$lines = explode("\n", $content);
// Use CommonMark's own SlugNormalizer so TOC links match the rendered heading IDs exactly.
$normalizer = new \League\CommonMark\Normalizer\SlugNormalizer();
foreach ($lines as $line) {
if (preg_match('/^(#{1,3})\s+(.+)$/', $line, $m)) {
$level = strlen($m[1]);
$label = trim($m[2]);
$id = $normalizer->normalize($label);
$items[] = ['label' => $label, 'href' => '#' . $id, 'level' => $level];
if (!preg_match('/^(#{1,3})\s+(.+)$/', $line, $m)) {
continue;
}
$headingText = self::parseInlineText(trim($m[2]));
$items[] = [
'label' => $headingText,
'href' => '#' . $normalizer->normalize($headingText),
'level' => strlen($m[1]),
];
}
return $items;
}
/**
* Parse markdown and return its plain-text content.
*
* Walks the inline AST and concatenates the text nodes, so links keep their
* label (`[lien](url)` → "lien") and markup is dropped.
*/
private static function parseInlineText(string $markdown): string
{
static $parser = null;
if ($parser === null) {
$environment = new \League\CommonMark\Environment\Environment([
'html_input' => 'strip',
]);
$environment->addExtension(new \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension());
$parser = new \League\CommonMark\Parser\MarkdownParser($environment);
}
$document = $parser->parse($markdown);
$walker = $document->walker();
$text = '';
while ($event = $walker->next()) {
if (!$event->isEntering()) {
continue;
}
$node = $event->getNode();
if ($node instanceof \League\CommonMark\Node\Inline\Text
|| $node instanceof \League\CommonMark\Extension\CommonMark\Node\Inline\Code
) {
$text .= $node->getLiteral();
}
}
return trim($text);
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
use PHPUnit\Framework\TestCase;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
/**
* MarkdownHelperTest — TOC extraction from markdown headings.
*
* Regression: TOC labels used to show raw markdown (`*Licence*`, `**Gras**`,
* `` `code` ``), because the raw heading text was passed straight to the
* template. Labels must be plain text and hrefs must match the heading ids
* CommonMark generates on the rendered page.
*/
class MarkdownHelperTest extends TestCase
{
/**
* Render markdown exactly like the content controllers do, and return the
* heading ids in document order.
*/
private function renderedHeadingIds(string $markdown): array
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'heading_permalink' => [
'apply_id_to_heading' => true,
'id_prefix' => '',
'insert' => 'before',
'aria_hidden' => true,
],
]);
$converter->getEnvironment()->addExtension(new HeadingPermalinkExtension());
$html = $converter->convert($markdown)->getContent();
preg_match_all('/<h[1-3] id="([^"]+)"/', $html, $matches);
return $matches[1];
}
// ── Labels are plain text ─────────────────────────────────────────────────
public function testItalicMarkupIsStrippedFromLabel(): void
{
$toc = MarkdownHelper::extractToc('## *Licence* CC');
$this->assertSame('Licence CC', $toc[0]['label']);
}
public function testBoldMarkupIsStrippedFromLabel(): void
{
$toc = MarkdownHelper::extractToc('## **Gros** gras');
$this->assertSame('Gros gras', $toc[0]['label']);
}
public function testInlineCodeMarkupIsStrippedFromLabel(): void
{
$toc = MarkdownHelper::extractToc('## `code` inline');
$this->assertSame('code inline', $toc[0]['label']);
}
public function testLinkKeepsLabelTextNotUrl(): void
{
$toc = MarkdownHelper::extractToc('## [lien](http://x) ici');
$this->assertSame('lien ici', $toc[0]['label']);
}
public function testNestedMarkupIsFullyStripped(): void
{
$toc = MarkdownHelper::extractToc('## *[lien](http://x)* et `code`');
$this->assertSame('lien et code', $toc[0]['label']);
}
public function testNoMarkdownSyntaxLeaksIntoAnyLabel(): void
{
$markdown = "## *Italique*\n\n## **Gras**\n\n## `Code`\n\n## [Lien](http://x)\n";
$labels = array_column(MarkdownHelper::extractToc($markdown), 'label');
foreach ($labels as $label) {
$this->assertDoesNotMatchRegularExpression('/[*`]|\[|\]\(/', $label);
}
}
// ── Hrefs match rendered heading ids ──────────────────────────────────────
public function testHrefsMatchRenderedHeadingIdsForInlineMarkup(): void
{
$markdown = "## *Licence* CC\n\n## **Gros** gras\n\n## `code` inline\n\n## [lien](http://x) ici\n";
$toc = MarkdownHelper::extractToc($markdown);
$headingIds = $this->renderedHeadingIds($markdown);
foreach ($toc as $index => $item) {
$this->assertSame('#' . $headingIds[$index], $item['href'], "TOC href mismatch at index {$index}");
}
}
public function testHrefsMatchRenderedHeadingIdsForAccents(): void
{
$markdown = "## Été accents\n\n## Emoji 🎉 fin\n";
$toc = MarkdownHelper::extractToc($markdown);
$headingIds = $this->renderedHeadingIds($markdown);
foreach ($toc as $index => $item) {
$this->assertSame('#' . $headingIds[$index], $item['href'], "TOC href mismatch at index {$index}");
}
}
// ── Levels and structure ──────────────────────────────────────────────────
public function testLevelsAreCapturedFromHashes(): void
{
$markdown = "# Un\n\n## Deux\n\n### Trois\n";
$toc = MarkdownHelper::extractToc($markdown);
$this->assertSame([1, 2, 3], array_column($toc, 'level'));
}
public function testHeadingsDeeperThanThreeAreIgnored(): void
{
$toc = MarkdownHelper::extractToc("# Un\n\n#### Quatre\n");
$this->assertCount(1, $toc);
}
public function testContentWithoutHeadingsYieldsEmptyToc(): void
{
$this->assertSame([], MarkdownHelper::extractToc("Juste du texte.\n\nEt un paragraphe."));
}
public function testEmptyContentYieldsEmptyToc(): void
{
$this->assertSame([], MarkdownHelper::extractToc(''));
}
}