From ff409870c4eb9d039d4d2f7eef58910036db0867 Mon Sep 17 00:00:00 2001
From: Bissbert <43237892+Bissbert@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:25:00 +0200
Subject: [PATCH] fix(learn): pin desktop TOC on scroll + mobile bottom-sheet
section switcher
- desktop TOC rail was never actually sticky on scroll - the grid's items-start shrink-wrapped the TOC cell so position:sticky had no travel room; fixed with lg:self-stretch on the TOC cell (measured pinned at top:80px through scrollY 4000)
- mobile "On this page" floating thumb-zone button opens a native bottom sheet (focus trap, Escape/backdrop/link-tap dismiss, focus restore, reduced-motion + both themes), replacing the top-of-article details that scrolled out of reach
- one shared IntersectionObserver keeps scroll-spy in sync across desktop rail + mobile sheet
502 tests, browser scroll-verified.
---
src/components/learn/TableOfContents.astro | 298 +++++++++++++++++----
src/pages/learn/[...slug].astro | 2 +-
2 files changed, 242 insertions(+), 58 deletions(-)
diff --git a/src/components/learn/TableOfContents.astro b/src/components/learn/TableOfContents.astro
index d56d47c..4b889e3 100644
--- a/src/components/learn/TableOfContents.astro
+++ b/src/components/learn/TableOfContents.astro
@@ -10,9 +10,17 @@
* Renders two variants, each responsible for hiding itself at the "wrong"
* breakpoint via Tailwind's `lg:` variant, so the page only needs to
* include this component once:
- * - Mobile ( above the article body.
+ * - Mobile ( bottom sheet listing
+ * the same sections. A top-of-article control would scroll out of
+ * reach on a long article, so this replaces that pattern entirely
+ * rather than running both (avoids two redundant mobile TOCs).
* - Desktop (lg+): a sticky aside rail with scroll-spy highlighting.
*
+ * Both variants share the `.toc-link` class + `data-toc-target` attribute,
+ * so the single scroll-spy observer below keeps the active-section
+ * indication in sync in both places at once.
+ *
* The page decides layout/grid placement; this component only owns its own
* content and internal responsive visibility.
*/
@@ -32,18 +40,56 @@ const show = items.length >= 4;
{show && (
<>
- {/* Mobile: collapsible, closed by default, rendered above the article body */}
-
-
- On this page
-
-
-
+ {/* Mobile: floating trigger, thumb-reachable at any scroll position. */}
+
+
+
+
+
+
+
+
+
+ On this page
+
+
+ {/* Mobile: bottom sheet, opened by the trigger above. Native
+ (via showModal()) gives us a top-layer modal with an automatic
+ focus trap and Escape-to-close for free - no dependency needed. */}
+
+
+
+ On this page
+
+
+
+
+
+
+
+
+
{items.map((item) => (
{item.title}
@@ -51,7 +97,7 @@ const show = items.length >= 4;
))}
-
+
{/* Desktop: sticky rail with scroll-spy */}
= 4;
@@ -157,4 +286,59 @@ const show = items.length >= 4;
color: #38bdf8; /* crystal-400 */
border-left-color: #38bdf8;
}
+
+ /* The desktop rail's active indicator uses a left border rail; the mobile
+ sheet's rows are borderless pill rows, so the active state there needs
+ its own background tint instead (the color/font-weight rule above still
+ applies to both via the shared `.toc-link[aria-current]` selector). */
+ .toc-mobile-link[aria-current='location'] {
+ background-color: rgb(3 105 161 / 0.08); /* crystal-700 tint */
+ }
+ :global([data-theme='dark']) .toc-mobile-link[aria-current='location'] {
+ background-color: rgb(56 189 248 / 0.12); /* crystal-400 tint */
+ }
+
+ /* Bottom sheet: reset the UA box model and turn it into a
+ full-width panel pinned to the bottom of the viewport. */
+ .toc-mobile-sheet {
+ position: fixed;
+ inset: auto 0 0 0;
+ margin: 0;
+ width: 100%;
+ max-width: 100%;
+ max-height: 85vh;
+ padding: 0;
+ border: none;
+ border-top-left-radius: 1rem;
+ border-top-right-radius: 1rem;
+ background: #ffffff;
+ color: inherit;
+ overflow-y: auto;
+ transform: translateY(100%);
+ transition: transform 220ms ease;
+ }
+ .toc-mobile-sheet.toc-sheet--visible {
+ transform: translateY(0);
+ }
+ .toc-mobile-sheet::backdrop {
+ background: rgb(15 23 42 / 0.5); /* slate-900/50 */
+ opacity: 0;
+ transition: opacity 220ms ease;
+ }
+ .toc-mobile-sheet.toc-sheet--visible::backdrop {
+ opacity: 1;
+ }
+ :global([data-theme='dark']) .toc-mobile-sheet {
+ background: #332619; /* coffee-raised2 */
+ color: #ede3d5; /* cream-primary */
+ border: 1px solid #362b23; /* coffee-border */
+ border-bottom: none;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .toc-mobile-sheet,
+ .toc-mobile-sheet::backdrop {
+ transition: none;
+ }
+ }
diff --git a/src/pages/learn/[...slug].astro b/src/pages/learn/[...slug].astro
index 71537eb..a5af88c 100644
--- a/src/pages/learn/[...slug].astro
+++ b/src/pages/learn/[...slug].astro
@@ -232,7 +232,7 @@ const showToc = data.sections.length >= 4;