ui: figures join the document — no card chrome, caption row below
Owner round: the figure card loses its container entirely (no border, no padding box) - the figure is document material. Image first, then the CAPTION ROW below it: 'Figure' label aligned to the image's left edge, the controls (invert toggle, download, fullscreen) on the same row aligned to its right edge. The wrapper carries the natural-sizing law, so for a declared-width svg the row hugs the centered figure's own edges, not the column's. The invert affordance: the house Toggle gains an optional icon disc - the figure's toggle renders lucide's Contrast (the half-filled circle) AS the sliding disc, plus a native 'Invert colors' tooltip; every plain toggle elsewhere is unchanged. Interactive demo cards keep their boxed stage (owner ruling: an interactive surface reads as its own stage).
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { ChatActionsContext } from "@/components/chat";
|
||||
import { DownloadIcon, MaximizeIcon } from "lucide-react";
|
||||
import { ContrastIcon, DownloadIcon, MaximizeIcon } from "lucide-react";
|
||||
import { Toggle } from "@/components/settings/controls";
|
||||
import { useSessionMermaid } from "@/components/session-overlay";
|
||||
import { downloadFile } from "@/components/artifacts/download";
|
||||
@@ -99,11 +99,47 @@ export function VisualOutputCard({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
/* 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). */
|
||||
<div
|
||||
className={
|
||||
natural !== null
|
||||
? "my-4 mx-auto w-fit max-w-full"
|
||||
: "my-4 w-full"
|
||||
}
|
||||
data-testid="visual-card"
|
||||
>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={
|
||||
natural !== null
|
||||
? "w-fit max-w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:max-w-full"
|
||||
: "w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:w-full"
|
||||
}
|
||||
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }}
|
||||
data-testid="visual-svg"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Toggle checked={inverted} onChange={toggleInverted} label="Invert figure brightness" />
|
||||
{/* The invert affordance (owner round, 2026-10-07): the disc IS
|
||||
the half-filled contrast circle; the tooltip names it. */}
|
||||
<Toggle
|
||||
checked={inverted}
|
||||
onChange={toggleInverted}
|
||||
label="Invert figure brightness"
|
||||
title="Invert colors"
|
||||
discIcon={<ContrastIcon className="size-3" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download figure as SVG"
|
||||
@@ -128,23 +164,6 @@ export function VisualOutputCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={
|
||||
natural !== null
|
||||
? "mx-auto mt-2 w-fit max-w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:max-w-full"
|
||||
: "mt-2 w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:w-full"
|
||||
}
|
||||
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }}
|
||||
data-testid="visual-svg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* control together) and the control itself locks — the row owns the dim.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A section block: pronounced mono heading + content. */
|
||||
@@ -87,12 +87,20 @@ export function Toggle({
|
||||
disabled,
|
||||
onChange,
|
||||
label,
|
||||
discIcon,
|
||||
title,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
/** Accessible name for the switch role. */
|
||||
label: string;
|
||||
/** Optional icon disc (owner round, 2026-10-07): the figure cards' invert
|
||||
* toggle renders the half-filled contrast circle AS the disc — the icon
|
||||
* carries the meaning; plain toggles keep the plain disc. */
|
||||
discIcon?: ReactNode;
|
||||
/** Optional native tooltip on the switch. */
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
@@ -100,6 +108,7 @@ export function Toggle({
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
{...(title ? { title } : {})}
|
||||
disabled={disabled}
|
||||
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
|
||||
onClick={() => onChange(!checked)}
|
||||
@@ -111,14 +120,27 @@ export function Toggle({
|
||||
disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0 top-0.5 size-3 rounded-full transition-transform",
|
||||
// 2px inset from each inner edge, both states (owner round: the on
|
||||
// disc used to touch the pill's right edge).
|
||||
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{discIcon ? (
|
||||
// The icon IS the disc: same size, same slide, same state colors —
|
||||
// only the knob's face changes (the invert affordance).
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0 top-0.5 flex size-3 items-center justify-center rounded-full transition-transform",
|
||||
checked ? "translate-x-4 text-foreground" : "translate-x-0.5 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{discIcon}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0 top-0.5 size-3 rounded-full transition-transform",
|
||||
// 2px inset from each inner edge, both states (owner round: the on
|
||||
// disc used to touch the pill's right edge).
|
||||
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,6 +505,23 @@ describe("artifact chrome — scrollbar, invert toggles, downloads (release poli
|
||||
assert.ok(!html.includes("hue-rotate"), "inversion OFF by default");
|
||||
});
|
||||
|
||||
it("figure card: NO card chrome, image first, caption row BELOW (owner round, 2026-10-07)", () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
|
||||
const html = renderToStaticMarkup(<VisualOutputCard output={{ svg }} />);
|
||||
// Document material: the outer box carries no card styling.
|
||||
const rootAt = html.indexOf('data-testid="visual-card"');
|
||||
const root = html.slice(rootAt - 200, rootAt);
|
||||
assert.ok(!root.includes("rounded-xl border"), "no card container");
|
||||
// Image BEFORE the caption row; the caption label and the controls follow it.
|
||||
const svgAt = html.indexOf('data-testid="visual-svg"');
|
||||
const captionAt = html.indexOf(">Figure<");
|
||||
assert.ok(svgAt !== -1 && captionAt !== -1 && svgAt < captionAt, "the caption row sits below the image");
|
||||
assert.ok(captionAt < html.indexOf("visual-download"), "the controls ride the caption row");
|
||||
// The invert affordance: the contrast-circle disc + the tooltip.
|
||||
assert.ok(html.includes("size-3"), "the icon disc");
|
||||
assert.ok(html.includes('title="Invert colors"'), "the tooltip names the toggle");
|
||||
});
|
||||
|
||||
it("svgNaturalWidth: declared px sizes parse; %, auto, and absent fall back to null", () => {
|
||||
assert.equal(svgNaturalWidth('<svg width="200" height="100"></svg>'), 200);
|
||||
assert.equal(svgNaturalWidth("<svg width='320'></svg>"), 320);
|
||||
|
||||
Reference in New Issue
Block a user