"use client"; import { useI18n } from "@/components/i18n"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { DownloadIcon } from "lucide-react"; import { Toggle } from "@/components/settings/controls"; import { InvertDiscIcon } from "@/components/artifacts/invert-disc"; 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"; /** * Demo artifact card (W6): while the part is input-available, a job activity * card (the chat view's jobs feed — the job-card idiom); when output-available, the demo * runs in a SANDBOXED iframe (sandbox="allow-scripts", no allow-same-origin → * no access to app storage/cookies/DOM). * * The bridge script (contracts.ts "Demo iframe bridge") is auto-injected into * every demo by buildDemoSrcDoc — never the model's job: ResizeObserver → * postMessage {type:"demo-resize",height} (parent sizes the iframe) and * window.onerror → {type:"demo-error",message} (error chip on the card, with * a "Send to teacher" button that feeds the error back into the lesson — the * fix loop). * * Bridge message handling is a pure function (reduceDemoBridgeState) so * tests cover it without a DOM. */ // ─── bridge injection + message handling (pure, testable) ─────────────────── export const DEMO_BRIDGE_SCRIPT = ``; /** Inject the bridge before (or at the end of a body-less document). */ export function buildDemoSrcDoc(html: string): string { if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${DEMO_BRIDGE_SCRIPT}`); return `${html}${DEMO_BRIDGE_SCRIPT}`; } export interface DemoBridgeState { height: number | null; error: string | null; } export const INITIAL_DEMO_BRIDGE_STATE: DemoBridgeState = { height: null, error: null }; const MIN_HEIGHT = 80; const MAX_HEIGHT = 2400; /** Pure reducer over incoming postMessage data (contracts.ts DemoBridgeMessage). */ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): DemoBridgeState { if (typeof data !== "object" || data === null) return state; const message = data as { type?: unknown; height?: unknown; message?: unknown }; if (message.type === "demo-resize") { if (typeof message.height !== "number" || !Number.isFinite(message.height) || message.height <= 0) return state; const height = Math.min(Math.max(Math.round(message.height), MIN_HEIGHT), MAX_HEIGHT); return height === state.height ? state : { ...state, height }; } if (message.type === "demo-error") { const text = typeof message.message === "string" && message.message.trim() ? message.message : "unknown error"; return text === state.error ? state : { ...state, error: text }; } return state; } // ─── the demo card ────────────────────────────────────────────────────────── /** The demo tool-call box: the part's output is the dispatch ack — the box * is the DispatchCard (live status via the chat view's jobs feed). The demo * itself renders from the steer message via DemoOutputCard. */ export function DemoCard({ part }: ArtifactPartProps) { if (part.state !== "output-available") return null; return ; } /** The demo artifact — exported: the steer card renders the delivered * result. `messageId` + `initialInverted` carry the PERSISTED invert * switch (details.ui.inverted via the artifact-ui route, owner 2026-10-07). */ export function DemoOutputCard({ output, messageId, initialInverted = false, }: { output: unknown; messageId?: string; initialInverted?: boolean; }) { const { t } = useI18n(); const result = output as DemoResult | undefined; if (!result || typeof result.html !== "string" || typeof result.title !== "string") { return (
{t("Demo completed, but the result could not be displayed.")}
); } const failed = result.html.length === 0; if (failed) { return (
{t("Interactive demo")}{t("failed")}

{result.notes ?? t("The demo could not be built.")}

); } return ( ); } function DemoFrame({ title, html, messageId, initialInverted = false, }: { title: string; html: string; messageId?: string; initialInverted?: boolean; }) { const { t } = useI18n(); const actions = useContext(ChatActionsContext); const iframeRef = useRef(null); const [state, setState] = useState(INITIAL_DEMO_BRIDGE_STATE); // 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) the toggle stays local-only. The filter rides the iframe // element, which carries the card's white backing, so one filter flips // page + content together while hue-rotate(180deg) undoes invert's hue // flip. 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 srcDoc = useMemo(() => buildDemoSrcDoc(html), [html]); useEffect(() => { const onMessage = (event: MessageEvent) => { // Only messages from THIS demo's iframe (the sandbox gives it an // opaque origin; the window reference still matches). if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return; setState((prev) => reduceDemoBridgeState(prev, event.data)); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, []); return (
{/* Caption header (owner, 2026-10-07): kind label + title in the label's size/tracking (title normal-case, foreground) — the figure card's grammar plus a name. Switch + download far right. */}
{t("interactive demo")} {title}
} />
{state.error && ( actions.sendText(t("The demo \"{v0}\" threw an error: {v1}", { v0: title, v1: state.error })) : undefined} /> )}