From 36fa6fdf76f09687544dbd2a5a2d06741524d6c4 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Thu, 23 Jul 2026 10:23:39 +0100 Subject: [PATCH 1/3] Fix stacked modals on Mermaid diagrams and squished figure captions Mermaid diagrams are wrapped in an imageblock div, so the generic expand-images lightbox bound to them alongside the dedicated Mermaid zoom modal. One click opened both overlays stacked, and closing the Mermaid modal left the plain lightbox behind. Skip imageblocks that contain a Mermaid diagram when binding the generic lightbox. Mermaid SVGs have no internal bottom padding, so the 0.5rem figure caption margin looked squished against the diagram. Give Mermaid captions 1.25rem. Also remove a dead, duplicate #modal-overlay div from the feedback form partial. Nothing references it, and the duplicate id breaks getElementById-based checks against the real overlay. --- src/css/mermaid.css | 6 ++++++ src/js/07-expand-images.js | 3 +++ src/partials/feedback-forms.hbs | 2 -- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/css/mermaid.css b/src/css/mermaid.css index 42b304e4..8a01222b 100644 --- a/src/css/mermaid.css +++ b/src/css/mermaid.css @@ -4,6 +4,12 @@ text-align: center; } +/* Mermaid SVGs have no internal bottom padding, so the figure caption + needs more separation than a regular image caption. */ +.doc .imageblock .mermaid + .title { + margin-top: 1.25rem; +} + .mermaid > svg { background-color: var(--body-background) !important; max-width: 100%; diff --git a/src/js/07-expand-images.js b/src/js/07-expand-images.js index 98c87948..04b6db4d 100644 --- a/src/js/07-expand-images.js +++ b/src/js/07-expand-images.js @@ -18,6 +18,9 @@ if (!blocks.length || !modalOverlay) return blocks.forEach((block) => { + // Mermaid diagrams have their own zoom modal (20-mermaid-zoom.js). + // Binding both opens two stacked modals for one click. + if (block.querySelector('.mermaid')) return block.addEventListener('click', function (e) { const media = block.querySelector('img, svg') if (!media) return diff --git a/src/partials/feedback-forms.hbs b/src/partials/feedback-forms.hbs index 8d70f270..b58d4a25 100644 --- a/src/partials/feedback-forms.hbs +++ b/src/partials/feedback-forms.hbs @@ -43,6 +43,4 @@ - - From ad9fde538c4e8b975adca49abdc8004e62426e76 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Thu, 23 Jul 2026 11:33:03 +0100 Subject: [PATCH 2/3] Add vertical space between Mermaid subgraph labels and nested subgraphs Mermaid v10 and v11 flowcharts never reserve vertical space for a cluster label above nested subgraphs: the label overlaps the inner boxes' top borders, and neither flowchart.subGraphTitleMargin nor flowchart.padding changes the label-to-content distance (top shifts the label down into the content, bottom only grows the box downward). Post-process the rendered SVG instead: for each cluster whose label sits within 12px of a fully-contained child cluster, shift the label up, grow the cluster rect, and expand the viewBox for top-level clusters. --- src/js/08-mermaid-label-spacing.js | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/js/08-mermaid-label-spacing.js diff --git a/src/js/08-mermaid-label-spacing.js b/src/js/08-mermaid-label-spacing.js new file mode 100644 index 00000000..c45bedce --- /dev/null +++ b/src/js/08-mermaid-label-spacing.js @@ -0,0 +1,77 @@ +;(function () { + 'use strict' + + // Mermaid (v10 and v11) never reserves vertical space between a subgraph + // label and nested subgraphs: the label overlaps the inner boxes' top + // borders, and neither flowchart.subGraphTitleMargin nor flowchart.padding + // moves the children. Fix the rendered SVG instead: shift the label up and + // grow the cluster rect (and the viewBox for top-level clusters) to match. + + const MIN_GAP = 12 + + if (!document.querySelector('.mermaid')) return + + function fixSvg (svg) { + const clusters = Array.from(svg.querySelectorAll('g.cluster')) + let viewBoxGrow = 0 + clusters.forEach(function (cluster) { + const rect = cluster.querySelector('rect') + const label = cluster.querySelector('.cluster-label') + if (!rect || !label) return + const rx = parseFloat(rect.getAttribute('x')) + const ry = parseFloat(rect.getAttribute('y')) + const rw = parseFloat(rect.getAttribute('width')) + const rh = parseFloat(rect.getAttribute('height')) + const m = (label.getAttribute('transform') || '').match(/translate\(\s*([-\d.]+)[ ,]\s*([-\d.]+)\s*\)/) + if (!m) return + const lx = parseFloat(m[1]) + const ly = parseFloat(m[2]) + let lh + try { + lh = label.getBBox().height + } catch (e) { + return // not rendered (display: none); leave untouched + } + // Find the highest cluster rect fully contained in this one + let minChildTop = Infinity + clusters.forEach(function (other) { + if (other === cluster) return + const or = other.querySelector('rect') + if (!or) return + const ox = parseFloat(or.getAttribute('x')) + const oy = parseFloat(or.getAttribute('y')) + const ow = parseFloat(or.getAttribute('width')) + const oh = parseFloat(or.getAttribute('height')) + const contained = ox >= rx - 1 && ox + ow <= rx + rw + 1 && oy > ry && oy + oh <= ry + rh + 1 + if (contained && oy < minChildTop) minChildTop = oy + }) + if (minChildTop === Infinity) return + const gap = minChildTop - (ly + lh) + if (gap >= MIN_GAP) return + const delta = Math.ceil(MIN_GAP - gap) + label.setAttribute('transform', 'translate(' + lx + ', ' + (ly - delta) + ')') + rect.setAttribute('y', ry - delta) + rect.setAttribute('height', rh + delta) + viewBoxGrow = Math.max(viewBoxGrow, delta) + }) + if (viewBoxGrow > 0) { + const vb = (svg.getAttribute('viewBox') || '').split(/[ ,]+/).map(Number) + if (vb.length === 4 && vb.every(function (n) { return !isNaN(n) })) { + vb[1] -= viewBoxGrow + vb[3] += viewBoxGrow + svg.setAttribute('viewBox', vb.join(' ')) + } + } + svg.setAttribute('data-label-spacing', 'fixed') + } + + // Mermaid renders asynchronously after its module loads; poll briefly. + let attempts = 0 + const timer = setInterval(function () { + attempts++ + const pending = document.querySelectorAll('.mermaid svg:not([data-label-spacing])') + pending.forEach(fixSvg) + const remaining = document.querySelectorAll('.mermaid:not([data-processed])').length + if (remaining === 0 || attempts > 100) clearInterval(timer) + }, 200) +})() From a7f9a7c2bfc32f9246f6583c92171514859ac1e9 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 27 Jul 2026 09:34:47 +0100 Subject: [PATCH 3/3] Address review: hidden-tab retry and top-level-only viewBox growth Review feedback from PR #405 (non-blocking suggestions): - Hidden diagrams (inactive context-switcher tabs) were stamped data-label-spacing=fixed without being fixed: getBBox() throws in Firefox and returns zero-height boxes in Chrome for non-rendered content. fixSvg now bails before stamping when the SVG has no client rects, and a click listener retries pending diagrams after the poll ends, so tabs get fixed when revealed. - viewBox growth is now limited to shifts on top-level clusters, as the file comment already claimed; a nested cluster's shift is absorbed inside its parent instead of adding whitespace around the diagram. - Documented the live-measurement order-dependence between sibling clusters. Verified on the k-choose-deployment diagram (docs#1820 preview): three overlapping labels go from -5.7px to +12.3px clear space with an 18px viewBox extension, and a local harness confirms a diagram hidden in a tab is left unstamped, then fixed on the click that reveals it. Co-Authored-By: Claude Fable 5 --- src/js/08-mermaid-label-spacing.js | 57 ++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/src/js/08-mermaid-label-spacing.js b/src/js/08-mermaid-label-spacing.js index c45bedce..863d646b 100644 --- a/src/js/08-mermaid-label-spacing.js +++ b/src/js/08-mermaid-label-spacing.js @@ -12,15 +12,35 @@ if (!document.querySelector('.mermaid')) return function fixSvg (svg) { + // Hidden diagrams (e.g. in an inactive context-switcher tab) have no + // layout: getBBox() throws in Firefox and returns zero-height boxes in + // Chrome. Leave them unstamped so a later pass can fix them once revealed. + if (svg.getClientRects().length === 0) return + const clusters = Array.from(svg.querySelectorAll('g.cluster')) + + function contains (outer, inner) { + const ox = parseFloat(outer.getAttribute('x')) + const oy = parseFloat(outer.getAttribute('y')) + const ow = parseFloat(outer.getAttribute('width')) + const oh = parseFloat(outer.getAttribute('height')) + const ix = parseFloat(inner.getAttribute('x')) + const iy = parseFloat(inner.getAttribute('y')) + const iw = parseFloat(inner.getAttribute('width')) + const ih = parseFloat(inner.getAttribute('height')) + return ix >= ox - 1 && ix + iw <= ox + ow + 1 && iy > oy && iy + ih <= oy + oh + 1 + } + + // Clusters are processed in document order and measured live, so a + // cluster shifted early feeds its new position to clusters measured + // later. The one-directional (upward) shifts keep that stable enough + // in practice; don't rely on exact pixel positions across siblings. let viewBoxGrow = 0 clusters.forEach(function (cluster) { const rect = cluster.querySelector('rect') const label = cluster.querySelector('.cluster-label') if (!rect || !label) return - const rx = parseFloat(rect.getAttribute('x')) const ry = parseFloat(rect.getAttribute('y')) - const rw = parseFloat(rect.getAttribute('width')) const rh = parseFloat(rect.getAttribute('height')) const m = (label.getAttribute('transform') || '').match(/translate\(\s*([-\d.]+)[ ,]\s*([-\d.]+)\s*\)/) if (!m) return @@ -38,21 +58,25 @@ if (other === cluster) return const or = other.querySelector('rect') if (!or) return - const ox = parseFloat(or.getAttribute('x')) const oy = parseFloat(or.getAttribute('y')) - const ow = parseFloat(or.getAttribute('width')) - const oh = parseFloat(or.getAttribute('height')) - const contained = ox >= rx - 1 && ox + ow <= rx + rw + 1 && oy > ry && oy + oh <= ry + rh + 1 - if (contained && oy < minChildTop) minChildTop = oy + if (contains(rect, or) && oy < minChildTop) minChildTop = oy }) if (minChildTop === Infinity) return const gap = minChildTop - (ly + lh) if (gap >= MIN_GAP) return const delta = Math.ceil(MIN_GAP - gap) + // Only top-level clusters push against the viewBox; a shift on a + // nested cluster is absorbed inside its parent. Decide before the + // shift moves this rect relative to its parent. + const isNested = clusters.some(function (other) { + if (other === cluster) return false + const or = other.querySelector('rect') + return or && contains(or, rect) + }) label.setAttribute('transform', 'translate(' + lx + ', ' + (ly - delta) + ')') rect.setAttribute('y', ry - delta) rect.setAttribute('height', rh + delta) - viewBoxGrow = Math.max(viewBoxGrow, delta) + if (!isNested) viewBoxGrow = Math.max(viewBoxGrow, delta) }) if (viewBoxGrow > 0) { const vb = (svg.getAttribute('viewBox') || '').split(/[ ,]+/).map(Number) @@ -65,13 +89,24 @@ svg.setAttribute('data-label-spacing', 'fixed') } + function fixPending () { + document.querySelectorAll('.mermaid svg:not([data-label-spacing])').forEach(fixSvg) + } + // Mermaid renders asynchronously after its module loads; poll briefly. let attempts = 0 const timer = setInterval(function () { attempts++ - const pending = document.querySelectorAll('.mermaid svg:not([data-label-spacing])') - pending.forEach(fixSvg) + fixPending() const remaining = document.querySelectorAll('.mermaid:not([data-processed])').length - if (remaining === 0 || attempts > 100) clearInterval(timer) + if (remaining === 0 || attempts > 100) { + clearInterval(timer) + // Diagrams hidden while the poll ran (e.g. in inactive tabs) are left + // unstamped; retry after clicks, which is how tabs get revealed. The + // selector is empty once everything is fixed, so this stays cheap. + if (document.querySelector('.mermaid svg:not([data-label-spacing])')) { + document.addEventListener('click', function () { setTimeout(fixPending, 50) }) + } + } }, 200) })()