Skip to content

Commit 2d3e4ea

Browse files
vpcclaude
andcommitted
fix(site): mermaid svg styles must be is:global so runtime nodes match
Two compounding bugs were keeping the pipeline diagram from rendering on attestloop.ai: 1. Astro CSS scoping. The .mermaid-host svg, .node rect, .edgePath etc. selectors lived inside the component's scoped <style> block, so Astro rewrote each one with [data-astro-cid-mnvk4ct3] attribute tags. Mermaid generates the SVG at runtime — none of its child nodes carry the Astro CID, so every override silently missed and the diagram could not match the monochrome theme even when it did render. Move the runtime-SVG rules into a sibling <style is:global> block; the host wrapper rules stay scoped. 2. <pre class="mermaid"> + host.innerHTML = svg. The host was a <pre> tag with font-family: monospace and white-space: pre, so the injected SVG inherited preserved-whitespace layout. Plus the source delivery via JSX text interpolation forced :not([data-rendered]) gymnastics to hide the un-rendered source. Replace the <pre> with a <div class="mermaid-host" data-mermaid-source="..."> that carries the source as an attribute and shows a "Loading diagram..." fallback until Mermaid replaces the host's children with the rendered SVG. Drop securityLevel: "strict" -> "loose"; the Mermaid source is a build-time artefact (LangGraph output), not user input, and the strict DOMPurify pass mishandles the <p>__start__</p> labels LangGraph emits in the start/end nodes. Verified locally: pnpm build clean, dist/_astro/index.*.css emits the SVG selectors without [data-astro-cid] tags (i.e. they will match Mermaid's runtime nodes), and the rendered HTML carries data-mermaid-source on the host div as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 96df2dc commit 2d3e4ea

1 file changed

Lines changed: 81 additions & 47 deletions

File tree

site/src/components/PipelineDiagram.astro

Lines changed: 81 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -361,19 +361,26 @@ const formatPercent = (frac: number) => `${(frac * 100).toFixed(0)}%`;
361361

362362
<!-- Mermaid diagram, generated from the compiled LangGraph state
363363
machine. Source lives at site/src/content/orchestration/
364-
v6_pipeline.mmd; the script tag below renders it on mount. -->
364+
v6_pipeline.mmd; the script tag below renders it on mount.
365+
The source is delivered via a `data-mermaid-source` attribute
366+
(not as text content) so Astro's CSS scoping doesn't trip
367+
over the rendered SVG, and the host div is replaced outright
368+
when the render succeeds. -->
365369
<div class="mermaid-frame mb-10">
366370
<p class="mermaid-caption text-xs font-mono uppercase tracking-[0.18em] text-text-muted">
367371
Compiled LangGraph state machine · v2.0.0
368372
</p>
369-
<div class="mermaid-host" role="img" aria-label="Pipeline state-machine diagram">
370-
<pre class="mermaid" data-pipeline-source>{mermaidSource}</pre>
371-
<noscript>
372-
<p class="text-sm font-mono text-text-secondary">
373-
(Pipeline diagram requires JavaScript. The Mermaid source is
374-
in the page; see docs/orchestration/v6_pipeline.mmd in the repo.)
375-
</p>
376-
</noscript>
373+
<div
374+
class="mermaid-host"
375+
role="img"
376+
aria-label="Pipeline state-machine diagram"
377+
data-mermaid-source={mermaidSource}
378+
>
379+
<p class="mermaid-fallback text-sm font-mono text-text-muted">
380+
Loading diagram… (if this persists, see
381+
<code class="font-mono text-text-primary">docs/orchestration/v6_pipeline.mmd</code>
382+
in the repo for the source.)
383+
</p>
377384
</div>
378385
</div>
379386

@@ -848,7 +855,7 @@ const formatPercent = (frac: number) => `${(frac * 100).toFixed(0)}%`;
848855
line-height: 1.5;
849856
}
850857

851-
/* ─────────────── Mermaid pipeline diagram ─────────────── */
858+
/* ─────────────── Mermaid pipeline diagram (frame) ─────────────── */
852859
.mermaid-frame {
853860
border: 1px solid var(--color-border-subtle);
854861
background: var(--color-bg-elevated);
@@ -859,27 +866,37 @@ const formatPercent = (frac: number) => `${(frac * 100).toFixed(0)}%`;
859866
}
860867
.mermaid-host {
861868
overflow-x: auto;
862-
}
863-
/* Hide the raw .mmd source until Mermaid replaces it with rendered
864-
SVG; prevents a flash of un-rendered source on slow loads. */
865-
.mermaid-host pre.mermaid:not([data-rendered]) {
866-
visibility: hidden;
867869
min-height: 14rem;
870+
display: flex;
871+
align-items: center;
872+
justify-content: center;
868873
}
869-
.mermaid-host pre.mermaid {
870-
background: transparent;
871-
border: 0;
872-
padding: 0;
873-
margin: 0;
874-
font-family: var(--font-mono);
874+
.mermaid-host[data-rendered="true"] {
875+
display: block;
876+
min-height: 0;
875877
}
876-
/* Mermaid emits inline fills on its rendered SVG; override aggressively
877-
so the diagram matches the site's monochrome dark aesthetic without
878-
the default purple/teal palette bleeding through. */
878+
.mermaid-fallback {
879+
text-align: center;
880+
padding: 1rem;
881+
}
882+
.mermaid-host[data-rendered] .mermaid-fallback {
883+
display: none;
884+
}
885+
</style>
886+
887+
<!-- Global styles for the runtime-injected Mermaid SVG. Must live
888+
outside the scoped <style> block above, because Astro's
889+
data-astro-cid attribute selector rewrite can't tag elements
890+
Mermaid generates at runtime — those nodes have no Astro CID, so
891+
scoped selectors silently miss every override. is:global keeps
892+
the rules untouched. -->
893+
<style is:global>
879894
.mermaid-host svg {
880895
background: transparent;
881896
max-width: 100%;
882897
height: auto;
898+
display: block;
899+
margin: 0 auto;
883900
}
884901
.mermaid-host svg .node rect,
885902
.mermaid-host svg .node polygon,
@@ -889,10 +906,12 @@ const formatPercent = (frac: number) => `${(frac * 100).toFixed(0)}%`;
889906
stroke: #363b46 !important;
890907
stroke-width: 1px !important;
891908
}
892-
.mermaid-host svg .node .label {
909+
.mermaid-host svg .node .label,
910+
.mermaid-host svg .node text,
911+
.mermaid-host svg .nodeLabel {
893912
color: #e6e8eb !important;
894913
fill: #e6e8eb !important;
895-
font-family: var(--font-mono);
914+
font-family: "JetBrains Mono", ui-monospace, monospace;
896915
font-size: 0.78rem;
897916
}
898917
.mermaid-host svg .node.first rect,
@@ -920,11 +939,14 @@ const formatPercent = (frac: number) => `${(frac * 100).toFixed(0)}%`;
920939
background-color: #16191f !important;
921940
color: #b0bac6 !important;
922941
fill: #b0bac6 !important;
923-
font-family: var(--font-mono);
942+
font-family: "JetBrains Mono", ui-monospace, monospace;
924943
font-size: 0.7rem;
925944
}
926-
.mermaid-host svg .edgeLabel rect {
945+
.mermaid-host svg .edgeLabel rect,
946+
.mermaid-host svg .edgeLabel foreignObject div {
947+
background-color: #16191f !important;
927948
fill: #16191f !important;
949+
color: #b0bac6 !important;
928950
}
929951
.mermaid-host svg .cluster rect {
930952
fill: #1c2027 !important;
@@ -1019,26 +1041,38 @@ const formatPercent = (frac: number) => `${(frac * 100).toFixed(0)}%`;
10191041

10201042
<script>
10211043
// Render the LangGraph-generated Mermaid source into an SVG on mount.
1022-
// Mermaid is lazy-loaded via dynamic import so the ~600 KB bundle
1023-
// doesn't sit on the homepage's critical path; it only fetches once
1024-
// the page has settled and the host element is in view. Theme
1025-
// variables match the site's monochrome dark aesthetic; CSS in the
1026-
// scoped <style> block above pins the SVG fills/strokes defensively
1027-
// in case Mermaid's inline styles bleed through.
1044+
// Mermaid is lazy-loaded via dynamic import on intersection so the
1045+
// ~600 KB bundle doesn't sit on the homepage's critical path. Source
1046+
// is read from the host's data-mermaid-source attribute (not text
1047+
// content) so Astro's HTML escaping doesn't double-escape the
1048+
// <p>...</p> labels in the .mmd file. The rendered SVG replaces the
1049+
// host's children outright so Astro's scoped CSS doesn't try to
1050+
// attribute-tag elements Mermaid injected at runtime.
10281051

10291052
async function renderPipelineDiagram() {
1030-
const host = document.querySelector<HTMLPreElement>(
1031-
"pre.mermaid[data-pipeline-source]",
1053+
const host = document.querySelector<HTMLDivElement>(
1054+
"div.mermaid-host[data-mermaid-source]",
10321055
);
10331056
if (!host) return;
1034-
const source = host.textContent ?? "";
1057+
const source = host.getAttribute("data-mermaid-source") ?? "";
10351058
if (!source.trim()) return;
10361059

1037-
const { default: mermaid } = await import("mermaid");
1060+
let mermaid;
1061+
try {
1062+
({ default: mermaid } = await import("mermaid"));
1063+
} catch (err) {
1064+
console.error("Mermaid import failed:", err);
1065+
return;
1066+
}
1067+
10381068
mermaid.initialize({
10391069
startOnLoad: false,
10401070
theme: "base",
1041-
securityLevel: "strict",
1071+
// Default "strict" security level forces label sanitisation that
1072+
// mishandles the <p>__start__</p> wrappers LangGraph emits;
1073+
// "loose" preserves the labels and is safe here because the
1074+
// Mermaid source is a build-time artefact, not user input.
1075+
securityLevel: "loose",
10421076
themeVariables: {
10431077
primaryColor: "#16191f",
10441078
primaryTextColor: "#e6e8eb",
@@ -1058,23 +1092,23 @@ const formatPercent = (frac: number) => `${(frac * 100).toFixed(0)}%`;
10581092
});
10591093

10601094
try {
1061-
const { svg } = await mermaid.render("attestloop-pipeline", source);
1095+
const { svg } = await mermaid.render("attestloop-pipeline-svg", source);
10621096
host.innerHTML = svg;
10631097
host.setAttribute("data-rendered", "true");
10641098
} catch (err) {
1065-
// If Mermaid fails (rare; usually a syntax issue), surface the
1066-
// source as fallback rather than leaving a blank box.
10671099
console.error("Mermaid render failed:", err);
10681100
host.setAttribute("data-rendered", "error");
1069-
host.style.visibility = "visible";
1101+
const fallback = host.querySelector<HTMLElement>(".mermaid-fallback");
1102+
if (fallback) {
1103+
fallback.textContent =
1104+
"Pipeline diagram failed to render. The Mermaid source is at docs/orchestration/v6_pipeline.mmd in the repo.";
1105+
}
10701106
}
10711107
}
10721108

1073-
// Defer until the diagram approaches the viewport so the Mermaid
1074-
// bundle doesn't compete with first paint.
10751109
function scheduleRender() {
1076-
const host = document.querySelector<HTMLPreElement>(
1077-
"pre.mermaid[data-pipeline-source]",
1110+
const host = document.querySelector<HTMLDivElement>(
1111+
"div.mermaid-host[data-mermaid-source]",
10781112
);
10791113
if (!host) return;
10801114
if ("IntersectionObserver" in window) {

0 commit comments

Comments
 (0)