diff --git a/scripts/docs-site/diagram-interactions.test.mjs b/scripts/docs-site/diagram-interactions.test.mjs index 6ebdb64fbc..d05409bbd3 100644 --- a/scripts/docs-site/diagram-interactions.test.mjs +++ b/scripts/docs-site/diagram-interactions.test.mjs @@ -5,7 +5,7 @@ import { test } from "node:test"; import { chromium, webkit } from "playwright"; import { fixture } from "./test-helpers/redirect-fixture.mjs"; -const source = "flowchart LR\n A[\"Chat apps and plugins\"] --> B[Gateway]\n B --> C[\"OpenClaw agent\"]\n B --> D[\"Web Control UI\"]"; +const source = "flowchart LR\n A[\"Chat apps + plugins\"] --> B[Gateway]\n B --> C[\"OpenClaw agent\"]\n B --> D[CLI]\n B --> E[\"Web Control UI\"]\n B --> F[\"macOS app\"]\n B --> G[\"iOS and Android nodes\"]"; const markdown = "# Diagram interactions\n\n```mermaid\n" + source + "\n```\n\n```mermaid\nflowchart LR\n Broken -->\n```"; for (const [name, engine] of [["Chromium", chromium], ["WebKit", webkit]]) { @@ -17,7 +17,7 @@ for (const [name, engine] of [["Chromium", chromium], ["WebKit", webkit]]) { const site = path.join(f.root, "dist/docs-site"); const browser = await engine.launch({ headless: true }); t.after(() => browser.close()); - const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }); page.setDefaultTimeout(10000); await page.route("**/*", route => { const url = new URL(route.request().url()); @@ -37,16 +37,54 @@ for (const [name, engine] of [["Chromium", chromium], ["WebKit", webkit]]) { await page.locator("[data-mermaid-expand]").click(); const expanded = page.locator("[data-mermaid-overlay-canvas] > svg"); await expanded.waitFor({ state: "visible" }); - const geometry = await expanded.evaluate(svg => ({ width: svg.getBoundingClientRect().width, naturalWidth: svg.viewBox.baseVal.width })); - assert.ok(geometry.width >= geometry.naturalWidth - 1, "expansion must not shrink labels to fit a narrow screen"); const canvas = page.locator("[data-mermaid-overlay-canvas]"); - await canvas.evaluate(element => { element.scrollLeft = element.scrollWidth; }); - assert.ok(await expanded.evaluate(svg => svg.getBoundingClientRect().right <= svg.parentElement.getBoundingClientRect().right + 1), "the expanded diagram end must remain reachable by scrolling"); + async function assertFits() { + assert.ok(await expanded.evaluate(svg => { + const box = svg.getBoundingClientRect(); + const viewport = svg.parentElement.getBoundingClientRect(); + return box.left >= viewport.left && box.top >= viewport.top + && box.right <= viewport.right + 1 && box.bottom <= viewport.bottom + 1; + }), "the complete expanded diagram must fit the viewport before zooming"); + } + await assertFits(); + const fittedViewBox = await expanded.getAttribute("viewBox"); + await canvas.getByRole("button", { name: "Zoom in", exact: true }).tap(); + await canvas.getByRole("button", { name: "Zoom in", exact: true }).tap(); + assert.notEqual(await expanded.getAttribute("viewBox"), fittedViewBox, "zoom must reveal readable detail"); + assert.ok(await canvas.evaluate(element => element.scrollWidth <= element.clientWidth + 1 + && element.scrollHeight <= element.clientHeight + 1), "zoom must not replace pan with clipped outer scrolling"); + const beforePan = await expanded.getAttribute("viewBox"); + const viewport = await canvas.boundingBox(); + await page.mouse.move(viewport.x + viewport.width / 2, viewport.y + viewport.height / 2); + await page.mouse.down(); + await page.mouse.move(viewport.x + viewport.width / 2 - 60, viewport.y + viewport.height / 2, { steps: 5 }); + await page.mouse.up(); + assert.notEqual(await expanded.getAttribute("viewBox"), beforePan, "zoomed diagram must pan with real pointer input"); + await canvas.getByRole("button", { name: "Fit diagram", exact: true }).click(); + assert.equal(await expanded.getAttribute("viewBox"), fittedViewBox); + await canvas.getByRole("button", { name: "Zoom in", exact: true }).tap(); + await page.setViewportSize({ width: 844, height: 390 }); + await page.waitForFunction(() => { + const svg = document.querySelector("[data-mermaid-overlay-canvas] > svg"); + return svg.viewBox.baseVal.width === Number(svg.getAttribute("width")); + }); + await assertFits(); await page.keyboard.press("Tab"); assert.ok(await page.locator("[data-mermaid-overlay]").evaluate(overlay => overlay.contains(document.activeElement))); await page.keyboard.press("Escape"); assert.equal(await page.locator("[data-mermaid-overlay].open").count(), 0); assert.ok(await page.locator("[data-mermaid-expand]").evaluate(button => button === document.activeElement)); assert.ok(await page.evaluate(() => !document.body.classList.contains("has-mermaid-overlay"))); + await page.locator("[data-mermaid-expand]").click(); + await expanded.waitFor({ state: "visible" }); + assert.equal(await canvas.locator(".pele-zoom").count(), 1, "reopening must not retain old viewer controls"); + await page.keyboard.press("Tab"); + assert.ok(await expanded.evaluate(svg => svg === document.activeElement)); + const keyboardFit = await expanded.getAttribute("viewBox"); + await page.keyboard.press("+"); + assert.notEqual(await expanded.getAttribute("viewBox"), keyboardFit); + await page.keyboard.press("0"); + assert.equal(await expanded.getAttribute("viewBox"), keyboardFit); + await page.keyboard.press("Escape"); }); } diff --git a/scripts/docs-site/docs-layout.css b/scripts/docs-site/docs-layout.css index 60edcd8889..c451ee73fe 100644 --- a/scripts/docs-site/docs-layout.css +++ b/scripts/docs-site/docs-layout.css @@ -433,13 +433,16 @@ --pele-font-mono: var(--oc-font-mono); --pele-radius: 8px; } -.docs-layout .oc-mermaid-overlay-canvas { border-color: var(--docs-border); background: var(--paper); box-shadow: none; padding: 32px; } +.docs-layout .oc-mermaid-overlay-canvas { border-color: var(--docs-border); background: var(--paper); box-shadow: none; padding: 32px 32px 72px; } .docs-layout .doc .oc-mermaid.is-error { padding: 18px 20px; border-color: var(--docs-border); } .docs-layout .oc-mermaid-error { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--muted); font: 400 12px/1.5 var(--oc-font-body); } .docs-layout .oc-mermaid-error svg { flex: 0 0 16px; color: var(--oc-status-warning-fg); } .docs-layout .doc .oc-mermaid.is-error pre { margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; color: var(--text); font-size: 12px; line-height: 1.65; } @media (max-width: 520px) { - .docs-layout .oc-mermaid { padding: 44px 16px 20px; }} + .docs-layout .oc-mermaid { padding: 44px 16px 20px; } + .docs-layout .oc-mermaid-overlay { padding: 12px; } + .docs-layout .oc-mermaid-overlay-canvas { padding: 16px 16px 64px; } +} /* A section title belongs to its first block, regardless of that block's default margin. */ .docs-layout .article .doc > :is(h2,h3,h4,h5,h6) + :where(p,ul,ol,blockquote,figure,aside,details,div,section) { margin-block-start: 0; } .docs-layout .site-footer { margin-top: 0; border-bottom: 0; background: var(--bg); } @@ -777,6 +780,3 @@ @media (min-width:821px) { .docs-layout:dir(rtl) :is(.docs-sidebar-directory, .docs-sidebar-context > .docs-sections) { padding-block-end: 88px; } } - -/* Renderers put responsive max-width inline; expansion must stay readable and scroll. */ -.docs-layout .oc-mermaid-overlay-canvas > svg { max-width: none !important; } diff --git a/scripts/docs-site/site-css.mjs b/scripts/docs-site/site-css.mjs index e05ae8fe1f..bdb3166081 100644 --- a/scripts/docs-site/site-css.mjs +++ b/scripts/docs-site/site-css.mjs @@ -192,9 +192,11 @@ html{scroll-padding-top:calc(var(--sticky-header-h) + 54px)} .oc-mermaid-overlay-close{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line-strong);border-radius:var(--oc-radius-control);background:color-mix(in srgb,var(--paper) 90%,transparent);color:var(--muted);cursor:pointer} .oc-mermaid-overlay-close:hover,.oc-mermaid-overlay-close:focus-visible{outline:0;border-color:color-mix(in srgb,var(--brand) 46%,var(--line-strong));background:color-mix(in srgb,var(--soft) 64%,var(--paper) 36%);color:var(--ink)} .oc-mermaid-overlay-close .icon{width:18px;height:18px;min-width:18px;max-width:18px} -.oc-mermaid-overlay-canvas{min-height:0;overflow:auto;overscroll-behavior:contain;border:1px solid var(--line-strong);border-radius:var(--oc-radius-surface);background:color-mix(in srgb,var(--paper) 94%,transparent);padding:var(--oc-space-5);box-shadow:var(--shadow)} -.oc-mermaid-overlay-canvas svg{display:block;width:max-content;min-width:min(1040px,100%);max-width:none;height:auto;margin:auto} -.oc-mermaid-overlay-canvas pre{margin:0;white-space:pre;min-width:max-content} +.oc-mermaid-overlay-canvas{position:relative;container-type:size;display:grid;place-items:center;min-height:0;overflow:auto;overscroll-behavior:contain;border:1px solid var(--line-strong);border-radius:var(--oc-radius-surface);background:color-mix(in srgb,var(--paper) 94%,transparent);padding:var(--oc-space-5);box-shadow:var(--shadow)} +.oc-mermaid-overlay-canvas>svg{display:block;min-width:0;max-width:none;height:auto;margin:auto} +.oc-mermaid-overlay-canvas .pele-zoom{flex-direction:row!important;--pele-zoom-top:auto;--pele-zoom-bottom:10px;--pele-zoom-right:10px} +.oc-mermaid-overlay-canvas .pele-zoom button{min-width:44px;min-height:44px} +.oc-mermaid-overlay-canvas pre{align-self:start;justify-self:start;margin:0;white-space:pre;min-width:max-content} .has-mermaid-overlay{overflow:hidden} .language-menu{top:calc(100% + 8px);width:min(270px,calc(100vw - 32px));max-height:min(62vh,430px);padding:6px;border-radius:var(--oc-radius-surface);scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--brand) 58%,transparent) transparent;box-shadow:var(--oc-shadow-lg)} .language-menu::-webkit-scrollbar{width:8px} diff --git a/scripts/docs-site/site-js.mjs b/scripts/docs-site/site-js.mjs index 501cc7f62d..fc63db5a7d 100644 --- a/scripts/docs-site/site-js.mjs +++ b/scripts/docs-site/site-js.mjs @@ -207,7 +207,7 @@ function initAccordionMotion(){ function initCodeGroups(){document.querySelectorAll(".oc-code-group").forEach(group=>{if(group.dataset.codeGroupReady)return;const blocks=[...group.querySelectorAll(":scope > .oc-code")];if(!blocks.length)return;group.dataset.codeGroupReady="true";const preferred=localStorage.getItem("preferredCodeTab")||"";const preferredIndex=Math.max(0,blocks.findIndex(block=>(block.dataset.codeLabel||"Code")===preferred));const activeIndex=preferredIndex||0;const tabs=document.createElement("div");tabs.className="oc-code-tabs";tabs.setAttribute("role","tablist");blocks.forEach((block,i)=>{const label=block.dataset.codeLabel||"Code";const button=document.createElement("button");button.type="button";button.className="oc-code-tab"+(i===activeIndex?" active":"");button.textContent=label;button.setAttribute("role","tab");button.setAttribute("aria-selected",String(i===activeIndex));button.addEventListener("click",()=>{tabs.querySelectorAll(".oc-code-tab").forEach(tab=>{tab.classList.remove("active");tab.setAttribute("aria-selected","false")});blocks.forEach(item=>item.classList.remove("active"));button.classList.add("active");button.setAttribute("aria-selected","true");block.classList.add("active");localStorage.setItem("preferredCodeTab",label)});tabs.appendChild(button);block.classList.toggle("active",i===activeIndex)});group.prepend(tabs)})} const mermaidExpandIcon=''; const mermaidCloseIcon=''; -let mermaidReady;let lastMermaidTrigger=null;let mermaidOverlayRender=0; +let mermaidReady;let lastMermaidTrigger=null;let mermaidOverlayRender=0;let mermaidZoom; function resetMermaidBlock(block,source){block.dataset.mermaidReady="";block.classList.remove("is-rendered","is-error");block.innerHTML="
"+escapeHtml(source)+"
"} async function renderMermaidSvg(source,renderId){ const pele=await (mermaidReady??=import(withBase("/assets/pele-1.6.0.js"))); @@ -220,9 +220,10 @@ async function renderMermaidSvg(source,renderId){ } function mermaidExpandButton(){return '
'} function ensureMermaidOverlay(){let overlay=document.querySelector("[data-mermaid-overlay]");if(overlay)return overlay;overlay=document.createElement("div");overlay.className="oc-mermaid-overlay";overlay.dataset.mermaidOverlay="";overlay.setAttribute("role","dialog");overlay.setAttribute("aria-modal","true");overlay.setAttribute("aria-label","Expanded diagram");overlay.innerHTML='
';document.body.appendChild(overlay);return overlay} -function closeMermaidOverlay(){const overlay=document.querySelector("[data-mermaid-overlay]");if(!overlay?.classList.contains("open"))return;mermaidOverlayRender+=1;overlay.classList.remove("open");document.body.classList.remove("has-mermaid-overlay");overlay.querySelector("[data-mermaid-overlay-canvas]").innerHTML="";lastMermaidTrigger?.focus?.();lastMermaidTrigger=null} +function closeMermaidOverlay(){const overlay=document.querySelector("[data-mermaid-overlay]");if(!overlay?.classList.contains("open"))return;mermaidOverlayRender+=1;mermaidZoom?.destroy();mermaidZoom=undefined;overlay.classList.remove("open");document.body.classList.remove("has-mermaid-overlay");overlay.querySelector("[data-mermaid-overlay-canvas]").innerHTML="";lastMermaidTrigger?.focus?.();lastMermaidTrigger=null} function handleMermaidOverlayKeydown(e){const overlay=document.querySelector("[data-mermaid-overlay].open");if(!overlay)return false;if(e.key==="Escape"){e.preventDefault();e.stopImmediatePropagation();closeMermaidOverlay();return true}if(isSearchShortcutEvent(e)){e.preventDefault();e.stopImmediatePropagation();return true}if(e.key!=="Tab")return false;const controls=[...overlay.querySelectorAll("button:not([disabled]),[href],[tabindex]:not([tabindex='-1'])")].filter(control=>!control.hidden&&control.getAttribute("aria-hidden")!=="true"&&control.offsetParent!==null);const first=controls[0];const last=controls.at(-1);if(!first){e.preventDefault();return true}if(!overlay.contains(document.activeElement)||e.shiftKey&&document.activeElement===first||!e.shiftKey&&document.activeElement===last){e.preventDefault();(e.shiftKey?last:first).focus();return true}return false} -async function openMermaidOverlay(control){const block=control.closest("[data-mermaid]");const source=block?.dataset.mermaid||"";if(!source)return;const renderToken=++mermaidOverlayRender;lastMermaidTrigger=control;const overlay=ensureMermaidOverlay();const canvas=overlay.querySelector("[data-mermaid-overlay-canvas]");canvas.innerHTML="
"+escapeHtml(source)+"
";overlay.classList.add("open");document.body.classList.add("has-mermaid-overlay");overlay.querySelector("[data-mermaid-overlay-close]")?.focus();const renderId="oc_mermaid_overlay_"+Date.now();try{const {svg}=await renderMermaidSvg(source,renderId);if(renderToken!==mermaidOverlayRender||!overlay.classList.contains("open")){return}canvas.innerHTML=svg;const diagram=canvas.firstElementChild;diagram.style.width=diagram.viewBox.baseVal.width+"px"}catch{}} +// Fit both viewport axes; Pele owns all zoom, pan and resize behavior. +async function openMermaidOverlay(control){const block=control.closest("[data-mermaid]");const source=block?.dataset.mermaid||"";if(!source)return;const renderToken=++mermaidOverlayRender;lastMermaidTrigger=control;const overlay=ensureMermaidOverlay();const canvas=overlay.querySelector("[data-mermaid-overlay-canvas]");mermaidZoom?.destroy();mermaidZoom=undefined;canvas.innerHTML="
"+escapeHtml(source)+"
";overlay.classList.add("open");document.body.classList.add("has-mermaid-overlay");overlay.querySelector("[data-mermaid-overlay-close]")?.focus();const renderId="oc_mermaid_overlay_"+Date.now();try{const {svg}=await renderMermaidSvg(source,renderId);const pele=await mermaidReady;if(renderToken!==mermaidOverlayRender||!overlay.classList.contains("open")){return}canvas.innerHTML=svg;const diagram=canvas.firstElementChild;const bounds=diagram.viewBox.baseVal;diagram.style.width="min(max("+bounds.width+"px,1040px),100cqw,calc(100cqh * "+bounds.width/bounds.height+"))";mermaidZoom=pele.enableZoom(canvas,{always:true,labels:{reset:"Fit diagram"}})}catch{}} async function initMermaid(){const blocks=[...document.querySelectorAll("[data-mermaid]")].filter(block=>!block.dataset.mermaidReady);if(!blocks.length)return;let i=0;for(const block of blocks){const source=block.dataset.mermaid||"";const renderId="oc_mermaid_"+Date.now()+"_"+i++;try{const {svg}=await renderMermaidSvg(source,renderId);block.innerHTML=mermaidExpandButton()+svg;block.dataset.mermaidReady="true";block.classList.remove("is-error");block.classList.add("is-rendered")}catch(err){resetMermaidBlock(block,source);block.dataset.mermaidReady="true";block.classList.add("is-error");block.insertAdjacentHTML("afterbegin",'
Diagram could not be rendered
')}}} function setCopyFeedback(control,state){if(!control)return;if(control.matches("[data-code-copy]")||control.matches("[data-prompt-copy]")||control.matches("[data-chat-copy]")||control.matches("[data-copy-page]")||control.matches("[data-heading-anchor]")){const label=control.dataset.copyLabel||control.getAttribute("aria-label")||"Copy";control.dataset.copyLabel=label;const feedback=control.querySelector("[data-copy-feedback]");const old=control.dataset.copyDefaultText||feedback?.textContent||label;control.dataset.copyDefaultText=old;clearTimeout(control._copyStateTimer);if(state==="copying"){control.dataset.copyState="copying";control.setAttribute("aria-label","Copying");if(feedback)feedback.textContent="Copying...";return}const ok=state==="copied";control.dataset.copyState=ok?"copied":"failed";control.setAttribute("aria-label",ok?"Copied":"Copy failed");if(control.hasAttribute("title"))control.title=ok?"Copied":"Copy failed";if(feedback)feedback.textContent=ok?"Copied":"Copy failed";control._copyStateTimer=setTimeout(()=>{delete control.dataset.copyState;control.setAttribute("aria-label",label);if(control.hasAttribute("title"))control.title=label;if(feedback)feedback.textContent=old},1300);return}const old=control.textContent;control.textContent=state==="copied"?"Copied":"Copy failed";setTimeout(()=>control.textContent=old||"Copy",1300)} function showCopyFeedback(control,ok){setCopyFeedback(control,ok?"copied":"failed")} diff --git a/scripts/docs-site/smoke.mjs b/scripts/docs-site/smoke.mjs index 6549f37c72..d43e581b2d 100644 --- a/scripts/docs-site/smoke.mjs +++ b/scripts/docs-site/smoke.mjs @@ -426,6 +426,10 @@ if (!fs.existsSync(path.join(site, "assets/pele-1.6.0.js")) || !fs.existsSync(path.join(site, "assets/pele-LICENSE.txt"))) { throw new Error("assets: Pele runtime and license were not copied"); } +if (!siteJs.includes("pele.enableZoom(canvas") || !siteCss.includes("container-type:size") + || siteCss.includes("min-width:min(1040px,100%)")) { + throw new Error("assets: expanded diagrams must fit the viewport and retain zoom controls"); +} if (!/\.sidebar\{[^}]*padding:0 6px var\(--oc-space-8\) 0;[^}]*scrollbar-gutter:stable/.test(siteCss)) { throw new Error("assets: sidebar scroll-end padding is missing"); } diff --git a/scripts/docs-site/visual-smoke.mjs b/scripts/docs-site/visual-smoke.mjs index 4e81b30b2f..00c9857f89 100644 --- a/scripts/docs-site/visual-smoke.mjs +++ b/scripts/docs-site/visual-smoke.mjs @@ -592,7 +592,7 @@ async function checkMermaid() { await page.locator(".oc-mermaid.is-error pre code").waitFor({ state: "visible", timeout: 10000 }); await page.evaluate(() => { window.__mermaidNavigationProbe = true; }); await page.locator("[data-mermaid-expand]").first().click(); - await page.locator("[data-mermaid-overlay-canvas] svg").waitFor({ state: "visible", timeout: 10000 }); + await page.locator("[data-mermaid-overlay-canvas] > svg").waitFor({ state: "visible", timeout: 10000 }); // Use the normal link handler so the cached module and overlay lifecycle survive PJAX. await page.keyboard.press("Escape"); await navigateMermaidPage(page, "/"); @@ -604,7 +604,7 @@ async function checkMermaid() { await expectMermaidDiagrams(page); await page.locator(".oc-mermaid.is-error pre code").waitFor({ state: "visible", timeout: 10000 }); await page.locator("[data-mermaid-expand]").first().click(); - await page.locator("[data-mermaid-overlay-canvas] svg").waitFor({ state: "visible", timeout: 10000 }); + await page.locator("[data-mermaid-overlay-canvas] > svg").waitFor({ state: "visible", timeout: 10000 }); await page.keyboard.press("Escape"); } finally { await page.close(); @@ -620,7 +620,7 @@ async function checkMermaidContent(page) { ); await page.locator("[data-mermaid-expand]").first().click(); await page.locator("[data-mermaid-overlay].open").waitFor({ state: "visible" }); - await page.locator("[data-mermaid-overlay-canvas] svg").waitFor({ state: "visible", timeout: 10000 }); + await page.locator("[data-mermaid-overlay-canvas] > svg").waitFor({ state: "visible", timeout: 10000 }); await page.keyboard.press("Tab"); const mermaidOverlayFocus = await page.evaluate(() => ({ activeInOverlay: Boolean(document.querySelector("[data-mermaid-overlay].open")?.contains(document.activeElement)), @@ -641,7 +641,7 @@ async function checkMermaidContent(page) { } await page.locator("[data-mermaid-expand]").first().click(); await page.locator("[data-mermaid-overlay].open").waitFor({ state: "visible" }); - await page.locator("[data-mermaid-overlay-canvas] svg").waitFor({ state: "visible", timeout: 10000 }); + await page.locator("[data-mermaid-overlay-canvas] > svg").waitFor({ state: "visible", timeout: 10000 }); await page.evaluate(() => history.pushState({ docs: true }, "", `${location.pathname}#fixture-components`)); await page.goBack(); await page.locator("[data-mermaid-overlay].open").waitFor({ state: "hidden" }); @@ -657,7 +657,7 @@ async function checkMermaidContent(page) { } await page.locator("[data-mermaid-expand]").first().click(); await page.locator("[data-mermaid-overlay].open").waitFor({ state: "visible" }); - await page.locator("[data-mermaid-overlay-canvas] svg").waitFor({ state: "visible", timeout: 10000 }); + await page.locator("[data-mermaid-overlay-canvas] > svg").waitFor({ state: "visible", timeout: 10000 }); await page.keyboard.press("Meta+K"); if (await page.locator(".search-modal.open").count()) { throw new Error("mermaid overlay allowed the search shortcut");