ssh git@grit.deadkey.org man

Commit 490db3c h

Pat Ambrosio · 26-08-26 15:24

No index link on pages; a post names itself in the breadcrumb

Changes: 3 files +106 -27

Changes (Side-by-Side)

share/jenny/layout/page.sh +16 -7

render_footer_nav() {
183 back=$1 183 back=$1
184 lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || lines="" 184 lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || lines=""
185 185
186 # The row exists for the back link, which every page has; the nav is the
187 # optional part. Without navs/site-footer.tsv this still renders, just with
188 # nothing to the left of it.
186 # Both halves are optional and the row renders for whichever is present.
187 # A post passes a back link; a page passes none -- a page is not listed on
188 # the index, so an arrow back to it points at somewhere the reader did not
189 # come from, which is the same reason the heading has no arrow either. The
190 # breadcrumb in the header is what a page navigates by.
191 # Nothing to show: no links and no way back. Emitting the row anyway would
192 # leave an empty element and its top margin at the end of the document.
193 [ -n "$lines" ] || [ -n "$back" ] || return 0
194
189 printf '<footer class="site-footer">\n' 195 printf '<footer class="site-footer">\n'
190 # The way back comes first: it is the link every page has and the one a
191 # reader is most likely to want, so it leads the row rather than trailing it.
192 printf '%s\n' "$back"
196 # The way back comes first where there is one: it is the link a reader is
197 # most likely to want, so it leads the row rather than trailing it.
198 # An if, not "[ ] && printf": under set -e a failing test as the last
199 # statement of a function makes the function return non-zero, and this one is
200 # called from a command substitution.
201 if [ -n "$back" ]; then printf '%s\n' "$back"; fi
193 if [ -n "$lines" ]; then 202 if [ -n "$lines" ]; then
194 printf ' <span class="footer-links">\n' 203 printf ' <span class="footer-links">\n'
195 # A footer is a flat row of links, so sections and depth are read and 204 # A footer is a flat row of links, so sections and depth are read and
cat << _EOF_
806 <div class="contents"> 815 <div class="contents">
807 $(echo "${POST_CONTENTS:-}") 816 $(echo "${POST_CONTENTS:-}")
808 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 817 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
809 $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">&#x2190;&#xFE0E; Index</a>')
818 $(render_footer_nav '')
810 </div> 819 </div>
811 </div> 820 </div>
812 </div> 821 </div>

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

render_footer_nav() {
90 printf '</footer>\n' 90 printf '</footer>\n'
91 } 91 }
92 92
93 # post_crumb
94 # The post's own segment of the breadcrumb, matching what a page renders: an
95 # arrow, then the slug with dashes read as spaces.
96 #
97 # The slug rather than the title because the breadcrumb is a picture of the
98 # URL -- it says where you are, and the address is what the reader can act on.
99 # A long title would also wrap the header, which the trail is meant to survive.
100 #
101 # Marked current the way a page's last segment is, so the same rule underlines
102 # it and the two headers behave identically.
103 post_crumb() {
104 [ -z "${IS_PAGE:-}" ] || return 0
105 local url=${POST_URL:-} slug
106 [ -n "$url" ] || return 0
107 slug=${url##*/}
108 # Under URL_STYLE=html the URL carries the extension, which is part of the
109 # address but not of the post's name -- without this the crumb read
110 # "hello world.html".
111 slug=${slug%.html}
112 [ -n "$slug" ] || return 0
113 # Built from URL_PREFIX rather than POST_URL, which is site-absolute: the
114 # site title beside it is relative, and a header that mixes the two breaks
115 # the moment the site is served from a subdirectory.
116 # The href keeps the URL's own ending; only the label drops it.
117 printf ' &#x2192;&#xFE0E; <a href="%s/post/%s" class="current">%s</a>' \
118 "$URL_PREFIX" "$(html_escape "${url##*/}")" \
119 "$(html_escape "$(printf '%s' "$slug" | tr '-' ' ')")"
120 }
121
93 html_escape() { 122 html_escape() {
94 local s=$1 123 local s=$1
95 # The replacement text must escape &, because bash >= 5.2 expands a bare & 124 # The replacement text must escape &, because bash >= 5.2 expands a bare &
cat << _EOF_
578 </head> 607 </head>
579 <body> 608 <body>
580 <a href="#" class="theme-toggle" aria-label="Toggle dark mode">&#x263C;&#xFE0E;</a> 609 <a href="#" class="theme-toggle" aria-label="Toggle dark mode">&#x263C;&#xFE0E;</a>
581 <header><a href="$URL_PREFIX/">$(html_escape "${BLOG_TITLE:-}")</a></header>
610 <header><a href="$URL_PREFIX/"$([ -n "${IS_PAGE:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(post_crumb)</header>
582 <article> 611 <article>
583 <div class="heading$([ -n "${IS_PAGE:-}" ] && echo ' heading-page')"><a href="${POST_URL:-}">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp" data-iso="%s"><span class="stamp-slide"><span class="stamp-abs">%s</span><span class="stamp-rel" aria-hidden="true"></span></span></span>' "$(html_escape "${POST_DATE_RFC822:-}")" "$("$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> 612 <div class="heading$([ -n "${IS_PAGE:-}" ] && echo ' heading-page')"><a href="${POST_URL:-}">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp" data-iso="%s"><span class="stamp-slide"><span class="stamp-abs">%s</span><span class="stamp-rel" aria-hidden="true"></span></span></span>' "$(html_escape "${POST_DATE_RFC822:-}")" "$("$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>
584 <div class="body-row"> 613 <div class="body-row">

test/regressions.sh +60 -19

assert_file_contains "$tl_post" '.citations ol { padding-left: 1.4em; margin-top
913 it "the build is clean" 913 it "the build is clean"
914 assert_eq "$stderr_output" "" 914 assert_eq "$stderr_output" ""
915 915
916 suite "a post names itself in the breadcrumb"
917
918 # A page's header shows the trail to where you are; a post's showed only the
919 # site title, so the two headers disagreed about what a header is for.
920
921 bc="$TMPROOT/breadcrumb"
922 make_blog_min "$bc" 1 10
923 run_jenny_split "$bc"
924 bc_post=$(find "$bc/.dist/post" -name '*.html' | head -1)
925
926 it "the post's own segment follows the site title"
927 assert_file_contains "$bc_post" '&#x2192;&#xFE0E; <a href='
928
929 it "the segment reads the slug with dashes as spaces"
930 # The slug rather than the title: the breadcrumb is a picture of the URL, it
931 # says where you are, and a long title would wrap the header.
932 assert_file_contains "$bc_post" 'class="current">post 01</a>'
933
934 it "the segment is marked current, like a page's last one"
935 # So the same rule underlines it and the two headers behave identically.
936 assert_file_contains "$bc_post" 'class="current">'
937
938 it "the crumb link is relative, like the site title beside it"
939 # POST_URL is site-absolute; a header mixing the two breaks the moment the
940 # site is served from a subdirectory. Scoped to the header: the heading's own
941 # link is absolute and was so before this, which is a separate question.
942 bc_header=$("$GREP" -o '<header>.*</header>' "$bc_post" | head -1)
943 assert_contains "$bc_header" 'href="../post/'
944 assert_not_contains "$bc_header" 'href="/post/'
945
946 it "the label drops the extension the href keeps"
947 # Under URL_STYLE=html the URL carries .html, which is part of the address but
948 # not of the post's name -- the crumb read "hello world.html".
949 printf 'URL_STYLE=html\n' >> "$bc/.blogrc"
950 run_jenny_split "$bc"
951 assert_file_contains "$bc_post" 'class="current">post 01</a>'
952 assert_file_contains "$bc_post" 'href="../post/post-01.html"'
953
954 it "a page's breadcrumb is unchanged"
955 mkdir -p "$bc/pages"
956 printf '# Plain\n\nBody.\n' > "$bc/pages/plain.md"
957 run_jenny_split "$bc"
958 assert_file_contains "$bc/.dist/plain.html" 'class="current">plain</a>'
959
960 it "the build is clean"
961 assert_eq "$stderr_output" ""
962
916 suite "the footer row" 963 suite "the footer row"
917 964
918 # The way back and the site links share one row at the end of the text. Before 965 # The way back and the site links share one row at the end of the text. Before
assert_file_contains "$fr_post" '.site-footer { display: flex; align-items: base
944 assert_file_contains "$fr_post" 'gap: 1.5em; margin-top: 2.5em; }' 991 assert_file_contains "$fr_post" 'gap: 1.5em; margin-top: 2.5em; }'
945 assert_file_not_contains "$fr_post" '.home { margin-left: auto;' 992 assert_file_not_contains "$fr_post" '.home { margin-left: auto;'
946 993
947 it "pages get the same row"
994 it "pages get the site links but no way back"
995 # A page is not listed on the index, so an arrow back to it points somewhere
996 # the reader did not come from.
948 assert_file_contains "$fr/.dist/plain.html" 'class="footer-link"' 997 assert_file_contains "$fr/.dist/plain.html" 'class="footer-link"'
998 assert_file_not_contains "$fr/.dist/plain.html" 'class="home"'
949 999
950 it "a blog with no site nav still gets its way back" 1000 it "a blog with no site nav still gets its way back"
951 # The row exists for the back link; the nav is the optional part. 1001 # The row exists for the back link; the nav is the optional part.
assert_file_exists "$nop/.dist/fallback.html"
1523 it "and does so without complaint" 1573 it "and does so without complaint"
1524 assert_eq "$stderr_output" "" 1574 assert_eq "$stderr_output" ""
1525 1575
1526 it "a page ends with the way back, like a post"
1527 # Held in a column before the body the arrow wrapped onto its own line once
1528 # the body no longer fit beside it, leaving a stray glyph between the title and
1529 # the first paragraph on a phone. Below the text it needs no column at any
1530 # width, and posts and pages end the same way.
1531 assert_file_contains "$pg/.dist/study/consciousness.html" 'class="home"'
1532
1533 it "the way back comes after the body, not before it"
1534 tags_line=$("$GREP" -n 'class="tags"' "$pg/.dist/study/consciousness.html" | head -1 | cut -d: -f1)
1535 home_line=$("$GREP" -n 'class="home"' "$pg/.dist/study/consciousness.html" | head -1 | cut -d: -f1)
1536 [ -n "$home_line" ] && [ "$home_line" -gt "$tags_line" ] \
1537 && pass "back link follows the content" \
1538 || fail "back link at line $home_line must come after the body at line $tags_line"
1576 it "a page has no link back to the index"
1577 # A page is not listed on the index, so an arrow back to it points at a place
1578 # the reader did not come from -- the same reason the heading has no arrow.
1579 # The breadcrumb in the header is what a page navigates by.
1580 assert_file_not_contains "$pg/.dist/study/consciousness.html" 'class="home"'
1539 1581
1540 it "a blog with only pages and no posts still builds" 1582 it "a blog with only pages and no posts still builds"
1541 only="$TMPROOT/onlypages" 1583 only="$TMPROOT/onlypages"
printf 'Link & Text <foo>\t/x\n' > "$tfn/navs/site-footer.tsv"
2205 out=$(render_footer_nav_in "$tfn/navs" "..") 2247 out=$(render_footer_nav_in "$tfn/navs" "..")
2206 assert_contains "$out" 'Link &amp; Text &lt;foo&gt;' 2248 assert_contains "$out" 'Link &amp; Text &lt;foo&gt;'
2207 2249
2208 it "a missing template nav file leaves the row with just the way back"
2209 # The row exists for the back link, which every page has; the site nav is the
2210 # optional part. Before the two shared a row this returned nothing at all.
2250 it "a row with neither links nor a way back renders nothing"
2251 # Both halves are optional. Emitting the row anyway would leave an empty
2252 # element and its top margin at the end of the document.
2211 rm -f "$tfn/navs/site-footer.tsv" 2253 rm -f "$tfn/navs/site-footer.tsv"
2212 out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$? 2254 out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$?
2213 assert_eq "$st" "0" 2255 assert_eq "$st" "0"
2214 assert_contains "$out" '<footer class="site-footer">'
2215 assert_not_contains "$out" 'footer-link'
2256 assert_eq "$out" ""
2216 2257
2217 suite "CSS variables completeness" 2258 suite "CSS variables completeness"
2218 2259

Raw

commit 490db3c896a049e39c949620adeb428144cbc755
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 15:24:23 2026 +0800

    No index link on pages; a post names itself in the breadcrumb
    
    A page is not listed on the index, so an arrow back to it points at a
    place the reader did not come from -- the same reason the heading lost its
    arrow earlier. Pages keep the site links in the footer row and drop the
    way back; posts keep both. The row renders for whichever half is present
    and, with neither, does not render at all rather than leaving an empty
    element and its top margin at the end of the document.
    
    The back link is now emitted with an "if" rather than "[ ] && printf": the
    layouts run under set -e, and a failing test as the last statement of a
    function makes the function return non-zero -- which this one does from
    inside a command substitution.
    
    A post's header showed only the site title while a page's showed the trail
    to where you are, so the two disagreed about what a header is for. A post
    now names itself: an arrow, then its slug with dashes read as spaces,
    marked current the way a page's last segment is, so one rule underlines
    both.
    
    The slug rather than the title because the breadcrumb is a picture of the
    URL -- it says where you are, and the address is what the reader can act
    on. A long title would also wrap the header, which the trail is meant to
    survive.
    
    Two things measurement caught. The crumb was site-absolute where the site
    title beside it is relative, which breaks the moment the site is served
    from a subdirectory; it is built from URL_PREFIX now. And under
    URL_STYLE=html the label read "hello world.html" -- the extension is part
    of the address but not of the post's name, so the href keeps it and the
    label drops it.
    
    Tests: the crumb's presence, wording, current marker, relative href, the
    extension rule under both URL styles, that a page's own breadcrumb is
    unchanged, and that pages get site links without a way back. Three
    existing tests asserted the page back link that is now deliberately gone.
    Checked by mutation -- dropping the crumb, leaving the extension in the
    label, restoring the page's index link -- 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 2bbc7fd..629ca88 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -183,13 +183,22 @@ render_footer_nav() {
   back=$1
   lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || lines=""
 
-  # The row exists for the back link, which every page has; the nav is the
-  # optional part. Without navs/site-footer.tsv this still renders, just with
-  # nothing to the left of it.
+  # Both halves are optional and the row renders for whichever is present.
+  # A post passes a back link; a page passes none -- a page is not listed on
+  # the index, so an arrow back to it points at somewhere the reader did not
+  # come from, which is the same reason the heading has no arrow either. The
+  # breadcrumb in the header is what a page navigates by.
+  # Nothing to show: no links and no way back. Emitting the row anyway would
+  # leave an empty element and its top margin at the end of the document.
+  [ -n "$lines" ] || [ -n "$back" ] || return 0
+
   printf '<footer class="site-footer">\n'
-  # The way back comes first: it is the link every page has and the one a
-  # reader is most likely to want, so it leads the row rather than trailing it.
-  printf '%s\n' "$back"
+  # The way back comes first where there is one: it is the link a reader is
+  # most likely to want, so it leads the row rather than trailing it.
+  # An if, not "[ ] && printf": under set -e a failing test as the last
+  # statement of a function makes the function return non-zero, and this one is
+  # called from a command substitution.
+  if [ -n "$back" ]; then printf '%s\n' "$back"; fi
   if [ -n "$lines" ]; then
     printf '  <span class="footer-links">\n'
     # A footer is a flat row of links, so sections and depth are read and
@@ -806,7 +815,7 @@ cat << _EOF_
         <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>
-        $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">&#x2190;&#xFE0E; Index</a>')
+        $(render_footer_nav '')
         </div>
           </div>
         </div>
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 121c4ab..f22979c 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -90,6 +90,35 @@ render_footer_nav() {
   printf '</footer>\n'
 }
 
+# post_crumb
+# The post's own segment of the breadcrumb, matching what a page renders: an
+# arrow, then the slug with dashes read as spaces.
+#
+# The slug rather than the title because the breadcrumb is a picture of the
+# URL -- it says where you are, and the address is what the reader can act on.
+# A long title would also wrap the header, which the trail is meant to survive.
+#
+# Marked current the way a page's last segment is, so the same rule underlines
+# it and the two headers behave identically.
+post_crumb() {
+  [ -z "${IS_PAGE:-}" ] || return 0
+  local url=${POST_URL:-} slug
+  [ -n "$url" ] || return 0
+  slug=${url##*/}
+  # Under URL_STYLE=html the URL carries the extension, which is part of the
+  # address but not of the post's name -- without this the crumb read
+  # "hello world.html".
+  slug=${slug%.html}
+  [ -n "$slug" ] || return 0
+  # Built from URL_PREFIX rather than POST_URL, which is site-absolute: the
+  # site title beside it is relative, and a header that mixes the two breaks
+  # the moment the site is served from a subdirectory.
+  # The href keeps the URL's own ending; only the label drops it.
+  printf ' &#x2192;&#xFE0E; <a href="%s/post/%s" class="current">%s</a>' \
+    "$URL_PREFIX" "$(html_escape "${url##*/}")" \
+    "$(html_escape "$(printf '%s' "$slug" | tr '-' ' ')")"
+}
+
 html_escape() {
   local s=$1
   # The replacement text must escape &, because bash >= 5.2 expands a bare &
@@ -578,7 +607,7 @@ cat << _EOF_
     </head>
     <body>
       <a href="#" class="theme-toggle" aria-label="Toggle dark mode">&#x263C;&#xFE0E;</a>
-      <header><a href="$URL_PREFIX/">$(html_escape "${BLOG_TITLE:-}")</a></header>
+      <header><a href="$URL_PREFIX/"$([ -n "${IS_PAGE:-}" ] && echo ' class="current"')>$(html_escape "${BLOG_TITLE:-}")</a>$(post_crumb)</header>
       <article>
       <div class="heading$([ -n "${IS_PAGE:-}" ] && echo ' heading-page')"><a href="${POST_URL:-}">$([ -z "${IS_PAGE:-}" ] && printf '<span class="stamp" data-iso="%s"><span class="stamp-slide"><span class="stamp-abs">%s</span><span class="stamp-rel" aria-hidden="true"></span></span></span>' "$(html_escape "${POST_DATE_RFC822:-}")" "$("$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">
diff --git a/test/regressions.sh b/test/regressions.sh
index 729aec1..31d059e 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -913,6 +913,53 @@ assert_file_contains "$tl_post" '.citations ol { padding-left: 1.4em; margin-top
 it "the build is clean"
 assert_eq "$stderr_output" ""
 
+suite "a post names itself in the breadcrumb"
+
+# A page's header shows the trail to where you are; a post's showed only the
+# site title, so the two headers disagreed about what a header is for.
+
+bc="$TMPROOT/breadcrumb"
+make_blog_min "$bc" 1 10
+run_jenny_split "$bc"
+bc_post=$(find "$bc/.dist/post" -name '*.html' | head -1)
+
+it "the post's own segment follows the site title"
+assert_file_contains "$bc_post" '&#x2192;&#xFE0E; <a href='
+
+it "the segment reads the slug with dashes as spaces"
+# The slug rather than the title: the breadcrumb is a picture of the URL, it
+# says where you are, and a long title would wrap the header.
+assert_file_contains "$bc_post" 'class="current">post 01</a>'
+
+it "the segment is marked current, like a page's last one"
+# So the same rule underlines it and the two headers behave identically.
+assert_file_contains "$bc_post" 'class="current">'
+
+it "the crumb link is relative, like the site title beside it"
+# POST_URL is site-absolute; a header mixing the two breaks the moment the
+# site is served from a subdirectory. Scoped to the header: the heading's own
+# link is absolute and was so before this, which is a separate question.
+bc_header=$("$GREP" -o '<header>.*</header>' "$bc_post" | head -1)
+assert_contains "$bc_header" 'href="../post/'
+assert_not_contains "$bc_header" 'href="/post/'
+
+it "the label drops the extension the href keeps"
+# Under URL_STYLE=html the URL carries .html, which is part of the address but
+# not of the post's name -- the crumb read "hello world.html".
+printf 'URL_STYLE=html\n' >> "$bc/.blogrc"
+run_jenny_split "$bc"
+assert_file_contains "$bc_post" 'class="current">post 01</a>'
+assert_file_contains "$bc_post" 'href="../post/post-01.html"'
+
+it "a page's breadcrumb is unchanged"
+mkdir -p "$bc/pages"
+printf '# Plain\n\nBody.\n' > "$bc/pages/plain.md"
+run_jenny_split "$bc"
+assert_file_contains "$bc/.dist/plain.html" 'class="current">plain</a>'
+
+it "the build is clean"
+assert_eq "$stderr_output" ""
+
 suite "the footer row"
 
 # The way back and the site links share one row at the end of the text. Before
@@ -944,8 +991,11 @@ assert_file_contains "$fr_post" '.site-footer { display: flex; align-items: base
 assert_file_contains "$fr_post" 'gap: 1.5em; margin-top: 2.5em; }'
 assert_file_not_contains "$fr_post" '.home { margin-left: auto;'
 
-it "pages get the same row"
+it "pages get the site links but no way back"
+# A page is not listed on the index, so an arrow back to it points somewhere
+# the reader did not come from.
 assert_file_contains "$fr/.dist/plain.html" 'class="footer-link"'
+assert_file_not_contains "$fr/.dist/plain.html" 'class="home"'
 
 it "a blog with no site nav still gets its way back"
 # The row exists for the back link; the nav is the optional part.
@@ -1523,19 +1573,11 @@ assert_file_exists "$nop/.dist/fallback.html"
 it "and does so without complaint"
 assert_eq "$stderr_output" ""
 
-it "a page ends with the way back, like a post"
-# Held in a column before the body the arrow wrapped onto its own line once
-# the body no longer fit beside it, leaving a stray glyph between the title and
-# the first paragraph on a phone. Below the text it needs no column at any
-# width, and posts and pages end the same way.
-assert_file_contains "$pg/.dist/study/consciousness.html" 'class="home"'
-
-it "the way back comes after the body, not before it"
-tags_line=$("$GREP" -n 'class="tags"' "$pg/.dist/study/consciousness.html" | head -1 | cut -d: -f1)
-home_line=$("$GREP" -n 'class="home"' "$pg/.dist/study/consciousness.html" | head -1 | cut -d: -f1)
-[ -n "$home_line" ] && [ "$home_line" -gt "$tags_line" ] \
-  && pass "back link follows the content" \
-  || fail "back link at line $home_line must come after the body at line $tags_line"
+it "a page has no link back to the index"
+# A page is not listed on the index, so an arrow back to it points at a place
+# the reader did not come from -- the same reason the heading has no arrow.
+# The breadcrumb in the header is what a page navigates by.
+assert_file_not_contains "$pg/.dist/study/consciousness.html" 'class="home"'
 
 it "a blog with only pages and no posts still builds"
 only="$TMPROOT/onlypages"
@@ -2205,14 +2247,13 @@ printf 'Link & Text <foo>\t/x\n' > "$tfn/navs/site-footer.tsv"
 out=$(render_footer_nav_in "$tfn/navs" "..")
 assert_contains "$out" 'Link &amp; Text &lt;foo&gt;'
 
-it "a missing template nav file leaves the row with just the way back"
-# The row exists for the back link, which every page has; the site nav is the
-# optional part. Before the two shared a row this returned nothing at all.
+it "a row with neither links nor a way back renders nothing"
+# Both halves are optional. Emitting the row anyway would leave an empty
+# element and its top margin at the end of the document.
 rm -f "$tfn/navs/site-footer.tsv"
 out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$?
 assert_eq "$st" "0"
-assert_contains "$out" '<footer class="site-footer">'
-assert_not_contains "$out" 'footer-link'
+assert_eq "$out" ""
 
 suite "CSS variables completeness"