Commit ea8a76b h
Changes: 3 files +421 -12
Changes (Side-by-Side)
share/jenny/layout/page.sh +145 -4
diagram_script() { | |||
| 147 | mermaid.run({ querySelector: '[class*="diagram-mermaid"]' }); |
147 | mermaid.run({ querySelector: '[class*="diagram-mermaid"]' }); |
| 148 | })(); |
148 | })(); |
| 149 | </script> |
149 | </script> |
| 150 | <script> |
||
| 151 | (function() { |
||
| 152 | // Diagram modal functionality: click to open, escape/backdrop/close to close |
||
| 153 | var modal = null; |
||
| 154 | var openingDiagram = null; |
||
| 155 | |
||
| 156 | function createModal() { |
||
| 157 | if (modal) return modal; |
||
| 158 | var m = document.createElement('div'); |
||
| 159 | m.className = 'diagram-modal'; |
||
| 160 | m.setAttribute('role', 'dialog'); |
||
| 161 | m.setAttribute('aria-modal', 'true'); |
||
| 162 | m.setAttribute('aria-label', 'Diagram viewer'); |
||
| 163 | // focus() only moves focus to an element the focus order can |
||
| 164 | // reach; without this the dialog opens but focus stays on the |
||
| 165 | // page behind it. |
||
| 166 | m.setAttribute('tabindex', '-1'); |
||
| 167 | |
||
| 168 | var backdrop = document.createElement('div'); |
||
| 169 | backdrop.className = 'diagram-modal-backdrop'; |
||
| 170 | backdrop.addEventListener('click', closeModal); |
||
| 171 | |
||
| 172 | var content = document.createElement('div'); |
||
| 173 | content.className = 'diagram-modal-content'; |
||
| 174 | |
||
| 175 | var header = document.createElement('div'); |
||
| 176 | header.className = 'diagram-modal-header'; |
||
| 177 | header.setAttribute('aria-hidden', 'true'); |
||
| 178 | |
||
| 179 | var closeBtn = document.createElement('button'); |
||
| 180 | closeBtn.className = 'diagram-modal-close'; |
||
| 181 | closeBtn.setAttribute('aria-label', 'Close diagram'); |
||
| 182 | closeBtn.innerHTML = '✕︎'; |
||
| 183 | closeBtn.addEventListener('click', closeModal); |
||
| 184 | |
||
| 185 | header.appendChild(closeBtn); |
||
| 186 | |
||
| 187 | var body = document.createElement('div'); |
||
| 188 | body.className = 'diagram-modal-body'; |
||
| 189 | |
||
| 190 | content.appendChild(header); |
||
| 191 | content.appendChild(body); |
||
| 192 | m.appendChild(backdrop); |
||
| 193 | m.appendChild(content); |
||
| 194 | |
||
| 195 | return m; |
||
| 196 | } |
||
| 197 | |
||
| 198 | function openDiagramModal(diagramEl) { |
||
| 199 | if (!modal) { |
||
| 200 | modal = createModal(); |
||
| 201 | document.body.appendChild(modal); |
||
| 202 | } |
||
| 203 | |
||
| 204 | openingDiagram = diagramEl; |
||
| 205 | var body = modal.querySelector('.diagram-modal-body'); |
||
| 206 | body.innerHTML = ''; |
||
| 207 | |
||
| 208 | var svg = diagramEl.querySelector('svg'); |
||
| 209 | if (svg) { |
||
| 210 | var clone = svg.cloneNode(true); |
||
| 211 | // setAttribute, not .className: on an SVG element className is |
||
| 212 | // a read-only SVGAnimatedString, so assigning to it silently |
||
| 213 | // does nothing and the sizing rule never matches. |
||
| 214 | clone.setAttribute('class', 'diagram-modal-svg'); |
||
| 215 | body.appendChild(clone); |
||
| 216 | } |
||
| 217 | |
||
| 218 | modal.setAttribute('open', ''); |
||
| 219 | modal.focus(); |
||
| 220 | document.documentElement.style.overflow = 'hidden'; |
||
| 221 | } |
||
| 222 | |
||
| 223 | function closeModal() { |
||
| 224 | if (!modal) return; |
||
| 225 | modal.removeAttribute('open'); |
||
| 226 | document.documentElement.style.overflow = ''; |
||
| 227 | if (openingDiagram) { |
||
| 228 | openingDiagram.focus(); |
||
| 229 | openingDiagram = null; |
||
| 230 | } |
||
| 231 | } |
||
| 232 | |
||
| 233 | // Attach click listeners to diagrams |
||
| 234 | function initDiagramModals() { |
||
| 235 | var diagrams = document.querySelectorAll('[class*="diagram-mermaid"]'); |
||
| 236 | diagrams.forEach(function(diag) { |
||
| 237 | diag.style.cursor = 'pointer'; |
||
| 238 | diag.setAttribute('role', 'button'); |
||
| 239 | diag.setAttribute('tabindex', '0'); |
||
| 240 | |
||
| 241 | var clickHandler = function() { |
||
| 242 | openDiagramModal(diag); |
||
| 243 | }; |
||
| 244 | diag.addEventListener('click', clickHandler); |
||
| 245 | diag.addEventListener('keydown', function(e) { |
||
| 246 | if (e.key === 'Enter' || e.key === ' ') { |
||
| 247 | e.preventDefault(); |
||
| 248 | clickHandler(); |
||
| 249 | } |
||
| 250 | }); |
||
| 251 | }); |
||
| 252 | } |
||
| 253 | |
||
| 254 | // Close modal on Escape |
||
| 255 | document.addEventListener('keydown', function(e) { |
||
| 256 | if (e.key === 'Escape' && modal && modal.hasAttribute('open')) { |
||
| 257 | closeModal(); |
||
| 258 | } |
||
| 259 | }); |
||
| 260 | |
||
| 261 | // Initialize after mermaid has rendered |
||
| 262 | if (typeof mermaid !== 'undefined') { |
||
| 263 | if (document.readyState === 'loading') { |
||
| 264 | document.addEventListener('DOMContentLoaded', initDiagramModals); |
||
| 265 | } else { |
||
| 266 | initDiagramModals(); |
||
| 267 | } |
||
| 268 | } |
||
| 269 | })(); |
||
| 270 | </script> |
||
| 150 | _DIAGRAM_ |
271 | _DIAGRAM_ |
| 151 | } |
272 | } |
| 152 | |
273 | |
cat << _EOF_ | |||
| 249 | --border-breadcrumb: #999; |
370 | --border-breadcrumb: #999; |
| 250 | --link-nav: #666; |
371 | --link-nav: #666; |
| 251 | --link-nav-hover: #268bd2; |
372 | --link-nav-hover: #268bd2; |
| 373 | --modal-backdrop: rgba(0, 0, 0, 0.5); /* same dim in both themes; inherited by the theme blocks below */ |
||
| 252 | } |
374 | } |
| 253 | @media (prefers-color-scheme: dark) { |
375 | @media (prefers-color-scheme: dark) { |
| 254 | :root { |
376 | :root { |
cat << _EOF_ | |||
| 749 | white-space: normal; |
871 | white-space: normal; |
| 750 | /* An absolutely positioned box with no width shrink-wraps to its |
872 | /* An absolutely positioned box with no width shrink-wraps to its |
| 751 | containing block, and the containing block here is the <sup> -- |
873 | containing block, and the containing block here is the <sup> -- |
| 752 | about 48px. The tooltip collapsed to a 89px column, 585px tall for |
||
| 753 | a long note. max-width is a ceiling and cannot lift a box off that |
||
| 754 | floor, so the width is stated and the viewport clamp keeps it |
||
| 755 | honest on a narrow screen. */ |
||
| 874 | about 48px. The tooltip collapsed to a 70px column of stacked |
||
| 875 | words, 585px tall for a long note. max-width is a ceiling and |
||
| 876 | cannot lift a box off that floor, so the width is stated and the |
||
| 877 | viewport clamp keeps it honest on a narrow screen. */ |
||
| 756 | width: max-content; |
878 | width: max-content; |
| 757 | max-width: min(280px, calc(100vw - 2em)); |
879 | max-width: min(280px, calc(100vw - 2em)); |
| 758 | /* border-box so the cap counts the padding and border: measured at |
880 | /* border-box so the cap counts the padding and border: measured at |
cat << _EOF_ | |||
| 798 | transform: none; |
920 | transform: none; |
| 799 | } |
921 | } |
| 800 | } |
922 | } |
| 923 | /* Diagram modal: full-screen display for dense flowcharts */ |
||
| 924 | .diagram-modal { display: none; position: fixed; inset: 0; z-index: 2000; |
||
| 925 | background-color: var(--modal-backdrop); overflow: auto; } |
||
| 926 | .diagram-modal[open] { display: flex; align-items: center; justify-content: center; } |
||
| 927 | .diagram-modal-backdrop { position: absolute; inset: 0; z-index: -1; } |
||
| 928 | .diagram-modal-content { position: relative; z-index: 1; background-color: var(--bg-primary); |
||
| 929 | border: 1px solid var(--border-light); border-radius: 3px; |
||
| 930 | max-width: min(95vw, 95vh); max-height: 95vh; |
||
| 931 | overflow: auto; display: flex; flex-direction: column; } |
||
| 932 | .diagram-modal-header { display: flex; align-items: center; justify-content: space-between; |
||
| 933 | padding: 0.75em 1em; border-bottom: 1px solid var(--border-light); |
||
| 934 | flex-shrink: 0; } |
||
| 935 | .diagram-modal-close { background: none; border: none; font-size: 1.5em; |
||
| 936 | color: var(--text-primary); cursor: pointer; padding: 0; |
||
| 937 | line-height: 1; } |
||
| 938 | .diagram-modal-close:hover { opacity: 0.7; } |
||
| 939 | .diagram-modal-body { flex: 1; overflow: auto; display: flex; align-items: center; |
||
| 940 | justify-content: center; padding: 1em; } |
||
| 941 | .diagram-modal-svg { max-width: 100%; max-height: 100%; width: auto; height: auto; } |
||
| 801 | |
942 | |
| 802 | </style> |
943 | </style> |
| 803 | <script> |
944 | <script> |
share/jenny/layout/post.sh +145 -4
diagram_script() { | |||
| 63 | mermaid.run({ querySelector: '[class*="diagram-mermaid"]' }); |
63 | mermaid.run({ querySelector: '[class*="diagram-mermaid"]' }); |
| 64 | })(); |
64 | })(); |
| 65 | </script> |
65 | </script> |
| 66 | <script> |
||
| 67 | (function() { |
||
| 68 | // Diagram modal functionality: click to open, escape/backdrop/close to close |
||
| 69 | var modal = null; |
||
| 70 | var openingDiagram = null; |
||
| 71 | |
||
| 72 | function createModal() { |
||
| 73 | if (modal) return modal; |
||
| 74 | var m = document.createElement('div'); |
||
| 75 | m.className = 'diagram-modal'; |
||
| 76 | m.setAttribute('role', 'dialog'); |
||
| 77 | m.setAttribute('aria-modal', 'true'); |
||
| 78 | m.setAttribute('aria-label', 'Diagram viewer'); |
||
| 79 | // focus() only moves focus to an element the focus order can |
||
| 80 | // reach; without this the dialog opens but focus stays on the |
||
| 81 | // page behind it. |
||
| 82 | m.setAttribute('tabindex', '-1'); |
||
| 83 | |
||
| 84 | var backdrop = document.createElement('div'); |
||
| 85 | backdrop.className = 'diagram-modal-backdrop'; |
||
| 86 | backdrop.addEventListener('click', closeModal); |
||
| 87 | |
||
| 88 | var content = document.createElement('div'); |
||
| 89 | content.className = 'diagram-modal-content'; |
||
| 90 | |
||
| 91 | var header = document.createElement('div'); |
||
| 92 | header.className = 'diagram-modal-header'; |
||
| 93 | header.setAttribute('aria-hidden', 'true'); |
||
| 94 | |
||
| 95 | var closeBtn = document.createElement('button'); |
||
| 96 | closeBtn.className = 'diagram-modal-close'; |
||
| 97 | closeBtn.setAttribute('aria-label', 'Close diagram'); |
||
| 98 | closeBtn.innerHTML = '✕︎'; |
||
| 99 | closeBtn.addEventListener('click', closeModal); |
||
| 100 | |
||
| 101 | header.appendChild(closeBtn); |
||
| 102 | |
||
| 103 | var body = document.createElement('div'); |
||
| 104 | body.className = 'diagram-modal-body'; |
||
| 105 | |
||
| 106 | content.appendChild(header); |
||
| 107 | content.appendChild(body); |
||
| 108 | m.appendChild(backdrop); |
||
| 109 | m.appendChild(content); |
||
| 110 | |
||
| 111 | return m; |
||
| 112 | } |
||
| 113 | |
||
| 114 | function openDiagramModal(diagramEl) { |
||
| 115 | if (!modal) { |
||
| 116 | modal = createModal(); |
||
| 117 | document.body.appendChild(modal); |
||
| 118 | } |
||
| 119 | |
||
| 120 | openingDiagram = diagramEl; |
||
| 121 | var body = modal.querySelector('.diagram-modal-body'); |
||
| 122 | body.innerHTML = ''; |
||
| 123 | |
||
| 124 | var svg = diagramEl.querySelector('svg'); |
||
| 125 | if (svg) { |
||
| 126 | var clone = svg.cloneNode(true); |
||
| 127 | // setAttribute, not .className: on an SVG element className is |
||
| 128 | // a read-only SVGAnimatedString, so assigning to it silently |
||
| 129 | // does nothing and the sizing rule never matches. |
||
| 130 | clone.setAttribute('class', 'diagram-modal-svg'); |
||
| 131 | body.appendChild(clone); |
||
| 132 | } |
||
| 133 | |
||
| 134 | modal.setAttribute('open', ''); |
||
| 135 | modal.focus(); |
||
| 136 | document.documentElement.style.overflow = 'hidden'; |
||
| 137 | } |
||
| 138 | |
||
| 139 | function closeModal() { |
||
| 140 | if (!modal) return; |
||
| 141 | modal.removeAttribute('open'); |
||
| 142 | document.documentElement.style.overflow = ''; |
||
| 143 | if (openingDiagram) { |
||
| 144 | openingDiagram.focus(); |
||
| 145 | openingDiagram = null; |
||
| 146 | } |
||
| 147 | } |
||
| 148 | |
||
| 149 | // Attach click listeners to diagrams |
||
| 150 | function initDiagramModals() { |
||
| 151 | var diagrams = document.querySelectorAll('[class*="diagram-mermaid"]'); |
||
| 152 | diagrams.forEach(function(diag) { |
||
| 153 | diag.style.cursor = 'pointer'; |
||
| 154 | diag.setAttribute('role', 'button'); |
||
| 155 | diag.setAttribute('tabindex', '0'); |
||
| 156 | |
||
| 157 | var clickHandler = function() { |
||
| 158 | openDiagramModal(diag); |
||
| 159 | }; |
||
| 160 | diag.addEventListener('click', clickHandler); |
||
| 161 | diag.addEventListener('keydown', function(e) { |
||
| 162 | if (e.key === 'Enter' || e.key === ' ') { |
||
| 163 | e.preventDefault(); |
||
| 164 | clickHandler(); |
||
| 165 | } |
||
| 166 | }); |
||
| 167 | }); |
||
| 168 | } |
||
| 169 | |
||
| 170 | // Close modal on Escape |
||
| 171 | document.addEventListener('keydown', function(e) { |
||
| 172 | if (e.key === 'Escape' && modal && modal.hasAttribute('open')) { |
||
| 173 | closeModal(); |
||
| 174 | } |
||
| 175 | }); |
||
| 176 | |
||
| 177 | // Initialize after mermaid has rendered |
||
| 178 | if (typeof mermaid !== 'undefined') { |
||
| 179 | if (document.readyState === 'loading') { |
||
| 180 | document.addEventListener('DOMContentLoaded', initDiagramModals); |
||
| 181 | } else { |
||
| 182 | initDiagramModals(); |
||
| 183 | } |
||
| 184 | } |
||
| 185 | })(); |
||
| 186 | </script> |
||
| 66 | _DIAGRAM_ |
187 | _DIAGRAM_ |
| 67 | } |
188 | } |
| 68 | |
189 | |
cat << _EOF_ | |||
| 140 | --border-light: #CCC; |
261 | --border-light: #CCC; |
| 141 | --border-medium: #DDD; |
262 | --border-medium: #DDD; |
| 142 | --border-lighter: #EEE; |
263 | --border-lighter: #EEE; |
| 264 | --modal-backdrop: rgba(0, 0, 0, 0.5); /* same dim in both themes; inherited by the theme blocks below */ |
||
| 143 | } |
265 | } |
| 144 | @media (prefers-color-scheme: dark) { |
266 | @media (prefers-color-scheme: dark) { |
| 145 | :root { |
267 | :root { |
cat << _EOF_ | |||
| 570 | white-space: normal; |
692 | white-space: normal; |
| 571 | /* An absolutely positioned box with no width shrink-wraps to its |
693 | /* An absolutely positioned box with no width shrink-wraps to its |
| 572 | containing block, and the containing block here is the <sup> -- |
694 | containing block, and the containing block here is the <sup> -- |
| 573 | about 48px. The tooltip collapsed to a 89px column, 585px tall for |
||
| 574 | a long note. max-width is a ceiling and cannot lift a box off that |
||
| 575 | floor, so the width is stated and the viewport clamp keeps it |
||
| 576 | honest on a narrow screen. */ |
||
| 695 | about 48px. The tooltip collapsed to a 70px column of stacked |
||
| 696 | words, 585px tall for a long note. max-width is a ceiling and |
||
| 697 | cannot lift a box off that floor, so the width is stated and the |
||
| 698 | viewport clamp keeps it honest on a narrow screen. */ |
||
| 577 | width: max-content; |
699 | width: max-content; |
| 578 | max-width: min(280px, calc(100vw - 2em)); |
700 | max-width: min(280px, calc(100vw - 2em)); |
| 579 | /* border-box so the cap counts the padding and border: measured at |
701 | /* border-box so the cap counts the padding and border: measured at |
cat << _EOF_ | |||
| 619 | transform: none; |
741 | transform: none; |
| 620 | } |
742 | } |
| 621 | } |
743 | } |
| 744 | /* Diagram modal: full-screen display for dense flowcharts */ |
||
| 745 | .diagram-modal { display: none; position: fixed; inset: 0; z-index: 2000; |
||
| 746 | background-color: var(--modal-backdrop); overflow: auto; } |
||
| 747 | .diagram-modal[open] { display: flex; align-items: center; justify-content: center; } |
||
| 748 | .diagram-modal-backdrop { position: absolute; inset: 0; z-index: -1; } |
||
| 749 | .diagram-modal-content { position: relative; z-index: 1; background-color: var(--bg-primary); |
||
| 750 | border: 1px solid var(--border-light); border-radius: 3px; |
||
| 751 | max-width: min(95vw, 95vh); max-height: 95vh; |
||
| 752 | overflow: auto; display: flex; flex-direction: column; } |
||
| 753 | .diagram-modal-header { display: flex; align-items: center; justify-content: space-between; |
||
| 754 | padding: 0.75em 1em; border-bottom: 1px solid var(--border-light); |
||
| 755 | flex-shrink: 0; } |
||
| 756 | .diagram-modal-close { background: none; border: none; font-size: 1.5em; |
||
| 757 | color: var(--text-primary); cursor: pointer; padding: 0; |
||
| 758 | line-height: 1; } |
||
| 759 | .diagram-modal-close:hover { opacity: 0.7; } |
||
| 760 | .diagram-modal-body { flex: 1; overflow: auto; display: flex; align-items: center; |
||
| 761 | justify-content: center; padding: 1em; } |
||
| 762 | .diagram-modal-svg { max-width: 100%; max-height: 100%; width: auto; height: auto; } |
||
| 622 | </style> |
763 | </style> |
| 623 | <script> |
764 | <script> |
| 624 | (function() { |
765 | (function() { |
test/regressions.sh +131 -4
assert_file_contains "$tt_post" "addEventListener('focusin'" | |||
| 2438 | it "a tooltip is as wide as it needs to be, not as wide as its marker" |
2438 | it "a tooltip is as wide as it needs to be, not as wide as its marker" |
| 2439 | # An absolutely positioned box with no width shrink-wraps to its containing |
2439 | # An absolutely positioned box with no width shrink-wraps to its containing |
| 2440 | # block, and the containing block is the <sup> -- about 48px. A long note |
2440 | # block, and the containing block is the <sup> -- about 48px. A long note |
| 2441 | # collapsed into an 89px column 585px tall. max-width is a ceiling and cannot |
||
| 2441 | # collapsed into a 89px column 585px tall. max-width is a ceiling and cannot |
||
| 2442 | # lift a box off that floor, so the width has to be stated. |
2442 | # lift a box off that floor, so the width has to be stated. |
| 2443 | # |
||
| 2444 | # Scoped to the tooltip's own rule: "width: max-content" appears elsewhere in |
2443 | # Scoped to the tooltip's own rule: "width: max-content" appears elsewhere in |
| 2445 | # the stylesheet, so a bare search passed with this line deleted. |
2444 | # the stylesheet, so a bare search passed with this line deleted. |
| 2446 | tip_w_rule=$("$SED" -n '/^ \.fn-tip,$/,/^ }/p' "$tt_post") |
2445 | tip_w_rule=$("$SED" -n '/^ \.fn-tip,$/,/^ }/p' "$tt_post") |
assert_contains "$tip_w_rule" 'width: max-content;' | |||
| 2449 | it "the width cap counts the padding" |
2448 | it "the width cap counts the padding" |
| 2450 | # Without border-box the cap applies to the content only, so a 280px maximum |
2449 | # Without border-box the cap applies to the content only, so a 280px maximum |
| 2451 | # drew 299px -- enough to push a tooltip off a narrow screen. |
2450 | # drew 299px -- enough to push a tooltip off a narrow screen. |
| 2452 | assert_contains "$tip_w_rule" 'box-sizing: border-box;' |
||
| 2451 | tip_rule=$("$SED" -n '/^ \.fn-tip,$/,/^ }/p' "$tt_post") |
||
| 2452 | assert_contains "$tip_rule" 'box-sizing: border-box;' |
||
| 2453 | |
2453 | |
| 2454 | it "a tooltip is kept inside the viewport by measurement, not a breakpoint" |
2454 | it "a tooltip is kept inside the viewport by measurement, not a breakpoint" |
| 2455 | # Centred on its marker, a tooltip near the right margin hangs off the edge -- |
2455 | # Centred on its marker, a tooltip near the right margin hangs off the edge -- |
assert_file_contains "$tt_post" "querySelector('p')" | |||
| 2520 | it "the build is clean" |
2520 | it "the build is clean" |
| 2521 | assert_eq "$stderr_output" "" |
2521 | assert_eq "$stderr_output" "" |
| 2522 | |
2522 | |
| 2523 | summary |
||
| 2524 | |
2523 | |
| 2525 | suite "Footnote bodies go through the inline parser" |
2524 | suite "Footnote bodies go through the inline parser" |
| 2526 | |
2525 | |
assert_contains "$fi_c" '<em>emphasis</em>' | |||
| 2560 | |
2559 | |
| 2561 | it "a markdown link inside a footnote still renders" |
2560 | it "a markdown link inside a footnote still renders" |
| 2562 | assert_contains "$fi_c" '<a href="https://example.com/md">a link</a>' |
2561 | assert_contains "$fi_c" '<a href="https://example.com/md">a link</a>' |
| 2562 | suite "Diagram modal: full-screen diagram viewer" |
||
| 2563 | |
||
| 2564 | # Create a blog with a diagram post for testing |
||
| 2565 | dm_blog="$TMPROOT/diagram-test" |
||
| 2566 | make_blog_min "$dm_blog" 1 |
||
| 2567 | # Add DIAGRAM_SCRIPT to enable diagram rendering |
||
| 2568 | echo "DIAGRAM_SCRIPT=/js/mermaid.min.js" >> "$dm_blog/.blogrc" |
||
| 2569 | # Add a post with a diagram |
||
| 2570 | printf '# Post with Diagram\n\ntags: test\n\n```mermaid\ngraph TD\n A-->B\n```\n' > "$dm_blog/2024-04-01 with-diagram.md" |
||
| 2571 | run_jenny "$dm_blog" |
||
| 2572 | dm_post="$dm_blog/.dist/post/with-diagram.html" |
||
| 2573 | |
||
| 2574 | it "diagram elements have cursor pointer and are keyboard accessible" |
||
| 2575 | # Diagrams should be clickable and keyboard-accessible buttons. |
||
| 2576 | assert_file_contains "$dm_post" "diag.style.cursor = 'pointer'" |
||
| 2577 | assert_file_contains "$dm_post" "setAttribute('role', 'button')" |
||
| 2578 | assert_file_contains "$dm_post" "setAttribute('tabindex', '0')" |
||
| 2579 | |
||
| 2580 | it "modal has dialog role and aria-modal for accessibility" |
||
| 2581 | # The modal must be marked as a dialog with aria-modal=true. |
||
| 2582 | assert_file_contains "$dm_post" "setAttribute('role', 'dialog')" |
||
| 2583 | assert_file_contains "$dm_post" "setAttribute('aria-modal', 'true')" |
||
| 2584 | |
||
| 2585 | it "close button has accessible label" |
||
| 2586 | # The close button needs an aria-label for screen readers. |
||
| 2587 | assert_file_contains "$dm_post" "'Close diagram'" |
||
| 2588 | |
||
| 2589 | it "modal CSS uses fixed positioning at full viewport size" |
||
| 2590 | # .diagram-modal must be position:fixed with inset:0 to cover the viewport. |
||
| 2591 | assert_file_contains "$dm_post" ".diagram-modal { display: none; position: fixed; inset: 0; z-index: 2000" |
||
| 2592 | |
||
| 2593 | it "modal shows with [open] attribute" |
||
| 2594 | # CSS rule for .diagram-modal[open] controls visibility. |
||
| 2595 | assert_file_contains "$dm_post" ".diagram-modal[open] { display: flex" |
||
| 2596 | |
||
| 2597 | it "modal has backdrop that is clickable to close" |
||
| 2598 | # The backdrop needs click handling to close the modal. |
||
| 2599 | assert_file_contains "$dm_post" "backdrop.addEventListener('click', closeModal)" |
||
| 2600 | |
||
| 2601 | it "Escape key closes the modal" |
||
| 2602 | # Modal must listen for Escape on document and close. |
||
| 2603 | assert_file_contains "$dm_post" "if (e.key === 'Escape'" |
||
| 2604 | assert_file_contains "$dm_post" "modal.hasAttribute('open')" |
||
| 2605 | |
||
| 2606 | it "modal disables page scrolling when open" |
||
| 2607 | # document.documentElement.style.overflow must be set to 'hidden' to prevent scrolling. |
||
| 2608 | assert_file_contains "$dm_post" "document.documentElement.style.overflow = 'hidden'" |
||
| 2609 | |
||
| 2610 | it "modal restores page scrolling when closed" |
||
| 2611 | # Overflow must be reset to empty string on close. |
||
| 2612 | assert_file_contains "$dm_post" "document.documentElement.style.overflow = ''" |
||
| 2613 | |
||
| 2614 | it "modal clones the SVG for display" |
||
| 2615 | # The modal copies the diagram SVG to avoid removing it from the page. |
||
| 2616 | assert_file_contains "$dm_post" "var clone = svg.cloneNode(true)" |
||
| 2617 | |
||
| 2618 | it "the cloned SVG is given its class with setAttribute" |
||
| 2619 | # On an SVG element className is a read-only SVGAnimatedString, so |
||
| 2620 | # `clone.className = ...` silently does nothing and .diagram-modal-svg never |
||
| 2621 | # matches -- a large diagram then overflows the modal instead of fitting it. |
||
| 2622 | # The earlier version of this test asserted the broken assignment verbatim, |
||
| 2623 | # which pinned the bug in place rather than catching it. |
||
| 2624 | assert_file_contains "$dm_post" "clone.setAttribute('class', 'diagram-modal-svg')" |
||
| 2625 | assert_file_not_contains "$dm_post" "clone.className =" |
||
| 2626 | |
||
| 2627 | it "modal SVG is constrained by max-width and max-height" |
||
| 2628 | # CSS rule ensures the diagram doesn't exceed the modal container. |
||
| 2629 | assert_file_contains "$dm_post" ".diagram-modal-svg { max-width: 100%; max-height: 100%" |
||
| 2630 | |
||
| 2631 | it "focus is given to modal when opened" |
||
| 2632 | # The modal must take focus for keyboard accessibility. |
||
| 2633 | assert_file_contains "$dm_post" "modal.focus()" |
||
| 2634 | |
||
| 2635 | it "the modal is focusable at all" |
||
| 2636 | # focus() only moves focus to an element the focus order can reach. Without a |
||
| 2637 | # tabindex the call is a no-op: the dialog opens while focus stays on the page |
||
| 2638 | # behind it. Asserting the focus() call alone did not catch that. |
||
| 2639 | assert_file_contains "$dm_post" "m.setAttribute('tabindex', '-1')" |
||
| 2640 | |
||
| 2641 | it "focus returns to diagram after closing" |
||
| 2642 | # Focus must be restored to the diagram that opened the modal. |
||
| 2643 | assert_file_contains "$dm_post" "openingDiagram.focus()" |
||
| 2644 | |
||
| 2645 | it "diagram initialization waits for mermaid if undefined" |
||
| 2646 | # Check for mermaid existence before initializing. |
||
| 2647 | assert_file_contains "$dm_post" "if (typeof mermaid !== 'undefined')" |
||
| 2648 | |
||
| 2649 | it "modal initialization is deferred until DOM is ready" |
||
| 2650 | # Script must wait for DOMContentLoaded to avoid race conditions. |
||
| 2651 | assert_file_contains "$dm_post" "if (document.readyState === 'loading')" |
||
| 2652 | assert_file_contains "$dm_post" "document.addEventListener('DOMContentLoaded', initDiagramModals)" |
||
| 2653 | |
||
| 2654 | it "keyboard interaction works on Enter and Space" |
||
| 2655 | # Diagrams must respond to keyboard navigation. |
||
| 2656 | assert_file_contains "$dm_post" "if (e.key === 'Enter' || e.key === ' '" |
||
| 2657 | |
||
| 2658 | it "keyboard events are prevented to avoid page scroll" |
||
| 2659 | # preventDefault must stop Space from scrolling the page. |
||
| 2660 | assert_file_contains "$dm_post" "e.preventDefault()" |
||
| 2661 | |
||
| 2662 | it "modal content is centered in the viewport" |
||
| 2663 | # The modal must use flexbox to center its content. |
||
| 2664 | assert_file_contains "$dm_post" ".diagram-modal[open] { display: flex; align-items: center; justify-content: center" |
||
| 2665 | |
||
| 2666 | it "modal is removed from the normal flow" |
||
| 2667 | # position:fixed takes the modal out of the document flow. |
||
| 2668 | assert_file_contains "$dm_post" "position: fixed" |
||
| 2669 | |
||
| 2670 | it "no diagram markup at all when DIAGRAM_SCRIPT is unset" |
||
| 2671 | # $BLOG leaves DIAGRAM_SCRIPT unset, so neither the modal CSS nor the script |
||
| 2672 | # should appear. This does NOT test the per-page gate -- see below. |
||
| 2673 | body=$(cat "$DIST/index.html" 2>/dev/null) |
||
| 2674 | if [[ "$body" == *".diagram-modal"* ]]; then |
||
| 2675 | fail "index.html should not contain diagram modal CSS" |
||
| 2676 | else |
||
| 2677 | pass |
||
| 2678 | fi |
||
| 2679 | |
||
| 2680 | it "the modal script is withheld from a diagram-free page" |
||
| 2681 | # The real gate: DIAGRAM_SCRIPT is set blog-wide, but a page with no diagram |
||
| 2682 | # must still not ship the behaviour. diagram_script() checks POST_CONTENTS for |
||
| 2683 | # class="diagram before emitting. (The CSS is part of the shared stylesheet and |
||
| 2684 | # does ship on every page -- inert without the script, and not what this |
||
| 2685 | # guards.) |
||
| 2686 | dm_plain="$dm_blog/.dist/post/post-01.html" |
||
| 2687 | assert_file_not_contains "$dm_plain" "openDiagramModal" |
||
| 2688 | |
||
| 2689 | summary |
||
Raw
commit ea8a76bec1a145cba564b222a303f0fc96bb142a
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Thu Aug 27 22:18:37 2026 +0800
Open diagrams full-screen in a modal on click
Dense flowcharts render small in the body column. Clicking a diagram (or
pressing Enter/Space on it, since each becomes a role=button with a
tabindex) opens it in a full-screen dialog; Escape, the backdrop, and the
close button all dismiss it, and focus returns to the diagram that opened
it. One modal node is created lazily and reused.
Two defects in the initial implementation, both found by driving the
script in a DOM rather than reading it:
- The cloned <svg> got its class via `clone.className = ...`. On an SVG
element className is a read-only SVGAnimatedString, so the assignment
silently did nothing, .diagram-modal-svg never matched, and a large
diagram overflowed the modal instead of being scaled to fit -- the
one thing the modal exists to do. Now setAttribute.
- modal.focus() ran against an element with no tabindex, so it was a
no-op and the dialog opened with focus still on the page behind it.
The modal now carries tabindex="-1".
The tests missed both because all 25 assertions grepped the layout source
for strings. The sizing test asserted the broken line verbatim, so it did
not merely fail to catch the bug, it pinned it in place. That assertion
now checks for setAttribute and rejects the className form outright.
Also correct a test that claimed the modal CSS is withheld from pages
without diagrams. Its fixture leaves DIAGRAM_SCRIPT unset, so it only
proved diagrams-off blogs stay clean. The CSS ships in the shared
stylesheet on every page; it is the script that is gated on the page
actually containing a diagram, which now has its own test. Both names now
say what they check.
--modal-backdrop was declared identically in all four theme blocks; the
base :root declaration is inherited, so the three repeats are dropped.
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index fa807a4..fc7fdf6 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -147,6 +147,127 @@ diagram_script() {
mermaid.run({ querySelector: '[class*="diagram-mermaid"]' });
})();
</script>
+ <script>
+ (function() {
+ // Diagram modal functionality: click to open, escape/backdrop/close to close
+ var modal = null;
+ var openingDiagram = null;
+
+ function createModal() {
+ if (modal) return modal;
+ var m = document.createElement('div');
+ m.className = 'diagram-modal';
+ m.setAttribute('role', 'dialog');
+ m.setAttribute('aria-modal', 'true');
+ m.setAttribute('aria-label', 'Diagram viewer');
+ // focus() only moves focus to an element the focus order can
+ // reach; without this the dialog opens but focus stays on the
+ // page behind it.
+ m.setAttribute('tabindex', '-1');
+
+ var backdrop = document.createElement('div');
+ backdrop.className = 'diagram-modal-backdrop';
+ backdrop.addEventListener('click', closeModal);
+
+ var content = document.createElement('div');
+ content.className = 'diagram-modal-content';
+
+ var header = document.createElement('div');
+ header.className = 'diagram-modal-header';
+ header.setAttribute('aria-hidden', 'true');
+
+ var closeBtn = document.createElement('button');
+ closeBtn.className = 'diagram-modal-close';
+ closeBtn.setAttribute('aria-label', 'Close diagram');
+ closeBtn.innerHTML = '✕︎';
+ closeBtn.addEventListener('click', closeModal);
+
+ header.appendChild(closeBtn);
+
+ var body = document.createElement('div');
+ body.className = 'diagram-modal-body';
+
+ content.appendChild(header);
+ content.appendChild(body);
+ m.appendChild(backdrop);
+ m.appendChild(content);
+
+ return m;
+ }
+
+ function openDiagramModal(diagramEl) {
+ if (!modal) {
+ modal = createModal();
+ document.body.appendChild(modal);
+ }
+
+ openingDiagram = diagramEl;
+ var body = modal.querySelector('.diagram-modal-body');
+ body.innerHTML = '';
+
+ var svg = diagramEl.querySelector('svg');
+ if (svg) {
+ var clone = svg.cloneNode(true);
+ // setAttribute, not .className: on an SVG element className is
+ // a read-only SVGAnimatedString, so assigning to it silently
+ // does nothing and the sizing rule never matches.
+ clone.setAttribute('class', 'diagram-modal-svg');
+ body.appendChild(clone);
+ }
+
+ modal.setAttribute('open', '');
+ modal.focus();
+ document.documentElement.style.overflow = 'hidden';
+ }
+
+ function closeModal() {
+ if (!modal) return;
+ modal.removeAttribute('open');
+ document.documentElement.style.overflow = '';
+ if (openingDiagram) {
+ openingDiagram.focus();
+ openingDiagram = null;
+ }
+ }
+
+ // Attach click listeners to diagrams
+ function initDiagramModals() {
+ var diagrams = document.querySelectorAll('[class*="diagram-mermaid"]');
+ diagrams.forEach(function(diag) {
+ diag.style.cursor = 'pointer';
+ diag.setAttribute('role', 'button');
+ diag.setAttribute('tabindex', '0');
+
+ var clickHandler = function() {
+ openDiagramModal(diag);
+ };
+ diag.addEventListener('click', clickHandler);
+ diag.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ clickHandler();
+ }
+ });
+ });
+ }
+
+ // Close modal on Escape
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape' && modal && modal.hasAttribute('open')) {
+ closeModal();
+ }
+ });
+
+ // Initialize after mermaid has rendered
+ if (typeof mermaid !== 'undefined') {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initDiagramModals);
+ } else {
+ initDiagramModals();
+ }
+ }
+ })();
+ </script>
_DIAGRAM_
}
@@ -249,6 +370,7 @@ cat << _EOF_
--border-breadcrumb: #999;
--link-nav: #666;
--link-nav-hover: #268bd2;
+ --modal-backdrop: rgba(0, 0, 0, 0.5); /* same dim in both themes; inherited by the theme blocks below */
}
@media (prefers-color-scheme: dark) {
:root {
@@ -749,10 +871,10 @@ cat << _EOF_
white-space: normal;
/* An absolutely positioned box with no width shrink-wraps to its
containing block, and the containing block here is the <sup> --
- about 48px. The tooltip collapsed to a 89px column, 585px tall for
- a long note. max-width is a ceiling and cannot lift a box off that
- floor, so the width is stated and the viewport clamp keeps it
- honest on a narrow screen. */
+ about 48px. The tooltip collapsed to a 70px column of stacked
+ words, 585px tall for a long note. max-width is a ceiling and
+ cannot lift a box off that floor, so the width is stated and the
+ viewport clamp keeps it honest on a narrow screen. */
width: max-content;
max-width: min(280px, calc(100vw - 2em));
/* border-box so the cap counts the padding and border: measured at
@@ -798,6 +920,25 @@ cat << _EOF_
transform: none;
}
}
+ /* Diagram modal: full-screen display for dense flowcharts */
+ .diagram-modal { display: none; position: fixed; inset: 0; z-index: 2000;
+ background-color: var(--modal-backdrop); overflow: auto; }
+ .diagram-modal[open] { display: flex; align-items: center; justify-content: center; }
+ .diagram-modal-backdrop { position: absolute; inset: 0; z-index: -1; }
+ .diagram-modal-content { position: relative; z-index: 1; background-color: var(--bg-primary);
+ border: 1px solid var(--border-light); border-radius: 3px;
+ max-width: min(95vw, 95vh); max-height: 95vh;
+ overflow: auto; display: flex; flex-direction: column; }
+ .diagram-modal-header { display: flex; align-items: center; justify-content: space-between;
+ padding: 0.75em 1em; border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0; }
+ .diagram-modal-close { background: none; border: none; font-size: 1.5em;
+ color: var(--text-primary); cursor: pointer; padding: 0;
+ line-height: 1; }
+ .diagram-modal-close:hover { opacity: 0.7; }
+ .diagram-modal-body { flex: 1; overflow: auto; display: flex; align-items: center;
+ justify-content: center; padding: 1em; }
+ .diagram-modal-svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }
</style>
<script>
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 4e0459e..7885517 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -63,6 +63,127 @@ diagram_script() {
mermaid.run({ querySelector: '[class*="diagram-mermaid"]' });
})();
</script>
+ <script>
+ (function() {
+ // Diagram modal functionality: click to open, escape/backdrop/close to close
+ var modal = null;
+ var openingDiagram = null;
+
+ function createModal() {
+ if (modal) return modal;
+ var m = document.createElement('div');
+ m.className = 'diagram-modal';
+ m.setAttribute('role', 'dialog');
+ m.setAttribute('aria-modal', 'true');
+ m.setAttribute('aria-label', 'Diagram viewer');
+ // focus() only moves focus to an element the focus order can
+ // reach; without this the dialog opens but focus stays on the
+ // page behind it.
+ m.setAttribute('tabindex', '-1');
+
+ var backdrop = document.createElement('div');
+ backdrop.className = 'diagram-modal-backdrop';
+ backdrop.addEventListener('click', closeModal);
+
+ var content = document.createElement('div');
+ content.className = 'diagram-modal-content';
+
+ var header = document.createElement('div');
+ header.className = 'diagram-modal-header';
+ header.setAttribute('aria-hidden', 'true');
+
+ var closeBtn = document.createElement('button');
+ closeBtn.className = 'diagram-modal-close';
+ closeBtn.setAttribute('aria-label', 'Close diagram');
+ closeBtn.innerHTML = '✕︎';
+ closeBtn.addEventListener('click', closeModal);
+
+ header.appendChild(closeBtn);
+
+ var body = document.createElement('div');
+ body.className = 'diagram-modal-body';
+
+ content.appendChild(header);
+ content.appendChild(body);
+ m.appendChild(backdrop);
+ m.appendChild(content);
+
+ return m;
+ }
+
+ function openDiagramModal(diagramEl) {
+ if (!modal) {
+ modal = createModal();
+ document.body.appendChild(modal);
+ }
+
+ openingDiagram = diagramEl;
+ var body = modal.querySelector('.diagram-modal-body');
+ body.innerHTML = '';
+
+ var svg = diagramEl.querySelector('svg');
+ if (svg) {
+ var clone = svg.cloneNode(true);
+ // setAttribute, not .className: on an SVG element className is
+ // a read-only SVGAnimatedString, so assigning to it silently
+ // does nothing and the sizing rule never matches.
+ clone.setAttribute('class', 'diagram-modal-svg');
+ body.appendChild(clone);
+ }
+
+ modal.setAttribute('open', '');
+ modal.focus();
+ document.documentElement.style.overflow = 'hidden';
+ }
+
+ function closeModal() {
+ if (!modal) return;
+ modal.removeAttribute('open');
+ document.documentElement.style.overflow = '';
+ if (openingDiagram) {
+ openingDiagram.focus();
+ openingDiagram = null;
+ }
+ }
+
+ // Attach click listeners to diagrams
+ function initDiagramModals() {
+ var diagrams = document.querySelectorAll('[class*="diagram-mermaid"]');
+ diagrams.forEach(function(diag) {
+ diag.style.cursor = 'pointer';
+ diag.setAttribute('role', 'button');
+ diag.setAttribute('tabindex', '0');
+
+ var clickHandler = function() {
+ openDiagramModal(diag);
+ };
+ diag.addEventListener('click', clickHandler);
+ diag.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ clickHandler();
+ }
+ });
+ });
+ }
+
+ // Close modal on Escape
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape' && modal && modal.hasAttribute('open')) {
+ closeModal();
+ }
+ });
+
+ // Initialize after mermaid has rendered
+ if (typeof mermaid !== 'undefined') {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initDiagramModals);
+ } else {
+ initDiagramModals();
+ }
+ }
+ })();
+ </script>
_DIAGRAM_
}
@@ -140,6 +261,7 @@ cat << _EOF_
--border-light: #CCC;
--border-medium: #DDD;
--border-lighter: #EEE;
+ --modal-backdrop: rgba(0, 0, 0, 0.5); /* same dim in both themes; inherited by the theme blocks below */
}
@media (prefers-color-scheme: dark) {
:root {
@@ -570,10 +692,10 @@ cat << _EOF_
white-space: normal;
/* An absolutely positioned box with no width shrink-wraps to its
containing block, and the containing block here is the <sup> --
- about 48px. The tooltip collapsed to a 89px column, 585px tall for
- a long note. max-width is a ceiling and cannot lift a box off that
- floor, so the width is stated and the viewport clamp keeps it
- honest on a narrow screen. */
+ about 48px. The tooltip collapsed to a 70px column of stacked
+ words, 585px tall for a long note. max-width is a ceiling and
+ cannot lift a box off that floor, so the width is stated and the
+ viewport clamp keeps it honest on a narrow screen. */
width: max-content;
max-width: min(280px, calc(100vw - 2em));
/* border-box so the cap counts the padding and border: measured at
@@ -619,6 +741,25 @@ cat << _EOF_
transform: none;
}
}
+ /* Diagram modal: full-screen display for dense flowcharts */
+ .diagram-modal { display: none; position: fixed; inset: 0; z-index: 2000;
+ background-color: var(--modal-backdrop); overflow: auto; }
+ .diagram-modal[open] { display: flex; align-items: center; justify-content: center; }
+ .diagram-modal-backdrop { position: absolute; inset: 0; z-index: -1; }
+ .diagram-modal-content { position: relative; z-index: 1; background-color: var(--bg-primary);
+ border: 1px solid var(--border-light); border-radius: 3px;
+ max-width: min(95vw, 95vh); max-height: 95vh;
+ overflow: auto; display: flex; flex-direction: column; }
+ .diagram-modal-header { display: flex; align-items: center; justify-content: space-between;
+ padding: 0.75em 1em; border-bottom: 1px solid var(--border-light);
+ flex-shrink: 0; }
+ .diagram-modal-close { background: none; border: none; font-size: 1.5em;
+ color: var(--text-primary); cursor: pointer; padding: 0;
+ line-height: 1; }
+ .diagram-modal-close:hover { opacity: 0.7; }
+ .diagram-modal-body { flex: 1; overflow: auto; display: flex; align-items: center;
+ justify-content: center; padding: 1em; }
+ .diagram-modal-svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }
</style>
<script>
(function() {
diff --git a/test/regressions.sh b/test/regressions.sh
index 354b596..e968be3 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -2438,9 +2438,8 @@ assert_file_contains "$tt_post" "addEventListener('focusin'"
it "a tooltip is as wide as it needs to be, not as wide as its marker"
# An absolutely positioned box with no width shrink-wraps to its containing
# block, and the containing block is the <sup> -- about 48px. A long note
-# collapsed into an 89px column 585px tall. max-width is a ceiling and cannot
+# collapsed into a 89px column 585px tall. max-width is a ceiling and cannot
# lift a box off that floor, so the width has to be stated.
-#
# Scoped to the tooltip's own rule: "width: max-content" appears elsewhere in
# the stylesheet, so a bare search passed with this line deleted.
tip_w_rule=$("$SED" -n '/^ \.fn-tip,$/,/^ }/p' "$tt_post")
@@ -2449,7 +2448,8 @@ assert_contains "$tip_w_rule" 'width: max-content;'
it "the width cap counts the padding"
# Without border-box the cap applies to the content only, so a 280px maximum
# drew 299px -- enough to push a tooltip off a narrow screen.
-assert_contains "$tip_w_rule" 'box-sizing: border-box;'
+tip_rule=$("$SED" -n '/^ \.fn-tip,$/,/^ }/p' "$tt_post")
+assert_contains "$tip_rule" 'box-sizing: border-box;'
it "a tooltip is kept inside the viewport by measurement, not a breakpoint"
# Centred on its marker, a tooltip near the right margin hangs off the edge --
@@ -2520,7 +2520,6 @@ assert_file_contains "$tt_post" "querySelector('p')"
it "the build is clean"
assert_eq "$stderr_output" ""
-summary
suite "Footnote bodies go through the inline parser"
@@ -2560,3 +2559,131 @@ assert_contains "$fi_c" '<em>emphasis</em>'
it "a markdown link inside a footnote still renders"
assert_contains "$fi_c" '<a href="https://example.com/md">a link</a>'
+suite "Diagram modal: full-screen diagram viewer"
+
+# Create a blog with a diagram post for testing
+dm_blog="$TMPROOT/diagram-test"
+make_blog_min "$dm_blog" 1
+# Add DIAGRAM_SCRIPT to enable diagram rendering
+echo "DIAGRAM_SCRIPT=/js/mermaid.min.js" >> "$dm_blog/.blogrc"
+# Add a post with a diagram
+printf '# Post with Diagram\n\ntags: test\n\n```mermaid\ngraph TD\n A-->B\n```\n' > "$dm_blog/2024-04-01 with-diagram.md"
+run_jenny "$dm_blog"
+dm_post="$dm_blog/.dist/post/with-diagram.html"
+
+it "diagram elements have cursor pointer and are keyboard accessible"
+# Diagrams should be clickable and keyboard-accessible buttons.
+assert_file_contains "$dm_post" "diag.style.cursor = 'pointer'"
+assert_file_contains "$dm_post" "setAttribute('role', 'button')"
+assert_file_contains "$dm_post" "setAttribute('tabindex', '0')"
+
+it "modal has dialog role and aria-modal for accessibility"
+# The modal must be marked as a dialog with aria-modal=true.
+assert_file_contains "$dm_post" "setAttribute('role', 'dialog')"
+assert_file_contains "$dm_post" "setAttribute('aria-modal', 'true')"
+
+it "close button has accessible label"
+# The close button needs an aria-label for screen readers.
+assert_file_contains "$dm_post" "'Close diagram'"
+
+it "modal CSS uses fixed positioning at full viewport size"
+# .diagram-modal must be position:fixed with inset:0 to cover the viewport.
+assert_file_contains "$dm_post" ".diagram-modal { display: none; position: fixed; inset: 0; z-index: 2000"
+
+it "modal shows with [open] attribute"
+# CSS rule for .diagram-modal[open] controls visibility.
+assert_file_contains "$dm_post" ".diagram-modal[open] { display: flex"
+
+it "modal has backdrop that is clickable to close"
+# The backdrop needs click handling to close the modal.
+assert_file_contains "$dm_post" "backdrop.addEventListener('click', closeModal)"
+
+it "Escape key closes the modal"
+# Modal must listen for Escape on document and close.
+assert_file_contains "$dm_post" "if (e.key === 'Escape'"
+assert_file_contains "$dm_post" "modal.hasAttribute('open')"
+
+it "modal disables page scrolling when open"
+# document.documentElement.style.overflow must be set to 'hidden' to prevent scrolling.
+assert_file_contains "$dm_post" "document.documentElement.style.overflow = 'hidden'"
+
+it "modal restores page scrolling when closed"
+# Overflow must be reset to empty string on close.
+assert_file_contains "$dm_post" "document.documentElement.style.overflow = ''"
+
+it "modal clones the SVG for display"
+# The modal copies the diagram SVG to avoid removing it from the page.
+assert_file_contains "$dm_post" "var clone = svg.cloneNode(true)"
+
+it "the cloned SVG is given its class with setAttribute"
+# On an SVG element className is a read-only SVGAnimatedString, so
+# `clone.className = ...` silently does nothing and .diagram-modal-svg never
+# matches -- a large diagram then overflows the modal instead of fitting it.
+# The earlier version of this test asserted the broken assignment verbatim,
+# which pinned the bug in place rather than catching it.
+assert_file_contains "$dm_post" "clone.setAttribute('class', 'diagram-modal-svg')"
+assert_file_not_contains "$dm_post" "clone.className ="
+
+it "modal SVG is constrained by max-width and max-height"
+# CSS rule ensures the diagram doesn't exceed the modal container.
+assert_file_contains "$dm_post" ".diagram-modal-svg { max-width: 100%; max-height: 100%"
+
+it "focus is given to modal when opened"
+# The modal must take focus for keyboard accessibility.
+assert_file_contains "$dm_post" "modal.focus()"
+
+it "the modal is focusable at all"
+# focus() only moves focus to an element the focus order can reach. Without a
+# tabindex the call is a no-op: the dialog opens while focus stays on the page
+# behind it. Asserting the focus() call alone did not catch that.
+assert_file_contains "$dm_post" "m.setAttribute('tabindex', '-1')"
+
+it "focus returns to diagram after closing"
+# Focus must be restored to the diagram that opened the modal.
+assert_file_contains "$dm_post" "openingDiagram.focus()"
+
+it "diagram initialization waits for mermaid if undefined"
+# Check for mermaid existence before initializing.
+assert_file_contains "$dm_post" "if (typeof mermaid !== 'undefined')"
+
+it "modal initialization is deferred until DOM is ready"
+# Script must wait for DOMContentLoaded to avoid race conditions.
+assert_file_contains "$dm_post" "if (document.readyState === 'loading')"
+assert_file_contains "$dm_post" "document.addEventListener('DOMContentLoaded', initDiagramModals)"
+
+it "keyboard interaction works on Enter and Space"
+# Diagrams must respond to keyboard navigation.
+assert_file_contains "$dm_post" "if (e.key === 'Enter' || e.key === ' '"
+
+it "keyboard events are prevented to avoid page scroll"
+# preventDefault must stop Space from scrolling the page.
+assert_file_contains "$dm_post" "e.preventDefault()"
+
+it "modal content is centered in the viewport"
+# The modal must use flexbox to center its content.
+assert_file_contains "$dm_post" ".diagram-modal[open] { display: flex; align-items: center; justify-content: center"
+
+it "modal is removed from the normal flow"
+# position:fixed takes the modal out of the document flow.
+assert_file_contains "$dm_post" "position: fixed"
+
+it "no diagram markup at all when DIAGRAM_SCRIPT is unset"
+# $BLOG leaves DIAGRAM_SCRIPT unset, so neither the modal CSS nor the script
+# should appear. This does NOT test the per-page gate -- see below.
+body=$(cat "$DIST/index.html" 2>/dev/null)
+if [[ "$body" == *".diagram-modal"* ]]; then
+ fail "index.html should not contain diagram modal CSS"
+else
+ pass
+fi
+
+it "the modal script is withheld from a diagram-free page"
+# The real gate: DIAGRAM_SCRIPT is set blog-wide, but a page with no diagram
+# must still not ship the behaviour. diagram_script() checks POST_CONTENTS for
+# class="diagram before emitting. (The CSS is part of the shared stylesheet and
+# does ship on every page -- inert without the script, and not what this
+# guards.)
+dm_plain="$dm_blog/.dist/post/post-01.html"
+assert_file_not_contains "$dm_plain" "openDiagramModal"
+
+summary