diff --git a/apps/web/package.json b/apps/web/package.json index 1fc7fd6eb5fa9..0df51bcade531 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -66,7 +66,7 @@ "lucide": "^0.564.0", "lucide-react": "^0.564.0", "morphicons": "^1.7.1", - "pele": "^1.8.0", + "pele": "1.9.0", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx index 11f98ec57a739..4fcf117b9ae46 100644 --- a/apps/web/src/components/chat/MermaidDiagram.tsx +++ b/apps/web/src/components/chat/MermaidDiagram.tsx @@ -4,6 +4,7 @@ import { use, useLayoutEffect, useRef, useState } from "react"; import { serializeCodeBlockToMarkdown } from "../../markdown-clipboard"; import { Button } from "../ui/button"; +import { PELE_SAFE_OPTIONS } from "./peleOptions"; type Pele = typeof import("pele/lazy"); type PeleLoad = { readonly status: "loaded"; readonly pele: Pele } | { readonly status: "failed" }; @@ -203,6 +204,37 @@ let expandedImageUrl: string | null = null; * A blob URL, unlike a data URL, passes the desktop connect-src policy that media * save and copy actions fetch through. */ +// The house theme (hd-diagram-theme.css) styles shapes and labels from the page +// stylesheet, which a detached SVG image cannot see. Copy the resolved +// presentation values onto the clone so expanded/saved diagrams keep them. +const HOUSE_STYLED_SELECTOR = "rect, path, polygon, circle, ellipse, line, polyline, text, tspan"; +const HOUSE_STYLED_PROPERTIES = [ + "fill", + "stroke", + "stroke-width", + "stroke-dasharray", + "stroke-linecap", + "font-family", + "font-size", + "font-weight", + "letter-spacing", +] as const; + +function inlineHouseStyles(source: SVGSVGElement, clone: SVGSVGElement): void { + const from = source.querySelectorAll(HOUSE_STYLED_SELECTOR); + const to = clone.querySelectorAll(HOUSE_STYLED_SELECTOR); + if (from.length !== to.length) return; + from.forEach((node, index) => { + const target = to[index]; + if (!target) return; + const computed = getComputedStyle(node); + for (const name of HOUSE_STYLED_PROPERTIES) { + const value = computed.getPropertyValue(name).trim(); + if (value) target.style.setProperty(name, value); + } + }); +} + function diagramImageUrl(svg: SVGSVGElement): string { const element = svg.cloneNode(true) as SVGSVGElement; const computed = getComputedStyle(svg); @@ -210,6 +242,10 @@ function diagramImageUrl(svg: SVGSVGElement): string { const value = computed.getPropertyValue(name).trim(); if (value) element.style.setProperty(name, value); } + inlineHouseStyles(svg, element); + // Shapes use rx: var(--_r), which resolves from --pele-radius set by the house theme. + const radius = computed.getPropertyValue("--pele-radius").trim(); + if (radius) element.style.setProperty("--pele-radius", radius); element.style.maxWidth = "none"; element.style.backgroundColor = getComputedStyle(document.body).backgroundColor; if (expandedImageUrl) URL.revokeObjectURL(expandedImageUrl); @@ -239,8 +275,7 @@ export function MermaidDiagram({ const container = containerRef.current; if (load.status !== "loaded" || !container) return; const mounted = load.pele.mount(container, trimmedSource, { - links: false, - images: false, + ...PELE_SAFE_OPTIONS, zoom: false, // Keep the author's direction; a wide diagram scales down to fit instead of turning vertical. autoDirection: false, @@ -302,20 +337,22 @@ function DiagramError({ }) { return (
+
+        {source}
+      
-

Unable to render diagram: {message}

+

+ Unable to render diagram: {message} +

{onRetry ? ( ) : null}
-
-        {source}
-      
); } diff --git a/apps/web/src/components/chat/__snapshots__/peleRender.flowchart.svg b/apps/web/src/components/chat/__snapshots__/peleRender.flowchart.svg new file mode 100644 index 0000000000000..1c610379851f3 --- /dev/null +++ b/apps/web/src/components/chat/__snapshots__/peleRender.flowchart.svg @@ -0,0 +1 @@ +readsprimaryfallbacktooltipAgentSidecarExtensioncua-driverRemote \ No newline at end of file diff --git a/apps/web/src/components/chat/peleOptions.ts b/apps/web/src/components/chat/peleOptions.ts new file mode 100644 index 0000000000000..39a73e7c0a3bc --- /dev/null +++ b/apps/web/src/components/chat/peleOptions.ts @@ -0,0 +1,6 @@ +/** + * Options every Pele render in T3 uses. Diagrams can come from untrusted + * text, so they never emit links or load images. Fleet renderers + * (pele-render, the KB build) use the same pair. + */ +export const PELE_SAFE_OPTIONS = { links: false, images: false } as const; diff --git a/apps/web/src/components/chat/peleRender.test.ts b/apps/web/src/components/chat/peleRender.test.ts new file mode 100644 index 0000000000000..ef9902aaa6b11 --- /dev/null +++ b/apps/web/src/components/chat/peleRender.test.ts @@ -0,0 +1,30 @@ +import { render } from "pele"; +import { describe, expect, it } from "vite-plus/test"; + +import { PELE_SAFE_OPTIONS } from "./peleOptions"; + +const FLOWCHART = `flowchart LR + A[Agent] -->|reads| B{Sidecar} + B -->|primary| C[Extension] + B -.->|fallback| D[cua-driver] + I@{ img: "https://example.com/track.png", label: "Remote", pos: "t" } + C --> I + click A "https://example.com/phish" "tooltip" +`; + +describe("pele render", () => { + it("renders a flowchart to a stable SVG", async () => { + const { svg } = render(FLOWCHART, PELE_SAFE_OPTIONS); + expect(svg.startsWith(" { + const { svg } = render(FLOWCHART, PELE_SAFE_OPTIONS); + expect(svg).not.toMatch(/]|href=|]|example\.com/); + }); + + it("throws a readable error for invalid source", () => { + expect(() => render("flowchart LR\n A -->", PELE_SAFE_OPTIONS)).toThrow(); + }); +}); diff --git a/apps/web/src/hd-diagram-theme.css b/apps/web/src/hd-diagram-theme.css new file mode 100644 index 0000000000000..ba7e487053942 --- /dev/null +++ b/apps/web/src/hd-diagram-theme.css @@ -0,0 +1,151 @@ +/* Hack Dance house theme for Pele (Mermaid) diagrams, mapped to T3 tokens. + Keep in sync with the Hack Dance house theme source. + Neutrals, accent, error and the live/wait/ok/fail/idle state colors follow the + active T3 theme tokens (light and dark, custom themes too); the series palette + and font stacks come from the house theme. Pele reads colors through --pele-*, + so a theme change recolors diagrams without re-rendering. + Authors use house classes only: `classDef live stroke-width:1.6px` + `class X live`. */ +:root { + --hd-series-1: #5b3df5; + --hd-series-2: #0e9f8a; + --hd-series-3: #e08a00; + --hd-series-4: #d6345a; + --hd-series-5: #2f7de1; + --hd-series-6: #8a5cf6; + --hd-series-7: #4f9d2d; + --hd-series-8: #a0674b; +} + +.dark { + --hd-series-1: #9a86ff; + --hd-series-2: #3fd1b8; + --hd-series-3: #ffb547; + --hd-series-4: #ff6b8b; + --hd-series-5: #6aa8ff; + --hd-series-6: #c4a5ff; + --hd-series-7: #8fd16a; + --hd-series-8: #d29b7f; +} + +.pele { + --pele-font: "Geist", var(--font-sans, ui-sans-serif, system-ui, sans-serif); + --pele-font-mono: "Geist Mono", var(--font-mono, ui-monospace, monospace); + --pele-bg: var(--background); + --pele-fg: var(--foreground); + --pele-muted: var(--muted-foreground); + --pele-line: color-mix(in srgb, var(--foreground) 62%, transparent); + --pele-surface: var(--card); + --pele-surface-alt: var(--secondary); + --pele-border: color-mix(in srgb, var(--foreground) 22%, transparent); + --pele-accent: var(--primary); + --pele-critical: var(--destructive); + --pele-error: var(--destructive); + --pele-radius: 8px; + --pele-title-weight: 600; + --pele-heading-weight: 600; + --pele-bold-weight: 600; + --pele-series-1: var(--hd-series-1); + --pele-series-2: var(--hd-series-2); + --pele-series-3: var(--hd-series-3); + --pele-series-4: var(--hd-series-4); + --pele-series-5: var(--hd-series-5); + --pele-series-6: var(--hd-series-6); + --pele-series-7: var(--hd-series-7); + --pele-series-8: var(--hd-series-8); + /* semantic state colors (house classes) */ + --hd-live: var(--primary); + --hd-wait: var(--warning); + --hd-ok: var(--success); + --hd-fail: var(--destructive); + --hd-idle: var(--muted-foreground); + --hd-live-bg: color-mix(in srgb, var(--hd-live) 14%, var(--background)); + --hd-wait-bg: color-mix(in srgb, var(--hd-wait) 14%, var(--background)); + --hd-ok-bg: color-mix(in srgb, var(--hd-ok) 14%, var(--background)); + --hd-fail-bg: color-mix(in srgb, var(--hd-fail) 14%, var(--background)); + --hd-idle-bg: var(--secondary); +} + +/* polish */ +.pele .pele-edge path { + stroke-width: 1.4px; + stroke-linecap: round; +} +.pele .pele-edge-label { + font-size: 0.92em; +} +.pele .pele-edge-label text { + fill: var(--pele-muted); +} +.pele .pele-cluster-label, +.pele .pele-title { + letter-spacing: 0.01em; +} +.pele .pele-node > rect, +.pele .pele-node > path, +.pele .pele-node > polygon, +.pele .pele-node > circle, +.pele .pele-node > ellipse { + stroke-width: 1.2px; +} + +/* house semantic classes */ +.pele .live > rect, +.pele .live > path, +.pele .live > polygon, +.pele .live > circle, +.pele .live > ellipse { + fill: var(--hd-live-bg) !important; + stroke: var(--hd-live) !important; +} +.pele .wait > rect, +.pele .wait > path, +.pele .wait > polygon, +.pele .wait > circle, +.pele .wait > ellipse { + fill: var(--hd-wait-bg) !important; + stroke: var(--hd-wait) !important; +} +.pele .ok > rect, +.pele .ok > path, +.pele .ok > polygon, +.pele .ok > circle, +.pele .ok > ellipse { + fill: var(--hd-ok-bg) !important; + stroke: var(--hd-ok) !important; +} +.pele .fail > rect, +.pele .fail > path, +.pele .fail > polygon, +.pele .fail > circle, +.pele .fail > ellipse { + fill: var(--hd-fail-bg) !important; + stroke: var(--hd-fail) !important; +} +.pele .idle > rect, +.pele .idle > path, +.pele .idle > polygon, +.pele .idle > circle, +.pele .idle > ellipse { + fill: var(--hd-idle-bg) !important; + stroke: var(--hd-idle) !important; + stroke-dasharray: 4 3; +} +.pele .live .pele-label { + fill: var(--hd-live); + font-weight: 600; +} +.pele .wait .pele-label { + fill: var(--warning-foreground, var(--hd-wait)); + font-weight: 600; +} +.pele .ok .pele-label { + fill: var(--success-foreground, var(--hd-ok)); + font-weight: 600; +} +.pele .fail .pele-label { + fill: var(--destructive-foreground, var(--hd-fail)); + font-weight: 600; +} +.pele .idle .pele-label { + fill: var(--hd-idle); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 01fa601ab666e..e53ca1f197081 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./hd-diagram-theme.css"; @custom-variant dark (&:is(.dark, .dark *)); @custom-variant light (&:not(.dark, .dark *)); @@ -1729,20 +1730,6 @@ code { --diffs-header-font-family: var(--font-sans); } -/* Pele diagrams read their colors through these, so a theme change recolors - them without re-rendering. */ -.pele { - --pele-bg: var(--background); - --pele-fg: var(--foreground); - --pele-muted: var(--muted-foreground); - --pele-line: var(--muted-foreground); - --pele-surface: var(--secondary); - --pele-surface-alt: var(--accent); - --pele-border: color-mix(in srgb, var(--foreground) 28%, transparent); - --pele-accent: var(--primary); - --pele-critical: var(--destructive); -} - /* Window drag region (frameless titlebar) */ .drag-region { -webkit-app-region: drag; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c004aa9a0c300..f42293c537f0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1131,8 +1131,8 @@ importers: specifier: ^1.7.1 version: 1.7.1(react-native-svg@15.16.0(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) pele: - specifier: ^1.8.0 - version: 1.8.0 + specifier: 1.9.0 + version: 1.9.0 react: specifier: 'catalog:' version: 19.3.0 @@ -10269,8 +10269,8 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pele@1.8.0: - resolution: {integrity: sha512-AYmHttE70cwNJVXdfPMCANGivFlmc3bGGpMG0GqH5S5vE3RgtEuLlkqbhRfe94Csv/ZEd1TUbv2dMUHpw4p1Bg==} + pele@1.9.0: + resolution: {integrity: sha512-KUmBt7P5JduVKQaP6FKfXiRgH3LBVIKsmdgPJZU7jD3ye6F+Z8fbms+OjivE7rWY09Qx+5X65VKe/9ywKFJ9Sw==} engines: {node: '>=20'} pend@1.2.0: @@ -20775,7 +20775,7 @@ snapshots: pe-library@0.4.1: {} - pele@1.8.0: {} + pele@1.9.0: {} pend@1.2.0: {}