ssh git@grit.deadkey.org man

Commit 5a5d303 h

Pat Ambrosio · 26-08-26 13:20

Name a nav in its own file, count what it hides, label the tag row

Changes: 5 files +168 -2

Changes (Side-by-Side)

README.md +16 -0

Home /
252 About /about 252 About /about
253 ``` 253 ```
254 254
255 ### Naming a nav
256
257 A line beginning with `###` is the nav's own name. The default theme shows it
258 on the collapsed nav on a phone, alongside a count of the entries behind it;
259 without one it says "Contents".
260
261 ```
262 ### Study Contents
263 Introduction /study/intro
264 Method /study/method
265 ```
266
267 A file names itself once. A second `###` is ignored — a file naming itself
268 twice is being edited, not renamed, and taking the last one silently would
269 hide the duplicate.
270
255 ### Sections 271 ### Sections
256 272
257 A line beginning with `##` is a section: a label that names the group of 273 A line beginning with `##` is a section: a label that names the group of

share/jenny/layout/page.sh +40 -1

page_nav() {
44 # and a nav that needs JavaScript to open is a nav that does not open when 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 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. 46 # summary would be noise beside a sidebar that already fits.
47 # The summary needs the nav's name and its size before the list is walked,
48 # so the lines are read once for those and once to render. A nav is a few
49 # dozen entries at most; two passes over that is cheaper than the contortion
50 # of emitting the summary after the list and moving it with CSS.
51 local nav_title nav_count
52 nav_title=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "title" { print $3; exit }')
53 nav_count=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "entry" { n++ } END { print n+0 }')
54 # Falls back rather than requiring the file to name itself: a nav that has
55 # never heard of this feature still gets a usable label.
56 [ -n "$nav_title" ] || nav_title="Contents"
57
47 printf '<details class="page-nav-wrap" open>\n' 58 printf '<details class="page-nav-wrap" open>\n'
48 printf ' <summary class="page-nav-toggle">Contents</summary>\n'
59 # The count is what a collapsed nav is hiding, so it is shown only then --
60 # once the list is open the reader can see the entries and a number beside
61 # the title is just noise. Plural handled here because "1 pages" is the kind
62 # of detail that makes a page look automated.
63 printf ' <summary class="page-nav-toggle">%s<span class="nav-count">%s %s</span></summary>\n' \
64 "$(html_escape "$nav_title")" "$nav_count" \
65 "$([ "$nav_count" = "1" ] && printf 'page' || printf 'pages')"
49 printf '<nav class="page-nav">\n' 66 printf '<nav class="page-nav">\n'
50 printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do 67 printf '%s\n' "$PAGE_NAV" | while IFS="$(printf '\t')" read -r kind depth label path; do
51 [ -n "$label" ] || continue 68 [ -n "$label" ] || continue
52 case "$kind" in 69 case "$kind" in
70 title)
71 # Consumed above for the summary; it is not an item in the list.
72 ;;
53 section) 73 section)
54 printf ' <p class="nav-section">%s</p>\n' "$(html_escape "$label")" 74 printf ' <p class="nav-section">%s</p>\n' "$(html_escape "$label")"
55 ;; 75 ;;
cat << _EOF_
446 min-width: var(--page-nav-width); 466 min-width: var(--page-nav-width);
447 max-width: var(--page-nav-width); 467 max-width: var(--page-nav-width);
448 margin: 0; padding: 0; font-size: .9em; } 468 margin: 0; padding: 0; font-size: .9em; }
469 /* The summary, and with it the count, is a phone affordance: on a wide
470 screen the sidebar is simply present and needs no label. */
449 .page-nav-toggle { display: none; } 471 .page-nav-toggle { display: none; }
450 /* The element ships open, so a sidebar renders on a wide screen and, 472 /* 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 473 if the script below never runs, on a narrow one too -- failing to an
cat << _EOF_
552 color: var(--text-muted); padding: .4em 0; 574 color: var(--text-muted); padding: .4em 0;
553 border-bottom: 1px solid var(--border-lighter); } 575 border-bottom: 1px solid var(--border-lighter); }
554 .page-nav-toggle::marker { content: ""; } 576 .page-nav-toggle::marker { content: ""; }
577 /* The count says what is behind the summary, so it belongs to the
578 closed state only: with the list open the entries are right there
579 and a number beside the title is noise. Pushed to the far end so
580 the title stays where the eye lands. */
581 .nav-count { margin-left: auto; font-size: .9em; opacity: .7; }
582 .page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; }
555 .page-nav-toggle::-webkit-details-marker { display: none; } 583 .page-nav-toggle::-webkit-details-marker { display: none; }
556 /* A border rotated 45 degrees rather than a glyph: it inherits the 584 /* A border rotated 45 degrees rather than a glyph: it inherits the
557 text colour, scales with the font, and needs no font support. */ 585 text colour, scales with the font, and needs no font support. */
cat << _EOF_
622 .fn-handle, .fn-text { display: table-cell; } 650 .fn-handle, .fn-text { display: table-cell; }
623 .fn-handle { padding-right: 1ch; } 651 .fn-handle { padding-right: 1ch; }
624 .fnref { line-height: 0 } 652 .fnref { line-height: 0 }
653 /* Labelled the way notes and references are: a row of bare links under
654 a rule read as unexplained next to two sections that name
655 themselves. The wording lives here rather than in the template, so a
656 theme can relabel or hide it by overriding one rule -- the same
657 reason "Notes" and "References" are written in CSS.
658
659 :has(a) because the div is emitted whether or not the post has tags;
660 without it every untagged post grew a heading over nothing. */
625 .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); } 661 .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); }
662 .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em;
663 font-weight: normal; text-transform: uppercase;
664 color: var(--text-primary); margin: 0 0 1em; }
626 /* MMD metadata pairs are preserved in the output rather than 665 /* MMD metadata pairs are preserved in the output rather than
627 stripped, so nothing the author wrote is lost. The default theme 666 stripped, so nothing the author wrote is lost. The default theme
628 hides them: the title comes from the heading and tags render 667 hides them: the title comes from the heading and tags render

share/jenny/layout/post.sh +18 -1

cat << _EOF_
345 alignment is untouched. */ 345 alignment is untouched. */
346 @media (max-width: 735px) { 346 @media (max-width: 735px) {
347 .heading > a { display: block; } 347 .heading > a { display: block; }
348 .heading .stamp { display: block; flex: none; }
348 /* Right alignment belongs to the column: it sets the date against
349 the title beside it. Stacked, the stamp is a full-width block, so
350 the same rule threw the date to the far right of the screen with
351 the title under its left edge. The padding goes too -- it was the
352 gap to a column that is no longer there. */
353 .heading .stamp { display: block; flex: none;
354 text-align: left; padding-right: 0; }
349 .body-row { display: block; } 355 .body-row { display: block; }
350 .body-row .gutter { display: none; } 356 .body-row .gutter { display: none; }
351 } 357 }
cat << _EOF_
408 .fn-handle, .fn-text { display: table-cell; } 414 .fn-handle, .fn-text { display: table-cell; }
409 .fn-handle { padding-right: 1ch; } 415 .fn-handle { padding-right: 1ch; }
410 .fnref { line-height: 0 } 416 .fnref { line-height: 0 }
417 /* Labelled the way notes and references are: a row of bare links under
418 a rule read as unexplained next to two sections that name
419 themselves. The wording lives here rather than in the template, so a
420 theme can relabel or hide it by overriding one rule -- the same
421 reason "Notes" and "References" are written in CSS.
422
423 :has(a) because the div is emitted whether or not the post has tags;
424 without it every untagged post grew a heading over nothing. */
411 .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); } 425 .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); }
426 .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em;
427 font-weight: normal; text-transform: uppercase;
428 color: var(--text-primary); margin: 0 0 1em; }
412 /* MMD metadata pairs are preserved in the output rather than 429 /* MMD metadata pairs are preserved in the output rather than
413 stripped, so nothing the author wrote is lost. The default theme 430 stripped, so nothing the author wrote is lost. The default theme
414 hides them: the title comes from the heading and tags render 431 hides them: the title comes from the heading and tags render

share/jenny/lib/helpers.sh +15 -0

function assert_layouts_current() {
637 # Overview /guide an entry: label, tab, site-root-relative path 637 # Overview /guide an entry: label, tab, site-root-relative path
638 # Setup /guide/setup a nested entry: two leading spaces per level 638 # Setup /guide/setup a nested entry: two leading spaces per level
639 # ## Reference a section label: groups the entries that follow 639 # ## Reference a section label: groups the entries that follow
640 # ### The Guide the nav's own name, for a template that shows one
640 # # anything else a comment, dropped 641 # # anything else a comment, dropped
641 # 642 #
643 # The name is what a collapsed nav is called on a phone; without one a
644 # template picks its own wording. It is "###" because the prefixes already
645 # ladder -- more hashes, broader scope -- and because "#" was long since
646 # spoken for by comments. A file may name itself once; a second name is a
647 # mistake rather than a rename, so the first wins.
648 #
642 # Sections are flat -- a section groups the entries under it and sections do 649 # Sections are flat -- a section groups the entries under it and sections do
643 # not contain other sections. Nesting is for entries: a child is indented two 650 # not contain other sections. Nesting is for entries: a child is indented two
644 # spaces relative to its parent, and depth is reported so a template can 651 # spaces relative to its parent, and depth is reported so a template can
function read_nav() {
693 while (match(line, /^ /)) { indent++; line = substr(line, 3) } 700 while (match(line, /^ /)) { indent++; line = substr(line, 3) }
694 sub(/^[ \t]+/, "", line) 701 sub(/^[ \t]+/, "", line)
695 } 702 }
703 line ~ /^###/ {
704 label = substr(line, 4)
705 sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)
706 # Emitted once. A file that names itself twice is being edited, not
707 # renamed, and silently taking the last one hides the duplicate.
708 if (label != "" && !titled) { print "title\t0\t" label "\t"; titled = 1 }
709 next
710 }
696 line ~ /^##/ { 711 line ~ /^##/ {
697 label = substr(line, 3) 712 label = substr(line, 3)
698 sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label) 713 sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)

test/regressions.sh +79 -0

assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more
865 it "intraword underscores are still literal" 865 it "intraword underscores are still literal"
866 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' 866 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
867 867
868 suite "the tag row is labelled"
869
870 # Notes and references name themselves, so a bare row of links under a rule
871 # read as unexplained beside them.
872
873 tl="$TMPROOT/taglabel"
874 make_blog_min "$tl" 2 10
875 mkdir -p "$tl/pages"
876 printf '# Plain\n\nNo tags here.\n' > "$tl/pages/plain.md"
877 run_jenny_split "$tl"
878 tl_post=$(find "$tl/.dist/post" -name '*.html' | head -1)
879
880 it "the label is a Tags heading matching Notes and References"
881 assert_file_contains "$tl_post" '.tags:has(a)::before { content: "Tags";'
882
883 it "the wording lives in the theme, not the template"
884 # The same reason "Notes" and "References" are written in CSS: a theme
885 # relabels or hides it by overriding one rule.
886 assert_file_not_contains "$tl_post" '<div class="tags">Tags'
887
888 it "an untagged post grows no heading over nothing"
889 # The div is emitted whether or not the post has tags, so the label is scoped
890 # to the case where there is something to label.
891 assert_file_contains "$tl_post" ':has(a)::before'
892
893 it "pages carry the same rule"
894 assert_file_contains "$tl/.dist/plain.html" '.tags:has(a)::before { content: "Tags";'
895
896 it "the build is clean"
897 assert_eq "$stderr_output" ""
898
868 suite "a post lines up with its index entry" 899 suite "a post lines up with its index entry"
869 900
870 # A post should read as the index entry it came from: its stamp column and its 901 # A post should read as the index entry it came from: its stamp column and its
assert_contains "$narrow_post" '.heading > a { display: block; }'
938 assert_contains "$narrow_post" '.body-row { display: block; }' 969 assert_contains "$narrow_post" '.body-row { display: block; }'
939 assert_contains "$narrow_post" '.body-row .gutter { display: none; }' 970 assert_contains "$narrow_post" '.body-row .gutter { display: none; }'
940 971
972 it "a stacked stamp is not right-aligned"
973 # Right alignment belongs to the column: it sets the date against the title
974 # beside it. Stacked, the stamp is a full-width block, so the same rule threw
975 # the date to the far right of the screen with the title under its left edge.
976 narrow_stamp=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$al_post")
977 assert_contains "$narrow_stamp" 'text-align: left; padding-right: 0;'
978
941 it "the index alignment survives above that width" 979 it "the index alignment survives above that width"
942 # Giving up the match on a phone is deliberate -- an index entry is a single 980 # Giving up the match on a phone is deliberate -- an index entry is a single
943 # line with no body to squeeze, and nobody sees an index and a post side by 981 # line with no body to squeeze, and nobody sees an index and a post side by
it "a page with no nav gets no toggle"
1915 assert_file_not_contains "$nav_sb/.dist/plain.html" '<details class="page-nav-wrap"' 1953 assert_file_not_contains "$nav_sb/.dist/plain.html" '<details class="page-nav-wrap"'
1916 assert_file_not_contains "$nav_sb/.dist/plain.html" '<summary' 1954 assert_file_not_contains "$nav_sb/.dist/plain.html" '<summary'
1917 1955
1956 it "a nav can name itself in its own file"
1957 # "###" because the prefixes already ladder -- more hashes, broader scope --
1958 # and because "#" was long since spoken for by comments.
1959 printf '### Study Contents\nItem\t/item\n' > "$nav_sb/navs/side.tsv"
1960 run_jenny_split "$nav_sb"
1961 assert_file_contains "$nav_sb/.dist/test.html" '>Study Contents<'
1962
1963 it "a nav that names itself twice keeps the first name"
1964 # A file naming itself twice is being edited, not renamed; taking the last one
1965 # silently would hide the duplicate.
1966 printf '### First\n### Second\nItem\t/item\n' > "$nav_sb/navs/side.tsv"
1967 run_jenny_split "$nav_sb"
1968 assert_file_contains "$nav_sb/.dist/test.html" '>First<'
1969 assert_file_not_contains "$nav_sb/.dist/test.html" '>Second<'
1970
1971 it "a nav with no name of its own still gets a label"
1972 # A nav written before this feature existed has to keep working.
1973 printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv"
1974 run_jenny_split "$nav_sb"
1975 assert_file_contains "$nav_sb/.dist/test.html" '>Contents<'
1976
1977 it "the summary says how many entries it is hiding"
1978 printf '### Docs\nOne\t/one\nTwo\t/two\nThree\t/three\n' > "$nav_sb/navs/side.tsv"
1979 run_jenny_split "$nav_sb"
1980 assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">3 pages</span>'
1981
1982 it "the count is not counting sections or the name"
1983 # Only entries are pages; a section label and the nav's own name are neither.
1984 printf '### Docs\n## Group\nOne\t/one\n' > "$nav_sb/navs/side.tsv"
1985 run_jenny_split "$nav_sb"
1986 assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">1 page</span>'
1987
1988 it "one entry is a page, not pages"
1989 # "1 pages" is the kind of detail that makes a page look automated.
1990 assert_file_not_contains "$nav_sb/.dist/test.html" '>1 pages<'
1991
1992 it "the count is hidden once the list is open"
1993 # It says what is behind the summary; with the list open the entries are right
1994 # there and a number beside the title is noise.
1995 assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; }'
1996
1918 it "the build is clean for nav sidebar" 1997 it "the build is clean for nav sidebar"
1919 assert_eq "$stderr_output" "" 1998 assert_eq "$stderr_output" ""
1920 1999

Raw

commit 5a5d303bf1fd5f9d987c9f6c878ac2b2d3d32239
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 13:20:36 2026 +0800

    Name a nav in its own file, count what it hides, label the tag row
    
    Three things from reading the rendered pages.
    
    The stamp was right-aligned on a phone. That alignment belongs to the
    column -- it sets the date against the title beside it -- and when the
    header stacks the stamp becomes a full-width block, so the same rule threw
    the date to the far right of the screen with the title under its left
    edge. Stacked, it aligns left with no padding, which is the gap to a column
    that is no longer there. A regression from the commit that introduced the
    column; caught by looking, not by a test.
    
    A nav can name itself with "###", and the collapsed summary uses that name
    instead of the fixed "Contents". The prefixes already ladder -- more
    hashes, broader scope -- and "#" was long since spoken for by comments. A
    file names itself once; a second name is a mistake rather than a rename, so
    the first wins and the duplicate is not silently preferred. A nav written
    before this still works and still says "Contents".
    
    The summary also says how many entries are behind it, which is only useful
    while they are hidden -- with the list open the entries are right there and
    a number beside the title is noise. Sections and the nav's own name are not
    pages, so they are not counted, and one entry is "1 page".
    
    The tag row is labelled the way notes and references are. A row of bare
    links under a rule read as unexplained next to two sections that name
    themselves. The wording is in CSS for the same reason theirs is: a theme
    relabels or hides it by overriding one rule. Scoped with :has(a) because
    the div is emitted whether or not a post has tags -- without that, every
    untagged post grew a heading over nothing.
    
    Desktop re-checked after all three: 8 page types across 4 widths, no
    overflow, no misalignment, posts still landing on the index's columns.
    
    Tests: the stacked-stamp reset, the nav name and its first-wins rule, the
    fallback label, the count and its pluralisation and what it excludes, and
    the tag label on posts and pages. Checked by mutation -- leaving the stamp
    right-aligned, dropping the title line, unscoping the tag label -- each
    caught.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5

diff --git a/README.md b/README.md
index 23682b8..5091e75 100644
--- a/README.md
+++ b/README.md
@@ -252,6 +252,22 @@ Home	/
 About	/about
 ```
 
+### Naming a nav
+
+A line beginning with `###` is the nav's own name. The default theme shows it
+on the collapsed nav on a phone, alongside a count of the entries behind it;
+without one it says "Contents".
+
+```
+### Study Contents
+Introduction	/study/intro
+Method	/study/method
+```
+
+A file names itself once. A second `###` is ignored — a file naming itself
+twice is being edited, not renamed, and taking the last one silently would
+hide the duplicate.
+
 ### Sections
 
 A line beginning with `##` is a section: a label that names the group of
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index 5cdb876..9f48148 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -44,12 +44,32 @@ page_nav() {
   # 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.
+  # The summary needs the nav's name and its size before the list is walked,
+  # so the lines are read once for those and once to render. A nav is a few
+  # dozen entries at most; two passes over that is cheaper than the contortion
+  # of emitting the summary after the list and moving it with CSS.
+  local nav_title nav_count
+  nav_title=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "title" { print $3; exit }')
+  nav_count=$(printf '%s\n' "$PAGE_NAV" | "$AWK" -F'\t' '$1 == "entry" { n++ } END { print n+0 }')
+  # Falls back rather than requiring the file to name itself: a nav that has
+  # never heard of this feature still gets a usable label.
+  [ -n "$nav_title" ] || nav_title="Contents"
+
   printf '<details class="page-nav-wrap" open>\n'
-  printf '  <summary class="page-nav-toggle">Contents</summary>\n'
+  # The count is what a collapsed nav is hiding, so it is shown only then --
+  # once the list is open the reader can see the entries and a number beside
+  # the title is just noise. Plural handled here because "1 pages" is the kind
+  # of detail that makes a page look automated.
+  printf '  <summary class="page-nav-toggle">%s<span class="nav-count">%s %s</span></summary>\n' \
+    "$(html_escape "$nav_title")" "$nav_count" \
+    "$([ "$nav_count" = "1" ] && printf 'page' || printf 'pages')"
   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
     case "$kind" in
+      title)
+        # Consumed above for the summary; it is not an item in the list.
+        ;;
       section)
         printf '  <p class="nav-section">%s</p>\n' "$(html_escape "$label")"
         ;;
@@ -446,6 +466,8 @@ cat << _EOF_
                          min-width: var(--page-nav-width);
                          max-width: var(--page-nav-width);
                          margin: 0; padding: 0; font-size: .9em; }
+        /* The summary, and with it the count, is a phone affordance: on a wide
+           screen the sidebar is simply present and needs no label. */
         .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
@@ -552,6 +574,12 @@ cat << _EOF_
                              color: var(--text-muted); padding: .4em 0;
                              border-bottom: 1px solid var(--border-lighter); }
           .page-nav-toggle::marker { content: ""; }
+          /* The count says what is behind the summary, so it belongs to the
+             closed state only: with the list open the entries are right there
+             and a number beside the title is noise. Pushed to the far end so
+             the title stays where the eye lands. */
+          .nav-count { margin-left: auto; font-size: .9em; opacity: .7; }
+          .page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; }
           .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. */
@@ -622,7 +650,18 @@ cat << _EOF_
         .fn-handle, .fn-text { display: table-cell; }
         .fn-handle { padding-right: 1ch; }
         .fnref { line-height: 0 }
+        /* Labelled the way notes and references are: a row of bare links under
+           a rule read as unexplained next to two sections that name
+           themselves. The wording lives here rather than in the template, so a
+           theme can relabel or hide it by overriding one rule -- the same
+           reason "Notes" and "References" are written in CSS.
+
+           :has(a) because the div is emitted whether or not the post has tags;
+           without it every untagged post grew a heading over nothing. */
         .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); }
+        .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em;
+                               font-weight: normal; text-transform: uppercase;
+                               color: var(--text-primary); margin: 0 0 1em; }
         /* MMD metadata pairs are preserved in the output rather than
            stripped, so nothing the author wrote is lost. The default theme
            hides them: the title comes from the heading and tags render
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index f5cdfba..fdf9d21 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -345,7 +345,13 @@ cat << _EOF_
            alignment is untouched. */
         @media (max-width: 735px) {
           .heading > a { display: block; }
-          .heading .stamp { display: block; flex: none; }
+          /* Right alignment belongs to the column: it sets the date against
+             the title beside it. Stacked, the stamp is a full-width block, so
+             the same rule threw the date to the far right of the screen with
+             the title under its left edge. The padding goes too -- it was the
+             gap to a column that is no longer there. */
+          .heading .stamp { display: block; flex: none;
+                            text-align: left; padding-right: 0; }
           .body-row { display: block; }
           .body-row .gutter { display: none; }
         }
@@ -408,7 +414,18 @@ cat << _EOF_
         .fn-handle, .fn-text { display: table-cell; }
         .fn-handle { padding-right: 1ch; }
         .fnref { line-height: 0 }
+        /* Labelled the way notes and references are: a row of bare links under
+           a rule read as unexplained next to two sections that name
+           themselves. The wording lives here rather than in the template, so a
+           theme can relabel or hide it by overriding one rule -- the same
+           reason "Notes" and "References" are written in CSS.
+
+           :has(a) because the div is emitted whether or not the post has tags;
+           without it every untagged post grew a heading over nothing. */
         .tags { margin-top: 2.5em; padding-top: 1.5em; font-size: .9em; border-top: 2px solid var(--border-lighter); }
+        .tags:has(a)::before { content: "Tags"; display: block; font-size: 1em;
+                               font-weight: normal; text-transform: uppercase;
+                               color: var(--text-primary); margin: 0 0 1em; }
         /* MMD metadata pairs are preserved in the output rather than
            stripped, so nothing the author wrote is lost. The default theme
            hides them: the title comes from the heading and tags render
diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index 9a12a14..7d0bd61 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -637,8 +637,15 @@ function assert_layouts_current() {
 #   Overview	/guide          an entry: label, tab, site-root-relative path
 #     Setup	/guide/setup     a nested entry: two leading spaces per level
 #   ## Reference             a section label: groups the entries that follow
+#   ### The Guide            the nav's own name, for a template that shows one
 #   # anything else          a comment, dropped
 #
+# The name is what a collapsed nav is called on a phone; without one a
+# template picks its own wording. It is "###" because the prefixes already
+# ladder -- more hashes, broader scope -- and because "#" was long since
+# spoken for by comments. A file may name itself once; a second name is a
+# mistake rather than a rename, so the first wins.
+#
 # Sections are flat -- a section groups the entries under it and sections do
 # not contain other sections. Nesting is for entries: a child is indented two
 # spaces relative to its parent, and depth is reported so a template can
@@ -693,6 +700,14 @@ function read_nav() {
       while (match(line, /^  /)) { indent++; line = substr(line, 3) }
       sub(/^[ \t]+/, "", line)
     }
+    line ~ /^###/ {
+      label = substr(line, 4)
+      sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)
+      # Emitted once. A file that names itself twice is being edited, not
+      # renamed, and silently taking the last one hides the duplicate.
+      if (label != "" && !titled) { print "title\t0\t" label "\t"; titled = 1 }
+      next
+    }
     line ~ /^##/ {
       label = substr(line, 3)
       sub(/^[ \t]+/, "", label); sub(/[ \t]+$/, "", label)
diff --git a/test/regressions.sh b/test/regressions.sh
index 51631ef..668f21f 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -865,6 +865,37 @@ assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more
 it "intraword underscores are still literal"
 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
 
+suite "the tag row is labelled"
+
+# Notes and references name themselves, so a bare row of links under a rule
+# read as unexplained beside them.
+
+tl="$TMPROOT/taglabel"
+make_blog_min "$tl" 2 10
+mkdir -p "$tl/pages"
+printf '# Plain\n\nNo tags here.\n' > "$tl/pages/plain.md"
+run_jenny_split "$tl"
+tl_post=$(find "$tl/.dist/post" -name '*.html' | head -1)
+
+it "the label is a Tags heading matching Notes and References"
+assert_file_contains "$tl_post" '.tags:has(a)::before { content: "Tags";'
+
+it "the wording lives in the theme, not the template"
+# The same reason "Notes" and "References" are written in CSS: a theme
+# relabels or hides it by overriding one rule.
+assert_file_not_contains "$tl_post" '<div class="tags">Tags'
+
+it "an untagged post grows no heading over nothing"
+# The div is emitted whether or not the post has tags, so the label is scoped
+# to the case where there is something to label.
+assert_file_contains "$tl_post" ':has(a)::before'
+
+it "pages carry the same rule"
+assert_file_contains "$tl/.dist/plain.html" '.tags:has(a)::before { content: "Tags";'
+
+it "the build is clean"
+assert_eq "$stderr_output" ""
+
 suite "a post lines up with its index entry"
 
 # A post should read as the index entry it came from: its stamp column and its
@@ -938,6 +969,13 @@ assert_contains "$narrow_post" '.heading > a { display: block; }'
 assert_contains "$narrow_post" '.body-row { display: block; }'
 assert_contains "$narrow_post" '.body-row .gutter { display: none; }'
 
+it "a stacked stamp is not right-aligned"
+# Right alignment belongs to the column: it sets the date against the title
+# beside it. Stacked, the stamp is a full-width block, so the same rule threw
+# the date to the far right of the screen with the title under its left edge.
+narrow_stamp=$("$SED" -n '/@media (max-width: 735px)/,/^        }/p' "$al_post")
+assert_contains "$narrow_stamp" 'text-align: left; padding-right: 0;'
+
 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
@@ -1915,6 +1953,47 @@ 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 "a nav can name itself in its own file"
+# "###" because the prefixes already ladder -- more hashes, broader scope --
+# and because "#" was long since spoken for by comments.
+printf '### Study Contents\nItem\t/item\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '>Study Contents<'
+
+it "a nav that names itself twice keeps the first name"
+# A file naming itself twice is being edited, not renamed; taking the last one
+# silently would hide the duplicate.
+printf '### First\n### Second\nItem\t/item\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '>First<'
+assert_file_not_contains "$nav_sb/.dist/test.html" '>Second<'
+
+it "a nav with no name of its own still gets a label"
+# A nav written before this feature existed has to keep working.
+printf 'Item\t/item\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '>Contents<'
+
+it "the summary says how many entries it is hiding"
+printf '### Docs\nOne\t/one\nTwo\t/two\nThree\t/three\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">3 pages</span>'
+
+it "the count is not counting sections or the name"
+# Only entries are pages; a section label and the nav's own name are neither.
+printf '### Docs\n## Group\nOne\t/one\n' > "$nav_sb/navs/side.tsv"
+run_jenny_split "$nav_sb"
+assert_file_contains "$nav_sb/.dist/test.html" '<span class="nav-count">1 page</span>'
+
+it "one entry is a page, not pages"
+# "1 pages" is the kind of detail that makes a page look automated.
+assert_file_not_contains "$nav_sb/.dist/test.html" '>1 pages<'
+
+it "the count is hidden once the list is open"
+# It says what is behind the summary; with the list open the entries are right
+# there and a number beside the title is noise.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-nav-wrap[open] > .page-nav-toggle .nav-count { display: none; }'
+
 it "the build is clean for nav sidebar"
 assert_eq "$stderr_output" ""