Commit 5ca8f3d h
Changes: 3 files +123 -13
Changes (Side-by-Side)
share/jenny/layout/page.sh +50 -11
cat << _EOF_ | |||
| 208 | body { |
208 | body { |
| 209 | background-color: var(--bg-primary); |
209 | background-color: var(--bg-primary); |
| 210 | color: var(--text-primary); |
210 | color: var(--text-primary); |
| 211 | font-size: 14px; |
||
| 211 | font-size: 16px; |
||
| 212 | padding: 1em; |
212 | padding: 1em; |
| 213 | font-family: 'Roboto', sans-serif; |
213 | font-family: 'Roboto', sans-serif; |
| 214 | line-height: 1.5em; |
214 | line-height: 1.5em; |
| 215 | } |
215 | } |
| 216 | @media (min-width: 736px) { body { font-size: 16px } } |
||
| 216 | /* One size at every width, as the index has always done. This was |
||
| 217 | 14px below 736px and 16px above, so the same prose was a size |
||
| 218 | smaller on a phone than the index that linked to it -- and 14px is |
||
| 219 | small for body copy on a phone to begin with. There is no reason |
||
| 220 | for a narrow screen to get smaller text: the measure is already |
||
| 221 | handled by the column width, and shrinking the type only buys |
||
| 222 | characters per line that the reader did not ask for. */ |
||
| 217 | article { padding: 0; margin: 1em 0; max-width: 74ch; } |
223 | article { padding: 0; margin: 1em 0; max-width: 74ch; } |
| 218 | /* 74ch is sized for a post: the arrow's column plus a 60ch body. A |
224 | /* 74ch is sized for a post: the arrow's column plus a 60ch body. A |
| 219 | sidebar is an addition to that, so the article grows by exactly the |
225 | sidebar is an addition to that, so the article grows by exactly the |
cat << _EOF_ | |||
| 318 | title sat at the far left while its own body was indented past it. |
324 | title sat at the far left while its own body was indented past it. |
| 319 | The heading reserves the same column the body does, by the same |
325 | The heading reserves the same column the body does, by the same |
| 320 | mechanism, so the two line up whatever the width. */ |
326 | mechanism, so the two line up whatever the width. */ |
| 321 | .heading-page { display: flex; align-items: baseline; margin-left: 0; } |
||
| 322 | .heading-page .title { display: block; margin: 0; flex: 1; } |
||
| 323 | .heading-page .home-spacer { margin-bottom: 0; flex: 0 0 auto; } |
||
| 324 | /* Beside a sidebar the whole cell is already offset, so the heading |
327 | /* Beside a sidebar the whole cell is already offset, so the heading |
| 325 | must not indent again -- the title would land a column right of its |
328 | must not indent again -- the title would land a column right of its |
| 326 | own body. */ |
329 | own body. */ |
cat << _EOF_ | |||
| 337 | .heading a { text-decoration: none; } |
340 | .heading a { text-decoration: none; } |
| 338 | .heading .title { color: var(--text-heading); display: table-cell; margin: 0 0 1em; font-weight:bold; max-width: 500px; } |
341 | .heading .title { color: var(--text-heading); display: table-cell; margin: 0 0 1em; font-weight:bold; max-width: 500px; } |
| 339 | .heading .title:hover { text-decoration: underline; } |
342 | .heading .title:hover { text-decoration: underline; } |
| 343 | /* Block with inline-block children, not flex: flex items do not wrap, |
||
| 344 | so the heading spacer held its column at every width while the |
||
| 345 | body's spacer -- an inline-block beside an inline-block -- let the |
||
| 346 | contents wrap under it below about 700px. The title stayed indented |
||
| 347 | 97px above a body that had returned to 0, which on a phone reads as |
||
| 348 | the title pushed off to the right of its own text. Sharing the |
||
| 349 | body's mechanism is what makes the two reflow at the same width. |
||
| 350 | |
||
| 351 | These sit after ".heading .title" deliberately. That rule sets |
||
| 352 | display: table-cell at the same specificity as this one, so source |
||
| 353 | order decides, and a table-cell takes its own row -- the title |
||
| 354 | dropped below the spacer at every width. The previous version used |
||
| 355 | display: flex on the parent, which sidestepped the collision only |
||
| 356 | because flex overrides a child's display outright. */ |
||
| 357 | .heading-page { display: block; margin-left: 0; } |
||
| 358 | /* Keeps the base rule's bottom margin. An earlier version zeroed the |
||
| 359 | whole margin so the title could sit in a flex row, which left a |
||
| 360 | page title touching its first paragraph -- 0px of gap where a post |
||
| 361 | has 16px. The side margins still go, since the column comes from |
||
| 362 | the spacer beside it. */ |
||
| 363 | .heading-page .title { display: inline-block; margin: 0 0 1em; |
||
| 364 | vertical-align: top; max-width: 100%; } |
||
| 365 | .heading-page .home-spacer { margin-bottom: 0; } |
||
| 340 | .heading .stamp, |
366 | .heading .stamp, |
| 341 | .home { display: inline-block; text-align:right; margin-right: 1.5em; } |
367 | .home { display: inline-block; text-align:right; margin-right: 1.5em; } |
| 342 | .home { text-decoration: none; width: 5.4em; margin-bottom: 1.5em; text-align: left; color: var(--link-home); } .home:hover { color: var(--link-home-hover); } |
368 | .home { text-decoration: none; width: 5.4em; margin-bottom: 1.5em; text-align: left; color: var(--link-home); } .home:hover { color: var(--link-home-hover); } |
cat << _EOF_ | |||
| 436 | /* No indent at this width: 6.82rem plus a 60ch column overflows a |
462 | /* No indent at this width: 6.82rem plus a 60ch column overflows a |
| 437 | phone and forces horizontal scroll. */ |
463 | phone and forces horizontal scroll. */ |
| 438 | .contents-cell { padding-left: 0; } |
464 | .contents-cell { padding-left: 0; } |
| 439 | /* The heading is left alone here. A post has no breakpoint at all -- |
||
| 440 | its heading and body both collapse by wrapping, at the same width, |
||
| 441 | because both are driven by the same inline column. Forcing the |
||
| 442 | heading to block at 735px un-indented the title while the body was |
||
| 443 | still indented, so the two split apart for exactly one step of the |
||
| 444 | range. Letting it wrap on its own keeps them together. */ |
||
| 465 | /* No spacer at this width, in the heading or the body. The indent |
||
| 466 | exists to reserve a post's back-arrow column; on a phone it costs |
||
| 467 | more than it reserves -- at 375px it takes the measure from 42 |
||
| 468 | characters to 32, and at 320px down to 25. |
||
| 469 | |
||
| 470 | The body already wraps under its spacer here, because a 60ch |
||
| 471 | column cannot sit beside it. The heading did not: a two-word |
||
| 472 | title fits beside the spacer at any width, so the title stayed |
||
| 473 | indented 97px above a body that had returned to the left margin. |
||
| 474 | That is the artifact -- a title hanging to the right of its own |
||
| 475 | text on a phone. |
||
| 476 | |
||
| 477 | Removing both is what keeps them together. Matching the body's |
||
| 478 | content-driven turnover instead was tried and does not work: the |
||
| 479 | heading and the body hold different content, so they give out at |
||
| 480 | different widths, and tuning the title's column to compensate only |
||
| 481 | moved the disagreement (a 10px window at 614-622px became a 90px |
||
| 482 | one at 528-612px). */ |
||
| 483 | .home-spacer { display: none; } |
||
| 445 | } |
484 | } |
| 446 | .contents :first-child { margin-top: 0; } |
485 | .contents :first-child { margin-top: 0; } |
| 447 | h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; } |
486 | h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; } |
share/jenny/layout/post.sh +8 -2
cat << _EOF_ | |||
| 105 | body { |
105 | body { |
| 106 | background-color: var(--bg-primary); |
106 | background-color: var(--bg-primary); |
| 107 | color: var(--text-primary); |
107 | color: var(--text-primary); |
| 108 | font-size: 14px; |
||
| 108 | font-size: 16px; |
||
| 109 | padding: 1em; |
109 | padding: 1em; |
| 110 | font-family: 'Roboto', sans-serif; |
110 | font-family: 'Roboto', sans-serif; |
| 111 | line-height: 1.5em; |
111 | line-height: 1.5em; |
| 112 | } |
112 | } |
| 113 | @media (min-width: 736px) { body { font-size: 16px } } |
||
| 113 | /* One size at every width, as the index has always done. This was |
||
| 114 | 14px below 736px and 16px above, so the same prose was a size |
||
| 115 | smaller on a phone than the index that linked to it -- and 14px is |
||
| 116 | small for body copy on a phone to begin with. There is no reason |
||
| 117 | for a narrow screen to get smaller text: the measure is already |
||
| 118 | handled by the column width, and shrinking the type only buys |
||
| 119 | characters per line that the reader did not ask for. */ |
||
| 114 | article { padding: 0; margin: 1em 0; max-width: 74ch; } |
120 | article { padding: 0; margin: 1em 0; max-width: 74ch; } |
| 115 | article a { color: var(--link-article); } |
121 | article a { color: var(--link-article); } |
| 116 | article img { max-width: 100% } |
122 | article img { max-width: 100% } |
test/regressions.sh +65 -0
assert_contains "$narrow" 'min-width: 0' | |||
| 1423 | it "the build is clean for nav sidebar" |
1423 | it "the build is clean for nav sidebar" |
| 1424 | assert_eq "$stderr_output" "" |
1424 | assert_eq "$stderr_output" "" |
| 1425 | |
1425 | |
| 1426 | suite "type size and heading spacing" |
||
| 1427 | |
||
| 1428 | # Reported from an iPhone: body copy was a size smaller on posts and pages |
||
| 1429 | # than on the index, and a page title hung to the right of its own text. |
||
| 1430 | |
||
| 1431 | ts="$TMPROOT/typesize" |
||
| 1432 | make_blog_min "$ts" 2 10 |
||
| 1433 | mkdir -p "$ts/pages" "$ts/navs" |
||
| 1434 | printf 'Item\t/item\n' > "$ts/navs/side.tsv" |
||
| 1435 | printf '# Plain\n\nBody copy long enough to be worth reading on a phone.\n' > "$ts/pages/plain.md" |
||
| 1436 | printf 'Nav: side\n\n# Sidebar\n\nBody copy.\n' > "$ts/pages/sb.md" |
||
| 1437 | run_jenny_split "$ts" |
||
| 1438 | |
||
| 1439 | it "posts do not shrink their body text on narrow screens" |
||
| 1440 | # The index has always been a flat 16px. Posts and pages were 14px below |
||
| 1441 | # 736px, so the same prose was smaller on a phone than the index linking to |
||
| 1442 | # it. Nothing about a narrow screen calls for smaller type -- the measure is |
||
| 1443 | # handled by the column width. |
||
| 1444 | post_file=$(find "$ts/.dist/post" -name '*.html' | head -1) |
||
| 1445 | assert_file_not_contains "$post_file" '@media (min-width: 736px) { body { font-size' |
||
| 1446 | |
||
| 1447 | it "pages do not shrink their body text on narrow screens" |
||
| 1448 | assert_file_not_contains "$ts/.dist/plain.html" '@media (min-width: 736px) { body { font-size' |
||
| 1449 | |
||
| 1450 | it "every layout declares the same base type size" |
||
| 1451 | # Compare the declarations rather than trusting they look alike. |
||
| 1452 | idx_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$ts/.dist/index.html" | head -1) |
||
| 1453 | post_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$post_file" | head -1) |
||
| 1454 | page_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$ts/.dist/plain.html" | head -1) |
||
| 1455 | assert_eq "$post_size" "$idx_size" |
||
| 1456 | assert_eq "$page_size" "$idx_size" |
||
| 1457 | |
||
| 1458 | it "a page title keeps a gap above its body" |
||
| 1459 | # ".heading-page .title { margin: 0 }" zeroed the base rule's bottom margin so |
||
| 1460 | # the title could sit in a flex row; the title then touched the first |
||
| 1461 | # paragraph, 0px where a post has 1em. |
||
| 1462 | assert_file_contains "$ts/.dist/plain.html" '.heading-page .title { display: inline-block; margin: 0 0 1em;' |
||
| 1463 | |
||
| 1464 | it "the arrow column is dropped on narrow screens" |
||
| 1465 | # The indent reserves a post's back-arrow column. On a phone it costs more |
||
| 1466 | # than it reserves -- at 375px it takes the measure from 42 characters to 32 -- |
||
| 1467 | # and the body already wraps to the left margin there while the heading, whose |
||
| 1468 | # title always fits beside the spacer, did not. The title was left hanging |
||
| 1469 | # 97px right of its own text. |
||
| 1470 | narrow_sp=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$ts/.dist/plain.html") |
||
| 1471 | assert_contains "$narrow_sp" '.home-spacer { display: none; }' |
||
| 1472 | |
||
| 1473 | it "the heading is not a flex row" |
||
| 1474 | # Flex items do not wrap, which is why the heading held its column at widths |
||
| 1475 | # where the body had given up on it. |
||
| 1476 | assert_file_not_contains "$ts/.dist/plain.html" '.heading-page { display: flex' |
||
| 1477 | |
||
| 1478 | it "the page title rule comes after the base title rule" |
||
| 1479 | # ".heading .title" sets display: table-cell at equal specificity, so source |
||
| 1480 | # order decides. Placed earlier, the page rule lost and the title -- a |
||
| 1481 | # table-cell -- took its own row below the spacer at every width. |
||
| 1482 | base_line=$("$GREP" -n '\.heading \.title { color' "$ts/.dist/plain.html" | head -1 | cut -d: -f1) |
||
| 1483 | page_line=$("$GREP" -n '\.heading-page \.title {' "$ts/.dist/plain.html" | head -1 | cut -d: -f1) |
||
| 1484 | [ -n "$base_line" ] && [ -n "$page_line" ] && [ "$page_line" -gt "$base_line" ] \ |
||
| 1485 | && pass "page title rule wins on source order" \ |
||
| 1486 | || fail "page title rule at line $page_line must come after base rule at line $base_line" |
||
| 1487 | |
||
| 1488 | it "the build is clean" |
||
| 1489 | assert_eq "$stderr_output" "" |
||
| 1490 | |
||
| 1426 | suite "template-baked navs" |
1491 | suite "template-baked navs" |
| 1427 | |
1492 | |
| 1428 | # The default theme renders no footer, so render_footer_nav is not called from |
1493 | # The default theme renders no footer, so render_footer_nav is not called from |
Raw
commit 5ca8f3d2fa08691bb803ce9bd5d22cfda480fbbc
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Tue Aug 25 21:54:00 2026 +0800
Fix phone type size and a page title hanging off its own text
Both reported from an iPhone.
Body copy was 14px on posts and pages below 736px and 16px above, while
the index has always been a flat 16px -- so the same prose was a size
smaller on a phone than the index that linked to it. Nothing about a
narrow screen calls for smaller type: the measure is set by the column
width, and shrinking the type only buys characters per line the reader
did not ask for. Posts and pages now match the index at every width. The
index, tag and 404 layouts were already flat and are untouched.
A page title sat 97px right of its own body on a phone. The heading was a
flex row, and flex items do not wrap: the spacer held its column down to
320px while the body -- a full 60ch column that cannot fit beside it --
had long since wrapped to the left margin. The fix is to drop the spacer
entirely below 736px, in the heading and the body together. The indent
reserves a post's back-arrow column, and on a phone that costs more than
it reserves: at 375px it takes the measure from 42 characters to 32, and
at 320px to 25.
Matching the body's content-driven turnover was tried first and does not
work. The heading and the body hold different content -- a two-word title
against a 60ch column -- so they give out at different widths, and tuning
the title's column to compensate only moved the disagreement: a 10px
window at 614-622px became a 90px one at 528-612px. The post itself never
wraps its heading, so there was no mechanism to copy.
Two things fell out of that work:
The page title rule now sits after ".heading .title", which sets
display: table-cell at equal specificity. Placed earlier it lost on source
order and the title, as a table-cell, took its own row below the spacer at
every width. The previous flex version had sidestepped the collision only
because flex overrides a child's display outright.
The title's bottom margin is restored. Zeroing the whole margin for the
flex row left a page title touching its first paragraph -- 0px of gap
where a post has 1em.
Verified by measuring at 320-1920px: title and body share a left edge on
every page type at all 378 sampled widths, and prose is 16px on index,
post, page, sidebar page and tag alike.
Tests: a new suite covering the type size across layouts, the heading
spacing, the narrow-width spacer removal, and the source-order dependency
that a specificity tie leaves decidable only by position. Checked by
mutation -- restoring the shrinking font, re-zeroing the margin -- 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/page.sh b/share/jenny/layout/page.sh
index 3316907..df3312d 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -208,12 +208,18 @@ cat << _EOF_
body {
background-color: var(--bg-primary);
color: var(--text-primary);
- font-size: 14px;
+ font-size: 16px;
padding: 1em;
font-family: 'Roboto', sans-serif;
line-height: 1.5em;
}
- @media (min-width: 736px) { body { font-size: 16px } }
+ /* One size at every width, as the index has always done. This was
+ 14px below 736px and 16px above, so the same prose was a size
+ smaller on a phone than the index that linked to it -- and 14px is
+ small for body copy on a phone to begin with. There is no reason
+ for a narrow screen to get smaller text: the measure is already
+ handled by the column width, and shrinking the type only buys
+ characters per line that the reader did not ask for. */
article { padding: 0; margin: 1em 0; max-width: 74ch; }
/* 74ch is sized for a post: the arrow's column plus a 60ch body. A
sidebar is an addition to that, so the article grows by exactly the
@@ -318,9 +324,6 @@ cat << _EOF_
title sat at the far left while its own body was indented past it.
The heading reserves the same column the body does, by the same
mechanism, so the two line up whatever the width. */
- .heading-page { display: flex; align-items: baseline; margin-left: 0; }
- .heading-page .title { display: block; margin: 0; flex: 1; }
- .heading-page .home-spacer { margin-bottom: 0; flex: 0 0 auto; }
/* Beside a sidebar the whole cell is already offset, so the heading
must not indent again -- the title would land a column right of its
own body. */
@@ -337,6 +340,29 @@ cat << _EOF_
.heading a { text-decoration: none; }
.heading .title { color: var(--text-heading); display: table-cell; 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. */
+ .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 .home-spacer { margin-bottom: 0; }
.heading .stamp,
.home { display: inline-block; text-align:right; margin-right: 1.5em; }
.home { text-decoration: none; width: 5.4em; margin-bottom: 1.5em; text-align: left; color: var(--link-home); } .home:hover { color: var(--link-home-hover); }
@@ -436,12 +462,25 @@ cat << _EOF_
/* No indent at this width: 6.82rem plus a 60ch column overflows a
phone and forces horizontal scroll. */
.contents-cell { padding-left: 0; }
- /* The heading is left alone here. A post has no breakpoint at all --
- its heading and body both collapse by wrapping, at the same width,
- because both are driven by the same inline column. Forcing the
- heading to block at 735px un-indented the title while the body was
- still indented, so the two split apart for exactly one step of the
- range. Letting it wrap on its own keeps them together. */
+ /* No spacer at this width, in the heading or the body. The indent
+ exists to reserve a post's back-arrow column; on a phone it costs
+ more than it reserves -- at 375px it takes the measure from 42
+ characters to 32, and at 320px down to 25.
+
+ The body already wraps under its spacer here, because a 60ch
+ column cannot sit beside it. The heading did not: a two-word
+ title fits beside the spacer at any width, so the title stayed
+ indented 97px above a body that had returned to the left margin.
+ That is the artifact -- a title hanging to the right of its own
+ text on a phone.
+
+ Removing both is what keeps them together. Matching the body's
+ content-driven turnover instead was tried and does not work: the
+ heading and the body hold different content, so they give out at
+ different widths, and tuning the title's column to compensate only
+ moved the disagreement (a 10px window at 614-622px became a 90px
+ one at 528-612px). */
+ .home-spacer { display: none; }
}
.contents :first-child { margin-top: 0; }
h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; }
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 1bf8d27..8cd075b 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -105,12 +105,18 @@ cat << _EOF_
body {
background-color: var(--bg-primary);
color: var(--text-primary);
- font-size: 14px;
+ font-size: 16px;
padding: 1em;
font-family: 'Roboto', sans-serif;
line-height: 1.5em;
}
- @media (min-width: 736px) { body { font-size: 16px } }
+ /* One size at every width, as the index has always done. This was
+ 14px below 736px and 16px above, so the same prose was a size
+ smaller on a phone than the index that linked to it -- and 14px is
+ small for body copy on a phone to begin with. There is no reason
+ for a narrow screen to get smaller text: the measure is already
+ handled by the column width, and shrinking the type only buys
+ characters per line that the reader did not ask for. */
article { padding: 0; margin: 1em 0; max-width: 74ch; }
article a { color: var(--link-article); }
article img { max-width: 100% }
diff --git a/test/regressions.sh b/test/regressions.sh
index ee2698e..49218b0 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1423,6 +1423,71 @@ assert_contains "$narrow" 'min-width: 0'
it "the build is clean for nav sidebar"
assert_eq "$stderr_output" ""
+suite "type size and heading spacing"
+
+# Reported from an iPhone: body copy was a size smaller on posts and pages
+# than on the index, and a page title hung to the right of its own text.
+
+ts="$TMPROOT/typesize"
+make_blog_min "$ts" 2 10
+mkdir -p "$ts/pages" "$ts/navs"
+printf 'Item\t/item\n' > "$ts/navs/side.tsv"
+printf '# Plain\n\nBody copy long enough to be worth reading on a phone.\n' > "$ts/pages/plain.md"
+printf 'Nav: side\n\n# Sidebar\n\nBody copy.\n' > "$ts/pages/sb.md"
+run_jenny_split "$ts"
+
+it "posts do not shrink their body text on narrow screens"
+# The index has always been a flat 16px. Posts and pages were 14px below
+# 736px, so the same prose was smaller on a phone than the index linking to
+# it. Nothing about a narrow screen calls for smaller type -- the measure is
+# handled by the column width.
+post_file=$(find "$ts/.dist/post" -name '*.html' | head -1)
+assert_file_not_contains "$post_file" '@media (min-width: 736px) { body { font-size'
+
+it "pages do not shrink their body text on narrow screens"
+assert_file_not_contains "$ts/.dist/plain.html" '@media (min-width: 736px) { body { font-size'
+
+it "every layout declares the same base type size"
+# Compare the declarations rather than trusting they look alike.
+idx_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$ts/.dist/index.html" | head -1)
+post_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$post_file" | head -1)
+page_size=$("$GREP" -oE 'font-size: [0-9]+px;' "$ts/.dist/plain.html" | head -1)
+assert_eq "$post_size" "$idx_size"
+assert_eq "$page_size" "$idx_size"
+
+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;'
+
+it "the arrow column is dropped on narrow screens"
+# The indent reserves a post's back-arrow column. On a phone it costs more
+# than it reserves -- at 375px it takes the measure from 42 characters to 32 --
+# and the body already wraps to the left margin there while the heading, whose
+# title always fits beside the spacer, did not. The title was left hanging
+# 97px right of its own text.
+narrow_sp=$("$SED" -n '/@media (max-width: 735px)/,/^ }/p' "$ts/.dist/plain.html")
+assert_contains "$narrow_sp" '.home-spacer { display: none; }'
+
+it "the heading is not a flex row"
+# Flex items do not wrap, which is why the heading held its column at widths
+# where the body had given up on it.
+assert_file_not_contains "$ts/.dist/plain.html" '.heading-page { display: flex'
+
+it "the page title rule comes after the base title rule"
+# ".heading .title" sets display: table-cell at equal specificity, so source
+# order decides. Placed earlier, the page rule lost and the title -- a
+# table-cell -- took its own row below the spacer at every width.
+base_line=$("$GREP" -n '\.heading \.title { color' "$ts/.dist/plain.html" | head -1 | cut -d: -f1)
+page_line=$("$GREP" -n '\.heading-page \.title {' "$ts/.dist/plain.html" | head -1 | cut -d: -f1)
+[ -n "$base_line" ] && [ -n "$page_line" ] && [ "$page_line" -gt "$base_line" ] \
+ && pass "page title rule wins on source order" \
+ || fail "page title rule at line $page_line must come after base rule at line $base_line"
+
+it "the build is clean"
+assert_eq "$stderr_output" ""
+
suite "template-baked navs"
# The default theme renders no footer, so render_footer_nav is not called from