diff --git a/rivet-cli/src/render/artifacts.rs b/rivet-cli/src/render/artifacts.rs
index 1fd5eef..40ba287 100644
--- a/rivet-cli/src/render/artifacts.rs
+++ b/rivet-cli/src/render/artifacts.rs
@@ -44,6 +44,63 @@ use super::RenderResult;
use super::helpers::badge_for_type;
use crate::serve::components::ViewParams;
+/// Wrap any `
…
` block inside an HTML fragment
+/// (typically the output of `render_markdown` on an artifact description)
+/// in the shared `.svg-viewer` shell with the standard zoom-fit /
+/// fullscreen / popout toolbar.
+///
+/// The dashboard renders mermaid diagrams from two surfaces:
+///
+/// * The structured `diagram:` field — already wrapped in `.svg-viewer`
+/// by `render_artifact_detail` below.
+/// * Fenced ```mermaid blocks inside the artifact's `description` —
+/// emitted by `rivet_core::markdown::render_markdown` as bare
+/// `
`.
+///
+/// Without this wrapping the description-based diagrams missed the
+/// toolbar, breaking the cross-view diagram-viewer parity contract
+/// pinned by `tests/playwright/artifacts.spec.ts:70`.
+///
+/// The transform is a textual scan rather than a DOM rewrite to keep
+/// the render path dependency-free; pulldown-cmark always emits the
+/// open tag verbatim as `
` (see the
+/// `MERMAID_OPEN` sentinel in `rivet_core::markdown`), so a literal
+/// substring match is reliable.
+fn wrap_markdown_mermaid_in_svg_viewer(html: &str) -> String {
+ const NEEDLE: &str = "
";
+ let mut out = String::with_capacity(html.len() + 256);
+ let mut cursor = 0;
+ while let Some(start) = html[cursor..].find(NEEDLE) {
+ let abs_start = cursor + start;
+ out.push_str(&html[cursor..abs_start]);
+ // Find the matching `` close. No nested `
` is
+ // possible inside a fenced code block, so the first `
`
+ // after the open is the right one.
+ if let Some(close_rel) = html[abs_start..].find("") {
+ let close_end = abs_start + close_rel + "".len();
+ out.push_str(TOOLBAR);
+ out.push_str(&html[abs_start..close_end]);
+ out.push_str("
"); // .svg-viewer
+ cursor = close_end;
+ } else {
+ // Unterminated — emit the rest as-is and stop.
+ out.push_str(&html[abs_start..]);
+ return out;
+ }
+ }
+ out.push_str(&html[cursor..]);
+ out
+}
+
// ── Artifacts list ────────────────────────────────────────────────────────
pub(crate) fn render_artifacts_list(ctx: &RenderContext, params: &ViewParams) -> String {
@@ -451,9 +508,15 @@ pub(crate) fn render_artifact_detail(ctx: &RenderContext, id: &str) -> RenderRes
html_escape(&artifact.title)
));
if let Some(desc) = &artifact.description {
+ // Render markdown, then wrap any `
` blocks in
+ // the same `.svg-viewer` toolbar shell used by the dedicated
+ // `diagram:` field below — so a mermaid diagram embedded in a
+ // description gets the same zoom-fit / fullscreen / popout
+ // controls as one supplied via the structured field. Pins the
+ // diagram-viewer parity contract (tests/playwright/artifacts.spec.ts:70).
+ let rendered = wrap_markdown_mermaid_in_svg_viewer(&render_markdown(desc));
html.push_str(&format!(
- "
";
+ let wrapped = wrap_markdown_mermaid_in_svg_viewer(html);
+ assert!(wrapped.contains("
"));
+ assert!(wrapped.contains("svg-viewer-toolbar"));
+ assert!(wrapped.contains("title=\"Fullscreen\""));
+ // Pre block still in place.
+ assert!(wrapped.contains("
";
+ let wrapped = wrap_markdown_mermaid_in_svg_viewer(html);
+ // Two viewer wrappers present.
+ assert_eq!(wrapped.matches("
").count(), 2);
+ assert_eq!(wrapped.matches("svg-viewer-toolbar").count(), 2);
+ }
+}
diff --git a/rivet-cli/src/serve/layout.rs b/rivet-cli/src/serve/layout.rs
index 10c987d..14f1a9d 100644
--- a/rivet-cli/src/serve/layout.rs
+++ b/rivet-cli/src/serve/layout.rs
@@ -200,13 +200,18 @@ pub(crate) fn page_layout_with_variant(
s
};
// Variant selector: only rendered when the project has a feature model.
+ //
+ // The change handler is wired up in the variant-sync script (below)
+ // via `addEventListener('change', …)` instead of an inline `onchange`
+ // attribute. Inline handlers are blocked by some headless Chromium
+ // configs (e.g. Playwright's default page CSP) and can be silently
+ // skipped when the selectOption action mutates the DOM via the
+ // accessibility tree. The addEventListener route runs in every
+ // env we care about.
let variant_selector_html = if state.variants.has_model() {
let mut s = String::from(
"/\