Commit 77f5642 h
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