"use client"; import { useI18n } from "@/components/i18n"; import { useContext, useState } from "react"; import { ChatActionsContext } from "@/components/chat"; import { DownloadIcon, MaximizeIcon } from "lucide-react"; import { InvertDiscIcon } from "@/components/artifacts/invert-disc"; import { Toggle } from "@/components/settings/controls"; import { useSessionMermaid } from "@/components/session-overlay"; import { downloadFile } from "@/components/artifacts/download"; import { DispatchCard } from "@/components/artifacts/dispatch-card"; import { SvgImage } from "@/components/artifacts/svg-image"; import type { ArtifactPartProps, VisualResult } from "@/src/core"; /** * Visual artifact card (W6 → ack protocol): the tool part's output is the * dispatch ack, so the tool-call box is the DispatchCard (live status via * the chat view's jobs feed). The SVG itself renders from the steer message * (sub-agent result delivery) via VisualOutputCard. * * Both display modes use an SVG image document: generated markup and its * styles never join the app's DOM. Downloads still contain the original SVG. */ /** The svg root's DECLARED width in px, when there is one — the natural * size the card displays at instead of stretching to full width (owner, * 2026-10-07). %, em, "auto", or a missing attribute → null (the caller * falls back to full-width). Pure. */ export function svgNaturalWidth(svg: string): number | null { const m = /]*?\bwidth\s*=\s*["']([^"']+)["'][^>]*>/i.exec(svg); if (!m) return null; const value = m[1]!.trim(); const px = /^(\d+(?:\.\d+)?)(?:px)?$/i.exec(value); if (!px) return null; const n = Number.parseFloat(px[1]!); return Number.isFinite(n) && n > 0 ? n : null; } export function VisualCard({ part }: ArtifactPartProps) { if (part.state !== "output-available") return null; return ; } /** The figure itself — exported: the steer card renders the delivered artifact. */ export function VisualOutputCard({ output, messageId, initialInverted = false, }: { output: unknown; messageId?: string; initialInverted?: boolean; }) { const { t } = useI18n(); const result = output as VisualResult | undefined; const overlay = useSessionMermaid(); const actions = useContext(ChatActionsContext); // Brightness inversion (owner, 2026-10-07): the switch state PERSISTS on // the steer record (details.ui.inverted) — seeded here, written through // the silent artifact-ui route on toggle; without a chat page (tests, // previews) it stays local-only. The toggled state also feeds the // fullscreen overlay (openFigure). const [inverted, setInverted] = useState(initialInverted); const toggleInverted = (next: boolean) => { setInverted(next); if (!actions?.chatId || !messageId) return; void fetch(`/api/chats/${actions.chatId}/artifact-ui`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input: { messageId, inverted: next } }), }).catch(() => {}); }; const natural = result && typeof result.svg === "string" ? svgNaturalWidth(result.svg) : null; if (!result || typeof result.svg !== "string") { return (
{t("Figure completed, but the result could not be displayed.")}
); } const failed = result.svg.length === 0; if (failed) { return (
{t("Figure")}{t("failed")}

{result.notes ?? t("The figure could not be drawn.")}

); } return ( /* NO CARD CHROME (owner round, 2026-10-07): the figure is document material — no border, no padding box. Image first, then the CAPTION ROW below: "Figure" left, the controls right, both edges following the IMAGE's own width (the wrapper carries the natural-sizing law, so the row hugs a centered narrow figure, not the column). */
{/* Natural sizing (owner, 2026-10-07): a declared-width svg sits at ITS size; max-w-full still shrinks oversized ones to fit. No declared size (viewBox-only, %-width) keeps the full-width law. The invert filter rides the bg-white holder, so the backing flips white→black together with the strokes; transparent areas ARE the backing. */}
{t("Figure")}
{/* The invert affordance (owner round, 2026-10-07): the disc IS the half-filled contrast circle; the tooltip names it. */} } /> {overlay && ( )}
); }