Commit 3b08ad0 h
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">←︎ Index</a> |
||
| 809 | $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">←︎ 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">☼︎</a> |
580 | <a href="#" class="theme-toggle" aria-label="Toggle dark mode">☼︎</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">←︎ Index</a> |
||
| 589 | $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">←︎ 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 & Text <foo>' |
2206 | assert_contains "$out" 'Link & Text <foo>' |
| 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">←︎ Index</a>
+ $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">←︎ 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">☼︎</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">←︎ Index</a>
+ $(render_footer_nav '<a href="'"$URL_PREFIX"'/" class="home">←︎ 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 & Text <foo>'
-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"