From 79c276a37253b87fb08fa8fdfc47612ee261dd2c Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Thu, 13 Aug 2026 13:17:30 -0700 Subject: [PATCH] feat(docs-site): widen the content column and add a mermaid lightbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation site set its prose at Starlight's default 45rem and drew every mermaid diagram at that width. Fifteen of the forty-two pages carry a diagram and all of them are architecture pages, where the diagram is the page's point — and at column width the large ones are unreadable. Two changes, one file each plus the styles: - `--sl-content-width` goes to 50rem. It is a max-width, so it binds only where there is room: measured at 1440 the column gains 52px and at 1920 the full 80px, while at 1280 and below the pane is already space-constrained and nothing moves. - Every diagram gets a magnifier button opening it in a `` at 92vw x 88vh, with wheel-zoom about the cursor and drag-pan for the graphs that do not fit even there. One dialog is created lazily and reused; the diagram's SVG is cloned into it with mermaid's width, height and inline max-width cleared so the viewBox fits it to the box. The wrapper the button hangs off is emitted by the remark transform rather than injected in the browser, and that is load-bearing: the redraw that follows every theme toggle reassigns the `
`'s text content, so a button
inside it is destroyed. Verified with a control pair — a marker in the `
`
does not survive a toggle, one in the wrapper does.

Two things the browser corrected: a modal dialog is centred by the UA's
`margin: auto`, which Starlight's reset zeroes, so the dialog opened against
the top-left until the margin was restored explicitly; and the clone must be
found through `pre.mermaid svg`, since the figure also contains the
magnifier's own icon and a bare `figure svg` picked the diagram only by
document order.

No new dependency, no change to how diagrams are authored, no per-diagram
configuration.
---
 docs-site/integrations/remark-mermaid.mjs |  13 +-
 docs-site/src/components/Head.astro       | 181 +++++++++++++++++++++
 docs-site/src/styles/visionset.css        | 184 +++++++++++++++++++++-
 3 files changed, 371 insertions(+), 7 deletions(-)

diff --git a/docs-site/integrations/remark-mermaid.mjs b/docs-site/integrations/remark-mermaid.mjs
index cd1e98b..cb2c2c2 100644
--- a/docs-site/integrations/remark-mermaid.mjs
+++ b/docs-site/integrations/remark-mermaid.mjs
@@ -14,6 +14,14 @@
  * The source is escaped rather than interpolated. A label containing `<` or `&` is
  * ordinary in these diagrams (`callers --> services`, `A & B`), and unescaped it
  * would be parsed as markup and silently deleted before mermaid ever saw it.
+ *
+ * The wrapper is emitted **here, at build time**, and it is what the magnifier button
+ * hangs off. Two reasons it cannot be the `
` itself, and neither is cosmetic:
+ * `Head.astro`'s redraw assigns `node.textContent`, which destroys every child of the
+ * `
` on each theme toggle; and `pre.mermaid` scrolls horizontally, so a button
+ * positioned inside it would slide out of view on exactly the wide diagrams that need
+ * it most. A wrapper written by the transform is outside both problems and needs no
+ * DOM surgery in the browser.
  */
 
 import { visit } from "unist-util-visit";
@@ -30,7 +38,10 @@ export default function remarkMermaid() {
       if (node.lang !== "mermaid" || parent === undefined || index === undefined) return;
       parent.children[index] = {
         type: "html",
-        value: `
${escape(node.value)}
`, + value: + `
` + + `
${escape(node.value)}
` + + `
`, }; }); }; diff --git a/docs-site/src/components/Head.astro b/docs-site/src/components/Head.astro index b7d4255..fd899a8 100644 --- a/docs-site/src/components/Head.astro +++ b/docs-site/src/components/Head.astro @@ -11,6 +11,12 @@ * The import is dynamic and guarded on there being a diagram, so mermaid (which is * megabytes) is fetched on the fifteen architecture pages and on none of the other * twenty-seven. + * + * The second half of the script is the magnifier: a diagram set at the width of a + * prose column is unreadable, and these are the pages where the diagram *is* the + * point. Every drawn diagram gets a button, and the button opens the SVG in a + * `` that fills the viewport, with wheel-zoom and drag-pan for the large + * architecture graphs that do not fit even there. */ import Default from "@astrojs/starlight/components/Head.astro"; --- @@ -44,6 +50,181 @@ import Default from "@astrojs/starlight/components/Head.astro"; theme: document.documentElement.dataset.theme === "dark" ? "dark" : "default", }); await mermaid.run({ nodes }); + addMagnifiers(nodes); + } + + // Two icons, inline, because one `` literal each is smaller than any way of + // getting at an icon set from here. + const MAGNIFIER = ``; + + const CROSS = ``; + + // The wrapper comes from the remark transform, so it survives the redraw above — + // which reassigns the `
`'s text content and would take any button inside it
+  // with it. The button is therefore added once and never again, and the guard is
+  // what makes calling this on every draw free.
+  function addMagnifiers(nodes: HTMLElement[]) {
+    for (const node of nodes) {
+      const figure = node.closest(".mermaid-figure");
+      if (figure === null || figure.querySelector(".mermaid-zoom") !== null) continue;
+
+      const button = document.createElement("button");
+      button.type = "button";
+      button.className = "mermaid-zoom";
+      button.setAttribute("aria-label", "Expand diagram");
+      button.innerHTML = MAGNIFIER;
+      button.addEventListener("click", () => expand(figure));
+      figure.append(button);
+    }
+  }
+
+  // One dialog for the whole page, reused. Per-diagram instances would each need
+  // their own copy of the pan and zoom state below, for no gain: only one can be
+  // open at a time.
+  let lightbox: { dialog: HTMLDialogElement; stage: HTMLElement; canvas: HTMLElement } | undefined;
+
+  let scale = 1;
+  let panX = 0;
+  let panY = 0;
+
+  const MAX_SCALE = 8;
+
+  function applyTransform(stage: HTMLElement, canvas: HTMLElement) {
+    canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
+    stage.style.cursor = scale > 1 ? "grab" : "";
+  }
+
+  function lightboxElements() {
+    // `isConnected` rather than a bare `undefined` check: were Starlight's view
+    // transitions ever switched on, a navigation would swap the body out from under
+    // this and leave a reference to a detached dialog, which `showModal` accepts and
+    // nobody ever sees.
+    if (lightbox !== undefined && lightbox.dialog.isConnected) return lightbox;
+
+    const dialog = document.createElement("dialog");
+    dialog.className = "mermaid-lightbox";
+
+    const close = document.createElement("button");
+    close.type = "button";
+    close.className = "mermaid-lightbox-close";
+    close.setAttribute("aria-label", "Close");
+    close.innerHTML = CROSS;
+
+    const stage = document.createElement("div");
+    stage.className = "mermaid-lightbox-stage";
+    const canvas = document.createElement("div");
+    canvas.className = "mermaid-lightbox-canvas";
+    stage.append(canvas);
+    dialog.append(close, stage);
+    document.body.append(dialog);
+
+    close.addEventListener("click", () => dialog.close());
+    // A click whose target is the dialog *itself* is a click on the backdrop —
+    // anything on the content hits one of the children above. Esc is native, and so
+    // is returning focus to the button the dialog was opened from.
+    dialog.addEventListener("click", (event) => {
+      if (event.target === dialog) dialog.close();
+    });
+
+    // Non-passive, deliberately: a wheel listener registered the default way cannot
+    // `preventDefault`, and the page scrolls behind the dialog instead of zooming.
+    stage.addEventListener(
+      "wheel",
+      (event) => {
+        event.preventDefault();
+        const rect = stage.getBoundingClientRect();
+        // The canvas is centred in the stage and scales about its own centre, so
+        // offsets from the stage centre are the frame the transform is written in.
+        const x = event.clientX - rect.left - rect.width / 2;
+        const y = event.clientY - rect.top - rect.height / 2;
+
+        const next = Math.min(MAX_SCALE, Math.max(1, scale * Math.exp(-event.deltaY / 400)));
+        const ratio = next / scale;
+        // Hold whatever is under the cursor still while the scale changes.
+        panX = x - ratio * (x - panX);
+        panY = y - ratio * (y - panY);
+        scale = next;
+        // At fit there is nowhere to pan to, and a diagram left off-centre by an
+        // earlier drag would otherwise stay there.
+        if (scale === 1) {
+          panX = 0;
+          panY = 0;
+        }
+        applyTransform(stage, canvas);
+      },
+      { passive: false },
+    );
+
+    let panning = false;
+    let lastX = 0;
+    let lastY = 0;
+
+    stage.addEventListener("pointerdown", (event) => {
+      if (scale === 1) return;
+      panning = true;
+      lastX = event.clientX;
+      lastY = event.clientY;
+      stage.setPointerCapture(event.pointerId);
+      stage.style.cursor = "grabbing";
+    });
+
+    stage.addEventListener("pointermove", (event) => {
+      if (!panning) return;
+      panX += event.clientX - lastX;
+      panY += event.clientY - lastY;
+      lastX = event.clientX;
+      lastY = event.clientY;
+      applyTransform(stage, canvas);
+    });
+
+    for (const type of ["pointerup", "pointercancel"]) {
+      stage.addEventListener(type, () => {
+        panning = false;
+        applyTransform(stage, canvas);
+      });
+    }
+
+    lightbox = { dialog, stage, canvas };
+    return lightbox;
+  }
+
+  function expand(figure: HTMLElement) {
+    // Scoped to the `
` rather than the figure, because the figure also contains
+    // the magnifier — whose icon is itself an ``. A bare `figure svg` picks the
+    // diagram only because the button happens to be appended after it, which is not
+    // a property worth depending on.
+    const source = figure.querySelector(
+      "pre.mermaid svg, pre.mermaid img",
+    );
+    if (source === null) return;
+
+    const { dialog, stage, canvas } = lightboxElements();
+    const clone = source.cloneNode(true) as SVGSVGElement | HTMLImageElement;
+
+    // The `id` is deliberately kept. mermaid writes a `
` above; it exists only to be the button's positioning context. It carries
+ * no box of its own, so the `
`'s margins collapse straight through it and the
+ * two elements occupy exactly the same rectangle.
+ */
+.mermaid-figure {
+  position: relative;
+}
+
+/*
+ * Both buttons, one appearance. They sit in the same corner of their respective
+ * boxes and do the same kind of job, and a second set of values here would be one
+ * more thing to keep in step for no reader's benefit.
+ */
+.mermaid-zoom,
+.mermaid-lightbox-close {
+  position: absolute;
+  top: 0.5rem;
+  right: 0.5rem;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2rem;
+  height: 2rem;
+  padding: 0;
+  color: var(--sl-color-gray-2);
+  background: var(--sl-color-bg);
+  border: 1px solid var(--sl-color-gray-5);
+  border-radius: 0.375rem;
+  cursor: pointer;
+}
+
+.mermaid-zoom svg,
+.mermaid-lightbox-close svg {
+  width: 1.125rem;
+  height: 1.125rem;
+}
+
+.mermaid-zoom:hover,
+.mermaid-lightbox-close:hover {
+  color: var(--sl-color-white);
+  border-color: var(--sl-color-gray-4);
+}
+
+/*
+ * Hidden until wanted, so a page of diagrams is not a page of buttons. Revealed by
+ * hovering the diagram *or* by focusing the button, which is the whole of its
+ * keyboard reachability — `opacity` rather than `display` precisely so that it stays
+ * in the tab order while invisible.
+ */
+.mermaid-zoom {
+  opacity: 0;
+  transition: opacity 120ms ease-out;
+}
+
+.mermaid-figure:hover .mermaid-zoom,
+.mermaid-zoom:focus-visible {
+  opacity: 1;
+}
+
+/* A touch device has no hover to reveal it with, so it is simply always there. */
+@media (hover: none) {
+  .mermaid-zoom {
+    opacity: 1;
+  }
+}
+
+.mermaid-lightbox {
+  width: 92vw;
+  height: 88vh;
+  max-width: 92vw;
+  max-height: 88vh;
+
+  /*
+   * Explicit, and not redundant: the browser's own stylesheet centres a modal
+   * dialog with `margin: auto`, and Starlight's reset zeroes the margin on
+   * everything. Without this the dialog opens hard against the top-left corner —
+   * measured, not guessed.
+   */
+  margin: auto;
+  padding: 0;
+  overflow: hidden;
+  color: var(--sl-color-text);
+  background: var(--sl-color-bg);
+  border: 1px solid var(--sl-color-gray-5);
+  border-radius: 0.5rem;
+}
+
+.mermaid-lightbox::backdrop {
+  background: rgb(0 0 0 / 60%);
+}
+
+/*
+ * The stage clips; the canvas is what moves. Keeping the transform on an inner
+ * element is what lets the diagram be panned beyond the edges without the dialog
+ * growing a scrollbar, and `touch-action: none` is what stops a drag on a phone
+ * being read as a page scroll.
+ */
+.mermaid-lightbox-stage {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  touch-action: none;
+}
+
+.mermaid-lightbox-canvas {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  padding: 1.5rem;
+  /*
+   * Load-bearing, and it is the assumption the zoom arithmetic in `Head.astro` is
+   * written against: the canvas fills the stage, so its centre is the stage's
+   * centre, and scaling about that point is what lets "keep what is under the
+   * cursor still" be two lines rather than a matrix.
+   */
+  transform-origin: center;
+}
+
+@keyframes mermaid-lightbox-in {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
+.mermaid-lightbox[open],
+.mermaid-lightbox[open]::backdrop {
+  animation: mermaid-lightbox-in 120ms ease-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .mermaid-lightbox[open],
+  .mermaid-lightbox[open]::backdrop {
+    animation: none;
+  }
+
+  .mermaid-zoom {
+    transition: none;
+  }
+}