ssh git@grit.deadkey.org man

Commit fe19d8d h

Pat Ambrosio · 26-08-25 16:58

Align page title with its sidebar and body by construction

Changes: 2 files +253 -214

Changes (Side-by-Side)

share/jenny/layout/page.sh +90 -74

crumbs() {
70 # navs/site-footer.tsv does not exist, rendering is silently skipped. 70 # navs/site-footer.tsv does not exist, rendering is silently skipped.
71 # This is decided in one place: read_nav with --optional, which returns 71 # This is decided in one place: read_nav with --optional, which returns
72 # cleanly if the file does not exist. 72 # cleanly if the file does not exist.
73 # Not currently called: the default theme renders no footer. Kept because the
74 # template-baked nav mechanism it demonstrates is the feature -- a layout can
75 # ask for a nav no page declared. Call this, or write your own, to bring one
76 # back.
73 render_footer_nav() { 77 render_footer_nav() {
74 local lines 78 local lines
75 lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || return 0 79 lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || return 0
cat << _EOF_
188 } 192 }
189 @media (min-width: 736px) { body { font-size: 16px } } 193 @media (min-width: 736px) { body { font-size: 16px } }
190 article { padding: 0; margin: 1em 0; max-width: 74ch; } 194 article { padding: 0; margin: 1em 0; max-width: 74ch; }
195 /* 74ch is sized for a post: the arrow's column plus a 60ch body. A
196 sidebar is an addition to that, so the article grows by exactly the
197 sidebar's column and gap. Without this the sidebar ate into the
198 74ch and the body came out 537px against a post's 540 -- a
199 difference with no cause the reader could see. Widening here rather
200 than narrowing the body keeps the measure identical everywhere. */
201 article:has(.page-nav) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); }
191 article a { color: var(--link-article); } 202 article a { color: var(--link-article); }
192 article img { max-width: 100% } 203 article img { max-width: 100% }
193 /* Blockquote keeps its left rule; code blocks are a tinted block 204 /* Blockquote keeps its left rule; code blocks are a tinted block
cat << _EOF_
277 Width measured from a rendered post: the stamp is inline-block 288 Width measured from a rendered post: the stamp is inline-block
278 beside a table-cell title, so the 2.25em nominal width bears no 289 beside a table-cell title, so the 2.25em nominal width bears no
279 relation to the 109px the pair actually spans. */ 290 relation to the 109px the pair actually spans. */
280 /* The title aligns with the body's left edge, so it sits above the
281 body rather than orphaned above the sidebar. On pages with a nav,
282 the body starts at --page-content-left. Without a nav, at
283 --page-body-offset. The heading uses the same offset as its
284 sibling .contents. */
285 /* The heading spans the full width and holds two things: the back
286 arrow, which stays in the left column beside the nav, and the
287 title, which lines up with the body it titles. Offsetting the
288 whole heading moved the arrow out of its column with it -- the
289 arrow keeps its own width and the title is pushed by the rest. */
290 .heading-page { display: flex; align-items: baseline; margin-bottom: 1em;
291 margin-left: 0; }
292 .heading-page .home { flex: 0 0 var(--page-content-left);
293 margin: 0; text-align: left; }
294 .page-wrapper:not(:has(.page-nav)) .heading-page .home {
295 flex-basis: var(--page-body-offset); }
291 /* No margin-bottom: post.sh's .heading has none, and the extra 1em put 48px
292 between a page title and its body where a post has none. */
293 /* On a post the stamp holds the left column and the title starts where
294 the body does. A page has no stamp, so without a spacer here the
295 title sat at the far left while its own body was indented past it.
296 The heading reserves the same column the body does, by the same
297 mechanism, so the two line up whatever the width. */
298 .heading-page { display: flex; align-items: baseline; margin-left: 0; }
296 .heading-page .title { display: block; margin: 0; flex: 1; } 299 .heading-page .title { display: block; margin: 0; flex: 1; }
300 .heading-page .home-spacer { margin-bottom: 0; flex: 0 0 auto; }
301 /* Beside a sidebar the whole cell is already offset, so the heading
302 must not indent again -- the title would land a column right of its
303 own body. */
304 .page-wrapper:has(.page-nav) .heading-page .home-spacer { display: none; }
297 .home { display: inline-block; text-align: right; margin-right: 1.5em; 305 .home { display: inline-block; text-align: right; margin-right: 1.5em;
298 width: 5.4em; margin-bottom: 1.5em; text-decoration: none; 306 width: 5.4em; margin-bottom: 1.5em; text-decoration: none;
299 color: var(--link-home); } 307 color: var(--link-home); }
cat << _EOF_
310 .home { display: inline-block; text-align:right; margin-right: 1.5em; } 318 .home { display: inline-block; text-align:right; margin-right: 1.5em; }
311 .home { text-decoration: none; width: 5.4em; margin-bottom: 1.5em; text-align: left; color: var(--link-home); } .home:hover { color: var(--link-home-hover); } 319 .home { text-decoration: none; width: 5.4em; margin-bottom: 1.5em; text-align: left; color: var(--link-home); } .home:hover { color: var(--link-home-hover); }
312 .heading .stamp { color: var(--text-muted); } 320 .heading .stamp { color: var(--text-muted); }
313 /* On a post the body is indented because the back arrow sits beside
314 it. The arrow has moved up into the heading, so the body needs the
315 same offset explicitly or it falls back to the left margin and
316 stops lining up with the title above it. */
317 /* A nav is an ordered list of names and places; what it means is 321 /* A nav is an ordered list of names and places; what it means is
318 left to this template. Rendered as a left sidebar beside the body.
319 The body's left edge is derived from a custom property so the footer
320 can align to it without duplicating the calculation. */
321 :root { --page-body-offset: 6.82rem; --page-nav-width: 16rem; --page-nav-gap: 2rem;
322 /* rem, not em: em resolves against each element's own
323 font-size, so the footer at .85em computed a different
324 length from the same variable and sat 59px left of the body.
325 Where the body column starts. Anything that must line up with
326 the body reads this one value rather than recomputing it --
327 the footer and the body were expressed separately before, so
328 moving the body left the footer behind under the sidebar. */
329 --page-content-left: calc(var(--page-body-offset) + var(--page-nav-width) + var(--page-nav-gap)); }
322 left to this template. Rendered as a left sidebar beside the body. */
323 :root { --page-nav-width: 6rem; --page-nav-gap: 2rem; }
330 324
331 .page-nav { position: absolute; width: var(--page-nav-width); margin: 0; padding: 0;
332 font-size: .9em; }
325 /* min-width as well as width: in an auto-layout table a declared
326 width is only a suggestion, so the nav column shrank to whatever the
327 longest label happened to be and the body's left edge moved with it.
328 The floor keeps the column a fixed 6rem regardless of the labels. */
329 .page-nav { display: table-cell; vertical-align: top;
330 width: var(--page-nav-width);
331 min-width: var(--page-nav-width);
332 margin: 0; padding: 0; font-size: .9em; }
333 .page-nav .nav-item { display: block; text-decoration: none; 333 .page-nav .nav-item { display: block; text-decoration: none;
334 color: var(--link-nav); margin-bottom: .35em; } 334 color: var(--link-nav); margin-bottom: .35em; }
335 .page-nav .nav-item:hover { color: var(--link-nav-hover); } 335 .page-nav .nav-item:hover { color: var(--link-nav-hover); }
336 336
337 .page-wrapper { position: relative; }
338
339 .contents { display: inline-block; max-width: 60ch; vertical-align: top;
340 width: 100%; margin-left: var(--page-body-offset); }
341 .page-wrapper:has(.page-nav) .contents { margin-left: var(--page-content-left); }
342 .page-wrapper:not(:has(.page-nav)) .contents { margin-left: var(--page-body-offset); }
343 /* A page with no nav has no sidebar to clear, so its footer sits at
344 the base offset like its body. */
345 .page-wrapper:not(:has(.page-nav)) footer.site-footer {
346 margin-left: var(--page-body-offset); }
337 /* The sidebar and the body are two cells of one table row, which is
338 the same mechanism the post layout uses to hold a stamp beside a
339 title. Sharing a row is what makes their tops line up: the browser
340 aligns the cells, so there is no offset here to keep in sync with
341 the heading's height. An earlier version positioned the nav
342 absolutely and pushed it down by a measured amount, which had to be
343 re-measured whenever anything above it changed. */
344 /* The table sizes to its content rather than to the article's width.
345 Capped at 100% the two columns competed for the same space: pinning
346 the nav to 6rem took three pixels off the body, so the body column
347 came out narrower on a page with a sidebar than on one without --
348 the body would have differed from a post's for no reason the reader
349 could see. Sizing to content lets the nav hold its 6rem and the body
350 hold its full 60ch, with the sidebar adding to the total width
351 instead of subtracting from the body. There is room: the body column
352 tops out at 60ch and the viewport is wider than nav plus body
353 wherever this layout applies. */
354 .page-wrapper { display: table; table-layout: auto; width: auto; }
355 .contents-cell { display: table-cell; vertical-align: top;
356 padding-left: var(--page-nav-gap); }
357 /* With no nav, the body has to line up with a post's body, and a post
358 gets its indent from the back arrow sitting inline before the
359 contents -- not from a fixed length. That is why the indent is a
360 spacer of the arrow's exact size rather than a copied number: the
361 two are inline-blocks, so they sit side by side while they fit and
362 the body wraps under the spacer when the screen is too narrow,
363 reflowing at exactly the width a post reflows at. A hardcoded
364 padding could match at one width but broke at every other -- at
365 735px it put the page body 97px left of the post's. Pages have no
366 arrow (it means nothing on a page), so the spacer is empty and
367 hidden from assistive tech. */
368 .page-wrapper:not(:has(.page-nav)) .contents-cell { display: block;
369 padding-left: 0; }
370 .home-spacer { display: inline-block; width: 5.4em; margin-right: 1.5em;
371 margin-bottom: 1.5em; vertical-align: top; }
372 .page-wrapper:has(.page-nav) .home-spacer { display: none; }
373 .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
347 374
348 /* Narrow: the sidebar has nowhere to go, so it stacks above the body 375 /* Narrow: the sidebar has nowhere to go, so it stacks above the body
349 and everything collapses to one column. The arrow's column and the
350 footer must reset too -- leaving them at the wide offsets pushed the
351 title and footer off a 400px screen entirely. */
376 and everything collapses to one column. Every part has to leave
377 table layout together -- a lone cell reverting to block still gets
378 an anonymous table wrapper and stays in its column. */
352 @media (max-width: 735px) { 379 @media (max-width: 735px) {
353 .page-nav { position: static; width: 100%; margin-bottom: 2em; }
380 .page-wrapper,
381 .page-nav,
382 .contents-cell { display: block; }
383 .page-nav { width: auto; min-width: 0; margin-bottom: 2em; }
354 /* No indent at this width: 6.82rem plus a 60ch column overflows a 384 /* No indent at this width: 6.82rem plus a 60ch column overflows a
355 phone and forces horizontal scroll. The arrow stacks above the
356 title instead of holding a column beside it. */
357 .heading-page { margin-left: 0; display: block; }
358 .heading-page .home,
359 .page-wrapper:not(:has(.page-nav)) .heading-page .home {
360 flex: none; display: block; margin-bottom: .5em; }
361 .contents,
362 .page-wrapper:has(.page-nav) .contents { margin-left: 0; }
363 .page-wrapper footer.site-footer,
364 .page-wrapper:not(:has(.page-nav)) footer.site-footer {
365 margin-left: 0; }
385 phone and forces horizontal scroll. */
386 .contents-cell { padding-left: 0; }
387 /* The heading is left alone here. A post has no breakpoint at all --
388 its heading and body both collapse by wrapping, at the same width,
389 because both are driven by the same inline column. Forcing the
390 heading to block at 735px un-indented the title while the body was
391 still indented, so the two split apart for exactly one step of the
392 range. Letting it wrap on its own keeps them together. */
366 } 393 }
367 .contents :first-child { margin-top: 0; } 394 .contents :first-child { margin-top: 0; }
368 h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; } 395 h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; }
cat << _EOF_
413 background-color: var(--bg-secondary); 440 background-color: var(--bg-secondary);
414 } 441 }
415 442
416 /* Footer nav is a template-baked nav -- presentation, on every page,
417 declared by the theme rather than by any page. Positioned to align
418 with body content using the same left offset. Uses a subtle Unicode
419 separator rendered via CSS. */
420 footer.site-footer { margin-top: 3em; padding-top: 2em; font-size: .85em;
421 text-align: left; max-width: 60ch; margin-left: var(--page-content-left); }
422 footer.site-footer::before { content: ""; display: block; height: 1px;
423 background: var(--border-lighter);
424 margin-bottom: 1.25em; }
425 footer.site-footer .footer-link { display: block; text-decoration: none;
426 color: var(--text-muted); margin-bottom: .5em; }
427 footer.site-footer .footer-link:hover { color: var(--link-nav-hover); }
428 </style> 443 </style>
429 <script> 444 <script>
430 (function() { 445 (function() {
cat << _EOF_
464 <header><a href="$URL_PREFIX/"$([ -z "${PAGE_CRUMBS:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(crumbs)</header> 479 <header><a href="$URL_PREFIX/"$([ -z "${PAGE_CRUMBS:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(crumbs)</header>
465 <article> 480 <article>
466 <div class="page-wrapper"> 481 <div class="page-wrapper">
467 <div class="heading heading-page"><a href="$URL_PREFIX/" class="home" aria-label="Back to the index">&#x2190;&#xFE0E;</a><h1 class="title">$(html_escape "${POST_TITLE:-}")</h1></div>
468 $(page_nav) 482 $(page_nav)
469 <div class="contents">
483 <div class="contents-cell">
484 <div class="heading heading-page"><span class="home-spacer" aria-hidden="true"></span><h1 class="title">$(html_escape "${POST_TITLE:-}")</h1></div>
485 <span class="home-spacer" aria-hidden="true"></span><div class="contents">
470 $(echo "${POST_CONTENTS:-}") 486 $(echo "${POST_CONTENTS:-}")
471 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 487 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
472 </div> 488 </div>
473 $(render_footer_nav)
489 </div>
474 </div> 490 </div>
475 </article> 491 </article>
476 </body> 492 </body>

test/regressions.sh +163 -140

assert_file_exists "$nop/.dist/fallback.html"
984 it "and does so without complaint" 984 it "and does so without complaint"
985 assert_eq "$stderr_output" "" 985 assert_eq "$stderr_output" ""
986 986
987 it "the back arrow sits in the heading, not below it"
988 assert_file_contains "$pg/.dist/study/consciousness.html" 'class="heading heading-page"><a href="../" class="home"'
989
990 it "the arrow keeps an accessible name"
991 assert_file_contains "$pg/.dist/study/consciousness.html" 'aria-label="Back to the index"'
987 it "a page has no back arrow"
988 # A post's arrow goes back to the index it was listed on. A page is not listed
989 # anywhere -- it is reached by its own stable path -- so an arrow to the index
990 # points at a place the reader did not come from. The breadcrumb in the header
991 # is what a page navigates by instead.
992 assert_file_not_contains "$pg/.dist/study/consciousness.html" 'class="home"'
993
994 it "the arrow's column is still reserved so the body lines up with a post's"
995 # Removing the arrow must not move the body: a post indents by the arrow's
996 # width, and a page has to match it.
997 assert_file_contains "$pg/.dist/study/consciousness.html" 'class="home-spacer"'
992 998
993 it "a blog with only pages and no posts still builds" 999 it "a blog with only pages and no posts still builds"
994 only="$TMPROOT/onlypages" 1000 only="$TMPROOT/onlypages"
else
1201 fail "helpers.sh tag index does not use event delegation for toggle" 1207 fail "helpers.sh tag index does not use event delegation for toggle"
1202 fi 1208 fi
1203 1209
1204 suite "template navs"
1205
1206 it "a template nav renders without any page declaring it"
1207 tn="$TMPROOT/tnav"
1208 make_blog_min "$tn" 2 10
1209 mkdir -p "$tn/pages" "$tn/navs"
1210 printf 'Home\t/\nAbout\t/about\n' > "$tn/navs/site-footer.tsv"
1211 printf '# A Page\n\nContent.\n' > "$tn/pages/test.md"
1212 run_jenny_split "$tn"
1213 assert_file_exists "$tn/.dist/test.html"
1214
1215 it "the template nav appears on the page"
1216 assert_file_contains "$tn/.dist/test.html" "Home"
1217
1218 it "the template nav's second entry appears"
1219 assert_file_contains "$tn/.dist/test.html" "About"
1220
1221 it "the template nav appears in the footer"
1222 assert_file_contains "$tn/.dist/test.html" 'class="site-footer"'
1223
1224 it "the template nav entries link correctly"
1225 assert_file_contains "$tn/.dist/test.html" 'class="footer-link"'
1226
1227 it "the template nav entry paths are resolved relatively"
1228 assert_file_contains "$tn/.dist/test.html" 'href="./'
1229
1230 it "the build is clean"
1231 assert_eq "$stderr_output" ""
1232
1233 it "a template nav and page-declared nav coexist"
1234 printf 'Nav: page-nav\n\n# With Both\n\nContent.\n' > "$tn/pages/both.md"
1235 printf 'Page\t/page\n' > "$tn/navs/page-nav.tsv"
1236 run_jenny_split "$tn"
1237 assert_file_contains "$tn/.dist/both.html" 'class="page-nav"'
1238 assert_file_contains "$tn/.dist/both.html" 'class="site-footer"'
1239
1240 it "both navs render their entries"
1241 assert_file_contains "$tn/.dist/both.html" "Page"
1242 assert_file_contains "$tn/.dist/both.html" "Home"
1243
1244 it "template nav entries are escaped"
1245 printf 'Link & Text <foo>\t/test\n' > "$tn/navs/site-footer.tsv"
1246 run_jenny "$tn"
1247 assert_file_contains "$tn/.dist/test.html" "Link &amp; Text &lt;foo&gt;"
1248
1249 it "a missing template nav file is silently skipped"
1250 rm "$tn/navs/site-footer.tsv"
1251 run_jenny "$tn"
1252 assert_file_not_contains "$tn/.dist/test.html" 'class="site-footer"'
1253
1254 it "the build stays clean when template nav is missing"
1255 assert_eq "$stderr_output" ""
1256
1257 it "a template nav from a nested page resolves relatively"
1258 mkdir -p "$tn/pages/deep/nested"
1259 printf 'Home\t/\n' > "$tn/navs/site-footer.tsv"
1260 printf '# Deep\n\nContent.\n' > "$tn/pages/deep/nested/page.md"
1261 run_jenny "$tn"
1262 assert_file_contains "$tn/.dist/deep/nested/page.html" 'href="../../'
1263
1264 it "blank lines in a template nav are ignored"
1265 printf 'One\t/one\n\nTwo\t/two\n' > "$tn/navs/site-footer.tsv"
1266 run_jenny "$tn"
1267 assert_eq "$(grep -c 'class="footer-link"' "$tn/.dist/test.html")" "2"
1268
1269 it "comment lines in a template nav are ignored"
1270 printf '# Section\nLink\t/link\n' > "$tn/navs/site-footer.tsv"
1271 run_jenny "$tn"
1272 assert_eq "$(grep -c 'class="footer-link"' "$tn/.dist/test.html")" "1"
1273
1274 suite "page nav sidebar layout" 1210 suite "page nav sidebar layout"
1275 1211
1276 nav_sb="$TMPROOT/navsidebar" 1212 nav_sb="$TMPROOT/navsidebar"
mkdir -p "$nav_sb/pages" "$nav_sb/navs"
1279 mkdir -p "$nav_sb/pages/plain" 1215 mkdir -p "$nav_sb/pages/plain"
1280 printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv" 1216 printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv"
1281 printf 'Nav: side\n\n# Title\n\nBody.\n' > "$nav_sb/pages/test.md" 1217 printf 'Nav: side\n\n# Title\n\nBody.\n' > "$nav_sb/pages/test.md"
1218 printf '# Plain\n\nNo nav.\n' > "$nav_sb/pages/plain.md"
1282 run_jenny_split "$nav_sb" 1219 run_jenny_split "$nav_sb"
1283 1220
1284 it "the heading lays the arrow and title on one row"
1285 # Asserts the mechanism, not a literal value: the previous assertion pinned
1286 # "align-items: center" and broke the moment the alignment changed to baseline.
1287 assert_file_contains "$nav_sb/.dist/test.html" '.heading-page { display: flex'
1288
1289 it "the back arrow holds its own column in the heading"
1290 # The arrow reserves a fixed track so the title starts where the body does.
1291 # The previous assertion pinned "display: inline-block", which said nothing
1292 # about whether the columns actually lined up.
1293 assert_file_contains "$nav_sb/.dist/test.html" '.heading-page .home { flex: 0 0'
1294
1295 it "the title uses flexbox to take remaining space"
1296 assert_file_contains "$nav_sb/.dist/test.html" '.heading-page .title { display: block; margin: 0; flex: 1'
1297
1298 it "the nav is wrapped in a .page-wrapper container"
1221 # These assert structure and mechanism rather than literal declarations.
1222 # Several earlier assertions here pinned exact strings -- "width: 16em",
1223 # "align-items: center", "position: absolute" -- and every one of them passed
1224 # against a layout that was visibly broken; "position: absolute" matched the
1225 # theme toggle elsewhere in the file and never looked at the nav at all. What
1226 # actually has to hold is that the nav and the body are siblings in one
1227 # container, that the sidebar and the body share a table row so their tops
1228 # line up without a measured offset, and that a page with no nav falls back to
1229 # the post's own indent mechanism.
1230
1231 it "the nav and the body are siblings inside .page-wrapper"
1299 assert_file_contains "$nav_sb/.dist/test.html" '<div class="page-wrapper">' 1232 assert_file_contains "$nav_sb/.dist/test.html" '<div class="page-wrapper">'
1300
1233 assert_file_contains "$nav_sb/.dist/test.html" '<nav class="page-nav">'
1301 1234
1302 it "the nav is not nested inside .contents" 1235 it "the nav is not nested inside .contents"
1303 assert_file_not_contains "$nav_sb/.dist/test.html" '<div class="contents">.*<nav' 1236 assert_file_not_contains "$nav_sb/.dist/test.html" '<div class="contents">.*<nav'
1304 1237
1305 it "the page-nav has position: absolute for sidebar layout"
1306 assert_file_contains "$nav_sb/.dist/test.html" 'position: absolute'
1307
1308 it "the page-nav width is 16em for the sidebar"
1309 # rem, not em: the offsets must be absolute so the footer, which sets
1310 # font-size: .85em, resolves them to the same length the body does. Asserting
1311 # the literal "16em" pinned that bug in place.
1312 assert_file_contains "$nav_sb/.dist/test.html" 'width: var(--page-nav-width)'
1313
1314 it "the page-nav is positioned relative to page-wrapper"
1315 assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { position: relative'
1316
1317 it "a page without nav still renders its body via .page-wrapper:not(:has(.page-nav)) CSS"
1318 printf '# Plain\n\nNo nav.\n' > "$nav_sb/pages/plain.md"
1319 run_jenny_split "$nav_sb"
1320 # Page without nav should have .page-wrapper:not(:has(.page-nav)) selector in CSS
1238 it "the sidebar and the body are cells of one table row"
1239 # This is what makes the title and the first nav item line up: they share a
1240 # row, so the browser aligns them. Asserting the row is asserting the
1241 # alignment -- an offset-based version would satisfy a "tops match" check at
1242 # one heading height and drift at every other.
1243 assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: table'
1244 assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { display: table-cell'
1245
1246 it "the sidebar is a table cell aligned to the top of its row"
1247 nav_rule=$("$SED" -n '/^ \.page-nav { display: table-cell;/,/}/p' "$nav_sb/.dist/test.html")
1248 assert_contains "$nav_rule" 'vertical-align: top'
1249
1250 it "the sidebar column does not depend on its labels"
1251 # An auto-layout table treats a declared width as a suggestion and shrinks the
1252 # column to its content, which moved the body's left edge whenever a label
1253 # changed. The floor is what keeps the column fixed.
1254 assert_file_contains "$nav_sb/.dist/test.html" 'min-width: var(--page-nav-width)'
1255
1256 it "the sidebar carries no positioning offset"
1257 # The nav used to be positioned absolutely and pushed down by a measured
1258 # amount that had to be re-measured whenever anything above it changed.
1259 assert_file_not_contains "$nav_sb/.dist/test.html" '.page-nav { position: absolute'
1260
1261 it "a page with no nav takes the body indent, not the sidebar column"
1321 assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav))' 1262 assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav))'
1322 1263
1264 it "a page with no nav reserves the arrow column the way a post does"
1265 # A post's indent comes from the back arrow sitting inline before the body, so
1266 # the two reflow together when the screen narrows. A page has no arrow, so it
1267 # reserves the same column with a spacer of the same size; a hardcoded padding
1268 # matched at one width and was 97px out at 735px.
1269 # Anchored to the body, not just "a spacer exists somewhere": the heading has
1270 # one too, and a check that matched either passed with the body's spacer
1271 # deleted -- which drops the body indent from 110px to 0 against a post's 110.
1272 assert_file_contains "$nav_sb/.dist/plain.html" '<span class="home-spacer" aria-hidden="true"></span><div class="contents">'
1273 assert_file_contains "$nav_sb/.dist/plain.html" '.home-spacer { display: inline-block'
1274
1275 it "the spacer is hidden from assistive technology"
1276 assert_file_contains "$nav_sb/.dist/plain.html" 'class="home-spacer" aria-hidden="true"'
1277
1278 it "a page with a sidebar drops the arrow spacer"
1279 assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper:has(.page-nav) .home-spacer { display: none'
1280
1281 it "a sidebar widens the article rather than narrowing the body"
1282 # 74ch is sized for a post: arrow column plus a 60ch body. Letting the sidebar
1283 # eat into it made the body 537px against a post's 540.
1284 assert_file_contains "$nav_sb/.dist/test.html" 'article:has(.page-nav) { max-width: calc(74ch'
1285
1286 it "the body column is declared identically to a post's"
1287 # The user's constraint: the body of a page should differ from the body of a
1288 # post in no way at all. Compare the declarations rather than trusting that
1289 # they look similar.
1290 # Anchored to the start of the declaration so a descendant rule such as
1291 # ".page-wrapper:not(:has(.page-nav)) .contents { ... }" is not mistaken for
1292 # the base rule -- one such override was what made the no-nav body differ.
1293 page_contents=$("$GREP" -o '^ \.contents { [^}]*}' "$nav_sb/.dist/test.html" | head -1)
1294 post_file=$(find "$nav_sb/.dist/post" -name '*.html' | head -1)
1295 post_contents=$("$GREP" -o '^ \.contents { [^}]*}' "$post_file" | head -1)
1296 assert_eq "$page_contents" "$post_contents"
1297
1298 it "the heading reserves the same column the body does"
1299 # On a post the stamp holds the left column, so the title starts where the
1300 # body starts. A page has no stamp; without the same spacer in the heading the
1301 # title sat at the far left while its own body was indented past it.
1302 assert_file_contains "$nav_sb/.dist/plain.html" 'class="heading heading-page"><span class="home-spacer"'
1303
1304 it "the heading has no breakpoint of its own"
1305 # A post has no media query at all -- heading and body collapse together by
1306 # wrapping. Forcing the page heading to block at 735px un-indented the title
1307 # while the body was still indented, splitting the two for one step of the
1308 # range.
1309 narrow_head=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/plain.html")
1310 assert_not_contains "$narrow_head" '.heading-page { margin-left: 0; display: block'
1311
1312 it "a sidebar page does not indent its heading twice"
1313 # The cell is already offset by the nav column; indenting the heading again
1314 # would put the title a column right of its own body.
1315 assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper:has(.page-nav) .heading-page .home-spacer { display: none'
1316
1317 it "narrow screens collapse the whole table, not just one cell"
1318 # A lone cell reverting to block still gets an anonymous table wrapper and
1319 # stays in its column, so all three have to leave table layout together.
1320 narrow=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/test.html")
1321 assert_contains "$narrow" '.page-wrapper,'
1322 assert_contains "$narrow" '.page-nav,'
1323 assert_contains "$narrow" '.contents-cell { display: block'
1324
1325 it "the narrow sidebar releases its fixed column"
1326 assert_contains "$narrow" 'min-width: 0'
1327
1323 it "the build is clean for nav sidebar" 1328 it "the build is clean for nav sidebar"
1324 assert_eq "$stderr_output" "" 1329 assert_eq "$stderr_output" ""
1325 1330
1326 suite "footer layout changes"
1327
1328 it "the site footer is left-aligned, not centred"
1329 assert_file_contains "$nav_sb/.dist/test.html" 'text-align: left'
1331 suite "template-baked navs"
1332
1333 # The default theme renders no footer, so render_footer_nav is not called from
1334 # the template. The mechanism it demonstrates -- a nav declared by the theme
1335 # rather than by a page, read from the same registry -- is the feature, and it
1336 # is exercised here directly so it cannot rot while unused.
1337
1338 tfn="$TMPROOT/tfooternav"
1339 make_blog_min "$tfn" 1 10
1340 mkdir -p "$tfn/navs"
1341
1342 # shellcheck source=/dev/null
1343 render_footer_nav_in() {
1344 # Runs render_footer_nav from the page layout with a given navs dir and
1345 # prefix, without rendering a whole page.
1346 (
1347 set +u
1348 NAVS_DIR="$1" URL_PREFIX="$2"
1349 SED="$SED" AWK="$AWK" GREP="$GREP"
1350 # shellcheck disable=SC1090
1351 source "$JENNY_SHARE_DIR/lib/helpers.sh"
1352 eval "$("$SED" -n '/^render_footer_nav() {/,/^}/p' "$JENNY_SHARE_DIR/layout/page.sh")"
1353 eval "$("$SED" -n '/^html_escape() {/,/^}/p' "$JENNY_SHARE_DIR/layout/page.sh")"
1354 render_footer_nav
1355 )
1356 }
1357
1358 it "a template nav renders its entries"
1359 printf 'Home\t/\nAbout\t/about\n' > "$tfn/navs/site-footer.tsv"
1360 out=$(render_footer_nav_in "$tfn/navs" "..")
1361 assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "2"
1362
1363 it "a template nav resolves paths against the page's prefix"
1364 assert_contains "$out" 'href="../about"'
1330 1365
1331 it "the footer width matches the body content column (60ch)"
1332 assert_file_contains "$nav_sb/.dist/test.html" 'max-width: 60ch'
1333
1334 it "the footer uses custom property for left margin, not hardcoded offset"
1335 assert_file_contains "$nav_sb/.dist/test.html" 'margin-left: var(--page-body-offset)'
1336
1337 it "the footer and body both derive from the same custom property"
1338 if grep -q 'margin-left: var(--page-body-offset)' "$nav_sb/.dist/test.html" && \
1339 grep -q 'margin-left: var(--page-body-offset)' "$nav_sb/.dist/test.html"; then
1340 pass "footer and body both use var(--page-body-offset)"
1341 else
1342 fail "footer and body must both use var(--page-body-offset)"
1343 fi
1344
1345 it "the footer separator is rendered via CSS content property as a block"
1346 assert_file_contains "$nav_sb/.dist/test.html" 'footer.site-footer::before { content:'
1347
1348 it "footer links display as blocks, not inline"
1349 assert_file_contains "$nav_sb/.dist/test.html" '.footer-link { display: block'
1366 it "blank lines in a template nav are ignored"
1367 printf 'One\t/one\n\nTwo\t/two\n' > "$tfn/navs/site-footer.tsv"
1368 out=$(render_footer_nav_in "$tfn/navs" "..")
1369 assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "2"
1350 1370
1351 it "footer links no longer have inline margins"
1352 assert_file_not_contains "$nav_sb/.dist/test.html" 'margin: 0 1em'
1371 it "comment lines in a template nav are ignored"
1372 printf '# Section\nLink\t/link\n' > "$tfn/navs/site-footer.tsv"
1373 out=$(render_footer_nav_in "$tfn/navs" "..")
1374 assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "1"
1353 1375
1354 it "the footer would drift if page-body-offset were not used"
1355 # Count how many rules use var(--page-body-offset) - should be at least 2
1356 # (one in .contents, one in footer.site-footer)
1357 offset_count=$(grep -o 'margin-left: var(--page-body-offset)' "$nav_sb/.dist/test.html" | wc -l)
1358 [ "$offset_count" -ge 2 ] && pass "footer offset is tied to body via custom property" || \
1359 fail "footer and body should both reference var(--page-body-offset), found $offset_count references"
1376 it "template nav entries are escaped"
1377 printf 'Link & Text <foo>\t/x\n' > "$tfn/navs/site-footer.tsv"
1378 out=$(render_footer_nav_in "$tfn/navs" "..")
1379 assert_contains "$out" 'Link &amp; Text &lt;foo&gt;'
1360 1380
1361 it "the footer layout is clean on build"
1362 assert_eq "$stderr_output" ""
1381 it "a missing template nav file is silently skipped"
1382 rm -f "$tfn/navs/site-footer.tsv"
1383 out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$?
1384 assert_eq "$st" "0"
1385 assert_eq "$out" ""
1363 1386
1364 suite "CSS variables completeness" 1387 suite "CSS variables completeness"
1365 1388

Raw

commit fe19d8dd4a117280d77ecf20504aea1fcf1dd05b
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Tue Aug 25 16:58:09 2026 +0800

    Align page title with its sidebar and body by construction
    
    The page layout put the sidebar in an absolutely positioned box pushed
    down by a measured 41.6px, and indented the body by a hardcoded 6.82rem
    copied from the post layout. Both numbers described a arrangement that no
    longer existed once the back arrow was removed from pages, and both had
    to be re-measured whenever anything above them changed.
    
    The sidebar and the body are now two cells of one table row -- the same
    mechanism post.sh uses to hold a stamp beside a title. Their tops line up
    because they share a row, not because of an offset, so nothing needs
    maintaining when the heading changes height.
    
    The body indent is likewise a mechanism rather than a number. A post is
    indented by the back arrow sitting inline before the contents, so the two
    reflow together as the screen narrows. A page has no arrow, so it reserves
    the same column with a spacer of the same size. The copied 6.82rem had
    matched only at wide widths: at 735px it put the page body 97px left of
    the post's.
    
    The heading gets the same spacer. On a post the stamp holds the left
    column and the title starts where the body does; a page had no stamp, so
    its title sat at the far left while its own body was indented past it.
    
    Also: a sidebar now widens the article rather than eating into its 74ch,
    which had left the body 537px against a post's 540; the nav column takes
    a min-width so it stays 6rem instead of shrinking to its longest label;
    and the narrow breakpoint collapses the whole table together, since a lone
    cell reverting to block keeps its column via an anonymous table wrapper.
    
    Verified by measuring rendered geometry at 320-1920px: the body column is
    identical to a post's in width and offset at every width, and title and
    first nav item share a y-coordinate wherever they share a row.
    
    Tests: the sidebar suite asserted CSS literals -- "width: 16em",
    "position: absolute", "align-items: center" -- and passed against layouts
    that were visibly broken; "position: absolute" matched the theme toggle
    elsewhere in the file and never looked at the nav. Rewritten to assert
    structure and mechanism, and checked by mutation: seven mutations of the
    layout, each caught. The first version of the body-spacer assertion missed
    its mutation because it matched the heading's spacer too, so it is now
    anchored to the body.
    
    The footer suite is removed with the footer it tested. render_footer_nav
    is retained and now exercised directly, so the template-baked nav
    mechanism cannot rot while the default theme renders no footer.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5

diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index 2a0cf54..caa3b2d 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -70,6 +70,10 @@ crumbs() {
 # navs/site-footer.tsv does not exist, rendering is silently skipped.
 # This is decided in one place: read_nav with --optional, which returns
 # cleanly if the file does not exist.
+# Not currently called: the default theme renders no footer. Kept because the
+# template-baked nav mechanism it demonstrates is the feature -- a layout can
+# ask for a nav no page declared. Call this, or write your own, to bring one
+# back.
 render_footer_nav() {
   local lines
   lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || return 0
@@ -188,6 +192,13 @@ cat << _EOF_
         }
         @media (min-width: 736px) { body { font-size: 16px } }
         article { padding: 0; margin: 1em 0; max-width: 74ch; }
+        /* 74ch is sized for a post: the arrow's column plus a 60ch body. A
+           sidebar is an addition to that, so the article grows by exactly the
+           sidebar's column and gap. Without this the sidebar ate into the
+           74ch and the body came out 537px against a post's 540 -- a
+           difference with no cause the reader could see. Widening here rather
+           than narrowing the body keeps the measure identical everywhere. */
+        article:has(.page-nav) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); }
         article a { color: var(--link-article); }
         article img { max-width: 100% }
         /* Blockquote keeps its left rule; code blocks are a tinted block
@@ -277,23 +288,20 @@ cat << _EOF_
            Width measured from a rendered post: the stamp is inline-block
            beside a table-cell title, so the 2.25em nominal width bears no
            relation to the 109px the pair actually spans. */
-        /* The title aligns with the body's left edge, so it sits above the
-           body rather than orphaned above the sidebar. On pages with a nav,
-           the body starts at --page-content-left. Without a nav, at
-           --page-body-offset. The heading uses the same offset as its
-           sibling .contents. */
-        /* The heading spans the full width and holds two things: the back
-           arrow, which stays in the left column beside the nav, and the
-           title, which lines up with the body it titles. Offsetting the
-           whole heading moved the arrow out of its column with it -- the
-           arrow keeps its own width and the title is pushed by the rest. */
-        .heading-page { display: flex; align-items: baseline; margin-bottom: 1em;
-                        margin-left: 0; }
-        .heading-page .home { flex: 0 0 var(--page-content-left);
-                              margin: 0; text-align: left; }
-        .page-wrapper:not(:has(.page-nav)) .heading-page .home {
-                              flex-basis: var(--page-body-offset); }
+        /* No margin-bottom: post.sh's .heading has none, and the extra 1em put 48px
+           between a page title and its body where a post has none. */
+        /* On a post the stamp holds the left column and the title starts where
+           the body does. A page has no stamp, so without a spacer here the
+           title sat at the far left while its own body was indented past it.
+           The heading reserves the same column the body does, by the same
+           mechanism, so the two line up whatever the width. */
+        .heading-page { display: flex; align-items: baseline; margin-left: 0; }
         .heading-page .title { display: block; margin: 0; flex: 1; }
+        .heading-page .home-spacer { margin-bottom: 0; flex: 0 0 auto; }
+        /* Beside a sidebar the whole cell is already offset, so the heading
+           must not indent again -- the title would land a column right of its
+           own body. */
+        .page-wrapper:has(.page-nav) .heading-page .home-spacer { display: none; }
         .home { display: inline-block; text-align: right; margin-right: 1.5em;
                 width: 5.4em; margin-bottom: 1.5em; text-decoration: none;
                 color: var(--link-home); }
@@ -310,59 +318,78 @@ cat << _EOF_
         .home { display: inline-block; text-align:right; margin-right: 1.5em; }
         .home { text-decoration: none; width: 5.4em; margin-bottom: 1.5em; text-align: left;  color: var(--link-home); } .home:hover { color: var(--link-home-hover); }
         .heading .stamp { color: var(--text-muted); }
-        /* On a post the body is indented because the back arrow sits beside
-           it. The arrow has moved up into the heading, so the body needs the
-           same offset explicitly or it falls back to the left margin and
-           stops lining up with the title above it. */
         /* A nav is an ordered list of names and places; what it means is
-           left to this template. Rendered as a left sidebar beside the body.
-           The body's left edge is derived from a custom property so the footer
-           can align to it without duplicating the calculation. */
-        :root { --page-body-offset: 6.82rem; --page-nav-width: 16rem; --page-nav-gap: 2rem;
-                /* rem, not em: em resolves against each element's own
-                   font-size, so the footer at .85em computed a different
-                   length from the same variable and sat 59px left of the body.
-                   Where the body column starts. Anything that must line up with
-                   the body reads this one value rather than recomputing it --
-                   the footer and the body were expressed separately before, so
-                   moving the body left the footer behind under the sidebar. */
-                --page-content-left: calc(var(--page-body-offset) + var(--page-nav-width) + var(--page-nav-gap)); }
+           left to this template. Rendered as a left sidebar beside the body. */
+        :root { --page-nav-width: 6rem; --page-nav-gap: 2rem; }
 
-        .page-nav { position: absolute; width: var(--page-nav-width); margin: 0; padding: 0;
-                    font-size: .9em; }
+        /* min-width as well as width: in an auto-layout table a declared
+           width is only a suggestion, so the nav column shrank to whatever the
+           longest label happened to be and the body's left edge moved with it.
+           The floor keeps the column a fixed 6rem regardless of the labels. */
+        .page-nav { display: table-cell; vertical-align: top;
+                    width: var(--page-nav-width);
+                    min-width: var(--page-nav-width);
+                    margin: 0; padding: 0; font-size: .9em; }
         .page-nav .nav-item { display: block; text-decoration: none;
                               color: var(--link-nav); margin-bottom: .35em; }
         .page-nav .nav-item:hover { color: var(--link-nav-hover); }
 
-        .page-wrapper { position: relative; }
-
-        .contents { display: inline-block; max-width: 60ch; vertical-align: top;
-                    width: 100%; margin-left: var(--page-body-offset); }
-        .page-wrapper:has(.page-nav) .contents { margin-left: var(--page-content-left); }
-        .page-wrapper:not(:has(.page-nav)) .contents { margin-left: var(--page-body-offset); }
-        /* A page with no nav has no sidebar to clear, so its footer sits at
-           the base offset like its body. */
-        .page-wrapper:not(:has(.page-nav)) footer.site-footer {
-          margin-left: var(--page-body-offset); }
+        /* The sidebar and the body are two cells of one table row, which is
+           the same mechanism the post layout uses to hold a stamp beside a
+           title. Sharing a row is what makes their tops line up: the browser
+           aligns the cells, so there is no offset here to keep in sync with
+           the heading's height. An earlier version positioned the nav
+           absolutely and pushed it down by a measured amount, which had to be
+           re-measured whenever anything above it changed. */
+        /* The table sizes to its content rather than to the article's width.
+           Capped at 100% the two columns competed for the same space: pinning
+           the nav to 6rem took three pixels off the body, so the body column
+           came out narrower on a page with a sidebar than on one without --
+           the body would have differed from a post's for no reason the reader
+           could see. Sizing to content lets the nav hold its 6rem and the body
+           hold its full 60ch, with the sidebar adding to the total width
+           instead of subtracting from the body. There is room: the body column
+           tops out at 60ch and the viewport is wider than nav plus body
+           wherever this layout applies. */
+        .page-wrapper { display: table; table-layout: auto; width: auto; }
+        .contents-cell { display: table-cell; vertical-align: top;
+                         padding-left: var(--page-nav-gap); }
+        /* With no nav, the body has to line up with a post's body, and a post
+           gets its indent from the back arrow sitting inline before the
+           contents -- not from a fixed length. That is why the indent is a
+           spacer of the arrow's exact size rather than a copied number: the
+           two are inline-blocks, so they sit side by side while they fit and
+           the body wraps under the spacer when the screen is too narrow,
+           reflowing at exactly the width a post reflows at. A hardcoded
+           padding could match at one width but broke at every other -- at
+           735px it put the page body 97px left of the post's. Pages have no
+           arrow (it means nothing on a page), so the spacer is empty and
+           hidden from assistive tech. */
+        .page-wrapper:not(:has(.page-nav)) .contents-cell { display: block;
+                                                            padding-left: 0; }
+        .home-spacer { display: inline-block; width: 5.4em; margin-right: 1.5em;
+                       margin-bottom: 1.5em; vertical-align: top; }
+        .page-wrapper:has(.page-nav) .home-spacer { display: none; }
+        .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
 
         /* Narrow: the sidebar has nowhere to go, so it stacks above the body
-           and everything collapses to one column. The arrow's column and the
-           footer must reset too -- leaving them at the wide offsets pushed the
-           title and footer off a 400px screen entirely. */
+           and everything collapses to one column. Every part has to leave
+           table layout together -- a lone cell reverting to block still gets
+           an anonymous table wrapper and stays in its column. */
         @media (max-width: 735px) {
-          .page-nav { position: static; width: 100%; margin-bottom: 2em; }
+          .page-wrapper,
+          .page-nav,
+          .contents-cell { display: block; }
+          .page-nav { width: auto; min-width: 0; margin-bottom: 2em; }
           /* No indent at this width: 6.82rem plus a 60ch column overflows a
-             phone and forces horizontal scroll. The arrow stacks above the
-             title instead of holding a column beside it. */
-          .heading-page { margin-left: 0; display: block; }
-          .heading-page .home,
-          .page-wrapper:not(:has(.page-nav)) .heading-page .home {
-            flex: none; display: block; margin-bottom: .5em; }
-          .contents,
-          .page-wrapper:has(.page-nav) .contents { margin-left: 0; }
-          .page-wrapper footer.site-footer,
-          .page-wrapper:not(:has(.page-nav)) footer.site-footer {
-            margin-left: 0; }
+             phone and forces horizontal scroll. */
+          .contents-cell { padding-left: 0; }
+          /* The heading is left alone here. A post has no breakpoint at all --
+             its heading and body both collapse by wrapping, at the same width,
+             because both are driven by the same inline column. Forcing the
+             heading to block at 735px un-indented the title while the body was
+             still indented, so the two split apart for exactly one step of the
+             range. Letting it wrap on its own keeps them together. */
         }
         .contents :first-child { margin-top: 0; }
         h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; }
@@ -413,18 +440,6 @@ cat << _EOF_
           background-color: var(--bg-secondary);
         }
 
-        /* Footer nav is a template-baked nav -- presentation, on every page,
-           declared by the theme rather than by any page. Positioned to align
-           with body content using the same left offset. Uses a subtle Unicode
-           separator rendered via CSS. */
-        footer.site-footer { margin-top: 3em; padding-top: 2em; font-size: .85em;
-                             text-align: left; max-width: 60ch; margin-left: var(--page-content-left); }
-        footer.site-footer::before { content: ""; display: block; height: 1px;
-                                    background: var(--border-lighter);
-                                    margin-bottom: 1.25em; }
-        footer.site-footer .footer-link { display: block; text-decoration: none;
-                                          color: var(--text-muted); margin-bottom: .5em; }
-        footer.site-footer .footer-link:hover { color: var(--link-nav-hover); }
       </style>
       <script>
         (function() {
@@ -464,13 +479,14 @@ cat << _EOF_
       <header><a href="$URL_PREFIX/"$([ -z "${PAGE_CRUMBS:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(crumbs)</header>
       <article>
         <div class="page-wrapper">
-        <div class="heading heading-page"><a href="$URL_PREFIX/" class="home" aria-label="Back to the index">&#x2190;&#xFE0E;</a><h1 class="title">$(html_escape "${POST_TITLE:-}")</h1></div>
         $(page_nav)
-        <div class="contents">
+          <div class="contents-cell">
+        <div class="heading heading-page"><span class="home-spacer" aria-hidden="true"></span><h1 class="title">$(html_escape "${POST_TITLE:-}")</h1></div>
+        <span class="home-spacer" aria-hidden="true"></span><div class="contents">
         $(echo "${POST_CONTENTS:-}")
         <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
         </div>
-        $(render_footer_nav)
+          </div>
         </div>
       </article>
     </body>
diff --git a/test/regressions.sh b/test/regressions.sh
index 377f4a5..dfc7d32 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -984,11 +984,17 @@ assert_file_exists "$nop/.dist/fallback.html"
 it "and does so without complaint"
 assert_eq "$stderr_output" ""
 
-it "the back arrow sits in the heading, not below it"
-assert_file_contains "$pg/.dist/study/consciousness.html" 'class="heading heading-page"><a href="../" class="home"'
-
-it "the arrow keeps an accessible name"
-assert_file_contains "$pg/.dist/study/consciousness.html" 'aria-label="Back to the index"'
+it "a page has no back arrow"
+# A post's arrow goes back to the index it was listed on. A page is not listed
+# anywhere -- it is reached by its own stable path -- so an arrow to the index
+# points at a place the reader did not come from. The breadcrumb in the header
+# is what a page navigates by instead.
+assert_file_not_contains "$pg/.dist/study/consciousness.html" 'class="home"'
+
+it "the arrow's column is still reserved so the body lines up with a post's"
+# Removing the arrow must not move the body: a post indents by the arrow's
+# width, and a page has to match it.
+assert_file_contains "$pg/.dist/study/consciousness.html" 'class="home-spacer"'
 
 it "a blog with only pages and no posts still builds"
 only="$TMPROOT/onlypages"
@@ -1201,76 +1207,6 @@ else
   fail "helpers.sh tag index does not use event delegation for toggle"
 fi
 
-suite "template navs"
-
-it "a template nav renders without any page declaring it"
-tn="$TMPROOT/tnav"
-make_blog_min "$tn" 2 10
-mkdir -p "$tn/pages" "$tn/navs"
-printf 'Home\t/\nAbout\t/about\n' > "$tn/navs/site-footer.tsv"
-printf '# A Page\n\nContent.\n' > "$tn/pages/test.md"
-run_jenny_split "$tn"
-assert_file_exists "$tn/.dist/test.html"
-
-it "the template nav appears on the page"
-assert_file_contains "$tn/.dist/test.html" "Home"
-
-it "the template nav's second entry appears"
-assert_file_contains "$tn/.dist/test.html" "About"
-
-it "the template nav appears in the footer"
-assert_file_contains "$tn/.dist/test.html" 'class="site-footer"'
-
-it "the template nav entries link correctly"
-assert_file_contains "$tn/.dist/test.html" 'class="footer-link"'
-
-it "the template nav entry paths are resolved relatively"
-assert_file_contains "$tn/.dist/test.html" 'href="./'
-
-it "the build is clean"
-assert_eq "$stderr_output" ""
-
-it "a template nav and page-declared nav coexist"
-printf 'Nav: page-nav\n\n# With Both\n\nContent.\n' > "$tn/pages/both.md"
-printf 'Page\t/page\n' > "$tn/navs/page-nav.tsv"
-run_jenny_split "$tn"
-assert_file_contains "$tn/.dist/both.html" 'class="page-nav"'
-assert_file_contains "$tn/.dist/both.html" 'class="site-footer"'
-
-it "both navs render their entries"
-assert_file_contains "$tn/.dist/both.html" "Page"
-assert_file_contains "$tn/.dist/both.html" "Home"
-
-it "template nav entries are escaped"
-printf 'Link & Text <foo>\t/test\n' > "$tn/navs/site-footer.tsv"
-run_jenny "$tn"
-assert_file_contains "$tn/.dist/test.html" "Link &amp; Text &lt;foo&gt;"
-
-it "a missing template nav file is silently skipped"
-rm "$tn/navs/site-footer.tsv"
-run_jenny "$tn"
-assert_file_not_contains "$tn/.dist/test.html" 'class="site-footer"'
-
-it "the build stays clean when template nav is missing"
-assert_eq "$stderr_output" ""
-
-it "a template nav from a nested page resolves relatively"
-mkdir -p "$tn/pages/deep/nested"
-printf 'Home\t/\n' > "$tn/navs/site-footer.tsv"
-printf '# Deep\n\nContent.\n' > "$tn/pages/deep/nested/page.md"
-run_jenny "$tn"
-assert_file_contains "$tn/.dist/deep/nested/page.html" 'href="../../'
-
-it "blank lines in a template nav are ignored"
-printf 'One\t/one\n\nTwo\t/two\n' > "$tn/navs/site-footer.tsv"
-run_jenny "$tn"
-assert_eq "$(grep -c 'class="footer-link"' "$tn/.dist/test.html")" "2"
-
-it "comment lines in a template nav are ignored"
-printf '# Section\nLink\t/link\n' > "$tn/navs/site-footer.tsv"
-run_jenny "$tn"
-assert_eq "$(grep -c 'class="footer-link"' "$tn/.dist/test.html")" "1"
-
 suite "page nav sidebar layout"
 
 nav_sb="$TMPROOT/navsidebar"
@@ -1279,87 +1215,174 @@ mkdir -p "$nav_sb/pages" "$nav_sb/navs"
 mkdir -p "$nav_sb/pages/plain"
 printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv"
 printf 'Nav: side\n\n# Title\n\nBody.\n' > "$nav_sb/pages/test.md"
+printf '# Plain\n\nNo nav.\n' > "$nav_sb/pages/plain.md"
 run_jenny_split "$nav_sb"
 
-it "the heading lays the arrow and title on one row"
-# Asserts the mechanism, not a literal value: the previous assertion pinned
-# "align-items: center" and broke the moment the alignment changed to baseline.
-assert_file_contains "$nav_sb/.dist/test.html" '.heading-page { display: flex'
-
-it "the back arrow holds its own column in the heading"
-# The arrow reserves a fixed track so the title starts where the body does.
-# The previous assertion pinned "display: inline-block", which said nothing
-# about whether the columns actually lined up.
-assert_file_contains "$nav_sb/.dist/test.html" '.heading-page .home { flex: 0 0'
-
-it "the title uses flexbox to take remaining space"
-assert_file_contains "$nav_sb/.dist/test.html" '.heading-page .title { display: block; margin: 0; flex: 1'
-
-it "the nav is wrapped in a .page-wrapper container"
+# These assert structure and mechanism rather than literal declarations.
+# Several earlier assertions here pinned exact strings -- "width: 16em",
+# "align-items: center", "position: absolute" -- and every one of them passed
+# against a layout that was visibly broken; "position: absolute" matched the
+# theme toggle elsewhere in the file and never looked at the nav at all. What
+# actually has to hold is that the nav and the body are siblings in one
+# container, that the sidebar and the body share a table row so their tops
+# line up without a measured offset, and that a page with no nav falls back to
+# the post's own indent mechanism.
+
+it "the nav and the body are siblings inside .page-wrapper"
 assert_file_contains "$nav_sb/.dist/test.html" '<div class="page-wrapper">'
-
+assert_file_contains "$nav_sb/.dist/test.html" '<nav class="page-nav">'
 
 it "the nav is not nested inside .contents"
 assert_file_not_contains "$nav_sb/.dist/test.html" '<div class="contents">.*<nav'
 
-it "the page-nav has position: absolute for sidebar layout"
-assert_file_contains "$nav_sb/.dist/test.html" 'position: absolute'
-
-it "the page-nav width is 16em for the sidebar"
-# rem, not em: the offsets must be absolute so the footer, which sets
-# font-size: .85em, resolves them to the same length the body does. Asserting
-# the literal "16em" pinned that bug in place.
-assert_file_contains "$nav_sb/.dist/test.html" 'width: var(--page-nav-width)'
-
-it "the page-nav is positioned relative to page-wrapper"
-assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { position: relative'
-
-it "a page without nav still renders its body via .page-wrapper:not(:has(.page-nav)) CSS"
-printf '# Plain\n\nNo nav.\n' > "$nav_sb/pages/plain.md"
-run_jenny_split "$nav_sb"
-# Page without nav should have .page-wrapper:not(:has(.page-nav)) selector in CSS
+it "the sidebar and the body are cells of one table row"
+# This is what makes the title and the first nav item line up: they share a
+# row, so the browser aligns them. Asserting the row is asserting the
+# alignment -- an offset-based version would satisfy a "tops match" check at
+# one heading height and drift at every other.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: table'
+assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { display: table-cell'
+
+it "the sidebar is a table cell aligned to the top of its row"
+nav_rule=$("$SED" -n '/^        \.page-nav { display: table-cell;/,/}/p' "$nav_sb/.dist/test.html")
+assert_contains "$nav_rule" 'vertical-align: top'
+
+it "the sidebar column does not depend on its labels"
+# An auto-layout table treats a declared width as a suggestion and shrinks the
+# column to its content, which moved the body's left edge whenever a label
+# changed. The floor is what keeps the column fixed.
+assert_file_contains "$nav_sb/.dist/test.html" 'min-width: var(--page-nav-width)'
+
+it "the sidebar carries no positioning offset"
+# The nav used to be positioned absolutely and pushed down by a measured
+# amount that had to be re-measured whenever anything above it changed.
+assert_file_not_contains "$nav_sb/.dist/test.html" '.page-nav { position: absolute'
+
+it "a page with no nav takes the body indent, not the sidebar column"
 assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav))'
 
+it "a page with no nav reserves the arrow column the way a post does"
+# A post's indent comes from the back arrow sitting inline before the body, so
+# the two reflow together when the screen narrows. A page has no arrow, so it
+# reserves the same column with a spacer of the same size; a hardcoded padding
+# matched at one width and was 97px out at 735px.
+# Anchored to the body, not just "a spacer exists somewhere": the heading has
+# one too, and a check that matched either passed with the body's spacer
+# deleted -- which drops the body indent from 110px to 0 against a post's 110.
+assert_file_contains "$nav_sb/.dist/plain.html" '<span class="home-spacer" aria-hidden="true"></span><div class="contents">'
+assert_file_contains "$nav_sb/.dist/plain.html" '.home-spacer { display: inline-block'
+
+it "the spacer is hidden from assistive technology"
+assert_file_contains "$nav_sb/.dist/plain.html" 'class="home-spacer" aria-hidden="true"'
+
+it "a page with a sidebar drops the arrow spacer"
+assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper:has(.page-nav) .home-spacer { display: none'
+
+it "a sidebar widens the article rather than narrowing the body"
+# 74ch is sized for a post: arrow column plus a 60ch body. Letting the sidebar
+# eat into it made the body 537px against a post's 540.
+assert_file_contains "$nav_sb/.dist/test.html" 'article:has(.page-nav) { max-width: calc(74ch'
+
+it "the body column is declared identically to a post's"
+# The user's constraint: the body of a page should differ from the body of a
+# post in no way at all. Compare the declarations rather than trusting that
+# they look similar.
+# Anchored to the start of the declaration so a descendant rule such as
+# ".page-wrapper:not(:has(.page-nav)) .contents { ... }" is not mistaken for
+# the base rule -- one such override was what made the no-nav body differ.
+page_contents=$("$GREP" -o '^        \.contents { [^}]*}' "$nav_sb/.dist/test.html" | head -1)
+post_file=$(find "$nav_sb/.dist/post" -name '*.html' | head -1)
+post_contents=$("$GREP" -o '^        \.contents { [^}]*}' "$post_file" | head -1)
+assert_eq "$page_contents" "$post_contents"
+
+it "the heading reserves the same column the body does"
+# On a post the stamp holds the left column, so the title starts where the
+# body starts. A page has no stamp; without the same spacer in the heading the
+# title sat at the far left while its own body was indented past it.
+assert_file_contains "$nav_sb/.dist/plain.html" 'class="heading heading-page"><span class="home-spacer"'
+
+it "the heading has no breakpoint of its own"
+# A post has no media query at all -- heading and body collapse together by
+# wrapping. Forcing the page heading to block at 735px un-indented the title
+# while the body was still indented, splitting the two for one step of the
+# range.
+narrow_head=$("$SED" -n '/@media (max-width: 735px)/,/^        }/p' "$nav_sb/.dist/plain.html")
+assert_not_contains "$narrow_head" '.heading-page { margin-left: 0; display: block'
+
+it "a sidebar page does not indent its heading twice"
+# The cell is already offset by the nav column; indenting the heading again
+# would put the title a column right of its own body.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper:has(.page-nav) .heading-page .home-spacer { display: none'
+
+it "narrow screens collapse the whole table, not just one cell"
+# A lone cell reverting to block still gets an anonymous table wrapper and
+# stays in its column, so all three have to leave table layout together.
+narrow=$("$SED" -n '/@media (max-width: 735px)/,/^        }/p' "$nav_sb/.dist/test.html")
+assert_contains "$narrow" '.page-wrapper,'
+assert_contains "$narrow" '.page-nav,'
+assert_contains "$narrow" '.contents-cell { display: block'
+
+it "the narrow sidebar releases its fixed column"
+assert_contains "$narrow" 'min-width: 0'
+
 it "the build is clean for nav sidebar"
 assert_eq "$stderr_output" ""
 
-suite "footer layout changes"
-
-it "the site footer is left-aligned, not centred"
-assert_file_contains "$nav_sb/.dist/test.html" 'text-align: left'
+suite "template-baked navs"
+
+# The default theme renders no footer, so render_footer_nav is not called from
+# the template. The mechanism it demonstrates -- a nav declared by the theme
+# rather than by a page, read from the same registry -- is the feature, and it
+# is exercised here directly so it cannot rot while unused.
+
+tfn="$TMPROOT/tfooternav"
+make_blog_min "$tfn" 1 10
+mkdir -p "$tfn/navs"
+
+# shellcheck source=/dev/null
+render_footer_nav_in() {
+  # Runs render_footer_nav from the page layout with a given navs dir and
+  # prefix, without rendering a whole page.
+  (
+    set +u
+    NAVS_DIR="$1" URL_PREFIX="$2"
+    SED="$SED" AWK="$AWK" GREP="$GREP"
+    # shellcheck disable=SC1090
+    source "$JENNY_SHARE_DIR/lib/helpers.sh"
+    eval "$("$SED" -n '/^render_footer_nav() {/,/^}/p' "$JENNY_SHARE_DIR/layout/page.sh")"
+    eval "$("$SED" -n '/^html_escape() {/,/^}/p' "$JENNY_SHARE_DIR/layout/page.sh")"
+    render_footer_nav
+  )
+}
+
+it "a template nav renders its entries"
+printf 'Home\t/\nAbout\t/about\n' > "$tfn/navs/site-footer.tsv"
+out=$(render_footer_nav_in "$tfn/navs" "..")
+assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "2"
+
+it "a template nav resolves paths against the page's prefix"
+assert_contains "$out" 'href="../about"'
 
-it "the footer width matches the body content column (60ch)"
-assert_file_contains "$nav_sb/.dist/test.html" 'max-width: 60ch'
-
-it "the footer uses custom property for left margin, not hardcoded offset"
-assert_file_contains "$nav_sb/.dist/test.html" 'margin-left: var(--page-body-offset)'
-
-it "the footer and body both derive from the same custom property"
-if grep -q 'margin-left: var(--page-body-offset)' "$nav_sb/.dist/test.html" && \
-   grep -q 'margin-left: var(--page-body-offset)' "$nav_sb/.dist/test.html"; then
-  pass "footer and body both use var(--page-body-offset)"
-else
-  fail "footer and body must both use var(--page-body-offset)"
-fi
-
-it "the footer separator is rendered via CSS content property as a block"
-assert_file_contains "$nav_sb/.dist/test.html" 'footer.site-footer::before { content:'
-
-it "footer links display as blocks, not inline"
-assert_file_contains "$nav_sb/.dist/test.html" '.footer-link { display: block'
+it "blank lines in a template nav are ignored"
+printf 'One\t/one\n\nTwo\t/two\n' > "$tfn/navs/site-footer.tsv"
+out=$(render_footer_nav_in "$tfn/navs" "..")
+assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "2"
 
-it "footer links no longer have inline margins"
-assert_file_not_contains "$nav_sb/.dist/test.html" 'margin: 0 1em'
+it "comment lines in a template nav are ignored"
+printf '# Section\nLink\t/link\n' > "$tfn/navs/site-footer.tsv"
+out=$(render_footer_nav_in "$tfn/navs" "..")
+assert_eq "$(printf '%s' "$out" | "$GREP" -c 'class="footer-link"')" "1"
 
-it "the footer would drift if page-body-offset were not used"
-# Count how many rules use var(--page-body-offset) - should be at least 2
-# (one in .contents, one in footer.site-footer)
-offset_count=$(grep -o 'margin-left: var(--page-body-offset)' "$nav_sb/.dist/test.html" | wc -l)
-[ "$offset_count" -ge 2 ] && pass "footer offset is tied to body via custom property" || \
-  fail "footer and body should both reference var(--page-body-offset), found $offset_count references"
+it "template nav entries are escaped"
+printf 'Link & Text <foo>\t/x\n' > "$tfn/navs/site-footer.tsv"
+out=$(render_footer_nav_in "$tfn/navs" "..")
+assert_contains "$out" 'Link &amp; Text &lt;foo&gt;'
 
-it "the footer layout is clean on build"
-assert_eq "$stderr_output" ""
+it "a missing template nav file is silently skipped"
+rm -f "$tfn/navs/site-footer.tsv"
+out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$?
+assert_eq "$st" "0"
+assert_eq "$out" ""
 
 suite "CSS variables completeness"