Skip to content

feat(web): mermaid diagram lightbox on click#741

Open
heavygee wants to merge 20 commits into
tiann:mainfrom
heavygee:feat/mermaid-lightbox-737
Open

feat(web): mermaid diagram lightbox on click#741
heavygee wants to merge 20 commits into
tiann:mainfrom
heavygee:feat/mermaid-lightbox-737

Conversation

@heavygee
Copy link
Copy Markdown
Contributor

Summary

  • Click or tap a rendered mermaid diagram in assistant chat to open a full-screen zoomable viewer (pan, pinch, wheel zoom, Escape to close).
  • Modal re-renders from the diagram source with the current app theme instead of only scaling the inline SVG.
  • Failed mermaid fallbacks stay non-interactive.

Problem

Mermaid renders inline in chat (#567) but dense diagrams are hard to read on mobile with horizontal scroll only. Images already have a lightbox (#715, polish in #723).

Approach

  • New shared ZoomableLightbox component (same interaction model as ImagePreview, including backdrop dismiss).
  • MermaidDiagram wraps successful renders in a zoom-in trigger and opens the lightbox with a fresh mermaid.render() call.

Testing

  • cd web && bun run test src/components/assistant-ui/mermaid-diagram.test.tsx
  • cd web && bun run test src/components/assistant-ui/mermaid-diagram.live.test.tsx

Closes #737.

Made with Cursor

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No issues found.

Summary
Review mode: initial

Reviewed the Mermaid lightbox and new ZoomableLightbox diff in full. No blocker/major/minor issues met the confidence threshold. Residual risk: mobile gesture behavior and focus behavior were assessed statically only.

Testing

  • Not run (automation): bun is not installed in this runner, so targeted web tests could not be executed.

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Fit from unscaled dimensions — applyFitScale() can run once while the loading placeholder is scaled up, then re-run when fitContentKey changes after the SVG arrives. Because measureContentSize() reads getBoundingClientRect() from an element inside the already-transformed content wrapper, the second measurement includes the previous scale and computes a much smaller fit scale. Large diagrams can open tiny instead of fitted after the async Mermaid render completes. Evidence: web/src/components/ZoomableLightbox.tsx:39.
    Suggested fix:
    function measureContentSize(content: HTMLElement, scale = 1): { width: number; height: number } | null {
        const safeScale = scale > 0 ? scale : 1
        const svg = content.querySelector("svg")
        if (svg) {
            const box = svg.getBoundingClientRect()
            if (box.width > 0 && box.height > 0) {
                return { width: box.width / safeScale, height: box.height / safeScale }
            }
        }
    
        const rect = content.getBoundingClientRect()
        if (rect.width > 0 && rect.height > 0) {
            return { width: rect.width / safeScale, height: rect.height / safeScale }
        }
    
        return null
    }
    
    const contentSize = measureContentSize(content, scaleRef.current)

Questions

  • None.

Summary
Review mode: follow-up after new commits

Found one fit regression in the updated lightbox sizing path. The prior initial review had no findings; this issue is from the follow-up fitting changes.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Fit retries measure the already-scaled SVG — measureSvgIntrinsicSize() reads getBoundingClientRect() before intrinsic sources, and the 50ms/200ms retries call applyFitScale() again after the content wrapper has a CSS scale(...). Because ancestor transforms are included in the rect, the retry recomputes the fit from scaled dimensions and moves the viewer away from its fitted size. Large diagrams can jump oversized; small diagrams can shrink back toward 100%. Evidence: web/src/components/ZoomableLightbox.tsx:37.
    Suggested fix:
    function measureSvgIntrinsicSize(svg: SVGSVGElement, currentScale = 1): { width: number; height: number } | null {
        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 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
    }
    
    const contentSize = measureContentSize(content, scaleRef.current)

Questions

  • None.

Summary
Review mode: follow-up after new commits

The previous sizing issue is still present in the updated implementation: the code added intrinsic fallbacks, but the transformed getBoundingClientRect() path still wins before those fallbacks and the scheduled retries can overwrite the fitted scale.

Testing

  • Not run (automation: bun is not installed in this runner)

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Fit retries measure the already-scaled SVG, so the lightbox can jump away from its fitted size after opening. measureSvgIntrinsicSize() still returns getBoundingClientRect() before intrinsic sources, and the 50ms/200ms retries call applyFitScale() again after the content wrapper has scale(...); ancestor transforms are included in that rect. Evidence: web/src/components/ZoomableLightbox.tsx:37.
    Suggested fix:
    function measureSvgIntrinsicSize(svg: SVGSVGElement, currentScale = 1): { width: number; height: number } | null {
        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 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
    }
    
    const contentSize = measureContentSize(content, scaleRef.current)

Questions

  • None.

Summary
Review mode: follow-up after new commits

The previous sizing issue is still present in the latest head. The new SVG lightbox preparation improves the Mermaid markup, but ZoomableLightbox can still refit from transformed dimensions during its delayed retry pass.

Testing

  • Not run (automation: bun is not installed in this runner)

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Lightbox fit can still compound from the transformed image size. The follow-up moved the modal content to an <img>, but measureContentSize() reads getBoundingClientRect() before naturalWidth/naturalHeight; the scheduled 50ms/200ms refits run after the wrapper has scale(...), so a diagram that initially fits at 50% can be remeasured at half size and bumped back toward 100%, overflowing the screen. Evidence: web/src/components/ZoomableLightbox.tsx:59.
    Suggested fix:
    function measureContentSize(content: HTMLElement): { width: number; height: number } | null {
        const img = content.querySelector('img')
        if (img) {
            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) {
                const currentScale = scaleRef.current > 0 ? scaleRef.current : 1
                return { width: box.width / currentScale, height: box.height / currentScale }
            }
        }
    
        // keep existing SVG/content fallback handling
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits

The previous fit bug is still reproducible through the new image-based lightbox path because delayed retries measure transformed dimensions. This affects the main user-facing behavior of opening large Mermaid diagrams on smaller screens.

Testing

  • Not run: bun is not installed in this runner.

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Lightbox fit can still compound from transformed SVG measurements. measureSvgIntrinsicSize() reads getBoundingClientRect() before the SVG viewBox/attribute dimensions, but the SVG is inside the element that already has scale(...) applied. When the lightbox first fits the loading placeholder and then refits after lightboxSvg arrives, the 50ms/200ms retries can measure the already-scaled SVG and change the fit again, so large diagrams can jump too small or back toward viewport overflow. Evidence: web/src/components/ZoomableLightbox.tsx:37.
    Suggested fix:
    function measureSvgIntrinsicSize(svg: SVGSVGElement): { width: number; height: number } | null {
        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 box = svg.getBoundingClientRect()
        if (box.width > 0 && box.height > 0) {
            return { width: box.width, height: box.height }
        }
    
        return null
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits

The previous image-specific path was removed, but the current direct-SVG lightbox still has a transformed-measurement feedback loop in the shared fit logic. This affects the primary behavior of opening dense Mermaid diagrams and fitting them to small screens.

Testing

  • Not run (automation; review-only, did not execute PR code).

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No high-confidence issues found in the added/modified lines.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous lightbox sizing concern appears addressed for the Mermaid path by passing intrinsic viewBox dimensions into ZoomableLightbox. Residual risk: I could not execute the web test suite in this runner because bun is unavailable.

Testing

  • Not run: bun not found in environment when attempting the PR-listed web tests.

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Fit-to-screen still clips very large diagrams — applyFitScale runs the computed fit ratio through clampScale, whose minimum is 0.25, so any diagram that needs a scale below 25% cannot actually fit on open or after pressing the fit/reset control. Dense Mermaid diagrams can easily exceed 4x the viewport, which is the case this lightbox is meant to solve. Evidence web/src/components/ZoomableLightbox.tsx:160.
    Suggested fix:
    function clampScale(value: number, minScale = MIN_SCALE): number {
        return Math.min(MAX_SCALE, Math.max(minScale, value))
    }
    
    const rawFitScale = Math.min(fitWidth, fitHeight)
    const fitScale = clampScale(rawFitScale, 0.01)
  • [Minor] Playwright helper starts Vite from scripts/webREPO_ROOT resolves one directory up from scripts/dev, which is scripts, so WEB_DIR becomes <repo>/scripts/web. The new root script sets MERMAID_E2E_START_DEV=1, so this path prevents the helper from starting the actual web dev server. Evidence scripts/dev/mermaid-lightbox-playwright.mjs:18.
    Suggested fix:
    const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
    const WEB_DIR = resolve(REPO_ROOT, 'web')

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The follow-up improves Mermaid lightbox sizing coverage, but the fit logic still has a minimum-scale floor that breaks true fit-to-screen for very large diagrams, and the new Playwright script cannot start the web app from the correct directory.

Testing

  • Not run (automation): bun is not installed and dependencies are not present in this runner.

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Comment thread scripts/dev/mermaid-lightbox-playwright.mjs Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Fit-to-screen still clips very large diagrams — applyFitScale floors the computed fit ratio at 0.25, so any diagram needing less than 25% scale cannot fit on open or after reset. Evidence web/src/components/ZoomableLightbox.tsx:160.
    Suggested fix:
    function clampScale(value: number, minScale = MIN_SCALE): number {
        return Math.min(MAX_SCALE, Math.max(minScale, value))
    }
    
    const fitScale = clampScale(Math.min(fitWidth, fitHeight), 0.01)
  • [Minor] Playwright artifacts are not ignored — web/.gitignore is evaluated from the web/ directory, so web/test-results/ ignores web/web/test-results/ while Playwright writes web/test-results/. Evidence web/.gitignore:4.
    Suggested fix:
    test-results/

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous helper path issue was addressed by moving server lifecycle into Playwright config, but the large-diagram fit bug remains. The new E2E artifact ignore pattern also misses the actual generated directory.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Comment thread web/.gitignore Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Interactive zoom still snaps huge diagrams back to 25% — MIN_FIT_SCALE lets the lightbox open dense diagrams below the normal 0.25 floor, but wheel/pinch zoom still call clampScale(...) without that lower fit floor. Starting from a fitted scale like 0.05, a pinch gesture or wheel-down jumps back to 0.25, clipping the same large diagrams the fit path just fixed. Evidence web/src/components/ZoomableLightbox.tsx:186.
    Suggested fix:
    const zoomBy = useCallback((delta: number) => {
        const minScale = Math.min(MIN_SCALE, baseScaleRef.current)
        updateScale((current) => clampScale(current + delta, minScale))
    }, [updateScale])
    
    const nextScale = pinch.startDistance > 0
        ? clampScale(
            pinch.startScale * (distance / pinch.startDistance),
            Math.min(MIN_SCALE, baseScaleRef.current),
        )
        : pinch.startScale

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous two bot findings are addressed, but the new fit floor is not carried into interactive zoom, so very large diagrams can still become clipped as soon as the user zooms/pinches.

Testing

  • Not run: bun is not installed in this runner (bun: command not found).

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Interactive zoom still snaps large fitted diagrams back to 25% — MIN_FIT_SCALE lets the lightbox initially fit dense diagrams below the normal 0.25 floor, but wheel, keyboard, button, and pinch zoom still call clampScale(...) with the default floor. A diagram opened at 0.05 will jump/clamp back to 0.25 on zoom-out or pinch, reintroducing clipping for the same large diagrams the fit path is meant to handle. Evidence web/src/components/ZoomableLightbox.tsx:186.
    Suggested fix:
    const getMinInteractiveScale = () => Math.min(MIN_SCALE, baseScaleRef.current)
    
    const zoomBy = useCallback((delta: number) => {
        updateScale((current) => clampScale(current + delta, getMinInteractiveScale()))
    }, [updateScale])
    
    const nextScale = pinch.startDistance > 0
        ? clampScale(
            pinch.startScale * (distance / pinch.startDistance),
            getMinInteractiveScale(),
        )
        : pinch.startScale

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The current head still leaves the under-25% fit scale out of the interactive zoom paths, so large diagrams can become clipped immediately after user interaction. No other high-confidence issues found in the changed lines.

Testing

  • Not run (automation): bun is not installed in this runner.

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx Outdated
heavygee added a commit to heavygee/hapi that referenced this pull request May 31, 2026
…zoom

Resolves PR tiann#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 <cursoragent@cursor.com>
@heavygee heavygee force-pushed the feat/mermaid-lightbox-737 branch from 6e9cb0a to aa6f755 Compare May 31, 2026 15:42
@heavygee
Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main and pushed aa6f755 addressing the open HAPI Bot Major / Minor threads.

Fit-scale measurement (Major × ~7 threads, all on ZoomableLightbox.tsx)
measureSvgIntrinsicSize now reads viewBox -> width/height attributes -> getBoundingClientRect() (in that order). When the bounding rect is the only signal, it's divided by scaleRef.current so the 50ms / 200ms refit retries no longer compound with the wrapper's scale(...) transform. measureContentSize follows the same precedence (img.naturalSize over rect, SVG intrinsic over rect, host rect / current scale as last fallback). Large diagrams stay fitted instead of jumping tiny / oversize after async render completes.

Interactive zoom floor (Major × 2 threads)
Added getMinInteractiveScale() returning Math.min(MIN_SCALE, baseScaleRef.current). Wheel, keyboard, button, and pinch paths all use it, and the zoom-out button's disabled predicate uses the same floor. A diagram fitted at e.g. 0.05 stays fully visible after wheel-out / pinch instead of snapping back to 25%.

Coverage
Helpers are now exported and covered by web/src/components/ZoomableLightbox.test.ts (8 cases): viewBox precedence over rect, attribute fallback, scale-aware rect division, divide-by-zero guard, naturalSize precedence, host-rect fallback. npx vitest run -> 689/689 green; tsc --noEmit clean.

The two Minor threads (scripts/dev/mermaid-lightbox-playwright.mjs WEB_DIR, web/.gitignore test-results/) were already corrected in earlier commits on this branch.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Seed script can wipe a real session's messages — SESSION_ID is documented as configurable, and this script is meant to run against the host's real ~/.hapi/hapi.db; if that env var is set to an existing non-fixture session, line 57 deletes every message in it before inserting the mermaid cases. Guard the destructive delete so only the known test fixture session/tag can be replaced. Evidence scripts/dev/mermaid-lightbox-seed-session-db.ts:57.
    Suggested fix:
    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 replace messages for non-${sessionTag} session ${sessionId}`)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • Found one data-loss risk in the live dogfood seed helper. The prior interactive zoom floor issue appears addressed in the current diff.

Testing

  • Not run: bun is not installed in this runner, so bun run typecheck:web could not execute.

HAPI Bot

Comment thread scripts/dev/mermaid-lightbox-seed-session-db.ts
heavygee added a commit to heavygee/hapi that referenced this pull request May 31, 2026
HAPI Bot Major (PR tiann#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 <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found in the latest PR diff. The prior seed-script data-loss finding appears addressed by the fixture tag guard in scripts/dev/mermaid-lightbox-seed-session-db.ts:42.

Testing

  • Not run (automation): bun is not installed in this runner, so bun run typecheck:web could not execute. Local Playwright commands also could not run because the workspace dependencies/binaries are not installed here.

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found in the latest PR diff. Residual risk is mainly browser/device coverage for touch gestures and SVG sizing across Mermaid diagram types; the PR adds Vitest plus Playwright coverage for those paths.

Testing

  • Not run (automation): workspace dependencies are not installed in this runner, and bun is unavailable here.

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Guard ResizeObserver before constructing it — the new lightbox effect runs when a Mermaid dialog opens, but the repo's Vitest jsdom setup does not polyfill ResizeObserver, so the focused click path can throw before the dialog renders. This also makes the shared component brittle in browsers/webviews without the API. Evidence web/src/components/ZoomableLightbox.tsx:362.
    Suggested fix:
    if (typeof ResizeObserver === 'undefined') {
        window.addEventListener('resize', apply)
        return () => window.removeEventListener('resize', apply)
    }
    
    const resize = new ResizeObserver(apply)
    resize.observe(toolbar)
    window.addEventListener('resize', apply)
    return () => {
        resize.disconnect()
        window.removeEventListener('resize', apply)
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits
One major issue found in the latest head: the shared lightbox assumes ResizeObserver exists during open, which can break the new Mermaid lightbox tests/runtime path.

Testing

  • Not run (automation): bun is unavailable in this runner (/bin/bash: bun: command not found).

HAPI Bot

Comment thread web/src/components/ZoomableLightbox.tsx
heavygee added a commit to heavygee/hapi that referenced this pull request May 31, 2026
HAPI Bot Major (PR tiann#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 <cursoragent@cursor.com>
heavygee and others added 9 commits June 1, 2026 12:08
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 tiann#737.

Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
heavygee and others added 11 commits June 1, 2026 12:08
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
…zoom

Resolves PR tiann#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 <cursoragent@cursor.com>
HAPI Bot Major (PR tiann#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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
HAPI Bot Major (PR tiann#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 <cursoragent@cursor.com>
@tiann tiann force-pushed the feat/mermaid-lightbox-737 branch from 4584dfe to b884b20 Compare June 1, 2026 04:08
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Fix the live Playwright wrapper cwd — node scripts/dev/mermaid-lightbox-live-playwright.mjs runs npm run test:mermaid-lightbox:live from scripts/, but that directory has no package.json and no such script, so the wrapper exits before Playwright starts. Evidence scripts/dev/mermaid-lightbox-live-playwright.mjs:14.
    Suggested fix:
    const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
    
    const result = spawnSync(
        npmBin,
        ['run', 'test:mermaid-lightbox:live'],
        {
            cwd: REPO_ROOT,
            stdio: 'inherit',
            env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' },
        },
    )

Questions

  • None.

Summary
Review mode: follow-up after new commits
One minor issue found in the latest head. The prior ResizeObserver blocker appears addressed in web/src/components/ZoomableLightbox.tsx. Residual risk: runtime/typecheck coverage could not be verified in this runner.

Testing

  • Not run (automation): bun is unavailable in this runner (/bin/bash: bun: command not found).
  • git diff --check passed.

HAPI Bot

npmBin,
['run', 'test:mermaid-lightbox:live'],
{
cwd: resolve(dirname(fileURLToPath(import.meta.url)), '..'),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Fix the live Playwright wrapper cwd. This file runs npm run test:mermaid-lightbox:live from scripts/, but that directory has no package.json and no such script, so invoking the wrapper exits before Playwright starts.

Suggested fix:

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')

const result = spawnSync(
    npmBin,
    ['run', 'test:mermaid-lightbox:live'],
    {
        cwd: REPO_ROOT,
        stdio: 'inherit',
        env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' },
    },
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web): mermaid diagram lightbox / maximize on click

1 participant