chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:43 +08:00
commit e4dcfc49aa
1668 changed files with 324490 additions and 0 deletions
@@ -0,0 +1,556 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { Code2, Copy, Check, ExternalLink, Maximize2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Mermaid } from "@/components/Mermaid";
import { prepareIframeHtml } from "@/lib/iframe-html";
import { isManimResult, type VisualizeResult } from "@/lib/visualize-types";
import "./svg-theme.css";
const MathAnimatorViewer = dynamic(
() => import("@/components/math-animator/MathAnimatorViewer"),
{ ssr: false },
);
function stripCodeFence(source: string): string {
const trimmed = source.trim();
const fenced = trimmed.match(
/^```(?:json|javascript|js)?\s*([\s\S]*?)\s*```$/i,
);
return fenced ? fenced[1].trim() : trimmed;
}
function parseChartConfig(source: string): unknown {
const raw = stripCodeFence(source);
try {
return JSON.parse(raw);
} catch {
const jsonish = raw
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, value: string) =>
JSON.stringify(value.replace(/\\'/g, "'")),
)
.replace(/,\s*([}\]])/g, "$1");
return JSON.parse(jsonish);
}
}
function ChartJsRenderer({ config }: { config: string }) {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<unknown>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function render() {
if (!canvasRef.current) return;
try {
const ChartModule = await import("chart.js/auto");
const Chart = ChartModule.default;
if (chartRef.current) {
(chartRef.current as InstanceType<typeof Chart>).destroy();
chartRef.current = null;
}
const parsedConfig = parseChartConfig(config) as ConstructorParameters<
typeof Chart
>[1];
if (cancelled) return;
chartRef.current = new Chart(canvasRef.current, parsedConfig);
setError(null);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : t("Failed to render chart"),
);
}
}
}
void render();
return () => {
cancelled = true;
if (chartRef.current) {
(chartRef.current as { destroy: () => void }).destroy();
chartRef.current = null;
}
};
}, [config, t]);
if (error) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/60 dark:bg-red-950/30">
<p className="text-sm font-medium text-red-600 dark:text-red-400">
{t("Chart rendering error")}
</p>
<pre className="mt-2 whitespace-pre-wrap text-xs text-red-500">
{error}
</pre>
</div>
);
}
return (
<div className="dt-chart-wrap relative w-full">
<canvas ref={canvasRef} />
</div>
);
}
function HtmlRenderer({ html }: { html: string }) {
const { t } = useTranslation();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(560);
const prepared = useMemo(() => prepareIframeHtml(html || ""), [html]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
iframe.srcdoc = prepared;
}, [prepared]);
// Listen for the iframe bridge: a sendPrompt() call (mirror into the composer
// via the shared window event) or a height report (grow to fit, no clipping).
useEffect(() => {
const onMessage = (e: MessageEvent) => {
const iframe = iframeRef.current;
if (!iframe || e.source !== iframe.contentWindow) return;
const data = e.data as { type?: string; text?: string; height?: number };
if (!data || typeof data !== "object") return;
if (data.type === "dt:visualize-prompt" && data.text) {
window.dispatchEvent(
new CustomEvent("dt:visualize-prompt", { detail: data.text }),
);
} else if (
data.type === "dt:visualize-height" &&
typeof data.height === "number"
) {
setHeight(Math.min(2400, Math.max(240, Math.ceil(data.height) + 8)));
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
const handleOpenInNewTab = () => {
try {
const contentUrl = URL.createObjectURL(
new Blob([prepared], { type: "text/html" }),
);
const wrapper = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Visualization</title><style>html,body,iframe{height:100%;width:100%;margin:0;border:0;}</style></head><body><iframe sandbox="allow-scripts" src="${contentUrl}"></iframe></body></html>`;
const url = URL.createObjectURL(
new Blob([wrapper], { type: "text/html" }),
);
window.open(url, "_blank", "noopener,noreferrer");
setTimeout(() => {
URL.revokeObjectURL(url);
URL.revokeObjectURL(contentUrl);
}, 60_000);
} catch {
/* no-op */
}
};
return (
<div className="relative w-full">
<button
type="button"
onClick={handleOpenInNewTab}
className="absolute right-2 top-2 z-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--background)]/90 px-2 py-1 text-[10px] font-medium text-[var(--muted-foreground)] backdrop-blur transition-colors hover:text-[var(--foreground)]"
title={t("Open in new tab")}
>
<ExternalLink size={10} strokeWidth={1.8} />
{t("Open")}
</button>
<iframe
ref={iframeRef}
title={t("HTML visualization")}
sandbox="allow-scripts"
className="w-full rounded-lg border border-[var(--border)] bg-[var(--card)]"
style={{ minHeight: 320, height }}
/>
</div>
);
}
// Per-page sequence used to scope SVG ids (see the scoping block below).
let svgScopeSeq = 0;
// Sanitize an SVG string for safe inline rendering: parse as XML, strip
// script/foreign-object/event-handler vectors, then reserialize. SVGs come from
// our own LLM and already pass a backend well-formedness check, but we still
// defend against prompt-injected <script>/on* handlers. Kept dependency-free
// (same sanitize→string contract as DOMPurify, so it can be swapped later).
function sanitizeSvg(raw: string): string {
const trimmed = raw.trim();
if (typeof DOMParser === "undefined") return "";
const doc = new DOMParser().parseFromString(trimmed, "image/svg+xml");
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() !== "svg") return "";
if (root.getElementsByTagName("parsererror").length > 0) return "";
const STRIP = [
"script",
"foreignObject",
"iframe",
"object",
"embed",
"audio",
"video",
"handler",
];
root.querySelectorAll(STRIP.join(",")).forEach((n) => n.remove());
const walk = (el: Element) => {
const tag = el.nodeName.toLowerCase();
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value.replace(/\s+/g, "").toLowerCase();
if (name.startsWith("on")) {
el.removeAttribute(attr.name);
} else if (
(name === "href" || name === "xlink:href") &&
(val.startsWith("javascript:") ||
(val.startsWith("data:") && !val.startsWith("data:image/")))
) {
el.removeAttribute(attr.name);
} else if (name === "style" && val.includes("javascript:")) {
el.removeAttribute(attr.name);
} else if (
(tag === "set" || tag === "animate") &&
name === "attributename" &&
val.startsWith("on")
) {
// <set attributeName="onclick" .../> can inject a handler — drop it.
el.remove();
return;
}
}
Array.from(el.children).forEach((child) => walk(child));
};
walk(root);
// Scope ids so multiple inlined SVGs on one page don't collide: marker /
// clipPath / gradient defs are referenced via url(#id) or href="#id". A bare
// <img> kept each SVG in its own document; inline DOM shares one namespace,
// so without this the 2nd+ figure's arrows/gradients break.
const ids = new Set<string>();
root.querySelectorAll("[id]").forEach((el) => {
const id = el.getAttribute("id");
if (id) ids.add(id);
});
if (ids.size) {
const prefix = `dtsvg${svgScopeSeq++}-`;
const rescope = (el: Element) => {
const ownId = el.getAttribute("id");
if (ownId && ids.has(ownId)) el.setAttribute("id", prefix + ownId);
for (const attr of Array.from(el.attributes)) {
const lname = attr.name.toLowerCase();
let v = attr.value.replace(
/url\(\s*(['"]?)#([^)'"\s]+)\1\s*\)/g,
(m, q, id) => (ids.has(id) ? `url(${q}#${prefix}${id}${q})` : m),
);
if (
(lname === "href" || lname.endsWith(":href")) &&
v.charAt(0) === "#" &&
ids.has(v.slice(1))
) {
v = `#${prefix}${v.slice(1)}`;
} else if (
lname === "aria-labelledby" ||
lname === "aria-describedby"
) {
v = v
.split(/\s+/)
.map((token) => (ids.has(token) ? prefix + token : token))
.join(" ");
}
if (v !== attr.value) el.setAttribute(attr.name, v);
}
Array.from(el.children).forEach((child) => rescope(child));
};
rescope(root);
}
return root.outerHTML;
}
function SvgFigure({ svg }: { svg: string }) {
const { t } = useTranslation();
const trimmed = svg.trim();
const looksSvg = trimmed.startsWith("<svg") || trimmed.startsWith("<?xml");
// Client-only component (mounted via dynamic ssr:false), so DOMParser is
// always available and there's no SSR/hydration concern — sanitize in useMemo.
const safe = useMemo(
() => (looksSvg ? sanitizeSvg(trimmed) : ""),
[looksSvg, trimmed],
);
if (!looksSvg || !safe) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/60 dark:bg-red-950/30">
<p className="text-sm font-medium text-red-600 dark:text-red-400">
{t("SVG rendering error")}
</p>
<pre className="mt-2 whitespace-pre-wrap text-xs text-red-500">
{looksSvg
? t("SVG could not be safely rendered")
: t("Invalid SVG: does not start with <svg")}
</pre>
</div>
);
}
// Inline (not <img>) so host CSS and the SVG's own <style> apply. Clicking a
// node carrying data-prompt drops a follow-up question into the composer (via
// a window event the chat page listens for) — prefilled, not auto-sent.
const onSvgClick = (e: MouseEvent<HTMLDivElement>) => {
const node = (e.target as Element).closest?.("[data-prompt]");
const prompt = node?.getAttribute("data-prompt")?.trim();
if (prompt) {
window.dispatchEvent(
new CustomEvent("dt:visualize-prompt", { detail: prompt }),
);
}
};
return (
<div
className="dt-svg-root flex justify-center overflow-x-auto"
onClick={onSvgClick}
dangerouslySetInnerHTML={{ __html: safe }}
/>
);
}
// A model occasionally emits several <svg> blocks in one response, and the
// backend extractor concatenates everything from the first <svg to the last
// </svg> — so one code.content can hold multiple svgs with colliding ids
// (marker/gradient/clipPath). Split them and render each as its own figure,
// independently sanitized and id-scoped, instead of one malformed multi-root
// document where only the last svg's defs win.
function splitSvgBlocks(raw: string): string[] {
const blocks = raw.match(/<svg[\s\S]*?<\/svg>/gi);
return blocks && blocks.length ? blocks : [raw.trim()];
}
function SvgRenderer({ svg }: { svg: string }) {
const blocks = useMemo(() => splitSvgBlocks(svg.trim()), [svg]);
if (blocks.length <= 1) {
return <SvgFigure svg={blocks[0] ?? svg} />;
}
return (
<div className="flex w-full flex-col gap-4">
{blocks.map((block, i) => (
<SvgFigure key={i} svg={block} />
))}
</div>
);
}
type TextResult = Extract<
VisualizeResult,
{ render_type: "svg" | "chartjs" | "mermaid" | "html" }
>;
function renderTextVisualization(result: TextResult) {
if (result.render_type === "svg") {
return <SvgRenderer svg={result.code.content} />;
}
if (result.render_type === "mermaid") {
return <Mermaid chart={result.code.content} />;
}
if (result.render_type === "html") {
return <HtmlRenderer html={result.code.content} />;
}
return <ChartJsRenderer config={result.code.content} />;
}
export default function VisualizationViewer({
result,
}: {
result: VisualizeResult;
}) {
const { t } = useTranslation();
// All hooks must run unconditionally before any early return — React
// requires a stable hook order across renders. The text-path body below
// is the only consumer of these states; the manim path returns earlier
// and ignores them.
const [showCode, setShowCode] = useState(false);
const [copied, setCopied] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!fullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [fullscreen]);
if (isManimResult(result)) {
return <MathAnimatorViewer result={result.manim} />;
}
// TypeScript narrows ``result`` to the text-only variant from here on.
// HTML iframe already provides its own "Open in new tab" affordance; the
// sandboxed iframe also doesn't behave well inside a re-rendered modal.
const supportsFullscreen = result.render_type !== "html";
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(result.code.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* clipboard API may be unavailable */
}
};
return (
<div className="space-y-3">
{/* Visualization area */}
<div
className={`relative ${
result.render_type === "html"
? "overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--background)]"
: "overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--background)] p-4"
}`}
>
{supportsFullscreen && (
<button
type="button"
onClick={() => setFullscreen(true)}
title={t("Fullscreen")}
className="absolute right-2 top-2 z-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--background)]/90 px-2 py-1 text-[10px] font-medium text-[var(--muted-foreground)] backdrop-blur transition-colors hover:text-[var(--foreground)]"
>
<Maximize2 size={10} strokeWidth={1.8} />
{t("Fullscreen")}
</button>
)}
{renderTextVisualization(result)}
</div>
{/* Toolbar */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowCode((prev) => !prev)}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
>
<Code2 size={12} strokeWidth={1.8} />
{showCode ? t("Hide code") : t("Show code")}
</button>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
>
{copied ? (
<Check size={12} strokeWidth={1.8} />
) : (
<Copy size={12} strokeWidth={1.8} />
)}
{copied ? t("Copied") : t("Copy code")}
</button>
<span className="ml-auto text-[10px] uppercase tracking-wider text-[var(--muted-foreground)]/50">
{result.render_type === "svg"
? "SVG"
: result.render_type === "mermaid"
? `Mermaid · ${result.analysis.chart_type || "diagram"}`
: result.render_type === "html"
? `HTML · ${result.analysis.chart_type || "interactive"}`
: `Chart.js · ${result.analysis.chart_type || "chart"}`}
</span>
</div>
{/* Code panel — matches the always-dark .md-code-block style used by the
markdown renderers so a "Show code" toggle inside a chart message
looks identical to a fenced code block in the assistant response. */}
{showCode && (
<div className="md-code-block overflow-hidden rounded-xl border border-[var(--border)] bg-[#1f2937]">
<div className="border-b border-white/10 px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-[#9ca3af]">
{result.code.language}
</div>
<pre className="max-h-80 overflow-auto p-4 text-[13px] leading-relaxed text-[#e5e7eb]">
<code>{result.code.content}</code>
</pre>
</div>
)}
{/* Review notes */}
{result.review.changed && result.review.review_notes && (
<p className="text-[11px] text-[var(--muted-foreground)]">
{t("Review")}: {result.review.review_notes}
</p>
)}
{/* Fullscreen overlay — rendered via portal: the message bubble sits
inside transformed/overflow ancestors (streaming animations, chat
scroll root), which break position:fixed and put the composer above
the overlay. document.body has neither problem. */}
{fullscreen &&
supportsFullscreen &&
createPortal(
<div
className="fixed inset-0 z-[120] flex flex-col bg-black/85 p-4 backdrop-blur-sm"
onClick={() => setFullscreen(false)}
>
<div className="mb-2 flex shrink-0 items-center justify-between text-white">
<div className="text-xs uppercase tracking-wider opacity-80">
{result.render_type === "svg"
? "SVG"
: result.render_type === "mermaid"
? `Mermaid · ${result.analysis.chart_type || "diagram"}`
: `Chart.js · ${result.analysis.chart_type || "chart"}`}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setFullscreen(false);
}}
title={t("Close")}
className="inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 py-1.5 text-[11px] font-medium text-white transition-colors hover:bg-white/20"
>
<X size={12} strokeWidth={1.8} />
{t("Close")}
</button>
</div>
{/* m-auto (not items-center/justify-center) so oversized content
stays scrollable from its start edge instead of clipping. */}
<div
className="flex flex-1 overflow-auto rounded-xl bg-[var(--card)] p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="dt-viz-fullscreen m-auto w-full max-w-[1600px]">
{renderTextVisualization(result)}
</div>
</div>
</div>,
document.body,
)}
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { memo } from "react";
import { useTranslation } from "react-i18next";
import {
summarizeVisualizeConfig,
type VisualizeFormConfig,
} from "@/lib/visualize-types";
import {
CollapsibleConfigSection,
Field,
INPUT_CLS,
} from "@/components/chat/home/composer-field";
interface VisualizeConfigPanelProps {
value: VisualizeFormConfig;
onChange: (next: VisualizeFormConfig) => void;
/**
* When provided, the panel is wrapped in a `CollapsibleConfigSection`.
* Omit both to render bare for the chat Activity panel.
*/
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
export default memo(function VisualizeConfigPanel({
value,
onChange,
collapsed,
onToggleCollapsed,
}: VisualizeConfigPanelProps) {
const { t } = useTranslation();
const update = <K extends keyof VisualizeFormConfig>(
key: K,
val: VisualizeFormConfig[K],
) => onChange({ ...value, [key]: val });
// Manim modes need extra knobs (render quality, style hint) — match what
// the legacy Animator panel exposed so users don't lose granularity when
// they pick "Animation" / "Storyboard" here.
const isManim =
value.render_mode === "manim_video" || value.render_mode === "manim_image";
const body = (
<>
<Field label={t("Render Mode")} width="w-[140px]">
<select
value={value.render_mode}
onChange={(e) =>
update(
"render_mode",
e.target.value as VisualizeFormConfig["render_mode"],
)
}
className={`${INPUT_CLS} w-full`}
>
<option value="auto">{t("Auto")}</option>
<option value="chartjs">{t("Chart.js")}</option>
<option value="svg">{t("SVG")}</option>
<option value="mermaid">{t("Mermaid")}</option>
<option value="html">{t("HTML")}</option>
<option value="manim_video">{t("Animation")}</option>
<option value="manim_image">{t("Storyboard")}</option>
</select>
</Field>
{isManim ? (
<>
<Field label={t("Quality")} width="w-[100px]">
<select
value={value.quality}
onChange={(e) =>
update(
"quality",
e.target.value as VisualizeFormConfig["quality"],
)
}
className={`${INPUT_CLS} w-full`}
>
<option value="low">{t("Low")}</option>
<option value="medium">{t("Medium")}</option>
<option value="high">{t("High")}</option>
</select>
</Field>
<Field label={t("Style Hint")} width="min-w-[160px] flex-1">
<input
type="text"
value={value.style_hint}
onChange={(e) => update("style_hint", e.target.value)}
placeholder={t("Style, pacing, color...")}
className={`${INPUT_CLS} w-full`}
/>
</Field>
</>
) : null}
</>
);
if (collapsed === undefined) {
return (
<div className="flex flex-wrap items-end gap-x-3 gap-y-2 px-3.5 py-2.5">
{body}
</div>
);
}
return (
<CollapsibleConfigSection
collapsed={collapsed}
summary={summarizeVisualizeConfig(value, t)}
onToggleCollapsed={onToggleCollapsed ?? (() => undefined)}
bodyClassName="flex flex-wrap items-end gap-x-3 gap-y-2 px-3.5 pb-2.5"
>
{body}
</CollapsibleConfigSection>
);
});
+245
View File
@@ -0,0 +1,245 @@
/* Theme for inlined visualization SVGs (rendered under .dt-svg-root).
The codegen prompt (rules_svg) emits these pre-built classes instead of
hard-coded fills, so figures follow the app's light/dark theme and font:
text: .t (14px), .th (14px/500), .ts (12px muted)
neutral: .box (container), .arr (arrow), .leader (dashed leader)
interactive: .node (hover), [data-prompt] (clickable)
color ramps: .c-{gray,blue,teal,coral,pink,purple,green,amber,red}
Ramp values are Claude's 9-ramp palette. Light = 50 fill / 600 stroke /
800 text; dark = 800 fill / 200 stroke / 100 text — switched via .dark. */
.dt-svg-root svg {
display: block;
width: 100%;
height: auto;
}
/* Chart.js wrapper: keep inline charts compact. The canvas itself must
never be CSS-clamped — Chart.js owns the canvas size, and a stylesheet
max-height would squish the bitmap non-uniformly. */
.dt-chart-wrap {
max-height: 480px;
}
/* ---- Fullscreen overlay (.dt-viz-fullscreen wraps the figure) ----
Fit every render type to the viewport in BOTH dimensions. */
/* SVG (ours + mermaid's): clamp height; the default preserveAspectRatio
(xMidYMid meet) letterboxes the content inside the clamped box, so
nothing distorts. Width-only scaling would overflow the screen. */
.dt-viz-fullscreen svg {
max-height: calc(100vh - 140px);
}
/* Chart.js: cap the WIDTH so the chart's own aspect-ratio scaling (default
2:1) lands within the viewport height, instead of clamping the canvas. */
.dt-viz-fullscreen .dt-chart-wrap {
max-height: none;
max-width: min(100%, calc((100vh - 140px) * 2));
margin-inline: auto;
}
/* Use the app sans font (Geist), overriding any font-family on the SVG itself
and the serif (Lora) inherited from a surrounding .prose container. */
.dt-svg-root,
.dt-svg-root text {
font-family: var(
--font-sans,
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
sans-serif
);
}
/* Vertical centering is guaranteed by the host so the model never has to
emit (and possibly misspell) dominant-baseline itself: every text is
centered on its y, and the codegen prompt instructs y = box/row center. */
.dt-svg-root text {
dominant-baseline: central;
}
/* Text roles */
.dt-svg-root text.t,
.dt-svg-root text.th {
font-size: 14px;
fill: var(--foreground);
}
.dt-svg-root text.th {
font-weight: 500;
}
.dt-svg-root text.ts {
font-size: 12px;
fill: var(--muted-foreground);
}
/* Neutral container + connectors */
.dt-svg-root .box {
fill: var(--secondary);
stroke: var(--border);
stroke-width: 0.5;
}
.dt-svg-root .arr {
stroke: var(--muted-foreground);
stroke-width: 1.5;
fill: none;
}
.dt-svg-root .leader {
stroke: var(--muted-foreground);
stroke-width: 0.5;
stroke-dasharray: 3 3;
fill: none;
}
/* Interactive affordances */
.dt-svg-root .node,
.dt-svg-root [data-prompt] {
cursor: pointer;
}
.dt-svg-root .node {
transition: opacity 0.15s ease;
}
.dt-svg-root .node:hover,
.dt-svg-root [data-prompt]:hover {
opacity: 0.82;
}
/* Color ramps — each publishes --fill / --stroke / --text (light defaults). */
.dt-svg-root .c-gray {
--fill: #f1efe8;
--stroke: #5f5e5a;
--text: #2c2c2a;
}
.dt-svg-root .c-blue {
--fill: #e6f1fb;
--stroke: #185fa5;
--text: #0c447c;
}
.dt-svg-root .c-teal {
--fill: #e1f5ee;
--stroke: #0f6e56;
--text: #085041;
}
.dt-svg-root .c-coral {
--fill: #faece7;
--stroke: #993c1d;
--text: #712b13;
}
.dt-svg-root .c-pink {
--fill: #fbeaf0;
--stroke: #993556;
--text: #72243e;
}
.dt-svg-root .c-purple {
--fill: #eeedfe;
--stroke: #534ab7;
--text: #3c3489;
}
.dt-svg-root .c-green {
--fill: #eaf3de;
--stroke: #3b6d11;
--text: #27500a;
}
.dt-svg-root .c-amber {
--fill: #faeeda;
--stroke: #854f0b;
--text: #633806;
}
.dt-svg-root .c-red {
--fill: #fcebeb;
--stroke: #a32d2d;
--text: #791f1f;
}
/* Dark mode: flip to 800 fill / 200 stroke / 100 text. */
.dark .dt-svg-root .c-gray {
--fill: #444441;
--stroke: #b4b2a9;
--text: #d3d1c7;
}
.dark .dt-svg-root .c-blue {
--fill: #0c447c;
--stroke: #85b7eb;
--text: #b5d4f4;
}
.dark .dt-svg-root .c-teal {
--fill: #085041;
--stroke: #5dcaa5;
--text: #9fe1cb;
}
.dark .dt-svg-root .c-coral {
--fill: #712b13;
--stroke: #f0997b;
--text: #f5c4b3;
}
.dark .dt-svg-root .c-pink {
--fill: #72243e;
--stroke: #ed93b1;
--text: #f4c0d1;
}
.dark .dt-svg-root .c-purple {
--fill: #3c3489;
--stroke: #afa9ec;
--text: #cecbf6;
}
.dark .dt-svg-root .c-green {
--fill: #27500a;
--stroke: #97c459;
--text: #c0dd97;
}
.dark .dt-svg-root .c-amber {
--fill: #633806;
--stroke: #ef9f27;
--text: #fac775;
}
.dark .dt-svg-root .c-red {
--fill: #791f1f;
--stroke: #f09595;
--text: #f7c1c1;
}
/* Apply ramp vars: shapes take fill+stroke, descendant text takes ramp text. */
.dt-svg-root
:is(
.c-gray,
.c-blue,
.c-teal,
.c-coral,
.c-pink,
.c-purple,
.c-green,
.c-amber,
.c-red
)
> :is(rect, circle, ellipse, polygon),
.dt-svg-root
:is(rect, circle, ellipse, polygon):is(
.c-gray,
.c-blue,
.c-teal,
.c-coral,
.c-pink,
.c-purple,
.c-green,
.c-amber,
.c-red
) {
fill: var(--fill);
stroke: var(--stroke);
}
.dt-svg-root
:is(
.c-gray,
.c-blue,
.c-teal,
.c-coral,
.c-pink,
.c-purple,
.c-green,
.c-amber,
.c-red
)
text {
fill: var(--text);
}