fix(components): make the Mermaid full-screen viewer closable on touch - #388
Conversation
Streamdown's bundled Mermaid overlay put its only exit — a 32px button — at a raw `top-4 right-4`, which on a phone lands entirely inside the status-bar inset, where the system takes the touch. Its content layer then covered the whole backdrop and stopped propagation, so a backdrop tap never reached the close handler either, and touch surfaces have no Escape. The overlay could be opened and not left. It also rendered the diagram scaled to fit, which turns an agent's sequence diagram into an unreadable grey texture. Turn that overlay off and mount `MermaidDiagramViewer` instead: controls padded by the `--safe-area-*` variables at 44px, three exits (close button, a click off the diagram, Escape), and `--z-image-viewer` so a diagram opened from a message inside a dialog lands above it. A diagram that does not fit opens at natural size and pans, with explicit zoom controls. Streamdown owns the diagram markup, so the click target and its role/tabindex are applied by observer, and the block's own copy/download controls now reveal themselves on `(hover: none)` — the same rule the code block already carried, without which they were invisible on touch for good. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e14f3d22b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| }} | ||
| onClick={handleSurfaceClick} | ||
| > | ||
| <div className="flex min-h-full min-w-full items-center justify-center p-4"> |
There was a problem hiding this comment.
Align oversized diagrams to a scrollable edge
When a diagram is wider than the viewer—as intended for phone-sized sequence diagrams—justify-center centers the non-shrinking SVG inside a viewport-width flex container, placing its leading portion at a negative horizontal offset. The scroll surface starts at offset zero and cannot scroll into that negative overflow, so roughly half of the diagram remains permanently clipped; use safe centering or start alignment whenever the content overflows. This breaks the documented natural-size-and-pan behavior for the primary mobile scenario.
AGENTS.md reference: packages/components/src/components/ai-gui/AGENTS.md:L120-L122
Useful? React with 👍 / 👎.
Related issue
Problem / pressure
A Mermaid diagram opened full screen on a phone could not be closed.
Streamdown's bundled Mermaid overlay puts its only exit — a 32px button — at a
raw
top-4 right-4. Measured against a simulated iPhone inset that is{top: 16, right: 16, w: 32, h: 32}against--safe-area-top: 59px: the wholebutton sits inside the status-bar strip, where the system takes the touch. The
overlay's own content layer is
size-fullwithstopPropagation, soelementFromPointanywhere on the "backdrop" returns that layer and a tap neverreaches the close handler. Escape is the third exit and touch devices have none.
Opened, the overlay was a dead end.
It was also unreadable before it was inescapable: the overlay renders the
diagram scaled to fit, which turns the sequence diagrams agents emit into a grey
texture on a 390px screen. That is what makes a user reach for the corner in the
first place.
Two adjacent gaps came out of the same reproduction: the block's own
copy/download controls are revealed only on
:hover/:focus-within, with no(hover: none)rule — the code block has one — so on touch they are invisiblefor good; and the full-screen portal mounts on
document.bodyatz-50, below--z-dialog(80) and every other layer on the app's scale.Summary
mermaid-diagram-viewer.tsxis a new full-screen viewer. Its control bar ispadded by the
--safe-area-*variables rather than a fixed viewport offset,its controls are 44px (the shared
size="icon"is 36), it always offers threeexits (close button, a click anywhere off the diagram, Escape), and it stacks
at
--z-image-viewerso a diagram opened from a message inside a dialog landsabove that dialog. Escape is handled in the capture phase with
stopImmediatePropagation, so it dismisses the viewer alone instead of alsoclosing the dialog underneath.
computeInitialDiagramZoomopens a diagram that does not fit at NATURAL sizeand pans it, and scales up only one that already fits (cap 3x). Explicit
zoom-out / reset / zoom-in controls replace the fit-to-screen behavior.
markdown-renderer.tsxsetscontrols.mermaid.fullscreen: falseand opensthe new viewer from a delegated click/Enter/Space on the diagram, passing a
cloneNode(true)of the already-rendered SVG — a live node, not markupre-parsed through
dangerouslySetInnerHTML, and the copy in the conversationkeeps its own size.
resolves, so
role/tabindex/aria-labelare applied by a MutationObserverthat is installed only for text containing a ```mermaid fence.
tailwind/index.cssgivesmermaid-block-actionsthe(hover: none)revealthe code block already had, swaps the block's
cursor: defaultforzoom-in,and adds a
:focus-visiblering for the keyboard affordance.MermaidPhoneVieweris a phone-shaped Storybook fixture that sets the--safe-area-*variables (a headless browser reports zero insets, so withoutit nothing can show a control parked under the status bar). The ai-gui
AGENTS.mdrecords the invariant.Before / after
{top: 16, w: 32, h: 32}— entirely inside a 59px status-bar inset{top: 59, right: 4, w: 44, h: 44}— clears the inset, meets the 44px touch floorelementFromPointreturns thesize-fullstopPropagation layer; overlay stays openz-50, under--z-dialog(80)--z-image-viewer(95)(hover: none)rule)Test plan
tests/markdown-mermaid-fullscreen.test.tsx(8 cases): the bundledcontrol is gone while copy/download remain; the diagram carries
role/tabindex/aria-label; opening leaves the message copy in place; thecontrol bar reserves the safe area and the close button is 44px at
--z-image-viewer; all three exits close and a click on the diagram does not;Enter opens from the keyboard; plus the zoom-selection function directly.
Verified the suite fails when
fullscreenis restored totrue.and idle callback through
advanceTimersByTimeAsync, so no wall-clock waits.pnpm testinpackages/components: 420 files / 3022 tests pass. (An earlierrun had one unrelated
markdown-streaming-reparse5s timeout under load; itruns in 939ms in isolation and the clean full run includes it.)
pnpm --filter @lody/components typecheckclean;pnpm check:quickpasses(lint 0 errors, i18n keys complete in en and zh_CN, code-collab imports,
platform boundaries, public boundary); Prettier clean on every touched file.
insets and at 1280x800: measured the numbers in the Before/after table from
the live DOM; confirmed all three exits with dispatched events; confirmed the
(hover: none)rule ships in the compiled stylesheet; confirmed the viewerSVG scales 874 -> 1093px at 125% while the message copy stays 874px.
pnpm checkin full. Itstest:cileg coversapps/cliandapps/electron, which this change does not touch; the component package's ownsuite and every static guard were run instead.
Context handoff
Instructions for reviewing agents
mermaid-diagram-viewer.tsx(safe-area padding, the threeexits, capture-phase Escape) and the delegated open path in
markdown-renderer.tsx— particularly that the cloned SVG is a node ratherthan re-parsed markup, and that the MutationObserver is installed only for
text with a mermaid fence, since a conversation mounts one renderer per block.
instead of fit-to-screen; making the diagram itself the click target rather
than injecting a button into Streamdown's toolbar; and not pushing a history
entry for back-gesture dismissal, which keeps this consistent with
ZoomableImageViewerbut leaves Android back unhandled.inset in a headless Chromium, not a physical device, and the mobile entry
point lives outside this repository; pinch-to-zoom is not implemented, so
mobile zoom is buttons plus native pan; duplicated SVG
ids now exist whilethe viewer is open (both copies resolve
url(#...)to identical defs, butthat is reasoning, not a rendered check on every diagram type).
Authoring context
could not be closed by the X in the corner; analyze, reproduce, fix, verify.
(
beautiful-mermaid, themes, the fallback path) or to the inline block'slayout; Streamdown stays on the same version; the mobile app's own sources are
outside this repository, so the fix lands in the shared component.
is presentation plus one dependency control flag; the cloned SVG is inserted
as a node, so no new HTML-injection surface.
network path. The viewer restores
document.body.style.overflowto its priorvalue on unmount.
(both explained above); Streamdown's inline
panZoomstays off, sinceenabling it would hand chat scroll gestures to the diagram; no physical-device
run.
are measured and asserted; moderate for real iOS, where the inset comes from
env()rather than the fixture's override and standalone-PWA insets differfrom the browser's.
🤖 Generated with Claude Code