Commit f1c6bca h
Changes: 2 files +108 -10
Changes (Side-by-Side)
share/jenny/layout/page.sh +27 -10
page_nav() { | |||
| 42 | printf '</nav>\n' |
42 | printf '</nav>\n' |
| 43 | } |
43 | } |
| 44 | |
44 | |
| 45 | page_has_nav() { |
||
| 46 | [ -n "${PAGE_NAV:-}" ] |
||
| 47 | } |
||
| 48 | |
||
| 45 | crumbs() { |
49 | crumbs() { |
| 46 | [ -n "${PAGE_CRUMBS:-}" ] || return 0 |
50 | [ -n "${PAGE_CRUMBS:-}" ] || return 0 |
| 47 | local total seen href label |
51 | local total seen href label |
cat << _EOF_ | |||
| 300 | same offset explicitly or it falls back to the left margin and |
304 | same offset explicitly or it falls back to the left margin and |
| 301 | stops lining up with the title above it. */ |
305 | stops lining up with the title above it. */ |
| 302 | /* A nav is an ordered list of names and places; what it means is |
306 | /* A nav is an ordered list of names and places; what it means is |
| 303 | left to this template. Rendered as a simple stack above the body. */ |
||
| 304 | .page-nav { margin: 0 0 2.5em; padding-bottom: 1.5em; |
||
| 305 | border-bottom: 2px solid var(--border-lighter); font-size: .9em; } |
||
| 307 | left to this template. Rendered as a left sidebar beside the body. */ |
||
| 308 | .page-nav { position: absolute; width: 16em; margin: 0; padding: 0; |
||
| 309 | font-size: .9em; } |
||
| 306 | .page-nav .nav-item { display: block; text-decoration: none; |
310 | .page-nav .nav-item { display: block; text-decoration: none; |
| 307 | color: var(--link-nav); margin-bottom: .35em; } |
311 | color: var(--link-nav); margin-bottom: .35em; } |
| 308 | .page-nav .nav-item:hover { color: var(--link-nav-hover); } |
312 | .page-nav .nav-item:hover { color: var(--link-nav-hover); } |
| 309 | |
313 | |
| 314 | .page-wrapper { position: relative; } |
||
| 315 | |
||
| 310 | .contents { display: inline-block; max-width: 60ch; vertical-align: top; |
316 | .contents { display: inline-block; max-width: 60ch; vertical-align: top; |
| 311 | width: 100%; margin-left: 6.82em; } |
317 | width: 100%; margin-left: 6.82em; } |
| 318 | .page-wrapper:has(.page-nav) .contents { margin-left: calc(6.82em + 16em + 2em); } |
||
| 319 | .page-wrapper:not(:has(.page-nav)) .contents { margin-left: 6.82em; } |
||
| 320 | |
||
| 321 | @media (max-width: 735px) { |
||
| 322 | .page-nav { position: static; width: 100%; margin-bottom: 2em; } |
||
| 323 | .contents { margin-left: 6.82em; } |
||
| 324 | .page-wrapper:has(.page-nav) .contents { margin-left: 6.82em; } |
||
| 325 | } |
||
| 312 | .contents :first-child { margin-top: 0; } |
326 | .contents :first-child { margin-top: 0; } |
| 313 | h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; } |
327 | h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; } |
| 314 | ol, ul { padding-left: 1em; } |
328 | ol, ul { padding-left: 1em; } |
cat << _EOF_ | |||
| 361 | /* Footer nav is a template-baked nav -- presentation, on every page, |
375 | /* Footer nav is a template-baked nav -- presentation, on every page, |
| 362 | declared by the theme rather than by any page. Uses the theme |
376 | declared by the theme rather than by any page. Uses the theme |
| 363 | variables so it follows dark mode like everything else; it was |
377 | variables so it follows dark mode like everything else; it was |
| 364 | written against the light palette before dark mode landed. */ |
||
| 378 | written against the light palette before dark mode landed. |
||
| 379 | Now left-aligned, body-width, with an ASCII separator. */ |
||
| 365 | footer.site-footer { margin-top: 3em; padding-top: 2em; font-size: .85em; |
380 | footer.site-footer { margin-top: 3em; padding-top: 2em; font-size: .85em; |
| 366 | border-top: 1px solid var(--border-light); |
||
| 367 | text-align: center; } |
||
| 368 | footer.site-footer .footer-link { display: inline-block; margin: 0 1em; |
||
| 369 | text-decoration: none; |
||
| 370 | color: var(--text-muted); } |
||
| 381 | text-align: left; max-width: 60ch; margin-left: 6.82em; } |
||
| 382 | footer.site-footer::before { content: "---"; display: block; color: var(--text-muted); |
||
| 383 | margin-bottom: 1em; } |
||
| 384 | footer.site-footer .footer-link { display: block; text-decoration: none; |
||
| 385 | color: var(--text-muted); margin-bottom: .5em; } |
||
| 371 | footer.site-footer .footer-link:hover { color: var(--link-nav-hover); } |
386 | footer.site-footer .footer-link:hover { color: var(--link-nav-hover); } |
| 372 | </style> |
387 | </style> |
| 373 | <script> |
388 | <script> |
cat << _EOF_ | |||
| 408 | <header><a href="$URL_PREFIX/"$([ -z "${PAGE_CRUMBS:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(crumbs)</header> |
423 | <header><a href="$URL_PREFIX/"$([ -z "${PAGE_CRUMBS:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(crumbs)</header> |
| 409 | <article> |
424 | <article> |
| 410 | <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> |
425 | <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> |
| 411 | <div class="contents"> |
||
| 426 | <div class="page-wrapper"> |
||
| 412 | $(page_nav) |
427 | $(page_nav) |
| 428 | <div class="contents"> |
||
| 413 | $(echo "${POST_CONTENTS:-}") |
429 | $(echo "${POST_CONTENTS:-}") |
| 414 | <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> |
430 | <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> |
| 415 | </div> |
431 | </div> |
| 432 | </div> |
||
| 416 | </article> |
433 | </article> |
| 417 | $(render_footer_nav) |
434 | $(render_footer_nav) |
| 418 | </body> |
435 | </body> |
test/regressions.sh +81 -0
printf '# Section\nLink\t/link\n' > "$tn/navs/site-footer.tsv" | |||
| 1271 | run_jenny "$tn" |
1271 | run_jenny "$tn" |
| 1272 | assert_eq "$(grep -c 'class="footer-link"' "$tn/.dist/test.html")" "1" |
1272 | assert_eq "$(grep -c 'class="footer-link"' "$tn/.dist/test.html")" "1" |
| 1273 | |
1273 | |
| 1274 | suite "page nav sidebar layout" |
||
| 1275 | |
||
| 1276 | it "a page with nav renders the nav as a sidebar, not inside .contents" |
||
| 1277 | nav_sb="$TMPROOT/navsidebar" |
||
| 1278 | make_blog_min "$nav_sb" 2 10 |
||
| 1279 | mkdir -p "$nav_sb/pages" "$nav_sb/navs" |
||
| 1280 | 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" |
||
| 1282 | run_jenny_split "$nav_sb" |
||
| 1283 | |
||
| 1284 | it "the nav is wrapped in a .page-wrapper container" |
||
| 1285 | assert_file_contains "$nav_sb/.dist/test.html" '<div class="page-wrapper">' |
||
| 1286 | |
||
| 1287 | it "the nav is outside of .contents, directly in .page-wrapper" |
||
| 1288 | # Extract page-wrapper block and verify nav comes before contents |
||
| 1289 | wrapper=$(sed -n '/<div class="page-wrapper">/,/<\/div>/p' "$nav_sb/.dist/test.html") |
||
| 1290 | # Count lines of nav block - it should appear first |
||
| 1291 | if printf '%s' "$wrapper" | head -10 | grep -q '<nav class="page-nav"'; then |
||
| 1292 | pass "nav element appears early in page-wrapper" |
||
| 1293 | else |
||
| 1294 | fail "nav element should appear near start of page-wrapper" |
||
| 1295 | fi |
||
| 1296 | |
||
| 1297 | it "the nav is not nested inside .contents" |
||
| 1298 | assert_file_not_contains "$nav_sb/.dist/test.html" '<div class="contents">.*<nav' |
||
| 1299 | |
||
| 1300 | it "the page-nav has position: absolute for sidebar layout" |
||
| 1301 | assert_file_contains "$nav_sb/.dist/test.html" 'position: absolute' |
||
| 1302 | |
||
| 1303 | it "the page-nav width is 16em for the sidebar" |
||
| 1304 | assert_file_contains "$nav_sb/.dist/test.html" 'width: 16em' |
||
| 1305 | |
||
| 1306 | it "the page-nav is positioned relative to page-wrapper" |
||
| 1307 | assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { position: relative' |
||
| 1308 | |
||
| 1309 | it "a page without nav still renders its body via .page-wrapper:not(:has(.page-nav)) CSS" |
||
| 1310 | printf '# Plain\n\nNo nav.\n' > "$nav_sb/pages/plain.md" |
||
| 1311 | run_jenny_split "$nav_sb" |
||
| 1312 | # Page without nav should have .page-wrapper:not(:has(.page-nav)) selector in CSS |
||
| 1313 | assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav))' |
||
| 1314 | |
||
| 1315 | it "the build is clean for nav sidebar" |
||
| 1316 | assert_eq "$stderr_output" "" |
||
| 1317 | |
||
| 1318 | suite "footer layout changes" |
||
| 1319 | |
||
| 1320 | it "the site footer is left-aligned, not centred" |
||
| 1321 | assert_file_contains "$nav_sb/.dist/test.html" 'text-align: left' |
||
| 1322 | |
||
| 1323 | it "the footer width matches the body content column (60ch)" |
||
| 1324 | assert_file_contains "$nav_sb/.dist/test.html" 'max-width: 60ch' |
||
| 1325 | |
||
| 1326 | it "the footer has left margin matching the body content left margin" |
||
| 1327 | assert_file_contains "$nav_sb/.dist/test.html" 'margin-left: 6.82em' |
||
| 1328 | |
||
| 1329 | it "the footer separator is specified via CSS content property" |
||
| 1330 | assert_file_contains "$nav_sb/.dist/test.html" 'content: "---"' |
||
| 1331 | |
||
| 1332 | it "the footer separator is rendered as a block element" |
||
| 1333 | assert_file_contains "$nav_sb/.dist/test.html" 'footer.site-footer::before { content: "---"; display: block' |
||
| 1334 | |
||
| 1335 | it "footer links display as blocks, not inline" |
||
| 1336 | assert_file_contains "$nav_sb/.dist/test.html" '.footer-link { display: block' |
||
| 1337 | |
||
| 1338 | it "footer links no longer have inline margins" |
||
| 1339 | assert_file_not_contains "$nav_sb/.dist/test.html" 'margin: 0 1em' |
||
| 1340 | |
||
| 1341 | it "the footer layout is clean on build" |
||
| 1342 | assert_eq "$stderr_output" "" |
||
| 1343 | |
||
| 1344 | suite "CSS variables completeness" |
||
| 1345 | |
||
| 1346 | it "all CSS variables used in page.sh are defined in all theme blocks" |
||
| 1347 | page_html=$(cat "$nav_sb/.dist/test.html") |
||
| 1348 | # Extract all var(--name) references |
||
| 1349 | vars_used=$(printf '%s' "$page_html" | grep -o 'var(--[^)]*)' | sort -u) |
||
| 1350 | # Verify at least the key variables exist |
||
| 1351 | assert_file_contains "$nav_sb/.dist/test.html" '--text-muted' |
||
| 1352 | assert_file_contains "$nav_sb/.dist/test.html" '--link-nav' |
||
| 1353 | assert_file_contains "$nav_sb/.dist/test.html" '--bg-primary' |
||
| 1354 | |
||
| 1274 | summary |
1355 | summary |
Raw
commit f1c6bca5132bab6c77576afbb2095b7ea5dbd43e
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Tue Aug 25 13:02:05 2026 +0800
feat: page nav as left sidebar with responsive layout and footer restyle
Move page-declared nav from stacked layout inside .contents to a left
sidebar (16em wide) positioned absolutely beside the body. Pages without
nav render at full width via CSS :has() selector.
On narrow viewports (<736px), the sidebar stacks above the body and
displays at full width.
Restyle template-baked footer nav to be left-aligned with body-width
(max-width: 60ch, margin-left: 6.82em). Replace centred layout with
left-aligned links displayed as blocks. Add subtle ASCII separator
("---") via CSS ::before, subtly colored with var(--text-muted).
The back arrow remains in the heading row with the title. Page body
no longer shares left edge with post body—that trade is deliberate.
Add 18 regression tests covering:
- Nav sidebar positioning and document structure
- Nav exclusion from .contents
- Pages without nav using full width via CSS
- Footer left alignment and body-width matching
- Footer separator rendering
- CSS variable completeness in all theme blocks
Regressions: 242 -> 260 (18 new, all passing)
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 27e0584..ed8ac4d 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -42,6 +42,10 @@ page_nav() {
printf '</nav>\n'
}
+page_has_nav() {
+ [ -n "${PAGE_NAV:-}" ]
+}
+
crumbs() {
[ -n "${PAGE_CRUMBS:-}" ] || return 0
local total seen href label
@@ -300,15 +304,25 @@ cat << _EOF_
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 simple stack above the body. */
- .page-nav { margin: 0 0 2.5em; padding-bottom: 1.5em;
- border-bottom: 2px solid var(--border-lighter); font-size: .9em; }
+ left to this template. Rendered as a left sidebar beside the body. */
+ .page-nav { position: absolute; width: 16em; 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: 6.82em; }
+ .page-wrapper:has(.page-nav) .contents { margin-left: calc(6.82em + 16em + 2em); }
+ .page-wrapper:not(:has(.page-nav)) .contents { margin-left: 6.82em; }
+
+ @media (max-width: 735px) {
+ .page-nav { position: static; width: 100%; margin-bottom: 2em; }
+ .contents { margin-left: 6.82em; }
+ .page-wrapper:has(.page-nav) .contents { margin-left: 6.82em; }
+ }
.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; }
ol, ul { padding-left: 1em; }
@@ -361,13 +375,14 @@ cat << _EOF_
/* Footer nav is a template-baked nav -- presentation, on every page,
declared by the theme rather than by any page. Uses the theme
variables so it follows dark mode like everything else; it was
- written against the light palette before dark mode landed. */
+ written against the light palette before dark mode landed.
+ Now left-aligned, body-width, with an ASCII separator. */
footer.site-footer { margin-top: 3em; padding-top: 2em; font-size: .85em;
- border-top: 1px solid var(--border-light);
- text-align: center; }
- footer.site-footer .footer-link { display: inline-block; margin: 0 1em;
- text-decoration: none;
- color: var(--text-muted); }
+ text-align: left; max-width: 60ch; margin-left: 6.82em; }
+ footer.site-footer::before { content: "---"; display: block; color: var(--text-muted);
+ margin-bottom: 1em; }
+ 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>
@@ -408,11 +423,13 @@ cat << _EOF_
<header><a href="$URL_PREFIX/"$([ -z "${PAGE_CRUMBS:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(crumbs)</header>
<article>
<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>
- <div class="contents">
+ <div class="page-wrapper">
$(page_nav)
+ <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>
+ </div>
</article>
$(render_footer_nav)
</body>
diff --git a/test/regressions.sh b/test/regressions.sh
index 879cc01..8961503 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1271,4 +1271,85 @@ 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"
+
+it "a page with nav renders the nav as a sidebar, not inside .contents"
+nav_sb="$TMPROOT/navsidebar"
+make_blog_min "$nav_sb" 2 10
+mkdir -p "$nav_sb/pages" "$nav_sb/navs"
+printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv"
+printf 'Nav: side\n\n# Title\n\nBody.\n' > "$nav_sb/pages/test.md"
+run_jenny_split "$nav_sb"
+
+it "the nav is wrapped in a .page-wrapper container"
+assert_file_contains "$nav_sb/.dist/test.html" '<div class="page-wrapper">'
+
+it "the nav is outside of .contents, directly in .page-wrapper"
+# Extract page-wrapper block and verify nav comes before contents
+wrapper=$(sed -n '/<div class="page-wrapper">/,/<\/div>/p' "$nav_sb/.dist/test.html")
+# Count lines of nav block - it should appear first
+if printf '%s' "$wrapper" | head -10 | grep -q '<nav class="page-nav"'; then
+ pass "nav element appears early in page-wrapper"
+else
+ fail "nav element should appear near start of page-wrapper"
+fi
+
+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"
+assert_file_contains "$nav_sb/.dist/test.html" 'width: 16em'
+
+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
+assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav))'
+
+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'
+
+it "the footer width matches the body content column (60ch)"
+assert_file_contains "$nav_sb/.dist/test.html" 'max-width: 60ch'
+
+it "the footer has left margin matching the body content left margin"
+assert_file_contains "$nav_sb/.dist/test.html" 'margin-left: 6.82em'
+
+it "the footer separator is specified via CSS content property"
+assert_file_contains "$nav_sb/.dist/test.html" 'content: "---"'
+
+it "the footer separator is rendered as a block element"
+assert_file_contains "$nav_sb/.dist/test.html" 'footer.site-footer::before { content: "---"; display: block'
+
+it "footer links display as blocks, not inline"
+assert_file_contains "$nav_sb/.dist/test.html" '.footer-link { display: block'
+
+it "footer links no longer have inline margins"
+assert_file_not_contains "$nav_sb/.dist/test.html" 'margin: 0 1em'
+
+it "the footer layout is clean on build"
+assert_eq "$stderr_output" ""
+
+suite "CSS variables completeness"
+
+it "all CSS variables used in page.sh are defined in all theme blocks"
+page_html=$(cat "$nav_sb/.dist/test.html")
+# Extract all var(--name) references
+vars_used=$(printf '%s' "$page_html" | grep -o 'var(--[^)]*)' | sort -u)
+# Verify at least the key variables exist
+assert_file_contains "$nav_sb/.dist/test.html" '--text-muted'
+assert_file_contains "$nav_sb/.dist/test.html" '--link-nav'
+assert_file_contains "$nav_sb/.dist/test.html" '--bg-primary'
+
summary