From 3e14f51a6b4005eccbb0136a1ff4a7e3b54b61e1 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 30 May 2026 23:12:43 +0100 Subject: [PATCH 01/24] feat(web): mermaid diagram lightbox on click Click rendered mermaid blocks in chat to open a zoomable full-screen viewer. Re-renders from source in the modal with the current theme. Closes #737. Co-authored-by: Cursor --- web/src/components/ZoomableLightbox.tsx | 273 ++++++++++++++++++ .../mermaid-diagram.live.test.tsx | 57 ++++ .../assistant-ui/mermaid-diagram.test.tsx | 80 ++++- .../assistant-ui/mermaid-diagram.tsx | 132 +++++++-- web/src/lib/locales/en.ts | 6 + web/src/lib/locales/zh-CN.ts | 6 + 6 files changed, 520 insertions(+), 34 deletions(-) create mode 100644 web/src/components/ZoomableLightbox.tsx create mode 100644 web/src/components/assistant-ui/mermaid-diagram.live.test.tsx diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx new file mode 100644 index 000000000..78751b5d3 --- /dev/null +++ b/web/src/components/ZoomableLightbox.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' +import { CloseIcon } from '@/components/icons' + +const MIN_SCALE = 0.25 +const MAX_SCALE = 8 +const SCALE_STEP = 0.25 +const BACKDROP_CLICK_MAX_MOVEMENT = 4 + +type Point = { x: number; y: number } + +function clampScale(value: number): number { + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, value)) +} + +function getPointDistance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function getPointCenter(a: Point, b: Point): Point { + return { + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + } +} + +export type ZoomableLightboxProps = { + open: boolean + onClose: () => void + title?: string + ariaLabel: string + children: ReactNode +} + +export function ZoomableLightbox(props: ZoomableLightboxProps) { + const { open, onClose, title, ariaLabel, children } = props + const [scale, setScale] = useState(1) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const scaleRef = useRef(scale) + const offsetRef = useRef(offset) + const activePointersRef = useRef(new Map()) + const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) + const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null) + const backdropPressRef = useRef<{ pointerId: number; x: number; y: number } | null>(null) + + const updateScale = useCallback((next: number | ((current: number) => number)) => { + setScale((current) => { + const value = typeof next === 'function' ? next(current) : next + scaleRef.current = value + return value + }) + }, []) + + const updateOffset = useCallback((next: Point) => { + offsetRef.current = next + setOffset(next) + }, []) + + const resetView = useCallback(() => { + updateScale(1) + updateOffset({ x: 0, y: 0 }) + }, [updateOffset, updateScale]) + + const closeViewer = useCallback(() => { + onClose() + activePointersRef.current.clear() + dragRef.current = null + pinchRef.current = null + backdropPressRef.current = null + resetView() + }, [onClose, resetView]) + + const zoomBy = useCallback((delta: number) => { + updateScale((current) => clampScale(current + delta)) + }, [updateScale]) + + const handleWheel = useCallback((event: WheelEvent) => { + event.preventDefault() + const delta = event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP + zoomBy(delta) + }, [zoomBy]) + + const beginPinch = useCallback(() => { + const pointers = Array.from(activePointersRef.current.values()) + if (pointers.length < 2) return + + const [first, second] = pointers + pinchRef.current = { + startDistance: getPointDistance(first, second), + startScale: scaleRef.current, + startCenter: getPointCenter(first, second), + origin: offsetRef.current, + } + dragRef.current = null + }, []) + + const handlePointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) return + event.currentTarget.setPointerCapture(event.pointerId) + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + backdropPressRef.current = event.target === event.currentTarget + ? { pointerId: event.pointerId, x: event.clientX, y: event.clientY } + : null + + if (activePointersRef.current.size >= 2) { + backdropPressRef.current = null + beginPinch() + return + } + + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + }, [beginPinch]) + + const handlePointerMove = useCallback((event: PointerEvent) => { + if (!activePointersRef.current.has(event.pointerId)) return + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + + if (activePointersRef.current.size >= 2 && pinchRef.current) { + const pointers = Array.from(activePointersRef.current.values()) + const [first, second] = pointers + const distance = getPointDistance(first, second) + const center = getPointCenter(first, second) + const pinch = pinchRef.current + const nextScale = pinch.startDistance > 0 + ? clampScale(pinch.startScale * (distance / pinch.startDistance)) + : pinch.startScale + + updateScale(nextScale) + updateOffset({ + x: pinch.origin.x + center.x - pinch.startCenter.x, + y: pinch.origin.y + center.y - pinch.startCenter.y, + }) + return + } + + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + updateOffset({ + x: drag.originX + event.clientX - drag.startX, + y: drag.originY + event.clientY - drag.startY, + }) + }, [updateOffset, updateScale]) + + const handlePointerUp = useCallback((event: PointerEvent) => { + const backdropPress = backdropPressRef.current + const moved = backdropPress + ? Math.hypot(event.clientX - backdropPress.x, event.clientY - backdropPress.y) + : Number.POSITIVE_INFINITY + const shouldCloseFromBackdrop = event.type === 'pointerup' + && backdropPress?.pointerId === event.pointerId + && event.target === event.currentTarget + && activePointersRef.current.size === 1 + && moved <= BACKDROP_CLICK_MAX_MOVEMENT + + activePointersRef.current.delete(event.pointerId) + if (backdropPress?.pointerId === event.pointerId) { + backdropPressRef.current = null + } + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + } + pinchRef.current = null + + const remainingPointer = activePointersRef.current.entries().next().value as [number, Point] | undefined + if (remainingPointer) { + dragRef.current = { + pointerId: remainingPointer[0], + startX: remainingPointer[1].x, + startY: remainingPointer[1].y, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + } + if (shouldCloseFromBackdrop) { + closeViewer() + } + }, [closeViewer]) + + useEffect(() => { + if (!open) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeViewer() + } + if (event.key === '0') { + resetView() + } + if (event.key === '+' || event.key === '=') { + zoomBy(SCALE_STEP) + } + if (event.key === '-') { + zoomBy(-SCALE_STEP) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeViewer, open, resetView, zoomBy]) + + if (!open) return null + + return ( +
+
+
{title ?? ariaLabel}
+ + + + +
+
+
+ {children} +
+
+
+ ) +} diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx new file mode 100644 index 000000000..bbe10366e --- /dev/null +++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { I18nProvider } from '@/lib/i18n-context' + +function installSvgBBoxPolyfill() { + const bbox = () => ({ + x: 0, + y: 0, + width: 120, + height: 24, + top: 0, + left: 0, + right: 120, + bottom: 24, + toJSON() { + return {} + }, + }) + + for (const proto of [Element.prototype, HTMLElement.prototype, SVGElement.prototype]) { + if (proto && !('getBBox' in proto)) { + Object.defineProperty(proto, 'getBBox', { + configurable: true, + value: bbox, + }) + } + } +} + +describe('MermaidDiagram live render', () => { + it('renders real mermaid source to svg in jsdom', async () => { + installSvgBBoxPolyfill() + + render( + + WebUI\n WebUI --> SVG'} + language="mermaid" + components={{ + Pre: (props) =>
,
+                        Code: (props) => ,
+                    }}
+                />
+            ,
+        )
+
+        await waitFor(
+            () => {
+                const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
+                expect(diagram).toBeTruthy()
+                expect(diagram?.querySelector('svg')).toBeTruthy()
+            },
+            { timeout: 10000 },
+        )
+    })
+})
diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
index 463d44d85..b4f478305 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
@@ -1,5 +1,7 @@
+import type { ComponentProps } from 'react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { I18nProvider } from '@/lib/i18n-context'
 
 const mermaidMocks = vi.hoisted(() => ({
     initializeMock: vi.fn(),
@@ -20,16 +22,16 @@ vi.mock('mermaid', () => ({
 import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
 import { MARKDOWN_COMPONENTS_BY_LANGUAGE } from '@/components/assistant-ui/markdown-text'
 
-function renderMermaid(code: string) {
+const defaultComponents = {
+    Pre: (props: ComponentProps<'pre'>) => 
,
+    Code: (props: ComponentProps<'code'>) => ,
+}
+
+function renderDiagram(props: ComponentProps) {
     return render(
-         
,
-                Code: (props) => ,
-            }}
-        />
+        
+            
+        ,
     )
 }
 
@@ -41,7 +43,7 @@ describe('MermaidDiagram', () => {
         mermaidMocks.parseMock.mockResolvedValue({ diagramType: 'flowchart-v2' })
         mermaidMocks.renderMock.mockReset()
         mermaidMocks.renderMock.mockResolvedValue({
-            svg: ''
+            svg: '',
         })
     })
 
@@ -51,7 +53,11 @@ describe('MermaidDiagram', () => {
     })
 
     it('is wired into the shared markdown language overrides and renders svg output', async () => {
-        renderMermaid('graph TD\nA --> B')
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
@@ -73,7 +79,11 @@ describe('MermaidDiagram', () => {
         document.documentElement.dataset.theme = 'dark'
         mermaidMocks.parseMock.mockResolvedValueOnce(false)
 
-        renderMermaid('graph TD\nA --')
+        renderDiagram({
+            code: 'graph TD\nA --',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -90,7 +100,11 @@ describe('MermaidDiagram', () => {
         mermaidMocks.renderMock.mockRejectedValueOnce(new Error('render failed'))
         const code = 'gantt\ndateFormat YYYY-MM-DD\nsection A\nTask :a, 2024-01-01'
 
-        renderMermaid(code)
+        renderDiagram({
+            code,
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -104,4 +118,42 @@ describe('MermaidDiagram', () => {
         }))
     })
 
+    it('opens a zoomable lightbox when the rendered diagram is clicked', async () => {
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"]')).toBeTruthy()
+        })
+
+        fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+        await waitFor(() => {
+            expect(screen.getByRole('dialog', { name: 'Diagram' })).toBeTruthy()
+        })
+
+        expect(mermaidMocks.renderMock).toHaveBeenCalledWith(
+            expect.stringContaining('mermaid-modal-'),
+            'graph TD\nA --> B',
+        )
+    })
+
+    it('does not expose a lightbox trigger when rendering fails', async () => {
+        mermaidMocks.renderMock.mockRejectedValue(new Error('syntax'))
+
+        renderDiagram({
+            code: 'not valid mermaid',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="false"]')).toBeTruthy()
+        })
+
+        expect(screen.queryByRole('button', { name: 'Open diagram full screen' })).toBeNull()
+    })
 })
diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index e2b47583c..9df236f59 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -1,6 +1,8 @@
 import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
-import { useEffect, useId, useState, type ComponentPropsWithoutRef } from 'react'
+import { useEffect, useId, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react'
+import { ZoomableLightbox } from '@/components/ZoomableLightbox'
 import { cn } from '@/lib/utils'
+import { useTranslation } from '@/lib/use-translation'
 
 let initializedTheme: 'light' | 'dark' | null = null
 let mermaidPromise: Promise | null = null
@@ -63,24 +65,63 @@ async function ensureMermaid(theme: 'light' | 'dark') {
     return mermaid
 }
 
+export async function renderMermaidSvg(
+    code: string,
+    elementId: string,
+    theme: 'light' | 'dark',
+): Promise {
+    const mermaid = await ensureMermaid(theme)
+    const isValid = await mermaid.parse(code, { suppressErrors: true })
+    if (!isValid) return null
+    const result = await mermaid.render(elementId, code)
+    return result.svg
+}
+
 function MermaidFallback(props: ComponentPropsWithoutRef<'pre'> & { code: string }) {
+    const { code, className, ...rest } = props
     return (
         
-            {props.code}
+            {code}
         
) } +function MermaidSvgContent(props: { svg: string; className?: string }) { + return ( +
+ ) +} + export function MermaidDiagram(props: SyntaxHighlighterProps) { + const { t } = useTranslation() const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme()) const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxSvg, setLightboxSvg] = useState(null) + const [lightboxLoading, setLightboxLoading] = useState(false) const id = useId().replace(/:/g, '-') + const openLabel = t('mermaid.openFullscreen') + const viewerLabel = t('mermaid.viewerTitle') + + const stopEvent = (event: SyntheticEvent) => { + event.stopPropagation() + } + + const openLightbox = (event: SyntheticEvent) => { + event.preventDefault() + event.stopPropagation() + setLightboxOpen(true) + } useEffect(() => { if (typeof document === 'undefined') return undefined @@ -103,18 +144,14 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const render = async () => { try { - const mermaid = await ensureMermaid(theme) - const isValid = await mermaid.parse(props.code, { suppressErrors: true }) + const nextSvg = await renderMermaidSvg(props.code, `mermaid-${id}`, theme) if (cancelled) return - if (!isValid) { + if (!nextSvg) { setSvg(null) setRenderError(true) return } - - const result = await mermaid.render(`mermaid-${id}`, props.code) - if (cancelled) return - setSvg(result.svg) + setSvg(nextSvg) setRenderError(false) } catch { if (cancelled) return @@ -130,20 +167,75 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) + useEffect(() => { + if (!lightboxOpen) { + setLightboxSvg(null) + setLightboxLoading(false) + return + } + + let cancelled = false + setLightboxLoading(true) + + const render = async () => { + try { + const nextSvg = await renderMermaidSvg(props.code, `mermaid-modal-${id}`, theme) + if (cancelled) return + setLightboxSvg(nextSvg) + } catch { + if (cancelled) return + setLightboxSvg(null) + } finally { + if (!cancelled) setLightboxLoading(false) + } + } + + void render() + + return () => { + cancelled = true + } + }, [id, lightboxOpen, props.code, theme]) + if (renderError || !svg) { return } return ( -
-
-
+ <> + + + setLightboxOpen(false)} + title={viewerLabel} + ariaLabel={viewerLabel} + > + {lightboxLoading ? ( +
{t('mermaid.loading')}
+ ) : lightboxSvg ? ( + + ) : ( +
{t('mermaid.renderError')}
+ )} +
+ ) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d5f059c80..b2bcd5ed6 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -182,6 +182,12 @@ export default { 'session.export.toast.success.body': 'Downloaded {filename}', 'session.export.toast.error.title': 'Export failed', + // Mermaid diagrams + 'mermaid.openFullscreen': 'Open diagram full screen', + 'mermaid.viewerTitle': 'Diagram', + 'mermaid.loading': 'Loading diagram…', + 'mermaid.renderError': 'Could not render diagram.', + // Common buttons 'button.cancel': 'Cancel', 'button.save': 'Save', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 5e63692e9..e3b688fa7 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -186,6 +186,12 @@ export default { 'session.export.toast.success.body': '已下载 {filename}', 'session.export.toast.error.title': '导出失败', + // Mermaid diagrams + 'mermaid.openFullscreen': '全屏查看图表', + 'mermaid.viewerTitle': '图表', + 'mermaid.loading': '正在加载图表…', + 'mermaid.renderError': '无法渲染图表。', + // Common buttons 'button.cancel': '取消', 'button.save': '保存', From 02b8cebbe209d570af187a75bbcb9fed608221b9 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 00:18:10 +0100 Subject: [PATCH 02/24] fix(web): fit mermaid lightbox to viewport on open Auto-scale diagrams to fill the viewer instead of opening at intrinsic mermaid size. Reset returns to fit; zoom label is relative to fit (100%). Co-authored-by: Cursor --- web/src/components/ZoomableLightbox.tsx | 94 +++++++++++++++++-- .../assistant-ui/mermaid-diagram.tsx | 3 +- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index 78751b5d3..a65620803 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' import { CloseIcon } from '@/components/icons' const MIN_SCALE = 0.25 const MAX_SCALE = 8 const SCALE_STEP = 0.25 const BACKDROP_CLICK_MAX_MOVEMENT = 4 +const FIT_PADDING_PX = 40 type Point = { x: number; y: number } @@ -23,20 +24,44 @@ function getPointCenter(a: Point, b: Point): Point { } } +function measureContentSize(content: HTMLElement): { width: number; height: number } | null { + const svg = content.querySelector('svg') + if (svg) { + const box = svg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width, height: box.height } + } + } + + const rect = content.getBoundingClientRect() + if (rect.width > 0 && rect.height > 0) { + return { width: rect.width, height: rect.height } + } + + return null +} + export type ZoomableLightboxProps = { open: boolean onClose: () => void title?: string ariaLabel: string children: ReactNode + /** When set, re-fit viewport when this value changes (e.g. after async SVG load). */ + fitContentKey?: string | number | null + /** Compute initial scale to fill the viewport (default true). */ + fitOnOpen?: boolean } export function ZoomableLightbox(props: ZoomableLightboxProps) { - const { open, onClose, title, ariaLabel, children } = props + const { open, onClose, title, ariaLabel, children, fitContentKey = null, fitOnOpen = true } = props const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) const scaleRef = useRef(scale) const offsetRef = useRef(offset) + const baseScaleRef = useRef(1) + const viewportRef = useRef(null) + const contentRef = useRef(null) const activePointersRef = useRef(new Map()) const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null) @@ -55,8 +80,33 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { setOffset(next) }, []) + const applyFitScale = useCallback(() => { + if (!fitOnOpen) { + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + return + } + + const viewport = viewportRef.current + const content = contentRef.current + if (!viewport || !content) return + + const contentSize = measureContentSize(content) + if (!contentSize) return + + const viewportRect = viewport.getBoundingClientRect() + const fitWidth = (viewportRect.width - FIT_PADDING_PX) / contentSize.width + const fitHeight = (viewportRect.height - FIT_PADDING_PX) / contentSize.height + const fitScale = clampScale(Math.min(fitWidth, fitHeight)) + + baseScaleRef.current = fitScale + updateScale(fitScale) + updateOffset({ x: 0, y: 0 }) + }, [fitOnOpen, updateOffset, updateScale]) + const resetView = useCallback(() => { - updateScale(1) + updateScale(baseScaleRef.current) updateOffset({ x: 0, y: 0 }) }, [updateOffset, updateScale]) @@ -66,8 +116,10 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { dragRef.current = null pinchRef.current = null backdropPressRef.current = null - resetView() - }, [onClose, resetView]) + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + }, [onClose, updateOffset, updateScale]) const zoomBy = useCallback((delta: number) => { updateScale((current) => clampScale(current + delta)) @@ -181,6 +233,25 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { } }, [closeViewer]) + useLayoutEffect(() => { + if (!open) return + + let frame = 0 + const scheduleFit = () => { + frame = requestAnimationFrame(() => { + applyFitScale() + }) + } + + scheduleFit() + const retry = window.setTimeout(scheduleFit, 50) + + return () => { + cancelAnimationFrame(frame) + window.clearTimeout(retry) + } + }, [open, fitContentKey, applyFitScale]) + useEffect(() => { if (!open) return @@ -205,6 +276,11 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { if (!open) return null + const baseScale = baseScaleRef.current + const zoomLabel = baseScale > 0 + ? `${Math.round((scale / baseScale) * 100)}%` + : `${Math.round(scale * 100)}%` + return (
- {Math.round(scale * 100)}% + {zoomLabel}
setLightboxOpen(false)} title={viewerLabel} ariaLabel={viewerLabel} + fitContentKey={lightboxSvg} > {lightboxLoading ? (
{t('mermaid.loading')}
) : lightboxSvg ? ( ) : (
{t('mermaid.renderError')}
From 9b17ac24ebdfab76024cf704fd2cacb7e5983887 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 00:20:01 +0100 Subject: [PATCH 03/24] fix(web): fit mermaid lightbox to device screen not inner panel Use visualViewport for fit scale, full-screen pan layer, and a floating toolbar so the diagram can use the whole display. Co-authored-by: Cursor --- web/src/components/ZoomableLightbox.tsx | 106 ++++++++++++++---------- 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index a65620803..3efa68955 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -5,7 +5,16 @@ const MIN_SCALE = 0.25 const MAX_SCALE = 8 const SCALE_STEP = 0.25 const BACKDROP_CLICK_MAX_MOVEMENT = 4 -const FIT_PADDING_PX = 40 +/** Edge margin when fitting to the device screen (not the inner panel only). */ +const SCREEN_FIT_PADDING_PX = 12 + +function getScreenFitSize(): { width: number; height: number } { + const viewport = window.visualViewport + if (viewport) { + return { width: viewport.width, height: viewport.height } + } + return { width: window.innerWidth, height: window.innerHeight } +} type Point = { x: number; y: number } @@ -49,7 +58,7 @@ export type ZoomableLightboxProps = { children: ReactNode /** When set, re-fit viewport when this value changes (e.g. after async SVG load). */ fitContentKey?: string | number | null - /** Compute initial scale to fill the viewport (default true). */ + /** Compute initial scale to fill the device screen (default true). */ fitOnOpen?: boolean } @@ -88,16 +97,16 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { return } - const viewport = viewportRef.current const content = contentRef.current - if (!viewport || !content) return + if (!content) return const contentSize = measureContentSize(content) if (!contentSize) return - const viewportRect = viewport.getBoundingClientRect() - const fitWidth = (viewportRect.width - FIT_PADDING_PX) / contentSize.width - const fitHeight = (viewportRect.height - FIT_PADDING_PX) / contentSize.height + const screen = getScreenFitSize() + const pad = SCREEN_FIT_PADDING_PX * 2 + const fitWidth = (screen.width - pad) / contentSize.width + const fitHeight = (screen.height - pad) / contentSize.height const fitScale = clampScale(Math.min(fitWidth, fitHeight)) baseScaleRef.current = fitScale @@ -283,51 +292,14 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { return (
-
-
{title ?? ariaLabel}
- - - - -
+
event.stopPropagation()} + > +
+
{title ?? ariaLabel}
+ + + + +
+
) } From a337b253eab0b23ac24f7810d0149c2aea59787e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 00:39:00 +0100 Subject: [PATCH 04/24] fix(web): show mermaid lightbox by reusing inline SVG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second mermaid.render on open often left a 0×0 SVG while fit scale was computed from the loading placeholder. Reuse the inline SVG in the modal and measure viewBox with retried fit-to-screen. Co-authored-by: Cursor --- web/public/mermaid-lightbox-smoke.html | 23 +++++++++ web/src/components/ZoomableLightbox.tsx | 37 ++++++++++++-- .../mermaid-diagram.live.test.tsx | 15 +++++- .../assistant-ui/mermaid-diagram.test.tsx | 6 ++- .../assistant-ui/mermaid-diagram.tsx | 48 ++----------------- web/src/dev/mermaid-lightbox-smoke.tsx | 21 ++++++++ 6 files changed, 100 insertions(+), 50 deletions(-) create mode 100644 web/public/mermaid-lightbox-smoke.html create mode 100644 web/src/dev/mermaid-lightbox-smoke.tsx diff --git a/web/public/mermaid-lightbox-smoke.html b/web/public/mermaid-lightbox-smoke.html new file mode 100644 index 000000000..0defbd4ee --- /dev/null +++ b/web/public/mermaid-lightbox-smoke.html @@ -0,0 +1,23 @@ + + + + + + Mermaid lightbox smoke + + + +
+ + + + diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index 3efa68955..bc88f853a 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -33,13 +33,31 @@ function getPointCenter(a: Point, b: Point): Point { } } +function measureSvgIntrinsicSize(svg: SVGSVGElement): { width: number; height: number } | null { + const box = svg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width, height: box.height } + } + + const viewBox = svg.viewBox?.baseVal + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height } + } + + const widthAttr = Number.parseFloat(svg.getAttribute('width') ?? '') + const heightAttr = Number.parseFloat(svg.getAttribute('height') ?? '') + if (widthAttr > 0 && heightAttr > 0) { + return { width: widthAttr, height: heightAttr } + } + + return null +} + function measureContentSize(content: HTMLElement): { width: number; height: number } | null { const svg = content.querySelector('svg') if (svg) { - const box = svg.getBoundingClientRect() - if (box.width > 0 && box.height > 0) { - return { width: box.width, height: box.height } - } + const intrinsic = measureSvgIntrinsicSize(svg) + if (intrinsic) return intrinsic } const rect = content.getBoundingClientRect() @@ -246,18 +264,29 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { if (!open) return let frame = 0 + let attempt = 0 + const maxAttempts = 16 + const scheduleFit = () => { frame = requestAnimationFrame(() => { + const content = contentRef.current + const hadSize = content ? measureContentSize(content) : null applyFitScale() + attempt += 1 + if (!hadSize && attempt < maxAttempts) { + scheduleFit() + } }) } scheduleFit() const retry = window.setTimeout(scheduleFit, 50) + const lateRetry = window.setTimeout(scheduleFit, 200) return () => { cancelAnimationFrame(frame) window.clearTimeout(retry) + window.clearTimeout(lateRetry) } }, [open, fitContentKey, applyFitScale]) diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx index bbe10366e..af4181d23 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { render, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' import { I18nProvider } from '@/lib/i18n-context' @@ -53,5 +53,18 @@ describe('MermaidDiagram live render', () => { }, { timeout: 10000 }, ) + + fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement) + + await waitFor( + () => { + const dialog = screen.getByRole('dialog', { name: 'Diagram' }) + const dialogSvg = dialog.querySelector('svg') + expect(dialogSvg).toBeTruthy() + const transform = dialog.querySelector('[style*="transform"]')?.style.transform ?? '' + expect(transform).toMatch(/scale\([^0)]/) + }, + { timeout: 5000 }, + ) }) }) diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx index b4f478305..46daa771f 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx @@ -132,11 +132,13 @@ describe('MermaidDiagram', () => { fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement) await waitFor(() => { - expect(screen.getByRole('dialog', { name: 'Diagram' })).toBeTruthy() + const dialog = screen.getByRole('dialog', { name: 'Diagram' }) + expect(dialog.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy() }) + expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1) expect(mermaidMocks.renderMock).toHaveBeenCalledWith( - expect.stringContaining('mermaid-modal-'), + expect.stringContaining('mermaid-'), 'graph TD\nA --> B', ) }) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index a964c20ba..3b160875c 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -107,8 +107,6 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) const [lightboxOpen, setLightboxOpen] = useState(false) - const [lightboxSvg, setLightboxSvg] = useState(null) - const [lightboxLoading, setLightboxLoading] = useState(false) const id = useId().replace(/:/g, '-') const openLabel = t('mermaid.openFullscreen') const viewerLabel = t('mermaid.viewerTitle') @@ -167,36 +165,6 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) - useEffect(() => { - if (!lightboxOpen) { - setLightboxSvg(null) - setLightboxLoading(false) - return - } - - let cancelled = false - setLightboxLoading(true) - - const render = async () => { - try { - const nextSvg = await renderMermaidSvg(props.code, `mermaid-modal-${id}`, theme) - if (cancelled) return - setLightboxSvg(nextSvg) - } catch { - if (cancelled) return - setLightboxSvg(null) - } finally { - if (!cancelled) setLightboxLoading(false) - } - } - - void render() - - return () => { - cancelled = true - } - }, [id, lightboxOpen, props.code, theme]) - if (renderError || !svg) { return } @@ -224,18 +192,12 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { onClose={() => setLightboxOpen(false)} title={viewerLabel} ariaLabel={viewerLabel} - fitContentKey={lightboxSvg} + fitContentKey={lightboxOpen ? svg : null} > - {lightboxLoading ? ( -
{t('mermaid.loading')}
- ) : lightboxSvg ? ( - - ) : ( -
{t('mermaid.renderError')}
- )} + ) diff --git a/web/src/dev/mermaid-lightbox-smoke.tsx b/web/src/dev/mermaid-lightbox-smoke.tsx new file mode 100644 index 000000000..80ff95463 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-smoke.tsx @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +createRoot(root).render( + + Lightbox\n Lightbox --> Visible'} + language="mermaid" + components={{ + Pre: (props) =>
,
+                Code: (props) => ,
+            }}
+        />
+    ,
+)

From 1ec883473fbca0811871c2d8bb384700b327651b Mon Sep 17 00:00:00 2001
From: HeavyGee <133152184+heavygee@users.noreply.github.com>
Date: Sun, 31 May 2026 00:52:44 +0100
Subject: [PATCH 05/24] fix(web): uniquify mermaid SVG ids in lightbox clone

Inlining the same mermaid markup twice duplicates element ids and breaks
url(#ref) resolution in the modal copy. Prefix ids and hrefs for lightbox only.

Co-authored-by: Cursor 
---
 .../mermaid-diagram.live.test.tsx             |  7 ++++
 .../assistant-ui/mermaid-diagram.tsx          | 34 +++++++++++++++++--
 .../assistant-ui/mermaid-svg-id.test.ts       | 14 ++++++++
 3 files changed, 52 insertions(+), 3 deletions(-)
 create mode 100644 web/src/components/assistant-ui/mermaid-svg-id.test.ts

diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
index af4181d23..8c95f5a0f 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
@@ -63,6 +63,13 @@ describe('MermaidDiagram live render', () => {
                 expect(dialogSvg).toBeTruthy()
                 const transform = dialog.querySelector('[style*="transform"]')?.style.transform ?? ''
                 expect(transform).toMatch(/scale\([^0)]/)
+
+                const idCounts = new Map()
+                for (const el of document.querySelectorAll('[id]')) {
+                    idCounts.set(el.id, (idCounts.get(el.id) ?? 0) + 1)
+                }
+                const duplicateIds = [...idCounts.entries()].filter(([, count]) => count > 1)
+                expect(duplicateIds).toEqual([])
             },
             { timeout: 5000 },
         )
diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index 3b160875c..efc598681 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -1,5 +1,5 @@
 import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
-import { useEffect, useId, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react'
+import { useEffect, useId, useMemo, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react'
 import { ZoomableLightbox } from '@/components/ZoomableLightbox'
 import { cn } from '@/lib/utils'
 import { useTranslation } from '@/lib/use-translation'
@@ -77,6 +77,30 @@ export async function renderMermaidSvg(
     return result.svg
 }
 
+/** Clone markup for lightbox so url(#id) / defs are not shared with the inline copy. */
+export function uniqueifyMermaidSvgIds(svg: string, scope: string): string {
+    const prefix = `mermaid-lb-${scope}-`
+    const idRegex = /\bid="([^"]+)"/g
+    const ids: string[] = []
+    idRegex.lastIndex = 0
+    let match: RegExpExecArray | null = idRegex.exec(svg)
+    while (match !== null) {
+        ids.push(match[1])
+        match = idRegex.exec(svg)
+    }
+
+    const uniqueIds = [...new Set(ids)].sort((a, b) => b.length - a.length)
+    let result = svg
+    for (const id of uniqueIds) {
+        const next = `${prefix}${id}`
+        result = result.replaceAll(`id="${id}"`, `id="${next}"`)
+        result = result.replaceAll(`url(#${id})`, `url(#${next})`)
+        result = result.replaceAll(`href="#${id}"`, `href="#${next}"`)
+        result = result.replaceAll(`xlink:href="#${id}"`, `xlink:href="#${next}"`)
+    }
+    return result
+}
+
 function MermaidFallback(props: ComponentPropsWithoutRef<'pre'> & { code: string }) {
     const { code, className, ...rest } = props
     return (
@@ -110,6 +134,10 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
     const id = useId().replace(/:/g, '-')
     const openLabel = t('mermaid.openFullscreen')
     const viewerLabel = t('mermaid.viewerTitle')
+    const lightboxSvg = useMemo(
+        () => (svg ? uniqueifyMermaidSvgIds(svg, id) : null),
+        [svg, id],
+    )
 
     const stopEvent = (event: SyntheticEvent) => {
         event.stopPropagation()
@@ -192,10 +220,10 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
                 onClose={() => setLightboxOpen(false)}
                 title={viewerLabel}
                 ariaLabel={viewerLabel}
-                fitContentKey={lightboxOpen ? svg : null}
+                fitContentKey={lightboxOpen ? lightboxSvg : null}
             >
                 
             
diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts
new file mode 100644
index 000000000..c121e773e
--- /dev/null
+++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { uniqueifyMermaidSvgIds } from '@/components/assistant-ui/mermaid-diagram'
+
+describe('uniqueifyMermaidSvgIds', () => {
+    it('rewrites ids and url(#) references for a lightbox clone', () => {
+        const svg = ``
+        const scoped = uniqueifyMermaidSvgIds(svg, 'abc')
+
+        expect(scoped).toContain('id="mermaid-lb-abc-root"')
+        expect(scoped).toContain('id="mermaid-lb-abc-arrowhead"')
+        expect(scoped).toContain('url(#mermaid-lb-abc-arrowhead)')
+        expect(scoped).not.toContain('url(#arrowhead)')
+    })
+})

From be7ca87415c46086471c8d477298f59b327b6fd9 Mon Sep 17 00:00:00 2001
From: HeavyGee <133152184+heavygee@users.noreply.github.com>
Date: Sun, 31 May 2026 00:59:59 +0100
Subject: [PATCH 06/24] fix(web): give mermaid lightbox SVG explicit dimensions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Mermaid emits width="100%" with max-width in px; that collapses to 0×0
inside the centered lightbox layer. Derive width/height from viewBox for
the uniquified lightbox clone.

Co-authored-by: Cursor 
---
 .../assistant-ui/mermaid-diagram.tsx          | 51 ++++++++++++++++++-
 .../assistant-ui/mermaid-svg-id.test.ts       | 11 +++-
 2 files changed, 60 insertions(+), 2 deletions(-)

diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index efc598681..a8e858f4f 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -101,6 +101,55 @@ export function uniqueifyMermaidSvgIds(svg: string, scope: string): string {
     return result
 }
 
+function parseViewBoxSize(svg: string): { width: number; height: number } | null {
+    const viewBoxMatch = svg.match(/\bviewBox="([\d.\s]+)"/)
+    if (!viewBoxMatch) return null
+    const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number)
+    if (parts.length < 4 || parts[2] <= 0 || parts[3] <= 0) return null
+    return { width: parts[2], height: parts[3] }
+}
+
+/** Lightbox has no block width context; mermaid often emits width="100%" which collapses to 0. */
+export function prepareMermaidSvgForLightbox(svg: string, scope: string): string {
+    let result = uniqueifyMermaidSvgIds(svg, scope)
+    const viewBoxSize = parseViewBoxSize(result)
+    if (!viewBoxSize) return result
+
+    const { width, height } = viewBoxSize
+    result = result.replace(/\swidth="100%"/gi, '')
+    result = result.replace(/\sheight="100%"/gi, '')
+
+    if (/\sstyle="/i.test(result)) {
+        result = result.replace(
+            /(]*?\sstyle=")([^"]*)(")/i,
+            (_full, prefix: string, style: string, suffix: string) => {
+                const cleaned = style
+                    .replace(/(?:^|;)\s*max-width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*height:\s*[^;]+/gi, '')
+                    .replace(/^;+|;+$/g, '')
+                    .replace(/;\s*;/g, ';')
+                    .trim()
+                const nextStyle = cleaned
+                    ? `${cleaned};width:${width}px;height:${height}px`
+                    : `width:${width}px;height:${height}px`
+                return `${prefix}${nextStyle}${suffix}`
+            },
+        )
+    } else {
+        result = result.replace(
+            / & { code: string }) {
     const { code, className, ...rest } = props
     return (
@@ -135,7 +184,7 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
     const openLabel = t('mermaid.openFullscreen')
     const viewerLabel = t('mermaid.viewerTitle')
     const lightboxSvg = useMemo(
-        () => (svg ? uniqueifyMermaidSvgIds(svg, id) : null),
+        () => (svg ? prepareMermaidSvgForLightbox(svg, id) : null),
         [svg, id],
     )
 
diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts
index c121e773e..25daf5771 100644
--- a/web/src/components/assistant-ui/mermaid-svg-id.test.ts
+++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { uniqueifyMermaidSvgIds } from '@/components/assistant-ui/mermaid-diagram'
+import { prepareMermaidSvgForLightbox, uniqueifyMermaidSvgIds } from '@/components/assistant-ui/mermaid-diagram'
 
 describe('uniqueifyMermaidSvgIds', () => {
     it('rewrites ids and url(#) references for a lightbox clone', () => {
@@ -11,4 +11,13 @@ describe('uniqueifyMermaidSvgIds', () => {
         expect(scoped).toContain('url(#mermaid-lb-abc-arrowhead)')
         expect(scoped).not.toContain('url(#arrowhead)')
     })
+
+    it('replaces width="100%" with explicit viewBox dimensions for lightbox layout', () => {
+        const svg = ''
+        const prepared = prepareMermaidSvgForLightbox(svg, 'x')
+
+        expect(prepared).not.toContain('width="100%"')
+        expect(prepared).toContain('width:200px')
+        expect(prepared).toContain('height:80px')
+    })
 })

From 575de5470941af7c248f962364137d6716cdc797 Mon Sep 17 00:00:00 2001
From: HeavyGee <133152184+heavygee@users.noreply.github.com>
Date: Sun, 31 May 2026 01:24:37 +0100
Subject: [PATCH 07/24] fix(web): render mermaid lightbox via isolated SVG data
 URL

String id rewrites broke mermaid's embedded CSS so only labels appeared
zoomed. Rasterize the inline SVG to a data-URL img instead of duplicating
markup in the DOM.

Co-authored-by: Cursor 
---
 web/src/components/ZoomableLightbox.tsx       | 11 ++++
 .../mermaid-diagram.live.test.tsx             | 12 +---
 .../assistant-ui/mermaid-diagram.test.tsx     |  2 +-
 .../assistant-ui/mermaid-diagram.tsx          | 55 +++++++------------
 .../assistant-ui/mermaid-svg-id.test.ts       | 31 ++++++-----
 5 files changed, 52 insertions(+), 59 deletions(-)

diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx
index bc88f853a..ad32b2d0a 100644
--- a/web/src/components/ZoomableLightbox.tsx
+++ b/web/src/components/ZoomableLightbox.tsx
@@ -54,6 +54,17 @@ function measureSvgIntrinsicSize(svg: SVGSVGElement): { width: number; height: n
 }
 
 function measureContentSize(content: HTMLElement): { width: number; height: number } | null {
+    const img = content.querySelector('img')
+    if (img) {
+        const box = img.getBoundingClientRect()
+        if (box.width > 0 && box.height > 0) {
+            return { width: box.width, height: box.height }
+        }
+        if (img.naturalWidth > 0 && img.naturalHeight > 0) {
+            return { width: img.naturalWidth, height: img.naturalHeight }
+        }
+    }
+
     const svg = content.querySelector('svg')
     if (svg) {
         const intrinsic = measureSvgIntrinsicSize(svg)
diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
index 8c95f5a0f..07ea845f7 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
@@ -59,17 +59,11 @@ describe('MermaidDiagram live render', () => {
         await waitFor(
             () => {
                 const dialog = screen.getByRole('dialog', { name: 'Diagram' })
-                const dialogSvg = dialog.querySelector('svg')
-                expect(dialogSvg).toBeTruthy()
+                const img = dialog.querySelector('img')
+                expect(img).toBeTruthy()
+                expect(img?.getAttribute('src')?.startsWith('data:image/svg+xml')).toBe(true)
                 const transform = dialog.querySelector('[style*="transform"]')?.style.transform ?? ''
                 expect(transform).toMatch(/scale\([^0)]/)
-
-                const idCounts = new Map()
-                for (const el of document.querySelectorAll('[id]')) {
-                    idCounts.set(el.id, (idCounts.get(el.id) ?? 0) + 1)
-                }
-                const duplicateIds = [...idCounts.entries()].filter(([, count]) => count > 1)
-                expect(duplicateIds).toEqual([])
             },
             { timeout: 5000 },
         )
diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
index 46daa771f..480c8cbc6 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
@@ -133,7 +133,7 @@ describe('MermaidDiagram', () => {
 
         await waitFor(() => {
             const dialog = screen.getByRole('dialog', { name: 'Diagram' })
-            expect(dialog.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy()
+            expect(dialog.querySelector('img[alt="Diagram"]')).toBeTruthy()
         })
 
         expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1)
diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index a8e858f4f..baf2bc821 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -77,30 +77,6 @@ export async function renderMermaidSvg(
     return result.svg
 }
 
-/** Clone markup for lightbox so url(#id) / defs are not shared with the inline copy. */
-export function uniqueifyMermaidSvgIds(svg: string, scope: string): string {
-    const prefix = `mermaid-lb-${scope}-`
-    const idRegex = /\bid="([^"]+)"/g
-    const ids: string[] = []
-    idRegex.lastIndex = 0
-    let match: RegExpExecArray | null = idRegex.exec(svg)
-    while (match !== null) {
-        ids.push(match[1])
-        match = idRegex.exec(svg)
-    }
-
-    const uniqueIds = [...new Set(ids)].sort((a, b) => b.length - a.length)
-    let result = svg
-    for (const id of uniqueIds) {
-        const next = `${prefix}${id}`
-        result = result.replaceAll(`id="${id}"`, `id="${next}"`)
-        result = result.replaceAll(`url(#${id})`, `url(#${next})`)
-        result = result.replaceAll(`href="#${id}"`, `href="#${next}"`)
-        result = result.replaceAll(`xlink:href="#${id}"`, `xlink:href="#${next}"`)
-    }
-    return result
-}
-
 function parseViewBoxSize(svg: string): { width: number; height: number } | null {
     const viewBoxMatch = svg.match(/\bviewBox="([\d.\s]+)"/)
     if (!viewBoxMatch) return null
@@ -109,9 +85,9 @@ function parseViewBoxSize(svg: string): { width: number; height: number } | null
     return { width: parts[2], height: parts[3] }
 }
 
-/** Lightbox has no block width context; mermaid often emits width="100%" which collapses to 0. */
-export function prepareMermaidSvgForLightbox(svg: string, scope: string): string {
-    let result = uniqueifyMermaidSvgIds(svg, scope)
+/** Mermaid often emits width="100%"; normalize before rasterizing for the lightbox. */
+export function normalizeMermaidSvgForStandaloneDisplay(svg: string): string {
+    let result = svg
     const viewBoxSize = parseViewBoxSize(result)
     if (!viewBoxSize) return result
 
@@ -150,6 +126,11 @@ export function prepareMermaidSvgForLightbox(svg: string, scope: string): string
     return result
 }
 
+export function mermaidSvgToDataUrl(svg: string): string {
+    const normalized = normalizeMermaidSvgForStandaloneDisplay(svg)
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(normalized)}`
+}
+
 function MermaidFallback(props: ComponentPropsWithoutRef<'pre'> & { code: string }) {
     const { code, className, ...rest } = props
     return (
@@ -183,9 +164,9 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
     const id = useId().replace(/:/g, '-')
     const openLabel = t('mermaid.openFullscreen')
     const viewerLabel = t('mermaid.viewerTitle')
-    const lightboxSvg = useMemo(
-        () => (svg ? prepareMermaidSvgForLightbox(svg, id) : null),
-        [svg, id],
+    const lightboxSrc = useMemo(
+        () => (svg ? mermaidSvgToDataUrl(svg) : null),
+        [svg],
     )
 
     const stopEvent = (event: SyntheticEvent) => {
@@ -269,12 +250,16 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
                 onClose={() => setLightboxOpen(false)}
                 title={viewerLabel}
                 ariaLabel={viewerLabel}
-                fitContentKey={lightboxOpen ? lightboxSvg : null}
+                fitContentKey={lightboxOpen ? lightboxSrc : null}
             >
-                
+                {lightboxSrc ? (
+                    {viewerLabel}
+                ) : null}
             
         
     )
diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts
index 25daf5771..ec3679e8d 100644
--- a/web/src/components/assistant-ui/mermaid-svg-id.test.ts
+++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts
@@ -1,23 +1,26 @@
 import { describe, expect, it } from 'vitest'
-import { prepareMermaidSvgForLightbox, uniqueifyMermaidSvgIds } from '@/components/assistant-ui/mermaid-diagram'
+import {
+    mermaidSvgToDataUrl,
+    normalizeMermaidSvgForStandaloneDisplay,
+} from '@/components/assistant-ui/mermaid-diagram'
 
-describe('uniqueifyMermaidSvgIds', () => {
-    it('rewrites ids and url(#) references for a lightbox clone', () => {
-        const svg = ``
-        const scoped = uniqueifyMermaidSvgIds(svg, 'abc')
-
-        expect(scoped).toContain('id="mermaid-lb-abc-root"')
-        expect(scoped).toContain('id="mermaid-lb-abc-arrowhead"')
-        expect(scoped).toContain('url(#mermaid-lb-abc-arrowhead)')
-        expect(scoped).not.toContain('url(#arrowhead)')
-    })
-
-    it('replaces width="100%" with explicit viewBox dimensions for lightbox layout', () => {
+describe('normalizeMermaidSvgForStandaloneDisplay', () => {
+    it('replaces width="100%" with explicit viewBox dimensions', () => {
         const svg = ''
-        const prepared = prepareMermaidSvgForLightbox(svg, 'x')
+        const prepared = normalizeMermaidSvgForStandaloneDisplay(svg)
 
         expect(prepared).not.toContain('width="100%"')
         expect(prepared).toContain('width:200px')
         expect(prepared).toContain('height:80px')
     })
 })
+
+describe('mermaidSvgToDataUrl', () => {
+    it('returns a data URL without duplicating SVG nodes in the document', () => {
+        const svg = 'A'
+        const url = mermaidSvgToDataUrl(svg)
+
+        expect(url.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
+        expect(decodeURIComponent(url.split(',')[1] ?? '')).toContain('width="120"')
+    })
+})

From 63c8c3d51458f87d417509f4bac9a48b0b4ed810 Mon Sep 17 00:00:00 2001
From: HeavyGee <133152184+heavygee@users.noreply.github.com>
Date: Sun, 31 May 2026 01:54:08 +0100
Subject: [PATCH 08/24] fix(web): lightbox re-renders SVG for sequence diagrams

Data-URL images drop or blank some mermaid diagram types (sequence).
Re-render with a modal-specific id into inline SVG on a code-bg panel,
and add sequence theme variables for dark/light.

Co-authored-by: Cursor 
---
 .../mermaid-diagram.live.test.tsx             | 96 ++++++++++++-------
 .../assistant-ui/mermaid-diagram.test.tsx     | 12 ++-
 .../assistant-ui/mermaid-diagram.tsx          | 83 ++++++++++++----
 .../assistant-ui/mermaid-svg-id.test.ts       | 15 +--
 4 files changed, 134 insertions(+), 72 deletions(-)

diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
index 07ea845f7..c3cc479a0 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx
@@ -1,3 +1,4 @@
+import type React from 'react'
 import { describe, expect, it } from 'vitest'
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
@@ -28,44 +29,67 @@ function installSvgBBoxPolyfill() {
     }
 }
 
-describe('MermaidDiagram live render', () => {
-    it('renders real mermaid source to svg in jsdom', async () => {
-        installSvgBBoxPolyfill()
+const sequenceDiagram = `sequenceDiagram
+  participant U as Operator
+  participant C as Chat
+  participant M as Mermaid
+  U->>C: Send message
+  C->>M: Render SVG
+  U->>M: Click diagram
+  M-->>U: Lightbox + zoom`
 
-        render(
-            
-                 WebUI\n  WebUI --> SVG'}
-                    language="mermaid"
-                    components={{
-                        Pre: (props) => 
,
-                        Code: (props) => ,
-                    }}
-                />
-            ,
-        )
+const defaultComponents = {
+    Pre: (props: React.ComponentProps<'pre'>) => 
,
+    Code: (props: React.ComponentProps<'code'>) => ,
+}
 
-        await waitFor(
-            () => {
-                const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
-                expect(diagram).toBeTruthy()
-                expect(diagram?.querySelector('svg')).toBeTruthy()
-            },
-            { timeout: 10000 },
-        )
+async function expectLightboxShowsDiagram(code: string) {
+    installSvgBBoxPolyfill()
 
-        fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+    render(
+        
+            
+        ,
+    )
 
-        await waitFor(
-            () => {
-                const dialog = screen.getByRole('dialog', { name: 'Diagram' })
-                const img = dialog.querySelector('img')
-                expect(img).toBeTruthy()
-                expect(img?.getAttribute('src')?.startsWith('data:image/svg+xml')).toBe(true)
-                const transform = dialog.querySelector('[style*="transform"]')?.style.transform ?? ''
-                expect(transform).toMatch(/scale\([^0)]/)
-            },
-            { timeout: 5000 },
-        )
-    })
+    await waitFor(
+        () => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg')).toBeTruthy()
+        },
+        { timeout: 10000 },
+    )
+
+    fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+    await waitFor(
+        () => {
+            const dialog = screen.getByRole('dialog', { name: 'Diagram' })
+            const lightboxSvg = dialog.querySelector('.rounded-lg svg')
+            expect(lightboxSvg).toBeTruthy()
+            expect(lightboxSvg?.querySelector('path, line, rect')).toBeTruthy()
+        },
+        { timeout: 10000 },
+    )
+}
+
+describe('MermaidDiagram live render', () => {
+    it(
+        'renders real mermaid source to svg in jsdom',
+        async () => {
+            await expectLightboxShowsDiagram('flowchart LR\n  Hub --> WebUI\n  WebUI --> SVG')
+        },
+        20_000,
+    )
+
+    it(
+        'renders sequence diagrams in the lightbox',
+        async () => {
+            await expectLightboxShowsDiagram(sequenceDiagram)
+        },
+        20_000,
+    )
 })
diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
index 480c8cbc6..574bd27a9 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
@@ -133,14 +133,20 @@ describe('MermaidDiagram', () => {
 
         await waitFor(() => {
             const dialog = screen.getByRole('dialog', { name: 'Diagram' })
-            expect(dialog.querySelector('img[alt="Diagram"]')).toBeTruthy()
+            expect(dialog.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy()
         })
 
-        expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1)
-        expect(mermaidMocks.renderMock).toHaveBeenCalledWith(
+        expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(2)
+        expect(mermaidMocks.renderMock).toHaveBeenNthCalledWith(
+            1,
             expect.stringContaining('mermaid-'),
             'graph TD\nA --> B',
         )
+        expect(mermaidMocks.renderMock).toHaveBeenNthCalledWith(
+            2,
+            expect.stringContaining('mermaid-modal-'),
+            'graph TD\nA --> B',
+        )
     })
 
     it('does not expose a lightbox trigger when rendering fails', async () => {
diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index baf2bc821..7664caee6 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -1,5 +1,5 @@
 import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
-import { useEffect, useId, useMemo, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react'
+import { useEffect, useId, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react'
 import { ZoomableLightbox } from '@/components/ZoomableLightbox'
 import { cn } from '@/lib/utils'
 import { useTranslation } from '@/lib/use-translation'
@@ -44,6 +44,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#2d3440',
                 clusterBorder: '#6d8fd6',
                 edgeLabelBackground: '#2a2f35',
+                actorBkg: '#323843',
+                actorBorder: '#6d8fd6',
+                actorTextColor: '#edf1f5',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#323843',
+                labelTextColor: '#edf1f5',
+                loopTextColor: '#edf1f5',
+                noteBkgColor: '#2d3440',
+                noteTextColor: '#edf1f5',
             }
             : {
                 primaryColor: '#f8fbff',
@@ -58,6 +67,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#eef4ff',
                 clusterBorder: '#b8cdfd',
                 edgeLabelBackground: '#f5f6f7',
+                actorBkg: '#f8fbff',
+                actorBorder: '#b8cdfd',
+                actorTextColor: '#2d333b',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#f8fbff',
+                labelTextColor: '#2d333b',
+                loopTextColor: '#2d333b',
+                noteBkgColor: '#eef4ff',
+                noteTextColor: '#2d333b',
             },
     })
 
@@ -126,11 +144,6 @@ export function normalizeMermaidSvgForStandaloneDisplay(svg: string): string {
     return result
 }
 
-export function mermaidSvgToDataUrl(svg: string): string {
-    const normalized = normalizeMermaidSvgForStandaloneDisplay(svg)
-    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(normalized)}`
-}
-
 function MermaidFallback(props: ComponentPropsWithoutRef<'pre'> & { code: string }) {
     const { code, className, ...rest } = props
     return (
@@ -161,13 +174,11 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
     const [renderError, setRenderError] = useState(false)
     const [svg, setSvg] = useState(null)
     const [lightboxOpen, setLightboxOpen] = useState(false)
+    const [lightboxSvg, setLightboxSvg] = useState(null)
+    const [lightboxLoading, setLightboxLoading] = useState(false)
     const id = useId().replace(/:/g, '-')
     const openLabel = t('mermaid.openFullscreen')
     const viewerLabel = t('mermaid.viewerTitle')
-    const lightboxSrc = useMemo(
-        () => (svg ? mermaidSvgToDataUrl(svg) : null),
-        [svg],
-    )
 
     const stopEvent = (event: SyntheticEvent) => {
         event.stopPropagation()
@@ -223,6 +234,36 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
         }
     }, [id, props.code, theme])
 
+    useEffect(() => {
+        if (!lightboxOpen) {
+            setLightboxSvg(null)
+            setLightboxLoading(false)
+            return
+        }
+
+        let cancelled = false
+        setLightboxLoading(true)
+
+        const render = async () => {
+            try {
+                const nextSvg = await renderMermaidSvg(props.code, `mermaid-modal-${id}`, theme)
+                if (cancelled) return
+                setLightboxSvg(normalizeMermaidSvgForStandaloneDisplay(nextSvg))
+            } catch {
+                if (cancelled) return
+                setLightboxSvg(null)
+            } finally {
+                if (!cancelled) setLightboxLoading(false)
+            }
+        }
+
+        void render()
+
+        return () => {
+            cancelled = true
+        }
+    }, [id, lightboxOpen, props.code, theme])
+
     if (renderError || !svg) {
         return 
     }
@@ -250,16 +291,20 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
                 onClose={() => setLightboxOpen(false)}
                 title={viewerLabel}
                 ariaLabel={viewerLabel}
-                fitContentKey={lightboxOpen ? lightboxSrc : null}
+                fitContentKey={lightboxOpen ? lightboxSvg : null}
             >
-                {lightboxSrc ? (
-                    {viewerLabel}
-                ) : null}
+                
+ {lightboxLoading ? ( +
{t('mermaid.loading')}
+ ) : lightboxSvg ? ( + + ) : ( +
{t('mermaid.renderError')}
+ )} +
) diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts index ec3679e8d..cfa52c88f 100644 --- a/web/src/components/assistant-ui/mermaid-svg-id.test.ts +++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - mermaidSvgToDataUrl, - normalizeMermaidSvgForStandaloneDisplay, -} from '@/components/assistant-ui/mermaid-diagram' +import { normalizeMermaidSvgForStandaloneDisplay } from '@/components/assistant-ui/mermaid-diagram' describe('normalizeMermaidSvgForStandaloneDisplay', () => { it('replaces width="100%" with explicit viewBox dimensions', () => { @@ -14,13 +11,3 @@ describe('normalizeMermaidSvgForStandaloneDisplay', () => { expect(prepared).toContain('height:80px') }) }) - -describe('mermaidSvgToDataUrl', () => { - it('returns a data URL without duplicating SVG nodes in the document', () => { - const svg = 'A' - const url = mermaidSvgToDataUrl(svg) - - expect(url.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true) - expect(decodeURIComponent(url.split(',')[1] ?? '')).toContain('width="120"') - }) -}) From b204d90ff49a56aecd20ec60773051e9f24ede8e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 02:01:39 +0100 Subject: [PATCH 09/24] fix(web): mermaid lightbox uses inline SVG in shadow DOM Reuse the inline render in an isolated shadow root so sequence CSS stays intact, and fit the viewport from viewBox dimensions instead of the loading placeholder or width="100%" layout. Co-authored-by: Cursor --- web/src/components/ZoomableLightbox.tsx | 42 +++++++++--- .../mermaid-diagram.live.test.tsx | 8 ++- .../assistant-ui/mermaid-diagram.test.tsx | 14 ++-- .../assistant-ui/mermaid-diagram.tsx | 67 ++++++------------- 4 files changed, 66 insertions(+), 65 deletions(-) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index ad32b2d0a..5ad43e446 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -34,20 +34,30 @@ function getPointCenter(a: Point, b: Point): Point { } function measureSvgIntrinsicSize(svg: SVGSVGElement): { width: number; height: number } | null { + const widthAttr = svg.getAttribute('width') ?? '' + const heightAttr = svg.getAttribute('height') ?? '' + const usesRelativeSize = widthAttr.includes('%') + || heightAttr.includes('%') + || !widthAttr.trim() + + const viewBox = svg.viewBox?.baseVal + if (viewBox && viewBox.width > 0 && viewBox.height > 0 && usesRelativeSize) { + return { width: viewBox.width, height: viewBox.height } + } + const box = svg.getBoundingClientRect() if (box.width > 0 && box.height > 0) { return { width: box.width, height: box.height } } - const viewBox = svg.viewBox?.baseVal if (viewBox && viewBox.width > 0 && viewBox.height > 0) { return { width: viewBox.width, height: viewBox.height } } - const widthAttr = Number.parseFloat(svg.getAttribute('width') ?? '') - const heightAttr = Number.parseFloat(svg.getAttribute('height') ?? '') - if (widthAttr > 0 && heightAttr > 0) { - return { width: widthAttr, height: heightAttr } + const parsedWidth = Number.parseFloat(widthAttr) + const parsedHeight = Number.parseFloat(heightAttr) + if (parsedWidth > 0 && parsedHeight > 0) { + return { width: parsedWidth, height: parsedHeight } } return null @@ -87,12 +97,23 @@ export type ZoomableLightboxProps = { children: ReactNode /** When set, re-fit viewport when this value changes (e.g. after async SVG load). */ fitContentKey?: string | number | null + /** Intrinsic content size for fit (e.g. mermaid viewBox) when layout is not measurable yet. */ + fitContentSize?: { width: number; height: number } | null /** Compute initial scale to fill the device screen (default true). */ fitOnOpen?: boolean } export function ZoomableLightbox(props: ZoomableLightboxProps) { - const { open, onClose, title, ariaLabel, children, fitContentKey = null, fitOnOpen = true } = props + const { + open, + onClose, + title, + ariaLabel, + children, + fitContentKey = null, + fitContentSize = null, + fitOnOpen = true, + } = props const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) const scaleRef = useRef(scale) @@ -129,7 +150,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { const content = contentRef.current if (!content) return - const contentSize = measureContentSize(content) + const contentSize = fitContentSize ?? measureContentSize(content) if (!contentSize) return const screen = getScreenFitSize() @@ -141,7 +162,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { baseScaleRef.current = fitScale updateScale(fitScale) updateOffset({ x: 0, y: 0 }) - }, [fitOnOpen, updateOffset, updateScale]) + }, [fitContentSize, fitOnOpen, updateOffset, updateScale]) const resetView = useCallback(() => { updateScale(baseScaleRef.current) @@ -273,6 +294,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { useLayoutEffect(() => { if (!open) return + if (fitOnOpen && !fitContentKey) return let frame = 0 let attempt = 0 @@ -281,7 +303,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { const scheduleFit = () => { frame = requestAnimationFrame(() => { const content = contentRef.current - const hadSize = content ? measureContentSize(content) : null + const hadSize = fitContentSize ?? (content ? measureContentSize(content) : null) applyFitScale() attempt += 1 if (!hadSize && attempt < maxAttempts) { @@ -299,7 +321,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { window.clearTimeout(retry) window.clearTimeout(lateRetry) } - }, [open, fitContentKey, applyFitScale]) + }, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale]) useEffect(() => { if (!open) return diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx index c3cc479a0..e026389b1 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx @@ -68,9 +68,15 @@ async function expectLightboxShowsDiagram(code: string) { await waitFor( () => { const dialog = screen.getByRole('dialog', { name: 'Diagram' }) - const lightboxSvg = dialog.querySelector('.rounded-lg svg') + const host = dialog.querySelector('[data-mermaid-lightbox]') + expect(host).toBeTruthy() + const lightboxSvg = host?.shadowRoot?.querySelector('svg') expect(lightboxSvg).toBeTruthy() expect(lightboxSvg?.querySelector('path, line, rect')).toBeTruthy() + if (code.includes('sequenceDiagram')) { + const actorRects = lightboxSvg?.querySelectorAll('rect.actor, g.actor rect, rect').length ?? 0 + expect(actorRects).toBeGreaterThanOrEqual(3) + } }, { timeout: 10000 }, ) diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx index 574bd27a9..89c0e050c 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx @@ -133,20 +133,16 @@ describe('MermaidDiagram', () => { await waitFor(() => { const dialog = screen.getByRole('dialog', { name: 'Diagram' }) - expect(dialog.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy() + const host = dialog.querySelector('[data-mermaid-lightbox]') + expect(host?.shadowRoot?.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy() }) - expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(2) - expect(mermaidMocks.renderMock).toHaveBeenNthCalledWith( - 1, + expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1) + expect(mermaidMocks.renderMock).toHaveBeenCalledWith( expect.stringContaining('mermaid-'), 'graph TD\nA --> B', ) - expect(mermaidMocks.renderMock).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('mermaid-modal-'), - 'graph TD\nA --> B', - ) + expect(document.querySelector('[data-mermaid-lightbox]')).toBeTruthy() }) it('does not expose a lightbox trigger when rendering fails', async () => { diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index 7664caee6..0f760b100 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -1,5 +1,5 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown' -import { useEffect, useId, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react' +import { useEffect, useId, useRef, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react' import { ZoomableLightbox } from '@/components/ZoomableLightbox' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' @@ -95,7 +95,7 @@ export async function renderMermaidSvg( return result.svg } -function parseViewBoxSize(svg: string): { width: number; height: number } | null { +export function getMermaidSvgLayoutSize(svg: string): { width: number; height: number } | null { const viewBoxMatch = svg.match(/\bviewBox="([\d.\s]+)"/) if (!viewBoxMatch) return null const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number) @@ -106,7 +106,7 @@ function parseViewBoxSize(svg: string): { width: number; height: number } | null /** Mermaid often emits width="100%"; normalize before rasterizing for the lightbox. */ export function normalizeMermaidSvgForStandaloneDisplay(svg: string): string { let result = svg - const viewBoxSize = parseViewBoxSize(result) + const viewBoxSize = getMermaidSvgLayoutSize(result) if (!viewBoxSize) return result const { width, height } = viewBoxSize @@ -168,14 +168,27 @@ function MermaidSvgContent(props: { svg: string; className?: string }) { ) } +/** Shadow root isolates duplicate mermaid ids from the inline diagram in the page. */ +function MermaidLightboxSvg(props: { svg: string }) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + if (!host) return + + const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }) + root.innerHTML = `${props.svg}` + }, [props.svg]) + + return
+} + export function MermaidDiagram(props: SyntaxHighlighterProps) { const { t } = useTranslation() const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme()) const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) const [lightboxOpen, setLightboxOpen] = useState(false) - const [lightboxSvg, setLightboxSvg] = useState(null) - const [lightboxLoading, setLightboxLoading] = useState(false) const id = useId().replace(/:/g, '-') const openLabel = t('mermaid.openFullscreen') const viewerLabel = t('mermaid.viewerTitle') @@ -234,35 +247,7 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) - useEffect(() => { - if (!lightboxOpen) { - setLightboxSvg(null) - setLightboxLoading(false) - return - } - - let cancelled = false - setLightboxLoading(true) - - const render = async () => { - try { - const nextSvg = await renderMermaidSvg(props.code, `mermaid-modal-${id}`, theme) - if (cancelled) return - setLightboxSvg(normalizeMermaidSvgForStandaloneDisplay(nextSvg)) - } catch { - if (cancelled) return - setLightboxSvg(null) - } finally { - if (!cancelled) setLightboxLoading(false) - } - } - - void render() - - return () => { - cancelled = true - } - }, [id, lightboxOpen, props.code, theme]) + const lightboxLayoutSize = svg ? getMermaidSvgLayoutSize(svg) : null if (renderError || !svg) { return @@ -291,19 +276,11 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { onClose={() => setLightboxOpen(false)} title={viewerLabel} ariaLabel={viewerLabel} - fitContentKey={lightboxOpen ? lightboxSvg : null} + fitContentKey={lightboxOpen ? svg : null} + fitContentSize={lightboxLayoutSize} >
- {lightboxLoading ? ( -
{t('mermaid.loading')}
- ) : lightboxSvg ? ( - - ) : ( -
{t('mermaid.renderError')}
- )} +
From 005d211dfb454dad75809779157dbd29eb767a59 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 02:08:25 +0100 Subject: [PATCH 10/24] test(web): Playwright lightbox coverage per mermaid diagram type Add e2e harness and a script that opens the lightbox for each diagram kind (flowchart through kanban). Fit uses inline getBBox() so compact charts like gitGraph fill the viewport. Co-authored-by: Cursor --- package.json | 1 + scripts/dev/mermaid-lightbox-playwright.mjs | 221 ++++++++++++++++++ web/public/mermaid-lightbox-e2e.html | 23 ++ .../assistant-ui/mermaid-diagram.tsx | 34 ++- web/src/dev/mermaid-lightbox-cases.ts | 96 ++++++++ web/src/dev/mermaid-lightbox-e2e.tsx | 39 ++++ 6 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 scripts/dev/mermaid-lightbox-playwright.mjs create mode 100644 web/public/mermaid-lightbox-e2e.html create mode 100644 web/src/dev/mermaid-lightbox-cases.ts create mode 100644 web/src/dev/mermaid-lightbox-e2e.tsx diff --git a/package.json b/package.json index c33c2acc8..324e870fd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:shared": "cd shared && bun run test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:mermaid-lightbox:playwright": "MERMAID_E2E_START_DEV=1 node scripts/dev/mermaid-lightbox-playwright.mjs", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/scripts/dev/mermaid-lightbox-playwright.mjs b/scripts/dev/mermaid-lightbox-playwright.mjs new file mode 100644 index 000000000..91bb3fc21 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-playwright.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * Playwright: one lightbox open + fit assertion per mermaid diagram type. + * + * Prereq: Vite dev server serving web (default http://127.0.0.1:5173). + * cd web && npm run dev + * + * Env: + * MERMAID_E2E_BASE_URL default http://127.0.0.1:5173 + * PLAYWRIGHT_CHROME_PATH default /usr/bin/google-chrome + */ +import { chromium } from 'playwright' +import { spawn } from 'node:child_process' +import { createConnection } from 'node:net' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const WEB_DIR = resolve(REPO_ROOT, 'web') + +const BASE_URL = (process.env.MERMAID_E2E_BASE_URL ?? 'http://127.0.0.1:5173').replace(/\/$/, '') +const START_DEV = process.env.MERMAID_E2E_START_DEV === '1' +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200') + +const CASE_IDS = [ + 'flowchart', + 'sequence', + 'class', + 'state', + 'er', + 'journey', + 'gantt', + 'pie', + 'quadrant', + 'requirement', + 'gitGraph', + 'c4', + 'mindmap', + 'timeline', + 'kanban', +] + +function waitForPort(port, host = '127.0.0.1', timeoutMs = 60_000) { + const started = Date.now() + return new Promise((resolve, reject) => { + const tick = () => { + const socket = createConnection({ port, host }, () => { + socket.end() + resolve(undefined) + }) + socket.on('error', () => { + socket.destroy() + if (Date.now() - started > timeoutMs) { + reject(new Error(`Timed out waiting for ${host}:${port}`)) + return + } + setTimeout(tick, 250) + }) + } + tick() + }) +} + +async function maybeStartDevServer() { + if (!START_DEV) { + try { + await waitForPort(new URL(BASE_URL).port || 5173) + return null + } catch { + console.error(`Dev server not reachable at ${BASE_URL}. Start: cd web && npm run dev`) + console.error('Or: MERMAID_E2E_START_DEV=1 node scripts/dev/mermaid-lightbox-playwright.mjs') + process.exit(2) + } + } + + const npmBin = process.env.NPM_BIN ?? 'npm' + const child = spawn(npmBin, ['run', 'dev'], { + cwd: WEB_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PATH: process.env.PATH, BROWSER: 'none' }, + shell: false, + }) + child.stdout?.on('data', (chunk) => process.stderr.write(chunk)) + child.stderr?.on('data', (chunk) => process.stderr.write(chunk)) + await waitForPort(new URL(BASE_URL).port || 5173) + return child +} + +async function assertCase(page, caseId) { + const url = `${BASE_URL}/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}` + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }) + + await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) + await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + + await page.waitForFunction(() => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + if (!svg) return false + const box = svg.getBoundingClientRect() + return box.width > 0 && box.height > 0 + }, { timeout: 15_000 }) + + await page.waitForFunction( + (minCoverage) => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + if (!box || box.width <= 0) return false + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const coverage = Math.max(box.width / vw, box.height / vh) + return coverage >= minCoverage + }, + MIN_COVERAGE, + { timeout: 8000 }, + ).catch(() => { + // Fall through — assertion below reports metrics. + }) + + const metrics = await page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]') + const host = dialog?.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = { + rect: svg?.querySelectorAll('rect').length ?? 0, + path: svg?.querySelectorAll('path').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + text: svg?.querySelectorAll('text').length ?? 0, + circle: svg?.querySelectorAll('circle').length ?? 0, + } + const shapeTotal = shapes.rect + shapes.path + shapes.line + shapes.text + shapes.circle + return { + hasShadowSvg: Boolean(svg), + usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')), + svgW: box?.width ?? 0, + svgH: box?.height ?? 0, + vw, + vh, + coverageW: (box?.width ?? 0) / vw, + coverageH: (box?.height ?? 0) / vh, + shapes, + shapeTotal, + } + }) + + const errors = [] + if (!metrics.hasShadowSvg) errors.push('missing shadow-root svg') + if (metrics.usesDataUrlImg) errors.push('uses data-url img (regression)') + if (metrics.svgW < MIN_SVG_PX && metrics.svgH < MIN_SVG_PX) { + errors.push(`svg too small (${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px)`) + } + const coverage = Math.max(metrics.coverageW, metrics.coverageH) + if (coverage < MIN_COVERAGE) { + errors.push(`does not fill viewport (max axis ${(coverage * 100).toFixed(0)}%)`) + } + if (metrics.shapeTotal < 1) errors.push('no svg shapes') + + if (caseId === 'sequence' && metrics.shapes.rect < 2 && metrics.shapes.line < 2) { + errors.push('sequence diagram looks empty (need multiple actors/lines)') + } + + return { caseId, ok: errors.length === 0, errors, metrics } +} + +async function main() { + const devChild = await maybeStartDevServer() + + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROME_PATH ?? '/usr/bin/google-chrome', + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }) + + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + }) + const page = await context.newPage() + const pageErrors = [] + page.on('pageerror', (err) => pageErrors.push(String(err))) + + const results = [] + for (const caseId of CASE_IDS) { + process.stdout.write(` ${caseId} ... `) + try { + const result = await assertCase(page, caseId) + results.push(result) + console.log(result.ok ? 'OK' : `FAIL: ${result.errors.join('; ')}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + results.push({ caseId, ok: false, errors: [message], metrics: null }) + console.log(`FAIL: ${message}`) + } + } + + await browser.close() + if (devChild) devChild.kill('SIGTERM') + + const failed = results.filter((r) => !r.ok) + console.log('\n--- summary ---') + for (const r of results) { + const m = r.metrics + const size = m ? `${Math.round(m.svgW)}x${Math.round(m.svgH)} cov ${(m.coverageW * 100).toFixed(0)}%` : 'n/a' + console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.caseId}: ${size}${r.errors?.length ? ` — ${r.errors.join('; ')}` : ''}`) + } + if (pageErrors.length) { + console.log('\nPage errors:', pageErrors.slice(0, 5).join('\n')) + } + + process.exit(failed.length === 0 ? 0 : 1) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/web/public/mermaid-lightbox-e2e.html b/web/public/mermaid-lightbox-e2e.html new file mode 100644 index 000000000..6ee710df0 --- /dev/null +++ b/web/public/mermaid-lightbox-e2e.html @@ -0,0 +1,23 @@ + + + + + + Mermaid lightbox e2e + + + +
+ + + + diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index 0f760b100..982c40794 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -1,5 +1,13 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown' -import { useEffect, useId, useRef, useState, type ComponentPropsWithoutRef, type SyntheticEvent } from 'react' +import { + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, + type Ref, + type SyntheticEvent, +} from 'react' import { ZoomableLightbox } from '@/components/ZoomableLightbox' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' @@ -159,15 +167,29 @@ function MermaidFallback(props: ComponentPropsWithoutRef<'pre'> & { code: string ) } -function MermaidSvgContent(props: { svg: string; className?: string }) { +function MermaidSvgContent(props: { svg: string; className?: string; hostRef?: Ref }) { return (
) } +function measureRenderedSvgContentSize(svgElement: SVGSVGElement | null): { width: number; height: number } | null { + if (!svgElement) return null + try { + const box = svgElement.getBBox() + if (box.width > 0 && box.height > 0) { + return { width: box.width, height: box.height } + } + } catch { + // getBBox unavailable (some test environments) + } + return getMermaidSvgLayoutSize(svgElement.outerHTML) +} + /** Shadow root isolates duplicate mermaid ids from the inline diagram in the page. */ function MermaidLightboxSvg(props: { svg: string }) { const hostRef = useRef(null) @@ -189,6 +211,8 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxFitSize, setLightboxFitSize] = useState<{ width: number; height: number } | null>(null) + const inlineHostRef = useRef(null) const id = useId().replace(/:/g, '-') const openLabel = t('mermaid.openFullscreen') const viewerLabel = t('mermaid.viewerTitle') @@ -200,6 +224,8 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const openLightbox = (event: SyntheticEvent) => { event.preventDefault() event.stopPropagation() + const inlineSvg = inlineHostRef.current?.querySelector('svg') + setLightboxFitSize(measureRenderedSvgContentSize(inlineSvg)) setLightboxOpen(true) } @@ -247,7 +273,7 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) - const lightboxLayoutSize = svg ? getMermaidSvgLayoutSize(svg) : null + const lightboxLayoutSize = lightboxFitSize ?? (svg ? getMermaidSvgLayoutSize(svg) : null) if (renderError || !svg) { return @@ -268,7 +294,7 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { data-rendered="true" data-mermaid-source={encodeURIComponent(props.code)} > - + WebUI + WebUI --> Lightbox`, + + sequence: `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + M-->>U: Lightbox`, + + class: `classDiagram + Animal <|-- Duck + Animal : +int age + Duck : +swim()`, + + state: `stateDiagram-v2 + [*] --> Still + Still --> Moving + Moving --> Still`, + + er: `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains`, + + journey: `journey + title Checkout + section Browse + Open site: 5: User + Add item: 3: User`, + + gantt: `gantt + title Plan + dateFormat YYYY-MM-DD + section Build + Ship feature :2024-06-01, 3d`, + + pie: `pie title Pets + "Dogs" : 386 + "Cats" : 214`, + + quadrant: `quadrantChart + title Reach and engagement + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + Product A: [0.3, 0.6] + Product B: [0.45, 0.23]`, + + requirement: `requirementDiagram + requirement test_req { + id: 1 + text: the tested requirement. + risk: high + verifymethod: test + }`, + + gitGraph: `gitGraph + commit + branch develop + checkout develop + commit + checkout main + merge develop`, + + c4: `C4Context + title System + Person(user, "User") + System(app, "Application") + Rel(user, app, "Uses")`, + + mindmap: `mindmap + root((HAPI)) + Chat + Hub + Web`, + + timeline: `timeline + title History + 2024 : Alpha + 2025 : Beta`, + + kanban: `kanban + title Board + column Todo + task1[Task 1] + column Done + task2[Task 2]`, +} as const + +export type MermaidLightboxCaseId = keyof typeof MERMAID_LIGHTBOX_CASES + +export const MERMAID_LIGHTBOX_CASE_IDS = Object.keys(MERMAID_LIGHTBOX_CASES) as MermaidLightboxCaseId[] diff --git a/web/src/dev/mermaid-lightbox-e2e.tsx b/web/src/dev/mermaid-lightbox-e2e.tsx new file mode 100644 index 000000000..e38b007a1 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-e2e.tsx @@ -0,0 +1,39 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, + type MermaidLightboxCaseId, +} from '@/dev/mermaid-lightbox-cases' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +const params = new URLSearchParams(window.location.search) +const caseId = params.get('case') as MermaidLightboxCaseId | null +const code = caseId && caseId in MERMAID_LIGHTBOX_CASES + ? MERMAID_LIGHTBOX_CASES[caseId] + : MERMAID_LIGHTBOX_CASES.flowchart + +document.title = `Mermaid lightbox e2e: ${caseId ?? 'flowchart'}` + +createRoot(root).render( + +
+
,
+                    Code: (props) => ,
+                }}
+            />
+        
+
, +) + +// Expose case list for Playwright discovery without importing TS in Node. +;(window as Window & { __MERMAID_E2E_CASES__?: string[] }).__MERMAID_E2E_CASES__ = [...MERMAID_LIGHTBOX_CASE_IDS] From 90d48ae063e7f6c5460a21c3ab43ac9a83949ccc Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 03:40:41 +0100 Subject: [PATCH 11/24] test(web): bounded Playwright via webServer, fix gantt fit sizing Playwright owns Vite lifecycle (no agent-spawned dev server). Fit uses viewBox unless viewBox padding is excessive (gitGraph); wide charts use width-based coverage in e2e. Co-authored-by: Cursor --- package.json | 2 +- scripts/dev/mermaid-lightbox-playwright.mjs | 227 ++---------------- web/e2e/mermaid-lightbox.spec.ts | 116 +++++++++ web/package.json | 6 +- web/playwright.config.ts | 33 +++ .../assistant-ui/mermaid-diagram.tsx | 28 ++- web/test-results/.last-run.json | 4 + 7 files changed, 194 insertions(+), 222 deletions(-) create mode 100644 web/e2e/mermaid-lightbox.spec.ts create mode 100644 web/playwright.config.ts create mode 100644 web/test-results/.last-run.json diff --git a/package.json b/package.json index 324e870fd..b73b9779a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:shared": "cd shared && bun run test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "test:mermaid-lightbox:playwright": "MERMAID_E2E_START_DEV=1 node scripts/dev/mermaid-lightbox-playwright.mjs", + "test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/scripts/dev/mermaid-lightbox-playwright.mjs b/scripts/dev/mermaid-lightbox-playwright.mjs index 91bb3fc21..6914ef73e 100644 --- a/scripts/dev/mermaid-lightbox-playwright.mjs +++ b/scripts/dev/mermaid-lightbox-playwright.mjs @@ -1,221 +1,26 @@ #!/usr/bin/env node /** - * Playwright: one lightbox open + fit assertion per mermaid diagram type. + * Bounded wrapper for mermaid lightbox Playwright (web/e2e). + * Vite lifecycle is owned by web/playwright.config.ts webServer — not this process. * - * Prereq: Vite dev server serving web (default http://127.0.0.1:5173). - * cd web && npm run dev - * - * Env: - * MERMAID_E2E_BASE_URL default http://127.0.0.1:5173 - * PLAYWRIGHT_CHROME_PATH default /usr/bin/google-chrome + * Usage (from repo root): + * npm run test:mermaid-lightbox:playwright */ -import { chromium } from 'playwright' -import { spawn } from 'node:child_process' -import { createConnection } from 'node:net' +import { spawnSync } from 'node:child_process' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const WEB_DIR = resolve(REPO_ROOT, 'web') - -const BASE_URL = (process.env.MERMAID_E2E_BASE_URL ?? 'http://127.0.0.1:5173').replace(/\/$/, '') -const START_DEV = process.env.MERMAID_E2E_START_DEV === '1' -const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') -const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200') - -const CASE_IDS = [ - 'flowchart', - 'sequence', - 'class', - 'state', - 'er', - 'journey', - 'gantt', - 'pie', - 'quadrant', - 'requirement', - 'gitGraph', - 'c4', - 'mindmap', - 'timeline', - 'kanban', -] - -function waitForPort(port, host = '127.0.0.1', timeoutMs = 60_000) { - const started = Date.now() - return new Promise((resolve, reject) => { - const tick = () => { - const socket = createConnection({ port, host }, () => { - socket.end() - resolve(undefined) - }) - socket.on('error', () => { - socket.destroy() - if (Date.now() - started > timeoutMs) { - reject(new Error(`Timed out waiting for ${host}:${port}`)) - return - } - setTimeout(tick, 250) - }) - } - tick() - }) -} - -async function maybeStartDevServer() { - if (!START_DEV) { - try { - await waitForPort(new URL(BASE_URL).port || 5173) - return null - } catch { - console.error(`Dev server not reachable at ${BASE_URL}. Start: cd web && npm run dev`) - console.error('Or: MERMAID_E2E_START_DEV=1 node scripts/dev/mermaid-lightbox-playwright.mjs') - process.exit(2) - } - } +const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web') +const npmBin = process.env.NPM_BIN ?? 'npm' - const npmBin = process.env.NPM_BIN ?? 'npm' - const child = spawn(npmBin, ['run', 'dev'], { +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:e2e'], + { cwd: WEB_DIR, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, PATH: process.env.PATH, BROWSER: 'none' }, - shell: false, - }) - child.stdout?.on('data', (chunk) => process.stderr.write(chunk)) - child.stderr?.on('data', (chunk) => process.stderr.write(chunk)) - await waitForPort(new URL(BASE_URL).port || 5173) - return child -} - -async function assertCase(page, caseId) { - const url = `${BASE_URL}/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}` - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }) - - await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) - await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() - await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) - - await page.waitForFunction(() => { - const host = document.querySelector('[data-mermaid-lightbox]') - const svg = host?.shadowRoot?.querySelector('svg') - if (!svg) return false - const box = svg.getBoundingClientRect() - return box.width > 0 && box.height > 0 - }, { timeout: 15_000 }) - - await page.waitForFunction( - (minCoverage) => { - const host = document.querySelector('[data-mermaid-lightbox]') - const svg = host?.shadowRoot?.querySelector('svg') - const box = svg?.getBoundingClientRect() - if (!box || box.width <= 0) return false - const vw = window.visualViewport?.width ?? window.innerWidth - const vh = window.visualViewport?.height ?? window.innerHeight - const coverage = Math.max(box.width / vw, box.height / vh) - return coverage >= minCoverage - }, - MIN_COVERAGE, - { timeout: 8000 }, - ).catch(() => { - // Fall through — assertion below reports metrics. - }) - - const metrics = await page.evaluate(() => { - const dialog = document.querySelector('[role="dialog"]') - const host = dialog?.querySelector('[data-mermaid-lightbox]') - const svg = host?.shadowRoot?.querySelector('svg') - const box = svg?.getBoundingClientRect() - const vw = window.visualViewport?.width ?? window.innerWidth - const vh = window.visualViewport?.height ?? window.innerHeight - const shapes = { - rect: svg?.querySelectorAll('rect').length ?? 0, - path: svg?.querySelectorAll('path').length ?? 0, - line: svg?.querySelectorAll('line').length ?? 0, - text: svg?.querySelectorAll('text').length ?? 0, - circle: svg?.querySelectorAll('circle').length ?? 0, - } - const shapeTotal = shapes.rect + shapes.path + shapes.line + shapes.text + shapes.circle - return { - hasShadowSvg: Boolean(svg), - usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')), - svgW: box?.width ?? 0, - svgH: box?.height ?? 0, - vw, - vh, - coverageW: (box?.width ?? 0) / vw, - coverageH: (box?.height ?? 0) / vh, - shapes, - shapeTotal, - } - }) - - const errors = [] - if (!metrics.hasShadowSvg) errors.push('missing shadow-root svg') - if (metrics.usesDataUrlImg) errors.push('uses data-url img (regression)') - if (metrics.svgW < MIN_SVG_PX && metrics.svgH < MIN_SVG_PX) { - errors.push(`svg too small (${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px)`) - } - const coverage = Math.max(metrics.coverageW, metrics.coverageH) - if (coverage < MIN_COVERAGE) { - errors.push(`does not fill viewport (max axis ${(coverage * 100).toFixed(0)}%)`) - } - if (metrics.shapeTotal < 1) errors.push('no svg shapes') - - if (caseId === 'sequence' && metrics.shapes.rect < 2 && metrics.shapes.line < 2) { - errors.push('sequence diagram looks empty (need multiple actors/lines)') - } - - return { caseId, ok: errors.length === 0, errors, metrics } -} - -async function main() { - const devChild = await maybeStartDevServer() - - const browser = await chromium.launch({ - headless: true, - executablePath: process.env.PLAYWRIGHT_CHROME_PATH ?? '/usr/bin/google-chrome', - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - }) - - const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - }) - const page = await context.newPage() - const pageErrors = [] - page.on('pageerror', (err) => pageErrors.push(String(err))) - - const results = [] - for (const caseId of CASE_IDS) { - process.stdout.write(` ${caseId} ... `) - try { - const result = await assertCase(page, caseId) - results.push(result) - console.log(result.ok ? 'OK' : `FAIL: ${result.errors.join('; ')}`) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - results.push({ caseId, ok: false, errors: [message], metrics: null }) - console.log(`FAIL: ${message}`) - } - } - - await browser.close() - if (devChild) devChild.kill('SIGTERM') - - const failed = results.filter((r) => !r.ok) - console.log('\n--- summary ---') - for (const r of results) { - const m = r.metrics - const size = m ? `${Math.round(m.svgW)}x${Math.round(m.svgH)} cov ${(m.coverageW * 100).toFixed(0)}%` : 'n/a' - console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.caseId}: ${size}${r.errors?.length ? ` — ${r.errors.join('; ')}` : ''}`) - } - if (pageErrors.length) { - console.log('\nPage errors:', pageErrors.slice(0, 5).join('\n')) - } - - process.exit(failed.length === 0 ? 0 : 1) -} + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH }, + }, +) -main().catch((err) => { - console.error(err) - process.exit(1) -}) +process.exit(result.status === null ? 1 : result.status) diff --git a/web/e2e/mermaid-lightbox.spec.ts b/web/e2e/mermaid-lightbox.spec.ts new file mode 100644 index 000000000..017fbbd3a --- /dev/null +++ b/web/e2e/mermaid-lightbox.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' + +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200') + +type LightboxMetrics = { + hasShadowSvg: boolean + usesDataUrlImg: boolean + svgW: number + svgH: number + coverageW: number + coverageH: number + shapeTotal: number + shapes: { rect: number; path: number; line: number } +} + +async function openLightboxForCase(page: import('@playwright/test').Page, caseId: string) { + await page.goto(`/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) + await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) + await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + + await page.waitForFunction(() => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + return Boolean(box && box.width > 0 && box.height > 0) + }, { timeout: 15_000 }) + + await page + .waitForFunction( + (minCoverage) => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + if (!box || box.width <= 0) return false + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + return Math.max(box.width / vw, box.height / vh) >= minCoverage + }, + MIN_COVERAGE, + { timeout: 8_000 }, + ) + .catch(() => { + // Final expect below surfaces fit failures. + }) +} + +async function readLightboxMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]') + const host = dialog?.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = { + rect: svg?.querySelectorAll('rect').length ?? 0, + path: svg?.querySelectorAll('path').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + const shapeTotal = + shapes.rect + + shapes.path + + shapes.line + + (svg?.querySelectorAll('text').length ?? 0) + + (svg?.querySelectorAll('circle').length ?? 0) + return { + hasShadowSvg: Boolean(svg), + usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')), + svgW: box?.width ?? 0, + svgH: box?.height ?? 0, + coverageW: (box?.width ?? 0) / vw, + coverageH: (box?.height ?? 0) / vh, + shapeTotal, + shapes, + } + }) +} + +function assertMetrics(caseId: string, metrics: LightboxMetrics) { + expect(metrics.hasShadowSvg, `${caseId}: shadow-root svg`).toBe(true) + expect(metrics.usesDataUrlImg, `${caseId}: data-url img regression`).toBe(false) + expect( + metrics.svgW >= MIN_SVG_PX || metrics.svgH >= MIN_SVG_PX, + `${caseId}: svg ${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px`, + ).toBe(true) + const coverage = Math.max(metrics.coverageW, metrics.coverageH) + const wideShort = caseId === 'gantt' || caseId === 'kanban' + if (wideShort) { + expect( + metrics.coverageW >= MIN_COVERAGE || metrics.svgW >= 600, + `${caseId}: wide chart width cov ${(metrics.coverageW * 100).toFixed(0)}%`, + ).toBe(true) + } else { + expect(coverage, `${caseId}: viewport coverage ${(coverage * 100).toFixed(0)}%`).toBeGreaterThanOrEqual( + MIN_COVERAGE, + ) + } + expect(metrics.shapeTotal, `${caseId}: shape count`).toBeGreaterThan(0) + if (caseId === 'sequence') { + expect( + metrics.shapes.rect >= 2 || metrics.shapes.line >= 2, + `${caseId}: sequence actors/lines`, + ).toBe(true) + } +} + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`mermaid lightbox: ${caseId}`, async ({ page }) => { + await openLightboxForCase(page, caseId) + const metrics = await readLightboxMetrics(page) + assertMetrics(caseId, metrics) + }) +} diff --git a/web/package.json b/web/package.json index ba28bffca..d5fc09c05 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "build": "vite build && cp dist/index.html dist/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:mermaid-lightbox:e2e": "playwright test e2e/mermaid-lightbox.spec.ts" }, "dependencies": { "@assistant-ui/react": "^0.11.53", @@ -67,6 +68,7 @@ "typescript": "^5.9.3", "unified": "^11.0.5", "vite": "^7.3.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "@playwright/test": "1.49.1" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..e80617226 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +export default defineConfig({ + testDir: './e2e', + timeout: 45_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:5173', + viewport: { width: 1440, height: 900 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index 982c40794..1ed8d3cb1 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -177,17 +177,29 @@ function MermaidSvgContent(props: { svg: string; className?: string; hostRef?: R ) } -function measureRenderedSvgContentSize(svgElement: SVGSVGElement | null): { width: number; height: number } | null { - if (!svgElement) return null +/** Prefer viewBox layout; use getBBox when Mermaid pads the viewBox (e.g. gitGraph). */ +export function resolveMermaidLightboxFitSize( + svgElement: SVGSVGElement | null, + svgString: string, +): { width: number; height: number } | null { + const fromViewBox = getMermaidSvgLayoutSize(svgString) + if (!svgElement) return fromViewBox + try { - const box = svgElement.getBBox() - if (box.width > 0 && box.height > 0) { - return { width: box.width, height: box.height } + const bbox = svgElement.getBBox() + if (bbox.width <= 0 || bbox.height <= 0) return fromViewBox + if (!fromViewBox) return { width: bbox.width, height: bbox.height } + + const viewBoxArea = fromViewBox.width * fromViewBox.height + const bboxArea = bbox.width * bbox.height + if (viewBoxArea > bboxArea * 2) { + return { width: bbox.width, height: bbox.height } } } catch { // getBBox unavailable (some test environments) } - return getMermaidSvgLayoutSize(svgElement.outerHTML) + + return fromViewBox } /** Shadow root isolates duplicate mermaid ids from the inline diagram in the page. */ @@ -224,8 +236,8 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const openLightbox = (event: SyntheticEvent) => { event.preventDefault() event.stopPropagation() - const inlineSvg = inlineHostRef.current?.querySelector('svg') - setLightboxFitSize(measureRenderedSvgContentSize(inlineSvg)) + const inlineSvg = inlineHostRef.current?.querySelector('svg') ?? null + setLightboxFitSize(resolveMermaidLightboxFitSize(inlineSvg, svg)) setLightboxOpen(true) } diff --git a/web/test-results/.last-run.json b/web/test-results/.last-run.json new file mode 100644 index 000000000..cbcc1fbac --- /dev/null +++ b/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file From 499c3e9b6bc4606e74ffd408344339e835e0a784 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 03:40:48 +0100 Subject: [PATCH 12/24] chore(web): gitignore Playwright test-results Co-authored-by: Cursor --- web/.gitignore | 1 + web/test-results/.last-run.json | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 web/test-results/.last-run.json diff --git a/web/.gitignore b/web/.gitignore index 9ba881afd..e5daeba71 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ dev-dist/ +web/test-results/ diff --git a/web/test-results/.last-run.json b/web/test-results/.last-run.json deleted file mode 100644 index cbcc1fbac..000000000 --- a/web/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} \ No newline at end of file From 3eb6aa117e035c04efd9adfbcef567c857fc801e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 04:02:37 +0100 Subject: [PATCH 13/24] fix(web): address PR 741 bot feedback (typecheck, fit floor, gitignore) Guard lightbox open when svg is null; allow fit scale down to 0.01 while keeping 0.25 minimum for manual zoom; ignore Playwright test-results/ correctly. Co-authored-by: Cursor --- web/.gitignore | 2 +- web/src/components/ZoomableLightbox.tsx | 8 +++++--- web/src/components/assistant-ui/mermaid-diagram.tsx | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/.gitignore b/web/.gitignore index e5daeba71..f9f5a543f 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,4 +1,4 @@ node_modules/ dist/ dev-dist/ -web/test-results/ +test-results/ diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index 5ad43e446..450dbf94c 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Pointer import { CloseIcon } from '@/components/icons' const MIN_SCALE = 0.25 +/** Floor for fit-to-screen only; dense diagrams can need under 25% to fit. */ +const MIN_FIT_SCALE = 0.01 const MAX_SCALE = 8 const SCALE_STEP = 0.25 const BACKDROP_CLICK_MAX_MOVEMENT = 4 @@ -18,8 +20,8 @@ function getScreenFitSize(): { width: number; height: number } { type Point = { x: number; y: number } -function clampScale(value: number): number { - return Math.min(MAX_SCALE, Math.max(MIN_SCALE, value)) +function clampScale(value: number, minScale = MIN_SCALE): number { + return Math.min(MAX_SCALE, Math.max(minScale, value)) } function getPointDistance(a: Point, b: Point): number { @@ -157,7 +159,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { const pad = SCREEN_FIT_PADDING_PX * 2 const fitWidth = (screen.width - pad) / contentSize.width const fitHeight = (screen.height - pad) / contentSize.height - const fitScale = clampScale(Math.min(fitWidth, fitHeight)) + const fitScale = clampScale(Math.min(fitWidth, fitHeight), MIN_FIT_SCALE) baseScaleRef.current = fitScale updateScale(fitScale) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index 1ed8d3cb1..5e047a942 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -236,6 +236,7 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const openLightbox = (event: SyntheticEvent) => { event.preventDefault() event.stopPropagation() + if (!svg) return const inlineSvg = inlineHostRef.current?.querySelector('svg') ?? null setLightboxFitSize(resolveMermaidLightboxFitSize(inlineSvg, svg)) setLightboxOpen(true) From 7a5bc8a7272f9916c100a33a4cb506fc84477092 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 04:12:57 +0100 Subject: [PATCH 14/24] test(web): Playwright asserts click expands diagram vs inline Measure inline vs lightbox bounding box after click; require visible growth (area ratio or max dimension) plus dialog + shadow SVG content. Co-authored-by: Cursor --- web/e2e/mermaid-lightbox.spec.ts | 98 ++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/web/e2e/mermaid-lightbox.spec.ts b/web/e2e/mermaid-lightbox.spec.ts index 017fbbd3a..35c9c9320 100644 --- a/web/e2e/mermaid-lightbox.spec.ts +++ b/web/e2e/mermaid-lightbox.spec.ts @@ -15,36 +15,31 @@ type LightboxMetrics = { shapes: { rect: number; path: number; line: number } } -async function openLightboxForCase(page: import('@playwright/test').Page, caseId: string) { - await page.goto(`/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) - await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) - await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() - await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) +type ExpandMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + areaRatio: number +} - await page.waitForFunction(() => { +async function readExpandMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() const host = document.querySelector('[data-mermaid-lightbox]') - const svg = host?.shadowRoot?.querySelector('svg') - const box = svg?.getBoundingClientRect() - return Boolean(box && box.width > 0 && box.height > 0) - }, { timeout: 15_000 }) - - await page - .waitForFunction( - (minCoverage) => { - const host = document.querySelector('[data-mermaid-lightbox]') - const svg = host?.shadowRoot?.querySelector('svg') - const box = svg?.getBoundingClientRect() - if (!box || box.width <= 0) return false - const vw = window.visualViewport?.width ?? window.innerWidth - const vh = window.visualViewport?.height ?? window.innerHeight - return Math.max(box.width / vw, box.height / vh) >= minCoverage - }, - MIN_COVERAGE, - { timeout: 8_000 }, - ) - .catch(() => { - // Final expect below surfaces fit failures. - }) + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const inlineArea = (inlineBox?.width ?? 0) * (inlineBox?.height ?? 0) + const lightboxArea = (lightboxBox?.width ?? 0) * (lightboxBox?.height ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + areaRatio: inlineArea > 0 ? lightboxArea / inlineArea : 0, + } + }) } async function readLightboxMetrics(page: import('@playwright/test').Page): Promise { @@ -107,10 +102,55 @@ function assertMetrics(caseId: string, metrics: LightboxMetrics) { } } +/** Lightbox should be visibly larger than the inline chat preview after click. */ +const MIN_EXPAND_AREA_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.4') + for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { test(`mermaid lightbox: ${caseId}`, async ({ page }) => { - await openLightboxForCase(page, caseId) + await page.goto(`/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) + await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) + + const beforeExpand = await readExpandMetrics(page) + expect(beforeExpand.lightboxW, `${caseId}: dialog closed before click`).toBe(0) + + await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + await page.waitForFunction(() => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + return Boolean(box && box.width > 0 && box.height > 0) + }, { timeout: 15_000 }) + + await page + .waitForFunction( + (minCoverage) => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + if (!box || box.width <= 0) return false + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + return Math.max(box.width / vw, box.height / vh) >= minCoverage + }, + MIN_COVERAGE, + { timeout: 8_000 }, + ) + .catch(() => undefined) + + const expand = await readExpandMetrics(page) + const inlineMax = Math.max(expand.inlineW, expand.inlineH) + const lightboxMax = Math.max(expand.lightboxW, expand.lightboxH) + const expandedVisibly = + expand.areaRatio >= MIN_EXPAND_AREA_RATIO || lightboxMax > inlineMax * 1.05 + expect(expandedVisibly, `${caseId}: expand inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)}`).toBe(true) + const metrics = await readLightboxMetrics(page) assertMetrics(caseId, metrics) + + test.info().annotations.push({ + type: 'expand', + description: `${caseId}: inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)} (${expand.areaRatio.toFixed(1)}x area)`, + }) }) } From 19ccc043ba4caf201990187afd688d470e745736 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 04:30:16 +0100 Subject: [PATCH 15/24] test(web): Playwright against live HAPI session for mermaid lightbox Add seed script for a dedicated chat session, live hub Playwright suite (HAPI_LIVE=1), and dogfood doc. Live tests fail until driver serves shadow-DOM lightbox (catches gray-box regression on stale bundles). Co-authored-by: Cursor --- docs/tooling/mermaid-lightbox-dogfood.md | 56 +++++++++ package.json | 2 + .../dev/mermaid-lightbox-live-playwright.mjs | 20 ++++ .../dev/mermaid-lightbox-seed-session-db.ts | 74 ++++++++++++ web/e2e/helpers/hapi-live.ts | 82 ++++++++++++++ web/e2e/mermaid-lightbox-session.spec.ts | 107 ++++++++++++++++++ web/playwright.live.config.ts | 26 +++++ .../assistant-ui/mermaid-diagram.tsx | 6 + 8 files changed, 373 insertions(+) create mode 100644 docs/tooling/mermaid-lightbox-dogfood.md create mode 100644 scripts/dev/mermaid-lightbox-live-playwright.mjs create mode 100644 scripts/dev/mermaid-lightbox-seed-session-db.ts create mode 100644 web/e2e/helpers/hapi-live.ts create mode 100644 web/e2e/mermaid-lightbox-session.spec.ts create mode 100644 web/playwright.live.config.ts diff --git a/docs/tooling/mermaid-lightbox-dogfood.md b/docs/tooling/mermaid-lightbox-dogfood.md new file mode 100644 index 000000000..081f07e4f --- /dev/null +++ b/docs/tooling/mermaid-lightbox-dogfood.md @@ -0,0 +1,56 @@ +# Mermaid lightbox dogfood (Playwright) + +Two Playwright targets: + +| Target | What it exercises | Command | +|--------|-------------------|---------| +| **Component (Vite)** | `MermaidDiagram` in isolation on dev server | `npm run test:mermaid-lightbox:playwright` | +| **Live session (hub)** | Real chat thread, click-to-zoom | `npm run test:mermaid-lightbox:live` | + +## Live session (production-shaped) + +**Session URL (after seed):** + +`{HAPI_URL}/sessions/a7370000-0000-4000-8000-000000000737` + +Default `HAPI_URL` for live tests: `http://127.0.0.1:3006` (daily driver). +For tailnet: `HAPI_URL=https://hapi.tail9944ee.ts.net` (seed **that** hub's DB first). + +### 1. Seed fixtures (hub DB) + +On the machine that owns `HAPI_DB_PATH` (usually `~/.hapi/hapi.db`): + +```bash +bun run seed:mermaid-lightbox:session +``` + +Inserts 15 assistant messages (one per diagram type). Re-run to replace messages in that session. + +### 2. Deploy web with your branch + +```bash +hapi-driver-rebuild --build-web +# activate soup when ready (restarts hub) +``` + +Hard-refresh the browser after web changes. + +### 3. Run live Playwright + +```bash +HAPI_LIVE=1 HAPI_URL=http://127.0.0.1:3006 npm run test:mermaid-lightbox:live +``` + +Requires `~/.hapi/settings.json` `cliApiToken` (or `HAPI_ACCESS_TOKEN`). + +**Pass criteria:** dialog opens, SVG in **shadow root** (`[data-mermaid-lightbox]`), expands vs inline, sequence has multiple actors/lines. + +If tests report `legacy` or `empty` lightbox, the served web bundle predates the shadow-DOM fix — rebuild driver. + +## Isolation page (not chat) + +Only for component regression; **not** the same as chat: + +`http://127.0.0.1:5173/mermaid-lightbox-e2e.html?case=sequence` (Vite dev, not on tailnet dist unless you add the HTML to a build). + +Diagram sources: `web/src/dev/mermaid-lightbox-cases.ts` diff --git a/package.json b/package.json index b73b9779a..1f5184492 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs", + "test:mermaid-lightbox:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.live.config.ts", + "seed:mermaid-lightbox:session": "bun run scripts/dev/mermaid-lightbox-seed-session-db.ts", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/scripts/dev/mermaid-lightbox-live-playwright.mjs b/scripts/dev/mermaid-lightbox-live-playwright.mjs new file mode 100644 index 000000000..ab1e46645 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-live-playwright.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** Bounded wrapper: Playwright against a real HAPI chat session (no Vite). */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:live'], + { + cwd: resolve(dirname(fileURLToPath(import.meta.url)), '..'), + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-seed-session-db.ts b/scripts/dev/mermaid-lightbox-seed-session-db.ts new file mode 100644 index 000000000..14891fc84 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-seed-session-db.ts @@ -0,0 +1,74 @@ +/** + * Seed assistant messages with mermaid fixtures into a hub SQLite DB. + * Run on the host that owns HAPI_DB_PATH (usually the hub machine). + * + * HAPI_DB_PATH=~/.hapi/hapi.db SESSION_ID= bun run scripts/dev/mermaid-lightbox-seed-session-db.ts + */ +import { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, +} from '../../web/src/dev/mermaid-lightbox-cases' + +const dbPath = process.env.HAPI_DB_PATH ?? join(homedir(), '.hapi', 'hapi.db') +/** Stable id for mermaid Playwright live session (create if missing). */ +const sessionId = process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +const sessionTag = 'mermaid-lightbox-e2e' + +function agentMermaidEnvelope(caseId: string, code: string) { + const text = `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`` + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: randomUUID(), + parentUuid: null, + isSidechain: false, + message: { + content: [{ type: 'text', text }], + }, + }, + }, + } +} + +const db = new Database(dbPath) +const now = Date.now() +const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId) as { id: string } | undefined +if (!existing) { + db.prepare(` + INSERT INTO sessions ( + id, tag, namespace, created_at, updated_at, active, seq + ) VALUES (?, ?, 'default', ?, ?, 0, 0) + `).run(sessionId, sessionTag, now, now) + console.log(`created session ${sessionId} (${sessionTag})`) +} + +const insert = db.prepare(` + INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, NULL) +`) + +db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId) + +let seqRow = db.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?').get(sessionId) as { + maxSeq: number +} + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + const code = MERMAID_LIGHTBOX_CASES[caseId] + const envelope = agentMermaidEnvelope(caseId, code) + const seq = (seqRow.maxSeq ?? 0) + 1 + seqRow = { maxSeq: seq } + const messageId = randomUUID() + insert.run(messageId, sessionId, JSON.stringify(envelope), now, seq, now) + console.log(`seeded ${caseId} @ seq ${seq}`) +} + +db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, sessionId) +console.log(`Done. Open: /sessions/${sessionId}`) diff --git a/web/e2e/helpers/hapi-live.ts b/web/e2e/helpers/hapi-live.ts new file mode 100644 index 000000000..60f5aef2f --- /dev/null +++ b/web/e2e/helpers/hapi-live.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Page } from '@playwright/test' + +export function getHapiBaseUrl(): string { + return (process.env.HAPI_URL ?? 'http://127.0.0.1:3006').replace(/\/$/, '') +} + +export function getMermaidTestSessionId(): string { + return process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +} + +export function readCliAccessToken(): string { + if (process.env.HAPI_ACCESS_TOKEN?.trim()) { + return process.env.HAPI_ACCESS_TOKEN.trim() + } + const settingsPath = process.env.HAPI_SETTINGS_PATH ?? join(homedir(), '.hapi', 'settings.json') + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { cliApiToken?: string } + if (!settings.cliApiToken) { + throw new Error(`Missing cliApiToken in ${settingsPath}`) + } + return settings.cliApiToken +} + +export async function installHapiAuth(page: Page, baseUrl: string, accessToken: string) { + await page.addInitScript(({ token, url }) => { + localStorage.setItem(`hapi_access_token::${url}`, token) + }, { token: accessToken, url: baseUrl }) +} + +export async function scrollChatToBottom(page: Page) { + for (let i = 0; i < 24; i += 1) { + const found = await page.locator('[data-mermaid-diagram][data-rendered="true"]').count() + if (found > 0) break + await page.evaluate(() => { + const scrollers = [...document.querySelectorAll('*')].filter( + (el) => el.scrollHeight > el.clientHeight + 80, + ) + scrollers.sort((a, b) => b.scrollHeight - a.scrollHeight) + const target = scrollers[0] + if (target) target.scrollTop = target.scrollHeight + window.scrollTo(0, document.body.scrollHeight) + }) + await page.waitForTimeout(400) + } +} + +export type LiveLightboxMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + hasShadowSvg: boolean + shapeTotal: number + coverage: number +} + +export async function readLiveLightboxMetrics(page: Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = + (lightboxSvg?.querySelectorAll('rect').length ?? 0) + + (lightboxSvg?.querySelectorAll('path').length ?? 0) + + (lightboxSvg?.querySelectorAll('line').length ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + hasShadowSvg: Boolean(lightboxSvg), + shapeTotal: shapes, + coverage: Math.max((lightboxBox?.width ?? 0) / vw, (lightboxBox?.height ?? 0) / vh), + } + }) +} diff --git a/web/e2e/mermaid-lightbox-session.spec.ts b/web/e2e/mermaid-lightbox-session.spec.ts new file mode 100644 index 000000000..f5af7c9e5 --- /dev/null +++ b/web/e2e/mermaid-lightbox-session.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' +import { + getHapiBaseUrl, + getMermaidTestSessionId, + installHapiAuth, + readCliAccessToken, + readLiveLightboxMetrics, + scrollChatToBottom, +} from './helpers/hapi-live' + +const liveEnabled = process.env.HAPI_LIVE === '1' +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_EXPAND_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.25') + +test.describe.configure({ mode: 'serial' }) + +test.describe('mermaid lightbox (live HAPI session)', () => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + test.beforeAll(() => { + readCliAccessToken() + }) + + for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`live session lightbox: ${caseId}`, async ({ page }) => { + const baseUrl = getHapiBaseUrl() + const sessionId = getMermaidTestSessionId() + const token = readCliAccessToken() + + await installHapiAuth(page, baseUrl, token) + await page.goto(`${baseUrl}/sessions/${sessionId}`, { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }) + await page.waitForTimeout(2000) + await scrollChatToBottom(page) + + const diagramIndex = MERMAID_LIGHTBOX_CASE_IDS.indexOf(caseId) + const rendered = page.locator('[data-mermaid-diagram][data-rendered="true"]') + await expect( + rendered, + `Expected ${MERMAID_LIGHTBOX_CASE_IDS.length} seeded diagrams. Run: bun run seed:mermaid-lightbox:session`, + ).toHaveCount(MERMAID_LIGHTBOX_CASE_IDS.length, { timeout: 20_000 }) + + const diagram = rendered.nth(diagramIndex) + + await diagram.scrollIntoViewIfNeeded() + const before = await diagram.evaluate((el) => { + const svg = el.querySelector('svg') + const box = svg?.getBoundingClientRect() + return { inlineW: box?.width ?? 0, inlineH: box?.height ?? 0 } + }) + + await diagram.click({ timeout: 15_000 }) + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + const lightboxKind = await page.waitForFunction(() => { + const dialog = document.querySelector('[role="dialog"]') + if (!dialog) return 'no-dialog' + const shadowSvg = dialog.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + if (shadowSvg) { + const box = shadowSvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'shadow' + } + const legacySvg = dialog.querySelector('.rounded-lg svg') + if (legacySvg) { + const box = legacySvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'legacy' + } + return 'empty' + }, { timeout: 15_000 }).then((h) => h.jsonValue() as Promise) + + expect( + lightboxKind, + `${caseId}: expected shadow-DOM lightbox (rebuild driver web after feat/mermaid-lightbox-737)`, + ).toBe('shadow') + + const after = await readLiveLightboxMetrics(page) + const areaRatio = + before.inlineW > 0 && before.inlineH > 0 + ? (after.lightboxW * after.lightboxH) / (before.inlineW * before.inlineH) + : 0 + + expect(after.hasShadowSvg, `${caseId}: shadow SVG in live chat`).toBe(true) + expect(after.shapeTotal, `${caseId}: diagram shapes`).toBeGreaterThan(0) + expect(after.coverage, `${caseId}: viewport coverage`).toBeGreaterThanOrEqual(MIN_COVERAGE) + expect( + areaRatio >= MIN_EXPAND_RATIO || after.lightboxW > before.inlineW * 1.05, + `${caseId}: expand ${areaRatio.toFixed(2)}x inline ${Math.round(before.inlineW)}x${Math.round(before.inlineH)} → lightbox ${Math.round(after.lightboxW)}x${Math.round(after.lightboxH)}`, + ).toBe(true) + + if (caseId === 'sequence') { + const seqShapes = await page.evaluate(() => { + const svg = document.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + return { + rect: svg?.querySelectorAll('rect').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + }) + expect(seqShapes.rect >= 2 || seqShapes.line >= 2, `${caseId}: sequence content`).toBe(true) + } + + await page.keyboard.press('Escape') + await page.waitForSelector('[role="dialog"]', { state: 'detached', timeout: 5000 }).catch(() => undefined) + }) + } +}) diff --git a/web/playwright.live.config.ts b/web/playwright.live.config.ts new file mode 100644 index 000000000..73b93f45a --- /dev/null +++ b/web/playwright.live.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +/** Live hub session tests — no Vite; hits HAPI_URL with HAPI_LIVE=1. */ +export default defineConfig({ + testDir: './e2e', + testMatch: 'mermaid-lightbox-session.spec.ts', + timeout: 120_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1100 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, +}) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index 5e047a942..d723073c5 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -217,8 +217,13 @@ function MermaidLightboxSvg(props: { svg: string }) { return
} +function readMermaidE2eCaseId(code: string): string | undefined { + return code.match(//i)?.[1] +} + export function MermaidDiagram(props: SyntaxHighlighterProps) { const { t } = useTranslation() + const e2eCaseId = readMermaidE2eCaseId(props.code) const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme()) const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) @@ -305,6 +310,7 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { className="aui-mermaid-diagram w-full cursor-zoom-in overflow-x-auto rounded-b-xl bg-[var(--app-code-bg)] px-4 py-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]" data-mermaid-diagram data-rendered="true" + data-mermaid-e2e-case={e2eCaseId} data-mermaid-source={encodeURIComponent(props.code)} > From fb435ab4f815e98f0929a02de0c3f54c21b504f7 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 16:42:36 +0100 Subject: [PATCH 16/24] fix(web): undo wrapper transform in lightbox fit; carry fit floor in zoom Resolves PR #741 review threads (HAPI Bot Major): 1. measureSvgIntrinsicSize / measureContentSize prefer intrinsic dimensions (viewBox -> width/height attrs -> img.naturalSize) before getBoundingClientRect. When the rect is the only signal, divide by scaleRef.current so the 50/200ms refit retries stop compounding with the wrapper's scale(...) transform. Large diagrams no longer jump tiny or oversize after async render completes. 2. Interactive zoom (wheel/keys/buttons/pinch) now clamps with Math.min(MIN_SCALE, baseScaleRef.current). A diagram fitted below the normal 25% floor stays reachable instead of snapping back to 25% and clipping. Zoom-out button disabled threshold uses the same min. 3. Add Vitest coverage for both helpers (intrinsic precedence, scale-aware rect fallback, divide-by-zero guard) so regressions surface without needing the full Playwright stack. Co-authored-by: Cursor --- web/src/components/ZoomableLightbox.test.ts | 101 ++++++++++++++++++++ web/src/components/ZoomableLightbox.tsx | 80 ++++++++++------ 2 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 web/src/components/ZoomableLightbox.test.ts diff --git a/web/src/components/ZoomableLightbox.test.ts b/web/src/components/ZoomableLightbox.test.ts new file mode 100644 index 000000000..60c22b3ce --- /dev/null +++ b/web/src/components/ZoomableLightbox.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox' + +type Rect = { width: number; height: number } + +function makeSvg(opts: { + viewBox?: { width: number; height: number } + widthAttr?: string | null + heightAttr?: string | null + rect?: Rect | null +}): SVGSVGElement { + const svg = { + viewBox: opts.viewBox + ? { baseVal: { width: opts.viewBox.width, height: opts.viewBox.height } } + : { baseVal: { width: 0, height: 0 } }, + getAttribute(name: string) { + if (name === 'width') return opts.widthAttr ?? null + if (name === 'height') return opts.heightAttr ?? null + return null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return svg as unknown as SVGSVGElement +} + +function makeContent(opts: { + img?: { naturalWidth: number; naturalHeight: number; rect?: Rect } | null + svg?: SVGSVGElement | null + rect?: Rect | null +}): HTMLElement { + const queryResults = new Map() + if (opts.img) { + queryResults.set('img', { + naturalWidth: opts.img.naturalWidth, + naturalHeight: opts.img.naturalHeight, + getBoundingClientRect: () => opts.img?.rect ?? { width: 0, height: 0 }, + }) + } + if (opts.svg) queryResults.set('svg', opts.svg) + const content = { + querySelector(selector: string) { + return queryResults.get(selector) ?? null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return content as unknown as HTMLElement +} + +describe('measureSvgIntrinsicSize', () => { + it('prefers viewBox over the (possibly transformed) bounding rect', () => { + const svg = makeSvg({ + viewBox: { width: 1200, height: 900 }, + rect: { width: 60, height: 45 }, + }) + expect(measureSvgIntrinsicSize(svg, 0.05)).toEqual({ width: 1200, height: 900 }) + }) + + it('falls back to width/height attributes when viewBox is missing', () => { + const svg = makeSvg({ widthAttr: '640', heightAttr: '480' }) + expect(measureSvgIntrinsicSize(svg)).toEqual({ width: 640, height: 480 }) + }) + + it('divides bounding rect by the current scale to undo wrapper transform', () => { + const svg = makeSvg({ rect: { width: 200, height: 100 } }) + expect(measureSvgIntrinsicSize(svg, 0.5)).toEqual({ width: 400, height: 200 }) + }) + + it('returns null when no source is usable', () => { + const svg = makeSvg({}) + expect(measureSvgIntrinsicSize(svg)).toBeNull() + }) +}) + +describe('measureContentSize', () => { + it('prefers img.naturalSize over its bounding rect', () => { + const content = makeContent({ + img: { naturalWidth: 800, naturalHeight: 600, rect: { width: 80, height: 60 } }, + }) + expect(measureContentSize(content, 0.1)).toEqual({ width: 800, height: 600 }) + }) + + it('uses svg intrinsic size when no img is present', () => { + const svg = makeSvg({ viewBox: { width: 500, height: 250 } }) + const content = makeContent({ svg }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 500, height: 250 }) + }) + + it('divides the host rect by current scale as the last fallback', () => { + const content = makeContent({ rect: { width: 100, height: 50 } }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 200, height: 100 }) + }) + + it('treats non-positive scale as identity to avoid divide-by-zero', () => { + const content = makeContent({ rect: { width: 100, height: 100 } }) + expect(measureContentSize(content, 0)).toEqual({ width: 100, height: 100 }) + }) +}) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index 450dbf94c..57fc461de 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -35,57 +35,67 @@ function getPointCenter(a: Point, b: Point): Point { } } -function measureSvgIntrinsicSize(svg: SVGSVGElement): { width: number; height: number } | null { - const widthAttr = svg.getAttribute('width') ?? '' - const heightAttr = svg.getAttribute('height') ?? '' - const usesRelativeSize = widthAttr.includes('%') - || heightAttr.includes('%') - || !widthAttr.trim() - +/** + * Measure SVG intrinsic size, ignoring the wrapper's `scale(...)` transform. + * Order: viewBox -> width/height attrs -> bounding rect divided by current scale. + */ +export function measureSvgIntrinsicSize( + svg: SVGSVGElement, + currentScale = 1, +): { width: number; height: number } | null { const viewBox = svg.viewBox?.baseVal - if (viewBox && viewBox.width > 0 && viewBox.height > 0 && usesRelativeSize) { - return { width: viewBox.width, height: viewBox.height } - } - - const box = svg.getBoundingClientRect() - if (box.width > 0 && box.height > 0) { - return { width: box.width, height: box.height } - } - if (viewBox && viewBox.width > 0 && viewBox.height > 0) { return { width: viewBox.width, height: viewBox.height } } + const widthAttr = svg.getAttribute('width') ?? '' + const heightAttr = svg.getAttribute('height') ?? '' const parsedWidth = Number.parseFloat(widthAttr) const parsedHeight = Number.parseFloat(heightAttr) if (parsedWidth > 0 && parsedHeight > 0) { return { width: parsedWidth, height: parsedHeight } } + const safeScale = currentScale > 0 ? currentScale : 1 + const box = svg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } + return null } -function measureContentSize(content: HTMLElement): { width: number; height: number } | null { +/** + * Measure rendered content size, ignoring any ancestor `scale(...)` transform. + * Prefer intrinsic dimensions (img.naturalSize, SVG viewBox/attrs) before the + * bounding rect, which otherwise compounds with `scaleRef.current` on retry. + */ +export function measureContentSize( + content: HTMLElement, + currentScale = 1, +): { width: number; height: number } | null { + const safeScale = currentScale > 0 ? currentScale : 1 + const img = content.querySelector('img') if (img) { - const box = img.getBoundingClientRect() - if (box.width > 0 && box.height > 0) { - return { width: box.width, height: box.height } - } if (img.naturalWidth > 0 && img.naturalHeight > 0) { return { width: img.naturalWidth, height: img.naturalHeight } } + const box = img.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } } const svg = content.querySelector('svg') if (svg) { - const intrinsic = measureSvgIntrinsicSize(svg) + const intrinsic = measureSvgIntrinsicSize(svg, safeScale) if (intrinsic) return intrinsic } const rect = content.getBoundingClientRect() if (rect.width > 0 && rect.height > 0) { - return { width: rect.width, height: rect.height } + return { width: rect.width / safeScale, height: rect.height / safeScale } } return null @@ -152,7 +162,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { const content = contentRef.current if (!content) return - const contentSize = fitContentSize ?? measureContentSize(content) + const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current) if (!contentSize) return const screen = getScreenFitSize() @@ -182,9 +192,17 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { updateOffset({ x: 0, y: 0 }) }, [onClose, updateOffset, updateScale]) + /** + * Lower bound for interactive zoom. Carries the fit floor when the diagram + * was opened below MIN_SCALE (e.g. 0.05); otherwise sticks at MIN_SCALE. + */ + const getMinInteractiveScale = useCallback(() => { + return Math.min(MIN_SCALE, baseScaleRef.current) + }, []) + const zoomBy = useCallback((delta: number) => { - updateScale((current) => clampScale(current + delta)) - }, [updateScale]) + updateScale((current) => clampScale(current + delta, getMinInteractiveScale())) + }, [getMinInteractiveScale, updateScale]) const handleWheel = useCallback((event: WheelEvent) => { event.preventDefault() @@ -240,7 +258,10 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { const center = getPointCenter(first, second) const pinch = pinchRef.current const nextScale = pinch.startDistance > 0 - ? clampScale(pinch.startScale * (distance / pinch.startDistance)) + ? clampScale( + pinch.startScale * (distance / pinch.startDistance), + getMinInteractiveScale(), + ) : pinch.startScale updateScale(nextScale) @@ -257,7 +278,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { x: drag.originX + event.clientX - drag.startX, y: drag.originY + event.clientY - drag.startY, }) - }, [updateOffset, updateScale]) + }, [getMinInteractiveScale, updateOffset, updateScale]) const handlePointerUp = useCallback((event: PointerEvent) => { const backdropPress = backdropPressRef.current @@ -353,6 +374,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { const zoomLabel = baseScale > 0 ? `${Math.round((scale / baseScale) * 100)}%` : `${Math.round(scale * 100)}%` + const minInteractiveScale = Math.min(MIN_SCALE, baseScale) return (
zoomBy(-SCALE_STEP)} className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40" - disabled={scale <= MIN_SCALE} + disabled={scale <= minInteractiveScale} title="Zoom out" > − From 71c57c12673855dce3e1fdc0bdff2e1a696cc420 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 16:49:56 +0100 Subject: [PATCH 17/24] fix(scripts): mermaid seed refuses to wipe non-fixture sessions HAPI Bot Major (PR #741): SESSION_ID is documented as overridable, and the script unconditionally deletes every message for the target session before seeding fixtures. If pointed at a real session id, that's silent data loss. Refuse to proceed when an existing session id has a tag other than 'mermaid-lightbox-e2e'. New ids and the canonical fixture session still seed normally; real sessions throw before any DELETE runs. Co-authored-by: Cursor --- scripts/dev/mermaid-lightbox-seed-session-db.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/dev/mermaid-lightbox-seed-session-db.ts b/scripts/dev/mermaid-lightbox-seed-session-db.ts index 14891fc84..8b2fa9181 100644 --- a/scripts/dev/mermaid-lightbox-seed-session-db.ts +++ b/scripts/dev/mermaid-lightbox-seed-session-db.ts @@ -39,7 +39,18 @@ function agentMermaidEnvelope(caseId: string, code: string) { const db = new Database(dbPath) const now = Date.now() -const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId) as { id: string } | undefined +const existing = db.prepare('SELECT id, tag FROM sessions WHERE id = ?').get(sessionId) as + | { id: string; tag: string | null } + | undefined + +if (existing && existing.tag !== sessionTag) { + throw new Error( + `Refusing to seed mermaid fixtures into session ${sessionId}: tag is ` + + `${JSON.stringify(existing.tag)}, expected ${JSON.stringify(sessionTag)}. ` + + `Unset SESSION_ID or use a session created by this script.`, + ) +} + if (!existing) { db.prepare(` INSERT INTO sessions ( From 8b5644df2c576c949f459544b73bb13b0cb65340 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 18:38:04 +0100 Subject: [PATCH 18/24] fix(web): normalize mermaid svg for lightbox shadow root Mermaid emits width="100%" on every diagram. Inside a shadow root whose host has no explicit size, that collapses to zero in Chromium for most diagram types - only ones that ship pixel attrs (e.g. journey) happen to render. Operator confirmed on the live driver: every diagram except journey opened to a grey rounded square. MermaidLightboxSvg now runs normalizeMermaidSvgForStandaloneDisplay before injecting (strips width/height="100%", bakes viewBox dims as pixels) and sets :host{display:inline-block} so the host sizes to the SVG. Inline svg in chat is unchanged - only the lightbox copy is normalized. Co-authored-by: Cursor --- .../assistant-ui/mermaid-diagram.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index d723073c5..c41d9a5a7 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -202,7 +202,15 @@ export function resolveMermaidLightboxFitSize( return fromViewBox } -/** Shadow root isolates duplicate mermaid ids from the inline diagram in the page. */ +/** + * Shadow root isolates duplicate mermaid ids from the inline diagram in the page. + * + * Mermaid emits `width="100%"` on every diagram. Inside a shadow root whose host + * has no explicit size, that collapses to zero in Chromium for most diagram types + * (only ones that ship pixel attrs - e.g. `journey` - happen to render). Strip + * the relative size and bake explicit pixels from the viewBox before injecting, + * and give the host an inline-block layout so it sizes to the SVG. + */ function MermaidLightboxSvg(props: { svg: string }) { const hostRef = useRef(null) @@ -211,7 +219,14 @@ function MermaidLightboxSvg(props: { svg: string }) { if (!host) return const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }) - root.innerHTML = `${props.svg}` + const normalized = normalizeMermaidSvgForStandaloneDisplay(props.svg) + root.innerHTML = [ + '', + normalized, + ].join('') }, [props.svg]) return
From 890bfa8758c4dadf26a07ebda4160f21f3e9937c Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 19:21:14 +0100 Subject: [PATCH 19/24] fix(web): keep mermaid lightbox content below the toolbar Operator screenshot showed the diagram top (e.g. pie 'Pets' title) clipped behind the toolbar bar. Two causes: 1. getScreenFitSize used the full viewport height, so the fit scale sized the diagram to fill an area the toolbar overlapped. 2. The viewport (drag/zoom area) was inset-0; content centered on the full viewport center, not the visible region's center, pushing the top behind the toolbar. Measure the toolbar with a ResizeObserver, subtract its height from the fit calculation (clamped at zero), and start the viewport region below the toolbar (top: toolbarHeight). Fit scale recomputes whenever toolbar height changes. Adds Vitest coverage for getScreenFitSize reserved-top math. Co-authored-by: Cursor --- web/src/components/ZoomableLightbox.test.ts | 56 ++++++++++++++++++++- web/src/components/ZoomableLightbox.tsx | 40 ++++++++++++--- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/web/src/components/ZoomableLightbox.test.ts b/web/src/components/ZoomableLightbox.test.ts index 60c22b3ce..c8cd542bf 100644 --- a/web/src/components/ZoomableLightbox.test.ts +++ b/web/src/components/ZoomableLightbox.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox' +import { getScreenFitSize, measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox' type Rect = { width: number; height: number } @@ -75,6 +75,60 @@ describe('measureSvgIntrinsicSize', () => { }) }) +describe('getScreenFitSize', () => { + const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport') + const originalInnerWidth = window.innerWidth + const originalInnerHeight = window.innerHeight + + function setViewport(width: number, height: number) { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { width, height }, + }) + } + + function clearViewport() { + Object.defineProperty(window, 'visualViewport', { configurable: true, value: null }) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + } + + function restore() { + if (originalVisualViewport) { + Object.defineProperty(window, 'visualViewport', originalVisualViewport) + } + } + + it('subtracts the reserved top region (toolbar) from height', () => { + setViewport(1000, 800) + try { + expect(getScreenFitSize(40)).toEqual({ width: 1000, height: 760 }) + } finally { + restore() + } + }) + + it('clamps reserved height at zero (no negative regions)', () => { + setViewport(800, 100) + try { + expect(getScreenFitSize(200)).toEqual({ width: 800, height: 0 }) + } finally { + restore() + } + }) + + it('falls back to window inner size when visualViewport is unavailable', () => { + clearViewport() + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 }) + try { + expect(getScreenFitSize(60)).toEqual({ width: 1280, height: 840 }) + } finally { + restore() + } + }) +}) + describe('measureContentSize', () => { it('prefers img.naturalSize over its bounding rect', () => { const content = makeContent({ diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index 57fc461de..76bafbdbf 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -10,12 +10,12 @@ const BACKDROP_CLICK_MAX_MOVEMENT = 4 /** Edge margin when fitting to the device screen (not the inner panel only). */ const SCREEN_FIT_PADDING_PX = 12 -function getScreenFitSize(): { width: number; height: number } { +export function getScreenFitSize(reservedTopPx = 0): { width: number; height: number } { const viewport = window.visualViewport - if (viewport) { - return { width: viewport.width, height: viewport.height } - } - return { width: window.innerWidth, height: window.innerHeight } + const width = viewport ? viewport.width : window.innerWidth + const fullHeight = viewport ? viewport.height : window.innerHeight + const reserved = Math.max(0, reservedTopPx) + return { width, height: Math.max(0, fullHeight - reserved) } } type Point = { x: number; y: number } @@ -128,11 +128,13 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { } = props const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) + const [toolbarHeight, setToolbarHeight] = useState(0) const scaleRef = useRef(scale) const offsetRef = useRef(offset) const baseScaleRef = useRef(1) const viewportRef = useRef(null) const contentRef = useRef(null) + const toolbarRef = useRef(null) const activePointersRef = useRef(new Map()) const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null) @@ -165,7 +167,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current) if (!contentSize) return - const screen = getScreenFitSize() + const screen = getScreenFitSize(toolbarHeight) const pad = SCREEN_FIT_PADDING_PX * 2 const fitWidth = (screen.width - pad) / contentSize.width const fitHeight = (screen.height - pad) / contentSize.height @@ -174,7 +176,7 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { baseScaleRef.current = fitScale updateScale(fitScale) updateOffset({ x: 0, y: 0 }) - }, [fitContentSize, fitOnOpen, updateOffset, updateScale]) + }, [fitContentSize, fitOnOpen, toolbarHeight, updateOffset, updateScale]) const resetView = useCallback(() => { updateScale(baseScaleRef.current) @@ -346,6 +348,26 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { } }, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale]) + useLayoutEffect(() => { + if (!open) return undefined + const toolbar = toolbarRef.current + if (!toolbar) return undefined + + const apply = () => { + const next = toolbar.getBoundingClientRect().height + setToolbarHeight((current) => (Math.abs(current - next) < 0.5 ? current : next)) + } + + apply() + const resize = new ResizeObserver(apply) + resize.observe(toolbar) + window.addEventListener('resize', apply) + return () => { + resize.disconnect() + window.removeEventListener('resize', apply) + } + }, [open]) + useEffect(() => { if (!open) return @@ -385,7 +407,8 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { >
event.stopPropagation()} > From bbdab3e740aef1d0de7e0b12cafb8ee149c92a71 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 31 May 2026 19:46:58 +0100 Subject: [PATCH 20/24] fix(web): guard ResizeObserver before constructing it HAPI Bot Major (PR #741): Vitest jsdom does not polyfill ResizeObserver, so the toolbar measure effect throws ReferenceError when the existing mermaid-diagram React tests open the lightbox. Same code path is also brittle in any browser/webview without the API. Fall back to plain window 'resize' listener when ResizeObserver is absent. Toolbar height won't auto-update on element resize without it, but the lightbox still renders and the resize listener catches the common viewport-rotation case. Co-authored-by: Cursor --- web/src/components/ZoomableLightbox.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx index 76bafbdbf..503a8e17e 100644 --- a/web/src/components/ZoomableLightbox.tsx +++ b/web/src/components/ZoomableLightbox.tsx @@ -359,9 +359,14 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) { } apply() + window.addEventListener('resize', apply) + + if (typeof ResizeObserver === 'undefined') { + return () => window.removeEventListener('resize', apply) + } + const resize = new ResizeObserver(apply) resize.observe(toolbar) - window.addEventListener('resize', apply) return () => { resize.disconnect() window.removeEventListener('resize', apply) From 454377735fb9e1c6679bf6a2084c5e6d04ede3b2 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:29:38 +0100 Subject: [PATCH 21/24] fix(scripts): live mermaid playwright wrapper runs from repo root HAPI Bot Minor (PR #741): the wrapper sets cwd to scripts/, but the test:mermaid-lightbox:live npm script lives in the repo-root package.json, so spawning npm there exited before Playwright started. Switch cwd to the repo root and drop the unused WEB_DIR constant. Co-authored-by: Cursor --- scripts/dev/mermaid-lightbox-live-playwright.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/dev/mermaid-lightbox-live-playwright.mjs b/scripts/dev/mermaid-lightbox-live-playwright.mjs index ab1e46645..e137029cd 100644 --- a/scripts/dev/mermaid-lightbox-live-playwright.mjs +++ b/scripts/dev/mermaid-lightbox-live-playwright.mjs @@ -4,14 +4,14 @@ import { spawnSync } from 'node:child_process' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web') +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') const npmBin = process.env.NPM_BIN ?? 'npm' const result = spawnSync( npmBin, ['run', 'test:mermaid-lightbox:live'], { - cwd: resolve(dirname(fileURLToPath(import.meta.url)), '..'), + cwd: REPO_ROOT, stdio: 'inherit', env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' }, }, From 03dbe06440404924c8e6a25a1e64d5e862d3a037 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:38:39 +0100 Subject: [PATCH 22/24] fix(web): accept signed viewBox values in mermaid lightbox normalize HAPI Bot Minor (PR #741): the viewBox regex only matched digits, dots, and spaces, so a valid viewBox with negative origin (e.g. '-8 -8 640 480') returned null. normalizeMermaidSvgForStandaloneDisplay then became a no-op and left width='100%', re-introducing the zero-sized lightbox render this PR is meant to fix for the affected diagrams. Switch to the bot's suggested regex (signed numbers, single or double quotes, comma or space separators) and reject NaN parts. Adds Vitest coverage for signed origins, single quotes, comma separators, the malformed/no-viewBox null paths, and an end-to-end normalize test that fails against the old regex. Co-authored-by: Cursor --- .../assistant-ui/mermaid-diagram.tsx | 6 +-- .../assistant-ui/mermaid-svg-id.test.ts | 38 ++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx index c41d9a5a7..c6a24fba5 100644 --- a/web/src/components/assistant-ui/mermaid-diagram.tsx +++ b/web/src/components/assistant-ui/mermaid-diagram.tsx @@ -104,10 +104,10 @@ export async function renderMermaidSvg( } export function getMermaidSvgLayoutSize(svg: string): { width: number; height: number } | null { - const viewBoxMatch = svg.match(/\bviewBox="([\d.\s]+)"/) + const viewBoxMatch = svg.match(/\bviewBox=(['"])([^'"]+)\1/i) if (!viewBoxMatch) return null - const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number) - if (parts.length < 4 || parts[2] <= 0 || parts[3] <= 0) return null + const parts = viewBoxMatch[2].trim().split(/[\s,]+/).map(Number) + if (parts.length < 4 || parts.some(Number.isNaN) || parts[2] <= 0 || parts[3] <= 0) return null return { width: parts[2], height: parts[3] } } diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts index cfa52c88f..5a7d3787d 100644 --- a/web/src/components/assistant-ui/mermaid-svg-id.test.ts +++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts @@ -1,5 +1,32 @@ import { describe, expect, it } from 'vitest' -import { normalizeMermaidSvgForStandaloneDisplay } from '@/components/assistant-ui/mermaid-diagram' +import { + getMermaidSvgLayoutSize, + normalizeMermaidSvgForStandaloneDisplay, +} from '@/components/assistant-ui/mermaid-diagram' + +describe('getMermaidSvgLayoutSize', () => { + it('reads simple unsigned viewBox', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 200, height: 80 }) + }) + + it('accepts signed origin values (negative offsets)', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 640, height: 480 }) + }) + + it('accepts single-quoted attribute and comma separators', () => { + expect(getMermaidSvgLayoutSize("")).toEqual({ width: 300, height: 150 }) + }) + + it('rejects malformed viewBox (NaN, missing dim, zero size)', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) + + it('returns null when no viewBox attribute exists', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) +}) describe('normalizeMermaidSvgForStandaloneDisplay', () => { it('replaces width="100%" with explicit viewBox dimensions', () => { @@ -10,4 +37,13 @@ describe('normalizeMermaidSvgForStandaloneDisplay', () => { expect(prepared).toContain('width:200px') expect(prepared).toContain('height:80px') }) + + it('normalizes width/height for SVGs with negative viewBox origins', () => { + const svg = '' + const prepared = normalizeMermaidSvgForStandaloneDisplay(svg) + + expect(prepared).not.toContain('width="100%"') + expect(prepared).toContain('width="800"') + expect(prepared).toContain('height="600"') + }) }) From c2fb713404b4fdfab39be7eab19eb309eb477f9d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:09:27 +0100 Subject: [PATCH 23/24] fix(web): align @playwright/test on 1.60.0 across workspaces HAPI Bot Major (PR #741): web/package.json pinned @playwright/test at 1.49.1 while the root workspace and bun.lock were on 1.60.0. The mismatch surfaced after rebasing onto upstream/main, where the root had already moved to 1.60.0 while my web devDependency lagged from an older commit. A frozen install would reject the lockfile and the new web e2e script could resolve a different Playwright than root scripts. Bump the web devDependency to 1.60.0 and regenerate bun.lock so all workspaces share one Playwright version. Co-authored-by: Cursor --- bun.lock | 1 + web/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 8400dfe30..228e96191 100644 --- a/bun.lock +++ b/bun.lock @@ -132,6 +132,7 @@ "workbox-window": "^7.4.0", }, "devDependencies": { + "@playwright/test": "1.60.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", diff --git a/web/package.json b/web/package.json index d5fc09c05..f6c8904e8 100644 --- a/web/package.json +++ b/web/package.json @@ -69,6 +69,6 @@ "unified": "^11.0.5", "vite": "^7.3.0", "vitest": "^4.0.16", - "@playwright/test": "1.49.1" + "@playwright/test": "1.60.0" } } From ed2d22a130d0be90cc311dab4986228efc1d13fe Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:59:26 +0100 Subject: [PATCH 24/24] fix(web): move mermaid playwright fixtures out of public HAPI Bot Minor (PR #741): the e2e and smoke fixtures lived under web/public, so Vite copied them verbatim into web/dist and the hub asset generator embedded them in production bundles. Both pages import Vite dev-only paths (/@react-refresh and /src/dev/...), so the production /mermaid-lightbox-{e2e,smoke}.html routes would 404 on those imports. Move both fixtures to web/e2e-fixtures/ to match the existing scratchlist-fixture pattern (relative ../src/dev import, served by Vite at /e2e-fixtures/...) and update the Playwright spec to hit the new path. Build now ships 112 PWA precache entries instead of 114 (both fixtures excluded from dist). Co-authored-by: Cursor --- web/{public => e2e-fixtures}/mermaid-lightbox-e2e.html | 9 +-------- web/{public => e2e-fixtures}/mermaid-lightbox-smoke.html | 9 +-------- web/e2e/mermaid-lightbox.spec.ts | 2 +- 3 files changed, 3 insertions(+), 17 deletions(-) rename web/{public => e2e-fixtures}/mermaid-lightbox-e2e.html (54%) rename web/{public => e2e-fixtures}/mermaid-lightbox-smoke.html (54%) diff --git a/web/public/mermaid-lightbox-e2e.html b/web/e2e-fixtures/mermaid-lightbox-e2e.html similarity index 54% rename from web/public/mermaid-lightbox-e2e.html rename to web/e2e-fixtures/mermaid-lightbox-e2e.html index 6ee710df0..a1cfc7ead 100644 --- a/web/public/mermaid-lightbox-e2e.html +++ b/web/e2e-fixtures/mermaid-lightbox-e2e.html @@ -11,13 +11,6 @@
- - + diff --git a/web/public/mermaid-lightbox-smoke.html b/web/e2e-fixtures/mermaid-lightbox-smoke.html similarity index 54% rename from web/public/mermaid-lightbox-smoke.html rename to web/e2e-fixtures/mermaid-lightbox-smoke.html index 0defbd4ee..6e18f25a5 100644 --- a/web/public/mermaid-lightbox-smoke.html +++ b/web/e2e-fixtures/mermaid-lightbox-smoke.html @@ -11,13 +11,6 @@
- - + diff --git a/web/e2e/mermaid-lightbox.spec.ts b/web/e2e/mermaid-lightbox.spec.ts index 35c9c9320..2f1c7f345 100644 --- a/web/e2e/mermaid-lightbox.spec.ts +++ b/web/e2e/mermaid-lightbox.spec.ts @@ -107,7 +107,7 @@ const MIN_EXPAND_AREA_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { test(`mermaid lightbox: ${caseId}`, async ({ page }) => { - await page.goto(`/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) + await page.goto(`/e2e-fixtures/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) const beforeExpand = await readExpandMetrics(page)