ssh git@grit.deadkey.org man

Commit ea8a76b h

Pat Ambrosio · 26-08-27 22:18

Open diagrams full-screen in a modal on click

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 = '&#x2715;&#xFE0E;';
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 = '&#x2715;&#xFE0E;';
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 = '&#x2715;&#xFE0E;';
+	            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 = '&#x2715;&#xFE0E;';
+	            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