ssh git@grit.deadkey.org man

Commit 81fc1d9 h

Pat Ambrosio · 26-08-26 12:28

Collapse the page nav behind a toggle on a phone

Changes: 2 files +109 -13

Changes (Side-by-Side)

share/jenny/layout/page.sh +68 -9

URL_PREFIX=${URL_PREFIX:-.}
39 # touching this function. 39 # touching this function.
40 page_nav() { 40 page_nav() {
41 [ -n "${PAGE_NAV:-}" ] || return 0 41 [ -n "${PAGE_NAV:-}" ] || return 0
42 # <details> rather than a scripted toggle: the open/closed state, the
43 # keyboard handling and the screen-reader semantics are the element's own,
44 # and a nav that needs JavaScript to open is a nav that does not open when
45 # the script fails. Wide screens override it back to always-open, where a
46 # summary would be noise beside a sidebar that already fits.
47 printf '<details class="page-nav-wrap" open>\n'
48 printf ' <summary class="page-nav-toggle">Contents</summary>\n'
42 printf '<nav class="page-nav">\n' 49 printf '<nav class="page-nav">\n'
43 printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do 50 printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do
44 [ -n "$label" ] || continue 51 [ -n "$label" ] || continue
page_nav() {
59 esac 66 esac
60 done 67 done
61 printf '</nav>\n' 68 printf '</nav>\n'
69 printf '</details>\n'
62 } 70 }
63 71
64 # diagram_script 72 # diagram_script
cat << _EOF_
284 74ch and the body came out 537px against a post's 540 -- a 292 74ch and the body came out 537px against a post's 540 -- a
285 difference with no cause the reader could see. Widening here rather 293 difference with no cause the reader could see. Widening here rather
286 than narrowing the body keeps the measure identical everywhere. */ 294 than narrowing the body keeps the measure identical everywhere. */
287 article:has(.page-nav) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); }
295 article:has(.page-nav-wrap) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); }
288 /* A bare URL has no spaces to wrap at, so on a narrow screen one 296 /* A bare URL has no spaces to wrap at, so on a narrow screen one
289 long link sets the width of the whole page -- a reference to 297 long link sets the width of the whole page -- a reference to
290 https://github.com/hmngwy/jenny made this post 375px wide at a 298 https://github.com/hmngwy/jenny made this post 375px wide at a
cat << _EOF_
429 width is only a suggestion, so the nav column shrank to whatever the 437 width is only a suggestion, so the nav column shrank to whatever the
430 longest label happened to be and the body's left edge moved with it. 438 longest label happened to be and the body's left edge moved with it.
431 The floor keeps the column a fixed 6rem regardless of the labels. */ 439 The floor keeps the column a fixed 6rem regardless of the labels. */
432 .page-nav { display: table-cell; vertical-align: top;
433 width: var(--page-nav-width);
434 min-width: var(--page-nav-width);
435 max-width: var(--page-nav-width);
436 margin: 0; padding: 0; font-size: .9em; }
440 /* The wrapper is the sidebar cell; the nav inside it is a plain
441 block. The <details> only does anything on a phone -- here the
442 summary is hidden and the element is forced open, so a wide screen
443 renders exactly the sidebar it did before. */
444 .page-nav-wrap { display: table-cell; vertical-align: top;
445 width: var(--page-nav-width);
446 min-width: var(--page-nav-width);
447 max-width: var(--page-nav-width);
448 margin: 0; padding: 0; font-size: .9em; }
449 .page-nav-toggle { display: none; }
450 /* The element ships open, so a sidebar renders on a wide screen and,
451 if the script below never runs, on a narrow one too -- failing to an
452 expanded nav rather than a hidden one. Overriding a closed
453 <details> from CSS was tried instead and does not work: a closed
454 details suppresses painting of its contents whatever display says,
455 so the column measured correct while rendering blank. */
456 .page-nav { margin: 0; padding: 0; }
437 /* A label with no space in it -- a long slug, a URL -- cannot wrap, so 457 /* A label with no space in it -- a long slug, a URL -- cannot wrap, so
438 the column grew to fit it and pushed the body right; min-width is a 458 the column grew to fit it and pushed the body right; min-width is a
439 floor, not a ceiling. The ceiling plus overflow-wrap keeps the 459 floor, not a ceiling. The ceiling plus overflow-wrap keeps the
cat << _EOF_
489 /* With no nav there is no sidebar to sit beside, so the body starts at 509 /* With no nav there is no sidebar to sit beside, so the body starts at
490 the left margin -- the same place a post's body starts now that the 510 the left margin -- the same place a post's body starts now that the
491 way back is below the text rather than beside it. */ 511 way back is below the text rather than beside it. */
492 .page-wrapper:not(:has(.page-nav)) .contents-cell { display: block;
512 .page-wrapper:not(:has(.page-nav-wrap)) .contents-cell { display: block;
493 padding-left: 0; } 513 padding-left: 0; }
494 /* Identical to the post layout's. A block, not an inline-block: the 514 /* Identical to the post layout's. A block, not an inline-block: the
495 body no longer sits beside anything -- the back arrow that used to 515 body no longer sits beside anything -- the back arrow that used to
cat << _EOF_
504 an anonymous table wrapper and stays in its column. */ 524 an anonymous table wrapper and stays in its column. */
505 @media (max-width: 735px) { 525 @media (max-width: 735px) {
506 .page-wrapper, 526 .page-wrapper,
507 .page-nav,
527 .page-nav-wrap,
508 .contents-cell { display: block; } 528 .contents-cell { display: block; }
509 .page-nav { width: auto; min-width: 0; margin-bottom: 2em; }
529 /* max-width was the reason labels wrapped at a 11px indent on a
530 phone: the narrow block reset width and min-width but left the
531 6rem ceiling in place, so the nav stayed 96px wide while the rest
532 of the page went full width. All three go together. */
533 .page-nav-wrap { width: auto; min-width: 0; max-width: none;
534 margin-bottom: 2em; font-size: 1em; }
535 /* Collapsed by default here, and only here. A nav ahead of the text
536 costs a quarter of the screen before a reader reaches the title;
537 behind a summary it costs one line and is still one tap away. */
538
539 .page-nav-toggle { display: block; cursor: pointer;
540 text-transform: uppercase; font-size: .9em;
541 color: var(--text-muted); padding: .25em 0;
542 border-bottom: 1px solid var(--border-lighter); }
543 .page-nav-toggle::marker { color: var(--text-muted); }
544 .page-nav { margin-top: .75em; }
510 /* The sidebar's gap goes with the sidebar: stacked above the body, 545 /* The sidebar's gap goes with the sidebar: stacked above the body,
511 it is not a column to clear. Left in place it indented the body on 546 it is not a column to clear. Left in place it indented the body on
512 a phone and forced horizontal scroll. */ 547 a phone and forced horizontal scroll. */
cat << _EOF_
609 } else if (mediaQuery.addListener) { 644 } else if (mediaQuery.addListener) {
610 mediaQuery.addListener(handleSystemChange); 645 mediaQuery.addListener(handleSystemChange);
611 } 646 }
647 // The page nav ships open so it renders as a sidebar on a wide
648 // screen, and so that a failure here leaves an expanded nav rather
649 // than one the reader cannot open. On a narrow screen it starts
650 // collapsed: 264px of links ahead of the title is a quarter of a
651 // phone screen spent before the text begins.
652 //
653 // Closed only on the first paint at a narrow width. Re-collapsing on
654 // every resize would shut the nav under a reader who had just opened
655 // it, and matching the CSS breakpoint here keeps the two from
656 // disagreeing about what "narrow" means.
657 // Deferred: this script is in <head>, so the nav does not exist yet
658 // when it first runs -- querying for it here found nothing and the
659 // nav stayed open on a phone.
660 var collapseNav = function() {
661 var navWrap = document.querySelector('.page-nav-wrap');
662 if (navWrap && window.matchMedia('(max-width: 735px)').matches) {
663 navWrap.removeAttribute('open');
664 }
665 };
666 if (document.readyState === 'loading') {
667 document.addEventListener('DOMContentLoaded', collapseNav);
668 } else {
669 collapseNav();
670 }
612 document.addEventListener('click', function(e) { 671 document.addEventListener('click', function(e) {
613 var toggle = e.target.closest('.theme-toggle'); 672 var toggle = e.target.closest('.theme-toggle');
614 if (!toggle) return; 673 if (!toggle) return;

test/regressions.sh +41 -4

assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: table'
1743 assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { display: table-cell' 1743 assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { display: table-cell'
1744 1744
1745 it "the sidebar is a table cell aligned to the top of its row" 1745 it "the sidebar is a table cell aligned to the top of its row"
1746 nav_rule=$("$SED" -n '/^ \.page-nav { display: table-cell;/,/}/p' "$nav_sb/.dist/test.html")
1746 # The cell is the <details> wrapper now: the nav is collapsible on a phone, so
1747 # the sidebar column belongs to the element that holds the summary as well as
1748 # the list.
1749 nav_rule=$("$SED" -n '/^ \.page-nav-wrap { display: table-cell;/,/}/p' "$nav_sb/.dist/test.html")
1747 assert_contains "$nav_rule" 'vertical-align: top' 1750 assert_contains "$nav_rule" 'vertical-align: top'
1748 1751
1749 it "the sidebar column does not depend on its labels" 1752 it "the sidebar column does not depend on its labels"
it "the sidebar carries no positioning offset"
1758 assert_file_not_contains "$nav_sb/.dist/test.html" '.page-nav { position: absolute' 1761 assert_file_not_contains "$nav_sb/.dist/test.html" '.page-nav { position: absolute'
1759 1762
1760 it "a page with no nav takes the body indent, not the sidebar column" 1763 it "a page with no nav takes the body indent, not the sidebar column"
1761 assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav))'
1764 assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav-wrap))'
1762 1765
1763 it "the body starts at the left margin, with no column reserved" 1766 it "the body starts at the left margin, with no column reserved"
1764 # The indent existed only to make room for the back arrow beside the body. 1767 # The indent existed only to make room for the back arrow beside the body.
assert_file_contains "$nav_sb/.dist/plain.html" '.home { display: block; margin-
1771 it "a sidebar widens the article rather than narrowing the body" 1774 it "a sidebar widens the article rather than narrowing the body"
1772 # 74ch is sized for a post: arrow column plus a 60ch body. Letting the sidebar 1775 # 74ch is sized for a post: arrow column plus a 60ch body. Letting the sidebar
1773 # eat into it made the body 537px against a post's 540. 1776 # eat into it made the body 537px against a post's 540.
1774 assert_file_contains "$nav_sb/.dist/test.html" 'article:has(.page-nav) { max-width: calc(74ch'
1777 assert_file_contains "$nav_sb/.dist/test.html" 'article:has(.page-nav-wrap) { max-width: calc(74ch'
1775 1778
1776 it "the body column is declared identically to a post's" 1779 it "the body column is declared identically to a post's"
1777 # The user's constraint: the body of a page should differ from the body of a 1780 # The user's constraint: the body of a page should differ from the body of a
it "narrow screens collapse the whole table, not just one cell"
1834 # stays in its column, so all three have to leave table layout together. 1837 # stays in its column, so all three have to leave table layout together.
1835 narrow=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/test.html") 1838 narrow=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/test.html")
1836 assert_contains "$narrow" '.page-wrapper,' 1839 assert_contains "$narrow" '.page-wrapper,'
1837 assert_contains "$narrow" '.page-nav,'
1840 assert_contains "$narrow" '.page-nav-wrap,'
1838 assert_contains "$narrow" '.contents-cell { display: block' 1841 assert_contains "$narrow" '.contents-cell { display: block'
1839 1842
1840 it "the narrow sidebar releases its fixed column" 1843 it "the narrow sidebar releases its fixed column"
1844 # All three of width, min-width and max-width. The ceiling was the one left
1845 # behind before: the nav stayed 96px wide on a phone while the rest of the page
1846 # went full width, so every label wrapped at an 11px indent.
1841 assert_contains "$narrow" 'min-width: 0' 1847 assert_contains "$narrow" 'min-width: 0'
1848 assert_contains "$narrow" 'max-width: none'
1849
1850 it "the nav is a details element, so the toggle needs no script to work"
1851 # <details> brings its own open/closed state, keyboard handling and screen
1852 # reader semantics. A nav that needs JavaScript to open is a nav that does not
1853 # open when the script fails.
1854 assert_file_contains "$nav_sb/.dist/test.html" '<details class="page-nav-wrap" open>'
1855 assert_file_contains "$nav_sb/.dist/test.html" '<summary class="page-nav-toggle">'
1856
1857 it "it ships open and is closed on a phone by script"
1858 # Shipping open means a wide screen renders the sidebar it always did, and a
1859 # script failure leaves an expanded nav rather than one the reader cannot
1860 # reach. Overriding a closed <details> from CSS was tried first and does not
1861 # work -- a closed details suppresses painting of its contents whatever
1862 # display says, so the sidebar measured correct and rendered blank.
1863 assert_file_contains "$nav_sb/.dist/test.html" "matchMedia('(max-width: 735px)').matches"
1864 assert_file_contains "$nav_sb/.dist/test.html" "navWrap.removeAttribute('open')"
1865
1866 it "the collapse waits for the nav to exist"
1867 # The script is in <head>, so it first ran before the nav was parsed: the
1868 # query found nothing and the nav stayed open on a phone.
1869 assert_file_contains "$nav_sb/.dist/test.html" "document.addEventListener('DOMContentLoaded', collapseNav)"
1870
1871 it "the summary is hidden on a wide screen"
1872 # The toggle is a phone affordance; beside a sidebar that already fits it is
1873 # noise.
1874 assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-toggle { display: none; }'
1875
1876 it "a page with no nav gets no toggle"
1877 assert_file_not_contains "$nav_sb/.dist/plain.html" '<details class="page-nav-wrap"'
1878 assert_file_not_contains "$nav_sb/.dist/plain.html" '<summary'
1842 1879
1843 it "the build is clean for nav sidebar" 1880 it "the build is clean for nav sidebar"
1844 assert_eq "$stderr_output" "" 1881 assert_eq "$stderr_output" ""

Raw

commit 81fc1d98b9b6e67362cb646ba963dbdf8c56e334
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 12:28:07 2026 +0800

    Collapse the page nav behind a toggle on a phone
    
    264px of links stood between the header and the title -- 28% of a 440px
    screen spent before the text begins. And every label wrapped: "Why this
    shape" broke across two lines at an 11px indent, which reads as two
    entries rather than one.
    
    The wrapping was a missed reset. The narrow block cleared the nav's width
    and min-width but left max-width at 6rem, so the column stayed 96px wide
    while the rest of the page went full width. All three go together now, and
    the labels fit on one line each.
    
    The nav is a <details> with a summary. The element brings its own open
    state, keyboard handling and screen reader semantics; a nav that needs
    script to open is a nav that does not open when the script fails. It ships
    open, so a wide screen renders the sidebar it always did and a failure
    leaves an expanded nav rather than a hidden one, and a line of script
    closes it on the first paint below 735px -- the same width the sidebar
    already stacks at.
    
    Two approaches were tried and rejected on evidence. Shipping it closed and
    forcing it open with CSS renders blank on desktop: a closed <details>
    suppresses painting of its contents whatever display says, so the column
    measured correct -- 96px, 7 items, first item 24px at the right offset --
    while showing nothing. And the collapse ran too early: the script is in
    <head>, so the query found no nav and the phone stayed expanded. It waits
    for DOMContentLoaded now.
    
    Collapsed, the nav is one line and the title moves from y=356 to y=128.
    Opened, it takes the full article width with no label wrapping. Desktop is
    untouched: summary hidden, sidebar visible, tops aligned at 64, body under
    title at 152.
    
    The sidebar cell is now the wrapper rather than the nav, so the :has()
    selectors and the table-cell rules move with it. Three existing tests
    caught the rename.
    
    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 4e96dd0..ab258f6 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -39,6 +39,13 @@ URL_PREFIX=${URL_PREFIX:-.}
 # touching this function.
 page_nav() {
   [ -n "${PAGE_NAV:-}" ] || return 0
+  # <details> rather than a scripted toggle: the open/closed state, the
+  # keyboard handling and the screen-reader semantics are the element's own,
+  # and a nav that needs JavaScript to open is a nav that does not open when
+  # the script fails. Wide screens override it back to always-open, where a
+  # summary would be noise beside a sidebar that already fits.
+  printf '<details class="page-nav-wrap" open>\n'
+  printf '  <summary class="page-nav-toggle">Contents</summary>\n'
   printf '<nav class="page-nav">\n'
   printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do
     [ -n "$label" ] || continue
@@ -59,6 +66,7 @@ page_nav() {
     esac
   done
   printf '</nav>\n'
+  printf '</details>\n'
 }
 
 # diagram_script
@@ -284,7 +292,7 @@ cat << _EOF_
            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:has(.page-nav-wrap) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); }
         /* A bare URL has no spaces to wrap at, so on a narrow screen one
            long link sets the width of the whole page -- a reference to
            https://github.com/hmngwy/jenny made this post 375px wide at a
@@ -429,11 +437,23 @@ cat << _EOF_
            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);
-                    max-width: var(--page-nav-width);
-                    margin: 0; padding: 0; font-size: .9em; }
+        /* The wrapper is the sidebar cell; the nav inside it is a plain
+           block. The <details> only does anything on a phone -- here the
+           summary is hidden and the element is forced open, so a wide screen
+           renders exactly the sidebar it did before. */
+        .page-nav-wrap { display: table-cell; vertical-align: top;
+                         width: var(--page-nav-width);
+                         min-width: var(--page-nav-width);
+                         max-width: var(--page-nav-width);
+                         margin: 0; padding: 0; font-size: .9em; }
+        .page-nav-toggle { display: none; }
+        /* The element ships open, so a sidebar renders on a wide screen and,
+           if the script below never runs, on a narrow one too -- failing to an
+           expanded nav rather than a hidden one. Overriding a closed
+           <details> from CSS was tried instead and does not work: a closed
+           details suppresses painting of its contents whatever display says,
+           so the column measured correct while rendering blank. */
+        .page-nav { margin: 0; padding: 0; }
         /* A label with no space in it -- a long slug, a URL -- cannot wrap, so
            the column grew to fit it and pushed the body right; min-width is a
            floor, not a ceiling. The ceiling plus overflow-wrap keeps the
@@ -489,7 +509,7 @@ cat << _EOF_
         /* With no nav there is no sidebar to sit beside, so the body starts at
            the left margin -- the same place a post's body starts now that the
            way back is below the text rather than beside it. */
-        .page-wrapper:not(:has(.page-nav)) .contents-cell { display: block;
+        .page-wrapper:not(:has(.page-nav-wrap)) .contents-cell { display: block;
                                                             padding-left: 0; }
         /* Identical to the post layout's. A block, not an inline-block: the
            body no longer sits beside anything -- the back arrow that used to
@@ -504,9 +524,24 @@ cat << _EOF_
            an anonymous table wrapper and stays in its column. */
         @media (max-width: 735px) {
           .page-wrapper,
-          .page-nav,
+          .page-nav-wrap,
           .contents-cell { display: block; }
-          .page-nav { width: auto; min-width: 0; margin-bottom: 2em; }
+          /* max-width was the reason labels wrapped at a 11px indent on a
+             phone: the narrow block reset width and min-width but left the
+             6rem ceiling in place, so the nav stayed 96px wide while the rest
+             of the page went full width. All three go together. */
+          .page-nav-wrap { width: auto; min-width: 0; max-width: none;
+                           margin-bottom: 2em; font-size: 1em; }
+          /* Collapsed by default here, and only here. A nav ahead of the text
+             costs a quarter of the screen before a reader reaches the title;
+             behind a summary it costs one line and is still one tap away. */
+
+          .page-nav-toggle { display: block; cursor: pointer;
+                             text-transform: uppercase; font-size: .9em;
+                             color: var(--text-muted); padding: .25em 0;
+                             border-bottom: 1px solid var(--border-lighter); }
+          .page-nav-toggle::marker { color: var(--text-muted); }
+          .page-nav { margin-top: .75em; }
           /* The sidebar's gap goes with the sidebar: stacked above the body,
              it is not a column to clear. Left in place it indented the body on
              a phone and forced horizontal scroll. */
@@ -609,6 +644,30 @@ cat << _EOF_
           } else if (mediaQuery.addListener) {
             mediaQuery.addListener(handleSystemChange);
           }
+          // The page nav ships open so it renders as a sidebar on a wide
+          // screen, and so that a failure here leaves an expanded nav rather
+          // than one the reader cannot open. On a narrow screen it starts
+          // collapsed: 264px of links ahead of the title is a quarter of a
+          // phone screen spent before the text begins.
+          //
+          // Closed only on the first paint at a narrow width. Re-collapsing on
+          // every resize would shut the nav under a reader who had just opened
+          // it, and matching the CSS breakpoint here keeps the two from
+          // disagreeing about what "narrow" means.
+          // Deferred: this script is in <head>, so the nav does not exist yet
+          // when it first runs -- querying for it here found nothing and the
+          // nav stayed open on a phone.
+          var collapseNav = function() {
+            var navWrap = document.querySelector('.page-nav-wrap');
+            if (navWrap && window.matchMedia('(max-width: 735px)').matches) {
+              navWrap.removeAttribute('open');
+            }
+          };
+          if (document.readyState === 'loading') {
+            document.addEventListener('DOMContentLoaded', collapseNav);
+          } else {
+            collapseNav();
+          }
           document.addEventListener('click', function(e) {
             var toggle = e.target.closest('.theme-toggle');
             if (!toggle) return;
diff --git a/test/regressions.sh b/test/regressions.sh
index c0aa821..863818c 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1743,7 +1743,10 @@ 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")
+# The cell is the <details> wrapper now: the nav is collapsible on a phone, so
+# the sidebar column belongs to the element that holds the summary as well as
+# the list.
+nav_rule=$("$SED" -n '/^        \.page-nav-wrap { 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"
@@ -1758,7 +1761,7 @@ it "the sidebar carries no positioning offset"
 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))'
+assert_file_contains "$nav_sb/.dist/plain.html" '.page-wrapper:not(:has(.page-nav-wrap))'
 
 it "the body starts at the left margin, with no column reserved"
 # The indent existed only to make room for the back arrow beside the body.
@@ -1771,7 +1774,7 @@ assert_file_contains "$nav_sb/.dist/plain.html" '.home { display: block; margin-
 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'
+assert_file_contains "$nav_sb/.dist/test.html" 'article:has(.page-nav-wrap) { 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
@@ -1834,11 +1837,45 @@ it "narrow screens collapse the whole table, not just one cell"
 # 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" '.page-nav-wrap,'
 assert_contains "$narrow" '.contents-cell { display: block'
 
 it "the narrow sidebar releases its fixed column"
+# All three of width, min-width and max-width. The ceiling was the one left
+# behind before: the nav stayed 96px wide on a phone while the rest of the page
+# went full width, so every label wrapped at an 11px indent.
 assert_contains "$narrow" 'min-width: 0'
+assert_contains "$narrow" 'max-width: none'
+
+it "the nav is a details element, so the toggle needs no script to work"
+# <details> brings its own open/closed state, keyboard handling and screen
+# reader semantics. A nav that needs JavaScript to open is a nav that does not
+# open when the script fails.
+assert_file_contains "$nav_sb/.dist/test.html" '<details class="page-nav-wrap" open>'
+assert_file_contains "$nav_sb/.dist/test.html" '<summary class="page-nav-toggle">'
+
+it "it ships open and is closed on a phone by script"
+# Shipping open means a wide screen renders the sidebar it always did, and a
+# script failure leaves an expanded nav rather than one the reader cannot
+# reach. Overriding a closed <details> from CSS was tried first and does not
+# work -- a closed details suppresses painting of its contents whatever
+# display says, so the sidebar measured correct and rendered blank.
+assert_file_contains "$nav_sb/.dist/test.html" "matchMedia('(max-width: 735px)').matches"
+assert_file_contains "$nav_sb/.dist/test.html" "navWrap.removeAttribute('open')"
+
+it "the collapse waits for the nav to exist"
+# The script is in <head>, so it first ran before the nav was parsed: the
+# query found nothing and the nav stayed open on a phone.
+assert_file_contains "$nav_sb/.dist/test.html" "document.addEventListener('DOMContentLoaded', collapseNav)"
+
+it "the summary is hidden on a wide screen"
+# The toggle is a phone affordance; beside a sidebar that already fits it is
+# noise.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-toggle { display: none; }'
+
+it "a page with no nav gets no toggle"
+assert_file_not_contains "$nav_sb/.dist/plain.html" '<details class="page-nav-wrap"'
+assert_file_not_contains "$nav_sb/.dist/plain.html" '<summary'
 
 it "the build is clean for nav sidebar"
 assert_eq "$stderr_output" ""