diff --git a/components/artifacts/demo-card.tsx b/components/artifacts/demo-card.tsx index b27dfe6..574cd93 100644 --- a/components/artifacts/demo-card.tsx +++ b/components/artifacts/demo-card.tsx @@ -1,7 +1,9 @@ "use client"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { ContrastIcon, DownloadIcon } from "lucide-react"; import { ChatActionsContext } from "@/components/chat"; +import { downloadFile, slugFilename } from "@/components/artifacts/download"; import { DispatchCard } from "@/components/artifacts/dispatch-card"; import type { ArtifactPartProps, DemoResult } from "@/src/core"; @@ -106,6 +108,11 @@ function DemoFrame({ title, html }: { title: string; html: string }) { const actions = useContext(ChatActionsContext); const iframeRef = useRef(null); const [state, setState] = useState(INITIAL_DEMO_BRIDGE_STATE); + // Brightness inversion (owner, 2026-10-07): a MANUAL toggle only — no + // detection, no theme wiring. The filter rides the iframe element, which + // carries the card's white backing, so one class flips page + content + // together while hue-rotate(180deg) undoes invert's hue flip. + const [inverted, setInverted] = useState(false); const srcDoc = useMemo(() => buildDemoSrcDoc(html), [html]); useEffect(() => { @@ -123,8 +130,29 @@ function DemoFrame({ title, html }: { title: string; html: string }) {
{title}
-
- interactive demo +
+ + +
interactive demo
{state.error && ( @@ -141,8 +169,21 @@ function DemoFrame({ title, html }: { title: string; html: string }) { // but never touch the app's storage, cookies, or DOM. sandbox="allow-scripts" srcDoc={srcDoc} + // scrolling OFF (owner, 2026-10-07): the bridge auto-sizes the + // iframe to its content, so an internal scrollbar can only appear + // before the first resize lands — and a scrollable iframe TRAPS + // the wheel (session scrolling stalls over the demo). With + // scrolling disabled the wheel chains straight to the session. + scrolling="no" className="mt-2 w-full rounded-lg border bg-white" - style={{ height: state.height ?? 360 }} + // content-box: the bridge reports the CONTENT height — with the + // preflight's border-box the 2px border would eat into it and clip + // the bottom of the demo (smoke finding, 2026-10-07). + style={{ + height: state.height ?? 360, + boxSizing: "content-box", + filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined, + }} data-testid="demo-iframe" />
diff --git a/components/artifacts/download.ts b/components/artifacts/download.ts new file mode 100644 index 0000000..ad9c5ae --- /dev/null +++ b/components/artifacts/download.ts @@ -0,0 +1,32 @@ +/** + * Artifact download helpers (release polish, 2026-10-07): the tiny + * save-a-blob seam shared by the demo + figure cards, plus the filename + * slug. The slug is pure (test-pinned); downloadFile touches the DOM and + * only runs on click. + */ + +/** Slugify a demo title into a filename stem — lowercase, accents folded, + * runs of non-alphanumerics collapsed to "-", trimmed, capped at 64 + * chars. Empty/fully-symbolic titles fall back to "demo". */ +export function slugFilename(title: string): string { + const slug = title + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + return slug || "demo"; +} + +/** Save text as a downloaded file. The blob URL is revoked right after the + * click — Chromium keeps the download alive past the revoke. */ +export function downloadFile(name: string, text: string, mime: string): void { + const blob = new Blob([text], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/components/artifacts/visual-card.tsx b/components/artifacts/visual-card.tsx index df4e68d..4288520 100644 --- a/components/artifacts/visual-card.tsx +++ b/components/artifacts/visual-card.tsx @@ -1,5 +1,9 @@ "use client"; +import { useState } from "react"; +import { ContrastIcon, DownloadIcon, MaximizeIcon } from "lucide-react"; +import { useSessionMermaid } from "@/components/session-overlay"; +import { downloadFile } from "@/components/artifacts/download"; import { DispatchCard } from "@/components/artifacts/dispatch-card"; import type { ArtifactPartProps, VisualResult } from "@/src/core"; @@ -25,6 +29,20 @@ export function sanitizeSvg(svg: string): string { .replace(/(href|xlink:href)\s*=\s*(?:"\s*(?:javascript|data:text\/html)[^"]*"|'\s*(?:javascript|data:text\/html)[^']*'|(?:javascript|data:text\/html)[^\s>]*)/gi, ""); } +/** 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 ; @@ -33,6 +51,12 @@ export function VisualCard({ part }: ArtifactPartProps) { /** The figure itself — exported: the steer card renders the delivered artifact. */ export function VisualOutputCard({ output }: { output: unknown }) { const result = output as VisualResult | undefined; + const overlay = useSessionMermaid(); + // Brightness inversion (owner, 2026-10-07): a MANUAL toggle only — no + // detection, no theme wiring. Default off; a toggled figure passes its + // state into the fullscreen overlay (openFigure). + const [inverted, setInverted] = useState(false); + const natural = result && typeof result.svg === "string" ? svgNaturalWidth(result.svg) : null; if (!result || typeof result.svg !== "string") { return (
@@ -54,13 +78,58 @@ export function VisualOutputCard({ output }: { output: unknown }) { } return (
-
Figure
- {/* Scale to the display area: the svg always fills the card's width - (w-full), h-auto keeps the aspect ratio — no white strips beside - narrower figures, oversized ones shrink to fit. bg-white stays as a - neutral backing for svgs without their own background. */} +
+
Figure
+
+ {overlay && ( + + )} + + +
+
+ {/* Natural sizing (owner, 2026-10-07): a declared-width svg sits at + ITS size — the white figure area hugs it and centers (mx-auto), + max-w-full still shrinks oversized ones to fit the card. No + declared size (viewBox-only, %-width) keeps the old 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. */}
diff --git a/components/chat.tsx b/components/chat.tsx index ff85597..72006fa 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -349,7 +349,7 @@ export function Chat({ const onAbort = () => { const target = escAbortTarget({ abortAsk: abortAskRef.current !== null, - mermaidFocus: mermaidFocusRef.current !== null, + mermaidFocus: mermaidFocusRef.current !== null || figureFocusRef.current !== null, planOpen: planOpenRef.current, setupOpen: setupOpenRef.current, pending: pendingRef.current !== null, @@ -790,12 +790,21 @@ export function Chat({ // popup) mount INSIDE the session-area div — both are session-scoped, so // the drag bar stays sharp and draggable while a focus owns the area. const [mermaidFocus, setMermaidFocus] = useState(null); + // Figure focus (2026-10-07): the figure card's fullscreen button — same + // overlay shell as mermaid, but carrying the raw svg + the card's invert + // state so the fullscreen view stays faithful to the toggled card. + const [figureFocus, setFigureFocus] = useState<{ svg: string; inverted: boolean } | null>(null); // Overlay mirrors for the layered-Esc gate (owner 2026-10-04): the // lh:kbd-abort-turn handler reads these at EVENT time — declared here // beside their states, consumed below wherever the states live. const mermaidFocusRef = useRef(mermaidFocus); mermaidFocusRef.current = mermaidFocus; + // The figure focus rides the mermaid layer in the Esc gate — the same + // overlay class (session-scoped diagram focus, Esc claimed at the + // document level by the overlay itself); the window chain must stay out. + const figureFocusRef = useRef(figureFocus); + figureFocusRef.current = figureFocus; const planOpenRef = useRef(planOpen); planOpenRef.current = planOpen; const setupOpenRef = useRef(setupOpen); @@ -1137,7 +1146,12 @@ export function Chat({ openSetup: () => setSetupOpen(true), }} > - + setFigureFocus({ svg, inverted }), + }} + > {/* The session surface: the transcript column (header + scroller) and the plan-tree panel as full-height SIBLINGS — the panel spans top to @@ -1601,6 +1615,13 @@ export function Chat({ {mermaidFocus !== null && ( setMermaidFocus(null)} /> )} + {figureFocus !== null && ( + setFigureFocus(null)} + /> + )} {planOpen && setPlanOpen(false)} />} setAbortAsk(null)} onConfirm={confirmAbort} busy={busy} />
diff --git a/components/session-overlay.tsx b/components/session-overlay.tsx index ebe52c7..5ed4358 100644 --- a/components/session-overlay.tsx +++ b/components/session-overlay.tsx @@ -27,10 +27,14 @@ import { Hint } from "@/components/ui/hint"; */ /** The expand seam: md-blocks' mermaid hover button calls openMermaid with - * the chart source; the chat page (which provides this context) opens the - * overlay. Null when no chat page is mounted (sidebar previews etc.) — the - * expand button simply doesn't render there. */ -export const SessionMermaidContext = createContext<{ openMermaid: (chart: string) => void } | null>(null); + * the chart source; the figure card's fullscreen button calls openFigure + * with the raw svg (+ its invert state). The chat page (which provides + * this context) opens the overlay. Null when no chat page is mounted + * (sidebar previews etc.) — the buttons simply don't render there. */ +export const SessionMermaidContext = createContext<{ + openMermaid: (chart: string) => void; + openFigure: (svg: string, inverted: boolean) => void; +} | null>(null); export function useSessionMermaid() { return useContext(SessionMermaidContext); @@ -38,7 +42,9 @@ export function useSessionMermaid() { /** A chart svg's natural size — mermaid always emits a viewBox; this is the * extent at zoom 1 (1 svg unit = 1 px), like the plan diagram's natural - * size. The fallback covers a degenerate no-viewBox svg. Pure. */ + * size. Figure svgs (2026-10-07) may declare width/height instead — that + * pair is the fallback. The final fallback covers a degenerate svg with + * neither. Pure. */ export function chartSize(svg: string): { w: number; h: number } { const m = /]*\bviewBox="([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)"/.exec(svg); if (m) { @@ -46,19 +52,48 @@ export function chartSize(svg: string): { w: number; h: number } { const h = Number.parseFloat(m[4]!); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return { w, h }; } + const attr = (name: string): number | null => { + const a = new RegExp(`]*?\\b${name}\\s*=\\s*["'](\\d+(?:\\.\d+)?)(?:px)?["']`, "i").exec(svg); + if (!a) return null; + const n = Number.parseFloat(a[1]!); + return Number.isFinite(n) && n > 0 ? n : null; + }; + const w = attr("width"); + const h = attr("height"); + if (w !== null && h !== null) return { w, h }; return { w: 600, h: 400 }; } -/** The mermaid focus: the chart at natural size inside a pan/zoom viewport +/** The diagram focus: the chart at natural size inside a pan/zoom viewport * (the plan popup's fit-floor semantics — fitGraphView/zoomedGraphView in * pan-zoom.ts), Esc/X close. Renders its own copy of the chart (mermaid - * ids are per-render), not the injected inline svg. */ -export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: () => void }) { - // ── chart render (own instance, own ids) ── - const [svg, setSvg] = useState(null); + * ids are per-render), not the injected inline svg. FIGURE MODE + * (2026-10-07): pass a raw `svg` instead of a mermaid `chart` — the same + * shell then focuses a figure card's svg on a white stage, with the + * caller's invert state carried over (the filter rides the white stage, + * exactly like the in-card holder). */ +export function MermaidOverlay({ + chart, + svg: rawSvg, + inverted = false, + onClose, +}: { + chart?: string; + svg?: string; + inverted?: boolean; + onClose: () => void; +}) { + // ── chart render (own instance, own ids); figure mode uses the raw svg ── + const [svg, setSvg] = useState(rawSvg ?? null); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); useEffect(() => { + if (rawSvg !== undefined) { + setSvg(rawSvg); + setError(null); + return; + } + if (chart === undefined) return; let cancelled = false; setSvg(null); setError(null); @@ -72,7 +107,7 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: () return () => { cancelled = true; }; - }, [chart, attempt]); + }, [rawSvg, chart, attempt]); // Esc closes — claimed at the document level (THE ONE-ESC LAW, owner // keyboard round 2026-10-06): one Esc, one action; the window chain @@ -213,7 +248,7 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: () > {error ? (
- setAttempt((a) => a + 1)} /> + setAttempt((a) => a + 1)} />
) : svg ? ( <> @@ -237,10 +272,17 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: () {/* data-lh-overlay-chart: globals.css pins the svg to the wrapper's width (kills mermaid's inline max-width cap) so the wrapper IS the natural-size box the fit math - centers. */} + centers. FIGURE MODE: a white stage (the figure's + backing law — models draw for a white page) with the + caller's invert filter riding it, exactly like the + in-card holder. */}
diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index 3007485..6181b77 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -39,7 +39,9 @@ import { INITIAL_DEMO_BRIDGE_STATE, reduceDemoBridgeState, } from "../components/artifacts/demo-card.tsx"; -import { sanitizeSvg, VisualCard } from "../components/artifacts/visual-card.tsx"; +import { sanitizeSvg, svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx"; +import { slugFilename } from "../components/artifacts/download.ts"; +import { chartSize } from "../components/session-overlay.tsx"; import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx"; import { DispatchCard } from "../components/artifacts/dispatch-card.tsx"; import { JobCard, PdfJobCard } from "../components/artifacts/job-card.tsx"; @@ -378,7 +380,10 @@ describe("steer card rendering (the delivered artifact)", () => { const html = renderToStaticMarkup(); assert.ok(html.includes("circle"), "svg content rendered inline"); assert.match(html, /Figure/); - assert.match(html, /svg\]:w-full/, "svg scales to the display area's full width"); + // Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the + // holder hugs + centers rather than stretching full-width. + assert.match(html, /w-fit/, "holder hugs the declared size"); + assert.match(html, /svg\]:max-w-full/, "svg shrinks to fit only when oversized"); assert.ok(!html.includes("Looking at the render"), "model-facing notes never render"); }); @@ -475,6 +480,52 @@ describe("steer card rendering (the delivered artifact)", () => { }); +describe("artifact chrome — scrollbar, invert toggles, downloads (release polish 2026-10-07)", () => { + it("demo iframe: scrolling OFF (the bridge auto-sizes; a scrollable iframe traps the wheel) and the invert + download buttons render", () => { + const steer = buildSubagentSteer( + { id: "jd3", kind: "demo", toolCallId: "cd3" }, + { title: "Wave interference", html: DEMO_HTML, tested: true }, + ); + const html = renderToStaticMarkup(); + assert.match(html, /scrolling="no"/, "no internal scrollbar — the wheel chains to the session"); + assert.match(html, /data-testid="demo-invert"/); + assert.match(html, /data-testid="demo-download"/); + assert.ok(!html.includes("hue-rotate"), "inversion is OFF by default — a manual toggle only"); + }); + + it("figure card: invert + download buttons; NO fullscreen button without a chat page (the context is null there)", () => { + const svg = ''; + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="visual-invert"/); + assert.match(html, /data-testid="visual-download"/); + assert.ok(!html.includes('data-testid="visual-open"'), "fullscreen needs the session overlay context"); + assert.ok(!html.includes("hue-rotate"), "inversion OFF by default"); + }); + + it("svgNaturalWidth: declared px sizes parse; %, auto, and absent fall back to null", () => { + assert.equal(svgNaturalWidth(''), 200); + assert.equal(svgNaturalWidth(""), 320); + assert.equal(svgNaturalWidth(''), null, "% is no natural size"); + assert.equal(svgNaturalWidth(''), null); + assert.equal(svgNaturalWidth(''), null, "viewBox-only → full-width law"); + assert.equal(svgNaturalWidth(''), null, "degenerate zero is no size"); + }); + + it("slugFilename: titles slug to filename stems; symbol-only falls back", () => { + assert.equal(slugFilename("Damped oscillator"), "damped-oscillator"); + assert.equal(slugFilename("Ünïcode — Tïtle!"), "unicode-title"); + assert.equal(slugFilename("???"), "demo"); + }); + + it("chartSize: width/height attrs are the fallback when no viewBox (figure fullscreen fit math)", () => { + assert.deepEqual(chartSize(''), { w: 320, h: 240 }); + assert.deepEqual(chartSize(''), { + w: 100, + h: 50, + }, "viewBox always wins"); + }); +}); + describe("sanitizeSvg", () => { it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => { const dirty = ''; diff --git a/tests/render-md-blocks.test.tsx b/tests/render-md-blocks.test.tsx index 949be78..8e57f5f 100644 --- a/tests/render-md-blocks.test.tsx +++ b/tests/render-md-blocks.test.tsx @@ -160,7 +160,12 @@ describe("mermaid expand — the context seam", () => { // owner is chat.tsx — geometry covered by the tests above. const opened: string[] = []; const html = renderToStaticMarkup( - void opened.push(chart) }}> + void opened.push(chart), + openFigure: () => {}, + }} + >
, );