ssh git@grit.deadkey.org man

Commit f1c6bca h

Pat Ambrosio · 26-08-25 13:02

feat: page nav as left sidebar with responsive layout and footer restyle

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">&#x2190;&#xFE0E;</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">&#x2190;&#xFE0E;</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">&#x2190;&#xFE0E;</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