Layout: page-shell wrapper for iOS sticky, scrollable TOC

iOS WebKit silently degrades position: sticky to static on direct
flex/grid children of <body>, so the sticky nav was breaking on
mobile. Wrapping everything below the nav in a .page-shell flex
column keeps the sticky-footer math out of body { } and restores
sticky behaviour across browsers. The essay-frontmatter hoisted in
the Marks II commit becomes a body-level sibling of .page-shell so
its monogram and epistemic-figure columns can span viewport width.

* templates/default.html wraps $body$ + footer in .page-shell.
* static/css/layout.css moves the flex-column + min-height math from
  body to .page-shell; the body > header rule now excludes
  .essay-frontmatter so the essay header does not inherit nav chrome
  (sticky, nav-bg, border-bottom).
* static/css/base.css drops the html/body overflow-x: clip — the
  page-shell wrapper handles horizontal containment and clipping at
  the viewport level was interfering with position: sticky.
* static/css/reading.css updates its #markdownBody centering selector
  to match .page-shell > #markdownBody.
* static/css/components.css makes the TOC outline scrollable when
  it overflows: bounded max-height tied to the sticky budget plus a
  thin themed scrollbar, with overflow: hidden preserved for the
  collapse transition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-23 12:06:02 -04:00
parent fad8719045
commit 802fc75968
5 changed files with 49 additions and 18 deletions

View File

@ -278,15 +278,11 @@ html {
line-height: var(--line-height); line-height: var(--line-height);
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
scroll-behavior: smooth; scroll-behavior: smooth;
/* clip (not hidden) prevents horizontal scroll at the viewport level
without creating a scroll container, so position:sticky still works. */
overflow-x: clip;
} }
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-x: clip;
background-color: var(--bg); background-color: var(--bg);
color: var(--text); color: var(--text);
transition: background-color var(--transition-fast), transition: background-color var(--transition-fast),

View File

@ -543,12 +543,28 @@ nav.site-nav {
using `aria-hidden="true"` (set by toc.js). The transition still using `aria-hidden="true"` (set by toc.js). The transition still
works because we keep `max-height: 0` for the visual collapse. */ works because we keep `max-height: 0` for the visual collapse. */
.toc-nav { .toc-nav {
overflow: hidden; /* Fill the sticky sidebar's remaining height and scroll when the
max-height: 80vh; outline is taller than the viewport. The subtracted ~2.6rem
mirrors #toc's own max-height budget (layout.css) minus the
.toc-header row (label + progress rule + its bottom margin). */
max-height: calc(100vh - var(--nav-height, 4rem) - 3rem - 2.6rem);
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
transition: max-height 0.3s ease; transition: max-height 0.3s ease;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.toc-nav::-webkit-scrollbar { width: 6px; }
.toc-nav::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
} }
#toc.is-collapsed .toc-nav { #toc.is-collapsed .toc-nav {
max-height: 0; max-height: 0;
/* overflow:hidden so the outline is clipped (not scrolled) while
the max-height transition runs down to 0. */
overflow: hidden;
} }
#toc.is-collapsed .toc-nav a, #toc.is-collapsed .toc-nav a,
#toc.is-collapsed .toc-nav button { #toc.is-collapsed .toc-nav button {

View File

@ -5,10 +5,16 @@
The outer shell. Wide enough for TOC + body + sidenotes. The outer shell. Wide enough for TOC + body + sidenotes.
============================================================ */ ============================================================ */
body { /* Body is plain block keeps the sticky nav header (a direct body
child) working reliably on iOS WebKit, where position: sticky on a
direct flex/grid child silently degrades to static. The sticky-footer
math (full-viewport min-height + flex column to push footer down)
moves to .page-shell, which wraps everything below the nav. */
.page-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: calc(100dvh - var(--nav-height, 4rem));
} }
/* ============================================================ /* ============================================================
@ -17,7 +23,14 @@ body {
(Nav styles live in components.css) (Nav styles live in components.css)
============================================================ */ ============================================================ */
body > header { /* Site-nav header only exclude the essay-frontmatter <header> that
essay/reading/blog templates emit as a body-level sibling so its
monogram and figure columns can span full viewport width. Without
the :not() guard, the essay header inherits the sticky / nav-bg /
border-bottom chrome meant only for the top navigation, painting
the wrong color band over the page and pinning the frontmatter to
the viewport top. */
body > header:not(.essay-frontmatter) {
width: 100%; width: 100%;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background-color: var(--bg-nav); background-color: var(--bg-nav);
@ -78,18 +91,20 @@ body > header {
/* ============================================================ /* ============================================================
STANDALONE PAGES (no #content wrapper) STANDALONE PAGES (no #content wrapper)
essay-index, blog-index, tag-index, page, blog-post, search essay-index, blog-index, tag-index, page, blog-post, search
these emit #markdownBody as a direct child of <body>. Without these emit #markdownBody as a direct child of .page-shell.
the #content flex-row wrapper there is no centering; fix it here. Without the #content flex-row wrapper there is no centering;
fix it here. (Was body > #markdownBody before the page-shell
wrapper was introduced to keep iOS sticky working.)
============================================================ */ ============================================================ */
body > #markdownBody { .page-shell > #markdownBody {
align-self: center; align-self: center;
padding: 2rem var(--page-padding); padding: 2rem var(--page-padding);
flex: 1 0 auto; flex: 1 0 auto;
} }
@media (max-width: 680px) { @media (max-width: 680px) {
body > #markdownBody { .page-shell > #markdownBody {
padding: 1.25rem var(--page-padding); padding: 1.25rem var(--page-padding);
} }
} }
@ -99,7 +114,7 @@ body > #markdownBody {
FOOTER FOOTER
============================================================ */ ============================================================ */
body > footer { .page-shell > footer {
width: 100%; width: 100%;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 1.5rem var(--page-padding); padding: 1.5rem var(--page-padding);
@ -109,6 +124,7 @@ body > footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: auto;
} }
.footer-left { .footer-left {
@ -217,7 +233,7 @@ body > footer {
} }
/* Footer: stack vertically so three sections don't fight for width. */ /* Footer: stack vertically so three sections don't fight for width. */
body > footer { .page-shell > footer {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.3rem;

View File

@ -48,9 +48,10 @@ body.reading-mode {
} }
/* Reading body: narrower than the essay default (800px ~62ch). /* Reading body: narrower than the essay default (800px ~62ch).
Since reading.html emits body > #markdownBody (no #content grid), reading.html emits #markdownBody as a direct child of .page-shell
the centering is handled by the existing layout.css rule. */ (no #content grid); centering is handled by the matching
body.reading-mode > #markdownBody { .page-shell > #markdownBody rule in layout.css. */
body.reading-mode .page-shell > #markdownBody {
max-width: 62ch; max-width: 62ch;
} }

View File

@ -12,8 +12,10 @@ $if(search)$
<script src="/js/semantic-search.js" defer></script> <script src="/js/semantic-search.js" defer></script>
<script src="/js/search-filters.js" defer></script> <script src="/js/search-filters.js" defer></script>
$endif$ $endif$
<div class="page-shell">
$body$ $body$
$partial("templates/partials/footer.html")$ $partial("templates/partials/footer.html")$
</div>
<!-- JS — all deferred --> <!-- JS — all deferred -->
<script src="/js/popups.js" defer></script> <script src="/js/popups.js" defer></script>
<script src="/js/annotations.js" defer></script> <script src="/js/annotations.js" defer></script>