Commit fe19d8d h
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">←︎</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 & Text <foo>" |
||
| 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 & Text <foo>' |
||
| 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">←︎</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 & Text <foo>"
-
-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 & Text <foo>'
-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"