// --------------------------------------------------------------------------- // The artifact gallery's preview slot. // // An artifact is a live component, so the grid needs something to show for it. // Two things can be shown, and this component prefers the first: // // 1. a CAPTURED RENDER — sanitized markup of the artifact actually rendering. // Written at create time from the smoke render, which produces its loading // composition (cards, tables, chart frames, skeletons) with no data in it, // and REPLACED once a viewer opens the artifact and it settles, at which // point the same composition carries real numbers and rows. // 2. a SCHEMATIC — the deterministic figure below, derived from the id. // // The fallback is total: a real render if there is one, otherwise the // schematic. Nothing here can fail to draw something, which is what lets every // producer upstream fail open. // // ## Why the markup is inserted directly, and not into an iframe // // An iframe with `srcdoc` is the reflex for "render HTML I did not write", and // it is the wrong tool here on both axes: // // - It does not inherit the console's stylesheet. The markup's entire visual // layer is Tailwind classes plus theme tokens, and a srcdoc document has // neither — so every preview would need the compiled stylesheet and the // Geist woff2 inlined into it, per card, or it would render as unstyled // black-on-white text. Nine cards would each pay that. // - The security it buys is against a threat this markup does not carry. // // What is actually being inserted is not arbitrary HTML. It is OUR OWN server // render, produced by React's server renderer inside a QuickJS sandbox from // code that already passed the create-time guards, then reduced by // `sanitizeArtifactPreviewMarkup` to an ALLOWLIST: known-inert elements, known // attributes, no `script`, no `style` element, no `img`, no `iframe`, no // handler, no external reference of any kind, ids namespaced so a preview // cannot collide with the console document. The sanitizer is the boundary, and // it is written not to trust the renderer's output either — see its header. // // So direct insertion is the correct trade: the preview inherits the console's // fonts, tokens and dark mode for free, costs one DOM subtree instead of one // document, and is inert by allowlist rather than by container. The container // still does its share — `pointer-events-none`, `aria-hidden`, `contain`, and a // transform scale — so the preview cannot be clicked, focused, read by a screen // reader, or paint outside its box. // // The schematic remains for everything with no preview yet: deliberately // abstract hairline geometry in the grayscale ramp, the same vocabulary as the // marketing site's graph-paper illustrations, because a placeholder that // imitated a screenshot would be lying about what it knows. It is derived from // `artifactId`, so a given artifact always draws the same figure and the eye // can use the shape as a weak landmark. // --------------------------------------------------------------------------- import { useLayoutEffect, useRef, useState } from "react"; import type { ArtifactPreview as ArtifactPreviewValue } from "@executor-js/sdk/shared"; import { cn } from "../lib/utils"; /** FNV-1a over the id — a stable, well-mixed seed from an opaque string. */ const seedFrom = (value: string): number => { let hash = 2_166_136_261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16_777_619); } return hash >>> 0; }; /** * mulberry32 — a tiny deterministic PRNG. * * `Math.random` would reshuffle every card on every render; the schematic is * only useful as a landmark if it is a pure function of the artifact's id. */ const sequenceFrom = (seed: number): (() => number) => { let state = seed; return () => { state = (state + 0x6d_2b_79_f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; }; }; /** Pick a value in `[min, max)` from the sequence, rounded to whole units. */ const between = (next: () => number, min: number, max: number): number => Math.round(min + next() * (max - min)); // The frame the schematics are drawn in. 16:10, matching the card's preview box. const WIDTH = 320; const HEIGHT = 200; // Content inset. The chrome bar sits at the top; body layouts own everything // below `BODY_TOP`. const PAD = 20; const BODY_TOP = 56; /** Rounded blocks read as UI; every layout builds out of this one primitive. */ function Block(props: { readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly className?: string; }) { return ( ); } /** * The header every artifact has: a title bar and a couple of controls. * * Keeping it constant across layouts is what makes the four body figures read * as variations of one interface rather than four unrelated drawings. */ function Chrome(props: { readonly next: () => number }) { const titleWidth = between(props.next, 64, 120); return ( <> ); } /** A bar chart: the shape of a metrics artifact. */ function BarsFigure(props: { readonly next: () => number }) { const { next } = props; const count = between(next, 6, 9); const span = WIDTH - PAD * 2; const gap = 6; const barWidth = (span - gap * (count - 1)) / count; const floor = HEIGHT - PAD - 14; const maxHeight = floor - BODY_TOP - 4; return ( <> {Array.from({ length: count }, (_, index) => { const height = between(next, Math.round(maxHeight * 0.25), maxHeight); return ( ); })} ); } /** A table: the shape of a list or report artifact. */ function RowsFigure(props: { readonly next: () => number }) { const { next } = props; const rows = between(next, 4, 6); const rowHeight = (HEIGHT - PAD - BODY_TOP) / rows; return ( <> {Array.from({ length: rows }, (_, index) => { const y = BODY_TOP + index * rowHeight; const leadWidth = between(next, 70, 130); const tailWidth = between(next, 24, 44); return ( {index < rows - 1 ? ( ) : null} ); })} ); } /** A tile grid: the shape of a stat or summary dashboard. */ function TilesFigure(props: { readonly next: () => number }) { const { next } = props; const columns = 2; const rows = 2; const gap = 12; const tileWidth = (WIDTH - PAD * 2 - gap * (columns - 1)) / columns; const tileHeight = (HEIGHT - PAD - BODY_TOP - gap * (rows - 1)) / rows; return ( <> {Array.from({ length: columns * rows }, (_, index) => { const column = index % columns; const row = Math.floor(index / columns); const x = PAD + column * (tileWidth + gap); const y = BODY_TOP + row * (tileHeight + gap); const valueWidth = between(next, 28, 54); return ( ); })} ); } /** A list beside a panel: the shape of a detail or explorer artifact. */ function SplitFigure(props: { readonly next: () => number }) { const { next } = props; const railWidth = 96; const panelX = PAD + railWidth + 14; const panelWidth = WIDTH - PAD - panelX; const panelHeight = HEIGHT - PAD - BODY_TOP; const points = Array.from({ length: 7 }, (_, index) => { const x = panelX + 12 + (index * (panelWidth - 24)) / 6; const y = BODY_TOP + panelHeight - 16 - between(next, 6, panelHeight - 40); return `${x},${y}`; }).join(" "); return ( <> {Array.from({ length: 4 }, (_, index) => ( ))} ); } const FIGURES = [BarsFigure, RowsFigure, TilesFigure, SplitFigure] as const; /** The figure names, in the order `FIGURES` holds them. */ export const ARTIFACT_PREVIEW_FIGURES = ["bars", "rows", "tiles", "split"] as const; export type ArtifactPreviewFigure = (typeof ARTIFACT_PREVIEW_FIGURES)[number]; /** * Which schematic an artifact draws. * * Exported so the choice is testable as a pure function: determinism is this * component's contract, not an implementation detail. */ export const artifactPreviewFigure = (artifactId: string): ArtifactPreviewFigure => ARTIFACT_PREVIEW_FIGURES[seedFrom(artifactId) % ARTIFACT_PREVIEW_FIGURES.length] ?? "bars"; /** * The width the stored markup is laid out at before being scaled to fit. * * The markup came from a server renderer with no viewport, so it has no * intrinsic width. Laying it out at a desktop width and scaling the whole thing * down is what makes the card read as a small screenshot of the artifact rather * than a cramped phone rendering of it — a table stays a table, a four-column * stat row stays four columns. * * 880 matches the width the shell actually frames an artifact at * (`.artifact-root` is `max-width: 1100px` minus its padding), so the layout * the card shows is the layout the artifact really has. */ const LAYOUT_WIDTH = 880; /** The 16:10 slice of that width — one card's worth of the artifact. */ const LAYOUT_HEIGHT = Math.round((LAYOUT_WIDTH * 10) / 16); /** * The artifact's real loading-state markup, scaled into the card. * * `dangerouslySetInnerHTML` is the right call here and the sanitizer is why — * see this file's header for the full argument. In short: the markup is our own * sandboxed server render reduced to an allowlist of inert elements and * attributes, with no script, style element, image, frame, handler or external * reference able to survive. The wrapper adds containment on top: it cannot be * clicked or focused (`pointer-events-none`, no tabbable descendants survive * sanitizing), it is invisible to assistive tech (`aria-hidden`), and `contain` * plus `overflow-hidden` stop it painting or laying out beyond its box. */ function LayoutPreview(props: { readonly markup: string }) { const frameRef = useRef(null); const [scale, setScale] = useState(null); // The scale has to be MEASURED, not written as a constant. // // The card is fluid — one, two or three across depending on the viewport — so // any fixed divisor is right at exactly one breakpoint and wrong at the rest. // Container query units cannot do it either: `calc(100cqw / 880)` is a // length, and `scale()` takes a unitless number, so that expression is // invalid and the browser drops the whole transform — which renders the // markup at 1:1 and shows nothing but its top-left corner. useLayoutEffect(() => { const frame = frameRef.current; if (!frame) return; const measure = () => { const width = frame.clientWidth; if (width > 0) setScale(width / LAYOUT_WIDTH); }; measure(); const observer = new ResizeObserver(measure); observer.observe(frame); return () => observer.disconnect(); }, []); return (