ssh git@grit.deadkey.org man

Commit aeda49d h

Pat Ambrosio · 26-08-26 10:53

Give a post one column shared by its stamp, title and body

Changes: 3 files +167 -116

Changes (Side-by-Side)

share/jenny/layout/page.sh +20 -4

cat << _EOF_
285 difference with no cause the reader could see. Widening here rather 285 difference with no cause the reader could see. Widening here rather
286 than narrowing the body keeps the measure identical everywhere. */ 286 than narrowing the body keeps the measure identical everywhere. */
287 article:has(.page-nav) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); } 287 article:has(.page-nav) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); }
288 article a { color: var(--link-article); }
288 /* A bare URL has no spaces to wrap at, so on a narrow screen one
289 long link sets the width of the whole page -- a reference to
290 https://github.com/hmngwy/jenny made this post 375px wide at a
291 320px viewport. Breaking inside the word is better than a document
292 that scrolls sideways. */
293 article a { color: var(--link-article); overflow-wrap: break-word; }
289 article img { max-width: 100% } 294 article img { max-width: 100% }
290 /* Blockquote keeps its left rule; code blocks are a tinted block 295 /* Blockquote keeps its left rule; code blocks are a tinted block
291 with no rule, so the two read as different kinds of aside. */ 296 with no rule, so the two read as different kinds of aside. */
cat << _EOF_
412 it wrapped onto its own line on a phone, leaving a stray arrow 417 it wrapped onto its own line on a phone, leaving a stray arrow
413 between the title and the first paragraph. */ 418 between the title and the first paragraph. */
414 .home { display: block; margin-top: 2.5em; text-decoration: none; 419 .home { display: block; margin-top: 2.5em; text-decoration: none;
420 font-size: .85em; text-transform: uppercase;
415 color: var(--link-home); } 421 color: var(--link-home); }
416 .home:hover { color: var(--link-home-hover); } 422 .home:hover { color: var(--link-home-hover); }
417 .heading .stamp { color: var(--text-muted); } 423 .heading .stamp { color: var(--text-muted); }
cat << _EOF_
485 way back is below the text rather than beside it. */ 491 way back is below the text rather than beside it. */
486 .page-wrapper:not(:has(.page-nav)) .contents-cell { display: block; 492 .page-wrapper:not(:has(.page-nav)) .contents-cell { display: block;
487 padding-left: 0; } 493 padding-left: 0; }
488 .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
494 /* Identical to the post layout's. A block, not an inline-block: the
495 body no longer sits beside anything -- the back arrow that used to
496 share its line is below the text now -- and min-width: 0 lets it
497 shrink under a narrow viewport instead of setting a floor that makes
498 the page scroll sideways. */
499 .contents { display: block; max-width: 60ch; width: 100%; min-width: 0; }
489 500
490 /* Narrow: the sidebar has nowhere to go, so it stacks above the body 501 /* Narrow: the sidebar has nowhere to go, so it stacks above the body
491 and everything collapses to one column. Every part has to leave 502 and everything collapses to one column. Every part has to leave
cat << _EOF_
507 picked. Renderers emit a fixed width/height on the svg, so both are 518 picked. Renderers emit a fixed width/height on the svg, so both are
508 overridden -- width alone leaves the original height and distorts 519 overridden -- width alone leaves the original height and distorts
509 it. Before this the diagram sat small against a 60ch column. */ 520 it. Before this the diagram sat small against a 60ch column. */
510 .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
521 /* min-width: 0 for the same reason the table scroller needs it: the
522 box otherwise refuses to shrink below its rendered diagram's width,
523 so a narrow screen scrolled the whole page sideways instead of
524 scrolling the diagram inside its own container. */
525 .diagram { margin: 1.5em 0; overflow-x: auto; display: block;
526 width: 100%; min-width: 0; max-width: 100%; }
511 /* The renderer writes an inline max-width of the diagram's natural 527 /* The renderer writes an inline max-width of the diagram's natural
512 size, which is what keeps a small diagram from being blown up to 528 size, which is what keeps a small diagram from being blown up to
513 the column width. That is the right default, so it is left alone; 529 the column width. That is the right default, so it is left alone;
cat << _EOF_
615 <div class="contents"> 631 <div class="contents">
616 $(echo "${POST_CONTENTS:-}") 632 $(echo "${POST_CONTENTS:-}")
617 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 633 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
618 <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; $(html_escape "${BLOG_TITLE:-}")</a>
634 <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
619 </div> 635 </div>
620 </div> 636 </div>
621 </div> 637 </div>

share/jenny/layout/post.sh +79 -19

cat << _EOF_
175 handled by the column width, and shrinking the type only buys 175 handled by the column width, and shrinking the type only buys
176 characters per line that the reader did not ask for. */ 176 characters per line that the reader did not ask for. */
177 article { padding: 0; margin: 1em 0; max-width: 74ch; } 177 article { padding: 0; margin: 1em 0; max-width: 74ch; }
178 article a { color: var(--link-article); }
178 /* A bare URL has no spaces to wrap at, so on a narrow screen one
179 long link sets the width of the whole page -- a reference to
180 https://github.com/hmngwy/jenny made this post 375px wide at a
181 320px viewport. Breaking inside the word is better than a document
182 that scrolls sideways. */
183 article a { color: var(--link-article); overflow-wrap: break-word; }
179 article img { max-width: 100% } 184 article img { max-width: 100% }
180 /* Blockquote keeps its left rule; code blocks are a tinted block 185 /* Blockquote keeps its left rule; code blocks are a tinted block
181 with no rule, so the two read as different kinds of aside. */ 186 with no rule, so the two read as different kinds of aside. */
cat << _EOF_
195 /* display:block is what allows overflow-x to scroll, but it also 200 /* display:block is what allows overflow-x to scroll, but it also
196 makes the table shrink to its content. The wrapper scrolls and the 201 makes the table shrink to its content. The wrapper scrolls and the
197 table itself stays a table, so it fills the column. */ 202 table itself stays a table, so it fills the column. */
198 article .table-scroll { overflow-x: auto; margin: 1em 0; }
203 /* min-width: 0 is what makes the overflow actually scroll. Without it
204 the box refuses to shrink below the table's intrinsic width, so a
205 wide table pushed the page sideways instead of scrolling inside its
206 own container -- at 320px this post's three-column table made the
207 whole document 375px wide. */
208 article .table-scroll { overflow-x: auto; margin: 1em 0; min-width: 0; max-width: 100%; }
199 article table { border-collapse: collapse; width: 100%; 209 article table { border-collapse: collapse; width: 100%;
200 table-layout: auto; font-size: .95em; } 210 table-layout: auto; font-size: .95em; }
201 article table th, 211 article table th,
cat << _EOF_
247 not. */ 257 not. */
248 .heading-page .title { display: block; } 258 .heading-page .title { display: block; }
249 .heading a { text-decoration: none; } 259 .heading a { text-decoration: none; }
250 .heading .title { color: var(--text-heading); display: table-cell; margin: 0 0 1em; font-weight:bold; max-width: 500px; }
260 /* A flex item, not a table-cell: the heading is a flex row now, and a
261 table-cell inside it would be wrapped in an anonymous table and take
262 its own line. This rule sits before the flex declarations but at the
263 same specificity, so leaving display here would have won on source
264 order. */
265 .heading .title { color: var(--text-heading); margin: 0 0 1em; font-weight: bold; }
251 .heading .title:hover { text-decoration: underline; } 266 .heading .title:hover { text-decoration: underline; }
252 .heading .stamp,
253 /* The way back sits after the text, where a reader finishes, rather
254 than beside it. Held in a fixed column before the body it wrapped
255 onto its own line once the body no longer fit next to it, leaving a
256 stray arrow floating between the title and the first paragraph on a
257 phone -- and on wider screens it reserved a column the body then had
258 to be indented past. Below the text it needs no column at either
259 width, so the body starts at the left margin everywhere.
267 /* A post reads as the index entry it came from, so its stamp column and
268 its title have to land on the index's to the pixel: stamp at the
269 article's left edge, title one --stamp-column across. The heading
270 row and the body row are separate flex rows that both start from
271 that one length, rather than two things that happened to measure the
272 same -- which is what was there before. The title's left edge came
273 from the stamp sitting inline beside it and the body's came from the
274 back arrow sitting inline beside the body; moving the arrow out
275 collapsed one of them and left the title 109px right of its own
276 first paragraph.
260 277
261 It is a second route home: the site title in the header links there
262 from every page. Kept because the end of a long read is exactly
263 where that is useful, and named rather than left as a bare glyph so
264 it says where it goes. */
278 The body is padded rather than given a spacer element: an empty div
279 to hold a column is markup that exists only to be a number. */
280 /* The index's stamp is a table-cell whose "width: 2.25em" is only a
281 minimum: the cell expands to fit the date, so the column it actually
282 draws is 109px, not the 60px that declaration adds up to. A flex
283 basis cannot reproduce content-driven sizing, so the width is stated
284 here as the length it resolves to. Written as em rather than px so
285 it tracks the type size, and it is one value -- the heading and the
286 body both read it, so they cannot disagree with each other. If the
287 date format changes, this is the single place to re-measure. */
288 :root { --stamp-column: 6.82em; }
289 /* The flex container is the anchor, not the .heading around it: the
290 stamp and the title are wrapped in a single <a>, so making .heading
291 the container gave it one flex child -- the link -- and the two
292 inside it stayed stacked. (The index gets away with table-cell here
293 because anonymous table boxes form through the anchor; flex has no
294 such rule.) */
295 .heading { display: block; }
296 .heading > a { display: flex; align-items: baseline; }
297 .heading .stamp { flex: 0 0 var(--stamp-column); color: var(--text-muted); }
298 .heading .title { flex: 1 1 auto; }
299 .body-row { padding-left: var(--stamp-column); }
300 /* No breakpoint here. The index holds this column all the way down to
301 320px without scrolling -- a title is short and simply wraps -- so a
302 post that stacked below some width would stop matching the index at
303 exactly the sizes where the two sit closest together. The overflow
304 that suggested a breakpoint came from the body's own max-width, not
305 from the column, and is fixed where it belongs. */
306 /* min-width: 0 lets the column shrink below its content's intrinsic
307 width. Without it a 60ch measure kept the row from ever narrowing
308 past that, so at 320px the page scrolled sideways rather than the
309 text reflowing. */
310 .contents { display: block; max-width: 60ch; width: 100%; min-width: 0; }
311 .body-row { min-width: 0; }
312 /* The way back sits after the text, where a reader finishes. Held in a
313 column before the body it wrapped onto its own line once the body no
314 longer fit beside it, leaving a stray arrow between the title and the
315 first paragraph on a phone. It names its destination -- the header
316 already carries the site title two lines above -- and is set smaller
317 and in caps, the way the theme marks its other small labels, because
318 it is an exit rather than part of the reading. */
265 .home { display: block; margin-top: 2.5em; text-decoration: none; 319 .home { display: block; margin-top: 2.5em; text-decoration: none;
320 font-size: .85em; text-transform: uppercase;
266 color: var(--link-home); } 321 color: var(--link-home); }
267 .home:hover { color: var(--link-home-hover); } 322 .home:hover { color: var(--link-home-hover); }
268 .heading .stamp { color: var(--text-muted); }
269 .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
270 .contents :first-child { margin-top: 0; } 323 .contents :first-child { margin-top: 0; }
271 /* A diagram is a figure, not a paragraph: it takes the column's width 324 /* A diagram is a figure, not a paragraph: it takes the column's width
272 and scales to it rather than rendering at whatever size the renderer 325 and scales to it rather than rendering at whatever size the renderer
273 picked. Renderers emit a fixed width/height on the svg, so both are 326 picked. Renderers emit a fixed width/height on the svg, so both are
274 overridden -- width alone leaves the original height and distorts 327 overridden -- width alone leaves the original height and distorts
275 it. Before this the diagram sat small against a 60ch column. */ 328 it. Before this the diagram sat small against a 60ch column. */
276 .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
329 /* min-width: 0 for the same reason the table scroller needs it: the
330 box otherwise refuses to shrink below its rendered diagram's width,
331 so a narrow screen scrolled the whole page sideways instead of
332 scrolling the diagram inside its own container. */
333 .diagram { margin: 1.5em 0; overflow-x: auto; display: block;
334 width: 100%; min-width: 0; max-width: 100%; }
277 /* The renderer writes an inline max-width of the diagram's natural 335 /* The renderer writes an inline max-width of the diagram's natural
278 size, which is what keeps a small diagram from being blown up to 336 size, which is what keeps a small diagram from being blown up to
279 the column width. That is the right default, so it is left alone; 337 the column width. That is the right default, so it is left alone;
cat << _EOF_
374 <header><a href="$URL_PREFIX/">$(html_escape "${BLOG_TITLE:-}")</a></header> 432 <header><a href="$URL_PREFIX/">$(html_escape "${BLOG_TITLE:-}")</a></header>
375 <article> 433 <article>
376 <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> 434 <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>
435 <div class="body-row">
377 <div class="contents"> 436 <div class="contents">
378 $(echo "${POST_CONTENTS:-}") 437 $(echo "${POST_CONTENTS:-}")
379 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 438 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
380 <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; $(html_escape "${BLOG_TITLE:-}")</a>
439 <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
381 </div> 440 </div>
441 </div>
382 </article> 442 </article>
383 $(diagram_script) 443 $(diagram_script)
384 </body> 444 </body>

test/regressions.sh +68 -93

assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more
776 it "intraword underscores are still literal" 776 it "intraword underscores are still literal"
777 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' 777 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
778 778
779 suite "diagrams"
780
781 # A diagram fence is held for a client-side renderer instead of being shown as
782 # source. jenny renders no diagrams itself and loads nothing by default: a
783 # theme supplies the renderer, and the site self-hosts it.
784
785 it "a mermaid fence becomes a diagram div"
786 assert_contains "$(md_render '```mermaid
787 flowchart LR
788 A --> B
789 ```')" '<div class="diagram diagram-mermaid">'
790
791 it "diagram source is not HTML-escaped"
792 # The renderer reads the element's text content and parses it, so escaping
793 # "-->" to "--&gt;" would break every arrow in the diagram. This is the one
794 # place a fence's content is left raw.
795 dia=$(md_render '```mermaid
796 flowchart LR
797 A["x<br/>y"] -->|"label"| B
798 ```')
799 assert_contains "$dia" '-->'
800 assert_not_contains "$dia" '--&gt;'
801 assert_contains "$dia" '<br/>'
802
803 it "the diagram fence closes with a div, not a code block"
804 assert_contains "$dia" '</div>'
805 assert_not_contains "$dia" '</code></pre>'
806
807 it "the legend variant keeps its own class"
808 assert_contains "$(md_render '```mermaid-legend
809 flowchart LR
810 A --> B
811 ```')" 'diagram-mermaid-legend'
812
813 it "an ordinary code fence is still escaped"
814 # Only the fixed set of diagram languages is raw; everything else escapes as
815 # before, so a document cannot introduce a new raw language.
816 code=$(md_render '```bash
817 echo "a" && b > c
818 ```')
819 assert_contains "$code" '&amp;&amp;'
820 assert_contains "$code" '<pre><code class="language-bash">'
821
822 it "a bare fence is still escaped"
823 assert_contains "$(md_render '```
824 x > y & z
825 ```')" '&gt;'
826
827 it "a language whose name merely starts with mermaid is not raw"
828 notmer=$(md_render '```mermaidish
829 a > b
830 ```')
831 assert_contains "$notmer" '<pre><code'
832 assert_not_contains "$notmer" '<div class="diagram'
833
834 suite "self-hosted diagram renderer"
835
836 dg="$TMPROOT/diagrams"
837 make_blog_min "$dg" 1 10
838 mkdir -p "$dg/pages"
839 printf '# Plain\n\nNo diagram here.\n' > "$dg/pages/plain.md"
840 printf '# Chart\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/chart.md"
841
842 it "no renderer is loaded when DIAGRAM_SCRIPT is unset"
843 # jenny ships no renderer and pulls nothing from a CDN. Without the setting a
844 # diagram degrades to its source text, which stays readable.
845 run_jenny_split "$dg"
846 assert_file_not_contains "$dg/.dist/chart.html" '<script src='
847
848 it "the renderer is loaded on a page that has a diagram"
849 printf 'DIAGRAM_SCRIPT="/js/mermaid.min.js"\n' >> "$dg/.blogrc"
850 run_jenny_split "$dg"
851 assert_file_contains "$dg/.dist/chart.html" 'js/mermaid.min.js'
852
853 it "the renderer is not loaded on a page without one"
854 # The bundle is megabytes; every page paying for it so a few work would be the
855 # wrong trade.
856 assert_file_not_contains "$dg/.dist/plain.html" 'js/mermaid.min.js'
857
858 it "the renderer is pointed at the class the parser emits"
859 # The markup uses a neutral "diagram" class rather than naming one renderer,
860 # so the renderer has to be told what to look for. Left at its own default it
861 # found nothing and silently rendered no diagram at all.
862 assert_file_contains "$dg/.dist/chart.html" "querySelector: '.diagram-mermaid'"
863
864 it "the script path is resolved relative to the page"
865 mkdir -p "$dg/pages/deep"
866 printf '# Deep\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/deep/d.md"
867 run_jenny_split "$dg"
868 assert_file_contains "$dg/.dist/deep/d.html" '"../js/mermaid.min.js"'
779 suite "a post lines up with its index entry"
780
781 # A post should read as the index entry it came from: its stamp column and its
782 # title land on the index's, and the body runs in the title's column. Before
783 # this the title's left edge came from the stamp sitting inline beside it and
784 # the body's came from the back arrow sitting inline beside the body -- two
785 # independent accidents that agreed by coincidence. Moving the arrow below the
786 # text collapsed one and left the title 109px right of its own first paragraph.
787
788 al="$TMPROOT/aligned"
789 make_blog_min "$al" 2 10
790 run_jenny_split "$al"
791 al_post=$(find "$al/.dist/post" -name '*.html' | head -1)
792
793 it "one length defines the stamp column"
794 # Not several copies of the same number in several rules.
795 assert_file_contains "$al_post" '--stamp-column:'
796 assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)'
797 assert_file_contains "$al_post" '.body-row { padding-left: var(--stamp-column); }'
798
799 it "the flex container is the anchor, not the heading around it"
800 # The stamp and the title are wrapped in one <a>, so making .heading the
801 # container gave it a single flex child and the two inside stayed stacked --
802 # the date dropped onto its own line above the title.
803 assert_file_contains "$al_post" '.heading > a { display: flex'
804
805 it "the title is a flex item, not a table cell"
806 # The base rule set display:table-cell at equal specificity earlier in the
807 # file, so leaving it there won on source order and gave the title its own row.
808 assert_file_not_contains "$al_post" '.heading .title { color: var(--text-heading); display: table-cell'
809
810 it "the stamp column is stated once, in em"
811 # The index's stamp is a table-cell whose "width: 2.25em" is only a minimum --
812 # the cell grows to fit the date, so it draws 109px rather than the 60px that
813 # declaration adds up to. A flex basis cannot reproduce content-driven sizing,
814 # so the post states the length it resolves to; em rather than px so it tracks
815 # the type size. Whether the two agree is a rendered-geometry question, checked
816 # by measuring, not by matching declarations that are necessarily different.
817 assert_file_contains "$al_post" '--stamp-column: 6.82em'
818 assert_file_contains "$al/.dist/index.html" 'width: 2.25em'
819
820 it "the body is not indented by a second mechanism"
821 # A margin on .contents would be another copy of the column, drifting the
822 # moment the stamp changes.
823 assert_file_not_contains "$al_post" '.contents { display: block; max-width: 60ch; width: 100%; min-width: 0; margin-left'
824
825 it "long words cannot widen the page"
826 # A bare URL has no spaces to wrap at; one reference link made a post 375px
827 # wide inside a 320px viewport.
828 assert_file_contains "$al_post" 'article a { color: var(--link-article); overflow-wrap: break-word; }'
829
830 it "a wide table scrolls inside its own box"
831 # overflow-x alone does not let the box shrink below its content: without
832 # min-width the table pushed the document sideways instead of scrolling.
833 assert_file_contains "$al_post" 'article .table-scroll { overflow-x: auto; margin: 1em 0; min-width: 0; max-width: 100%; }'
869 834
870 it "the build is clean" 835 it "the build is clean"
871 assert_eq "$stderr_output" "" 836 assert_eq "$stderr_output" ""
it "the diagram box takes the body column, not its shrink-wrapped width"
989 # .contents is an inline-block, so a block inside it shrink-wraps to its 954 # .contents is an inline-block, so a block inside it shrink-wraps to its
990 # content; the diagram sat small against a full-width column until the 955 # content; the diagram sat small against a full-width column until the
991 # container was told to take the width. 956 # container was told to take the width.
992 assert_file_contains "$dg/.dist/chart.html" '.diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }'
957 assert_file_contains "$dg/.dist/chart.html" '.diagram { margin: 1.5em 0; overflow-x: auto; display: block;'
958 # min-width is what lets it shrink: without it a narrow screen scrolled the
959 # whole page sideways instead of scrolling the diagram in its own box.
960 assert_file_contains "$dg/.dist/chart.html" 'width: 100%; min-width: 0; max-width: 100%; }'
993 961
994 it "an unrendered diagram degrades to readable source" 962 it "an unrendered diagram degrades to readable source"
995 # If the script is absent or blocked, the element still holds what the author 963 # If the script is absent or blocked, the element still holds what the author
hl=$("$GREP" -n 'class="home"' "$post_file_bk" | head -1 | cut -d: -f1)
1739 || fail "post back link at line $hl must come after the body at line $tl" 1707 || fail "post back link at line $hl must come after the body at line $tl"
1740 1708
1741 it "the back link names where it goes" 1709 it "the back link names where it goes"
1742 # A bare arrow says only "away". Both layouts render the site title beside it.
1743 assert_file_contains "$post_file_bk" '&#x2190;&#xFE0E; '
1710 # A bare arrow says only "away". It names the destination -- "index", not the
1711 # site title, which the header already carries two lines above.
1712 assert_file_contains "$post_file_bk" '&#x2190;&#xFE0E; Index</a>'
1713 assert_file_not_contains "$post_file_bk" '&#x2190;&#xFE0E; Test Blog'
1714
1715 it "the back link is quieter than the body it follows"
1716 # It is an exit, not part of the reading. Set smaller and in caps, the way the
1717 # theme marks its other small labels.
1718 assert_file_contains "$post_file_bk" 'font-size: .85em; text-transform: uppercase;'
1744 1719
1745 it "posts and pages declare the same back link" 1720 it "posts and pages declare the same back link"
1746 # The whole point of moving it: both layouts end the same way. Compare the 1721 # The whole point of moving it: both layouts end the same way. Compare the

Raw

commit aeda49dcddb5621a73de523126efb4b7a49325a0
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Wed Aug 26 10:53:25 2026 +0800

    Give a post one column shared by its stamp, title and body
    
    You asked why the date had left the title's line, and then whether the
    post's structure was the problem. It was.
    
    The heading and the body were siblings with no shared grid. The title's
    left edge came from the stamp sitting inline beside it; the body's came
    from the back arrow sitting inline beside the body. Two independent
    accidents that happened to produce the same number, so moving the arrow
    below the text collapsed one of them and left the title 109px right of its
    own first paragraph. Adding a margin to the body would have been a third
    copy of that number, drifting the moment the stamp changed.
    
    The heading is now a flex row and the body row takes the same column as
    padding, both from one --stamp-column. The target is not internal
    consistency but the index: a post reads as the entry it came from, so its
    stamp and title land on the index's to the pixel. Measured at 320-1920px,
    the post's stamp sits at 24 and its title at 133 -- the index's numbers --
    with the body and the back link at 133 too.
    
    The flex container is the anchor rather than the .heading around it: the
    stamp and title are wrapped in a single <a>, so making .heading the
    container gave it one flex child and the two inside stayed stacked. That
    is what had dropped the date onto its own line, a regression from the
    previous commit where a dangling selector list left the stamp inheriting
    the back link's display:block. The base .heading .title rule also had to
    lose its display:table-cell, which sat earlier in the file at equal
    specificity and so won on source order.
    
    --stamp-column is 6.82em, a measured length rather than a derived one.
    Deriving it was tried: the index declares width: 2.25em on a table-cell,
    but that is only a minimum -- the cell grows to fit the date and draws
    109px, not the 60px the declaration adds up to. A flex basis cannot
    reproduce content-driven sizing, so the value is stated as what it
    resolves to, in em so it tracks the type size, in one place so the heading
    and the body cannot disagree.
    
    Three overflow fixes fell out of measuring at 320px, none of them the
    layout: a bare URL in the references has no spaces to wrap at and made the
    post 375px wide inside a 320px viewport; a wide table needed min-width: 0
    before overflow-x could actually scroll it rather than push the document
    sideways; and the diagram box needed the same. A breakpoint that stacked
    the stamp below 360px was tried and removed -- the index holds this column
    down to 320px without scrolling, so stacking would have broken the match
    at exactly the sizes where the two sit closest.
    
    Pages take the same body declaration, which the existing "identical to a
    post's" test caught.
    
    Tests: a suite asserting the column is one value, that the flex container
    is the anchor, that the title is not a table-cell, that the body is not
    indented by a second mechanism, and the three overflow fixes. Checked by
    mutation -- unflexing the heading, dropping the body's column, restoring
    unbreakable URLs -- each caught. Also removes a duplicated diagrams suite
    left by an interrupted edit.
    
    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 663dabb..4e96dd0 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -285,7 +285,12 @@ cat << _EOF_
            difference with no cause the reader could see. Widening here rather
            than narrowing the body keeps the measure identical everywhere. */
         article:has(.page-nav) { max-width: calc(74ch + var(--page-nav-width) + var(--page-nav-gap)); }
-        article a { color: var(--link-article); }
+        /* A bare URL has no spaces to wrap at, so on a narrow screen one
+           long link sets the width of the whole page -- a reference to
+           https://github.com/hmngwy/jenny made this post 375px wide at a
+           320px viewport. Breaking inside the word is better than a document
+           that scrolls sideways. */
+        article a { color: var(--link-article); overflow-wrap: break-word; }
         article img { max-width: 100% }
         /* Blockquote keeps its left rule; code blocks are a tinted block
            with no rule, so the two read as different kinds of aside. */
@@ -412,6 +417,7 @@ cat << _EOF_
            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;
+                font-size: .85em; text-transform: uppercase;
                 color: var(--link-home); }
         .home:hover { color: var(--link-home-hover); }
         .heading .stamp { color: var(--text-muted); }
@@ -485,7 +491,12 @@ cat << _EOF_
            way back is below the text rather than beside it. */
         .page-wrapper:not(:has(.page-nav)) .contents-cell { display: block;
                                                             padding-left: 0; }
-        .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
+        /* 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
+           shrink under a narrow viewport instead of setting a floor that makes
+           the page scroll sideways. */
+        .contents { display: block; max-width: 60ch; width: 100%; min-width: 0; }
 
         /* Narrow: the sidebar has nowhere to go, so it stacks above the body
            and everything collapses to one column. Every part has to leave
@@ -507,7 +518,12 @@ cat << _EOF_
            picked. Renderers emit a fixed width/height on the svg, so both are
            overridden -- width alone leaves the original height and distorts
            it. Before this the diagram sat small against a 60ch column. */
-        .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
+        /* min-width: 0 for the same reason the table scroller needs it: the
+           box otherwise refuses to shrink below its rendered diagram's width,
+           so a narrow screen scrolled the whole page sideways instead of
+           scrolling the diagram inside its own container. */
+        .diagram { margin: 1.5em 0; overflow-x: auto; display: block;
+                   width: 100%; min-width: 0; max-width: 100%; }
         /* The renderer writes an inline max-width of the diagram's natural
            size, which is what keeps a small diagram from being blown up to
            the column width. That is the right default, so it is left alone;
@@ -615,7 +631,7 @@ cat << _EOF_
         <div class="contents">
         $(echo "${POST_CONTENTS:-}")
         <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
-        <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; $(html_escape "${BLOG_TITLE:-}")</a>
+        <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
         </div>
           </div>
         </div>
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index ef6c18e..92ffd49 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -175,7 +175,12 @@ cat << _EOF_
            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); }
+        /* A bare URL has no spaces to wrap at, so on a narrow screen one
+           long link sets the width of the whole page -- a reference to
+           https://github.com/hmngwy/jenny made this post 375px wide at a
+           320px viewport. Breaking inside the word is better than a document
+           that scrolls sideways. */
+        article a { color: var(--link-article); overflow-wrap: break-word; }
         article img { max-width: 100% }
         /* Blockquote keeps its left rule; code blocks are a tinted block
            with no rule, so the two read as different kinds of aside. */
@@ -195,7 +200,12 @@ cat << _EOF_
         /* display:block is what allows overflow-x to scroll, but it also
            makes the table shrink to its content. The wrapper scrolls and the
            table itself stays a table, so it fills the column. */
-        article .table-scroll { overflow-x: auto; margin: 1em 0; }
+        /* min-width: 0 is what makes the overflow actually scroll. Without it
+           the box refuses to shrink below the table's intrinsic width, so a
+           wide table pushed the page sideways instead of scrolling inside its
+           own container -- at 320px this post's three-column table made the
+           whole document 375px wide. */
+        article .table-scroll { overflow-x: auto; margin: 1em 0; min-width: 0; max-width: 100%; }
         article table { border-collapse: collapse; width: 100%;
                         table-layout: auto; font-size: .95em; }
         article table th,
@@ -247,33 +257,81 @@ cat << _EOF_
            not. */
         .heading-page .title { display: block; }
         .heading a { text-decoration: none; }
-        .heading .title { color: var(--text-heading); display: table-cell; margin: 0 0 1em; font-weight:bold; max-width: 500px;  }
+        /* A flex item, not a table-cell: the heading is a flex row now, and a
+           table-cell inside it would be wrapped in an anonymous table and take
+           its own line. This rule sits before the flex declarations but at the
+           same specificity, so leaving display here would have won on source
+           order. */
+        .heading .title { color: var(--text-heading); margin: 0 0 1em; font-weight: bold; }
         .heading .title:hover { text-decoration: underline; }
-        .heading .stamp,
-        /* The way back sits after the text, where a reader finishes, rather
-           than beside it. Held in a fixed column before the body it wrapped
-           onto its own line once the body no longer fit next to it, leaving a
-           stray arrow floating between the title and the first paragraph on a
-           phone -- and on wider screens it reserved a column the body then had
-           to be indented past. Below the text it needs no column at either
-           width, so the body starts at the left margin everywhere.
+        /* A post reads as the index entry it came from, so its stamp column and
+           its title have to land on the index's to the pixel: stamp at the
+           article's left edge, title one --stamp-column across. The heading
+           row and the body row are separate flex rows that both start from
+           that one length, rather than two things that happened to measure the
+           same -- which is what was there before. The title's left edge came
+           from the stamp sitting inline beside it and the body's came from the
+           back arrow sitting inline beside the body; moving the arrow out
+           collapsed one of them and left the title 109px right of its own
+           first paragraph.
 
-           It is a second route home: the site title in the header links there
-           from every page. Kept because the end of a long read is exactly
-           where that is useful, and named rather than left as a bare glyph so
-           it says where it goes. */
+           The body is padded rather than given a spacer element: an empty div
+           to hold a column is markup that exists only to be a number. */
+        /* The index's stamp is a table-cell whose "width: 2.25em" is only a
+           minimum: the cell expands to fit the date, so the column it actually
+           draws is 109px, not the 60px that declaration adds up to. A flex
+           basis cannot reproduce content-driven sizing, so the width is stated
+           here as the length it resolves to. Written as em rather than px so
+           it tracks the type size, and it is one value -- the heading and the
+           body both read it, so they cannot disagree with each other. If the
+           date format changes, this is the single place to re-measure. */
+        :root { --stamp-column: 6.82em; }
+        /* The flex container is the anchor, not the .heading around it: the
+           stamp and the title are wrapped in a single <a>, so making .heading
+           the container gave it one flex child -- the link -- and the two
+           inside it stayed stacked. (The index gets away with table-cell here
+           because anonymous table boxes form through the anchor; flex has no
+           such rule.) */
+        .heading { display: block; }
+        .heading > a { display: flex; align-items: baseline; }
+        .heading .stamp { flex: 0 0 var(--stamp-column); color: var(--text-muted); }
+        .heading .title { flex: 1 1 auto; }
+        .body-row { padding-left: var(--stamp-column); }
+        /* No breakpoint here. The index holds this column all the way down to
+           320px without scrolling -- a title is short and simply wraps -- so a
+           post that stacked below some width would stop matching the index at
+           exactly the sizes where the two sit closest together. The overflow
+           that suggested a breakpoint came from the body's own max-width, not
+           from the column, and is fixed where it belongs. */
+        /* min-width: 0 lets the column shrink below its content's intrinsic
+           width. Without it a 60ch measure kept the row from ever narrowing
+           past that, so at 320px the page scrolled sideways rather than the
+           text reflowing. */
+        .contents { display: block; max-width: 60ch; width: 100%; min-width: 0; }
+        .body-row { min-width: 0; }
+        /* The way back sits after the text, where a reader finishes. Held in a
+           column before the body it wrapped onto its own line once the body no
+           longer fit beside it, leaving a stray arrow between the title and the
+           first paragraph on a phone. It names its destination -- the header
+           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;
+                font-size: .85em; text-transform: uppercase;
                 color: var(--link-home); }
         .home:hover { color: var(--link-home-hover); }
-        .heading .stamp { color: var(--text-muted); }
-        .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
         .contents :first-child { margin-top: 0; }
         /* A diagram is a figure, not a paragraph: it takes the column's width
            and scales to it rather than rendering at whatever size the renderer
            picked. Renderers emit a fixed width/height on the svg, so both are
            overridden -- width alone leaves the original height and distorts
            it. Before this the diagram sat small against a 60ch column. */
-        .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
+        /* min-width: 0 for the same reason the table scroller needs it: the
+           box otherwise refuses to shrink below its rendered diagram's width,
+           so a narrow screen scrolled the whole page sideways instead of
+           scrolling the diagram inside its own container. */
+        .diagram { margin: 1.5em 0; overflow-x: auto; display: block;
+                   width: 100%; min-width: 0; max-width: 100%; }
         /* The renderer writes an inline max-width of the diagram's natural
            size, which is what keeps a small diagram from being blown up to
            the column width. That is the right default, so it is left alone;
@@ -374,11 +432,13 @@ cat << _EOF_
       <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="body-row">
         <div class="contents">
         $(echo "${POST_CONTENTS:-}")
         <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
-        <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; $(html_escape "${BLOG_TITLE:-}")</a>
+        <a href="$URL_PREFIX/" class="home">&#x2190;&#xFE0E; Index</a>
         </div>
+      </div>
       </article>
 $(diagram_script)
     </body>
diff --git a/test/regressions.sh b/test/regressions.sh
index 0c385ea..3cc6221 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -776,96 +776,61 @@ assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more
 it "intraword underscores are still literal"
 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
 
-suite "diagrams"
-
-# A diagram fence is held for a client-side renderer instead of being shown as
-# source. jenny renders no diagrams itself and loads nothing by default: a
-# theme supplies the renderer, and the site self-hosts it.
-
-it "a mermaid fence becomes a diagram div"
-assert_contains "$(md_render '```mermaid
-flowchart LR
-  A --> B
-```')" '<div class="diagram diagram-mermaid">'
-
-it "diagram source is not HTML-escaped"
-# The renderer reads the element's text content and parses it, so escaping
-# "-->" to "--&gt;" would break every arrow in the diagram. This is the one
-# place a fence's content is left raw.
-dia=$(md_render '```mermaid
-flowchart LR
-  A["x<br/>y"] -->|"label"| B
-```')
-assert_contains "$dia" '-->'
-assert_not_contains "$dia" '--&gt;'
-assert_contains "$dia" '<br/>'
-
-it "the diagram fence closes with a div, not a code block"
-assert_contains "$dia" '</div>'
-assert_not_contains "$dia" '</code></pre>'
-
-it "the legend variant keeps its own class"
-assert_contains "$(md_render '```mermaid-legend
-flowchart LR
-  A --> B
-```')" 'diagram-mermaid-legend'
-
-it "an ordinary code fence is still escaped"
-# Only the fixed set of diagram languages is raw; everything else escapes as
-# before, so a document cannot introduce a new raw language.
-code=$(md_render '```bash
-echo "a" && b > c
-```')
-assert_contains "$code" '&amp;&amp;'
-assert_contains "$code" '<pre><code class="language-bash">'
-
-it "a bare fence is still escaped"
-assert_contains "$(md_render '```
-x > y & z
-```')" '&gt;'
-
-it "a language whose name merely starts with mermaid is not raw"
-notmer=$(md_render '```mermaidish
-a > b
-```')
-assert_contains "$notmer" '<pre><code'
-assert_not_contains "$notmer" '<div class="diagram'
-
-suite "self-hosted diagram renderer"
-
-dg="$TMPROOT/diagrams"
-make_blog_min "$dg" 1 10
-mkdir -p "$dg/pages"
-printf '# Plain\n\nNo diagram here.\n' > "$dg/pages/plain.md"
-printf '# Chart\n\n```mermaid\nflowchart LR\n  A --> B\n```\n' > "$dg/pages/chart.md"
-
-it "no renderer is loaded when DIAGRAM_SCRIPT is unset"
-# jenny ships no renderer and pulls nothing from a CDN. Without the setting a
-# diagram degrades to its source text, which stays readable.
-run_jenny_split "$dg"
-assert_file_not_contains "$dg/.dist/chart.html" '<script src='
-
-it "the renderer is loaded on a page that has a diagram"
-printf 'DIAGRAM_SCRIPT="/js/mermaid.min.js"\n' >> "$dg/.blogrc"
-run_jenny_split "$dg"
-assert_file_contains "$dg/.dist/chart.html" 'js/mermaid.min.js'
-
-it "the renderer is not loaded on a page without one"
-# The bundle is megabytes; every page paying for it so a few work would be the
-# wrong trade.
-assert_file_not_contains "$dg/.dist/plain.html" 'js/mermaid.min.js'
-
-it "the renderer is pointed at the class the parser emits"
-# The markup uses a neutral "diagram" class rather than naming one renderer,
-# so the renderer has to be told what to look for. Left at its own default it
-# found nothing and silently rendered no diagram at all.
-assert_file_contains "$dg/.dist/chart.html" "querySelector: '.diagram-mermaid'"
-
-it "the script path is resolved relative to the page"
-mkdir -p "$dg/pages/deep"
-printf '# Deep\n\n```mermaid\nflowchart LR\n  A --> B\n```\n' > "$dg/pages/deep/d.md"
-run_jenny_split "$dg"
-assert_file_contains "$dg/.dist/deep/d.html" '"../js/mermaid.min.js"'
+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
+# title land on the index's, and the body runs in the title's column. Before
+# this the title's left edge came from the stamp sitting inline beside it and
+# the body's came from the back arrow sitting inline beside the body -- two
+# independent accidents that agreed by coincidence. Moving the arrow below the
+# text collapsed one and left the title 109px right of its own first paragraph.
+
+al="$TMPROOT/aligned"
+make_blog_min "$al" 2 10
+run_jenny_split "$al"
+al_post=$(find "$al/.dist/post" -name '*.html' | head -1)
+
+it "one length defines the stamp column"
+# Not several copies of the same number in several rules.
+assert_file_contains "$al_post" '--stamp-column:'
+assert_file_contains "$al_post" 'flex: 0 0 var(--stamp-column)'
+assert_file_contains "$al_post" '.body-row { padding-left: var(--stamp-column); }'
+
+it "the flex container is the anchor, not the heading around it"
+# The stamp and the title are wrapped in one <a>, so making .heading the
+# container gave it a single flex child and the two inside stayed stacked --
+# the date dropped onto its own line above the title.
+assert_file_contains "$al_post" '.heading > a { display: flex'
+
+it "the title is a flex item, not a table cell"
+# The base rule set display:table-cell at equal specificity earlier in the
+# file, so leaving it there won on source order and gave the title its own row.
+assert_file_not_contains "$al_post" '.heading .title { color: var(--text-heading); display: table-cell'
+
+it "the stamp column is stated once, in em"
+# The index's stamp is a table-cell whose "width: 2.25em" is only a minimum --
+# the cell grows to fit the date, so it draws 109px rather than the 60px that
+# declaration adds up to. A flex basis cannot reproduce content-driven sizing,
+# so the post states the length it resolves to; em rather than px so it tracks
+# the type size. Whether the two agree is a rendered-geometry question, checked
+# by measuring, not by matching declarations that are necessarily different.
+assert_file_contains "$al_post" '--stamp-column: 6.82em'
+assert_file_contains "$al/.dist/index.html" 'width: 2.25em'
+
+it "the body is not indented by a second mechanism"
+# A margin on .contents would be another copy of the column, drifting the
+# moment the stamp changes.
+assert_file_not_contains "$al_post" '.contents { display: block; max-width: 60ch; width: 100%; min-width: 0; margin-left'
+
+it "long words cannot widen the page"
+# A bare URL has no spaces to wrap at; one reference link made a post 375px
+# wide inside a 320px viewport.
+assert_file_contains "$al_post" 'article a { color: var(--link-article); overflow-wrap: break-word; }'
+
+it "a wide table scrolls inside its own box"
+# overflow-x alone does not let the box shrink below its content: without
+# min-width the table pushed the document sideways instead of scrolling.
+assert_file_contains "$al_post" 'article .table-scroll { overflow-x: auto; margin: 1em 0; min-width: 0; max-width: 100%; }'
 
 it "the build is clean"
 assert_eq "$stderr_output" ""
@@ -989,7 +954,10 @@ it "the diagram box takes the body column, not its shrink-wrapped width"
 # .contents is an inline-block, so a block inside it shrink-wraps to its
 # content; the diagram sat small against a full-width column until the
 # container was told to take the width.
-assert_file_contains "$dg/.dist/chart.html" '.diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }'
+assert_file_contains "$dg/.dist/chart.html" '.diagram { margin: 1.5em 0; overflow-x: auto; display: block;'
+# min-width is what lets it shrink: without it a narrow screen scrolled the
+# whole page sideways instead of scrolling the diagram in its own box.
+assert_file_contains "$dg/.dist/chart.html" 'width: 100%; min-width: 0; max-width: 100%; }'
 
 it "an unrendered diagram degrades to readable source"
 # If the script is absent or blocked, the element still holds what the author
@@ -1739,8 +1707,15 @@ hl=$("$GREP" -n 'class="home"' "$post_file_bk" | head -1 | cut -d: -f1)
   || fail "post back link at line $hl must come after the body at line $tl"
 
 it "the back link names where it goes"
-# A bare arrow says only "away". Both layouts render the site title beside it.
-assert_file_contains "$post_file_bk" '&#x2190;&#xFE0E; '
+# A bare arrow says only "away". It names the destination -- "index", not the
+# site title, which the header already carries two lines above.
+assert_file_contains "$post_file_bk" '&#x2190;&#xFE0E; Index</a>'
+assert_file_not_contains "$post_file_bk" '&#x2190;&#xFE0E; Test Blog'
+
+it "the back link is quieter than the body it follows"
+# It is an exit, not part of the reading. Set smaller and in caps, the way the
+# theme marks its other small labels.
+assert_file_contains "$post_file_bk" 'font-size: .85em; text-transform: uppercase;'
 
 it "posts and pages declare the same back link"
 # The whole point of moving it: both layouts end the same way. Compare the