ssh git@grit.deadkey.org man

Commit 77f5642 h

Pat Ambrosio · 26-08-26 12:59

Chevron on the nav toggle; fix two alignment and state bugs

Changes: 3 files +133 -20

Changes (Side-by-Side)

share/jenny/layout/page.sh +59 -8

cat << _EOF_
536 costs a quarter of the screen before a reader reaches the title; 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. */ 537 behind a summary it costs one line and is still one tap away. */
538 538
539 .page-nav-toggle { display: block; cursor: pointer;
539 /* The default disclosure marker did not draw -- the summary's text
540 began at x=0 with nothing before it -- so "CONTENTS" read as a
541 heading rather than something to tap. The marker is turned off
542 explicitly and replaced with a chevron of our own, which also
543 makes the open state visible: it points right when closed and
544 down when open.
545
546 list-style and ::marker together because which one silences the
547 triangle depends on the engine, and ::-webkit-details-marker for
548 the older WebKit that honours neither. */
549 .page-nav-toggle { display: flex; align-items: center; gap: .5em;
550 cursor: pointer; list-style: none;
540 text-transform: uppercase; font-size: .9em; 551 text-transform: uppercase; font-size: .9em;
541 color: var(--text-muted); padding: .25em 0;
552 color: var(--text-muted); padding: .4em 0;
542 border-bottom: 1px solid var(--border-lighter); } 553 border-bottom: 1px solid var(--border-lighter); }
543 .page-nav-toggle::marker { color: var(--text-muted); }
554 .page-nav-toggle::marker { content: ""; }
555 .page-nav-toggle::-webkit-details-marker { display: none; }
556 /* A border rotated 45 degrees rather than a glyph: it inherits the
557 text colour, scales with the font, and needs no font support. */
558 .page-nav-toggle::before { content: ""; display: inline-block;
559 width: .4em; height: .4em;
560 border-right: 1px solid currentColor;
561 border-bottom: 1px solid currentColor;
562 transform: rotate(-45deg);
563 transform-origin: center;
564 transition: transform .15s ease;
565 flex: 0 0 auto; }
566 .page-nav-wrap[open] > .page-nav-toggle::before { transform: rotate(45deg); }
567 /* Nothing moves for a reader who asked for less motion. */
568 @media (prefers-reduced-motion: reduce) {
569 .page-nav-toggle::before { transition: none; }
570 }
544 .page-nav { margin-top: .75em; } 571 .page-nav { margin-top: .75em; }
545 /* The sidebar's gap goes with the sidebar: stacked above the body, 572 /* The sidebar's gap goes with the sidebar: stacked above the body,
546 it is not a column to clear. Left in place it indented the body on 573 it is not a column to clear. Left in place it indented the body on
cat << _EOF_
657 // Deferred: this script is in <head>, so the nav does not exist yet 684 // 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 685 // when it first runs -- querying for it here found nothing and the
659 // nav stayed open on a phone. 686 // nav stayed open on a phone.
660 var collapseNav = function() {
687 //
688 // The breakpoint is watched rather than sampled once. Sampling at
689 // load left the attribute stale when the viewport crossed 735px
690 // afterwards: collapse the nav on a phone, rotate to landscape or
691 // drag the window wider, and the sidebar stayed closed -- and a
692 // closed <details> paints nothing, so the desktop column rendered
693 // blank. Rotating a phone is the everyday version of that.
694 var navMQ = window.matchMedia('(max-width: 735px)');
695 var syncNav = function() {
661 var navWrap = document.querySelector('.page-nav-wrap'); 696 var navWrap = document.querySelector('.page-nav-wrap');
662 if (navWrap && window.matchMedia('(max-width: 735px)').matches) {
663 navWrap.removeAttribute('open');
697 if (!navWrap) return;
698 if (navMQ.matches) {
699 // Only on the way in: re-collapsing on every resize would shut
700 // the nav under a reader who had just opened it.
701 if (!navWrap.dataset.narrow) {
702 navWrap.removeAttribute('open');
703 navWrap.dataset.narrow = '1';
704 }
705 } else {
706 // Wide again: the sidebar always shows, whatever the reader left
707 // it as on the narrow side.
708 navWrap.setAttribute('open', '');
709 delete navWrap.dataset.narrow;
664 } 710 }
665 }; 711 };
712 if (navMQ.addEventListener) {
713 navMQ.addEventListener('change', syncNav);
714 } else if (navMQ.addListener) {
715 navMQ.addListener(syncNav);
716 }
666 if (document.readyState === 'loading') { 717 if (document.readyState === 'loading') {
667 document.addEventListener('DOMContentLoaded', collapseNav);
718 document.addEventListener('DOMContentLoaded', syncNav);
668 } else { 719 } else {
669 collapseNav();
720 syncNav();
670 } 721 }
671 document.addEventListener('click', function(e) { 722 document.addEventListener('click', function(e) {
672 var toggle = e.target.closest('.theme-toggle'); 723 var toggle = e.target.closest('.theme-toggle');

share/jenny/layout/post.sh +27 -3

cat << _EOF_
294 such rule.) */ 294 such rule.) */
295 .heading { display: block; } 295 .heading { display: block; }
296 .heading > a { display: flex; align-items: baseline; } 296 .heading > a { display: flex; align-items: baseline; }
297 .heading .stamp { flex: 0 0 var(--stamp-column); color: var(--text-muted); }
297 /* Sized by its own text plus the index's padding, not by a fixed
298 basis. The index's column is the rendered width of the date --
299 85.14px at the default type size -- plus 1.5em of padding; a
300 6.82em basis reproduced that to 109.12 against the index's 109.14,
301 close enough to measure equal after rounding but not equal. Letting
302 the same content set the width makes them the same number rather
303 than two numbers that agree. */
304 .heading .stamp { flex: 0 0 var(--stamp-column); box-sizing: border-box;
305 padding-right: 1.5em; text-align: right;
306 color: var(--text-muted); }
298 .heading .title { flex: 1 1 auto; } 307 .heading .title { flex: 1 1 auto; }
299 .body-row { padding-left: var(--stamp-column); }
308 /* The body's indent is a second copy of the stamp rather than a length
309 that restates it. The index's column is the rendered width of the
310 date plus its padding -- 109.140625px here -- which no declaration
311 can name: a 6.82em basis came to 109.12 and left the body 0.02px
312 short of its own title. The copy holds the same text in the same
313 font with the same padding, so it measures the same width by
314 construction. It is hidden from assistive tech and carries no text
315 of its own to read out.
316
317 On a phone the header stacks and there is no column to fill, so the
318 copy is removed there rather than left as an empty box. */
319 .body-row { display: flex; align-items: flex-start; }
320 .body-row .gutter { flex: 0 0 auto; visibility: hidden;
321 padding-right: 1.5em; text-align: right; }
300 /* No breakpoint here. The index holds this column all the way down to 322 /* No breakpoint here. The index holds this column all the way down to
301 320px without scrolling -- a title is short and simply wraps -- so a 323 320px without scrolling -- a title is short and simply wraps -- so a
302 post that stacked below some width would stop matching the index at 324 post that stacked below some width would stop matching the index at
cat << _EOF_
324 @media (max-width: 735px) { 346 @media (max-width: 735px) {
325 .heading > a { display: block; } 347 .heading > a { display: block; }
326 .heading .stamp { display: block; flex: none; } 348 .heading .stamp { display: block; flex: none; }
327 .body-row { padding-left: 0; }
349 .body-row { display: block; }
350 .body-row .gutter { display: none; }
328 } 351 }
329 /* The way back sits after the text, where a reader finishes. Held in a 352 /* The way back sits after the text, where a reader finishes. Held in a
330 column before the body it wrapped onto its own line once the body no 353 column before the body it wrapped onto its own line once the body no
cat << _EOF_
450 <article> 473 <article>
451 <div class="heading$([ -n "${IS_PAGE:-}" ] && echo ' heading-page')"><a href="${POST_URL:-}">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp">%s</span>' "$("$DATE" -d "${POST_DATE_RFC822:-}" +%m/%d/%Y 2>/dev/null || printf '%s' "${POST_DATE:-}")")<h1 class="title">$(html_escape "${POST_TITLE:-}")</h1></a></div> 474 <div class="heading$([ -n "${IS_PAGE:-}" ] && echo ' heading-page')"><a href="${POST_URL:-}">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp">%s</span>' "$("$DATE" -d "${POST_DATE_RFC822:-}" +%m/%d/%Y 2>/dev/null || printf '%s' "${POST_DATE:-}")")<h1 class="title">$(html_escape "${POST_TITLE:-}")</h1></a></div>
452 <div class="body-row"> 475 <div class="body-row">
476 <div class="gutter" aria-hidden="true">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp">%s</span>' "$("$DATE" -d "${POST_DATE_RFC822:-}" +%m/%d/%Y 2>/dev/null || printf '%s' "${POST_DATE:-}")")</div>
453 <div class="contents"> 477 <div class="contents">
454 $(echo "${POST_CONTENTS:-}") 478 $(echo "${POST_CONTENTS:-}")
455 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 479 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>

test/regressions.sh +47 -9

make_blog_min "$al" 2 10
879 run_jenny_split "$al" 879 run_jenny_split "$al"
880 al_post=$(find "$al/.dist/post" -name '*.html' | head -1) 880 al_post=$(find "$al/.dist/post" -name '*.html' | head -1)
881 881
882 it "one length defines the stamp column"
883 # Not several copies of the same number in several rules.
884 assert_file_contains "$al_post" '--stamp-column:'
885 assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)'
886 assert_file_contains "$al_post" '.body-row { padding-left: var(--stamp-column); }'
882 it "the body's column is a copy of the stamp, not a length that restates it"
883 # The index's column is the rendered width of the date plus its padding --
884 # 109.140625px at the default size -- which no declaration can name. A 6.82em
885 # basis came to 109.12 and left the body 0.02px short of its own title, which
886 # is visible as a pixel once the two round different ways. The copy holds the
887 # same text in the same font with the same padding, so it measures the same
888 # width by construction.
889 assert_file_contains "$al_post" '<div class="gutter" aria-hidden="true">'
890 assert_file_contains "$al_post" '.body-row { display: flex; align-items: flex-start; }'
891 assert_file_contains "$al_post" '.body-row .gutter { flex: 0 0 auto; visibility: hidden;'
887 892
888 it "the flex container is the anchor, not the heading around it" 893 it "the flex container is the anchor, not the heading around it"
889 # The stamp and the title are wrapped in one <a>, so making .heading the 894 # The stamp and the title are wrapped in one <a>, so making .heading the
it "a phone stacks the header instead of holding the column"
928 # post stacks too. 933 # post stacks too.
929 narrow_post=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$al_post") 934 narrow_post=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$al_post")
930 assert_contains "$narrow_post" '.heading > a { display: block; }' 935 assert_contains "$narrow_post" '.heading > a { display: block; }'
931 assert_contains "$narrow_post" '.body-row { padding-left: 0; }'
936 # The header stacks, so there is no column to fill: the copy is removed rather
937 # than left as an empty box holding a quarter of the screen.
938 assert_contains "$narrow_post" '.body-row { display: block; }'
939 assert_contains "$narrow_post" '.body-row .gutter { display: none; }'
932 940
933 it "the index alignment survives above that width" 941 it "the index alignment survives above that width"
934 # Giving up the match on a phone is deliberate -- an index entry is a single 942 # Giving up the match on a phone is deliberate -- an index entry is a single
935 # line with no body to squeeze, and nobody sees an index and a post side by 943 # line with no body to squeeze, and nobody sees an index and a post side by
936 # side there. The declarations outside the media query are untouched. 944 # side there. The declarations outside the media query are untouched.
937 assert_file_contains "$al_post" '.body-row { padding-left: var(--stamp-column); }'
945 assert_file_contains "$al_post" '.body-row { display: flex; align-items: flex-start; }'
938 assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)' 946 assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)'
939 947
948 it "the stamp pads itself the way the index's does"
949 # Same padding on both sides of the comparison, so the column is the date plus
950 # 1.5em in each case rather than two different sums.
951 assert_file_contains "$al_post" 'padding-right: 1.5em; text-align: right;'
952 assert_file_contains "$al/.dist/index.html" 'padding-right: 1.5em;'
953
940 it "the build is clean" 954 it "the build is clean"
941 assert_eq "$stderr_output" "" 955 assert_eq "$stderr_output" ""
942 956
it "it ships open and is closed on a phone by script"
1860 # reach. Overriding a closed <details> from CSS was tried first and does not 1874 # reach. Overriding a closed <details> from CSS was tried first and does not
1861 # work -- a closed details suppresses painting of its contents whatever 1875 # work -- a closed details suppresses painting of its contents whatever
1862 # display says, so the sidebar measured correct and rendered blank. 1876 # display says, so the sidebar measured correct and rendered blank.
1863 assert_file_contains "$nav_sb/.dist/test.html" "matchMedia('(max-width: 735px)').matches"
1877 assert_file_contains "$nav_sb/.dist/test.html" "matchMedia('(max-width: 735px)')"
1864 assert_file_contains "$nav_sb/.dist/test.html" "navWrap.removeAttribute('open')" 1878 assert_file_contains "$nav_sb/.dist/test.html" "navWrap.removeAttribute('open')"
1865 1879
1866 it "the collapse waits for the nav to exist" 1880 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 1881 # 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. 1882 # query found nothing and the nav stayed open on a phone.
1869 assert_file_contains "$nav_sb/.dist/test.html" "document.addEventListener('DOMContentLoaded', collapseNav)"
1883 assert_file_contains "$nav_sb/.dist/test.html" "document.addEventListener('DOMContentLoaded', syncNav)"
1884
1885 it "the breakpoint is watched, not sampled once"
1886 # Sampling at load left the attribute stale when the viewport crossed 735px
1887 # afterwards: collapse the nav on a phone, then rotate to landscape or widen
1888 # the window, and the sidebar stayed closed -- and a closed <details> paints
1889 # nothing, so the desktop column rendered blank.
1890 assert_file_contains "$nav_sb/.dist/test.html" "navMQ.addEventListener('change', syncNav)"
1891 assert_file_contains "$nav_sb/.dist/test.html" "navWrap.setAttribute('open', '')"
1892
1893 it "re-collapsing does not fight the reader"
1894 # Only on the way in to narrow: without the flag, every resize would shut a nav
1895 # the reader had just opened.
1896 assert_file_contains "$nav_sb/.dist/test.html" 'navWrap.dataset.narrow'
1897
1898 it "the toggle looks like something to tap"
1899 # The default disclosure marker did not draw -- the summary's text began at
1900 # x=0 with nothing before it -- so "CONTENTS" read as a heading. A chevron of
1901 # our own replaces it, pointing right when closed and down when open.
1902 assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-toggle::before { content: ""'
1903 assert_file_contains "$nav_sb/.dist/test.html" 'transform: rotate(-45deg)'
1904 assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-wrap[open] > .page-nav-toggle::before { transform: rotate(45deg); }'
1905
1906 it "the chevron holds still for a reader who asked for that"
1907 assert_file_contains "$nav_sb/.dist/test.html" '@media (prefers-reduced-motion: reduce)'
1870 1908
1871 it "the summary is hidden on a wide screen" 1909 it "the summary is hidden on a wide screen"
1872 # The toggle is a phone affordance; beside a sidebar that already fits it is 1910 # The toggle is a phone affordance; beside a sidebar that already fits it is

Raw

commit 77f56423dcc95c8f09c4984d4239df59c4170f5d
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 12:59:46 2026 +0800

    Chevron on the nav toggle; fix two alignment and state bugs
    
    Three things, all reported from looking at the rendered pages.
    
    The toggle did not read as tappable. The default disclosure marker was not
    drawing -- the summary's text began at x=0 with nothing before it -- so
    "CONTENTS" looked like a heading. It has a chevron of its own now, built
    from a rotated border so it inherits the text colour and scales with the
    font: pointing right when closed, down when open, and holding still under
    prefers-reduced-motion. The default marker is silenced three ways because
    which one works depends on the engine.
    
    Collapsing the nav on a phone and then widening past 735px left it closed,
    and a closed <details> paints nothing -- so the desktop sidebar rendered
    blank. The breakpoint was sampled once at load; it is watched now. Rotating
    a phone to landscape is the everyday version of that, so this is less of an
    edge case than it sounds. Re-collapsing is still one-way into narrow, so a
    resize cannot shut a nav the reader just opened.
    
    The post's title sat 0.023px left of the index's, which rounds to a visible
    pixel. The index's column is the rendered width of the date plus its
    padding -- 109.140625px -- and no declaration can name that: the 6.82em
    basis came to 109.12. Both the stamp and the body now take their width from
    the same content instead. The stamp sizes to its own text with the index's
    padding, and the body is indented by a hidden copy of the stamp rather than
    by a length that restates it. Title and body now land on 133.140625
    exactly, not rounded-to-equal.
    
    The copy is removed on a phone, where the header stacks and there is no
    column to fill.
    
    Tests: the gutter mechanism, the watched breakpoint, the one-way collapse,
    the chevron and its rotation, and the reduced-motion guard. Five existing
    tests caught the changes to the rules they pinned. Checked by mutation --
    removing the gutter, unwatching the breakpoint, flattening the chevron --
    each caught.
    
    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 ab258f6..5cdb876 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -536,11 +536,38 @@ cat << _EOF_
              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;
+          /* The default disclosure marker did not draw -- the summary's text
+             began at x=0 with nothing before it -- so "CONTENTS" read as a
+             heading rather than something to tap. The marker is turned off
+             explicitly and replaced with a chevron of our own, which also
+             makes the open state visible: it points right when closed and
+             down when open.
+
+             list-style and ::marker together because which one silences the
+             triangle depends on the engine, and ::-webkit-details-marker for
+             the older WebKit that honours neither. */
+          .page-nav-toggle { display: flex; align-items: center; gap: .5em;
+                             cursor: pointer; list-style: none;
                              text-transform: uppercase; font-size: .9em;
-                             color: var(--text-muted); padding: .25em 0;
+                             color: var(--text-muted); padding: .4em 0;
                              border-bottom: 1px solid var(--border-lighter); }
-          .page-nav-toggle::marker { color: var(--text-muted); }
+          .page-nav-toggle::marker { content: ""; }
+          .page-nav-toggle::-webkit-details-marker { display: none; }
+          /* A border rotated 45 degrees rather than a glyph: it inherits the
+             text colour, scales with the font, and needs no font support. */
+          .page-nav-toggle::before { content: ""; display: inline-block;
+                                     width: .4em; height: .4em;
+                                     border-right: 1px solid currentColor;
+                                     border-bottom: 1px solid currentColor;
+                                     transform: rotate(-45deg);
+                                     transform-origin: center;
+                                     transition: transform .15s ease;
+                                     flex: 0 0 auto; }
+          .page-nav-wrap[open] > .page-nav-toggle::before { transform: rotate(45deg); }
+          /* Nothing moves for a reader who asked for less motion. */
+          @media (prefers-reduced-motion: reduce) {
+            .page-nav-toggle::before { transition: none; }
+          }
           .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
@@ -657,16 +684,40 @@ cat << _EOF_
           // 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() {
+          //
+          // The breakpoint is watched rather than sampled once. Sampling at
+          // load left the attribute stale when the viewport crossed 735px
+          // afterwards: collapse the nav on a phone, rotate to landscape or
+          // drag the window wider, and the sidebar stayed closed -- and a
+          // closed <details> paints nothing, so the desktop column rendered
+          // blank. Rotating a phone is the everyday version of that.
+          var navMQ = window.matchMedia('(max-width: 735px)');
+          var syncNav = function() {
             var navWrap = document.querySelector('.page-nav-wrap');
-            if (navWrap && window.matchMedia('(max-width: 735px)').matches) {
-              navWrap.removeAttribute('open');
+            if (!navWrap) return;
+            if (navMQ.matches) {
+              // Only on the way in: re-collapsing on every resize would shut
+              // the nav under a reader who had just opened it.
+              if (!navWrap.dataset.narrow) {
+                navWrap.removeAttribute('open');
+                navWrap.dataset.narrow = '1';
+              }
+            } else {
+              // Wide again: the sidebar always shows, whatever the reader left
+              // it as on the narrow side.
+              navWrap.setAttribute('open', '');
+              delete navWrap.dataset.narrow;
             }
           };
+          if (navMQ.addEventListener) {
+            navMQ.addEventListener('change', syncNav);
+          } else if (navMQ.addListener) {
+            navMQ.addListener(syncNav);
+          }
           if (document.readyState === 'loading') {
-            document.addEventListener('DOMContentLoaded', collapseNav);
+            document.addEventListener('DOMContentLoaded', syncNav);
           } else {
-            collapseNav();
+            syncNav();
           }
           document.addEventListener('click', function(e) {
             var toggle = e.target.closest('.theme-toggle');
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 06903f8..f5cdfba 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -294,9 +294,31 @@ cat << _EOF_
            such rule.) */
         .heading { display: block; }
         .heading > a { display: flex; align-items: baseline; }
-        .heading .stamp { flex: 0 0 var(--stamp-column); color: var(--text-muted); }
+        /* Sized by its own text plus the index's padding, not by a fixed
+           basis. The index's column is the rendered width of the date --
+           85.14px at the default type size -- plus 1.5em of padding; a
+           6.82em basis reproduced that to 109.12 against the index's 109.14,
+           close enough to measure equal after rounding but not equal. Letting
+           the same content set the width makes them the same number rather
+           than two numbers that agree. */
+        .heading .stamp { flex: 0 0 var(--stamp-column); box-sizing: border-box;
+                          padding-right: 1.5em; text-align: right;
+                          color: var(--text-muted); }
         .heading .title { flex: 1 1 auto; }
-        .body-row { padding-left: var(--stamp-column); }
+        /* The body's indent is a second copy of the stamp rather than a length
+           that restates it. The index's column is the rendered width of the
+           date plus its padding -- 109.140625px here -- which no declaration
+           can name: a 6.82em basis came to 109.12 and left the body 0.02px
+           short of its own title. The copy holds the same text in the same
+           font with the same padding, so it measures the same width by
+           construction. It is hidden from assistive tech and carries no text
+           of its own to read out.
+
+           On a phone the header stacks and there is no column to fill, so the
+           copy is removed there rather than left as an empty box. */
+        .body-row { display: flex; align-items: flex-start; }
+        .body-row .gutter { flex: 0 0 auto; visibility: hidden;
+                            padding-right: 1.5em; text-align: right; }
         /* No breakpoint here. The index holds this column all the way down to
            320px without scrolling -- a title is short and simply wraps -- so a
            post that stacked below some width would stop matching the index at
@@ -324,7 +346,8 @@ cat << _EOF_
         @media (max-width: 735px) {
           .heading > a { display: block; }
           .heading .stamp { display: block; flex: none; }
-          .body-row { padding-left: 0; }
+          .body-row { display: block; }
+          .body-row .gutter { display: none; }
         }
         /* The way back sits after the text, where a reader finishes. Held in a
            column before the body it wrapped onto its own line once the body no
@@ -450,6 +473,7 @@ cat << _EOF_
       <article>
       <div class="heading$([ -n "${IS_PAGE:-}" ] && echo ' heading-page')"><a href="${POST_URL:-}">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp">%s</span>' "$("$DATE" -d "${POST_DATE_RFC822:-}" +%m/%d/%Y 2>/dev/null || printf '%s' "${POST_DATE:-}")")<h1 class="title">$(html_escape "${POST_TITLE:-}")</h1></a></div>
       <div class="body-row">
+        <div class="gutter" aria-hidden="true">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp">%s</span>' "$("$DATE" -d "${POST_DATE_RFC822:-}" +%m/%d/%Y 2>/dev/null || printf '%s' "${POST_DATE:-}")")</div>
         <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>
diff --git a/test/regressions.sh b/test/regressions.sh
index 863818c..51631ef 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -879,11 +879,16 @@ make_blog_min "$al" 2 10
 run_jenny_split "$al"
 al_post=$(find "$al/.dist/post" -name '*.html' | head -1)
 
-it "one length defines the stamp column"
-# Not several copies of the same number in several rules.
-assert_file_contains "$al_post" '--stamp-column:'
-assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)'
-assert_file_contains "$al_post" '.body-row { padding-left: var(--stamp-column); }'
+it "the body's column is a copy of the stamp, not a length that restates it"
+# The index's column is the rendered width of the date plus its padding --
+# 109.140625px at the default size -- which no declaration can name. A 6.82em
+# basis came to 109.12 and left the body 0.02px short of its own title, which
+# is visible as a pixel once the two round different ways. The copy holds the
+# same text in the same font with the same padding, so it measures the same
+# width by construction.
+assert_file_contains "$al_post" '<div class="gutter" aria-hidden="true">'
+assert_file_contains "$al_post" '.body-row { display: flex; align-items: flex-start; }'
+assert_file_contains "$al_post" '.body-row .gutter { flex: 0 0 auto; visibility: hidden;'
 
 it "the flex container is the anchor, not the heading around it"
 # The stamp and the title are wrapped in one <a>, so making .heading the
@@ -928,15 +933,24 @@ it "a phone stacks the header instead of holding the column"
 # post stacks too.
 narrow_post=$("$SED" -n '/@media (max-width: 735px)/,/^        }/p' "$al_post")
 assert_contains "$narrow_post" '.heading > a { display: block; }'
-assert_contains "$narrow_post" '.body-row { padding-left: 0; }'
+# The header stacks, so there is no column to fill: the copy is removed rather
+# than left as an empty box holding a quarter of the screen.
+assert_contains "$narrow_post" '.body-row { display: block; }'
+assert_contains "$narrow_post" '.body-row .gutter { display: none; }'
 
 it "the index alignment survives above that width"
 # Giving up the match on a phone is deliberate -- an index entry is a single
 # line with no body to squeeze, and nobody sees an index and a post side by
 # side there. The declarations outside the media query are untouched.
-assert_file_contains "$al_post" '.body-row { padding-left: var(--stamp-column); }'
+assert_file_contains "$al_post" '.body-row { display: flex; align-items: flex-start; }'
 assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)'
 
+it "the stamp pads itself the way the index's does"
+# Same padding on both sides of the comparison, so the column is the date plus
+# 1.5em in each case rather than two different sums.
+assert_file_contains "$al_post" 'padding-right: 1.5em; text-align: right;'
+assert_file_contains "$al/.dist/index.html" 'padding-right: 1.5em;'
+
 it "the build is clean"
 assert_eq "$stderr_output" ""
 
@@ -1860,13 +1874,37 @@ it "it ships open and is closed on a phone by script"
 # 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" "matchMedia('(max-width: 735px)')"
 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)"
+assert_file_contains "$nav_sb/.dist/test.html" "document.addEventListener('DOMContentLoaded', syncNav)"
+
+it "the breakpoint is watched, not sampled once"
+# Sampling at load left the attribute stale when the viewport crossed 735px
+# afterwards: collapse the nav on a phone, then rotate to landscape or widen
+# the window, and the sidebar stayed closed -- and a closed <details> paints
+# nothing, so the desktop column rendered blank.
+assert_file_contains "$nav_sb/.dist/test.html" "navMQ.addEventListener('change', syncNav)"
+assert_file_contains "$nav_sb/.dist/test.html" "navWrap.setAttribute('open', '')"
+
+it "re-collapsing does not fight the reader"
+# Only on the way in to narrow: without the flag, every resize would shut a nav
+# the reader had just opened.
+assert_file_contains "$nav_sb/.dist/test.html" 'navWrap.dataset.narrow'
+
+it "the toggle looks like something to tap"
+# The default disclosure marker did not draw -- the summary's text began at
+# x=0 with nothing before it -- so "CONTENTS" read as a heading. A chevron of
+# our own replaces it, pointing right when closed and down when open.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-toggle::before { content: ""'
+assert_file_contains "$nav_sb/.dist/test.html" 'transform: rotate(-45deg)'
+assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-wrap[open] > .page-nav-toggle::before { transform: rotate(45deg); }'
+
+it "the chevron holds still for a reader who asked for that"
+assert_file_contains "$nav_sb/.dist/test.html" '@media (prefers-reduced-motion: reduce)'
 
 it "the summary is hidden on a wide screen"
 # The toggle is a phone affordance; beside a sidebar that already fits it is