fix: keep the mermaid fullscreen dialog in its pane and its emoji vector (#10541)

* fix: keep the mermaid fullscreen dialog inside its pane and its emoji vector

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: load only the fonts a diagram needs and size chrome per breakpoint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cover every emoji class in the font preload without restyling diagrams

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the dialog chrome allowance in rem so it scales with the root font

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: decode mermaid entity codes when sampling text for the font preload

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: match mermaid's decimal-only entity codes and decode the Inter sample too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop emoji format characters from the font sample

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: state the emoji subset spread and modifier exclusions precisely

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: re-render once fonts settle instead of hand-picking emoji subsets

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: give Modal a fill-height mode instead of measuring its chrome

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard the post-fonts re-render against a newer render

The re-render after document.fonts.ready assigned svg without re-checking
renderSeq after its own await. renderedCode is set before that await, so a
stale re-render landing last leaves svg holding the previous diagram while
renderedCode names the current source — showSvg stays true and paints the
wrong diagram.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-08-06 12:44:47 +02:00
committed by GitHub
parent fc1e11cb3d
commit c2a6936e7d
3 changed files with 47 additions and 12 deletions
@@ -20,6 +20,11 @@
style?: string
cancelText?: string | undefined
kind?: 'button' | 'X'
/** Make the dialog fill the height it is anchored to and lay its body out as a flex
* column, so content can size itself with `h-full` / `flex-1 min-h-0`. Off by default:
* the dialog otherwise hugs its content, and percentage heights inside it do not
* resolve (the centering wrapper is `min-h-full`, i.e. height:auto). */
fillHeight?: boolean
/** Force a minimum z-index base. Defaults to elevating above the AI chat
* side panel when it is open. Pass an explicit value to stack above other
* surfaces (e.g. a modal opened over the /sessions preview-pane editor). */
@@ -36,6 +41,7 @@
style = '',
cancelText = undefined,
kind = 'button',
fillHeight = false,
minZIndex: minZIndexProp = undefined,
settings,
children: children_render,
@@ -119,12 +125,17 @@
></div>
<div class="{posClass} inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class="flex {fillHeight ? 'h-full' : 'min-h-full'} items-center justify-center p-4"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={stopPropagation(bubble('click'))}
class={twMerge(
'relative transform overflow-hidden rounded-md bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6',
'relative transform overflow-hidden rounded-md bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:w-full sm:max-w-lg sm:p-6',
// The margins are what keeps a content-sized dialog off the viewport edges; a
// filling one takes its inset from the wrapper's padding instead.
fillHeight ? 'h-full flex flex-col' : 'sm:my-8',
c,
open
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
@@ -137,16 +148,16 @@
><CloseButton on:close={() => (open = false)} /></div
>
{/if}
<div class="flex">
<div class="flex {fillHeight ? 'flex-1 min-h-0' : ''}">
<!-- min-w-0: without it this flex item takes its content's min-content width and
stretches the modal past its max-width instead of letting content shrink. -->
<div class="text-left flex-1 min-w-0">
<div class="text-left flex-1 min-w-0 {fillHeight ? 'flex flex-col min-h-0' : ''}">
<div class="flex flex-row items-center justify-between">
<h3 class="text-emphasis text-lg font-semibold">{title}</h3>
{@render settings?.()}
</div>
<div class="mt-4 text-sm text-primary">
<div class="mt-4 text-sm text-primary {fillHeight ? 'flex-1 min-h-0' : ''}">
{@render children_render?.()}
</div>
</div>
@@ -35,6 +35,13 @@
startOnLoad: false,
theme: dark ? 'dark' : 'default',
securityLevel: 'strict',
// Mermaid writes this stack into a <style> block inside the SVG, so the app's font
// never reaches a diagram: the bundled vector emoji font has to be named here or
// emoji fall back to the platform bitmap one, which ignores the zoom transform
// (app.css). Inter sits last before it because the emoji font also covers digits,
// # and *; on a box without the MS core fonts those would otherwise render as its
// keycap glyphs. Keeping Inter behind them leaves diagram typography unchanged.
fontFamily: '"trebuchet ms", verdana, arial, Inter, "Noto Color Emoji", sans-serif',
// Throw on parse errors instead of injecting an orphan error diagram into the DOM.
suppressErrorRendering: true
})
@@ -43,6 +50,17 @@
if (seq !== renderSeq) return
svg = result.svg
renderedCode = source
// Mermaid sizes each node by measuring its label, so a font still in flight yields
// boxes cut to fallback metrics that the real glyphs then overflow. Drawing the
// diagram is itself what asks for the fonts its glyphs need — including the emoji
// subsets — so let them settle and lay it out again against the true metrics.
if (document.fonts?.status === 'loading') {
await document.fonts.ready
if (seq !== renderSeq) return
const settled = await mermaid.render(`mermaid-${randomUUID()}`, source)
if (seq !== renderSeq) return
svg = settled.svg
}
} catch {
// Parse failure (often a partial block still streaming in): fall back to the
// raw source. `showSvg` already hides any previous diagram since `renderedCode`
@@ -145,7 +163,7 @@
</div>
</div>
<Modal bind:open={expanded} title="Diagram" kind="X" class="sm:max-w-none w-[92vw]">
<Modal bind:open={expanded} title="Diagram" kind="X" fillHeight class="sm:max-w-none w-[92vw]">
{#snippet settings()}
<div class="flex flex-row gap-1 mr-8">
<Button
@@ -183,7 +201,7 @@
</div>
{/snippet}
<div
class="relative w-full h-[78vh] overflow-hidden rounded border cursor-grab bg-surface-secondary"
class="relative w-full h-full overflow-hidden rounded border cursor-grab bg-surface-secondary"
>
{#if expanded}
<div use:panzoomAction class="w-full h-full flex items-center justify-center">
@@ -127,14 +127,16 @@
active ? 'z-10 opacity-100 pointer-events-auto' : 'z-0 opacity-0 pointer-events-none'
)
// Overlays the editor opens (drawers, modals, popovers) anchor here rather than to the
// Overlays a tab opens (drawers, modals, popovers) anchor here rather than to the
// document, so they stay within this tab and hide with it when another tab takes over.
// Every branch that renders content in-realm must bind this — an unbound host makes the
// overlay fall back to viewport-`fixed`, spilling it across the whole app.
// The stack is per-tab for the same reason: this host stays mounted while hidden, and a
// shared stack would let its overlays arbitrate Escape for the tab the user is looking at.
let editorEl: HTMLDivElement | undefined = $state()
let overlayHostEl: HTMLDivElement | undefined = $state()
let hostDrawers = $state({ val: [] as string[] })
setOverlayHost({
el: () => editorEl,
el: () => overlayHostEl,
drawers: hostDrawers,
// The panel is resized rather than unmounted, so the active tab of a panel that
// is off screen is as invisible as a background tab.
@@ -180,7 +182,7 @@
{#if slot.kind === 'editor' && mounted && runtime}
<div
bind:this={editorEl}
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
@@ -238,7 +240,11 @@
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}" aria-hidden={!active}>
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
{#if artifact}
<ArtifactViewer {artifact} />
{:else if !runtime?.manager.artifacts.loading}