ssh git@grit.deadkey.org man

Commit 3b08ad0 h

Pat Ambrosio · 26-08-26 14:58

Relative time on posts, footer nav inline, page layout on flexbox

Changes: 5 files +351 -147

Changes (Side-by-Side)

share/jenny/layout/index.sh +5 -84

1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 set -euo pipefail 2 set -euo pipefail
3 3
4
5 # Sourced for relative_time_script, the hover-swap stamp shared with the other
6 # layouts. JENNY_LIB_DIR is exported by bin/jenny.
7 source "${JENNY_LIB_DIR}/helpers.sh"
4 # Break apart the LIST payload. 8 # Break apart the LIST payload.
5 # 9 #
6 # Records are delimited by JENNY_RS and their fields by JENNY_FS. Nothing is 10 # Records are delimited by JENNY_RS and their fields by JENNY_FS. Nothing is
cat << _EOF_
259 $(index_loop) 263 $(index_loop)
260 </ul> 264 </ul>
261 $(nav) 265 $(nav)
262 <script>
263 // Fills each stamp's second line with a relative time, computed in the
264 // reader's browser -- a build-time string would be stale the moment it
265 // was served. Without script the absolute date simply stays put, and
266 // the hover swap never arms, because it keys off [data-rel].
267 //
268 // Units floor: 25 hours is "1 day ago", not "2 days".
269 (function () {
270 var UNITS = [
271 [31536000, 'year'],
272 [2592000, 'month'],
273 [604800, 'week'],
274 [86400, 'day'],
275 [3600, 'hour'],
276 [60, 'minute']
277 ];
278 function relative(then, now) {
279 var secs = Math.floor((now - then) / 1000);
280 if (secs < 0) return null; // scheduled or clock skew
281 if (secs < 60) return 'just now';
282 for (var i = 0; i < UNITS.length; i++) {
283 var size = UNITS[i][0], name = UNITS[i][1];
284 if (secs >= size) {
285 var n = Math.floor(secs / size);
286 return n + ' ' + name + (n === 1 ? '' : 's') + ' ago';
287 }
288 }
289 return null;
290 }
291 var now = Date.now();
292 var stamps = document.querySelectorAll('.stamp[data-iso]');
293 for (var i = 0; i < stamps.length; i++) {
294 var el = stamps[i];
295 var when = Date.parse(el.getAttribute('data-iso'));
296 if (isNaN(when)) continue;
297 var text = relative(when, now);
298 if (!text) continue;
299 var slot = el.querySelector('.stamp-rel');
300 if (!slot) continue;
301 // Two copies, so the tail of one is followed by the head of the
302 // next and the loop point is seamless.
303 slot.textContent = '';
304 var a = document.createElement('span');
305 var b = document.createElement('span');
306 a.className = b.className = 'stamp-run';
307 a.textContent = b.textContent = text;
308 b.setAttribute('aria-hidden', 'true');
309 slot.appendChild(a);
310 slot.appendChild(b);
311 el.setAttribute('data-rel', ''); // arms the hover rule
312 // The tooltip carries the exact timestamp with its zone. The
313 // relative string is already on screen while hovering, so
314 // repeating it there would say nothing new.
315 try {
316 el.title = new Date(when).toLocaleString(undefined, {
317 dateStyle: 'full',
318 timeStyle: 'long'
319 });
320 } catch (e) {
321 el.title = el.getAttribute('data-iso');
322 }
323
324 // Always marquee, even when the string appears to fit. A few pixels
325 // of overflow are invisible at rest, so a string that is only just
326 // too wide would be silently clipped; travelling regardless proves
327 // the whole thing has been shown. Strings that genuinely fit travel
328 // a small fixed distance rather than nothing, so the behaviour is
329 // uniform across the column.
330 var mask = el.querySelector('.stamp-slide');
331 if (mask) {
332 // Scroll by exactly one copy plus the gap between them, so the
333 // second copy lands where the first started. Every stamp runs,
334 // whether or not it overflows: the motion is what shows the whole
335 // string has been seen.
336 var one = a.getBoundingClientRect().width;
337 var gap = parseFloat(getComputedStyle(slot).columnGap) || 0;
338 slot.style.setProperty('--marquee', '-' + (one + gap) + 'px');
339 // Longer strings take proportionally longer, so the speed is even.
340 slot.style.setProperty('--marquee-time', ((one + gap) / 22) + 's');
341 el.setAttribute('data-overflow', '');
342 }
343 }
344 })();
345 </script>
266 $(relative_time_script)
346 </body> 267 </body>
347 </html> 268 </html>
348 _EOF_ 269 _EOF_

share/jenny/layout/page.sh +61 -46

crumbs() {
179 # ask for a nav no page declared. Call this, or write your own, to bring one 179 # ask for a nav no page declared. Call this, or write your own, to bring one
180 # back. 180 # back.
181 render_footer_nav() { 181 render_footer_nav() {
182 local lines
183 lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || return 0
184 [ -n "$lines" ] || return 0
182 local lines back
183 back=$1
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 printf '<footer class="site-footer">\n' 189 printf '<footer class="site-footer">\n'
187 # A footer is a flat row of links, so sections and depth are read and
188 # deliberately dropped rather than rendered. A nav carries structure; what
189 # to do with it is each template's decision, and this one flattens.
190 printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do
191 [ "$kind" = "entry" ] || continue
192 [ -n "$label" ] || continue
193 printf ' <a class="footer-link" href="%s%s">%s</a>\n' \
194 "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
195 done
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"
193 if [ -n "$lines" ]; then
194 printf ' <span class="footer-links">\n'
195 # A footer is a flat row of links, so sections and depth are read and
196 # deliberately dropped rather than rendered. A nav carries structure; what
197 # to do with it is each template's decision, and this one flattens.
198 printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do
199 [ "$kind" = "entry" ] || continue
200 [ -n "$label" ] || continue
201 printf ' <a class="footer-link" href="%s%s">%s</a>\n' \
202 "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
203 done
204 printf ' </span>\n'
205 fi
196 printf '</footer>\n' 206 printf '</footer>\n'
197 } 207 }
198 208
cat << _EOF_
415 underline, so the gap is adjustable. */ 425 underline, so the gap is adjustable. */
416 header a.current { border-bottom: 1px solid var(--border-breadcrumb); padding-bottom: .125em; } 426 header a.current { border-bottom: 1px solid var(--border-breadcrumb); padding-bottom: .125em; }
417 .heading a { text-decoration: none; } 427 .heading a { text-decoration: none; }
418 .heading .title { color: var(--text-heading); display: table-cell; margin: 0 0 1em; font-weight:bold; max-width: 500px; }
428 /* No display here. It was table-cell, inherited from the post layout
429 where the title sits beside a stamp; a page has no stamp, so the
430 cell was doing nothing but colliding with the rule below at equal
431 specificity. Dropping it removes the collision rather than working
432 around it. */
433 .heading .title { color: var(--text-heading); margin: 0 0 1em; font-weight: bold; max-width: 500px; }
419 .heading .title:hover { text-decoration: underline; } 434 .heading .title:hover { text-decoration: underline; }
420 /* Block with inline-block children, not flex: flex items do not wrap,
421 so the heading spacer held its column at every width while the
422 body's spacer -- an inline-block beside an inline-block -- let the
423 contents wrap under it below about 700px. The title stayed indented
424 97px above a body that had returned to 0, which on a phone reads as
425 the title pushed off to the right of its own text. Sharing the
426 body's mechanism is what makes the two reflow at the same width.
427
428 These sit after ".heading .title" deliberately. That rule sets
429 display: table-cell at the same specificity as this one, so source
430 order decides, and a table-cell takes its own row -- the title
431 dropped below the spacer at every width. The previous version used
432 display: flex on the parent, which sidestepped the collision only
433 because flex overrides a child's display outright. */
435 /* A page heading is a title and nothing else -- no stamp, no arrow --
436 so it needs no row of its own. The bottom margin is kept: zeroing it
437 once left a page title touching its first paragraph, 0px of gap
438 where a post has 16px. */
434 .heading-page { display: block; margin-left: 0; } 439 .heading-page { display: block; margin-left: 0; }
435 /* Keeps the base rule's bottom margin. An earlier version zeroed the
436 whole margin so the title could sit in a flex row, which left a
437 page title touching its first paragraph -- 0px of gap where a post
438 has 16px. The side margins still go, since the column comes from
439 the spacer beside it. */
440 .heading-page .title { display: inline-block; margin: 0 0 1em;
441 vertical-align: top; max-width: 100%; }
440 .heading-page .title { display: block; margin: 0 0 1em; max-width: 100%; }
442 .heading .stamp { display: inline-block; text-align: right; margin-right: 1.5em; } 441 .heading .stamp { display: inline-block; text-align: right; margin-right: 1.5em; }
443 /* Identical to the post layout's: the way back sits after the text, so 442 /* Identical to the post layout's: the way back sits after the text, so
444 posts and pages end the same way. Held in a column before the body 443 posts and pages end the same way. Held in a column before the body
445 it wrapped onto its own line on a phone, leaving a stray arrow 444 it wrapped onto its own line on a phone, leaving a stray arrow
446 between the title and the first paragraph. */ 445 between the title and the first paragraph. */
447 .home { display: block; margin-top: 2.5em; text-decoration: none;
446 /* The way back and the site links share one row at the end of the
447 text, all of it flowing from the left edge with one gap between
448 every item. Nothing is pushed to the far end: the row reads as a
449 list, and with no navs/site-footer.tsv present it holds only the
450 back link, exactly where it sat before this existed.
451
452 The spacing that used to be on .home lives here now, on the row, so
453 the two do not each carry their own idea of the gap. */
454 .site-footer { display: flex; align-items: baseline; flex-wrap: wrap;
455 gap: 1.5em; margin-top: 2.5em; }
456 .footer-links { display: flex; flex-wrap: wrap; gap: 1.5em; }
457 .footer-link { font-size: .85em; text-transform: uppercase;
458 text-decoration: none; color: var(--text-muted); }
459 .footer-link:hover { color: var(--link-home-hover); }
460 .home { text-decoration: none;
448 font-size: .85em; text-transform: uppercase; 461 font-size: .85em; text-transform: uppercase;
449 color: var(--link-home); } 462 color: var(--link-home); }
450 .home:hover { color: var(--link-home-hover); } 463 .home:hover { color: var(--link-home-hover); }
cat << _EOF_
461 block. The <details> only does anything on a phone -- here the 474 block. The <details> only does anything on a phone -- here the
462 summary is hidden and the element is forced open, so a wide screen 475 summary is hidden and the element is forced open, so a wide screen
463 renders exactly the sidebar it did before. */ 476 renders exactly the sidebar it did before. */
464 .page-nav-wrap { display: table-cell; vertical-align: top;
465 width: var(--page-nav-width);
466 min-width: var(--page-nav-width);
477 /* flex: 0 0 the column, so it neither grows nor shrinks; the width
478 trio the table version needed collapses into the basis. */
479 .page-nav-wrap { flex: 0 0 var(--page-nav-width);
467 max-width: var(--page-nav-width); 480 max-width: var(--page-nav-width);
468 margin: 0; padding: 0; font-size: .9em; } 481 margin: 0; padding: 0; font-size: .9em; }
469 /* The summary, and with it the count, is a phone affordance: on a wide 482 /* The summary, and with it the count, is a phone affordance: on a wide
cat << _EOF_
525 instead of subtracting from the body. There is room: the body column 538 instead of subtracting from the body. There is room: the body column
526 tops out at 60ch and the viewport is wider than nav plus body 539 tops out at 60ch and the viewport is wider than nav plus body
527 wherever this layout applies. */ 540 wherever this layout applies. */
528 .page-wrapper { display: table; table-layout: auto; width: auto; }
529 .contents-cell { display: table-cell; vertical-align: top;
541 .page-wrapper { display: flex; align-items: flex-start; }
542 /* min-width: 0 because a flex item will not shrink below its content
543 otherwise -- a wide table or a long word inside the body would set
544 the floor and push the page sideways instead of scrolling in its own
545 box. The table version got this for free. */
546 .contents-cell { flex: 1 1 auto; min-width: 0;
530 padding-left: var(--page-nav-gap); } 547 padding-left: var(--page-nav-gap); }
531 /* With no nav there is no sidebar to sit beside, so the body starts at 548 /* With no nav there is no sidebar to sit beside, so the body starts at
532 the left margin -- the same place a post's body starts now that the 549 the left margin -- the same place a post's body starts now that the
533 way back is below the text rather than beside it. */ 550 way back is below the text rather than beside it. */
534 .page-wrapper:not(:has(.page-nav-wrap)) .contents-cell { display: block;
535 padding-left: 0; }
551 .page-wrapper:not(:has(.page-nav-wrap)) .contents-cell { padding-left: 0; }
536 /* Identical to the post layout's. A block, not an inline-block: the 552 /* Identical to the post layout's. A block, not an inline-block: the
537 body no longer sits beside anything -- the back arrow that used to 553 body no longer sits beside anything -- the back arrow that used to
538 share its line is below the text now -- and min-width: 0 lets it 554 share its line is below the text now -- and min-width: 0 lets it
cat << _EOF_
545 table layout together -- a lone cell reverting to block still gets 561 table layout together -- a lone cell reverting to block still gets
546 an anonymous table wrapper and stays in its column. */ 562 an anonymous table wrapper and stays in its column. */
547 @media (max-width: 735px) { 563 @media (max-width: 735px) {
548 .page-wrapper,
549 .page-nav-wrap,
550 .contents-cell { display: block; }
564 .page-wrapper { display: block; }
565 .page-nav-wrap { flex: none; }
551 /* max-width was the reason labels wrapped at a 11px indent on a 566 /* max-width was the reason labels wrapped at a 11px indent on a
552 phone: the narrow block reset width and min-width but left the 567 phone: the narrow block reset width and min-width but left the
553 6rem ceiling in place, so the nav stayed 96px wide while the rest 568 6rem ceiling in place, so the nav stayed 96px wide while the rest
cat << _EOF_
791 <div class="contents"> 806 <div class="contents">
792 $(echo "${POST_CONTENTS:-}") 807 $(echo "${POST_CONTENTS:-}")
793 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 808 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
794 <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
809 $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">&#x2190;&#xFE0E; Index</a>')
795 </div> 810 </div>
796 </div> 811 </div>
797 </div> 812 </div>

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

1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 set -euo pipefail 2 set -euo pipefail
3 3
4
5 # Sourced for relative_time_script, the hover-swap stamp shared with the other
6 # layouts. JENNY_LIB_DIR is exported by bin/jenny.
7 source "${JENNY_LIB_DIR}/helpers.sh"
4 # diagram_script 8 # diagram_script
5 # 9 #
6 # Emitted only when the rendered page actually contains a diagram. The 10 # Emitted only when the rendered page actually contains a diagram. The
diagram_script() {
58 _DIAGRAM_ 62 _DIAGRAM_
59 } 63 }
60 64
65 render_footer_nav() {
66 local lines back
67 back=$1
68 lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || lines=""
69
70 # The row exists for the back link, which every page has; the nav is the
71 # optional part. Without navs/site-footer.tsv this still renders, just with
72 # nothing to the left of it.
73 printf '<footer class="site-footer">\n'
74 # The way back comes first: it is the link every page has and the one a
75 # reader is most likely to want, so it leads the row rather than trailing it.
76 printf '%s\n' "$back"
77 if [ -n "$lines" ]; then
78 printf ' <span class="footer-links">\n'
79 # A footer is a flat row of links, so sections and depth are read and
80 # deliberately dropped rather than rendered. A nav carries structure; what
81 # to do with it is each template's decision, and this one flattens.
82 printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do
83 [ "$kind" = "entry" ] || continue
84 [ -n "$label" ] || continue
85 printf ' <a class="footer-link" href="%s%s">%s</a>\n' \
86 "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
87 done
88 printf ' </span>\n'
89 fi
90 printf '</footer>\n'
91 }
92
61 html_escape() { 93 html_escape() {
62 local s=$1 94 local s=$1
63 # The replacement text must escape &, because bash >= 5.2 expands a bare & 95 # The replacement text must escape &, because bash >= 5.2 expands a bare &
cat << _EOF_
305 padding-right: 1.5em; text-align: right; 337 padding-right: 1.5em; text-align: right;
306 color: var(--text-muted); } 338 color: var(--text-muted); }
307 .heading .title { flex: 1 1 auto; } 339 .heading .title { flex: 1 1 auto; }
340 /* The same hover-swap stamp the index uses: the absolute date and,
341 once script supplies it, a relative one in the same box. The rules
342 are the index's, rescoped -- there the hover target is .post-link,
343 here it is the heading's own anchor.
344
345 The gutter's copy of the stamp is left out of all of this. It is a
346 hidden element whose only job is to be the same width as this one,
347 and a second stamp filled with a relative string would measure
348 differently from the date it is standing in for. */
349 .heading .stamp-slide { display: block; position: relative;
350 overflow: hidden; height: 1.6em;
351 line-height: 1.6em; }
352 .heading .stamp-abs,
353 .heading .stamp-rel { display: block; height: 1.6em; line-height: 1.6em;
354 white-space: nowrap; }
355 .heading .stamp-rel { position: absolute; top: 0; left: 0;
356 visibility: hidden; display: flex;
357 column-gap: .6em; width: max-content; }
358 .heading .stamp-run { flex: 0 0 auto; }
359 @media (hover: hover) {
360 .heading a:hover .stamp[data-rel] .stamp-abs { visibility: hidden; }
361 .heading a:hover .stamp[data-rel] .stamp-rel { visibility: visible; }
362 .heading a:hover .stamp[data-rel][data-overflow] .stamp-rel {
363 animation: stamp-marquee var(--marquee-time, 3s) linear .35s infinite;
364 }
365 }
366 /* Two copies scrolled by exactly one copy plus its gap, so the second
367 lands where the first began and the loop is seamless. */
368 @keyframes stamp-marquee {
369 from { transform: translateX(0); }
370 to { transform: translateX(var(--marquee, 0)); }
371 }
372 @media (prefers-reduced-motion: reduce) {
373 .heading .stamp-rel { animation: none !important; }
374 }
308 /* The body's indent is a second copy of the stamp rather than a length 375 /* 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 376 that restates it. The index's column is the rendered width of the
310 date plus its padding -- 109.140625px here -- which no declaration 377 date plus its padding -- 109.140625px here -- which no declaration
cat << _EOF_
362 already carries the site title two lines above -- and is set smaller 429 already carries the site title two lines above -- and is set smaller
363 and in caps, the way the theme marks its other small labels, because 430 and in caps, the way the theme marks its other small labels, because
364 it is an exit rather than part of the reading. */ 431 it is an exit rather than part of the reading. */
365 .home { display: block; margin-top: 2.5em; text-decoration: none;
432 /* The way back and the site links share one row at the end of the
433 text, all of it flowing from the left edge with one gap between
434 every item. Nothing is pushed to the far end: the row reads as a
435 list, and with no navs/site-footer.tsv present it holds only the
436 back link, exactly where it sat before this existed.
437
438 The spacing that used to be on .home lives here now, on the row, so
439 the two do not each carry their own idea of the gap. */
440 .site-footer { display: flex; align-items: baseline; flex-wrap: wrap;
441 gap: 1.5em; margin-top: 2.5em; }
442 .footer-links { display: flex; flex-wrap: wrap; gap: 1.5em; }
443 .footer-link { font-size: .85em; text-transform: uppercase;
444 text-decoration: none; color: var(--text-muted); }
445 .footer-link:hover { color: var(--link-home-hover); }
446 .home { text-decoration: none;
366 font-size: .85em; text-transform: uppercase; 447 font-size: .85em; text-transform: uppercase;
367 color: var(--link-home); } 448 color: var(--link-home); }
368 .home:hover { color: var(--link-home-hover); } 449 .home:hover { color: var(--link-home-hover); }
cat << _EOF_
499 <a href="#" class="theme-toggle" aria-label="Toggle dark mode">&#x263C;&#xFE0E;</a> 580 <a href="#" class="theme-toggle" aria-label="Toggle dark mode">&#x263C;&#xFE0E;</a>
500 <header><a href="$URL_PREFIX/">$(html_escape "${BLOG_TITLE:-}")</a></header> 581 <header><a href="$URL_PREFIX/">$(html_escape "${BLOG_TITLE:-}")</a></header>
501 <article> 582 <article>
502 <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>
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>
503 <div class="body-row"> 584 <div class="body-row">
504 <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> 585 <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>
505 <div class="contents"> 586 <div class="contents">
506 $(echo "${POST_CONTENTS:-}") 587 $(echo "${POST_CONTENTS:-}")
507 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 588 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
508 <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
589 $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">&#x2190;&#xFE0E; Index</a>')
509 </div> 590 </div>
510 </div> 591 </div>
511 </article> 592 </article>
512 $(diagram_script) 593 $(diagram_script)
594 $([ -z "${IS_PAGE:-}" ] && relative_time_script)
513 </body> 595 </body>
514 </html> 596 </html>
515 _EOF_ 597 _EOF_

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

function sweep_orphans() {
438 mv "$kept" "$BLOG_LOCK" 438 mv "$kept" "$BLOG_LOCK"
439 } 439 }
440 440
441 # ------------------------------------------------------- relative timestamps
442
443 # relative_time_script
444 # Emits the <script> that fills every .stamp[data-iso] on the page with a
445 # relative time and arms the hover swap.
446 #
447 # Shared rather than copied because two layouts want it: the index, where it
448 # started, and a post's own heading. The script keys off the markup alone --
449 # any .stamp carrying data-iso and a .stamp-rel slot -- so a layout opts in by
450 # emitting that structure and calling this. Copying it would have meant two
451 # definitions of "1 day ago" drifting apart.
452 #
453 # The relative string is computed in the reader's browser: a build-time string
454 # would be stale the moment it was served. Without script the absolute date
455 # stays put and the hover swap never arms, because the CSS keys off [data-rel]
456 # which only this sets.
457 function relative_time_script() {
458 cat <<'_RELTIME_'
459 <script>
460 // Fills each stamp's second line with a relative time, computed in the
461 // reader's browser -- a build-time string would be stale the moment it
462 // was served. Without script the absolute date simply stays put, and
463 // the hover swap never arms, because it keys off [data-rel].
464 //
465 // Units floor: 25 hours is "1 day ago", not "2 days".
466 (function () {
467 var UNITS = [
468 [31536000, 'year'],
469 [2592000, 'month'],
470 [604800, 'week'],
471 [86400, 'day'],
472 [3600, 'hour'],
473 [60, 'minute']
474 ];
475 function relative(then, now) {
476 var secs = Math.floor((now - then) / 1000);
477 if (secs < 0) return null; // scheduled or clock skew
478 if (secs < 60) return 'just now';
479 for (var i = 0; i < UNITS.length; i++) {
480 var size = UNITS[i][0], name = UNITS[i][1];
481 if (secs >= size) {
482 var n = Math.floor(secs / size);
483 return n + ' ' + name + (n === 1 ? '' : 's') + ' ago';
484 }
485 }
486 return null;
487 }
488 var now = Date.now();
489 var stamps = document.querySelectorAll('.stamp[data-iso]');
490 for (var i = 0; i < stamps.length; i++) {
491 var el = stamps[i];
492 var when = Date.parse(el.getAttribute('data-iso'));
493 if (isNaN(when)) continue;
494 var text = relative(when, now);
495 if (!text) continue;
496 var slot = el.querySelector('.stamp-rel');
497 if (!slot) continue;
498 // Two copies, so the tail of one is followed by the head of the
499 // next and the loop point is seamless.
500 slot.textContent = '';
501 var a = document.createElement('span');
502 var b = document.createElement('span');
503 a.className = b.className = 'stamp-run';
504 a.textContent = b.textContent = text;
505 b.setAttribute('aria-hidden', 'true');
506 slot.appendChild(a);
507 slot.appendChild(b);
508 el.setAttribute('data-rel', ''); // arms the hover rule
509 // The tooltip carries the exact timestamp with its zone. The
510 // relative string is already on screen while hovering, so
511 // repeating it there would say nothing new.
512 try {
513 el.title = new Date(when).toLocaleString(undefined, {
514 dateStyle: 'full',
515 timeStyle: 'long'
516 });
517 } catch (e) {
518 el.title = el.getAttribute('data-iso');
519 }
520
521 // Always marquee, even when the string appears to fit. A few pixels
522 // of overflow are invisible at rest, so a string that is only just
523 // too wide would be silently clipped; travelling regardless proves
524 // the whole thing has been shown. Strings that genuinely fit travel
525 // a small fixed distance rather than nothing, so the behaviour is
526 // uniform across the column.
527 var mask = el.querySelector('.stamp-slide');
528 if (mask) {
529 // Scroll by exactly one copy plus the gap between them, so the
530 // second copy lands where the first started. Every stamp runs,
531 // whether or not it overflows: the motion is what shows the whole
532 // string has been seen.
533 var one = a.getBoundingClientRect().width;
534 var gap = parseFloat(getComputedStyle(slot).columnGap) || 0;
535 slot.style.setProperty('--marquee', '-' + (one + gap) + 'px');
536 // Longer strings take proportionally longer, so the speed is even.
537 slot.style.setProperty('--marquee-time', ((one + gap) / 22) + 's');
538 el.setAttribute('data-overflow', '');
539 }
540 }
541 })();
542 </script>
543 _RELTIME_
544 }
545
441 # ---------------------------------------------------------------- tag index 546 # ---------------------------------------------------------------- tag index
442 547
443 # generate_tag_index <dist> <tags_temp_dir> 548 # generate_tag_index <dist> <tags_temp_dir>

test/regressions.sh +95 -14

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 "the footer row"
917
918 # The way back and the site links share one row at the end of the text. Before
919 # this the back link stood alone and the footer nav was never rendered at all.
920
921 fr="$TMPROOT/footerrow"
922 make_blog_min "$fr" 1 10
923 mkdir -p "$fr/pages" "$fr/navs"
924 printf '# Plain\n\nBody.\n' > "$fr/pages/plain.md"
925 printf '### Site\nAbout\t/about\nFeed\t/feed.xml\n' > "$fr/navs/site-footer.tsv"
926 run_jenny_split "$fr"
927 fr_post=$(find "$fr/.dist/post" -name '*.html' | head -1)
928
929 it "the site links render beside the way back"
930 assert_file_contains "$fr_post" '<footer class="site-footer">'
931 assert_file_contains "$fr_post" 'class="footer-link"'
932
933 it "the way back comes first"
934 # It is the link every page has and the one a reader is most likely to want.
935 home_line=$("$GREP" -n 'class="home"' "$fr_post" | head -1 | cut -d: -f1)
936 link_line=$("$GREP" -n 'class="footer-link"' "$fr_post" | head -1 | cut -d: -f1)
937 [ "$home_line" -lt "$link_line" ] \
938 && pass "back link precedes the site links" \
939 || fail "back link at $home_line should precede site links at $link_line"
940
941 it "the row flows from the left with one gap between items"
942 # Nothing is pushed to the far end: the row reads as a list.
943 assert_file_contains "$fr_post" '.site-footer { display: flex; align-items: baseline;'
944 assert_file_contains "$fr_post" 'gap: 1.5em; margin-top: 2.5em; }'
945 assert_file_not_contains "$fr_post" '.home { margin-left: auto;'
946
947 it "pages get the same row"
948 assert_file_contains "$fr/.dist/plain.html" 'class="footer-link"'
949
950 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.
952 rm "$fr/navs/site-footer.tsv"
953 run_jenny_split "$fr"
954 assert_file_contains "$fr_post" 'class="home"'
955 assert_file_not_contains "$fr_post" 'class="footer-link"'
956
957 it "the build is clean"
958 assert_eq "$stderr_output" ""
959
916 suite "a post lines up with its index entry" 960 suite "a post lines up with its index entry"
917 961
918 # A post should read as the index entry it came from: its stamp column and its 962 # A post should read as the index entry it came from: its stamp column and its
it "the date attribute parses as a date"
1333 iso=$(grep -o 'data-iso="[^"]*"' "$rt/.dist/index.html" | head -1 | sed 's/data-iso="//;s/"//') 1377 iso=$(grep -o 'data-iso="[^"]*"' "$rt/.dist/index.html" | head -1 | sed 's/data-iso="//;s/"//')
1334 if date -d "$iso" >/dev/null 2>&1; then pass; else fail "unparseable: $iso"; fi 1378 if date -d "$iso" >/dev/null 2>&1; then pass; else fail "unparseable: $iso"; fi
1335 1379
1336 it "a post page needs no relative-time script"
1337 assert_file_not_contains "$rt/.dist/post/post-01.html" 'data-iso'
1380 it "a post carries the relative-time script too"
1381 # The same hover-swap stamp the index has. The script is shared rather than
1382 # copied: two definitions of "1 day ago" would drift apart.
1383 assert_file_contains "$rt/.dist/post/post-01.html" 'data-iso'
1384 assert_file_contains "$rt/.dist/post/post-01.html" '<span class="stamp-rel"'
1385 assert_file_contains "$rt/.dist/post/post-01.html" "stamp[data-iso]"
1386
1387 it "the hover target is the heading's own anchor"
1388 # On the index the swap keys off .post-link; a post has no such wrapper, so
1389 # the rules are rescoped to the anchor that holds the stamp and the title.
1390 assert_file_contains "$rt/.dist/post/post-01.html" '.heading a:hover .stamp[data-rel] .stamp-abs'
1391
1392 it "the gutter's copy of the stamp is left alone"
1393 # It is a hidden element whose only job is to be the width of the real stamp.
1394 # Filled with a relative string it would measure differently from the date it
1395 # stands in for.
1396 gutter=$("$GREP" -o '<div class="gutter"[^>]*>[^<]*<span class="stamp"[^>]*>' "$rt/.dist/post/post-01.html" | head -1)
1397 assert_not_contains "$gutter" 'data-iso'
1398
1399 it "a page has no stamp to animate"
1400 # Pages carry no date, so neither the markup nor the script is emitted.
1401 mkdir -p "$rt/pages"
1402 printf '# Plain\n\nNo date here.\n' > "$rt/pages/plain.md"
1403 run_jenny "$rt"
1404 assert_file_not_contains "$rt/.dist/plain.html" 'data-iso'
1405 assert_file_not_contains "$rt/.dist/plain.html" "stamp[data-iso]"
1338 1406
1339 suite "pages/ mirrors its path to the URL" 1407 suite "pages/ mirrors its path to the URL"
1340 1408
it "the sidebar and the body are cells of one table row"
1808 # row, so the browser aligns them. Asserting the row is asserting the 1876 # row, so the browser aligns them. Asserting the row is asserting the
1809 # alignment -- an offset-based version would satisfy a "tops match" check at 1877 # alignment -- an offset-based version would satisfy a "tops match" check at
1810 # one heading height and drift at every other. 1878 # one heading height and drift at every other.
1811 assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: table'
1812 assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { display: table-cell'
1879 # Flexbox, matching the post layout: one mechanism for the same two-column
1880 # problem rather than tables here and flex there.
1881 assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: flex; align-items: flex-start; }'
1882 assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { flex: 1 1 auto; min-width: 0;'
1813 1883
1814 it "the sidebar is a table cell aligned to the top of its row" 1884 it "the sidebar is a table cell aligned to the top of its row"
1815 # The cell is the <details> wrapper now: the nav is collapsible on a phone, so 1885 # The cell is the <details> wrapper now: the nav is collapsible on a phone, so
1816 # the sidebar column belongs to the element that holds the summary as well as 1886 # the sidebar column belongs to the element that holds the summary as well as
1817 # the list. 1887 # the list.
1818 nav_rule=$("$SED" -n '/^ \.page-nav-wrap { display: table-cell;/,/}/p' "$nav_sb/.dist/test.html")
1819 assert_contains "$nav_rule" 'vertical-align: top'
1888 # align-items: flex-start on the row does what vertical-align: top did on the
1889 # cell -- the sidebar sits at the top rather than centring against the body.
1890 assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: flex; align-items: flex-start; }'
1820 1891
1821 it "the sidebar column does not depend on its labels" 1892 it "the sidebar column does not depend on its labels"
1822 # An auto-layout table treats a declared width as a suggestion and shrinks the 1893 # An auto-layout table treats a declared width as a suggestion and shrinks the
1823 # column to its content, which moved the body's left edge whenever a label 1894 # column to its content, which moved the body's left edge whenever a label
1824 # changed. The floor is what keeps the column fixed. 1895 # changed. The floor is what keeps the column fixed.
1825 assert_file_contains "$nav_sb/.dist/test.html" 'min-width: var(--page-nav-width)'
1896 # "flex: 0 0 <basis>" is the whole statement now: neither grow nor shrink, so
1897 # the width/min-width/max-width trio the table version needed collapses.
1898 assert_file_contains "$nav_sb/.dist/test.html" 'flex: 0 0 var(--page-nav-width)'
1826 1899
1827 it "the sidebar carries no positioning offset" 1900 it "the sidebar carries no positioning offset"
1828 # The nav used to be positioned absolutely and pushed down by a measured 1901 # The nav used to be positioned absolutely and pushed down by a measured
it "the body starts at the left margin, with no column reserved"
1838 # full measure -- on a 375px phone the reserved column cost a quarter of the 1911 # full measure -- on a 375px phone the reserved column cost a quarter of the
1839 # line, 42 characters down to 32. 1912 # line, 42 characters down to 32.
1840 assert_file_not_contains "$nav_sb/.dist/plain.html" 'home-spacer' 1913 assert_file_not_contains "$nav_sb/.dist/plain.html" 'home-spacer'
1841 assert_file_contains "$nav_sb/.dist/plain.html" '.home { display: block; margin-top: 2.5em;'
1914 # The spacing moved from .home to the footer row that now holds it.
1915 assert_file_contains "$nav_sb/.dist/plain.html" '.site-footer { display: flex; align-items: baseline;'
1842 1916
1843 it "a sidebar widens the article rather than narrowing the body" 1917 it "a sidebar widens the article rather than narrowing the body"
1844 # 74ch is sized for a post: arrow column plus a 60ch body. Letting the sidebar 1918 # 74ch is sized for a post: arrow column plus a 60ch body. Letting the sidebar
it "narrow screens collapse the whole table, not just one cell"
1905 # A lone cell reverting to block still gets an anonymous table wrapper and 1979 # A lone cell reverting to block still gets an anonymous table wrapper and
1906 # stays in its column, so all three have to leave table layout together. 1980 # stays in its column, so all three have to leave table layout together.
1907 narrow=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/test.html") 1981 narrow=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$nav_sb/.dist/test.html")
1908 assert_contains "$narrow" '.page-wrapper,'
1909 assert_contains "$narrow" '.page-nav-wrap,'
1910 assert_contains "$narrow" '.contents-cell { display: block'
1982 # One rule undoes the row; a flex container reverting to block takes its
1983 # children with it, so the cells need no rules of their own.
1984 assert_contains "$narrow" '.page-wrapper { display: block; }'
1985 assert_contains "$narrow" '.page-nav-wrap { flex: none; }'
1911 1986
1912 it "the narrow sidebar releases its fixed column" 1987 it "the narrow sidebar releases its fixed column"
1913 # All three of width, min-width and max-width. The ceiling was the one left 1988 # All three of width, min-width and max-width. The ceiling was the one left
it "a page title keeps a gap above its body"
2050 # ".heading-page .title { margin: 0 }" zeroed the base rule's bottom margin so 2125 # ".heading-page .title { margin: 0 }" zeroed the base rule's bottom margin so
2051 # the title could sit in a flex row; the title then touched the first 2126 # the title could sit in a flex row; the title then touched the first
2052 # paragraph, 0px where a post has 1em. 2127 # paragraph, 0px where a post has 1em.
2053 assert_file_contains "$ts/.dist/plain.html" '.heading-page .title { display: inline-block; margin: 0 0 1em;'
2128 # A page heading is a title and nothing else -- no stamp, no arrow -- so it
2129 # needs no row of its own. The bottom margin is what matters here: zeroing it
2130 # once left a page title touching its first paragraph.
2131 assert_file_contains "$ts/.dist/plain.html" '.heading-page .title { display: block; margin: 0 0 1em;'
2054 2132
2055 it "narrow screens need no rule for the arrow column" 2133 it "narrow screens need no rule for the arrow column"
2056 # There is no column to drop: the back link sits below the text at every 2134 # There is no column to drop: the back link sits below the text at every
printf 'Link & Text <foo>\t/x\n' > "$tfn/navs/site-footer.tsv"
2127 out=$(render_footer_nav_in "$tfn/navs" "..") 2205 out=$(render_footer_nav_in "$tfn/navs" "..")
2128 assert_contains "$out" 'Link &amp; Text &lt;foo&gt;' 2206 assert_contains "$out" 'Link &amp; Text &lt;foo&gt;'
2129 2207
2130 it "a missing template nav file is silently skipped"
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.
2131 rm -f "$tfn/navs/site-footer.tsv" 2211 rm -f "$tfn/navs/site-footer.tsv"
2132 out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$? 2212 out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$?
2133 assert_eq "$st" "0" 2213 assert_eq "$st" "0"
2134 assert_eq "$out" ""
2214 assert_contains "$out" '<footer class="site-footer">'
2215 assert_not_contains "$out" 'footer-link'
2135 2216
2136 suite "CSS variables completeness" 2217 suite "CSS variables completeness"
2137 2218

Raw

commit 3b08ad0f5c7201e700b73be0d02764d68d40a68d
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 14:58:22 2026 +0800

    Relative time on posts, footer nav inline, page layout on flexbox
    
    Three things.
    
    A post's date now does what the index's does: hover swaps it for a
    relative time, computed in the reader's browser because a build-time
    string is stale the moment it is served. The script moved into helpers.sh
    and both layouts call it rather than carrying a copy -- two definitions of
    "1 day ago" would drift apart. The rules are the index's, rescoped: there
    the hover target is .post-link, here it is the heading's own anchor. The
    gutter's hidden copy of the stamp is deliberately left out of all of it,
    since a relative string would measure differently from the date it stands
    in for.
    
    The footer nav renders again, on the same line as the way back. It had
    been written, tested and never called since we dropped the footer. The
    back link now sits inside that row and comes first -- it is the link every
    page has and the one a reader is most likely to want -- with the site
    links following at one even gap. Nothing is pushed to the far end: the row
    reads as a list flowing from the left margin. A blog with no
    navs/site-footer.tsv still gets its way back, exactly where it was before
    this existed.
    
    The page layout is flexbox, matching the post layout. It was still
    display: table for the sidebar split -- two mechanisms for the same
    two-column problem. The conversion is pixel-for-pixel: 21 measurements
    across seven widths and three page types, all identical to the table
    version, including the 736px switchover and the nav-to-title alignment.
    It also removes the title's vestigial display: table-cell, inherited from
    a layout where the title sits beside a stamp; a page has no stamp, so the
    cell was doing nothing but colliding with the rule below it at equal
    specificity, which a comment had been documenting rather than fixing.
    
    Tests: the post's stamp and its rescoped hover, the shared script, the
    gutter's exemption, the footer row and its ordering, the no-nav case, and
    the flex rules replacing the table ones. Six existing tests pinned the old
    table declarations and the standalone back link.
    
    Checked by mutation. The first attempt at the relative-time mutation was
    ineffective -- it edited a printf format string without changing the
    rendered output, so nothing failed and nothing should have. Mutating the
    script call and the stamp markup instead: both 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/index.sh b/share/jenny/layout/index.sh
index 97599be..6e3c218 100755
--- a/share/jenny/layout/index.sh
+++ b/share/jenny/layout/index.sh
@@ -1,6 +1,10 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
+
+# Sourced for relative_time_script, the hover-swap stamp shared with the other
+# layouts. JENNY_LIB_DIR is exported by bin/jenny.
+source "${JENNY_LIB_DIR}/helpers.sh"
 # Break apart the LIST payload.
 #
 # Records are delimited by JENNY_RS and their fields by JENNY_FS. Nothing is
@@ -259,90 +263,7 @@ cat << _EOF_
       $(index_loop)
     </ul>
     $(nav)
-    <script>
-      // Fills each stamp's second line with a relative time, computed in the
-      // reader's browser -- a build-time string would be stale the moment it
-      // was served. Without script the absolute date simply stays put, and
-      // the hover swap never arms, because it keys off [data-rel].
-      //
-      // Units floor: 25 hours is "1 day ago", not "2 days".
-      (function () {
-        var UNITS = [
-          [31536000, 'year'],
-          [2592000,  'month'],
-          [604800,   'week'],
-          [86400,    'day'],
-          [3600,     'hour'],
-          [60,       'minute']
-        ];
-        function relative(then, now) {
-          var secs = Math.floor((now - then) / 1000);
-          if (secs < 0) return null;          // scheduled or clock skew
-          if (secs < 60) return 'just now';
-          for (var i = 0; i < UNITS.length; i++) {
-            var size = UNITS[i][0], name = UNITS[i][1];
-            if (secs >= size) {
-              var n = Math.floor(secs / size);
-              return n + ' ' + name + (n === 1 ? '' : 's') + ' ago';
-            }
-          }
-          return null;
-        }
-        var now = Date.now();
-        var stamps = document.querySelectorAll('.stamp[data-iso]');
-        for (var i = 0; i < stamps.length; i++) {
-          var el = stamps[i];
-          var when = Date.parse(el.getAttribute('data-iso'));
-          if (isNaN(when)) continue;
-          var text = relative(when, now);
-          if (!text) continue;
-          var slot = el.querySelector('.stamp-rel');
-          if (!slot) continue;
-          // Two copies, so the tail of one is followed by the head of the
-          // next and the loop point is seamless.
-          slot.textContent = '';
-          var a = document.createElement('span');
-          var b = document.createElement('span');
-          a.className = b.className = 'stamp-run';
-          a.textContent = b.textContent = text;
-          b.setAttribute('aria-hidden', 'true');
-          slot.appendChild(a);
-          slot.appendChild(b);
-          el.setAttribute('data-rel', '');   // arms the hover rule
-          // The tooltip carries the exact timestamp with its zone. The
-          // relative string is already on screen while hovering, so
-          // repeating it there would say nothing new.
-          try {
-            el.title = new Date(when).toLocaleString(undefined, {
-              dateStyle: 'full',
-              timeStyle: 'long'
-            });
-          } catch (e) {
-            el.title = el.getAttribute('data-iso');
-          }
-
-          // Always marquee, even when the string appears to fit. A few pixels
-          // of overflow are invisible at rest, so a string that is only just
-          // too wide would be silently clipped; travelling regardless proves
-          // the whole thing has been shown. Strings that genuinely fit travel
-          // a small fixed distance rather than nothing, so the behaviour is
-          // uniform across the column.
-          var mask = el.querySelector('.stamp-slide');
-          if (mask) {
-            // Scroll by exactly one copy plus the gap between them, so the
-            // second copy lands where the first started. Every stamp runs,
-            // whether or not it overflows: the motion is what shows the whole
-            // string has been seen.
-            var one = a.getBoundingClientRect().width;
-            var gap = parseFloat(getComputedStyle(slot).columnGap) || 0;
-            slot.style.setProperty('--marquee', '-' + (one + gap) + 'px');
-            // Longer strings take proportionally longer, so the speed is even.
-            slot.style.setProperty('--marquee-time', ((one + gap) / 22) + 's');
-            el.setAttribute('data-overflow', '');
-          }
-        }
-      })();
-    </script>
+    $(relative_time_script)
   </body>
 </html>
 _EOF_
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index e079730..2bbc7fd 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -179,20 +179,30 @@ crumbs() {
 # ask for a nav no page declared. Call this, or write your own, to bring one
 # back.
 render_footer_nav() {
-  local lines
-  lines=$(read_nav "${NAVS_DIR}" "site-footer" --optional) || return 0
-  [ -n "$lines" ] || return 0
+  local lines back
+  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.
   printf '<footer class="site-footer">\n'
-  # A footer is a flat row of links, so sections and depth are read and
-  # deliberately dropped rather than rendered. A nav carries structure; what
-  # to do with it is each template's decision, and this one flattens.
-  printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do
-    [ "$kind" = "entry" ] || continue
-    [ -n "$label" ] || continue
-    printf '  <a class="footer-link" href="%s%s">%s</a>\n' \
-      "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
-  done
+  # 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"
+  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
+    # deliberately dropped rather than rendered. A nav carries structure; what
+    # to do with it is each template's decision, and this one flattens.
+    printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do
+      [ "$kind" = "entry" ] || continue
+      [ -n "$label" ] || continue
+      printf '    <a class="footer-link" href="%s%s">%s</a>\n' \
+        "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
+    done
+    printf '  </span>\n'
+  fi
   printf '</footer>\n'
 }
 
@@ -415,36 +425,39 @@ cat << _EOF_
            underline, so the gap is adjustable. */
         header a.current { border-bottom: 1px solid var(--border-breadcrumb); padding-bottom: .125em; }
         .heading a { text-decoration: none; }
-        .heading .title { color: var(--text-heading); display: table-cell; margin: 0 0 1em; font-weight:bold; max-width: 500px;  }
+        /* No display here. It was table-cell, inherited from the post layout
+           where the title sits beside a stamp; a page has no stamp, so the
+           cell was doing nothing but colliding with the rule below at equal
+           specificity. Dropping it removes the collision rather than working
+           around it. */
+        .heading .title { color: var(--text-heading); margin: 0 0 1em; font-weight: bold; max-width: 500px; }
         .heading .title:hover { text-decoration: underline; }
-        /* Block with inline-block children, not flex: flex items do not wrap,
-           so the heading spacer held its column at every width while the
-           body's spacer -- an inline-block beside an inline-block -- let the
-           contents wrap under it below about 700px. The title stayed indented
-           97px above a body that had returned to 0, which on a phone reads as
-           the title pushed off to the right of its own text. Sharing the
-           body's mechanism is what makes the two reflow at the same width.
-
-           These sit after ".heading .title" deliberately. That rule sets
-           display: table-cell at the same specificity as this one, so source
-           order decides, and a table-cell takes its own row -- the title
-           dropped below the spacer at every width. The previous version used
-           display: flex on the parent, which sidestepped the collision only
-           because flex overrides a child's display outright. */
+        /* A page heading is a title and nothing else -- no stamp, no arrow --
+           so it needs no row of its own. The bottom margin is kept: zeroing it
+           once left a page title touching its first paragraph, 0px of gap
+           where a post has 16px. */
         .heading-page { display: block; margin-left: 0; }
-        /* Keeps the base rule's bottom margin. An earlier version zeroed the
-           whole margin so the title could sit in a flex row, which left a
-           page title touching its first paragraph -- 0px of gap where a post
-           has 16px. The side margins still go, since the column comes from
-           the spacer beside it. */
-        .heading-page .title { display: inline-block; margin: 0 0 1em;
-                               vertical-align: top; max-width: 100%; }
+        .heading-page .title { display: block; margin: 0 0 1em; max-width: 100%; }
         .heading .stamp { display: inline-block; text-align: right; margin-right: 1.5em; }
         /* Identical to the post layout's: the way back sits after the text, so
            posts and pages end the same way. Held in a column before the body
            it wrapped onto its own line on a phone, leaving a stray arrow
            between the title and the first paragraph. */
-        .home { display: block; margin-top: 2.5em; text-decoration: none;
+        /* The way back and the site links share one row at the end of the
+           text, all of it flowing from the left edge with one gap between
+           every item. Nothing is pushed to the far end: the row reads as a
+           list, and with no navs/site-footer.tsv present it holds only the
+           back link, exactly where it sat before this existed.
+
+           The spacing that used to be on .home lives here now, on the row, so
+           the two do not each carry their own idea of the gap. */
+        .site-footer { display: flex; align-items: baseline; flex-wrap: wrap;
+                       gap: 1.5em; margin-top: 2.5em; }
+        .footer-links { display: flex; flex-wrap: wrap; gap: 1.5em; }
+        .footer-link { font-size: .85em; text-transform: uppercase;
+                       text-decoration: none; color: var(--text-muted); }
+        .footer-link:hover { color: var(--link-home-hover); }
+        .home { text-decoration: none;
                 font-size: .85em; text-transform: uppercase;
                 color: var(--link-home); }
         .home:hover { color: var(--link-home-hover); }
@@ -461,9 +474,9 @@ cat << _EOF_
            block. The <details> only does anything on a phone -- here the
            summary is hidden and the element is forced open, so a wide screen
            renders exactly the sidebar it did before. */
-        .page-nav-wrap { display: table-cell; vertical-align: top;
-                         width: var(--page-nav-width);
-                         min-width: var(--page-nav-width);
+        /* flex: 0 0 the column, so it neither grows nor shrinks; the width
+           trio the table version needed collapses into the basis. */
+        .page-nav-wrap { flex: 0 0 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
@@ -525,14 +538,17 @@ cat << _EOF_
            instead of subtracting from the body. There is room: the body column
            tops out at 60ch and the viewport is wider than nav plus body
            wherever this layout applies. */
-        .page-wrapper { display: table; table-layout: auto; width: auto; }
-        .contents-cell { display: table-cell; vertical-align: top;
+        .page-wrapper { display: flex; align-items: flex-start; }
+        /* min-width: 0 because a flex item will not shrink below its content
+           otherwise -- a wide table or a long word inside the body would set
+           the floor and push the page sideways instead of scrolling in its own
+           box. The table version got this for free. */
+        .contents-cell { flex: 1 1 auto; min-width: 0;
                          padding-left: var(--page-nav-gap); }
         /* With no nav there is no sidebar to sit beside, so the body starts at
            the left margin -- the same place a post's body starts now that the
            way back is below the text rather than beside it. */
-        .page-wrapper:not(:has(.page-nav-wrap)) .contents-cell { display: block;
-                                                            padding-left: 0; }
+        .page-wrapper:not(:has(.page-nav-wrap)) .contents-cell { padding-left: 0; }
         /* Identical to the post layout's. A block, not an inline-block: the
            body no longer sits beside anything -- the back arrow that used to
            share its line is below the text now -- and min-width: 0 lets it
@@ -545,9 +561,8 @@ cat << _EOF_
            table layout together -- a lone cell reverting to block still gets
            an anonymous table wrapper and stays in its column. */
         @media (max-width: 735px) {
-          .page-wrapper,
-          .page-nav-wrap,
-          .contents-cell { display: block; }
+          .page-wrapper { display: block; }
+          .page-nav-wrap { flex: none; }
           /* max-width was the reason labels wrapped at a 11px indent on a
              phone: the narrow block reset width and min-width but left the
              6rem ceiling in place, so the nav stayed 96px wide while the rest
@@ -791,7 +806,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>
-        <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
+        $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">&#x2190;&#xFE0E; Index</a>')
         </div>
           </div>
         </div>
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 46bd819..121c4ab 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -1,6 +1,10 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
+
+# Sourced for relative_time_script, the hover-swap stamp shared with the other
+# layouts. JENNY_LIB_DIR is exported by bin/jenny.
+source "${JENNY_LIB_DIR}/helpers.sh"
 # diagram_script
 #
 # Emitted only when the rendered page actually contains a diagram. The
@@ -58,6 +62,34 @@ diagram_script() {
 	_DIAGRAM_
 }
 
+render_footer_nav() {
+  local lines back
+  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.
+  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"
+  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
+    # deliberately dropped rather than rendered. A nav carries structure; what
+    # to do with it is each template's decision, and this one flattens.
+    printf '%s\n' "$lines" | while IFS="$(printf '\t')" read -r kind depth label path; do
+      [ "$kind" = "entry" ] || continue
+      [ -n "$label" ] || continue
+      printf '    <a class="footer-link" href="%s%s">%s</a>\n' \
+        "$URL_PREFIX" "$(html_escape "$path")" "$(html_escape "$label")"
+    done
+    printf '  </span>\n'
+  fi
+  printf '</footer>\n'
+}
+
 html_escape() {
   local s=$1
   # The replacement text must escape &, because bash >= 5.2 expands a bare &
@@ -305,6 +337,41 @@ cat << _EOF_
                           padding-right: 1.5em; text-align: right;
                           color: var(--text-muted); }
         .heading .title { flex: 1 1 auto; }
+        /* The same hover-swap stamp the index uses: the absolute date and,
+           once script supplies it, a relative one in the same box. The rules
+           are the index's, rescoped -- there the hover target is .post-link,
+           here it is the heading's own anchor.
+           
+           The gutter's copy of the stamp is left out of all of this. It is a
+           hidden element whose only job is to be the same width as this one,
+           and a second stamp filled with a relative string would measure
+           differently from the date it is standing in for. */
+        .heading .stamp-slide { display: block; position: relative;
+                                overflow: hidden; height: 1.6em;
+                                line-height: 1.6em; }
+        .heading .stamp-abs,
+        .heading .stamp-rel { display: block; height: 1.6em; line-height: 1.6em;
+                              white-space: nowrap; }
+        .heading .stamp-rel { position: absolute; top: 0; left: 0;
+                              visibility: hidden; display: flex;
+                              column-gap: .6em; width: max-content; }
+        .heading .stamp-run { flex: 0 0 auto; }
+        @media (hover: hover) {
+          .heading a:hover .stamp[data-rel] .stamp-abs { visibility: hidden; }
+          .heading a:hover .stamp[data-rel] .stamp-rel { visibility: visible; }
+          .heading a:hover .stamp[data-rel][data-overflow] .stamp-rel {
+            animation: stamp-marquee var(--marquee-time, 3s) linear .35s infinite;
+          }
+        }
+        /* Two copies scrolled by exactly one copy plus its gap, so the second
+           lands where the first began and the loop is seamless. */
+        @keyframes stamp-marquee {
+          from { transform: translateX(0); }
+          to   { transform: translateX(var(--marquee, 0)); }
+        }
+        @media (prefers-reduced-motion: reduce) {
+          .heading .stamp-rel { animation: none !important; }
+        }
         /* 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
@@ -362,7 +429,21 @@ cat << _EOF_
            already carries the site title two lines above -- and is set smaller
            and in caps, the way the theme marks its other small labels, because
            it is an exit rather than part of the reading. */
-        .home { display: block; margin-top: 2.5em; text-decoration: none;
+        /* The way back and the site links share one row at the end of the
+           text, all of it flowing from the left edge with one gap between
+           every item. Nothing is pushed to the far end: the row reads as a
+           list, and with no navs/site-footer.tsv present it holds only the
+           back link, exactly where it sat before this existed.
+
+           The spacing that used to be on .home lives here now, on the row, so
+           the two do not each carry their own idea of the gap. */
+        .site-footer { display: flex; align-items: baseline; flex-wrap: wrap;
+                       gap: 1.5em; margin-top: 2.5em; }
+        .footer-links { display: flex; flex-wrap: wrap; gap: 1.5em; }
+        .footer-link { font-size: .85em; text-transform: uppercase;
+                       text-decoration: none; color: var(--text-muted); }
+        .footer-link:hover { color: var(--link-home-hover); }
+        .home { text-decoration: none;
                 font-size: .85em; text-transform: uppercase;
                 color: var(--link-home); }
         .home:hover { color: var(--link-home-hover); }
@@ -499,17 +580,18 @@ cat << _EOF_
       <a href="#" class="theme-toggle" aria-label="Toggle dark mode">&#x263C;&#xFE0E;</a>
       <header><a href="$URL_PREFIX/">$(html_escape "${BLOG_TITLE:-}")</a></header>
       <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="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">
         <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>
-        <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
+        $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">&#x2190;&#xFE0E; Index</a>')
         </div>
       </div>
       </article>
 $(diagram_script)
+$([ -z "${IS_PAGE:-}" ] && relative_time_script)
     </body>
   </html>
 _EOF_
diff --git a/share/jenny/lib/helpers.sh b/share/jenny/lib/helpers.sh
index 7d0bd61..e4d8ec5 100644
--- a/share/jenny/lib/helpers.sh
+++ b/share/jenny/lib/helpers.sh
@@ -438,6 +438,111 @@ function sweep_orphans() {
   mv "$kept" "$BLOG_LOCK"
 }
 
+# ------------------------------------------------------- relative timestamps
+
+# relative_time_script
+# Emits the <script> that fills every .stamp[data-iso] on the page with a
+# relative time and arms the hover swap.
+#
+# Shared rather than copied because two layouts want it: the index, where it
+# started, and a post's own heading. The script keys off the markup alone --
+# any .stamp carrying data-iso and a .stamp-rel slot -- so a layout opts in by
+# emitting that structure and calling this. Copying it would have meant two
+# definitions of "1 day ago" drifting apart.
+#
+# The relative string is computed in the reader's browser: a build-time string
+# would be stale the moment it was served. Without script the absolute date
+# stays put and the hover swap never arms, because the CSS keys off [data-rel]
+# which only this sets.
+function relative_time_script() {
+  cat <<'_RELTIME_'
+    <script>
+      // Fills each stamp's second line with a relative time, computed in the
+      // reader's browser -- a build-time string would be stale the moment it
+      // was served. Without script the absolute date simply stays put, and
+      // the hover swap never arms, because it keys off [data-rel].
+      //
+      // Units floor: 25 hours is "1 day ago", not "2 days".
+      (function () {
+        var UNITS = [
+          [31536000, 'year'],
+          [2592000,  'month'],
+          [604800,   'week'],
+          [86400,    'day'],
+          [3600,     'hour'],
+          [60,       'minute']
+        ];
+        function relative(then, now) {
+          var secs = Math.floor((now - then) / 1000);
+          if (secs < 0) return null;          // scheduled or clock skew
+          if (secs < 60) return 'just now';
+          for (var i = 0; i < UNITS.length; i++) {
+            var size = UNITS[i][0], name = UNITS[i][1];
+            if (secs >= size) {
+              var n = Math.floor(secs / size);
+              return n + ' ' + name + (n === 1 ? '' : 's') + ' ago';
+            }
+          }
+          return null;
+        }
+        var now = Date.now();
+        var stamps = document.querySelectorAll('.stamp[data-iso]');
+        for (var i = 0; i < stamps.length; i++) {
+          var el = stamps[i];
+          var when = Date.parse(el.getAttribute('data-iso'));
+          if (isNaN(when)) continue;
+          var text = relative(when, now);
+          if (!text) continue;
+          var slot = el.querySelector('.stamp-rel');
+          if (!slot) continue;
+          // Two copies, so the tail of one is followed by the head of the
+          // next and the loop point is seamless.
+          slot.textContent = '';
+          var a = document.createElement('span');
+          var b = document.createElement('span');
+          a.className = b.className = 'stamp-run';
+          a.textContent = b.textContent = text;
+          b.setAttribute('aria-hidden', 'true');
+          slot.appendChild(a);
+          slot.appendChild(b);
+          el.setAttribute('data-rel', '');   // arms the hover rule
+          // The tooltip carries the exact timestamp with its zone. The
+          // relative string is already on screen while hovering, so
+          // repeating it there would say nothing new.
+          try {
+            el.title = new Date(when).toLocaleString(undefined, {
+              dateStyle: 'full',
+              timeStyle: 'long'
+            });
+          } catch (e) {
+            el.title = el.getAttribute('data-iso');
+          }
+
+          // Always marquee, even when the string appears to fit. A few pixels
+          // of overflow are invisible at rest, so a string that is only just
+          // too wide would be silently clipped; travelling regardless proves
+          // the whole thing has been shown. Strings that genuinely fit travel
+          // a small fixed distance rather than nothing, so the behaviour is
+          // uniform across the column.
+          var mask = el.querySelector('.stamp-slide');
+          if (mask) {
+            // Scroll by exactly one copy plus the gap between them, so the
+            // second copy lands where the first started. Every stamp runs,
+            // whether or not it overflows: the motion is what shows the whole
+            // string has been seen.
+            var one = a.getBoundingClientRect().width;
+            var gap = parseFloat(getComputedStyle(slot).columnGap) || 0;
+            slot.style.setProperty('--marquee', '-' + (one + gap) + 'px');
+            // Longer strings take proportionally longer, so the speed is even.
+            slot.style.setProperty('--marquee-time', ((one + gap) / 22) + 's');
+            el.setAttribute('data-overflow', '');
+          }
+        }
+      })();
+    </script>
+_RELTIME_
+}
+
 # ---------------------------------------------------------------- tag index
 
 # generate_tag_index <dist> <tags_temp_dir>
diff --git a/test/regressions.sh b/test/regressions.sh
index f154f68..729aec1 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -913,6 +913,50 @@ assert_file_contains "$tl_post" '.citations ol { padding-left: 1.4em; margin-top
 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
+# this the back link stood alone and the footer nav was never rendered at all.
+
+fr="$TMPROOT/footerrow"
+make_blog_min "$fr" 1 10
+mkdir -p "$fr/pages" "$fr/navs"
+printf '# Plain\n\nBody.\n' > "$fr/pages/plain.md"
+printf '### Site\nAbout\t/about\nFeed\t/feed.xml\n' > "$fr/navs/site-footer.tsv"
+run_jenny_split "$fr"
+fr_post=$(find "$fr/.dist/post" -name '*.html' | head -1)
+
+it "the site links render beside the way back"
+assert_file_contains "$fr_post" '<footer class="site-footer">'
+assert_file_contains "$fr_post" 'class="footer-link"'
+
+it "the way back comes first"
+# It is the link every page has and the one a reader is most likely to want.
+home_line=$("$GREP" -n 'class="home"' "$fr_post" | head -1 | cut -d: -f1)
+link_line=$("$GREP" -n 'class="footer-link"' "$fr_post" | head -1 | cut -d: -f1)
+[ "$home_line" -lt "$link_line" ] \
+  && pass "back link precedes the site links" \
+  || fail "back link at $home_line should precede site links at $link_line"
+
+it "the row flows from the left with one gap between items"
+# Nothing is pushed to the far end: the row reads as a list.
+assert_file_contains "$fr_post" '.site-footer { display: flex; align-items: baseline;'
+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"
+assert_file_contains "$fr/.dist/plain.html" 'class="footer-link"'
+
+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.
+rm "$fr/navs/site-footer.tsv"
+run_jenny_split "$fr"
+assert_file_contains "$fr_post" 'class="home"'
+assert_file_not_contains "$fr_post" 'class="footer-link"'
+
+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
@@ -1333,8 +1377,32 @@ it "the date attribute parses as a date"
 iso=$(grep -o 'data-iso="[^"]*"' "$rt/.dist/index.html" | head -1 | sed 's/data-iso="//;s/"//')
 if date -d "$iso" >/dev/null 2>&1; then pass; else fail "unparseable: $iso"; fi
 
-it "a post page needs no relative-time script"
-assert_file_not_contains "$rt/.dist/post/post-01.html" 'data-iso'
+it "a post carries the relative-time script too"
+# The same hover-swap stamp the index has. The script is shared rather than
+# copied: two definitions of "1 day ago" would drift apart.
+assert_file_contains "$rt/.dist/post/post-01.html" 'data-iso'
+assert_file_contains "$rt/.dist/post/post-01.html" '<span class="stamp-rel"'
+assert_file_contains "$rt/.dist/post/post-01.html" "stamp[data-iso]"
+
+it "the hover target is the heading's own anchor"
+# On the index the swap keys off .post-link; a post has no such wrapper, so
+# the rules are rescoped to the anchor that holds the stamp and the title.
+assert_file_contains "$rt/.dist/post/post-01.html" '.heading a:hover .stamp[data-rel] .stamp-abs'
+
+it "the gutter's copy of the stamp is left alone"
+# It is a hidden element whose only job is to be the width of the real stamp.
+# Filled with a relative string it would measure differently from the date it
+# stands in for.
+gutter=$("$GREP" -o '<div class="gutter"[^>]*>[^<]*<span class="stamp"[^>]*>' "$rt/.dist/post/post-01.html" | head -1)
+assert_not_contains "$gutter" 'data-iso'
+
+it "a page has no stamp to animate"
+# Pages carry no date, so neither the markup nor the script is emitted.
+mkdir -p "$rt/pages"
+printf '# Plain\n\nNo date here.\n' > "$rt/pages/plain.md"
+run_jenny "$rt"
+assert_file_not_contains "$rt/.dist/plain.html" 'data-iso'
+assert_file_not_contains "$rt/.dist/plain.html" "stamp[data-iso]"
 
 suite "pages/ mirrors its path to the URL"
 
@@ -1808,21 +1876,26 @@ it "the sidebar and the body are cells of one table row"
 # row, so the browser aligns them. Asserting the row is asserting the
 # alignment -- an offset-based version would satisfy a "tops match" check at
 # one heading height and drift at every other.
-assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: table'
-assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { display: table-cell'
+# Flexbox, matching the post layout: one mechanism for the same two-column
+# problem rather than tables here and flex there.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: flex; align-items: flex-start; }'
+assert_file_contains "$nav_sb/.dist/test.html" '.contents-cell { flex: 1 1 auto; min-width: 0;'
 
 it "the sidebar is a table cell aligned to the top of its row"
 # The cell is the <details> wrapper now: the nav is collapsible on a phone, so
 # the sidebar column belongs to the element that holds the summary as well as
 # the list.
-nav_rule=$("$SED" -n '/^        \.page-nav-wrap { display: table-cell;/,/}/p' "$nav_sb/.dist/test.html")
-assert_contains "$nav_rule" 'vertical-align: top'
+# align-items: flex-start on the row does what vertical-align: top did on the
+# cell -- the sidebar sits at the top rather than centring against the body.
+assert_file_contains "$nav_sb/.dist/test.html" '.page-wrapper { display: flex; align-items: flex-start; }'
 
 it "the sidebar column does not depend on its labels"
 # An auto-layout table treats a declared width as a suggestion and shrinks the
 # column to its content, which moved the body's left edge whenever a label
 # changed. The floor is what keeps the column fixed.
-assert_file_contains "$nav_sb/.dist/test.html" 'min-width: var(--page-nav-width)'
+# "flex: 0 0 <basis>" is the whole statement now: neither grow nor shrink, so
+# the width/min-width/max-width trio the table version needed collapses.
+assert_file_contains "$nav_sb/.dist/test.html" 'flex: 0 0 var(--page-nav-width)'
 
 it "the sidebar carries no positioning offset"
 # The nav used to be positioned absolutely and pushed down by a measured
@@ -1838,7 +1911,8 @@ it "the body starts at the left margin, with no column reserved"
 # full measure -- on a 375px phone the reserved column cost a quarter of the
 # line, 42 characters down to 32.
 assert_file_not_contains "$nav_sb/.dist/plain.html" 'home-spacer'
-assert_file_contains "$nav_sb/.dist/plain.html" '.home { display: block; margin-top: 2.5em;'
+# The spacing moved from .home to the footer row that now holds it.
+assert_file_contains "$nav_sb/.dist/plain.html" '.site-footer { display: flex; align-items: baseline;'
 
 it "a sidebar widens the article rather than narrowing the body"
 # 74ch is sized for a post: arrow column plus a 60ch body. Letting the sidebar
@@ -1905,9 +1979,10 @@ it "narrow screens collapse the whole table, not just one cell"
 # A lone cell reverting to block still gets an anonymous table wrapper and
 # stays in its column, so all three have to leave table layout together.
 narrow=$("$SED" -n '/@media (max-width: 735px)/,/^        }/p' "$nav_sb/.dist/test.html")
-assert_contains "$narrow" '.page-wrapper,'
-assert_contains "$narrow" '.page-nav-wrap,'
-assert_contains "$narrow" '.contents-cell { display: block'
+# One rule undoes the row; a flex container reverting to block takes its
+# children with it, so the cells need no rules of their own.
+assert_contains "$narrow" '.page-wrapper { display: block; }'
+assert_contains "$narrow" '.page-nav-wrap { flex: none; }'
 
 it "the narrow sidebar releases its fixed column"
 # All three of width, min-width and max-width. The ceiling was the one left
@@ -2050,7 +2125,10 @@ it "a page title keeps a gap above its body"
 # ".heading-page .title { margin: 0 }" zeroed the base rule's bottom margin so
 # the title could sit in a flex row; the title then touched the first
 # paragraph, 0px where a post has 1em.
-assert_file_contains "$ts/.dist/plain.html" '.heading-page .title { display: inline-block; margin: 0 0 1em;'
+# A page heading is a title and nothing else -- no stamp, no arrow -- so it
+# needs no row of its own. The bottom margin is what matters here: zeroing it
+# once left a page title touching its first paragraph.
+assert_file_contains "$ts/.dist/plain.html" '.heading-page .title { display: block; margin: 0 0 1em;'
 
 it "narrow screens need no rule for the arrow column"
 # There is no column to drop: the back link sits below the text at every
@@ -2127,11 +2205,14 @@ 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 is silently skipped"
+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.
 rm -f "$tfn/navs/site-footer.tsv"
 out=$(render_footer_nav_in "$tfn/navs" ".." 2>/dev/null); st=$?
 assert_eq "$st" "0"
-assert_eq "$out" ""
+assert_contains "$out" '<footer class="site-footer">'
+assert_not_contains "$out" 'footer-link'
 
 suite "CSS variables completeness"