Skip to content
Star on GitHub
ConversationArtifact card
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Artifact card

A file the agent made, with its versions, Open, Copy and Download. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add artifact-card
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add artifact-card

React

$npx designme add artifact-card --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Artifact card shows a file the agent made, like a report or a table, in one of four variants: a compact row, a card with its first page showing, a chip inside the message, or a stack of its versions. It shows the agent writing the file, then lets people open it in a reader, pick a version, copy it or download it.

  • When your agent writes a report, a table or a draft and you want to show it in the chat instead of a bare file link.
  • When the agent revises its work and people need to see which version they're looking at, and go back to an earlier one.
  • When people should be able to read the file, copy it or save it without leaving the conversation.
03

Usage

Import it and drive it from your own state.

Import

import { ArtifactCard } from '@/components/designme/artifact-card/ArtifactCard';

Example

The demo above as a React component, 97 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { ArtifactCard, type ArtifactCardProps, type ArtifactVersion } from './ArtifactCard';
import { Seg } from '../lib/motion';
import { Demo } from '../lib/Demo';
import { useScript, useStory } from '../lib/useScript';

/* ---------- Artifact card: a pricing page review in three versions, written while you watch, shown four ways ---------- */
const F = {
  look: 'The three plans look alike at a glance.',
  year: 'The yearly switch hides what a month costs.',
  cancel: 'Nothing near the buttons says you can cancel any time.',
  faq: 'The FAQ repeats the plan table instead of answering doubts.',
  price: 'Team costs 16% more than similar plans elsewhere, and the page never says why.'
};
const DRAFTS = [
  { meta: 'edited 2 hours ago', note: 'First pass, four findings', findings: [F.look, F.year, F.cancel, F.faq], fixes: [] as string[],
    summary: 'People reach the plan table and stall. Most of what slows them down is clarity, not price.',
    changes: ['Read the page from top to bottom', 'Listed four findings in page order'] },
  { meta: 'edited 40 min ago', note: 'Added a competitor price check', findings: [F.look, F.year, F.cancel, F.price], fixes: [],
    summary: 'People reach the plan table and stall. Team also costs more than similar plans elsewhere, and the page never says why.',
    changes: ['Compared Team with three competitors', 'Dropped the FAQ finding, which overlapped another'] },
  { meta: 'edited just now', note: 'Fixes, ordered by impact', findings: [F.look, F.year, F.cancel, F.price],
    fixes: ['Show the monthly price beside the yearly one.', 'Mark Team as the plan most people choose.', 'Put “Cancel any time” under each button.'],
    summary: 'People reach the plan table and stall. Three changes should move most of them on, and none of them touch the price.',
    changes: ['Turned each finding into a fix', 'Ordered the fixes by how many people they reach'] }
];
// each version as the Markdown that Copy copies, Download saves and the reader shows
const VERSIONS: ArtifactVersion[] = DRAFTS.map((d, i) => ({
  title: 'Pricing page review', meta: d.meta, note: d.note, summary: d.summary,
  content: `# Pricing page review\n\n## Summary\n\n${d.summary}\n\n## Findings\n\n${d.findings.map(f => '- ' + f).join('\n')}\n`
    + (d.fixes.length ? `\n## What to fix first\n\n${d.fixes.map((f, k) => `${k + 1}. ${f}`).join('\n')}\n` : '')
    + `\n## Changes in v${i + 1}\n\n${d.changes.map(f => '- ' + f).join('\n')}\n`
}));
const TABLE: ArtifactVersion[] = [{ title: 'Competitor prices', meta: 'edited 1 hour ago', note: 'Team and Business at three competitors',
  content: 'Plan,Ours,Northwind,Halcyon,Fernway\nTeam,$29,$22,$25,$28\nBusiness,$79,$69,$75,$89\n' }];
const STEPS = ['Reading the page…', 'Writing the findings…', 'Ordering the fixes…'];
type Variant = NonNullable<ArtifactCardProps['variant']>;
const ASK = 'Review our pricing page and tell me what to fix first.';

export default function ArtifactCardDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [variant, setVariant] = useState<Variant>('compact');
  const [version, setVersion] = useState(2);
  // where the writing is: a step while it runs, then done; the summary is written out a few letters at a time
  const [step, setStep] = useState(0);
  const [chars, setChars] = useState<number | null>(null);
  const writing = step < STEPS.length;
  // each variant plays the writing state again when it comes in
  useScript(async ({ wait }) => {
    setStep(0); setChars(0); await wait(1400);
    setStep(1);
    for (let n = 0; n < DRAFTS[2].summary.length; n += 3) { setChars(n); await wait(22); }
    setChars(null); await wait(300);
    setStep(2); await wait(1100);
    setStep(STEPS.length);
  }, [variant]);
  // the preview's story: once the report is written, someone opens its versions, reads v2 and goes back to v3
  useStory(ref, async ({ wait, press }) => {
    await wait(5600); press('.dm-afx-vb');
    await wait(1000); press('.dm-afx-menu .dm-mi[data-i="1"]');
    await wait(2000); press('.dm-afx-vb');
    await wait(1000); press('.dm-afx-menu .dm-mi[data-i="2"]');
  });

  const versions = writing && chars != null ? VERSIONS.map((x, i) => i === 2 ? { ...x, summary: DRAFTS[2].summary.slice(0, chars) } : x) : VERSIONS;
  const report = (
    <ArtifactCard variant={variant} versions={versions} version={writing ? 2 : version} onVersionChange={setVersion} kind="Report" fileName="pricing-page-review"
      state={writing ? 'writing' : 'ready'} status={STEPS[Math.min(step, STEPS.length - 1)]} progress={(step + 1) / STEPS.length} />
  );
  const body = variant === 'compact' ? (
    <div className="dm-afx-cp" data-dm="">
      <div className="dm-bub">{ASK}</div>
      <p className="dm-afx-cp-a">I went through the page from top to bottom and checked our prices against three competitors. Both are below.</p>
      <div className="dm-afx-cp-list"><ArtifactCard versions={TABLE} kind="Table" icon="table" ext="csv" fileName="competitor-prices" />{report}</div>
    </div>
  ) : variant === 'inline' ? (
    <div className="dm-afx-in" data-dm="">
      <div className="dm-bub">{ASK}</div>
      <div className="dm-afx-in-msg">
        <p>I went through the pricing page from top to bottom and wrote up what to fix first.</p>
        {report}
        <p>The short version: the plan table is where people stall, and three small changes should move most of them on.</p>
      </div>
    </div>
  ) : report;

  return (
    <div ref={ref} style={{ width: '100%', position: 'relative' }}>
      <Demo switcher={<Seg label="Variant" value={variant} onChange={setVariant} options={[{ value: 'compact', label: 'Compact' }, { value: 'preview', label: 'Preview' }, { value: 'inline', label: 'Inline' }, { value: 'stack', label: 'Stack' }]} />}>
        {body}
      </Demo>
    </div>
  );
}
04

Props

Everything ArtifactCard takes.

PropTypeWhat it does
variant'compact' | 'preview' | 'inline' | 'stack'How the card shows the file: a compact row, a card with its first page, a chip inside a message, or a stack of its versions
versionsrequiredArtifactVersion[]Oldest first
versionnumberIndex of the version shown. Leave it out to let the versions menu change it
defaultVersionnumberDefaults to the latest version
onVersionChange(version: number) => void
state'writing' | 'ready''writing' while the agent is still making the file: the card shows `status` with a spinner and holds its actions back. When it turns 'ready' the meta line and the actions come in
statusstringThe line shown while writing, like "Reading the page…"
progressnumberHow far the writing has got, from 0 to 1. The compact card draws it as a line along its bottom edge
kindstringThe kind of file on the meta line, like "Report" or "Table"
iconIconNameThe file's icon
fileNamestringThe name Download saves under, before "-v3.md". Defaults to the title in lowercase, with dashes
extstringThe extension Download saves with. "csv" also shows the content as a table in the reader
onOpen(version: number) => voidCalled when the file is opened, with the version shown. Without it, Open shows the card's own reader, which covers the card's nearest positioned ancestor (your chat column, say)
openLabelstringThe Open button's tip, like "Open beside the chat". Its label stays "Open"
onCopy(version: number) => voidCalled after Copy has put the version's content on the clipboard
onDownload(version: number) => voidCalled after Download has saved the version's content as a file
classNamestring
05

Code

The full source, as the CLI installs it.

ArtifactCard.tsxReact · 457 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './artifact-card.css';

export interface ArtifactVersion {
  /** The file's name in this version, like "Pricing page review" */
  title: string;
  /** When this version was made, like "edited 2 hours ago". The meta line and the versions menu show it */
  meta: string;
  /** What changed in this version, like "Added a competitor price check". The versions menu and the stack show it */
  note?: string;
  /** The file itself, as Markdown (or CSV when `ext` is "csv"): the reader shows it, Copy copies it and Download saves it */
  content: string;
  /** The text on the preview's first page. Defaults to the first paragraph of `content` */
  summary?: string;
}

export interface ArtifactCardProps {
  /** How the card shows the file: a compact row, a card with its first page, a chip inside a message, or a stack of its versions */
  variant?: 'compact' | 'preview' | 'inline' | 'stack';
  /** Oldest first */
  versions: ArtifactVersion[];
  /** Index of the version shown. Leave it out to let the versions menu change it */
  version?: number;
  /** Defaults to the latest version */
  defaultVersion?: number;
  onVersionChange?: (version: number) => void;
  /** 'writing' while the agent is still making the file: the card shows `status` with a spinner and holds its actions back.
      When it turns 'ready' the meta line and the actions come in */
  state?: 'writing' | 'ready';
  /** The line shown while writing, like "Reading the page…" */
  status?: string;
  /** How far the writing has got, from 0 to 1. The compact card draws it as a line along its bottom edge */
  progress?: number;
  /** The kind of file on the meta line, like "Report" or "Table" */
  kind?: string;
  /** The file's icon */
  icon?: IconName;
  /** The name Download saves under, before "-v3.md". Defaults to the title in lowercase, with dashes */
  fileName?: string;
  /** The extension Download saves with. "csv" also shows the content as a table in the reader */
  ext?: string;
  /** Called when the file is opened, with the version shown. Without it, Open shows the card's own reader, which covers
      the card's nearest positioned ancestor (your chat column, say) */
  onOpen?: (version: number) => void;
  /** The Open button's tip, like "Open beside the chat". Its label stays "Open" */
  openLabel?: string;
  /** Called after Copy has put the version's content on the clipboard */
  onCopy?: (version: number) => void;
  /** Called after Download has saved the version's content as a file */
  onDownload?: (version: number) => void;
  className?: string;
}

const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const MIME: Record<string, string> = { md: 'text/markdown', csv: 'text/csv', json: 'application/json', html: 'text/html' };
/** "edited 2 hours ago" reads "2 hours ago" in the menu, and drops "edited" in a narrow card */
const ago = (meta: string) => meta.replace(/^edited\s+/i, '');
function When({ meta }: { meta: string }) {
  const short = ago(meta);
  return short === meta ? <>{meta}</> : <><span className="dm-afx-ed">edited </span>{short}</>;
}
/** The first paragraph of the content, for the preview's first page */
const firstPara = (content: string) => content.split(/\n\s*\n/).map(b => b.trim()).find(b => b && !/^(#|[-*] |\d+\. |v\d+,)/.test(b)) ?? '';

/** The content as the reader shows it: ## headings, - and 1. lists and paragraphs; CSV becomes a table. The # title is
    left out, since the reader's header shows it */
function Body({ content, csv }: { content: string; csv: boolean }) {
  if (csv) {
    const rows = content.trim().split('\n').map(r => r.split(','));
    return (
      <table className="dm-afx-tb">
        <thead><tr>{rows[0].map((h, k) => <th key={k} scope="col" className={k ? 'dm-num' : undefined}>{h}</th>)}</tr></thead>
        <tbody>{rows.slice(1).map((r, i) => <tr key={i}>{r.map((x, k) => k ? <td key={k} className="dm-num">{x}</td> : <th key={k} scope="row">{x}</th>)}</tr>)}</tbody>
      </table>
    );
  }
  const out: ReactNode[] = [];
  content.split(/\n\s*\n/).map(b => b.trim()).filter(Boolean).forEach((b, i) => {
    const lines = b.split('\n');
    if (/^# /.test(b)) return;
    if (/^#{2,} /.test(b)) out.push(<h3 key={i}>{b.replace(/^#+ /, '')}</h3>);
    else if (lines.every(l => /^[-*] /.test(l))) out.push(<ul key={i}>{lines.map((l, k) => <li key={k}>{l.slice(2)}</li>)}</ul>);
    else if (lines.every(l => /^\d+\. /.test(l))) out.push(<ol key={i}>{lines.map((l, k) => <li key={k}>{l.replace(/^\d+\. /, '')}</li>)}</ol>);
    else out.push(<p key={i}>{b}</p>);
  });
  return <>{out}</>;
}

/** Feedback on a button that did something: a check and a word for a moment, then back */
function useFlash(ms = 1600) {
  const [on, setOn] = useState<string | null>(null);
  const t = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(t.current), []);
  return [on, (key: string) => { setOn(key); clearTimeout(t.current); t.current = setTimeout(() => setOn(null), ms); }] as const;
}

const cssMs = (el: Element, v: string, f = 1) => { const s = getComputedStyle(el).getPropertyValue(v).trim(); return (s.endsWith('ms') ? parseFloat(s) : parseFloat(s) * 1000) * f || 300; };
const cssEase = (el: Element) => getComputedStyle(el).getPropertyValue('--dm-ease').trim() || 'ease';

/** The opened file: a view, or a page that grows out of the preview or sheet it came from. A modal dialog: focus moves in
    and stays in, Esc or the scrim closes it, and focus goes back to what opened it */
function Reader({ title, icon, meta, label, children, kind, src, opener, onCopy, onDownload, flashed, onClosed }: {
  title: string; icon: IconName; meta: string; label: string; children: ReactNode; kind: 'view' | 'page'; src: HTMLElement | null; opener: HTMLElement | null;
  onCopy: () => void; onDownload: () => void; flashed: string | null; onClosed: () => void;
}) {
  const pan = useRef<HTMLDivElement>(null), scrim = useRef<HTMLDivElement>(null), x = useRef<HTMLButtonElement>(null);
  const reduce = useReducedMotion(), closing = useRef(false);
  const morph = (back: boolean) => {
    const p = pan.current!, a = src!.getBoundingClientRect(), b = p.getBoundingClientRect(), D = cssMs(p, '--dm-t', kind === 'page' ? 1.3 : 1.1);
    const f = { transform: `translate(${a.left - b.left}px,${a.top - b.top}px) scale(${a.width / b.width},${a.height / b.height})` };
    [...p.children].forEach(k => k.animate(back ? [{ opacity: 1 }, { opacity: 0, offset: .4 }, { opacity: 0 }] : [{ opacity: 0 }, { opacity: 0, offset: .45 }, { opacity: 1 }], { duration: D, fill: back ? 'forwards' : 'none' }));
    return p.animate(back ? [{ transform: 'none' }, f] : [f, { transform: 'none' }], { duration: D, easing: cssEase(p), fill: back ? 'forwards' : 'none' }).finished;
  };
  useIsoLayoutEffect(() => {
    const p = pan.current; if (!p) return;
    x.current?.focus({ preventScroll: true });
    if (src) src.style.visibility = 'hidden';
    if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
      const D = cssMs(p, '--dm-t', kind === 'page' ? 1.3 : 1.1);
      scrim.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: D, easing: 'ease-out' });
      if (src) morph(false).catch(() => {});
      else p.animate([{ transform: 'translateY(16px)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: D, easing: cssEase(p) });
    }
    return () => { if (src) src.style.visibility = ''; };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  const close = async () => {
    if (closing.current) return; closing.current = true;
    const p = pan.current;
    if (p && !reduce) {
      const out = cssMs(p, '--dm-t', kind === 'page' ? 1.3 : 1.1) * .8;
      scrim.current?.animate([{ opacity: 1 }, { opacity: 0 }], { duration: out, fill: 'forwards' });
      try { await (src ? morph(true) : p.animate([{ transform: 'none', opacity: 1 }, { transform: 'translateY(16px)', opacity: 0 }], { duration: out, easing: cssEase(p), fill: 'forwards' }).finished); } catch { /* unmounted */ }
    }
    onClosed(); opener?.focus({ preventScroll: true });
  };
  const keys = (e: KeyboardEvent) => {
    if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); void close(); return; }
    if (e.key !== 'Tab' || !pan.current) return;
    const f = [...pan.current.querySelectorAll<HTMLElement>('button,[tabindex="0"]')], first = f[0], last = f[f.length - 1];
    if (e.shiftKey && (document.activeElement === first || document.activeElement === pan.current)) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  };
  return (
    <div className="dm-afx-sheet" onKeyDown={keys}>
      <div className="dm-afx-scrim" ref={scrim} onClick={() => void close()} />
      <div className={cx('dm-afx-pan', kind === 'page' ? 'dm-afx-page' : 'dm-afx-view')} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} ref={pan}>
        <div className="dm-afx-ph">
          <span className="dm-afx-ic" aria-hidden="true"><Icon name={icon} /></span>
          <div className="dm-afx-ptx"><div className="dm-afx-pt">{title}</div><div className="dm-afx-pm">{meta}</div></div>
          <Tip label="Close" kbd="Esc"><button type="button" className="dm-ib dm-afx-x" aria-label="Close" ref={x} onClick={() => void close()}><Icon name="x" /></button></Tip>
        </div>
        <div className="dm-afx-pb" tabIndex={0} role="region" aria-label={label}>{children}</div>
        <div className="dm-afx-pf">
          <button type="button" className={cx('dm-pill dm-sm dm-line dm-afx-copy', flashed === 'rcopy' && 'dm-copied')} onClick={onCopy}><Icon name={flashed === 'rcopy' ? 'check' : 'copy'} /><span className="dm-afx-lbl">{flashed === 'rcopy' ? 'Copied' : 'Copy'}</span></button>
          <button type="button" className={cx('dm-pill dm-sm dm-line dm-afx-dl', flashed === 'rdl' && 'dm-copied')} onClick={onDownload}><Icon name={flashed === 'rdl' ? 'check' : 'down'} /><span className="dm-afx-lbl">{flashed === 'rdl' ? 'Downloaded' : 'Download'}</span></button>
        </div>
      </div>
    </div>
  );
}

// the stack's drawn lines stand for each page's text: p a paragraph line, b a bullet, n a numbered fix, s a gap
const LINES: [string, number?][][] = [
  [['p', 94], ['p', 72], ['s'], ['b', 82], ['b', 74], ['b', 66], ['b', 78], ['s'], ['p', 88], ['p', 52]],
  [['p', 92], ['p', 86], ['p', 56], ['s'], ['b', 80], ['b', 72], ['b', 86], ['b', 64], ['s'], ['p', 70]],
  [['p', 90], ['p', 78], ['s'], ['n', 84], ['n', 70], ['n', 78], ['s'], ['b', 76], ['b', 68], ['b', 82]]
];
// the preview's lines, a little different for each version so drafts are easy to tell apart
const WIDTHS = [[96, 88, 70, 82], [92, 84, 90, 64], [90, 78, 86, 72]];

/** A file the agent made, in four variants: a compact row, a card with its first page, a chip inside a message, or a stack
    of its versions. It plays a writing state, then opens in a reader, switches versions, copies and downloads. */
export function ArtifactCard({
  variant = 'compact', versions, version, defaultVersion, onVersionChange, state = 'ready', status = 'Writing…', progress, kind = 'Report', icon = 'file-text',
  fileName, ext = 'md', onOpen, openLabel, onCopy, onDownload, className
}: ArtifactCardProps) {
  const N = versions.length, last = N - 1;
  const [cur, setCur] = useControllable(version, defaultVersion ?? last, onVersionChange);
  const i = Math.max(0, Math.min(last, cur)), v = versions[i];
  const writing = state === 'writing';
  const reduce = useReducedMotion();
  const [said, setSaid] = useState('');
  const say = (msg: string) => { setSaid(''); setTimeout(() => setSaid(msg), 60); };
  const [flashed, flash] = useFlash();
  const [reading, setReading] = useState<null | { kind: 'view' | 'page'; src: HTMLElement | null; opener: HTMLElement | null }>(null);
  // the meta and the actions come in when the writing settles
  const [settled, setSettled] = useState(false);
  const was = useRef(writing);
  useEffect(() => { if (was.current && !writing) { setSettled(true); say(`${v.title} is ready, v${i + 1}`); } was.current = writing; }, [writing]); // eslint-disable-line react-hooks/exhaustive-deps

  const pick = (n: number) => { if (n === i) return; setCur(n); say(`Showing v${n + 1}, ${versions[n].meta}`); };
  const copy = (key: string) => {
    try { navigator.clipboard?.writeText(v.content).catch(() => {}); } catch { /* the page may not write to the clipboard */ }
    flash(key); say(`Copied ${v.title}, v${i + 1}, to the clipboard`); onCopy?.(i);
  };
  const download = (key: string) => {
    const name = `${fileName ?? slug(v.title)}-v${i + 1}.${ext}`, url = URL.createObjectURL(new Blob([v.content], { type: MIME[ext] ?? 'text/plain' }));
    const a = document.createElement('a'); a.href = url; a.download = name; a.hidden = true; document.body.append(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
    flash(key); say(`Downloaded ${name}`); onDownload?.(i);
  };
  const open = (kindOf: 'view' | 'page', opener: HTMLElement | null, src: HTMLElement | null = null) => {
    if (writing) return;
    if (onOpen) { onOpen(i); return; }
    setReading({ kind: kindOf, src, opener }); say(`Opened ${v.title}, v${i + 1}`);
  };

  /* versions: a menu button with one radio item per version, newest first. Arrow keys move, Enter picks, Esc closes */
  const menu = usePopover({ side: 'down' });
  const items = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (!menu.open) return;
    const b = items.current?.querySelector<HTMLElement>('[aria-checked="true"]') ?? items.current?.querySelector<HTMLElement>('.dm-mi');
    b?.focus({ preventScroll: true });
  }, [menu.open]);
  const menuKeys = (e: KeyboardEvent<HTMLDivElement>) => {
    const it = [...(items.current?.querySelectorAll<HTMLElement>('.dm-mi') ?? [])], k = it.indexOf(document.activeElement as HTMLElement);
    if (e.key === 'ArrowDown') { e.preventDefault(); it[(k + 1) % it.length]?.focus(); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); it[(k - 1 + it.length) % it.length]?.focus(); }
    else if (e.key === 'Home') { e.preventDefault(); it[0]?.focus(); }
    else if (e.key === 'End') { e.preventDefault(); it[it.length - 1]?.focus(); }
    else if (e.key === 'Tab') menu.close();
  };
  const trigger = {
    ...menu.trigger, 'aria-label': `v${i + 1} of ${N} versions`,
    onKeyDown: (e: KeyboardEvent) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); menu.setOpen(true); } }
  };
  const versionsMenu = N > 1 && (
    <div {...menu.pop} className={cx(menu.pop.className, 'dm-afx-menu')} role="menu" aria-label={`Versions of ${v.title}`} ref={el => { menu.pop.ref.current = el; items.current = el; }} onKeyDown={menuKeys}>
      <div className="dm-ph" aria-hidden="true">Versions</div>
      {versions.map((_, n) => n).reverse().map(n => (
        <button key={n} type="button" className="dm-mi" role="menuitemradio" tabIndex={-1} aria-checked={n === i} data-i={n}
          onClick={() => { pick(n); menu.close(); menu.trigger.ref.current?.focus({ preventScroll: true }); }}>
          <span className="dm-afx-mb"><span>v{n + 1}<span className="dm-afx-mw"> · {ago(versions[n].meta)}</span></span>{versions[n].note && <span className="dm-d">{versions[n].note}</span>}</span>
          <Icon name="check" className="dm-ck" />
        </button>
      ))}
    </div>
  );

  // the meta line: the kind, the version (a menu when there are several) and when it was edited; the writing line stands in for it
  const metaLine = (extra?: string) => (
    <div className={cx('dm-afx-meta', extra)}>
      <span className="dm-afx-wr dm-shim">{status}</span>
      <span className={cx('dm-afx-fin', settled && 'dm-enter')}>
        <span>{kind}</span><span aria-hidden="true">·</span>
        {N > 1 ? <button type="button" className="dm-afx-vb" {...trigger}><span className="dm-afx-vt">v{i + 1}</span><Icon name="chev-d" size="xs" /></button> : <span className="dm-afx-v">v1</span>}
        <span aria-hidden="true">·</span><span className="dm-afx-when"><When meta={v.meta} /></span>
      </span>
    </div>
  );
  const iconTile = <span className="dm-afx-ic"><Spin /><Icon name={icon} /></span>;
  const actions = (size?: 's') => (
    <>
      <Tip label={flashed === 'copy' ? 'Copied' : 'Copy'} open={flashed === 'copy' || undefined}>
        <button type="button" className={cx('dm-ib dm-afx-copy', flashed === 'copy' && 'dm-copied')} aria-label={`Copy ${v.title}`} onClick={() => copy('copy')}><Icon name={flashed === 'copy' ? 'check' : 'copy'} size={size} /></button>
      </Tip>
      <Tip label={flashed === 'dl' ? 'Downloaded' : 'Download'} open={flashed === 'dl' || undefined}>
        <button type="button" className={cx('dm-ib dm-afx-dl', flashed === 'dl' && 'dm-copied')} aria-label={`Download ${v.title}`} onClick={() => download('dl')}><Icon name={flashed === 'dl' ? 'check' : 'down'} size={size} /></button>
      </Tip>
    </>
  );
  const openPill = (ic: IconName) => {
    const b = <button type="button" className="dm-pill dm-sm dm-accent dm-afx-open" aria-label={openLabel ?? `Open ${v.title}`} onClick={e => open(variant === 'stack' ? 'page' : 'view', e.currentTarget, variant === 'stack' ? topSheet() : null)}><Icon name={ic} /><span className="dm-afx-lbl">Open</span></button>;
    return openLabel ? <Tip label={openLabel}>{b}</Tip> : b;
  };

  /* ---- stack: the sheets, laid out by Versions, and the new page written onto the top one ---- */
  const desk = useRef<HTMLDivElement>(null);
  const [fanned, setFanned] = useState(false);
  const [peek, setPeek] = useState<number | null>(null);
  const [typed, setTyped] = useState<number | null>(null);
  const topSheet = () => desk.current?.querySelector<HTMLElement>(`[data-i="${i}"]`) ?? null;
  const fanBtn = useRef<HTMLButtonElement>(null);
  const fan = (on: boolean, focus = false) => {
    if (writing) return; setFanned(on); setPeek(null);
    if (on && focus) requestAnimationFrame(() => topSheet()?.focus({ preventScroll: true }));
  };
  useEffect(() => {
    if (!fanned) return;
    const down = (e: PointerEvent) => { const t = e.target as Node; if (!desk.current?.contains(t) && !fanBtn.current?.contains(t)) fan(false); };
    document.addEventListener('pointerdown', down); return () => document.removeEventListener('pointerdown', down);
  }, [fanned]); // eslint-disable-line react-hooks/exhaustive-deps
  // writing: a blank sheet drops onto the stack, the title is typed, and a pen draws each line of the new page
  useEffect(() => {
    if (variant !== 'stack' || !writing) return;
    const sh = desk.current?.querySelector<HTMLElement>(`[data-i="${last}"]`); if (!sh) return;
    let alive = true; const timers: ReturnType<typeof setTimeout>[] = [], anims: Animation[] = [];
    const sleep = (ms: number) => new Promise<void>((res, rej) => timers.push(setTimeout(() => alive ? res() : rej(new Error('stop')), ms)));
    const motion = !matchMedia('(prefers-reduced-motion: reduce)').matches, E = cssEase(sh);
    (async () => {
      if (motion) anims.push(sh.animate([{ transform: 'translateY(-36px)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: cssMs(sh, '--dm-t-slow'), easing: E, composite: 'add' }));
      setTyped(0); await sleep(300);
      const T = versions[last].title;
      if (!motion) setTyped(T.length);
      else for (let n = 1; n <= T.length; n++) { setTyped(n); await sleep(40); }
      await sleep(motion ? 250 : 0);
      const pen = sh.querySelector<HTMLElement>('.dm-afx-pen'), rows = [...sh.querySelectorAll<HTMLElement>('.dm-afx-r')].filter(x => x.offsetParent);
      for (const x of rows) {
        const line = x.querySelector('i')!, dot = x.querySelector('b');
        if (!motion) { anims.push(line.animate([{ transform: 'none' }], { fill: 'forwards' })); if (dot) anims.push(dot.animate([{ transform: 'none' }], { fill: 'forwards' })); await sleep(150); continue; }
        const x0 = x.offsetLeft + line.offsetLeft, x1 = x0 + line.offsetWidth, y = x.offsetTop + x.offsetHeight / 2 - 3.5, ms = 260 + line.offsetWidth * 1.4;
        if (dot) anims.push(dot.animate([{ transform: 'scaleX(0)' }, { transform: 'none' }], { duration: 120, fill: 'forwards' }));
        anims.push(line.animate([{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], { duration: ms, easing: 'cubic-bezier(.4,0,.3,1)', fill: 'forwards' }));
        if (pen) anims.push(pen.animate([{ transform: `translate(${x0 - 3}px,${y}px)`, opacity: 1 }, { transform: `translate(${x1 - 3}px,${y}px)`, opacity: 1 }], { duration: ms, easing: 'cubic-bezier(.4,0,.3,1)', fill: 'forwards' }));
        await sleep(ms + 70);
      }
      pen?.getAnimations().forEach(a => a.cancel());
    })().catch(() => {});
    return () => {
      alive = false; timers.forEach(clearTimeout); anims.forEach(a => a.cancel()); setTyped(null);
      if (motion && sh.isConnected) sh.animate([{ transform: 'translateY(-6px)' }, { transform: 'none' }], { duration: cssMs(sh, '--dm-t'), easing: E, composite: 'add' });
    };
  }, [variant, writing]); // eslint-disable-line react-hooks/exhaustive-deps
  const deskKeys = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!fanned) return;
    const sheets = [...(desk.current?.querySelectorAll<HTMLElement>('.dm-afx-sh') ?? [])], k = sheets.indexOf(document.activeElement as HTMLElement);
    let n: number | null = null;
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = Math.min(last, k + 1);
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') n = Math.max(0, k - 1);
    else if (e.key === 'Home') n = 0; else if (e.key === 'End') n = last;
    else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); fan(false); fanBtn.current?.focus({ preventScroll: true }); return; }
    else if (e.key === 'Tab') { fan(false); return; }
    if (n != null) { e.preventDefault(); sheets[n]?.focus(); }
  };
  // laid out, the sheets fan from left to right, oldest first; stacked, the shown version is on top and the others sit
  // behind it, straight, a little further back each (the CSS peeks them out on hover and focus)
  const behind = versions.map((_, n) => n).reverse().filter(n => n !== i);
  const sheetStyle = (n: number): CSSProperties => {
    if (fanned) { const o = n - last / 2; return { transform: `translate(calc(${o} * var(--dm-fx)),${Math.abs(o) * 6 + (peek === n ? -8 : 0)}px) rotate(${o * 6}deg)`, zIndex: peek === n ? 9 : n + 1 }; }
    const d = n === i ? 0 : behind.indexOf(n) + 1;
    return { ['--dm-d' as string]: d, zIndex: N - d };
  };
  const shown = fanned && peek != null ? peek : i;

  let body: ReactNode;
  if (variant === 'preview') {
    const sum = v.summary ?? firstPara(v.content), w = WIDTHS[i % 3];
    body = (
      <div className="dm-afx-w dm-afx-pv">
        <div className={cx('dm-afx-pv-card dm-afx-card', writing && 'dm-is-writing')}>
          <div className="dm-afx-pv-prev" aria-hidden="true">
            <div className="dm-afx-pv-page" data-src="">
              <div className="dm-afx-pv-k"><span className="dm-afx-wr">Writing…</span><span className="dm-afx-fin"><Swap>{`${kind} · v${i + 1}`}</Swap></span></div>
              <div className="dm-afx-pv-h">{v.title}</div>
              <p className="dm-afx-pv-p">{writing ? <>{sum}<span className="dm-afx-caret" /></> : <Swap>{sum}</Swap>}</p>
              <div className="dm-afx-pv-ls">{w.map((x, k) => <i key={k} style={{ ['--dm-w' as string]: x + '%', transition: reduce ? undefined : `width var(--dm-t) var(--dm-ease),transform .42s var(--dm-ease) ${k * 180}ms` }} />)}</div>
            </div>
          </div>
          <div className="dm-afx-pv-meta">
            {iconTile}
            <div className="dm-afx-pv-tx"><div className="dm-afx-pv-t">{v.title}</div>{metaLine()}</div>
            <div className="dm-afx-pv-act">
              {actions()}
              <Tip label={openLabel ?? 'Open'}><button type="button" className="dm-ib dm-afx-open" aria-label={openLabel ?? `Open ${v.title}`}
                onClick={e => open('page', e.currentTarget, e.currentTarget.closest('.dm-afx-pv-card')?.querySelector<HTMLElement>('.dm-afx-pv-page') ?? null)}><Icon name="max" /></button></Tip>
            </div>
          </div>
        </div>
        {versionsMenu}
      </div>
    );
  } else if (variant === 'inline') {
    body = (
      <div className="dm-afx-w">
        <span className={cx('dm-afx-chip', writing && 'dm-is-writing', menu.open && 'dm-pin')}>
          <Tip label={openLabel ?? ''} off={writing || !openLabel}>
            <button type="button" className="dm-afx-in-main" disabled={writing} onClick={e => open('view', e.currentTarget)}>
              <span className="dm-afx-in-ic"><Spin /><Icon name={icon} /></span>
              <span className="dm-afx-in-t"><span className="dm-afx-sr">Open </span>{v.title}</span>
              <span className="dm-afx-in-m"><span className="dm-afx-wr dm-shim">{status}</span>
                <span className={cx('dm-afx-fin', settled && 'dm-enter')}><span className="dm-afx-v">v{i + 1}</span><span className="dm-afx-msep" aria-hidden="true">·</span><span className="dm-afx-when"><When meta={v.meta} /></span></span></span>
            </button>
          </Tip>
          <span className="dm-afx-tray"><span>
            <span className="dm-afx-tsep" aria-hidden="true" />
            {N > 1 && <Tip label="Versions" off={menu.open}><button type="button" className="dm-ib dm-afx-vb3" {...trigger} aria-label="Versions"><Icon name="history" /></button></Tip>}
            {actions()}
          </span></span>
        </span>
        {versionsMenu}
      </div>
    );
  } else if (variant === 'stack') {
    const T = versions[last].title;
    body = (
      <div className={cx('dm-afx-w dm-afx-st', writing && 'dm-is-writing', fanned && 'dm-fan')}>
        <div className="dm-afx-desk">
          <div className="dm-afx-stack" role="menu" aria-label={`Versions of ${v.title}`} aria-hidden={!fanned} ref={desk} onKeyDown={deskKeys}>
            {versions.map((x, n) => {
              const blank = writing && n === last;
              return (
                <button key={n} type="button" className={cx('dm-afx-sh', blank && 'dm-blank')} role="menuitemradio" data-i={n} aria-checked={n === i} tabIndex={fanned && n === i ? 0 : -1}
                  aria-label={`v${n + 1}, ${x.meta}${x.note ? ': ' + x.note : ''}`} style={sheetStyle(n)}
                  onClick={e => { if (writing) return; if (fanned) { pick(n); fan(false); fanBtn.current?.focus({ preventScroll: true }); } else open('page', e.currentTarget.closest('.dm-afx-st')?.querySelector<HTMLElement>('.dm-afx-open') ?? null, e.currentTarget); }}
                  onPointerEnter={() => fanned && setPeek(n)} onPointerLeave={() => fanned && setPeek(null)} onFocus={() => fanned && setPeek(n)} onBlur={() => fanned && setPeek(null)}>
                  <span className="dm-afx-pg" aria-hidden="true">
                    <span className="dm-afx-st-k"><span>{blank ? 'Writing…' : 'v' + (n + 1)}</span></span>
                    <span className="dm-afx-st-h">{blank ? T.slice(0, typed ?? T.length) : x.title}</span>
                    <span className="dm-afx-st-ls">{LINES[n === last ? 2 : n % 2].map(([t, w], k) => t === 's' ? <span key={k} className="dm-afx-gap" /> : <span key={k} className={cx('dm-afx-r', 'dm-' + t)}>{t !== 'p' && <b />}<i style={{ ['--dm-w' as string]: w + '%' }} /></span>)}</span>
                    {n === last && <span className="dm-afx-pen" />}
                  </span>
                  <span className="dm-afx-tab" aria-hidden="true"><Icon name="check" size="xs" /><span>v{n + 1}</span><span className="dm-afx-ta"> · {ago(x.meta)}</span></span>
                </button>
              );
            })}
          </div>
        </div>
        <div className="dm-afx-cap">
          <div className="dm-afx-st-t">{v.title}</div>
          <div className="dm-afx-meta dm-afx-st-m"><span className="dm-afx-wr dm-shim">{status}</span>
            <span className={cx('dm-afx-fin', settled && 'dm-enter')}><span>{kind}</span><span aria-hidden="true">·</span><span className="dm-afx-v">v{shown + 1}</span><span aria-hidden="true">·</span><span className="dm-afx-when"><When meta={versions[shown].meta} /></span></span>
          </div>
          <div className={cx('dm-fold dm-afx-nw', !fanned && 'dm-shut')}><div><div className="dm-afx-note">{versions[shown].note}</div></div></div>
        </div>
        <div className="dm-afx-st-act">
          {N > 1 && <button type="button" className="dm-pill dm-sm dm-line dm-afx-fan" aria-haspopup="menu" aria-expanded={fanned} ref={fanBtn} onClick={() => fan(!fanned, true)}><Icon name="layers" /><span>Versions</span></button>}
          {actions()}
          {openPill('max')}
        </div>
      </div>
    );
  } else {
    body = (
      <div className="dm-afx-w">
        <div className={cx('dm-afx-cp-card dm-afx-card', writing && 'dm-is-writing')}>
          {iconTile}
          <div className="dm-afx-cp-tx"><div className="dm-afx-cp-t">{v.title}</div>{metaLine()}</div>
          <div className="dm-afx-cp-act">{actions()}{openPill('panel')}</div>
          {writing && <span className="dm-afx-cp-bar" aria-hidden="true" style={{ transform: `scaleX(${Math.max(0, Math.min(1, progress ?? 0))})` }} />}
        </div>
        {versionsMenu}
      </div>
    );
  }

  return (
    <div data-dm="" className={cx('dm-afx', className)} style={{ width: '100%', containerType: 'inline-size' }}>
      {body}
      {reading && (
        <Reader title={v.title} icon={icon} meta={`${kind} · v${i + 1} · ${v.meta}`} label={`${v.title}, v${i + 1}`} kind={reading.kind} src={reading.src} opener={reading.opener}
          onCopy={() => copy('rcopy')} onDownload={() => download('rdl')} flashed={flashed} onClosed={() => setReading(null)}>
          <Body content={v.content} csv={ext === 'csv'} />
        </Reader>
      )}
      <div className="dm-afx-sr" role="status" aria-live="polite">{said}</div>
    </div>
  );
}
artifact-card.cssReact · 190 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Artifact card ---------- */
/* a file the agent made, shown four ways: a compact row, a card with its first page, a chip inside the message, and a stack
   of its versions. Each plays its writing state, then opens, copies, downloads and switches versions the same way */
/* the reader opens over the card's nearest positioned ancestor: on this page the stage, in an app its chat column */
.dm-afx{--dm-afx-t2:var(--dm-muted)}
/* the variants share one room, as tall as the tallest moment (the compact card with its versions open), so switching
   never moves what is below and an open menu still sits centred */
.dm-afx.dm-dm>.dm-afx-main{position:relative;min-height:452px;justify-content:center}
@container (max-width:400px){.dm-afx.dm-dm>.dm-afx-main{min-height:474px}}
/* secondary text: the core's grey in light mode; in dark mode a lighter grey, because the core's reaches 4.5:1 there but
   not APCA Lc 60, which small text needs */
:root[data-theme="dark"] .dm-afx{--dm-afx-t2:#c0c0c0}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-afx{--dm-afx-t2:#c0c0c0}}
.dm-afx>.dm-demo .dm-seg2 button{color:var(--dm-afx-t2)}
.dm-afx>.dm-demo .dm-seg2 button[aria-pressed="true"]{color:var(--dm-fg)}
.dm-afx-sr{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.dm-afx-w{position:relative;width:100%;transition:transform var(--dm-t) var(--dm-ease)}
.dm-afx-ic{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;flex:none;background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-afx-ic .dm-spin{display:none}
.dm-is-writing .dm-afx-ic .dm-spin{display:inline-block}
.dm-is-writing .dm-afx-ic svg{display:none}
.dm-afx-meta{display:flex;align-items:center;min-width:0;height:20px;font-size:12.5px;color:var(--dm-afx-t2);white-space:nowrap}
.dm-afx-fin{display:flex;align-items:center;gap:5px;min-width:0}
.dm-afx-meta .dm-afx-wr{display:none;overflow:hidden;text-overflow:ellipsis}
.dm-is-writing .dm-afx-meta .dm-afx-wr{display:inline}
.dm-is-writing .dm-afx-meta .dm-afx-fin{display:none}
.dm-afx-when{overflow:hidden;text-overflow:ellipsis;min-width:0}
/* versions: a quiet "v3" that opens a menu */
.dm-afx-vb{position:relative;z-index:1;display:inline-flex;align-items:center;gap:1px;height:20px;padding:0 3px 0 5px;margin:0 -2px;border-radius:6px;font-size:12.5px;font-weight:500;color:var(--dm-fg);transition:background var(--dm-t-fast)}
.dm-afx-vb[aria-expanded="true"]{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-afx-vb:hover{background:var(--dm-row)}}
.dm-afx-vb .dm-i{color:var(--dm-fg2)}
.dm-afx-menu{width:264px;max-width:calc(100% - 16px)}
.dm-afx-menu:not(.dm-open){visibility:hidden;transition:opacity .2s var(--dm-ease),transform .35s var(--dm-ease),filter .2s var(--dm-ease),visibility 0s .35s}
.dm-afx-menu .dm-ph{color:var(--dm-afx-t2)}
.dm-afx-menu .dm-mi{align-items:flex-start;gap:var(--dm-s-2)}
.dm-afx-menu .dm-mi:focus-visible{outline-offset:-2px;background:var(--dm-row)}
.dm-afx-mb{display:flex;flex-direction:column;min-width:0}
.dm-afx-mw{font-weight:400;color:var(--dm-afx-t2)}
.dm-afx-menu .dm-mi .dm-d{color:var(--dm-afx-t2);font-weight:400;white-space:normal}
@media (hover:hover) and (pointer:fine){.dm-afx-menu .dm-mi:is(:hover,.dm-hl,[aria-checked="true"]) .dm-d{color:var(--dm-afx-t2);font-weight:400;white-space:normal}}
.dm-afx-menu .dm-mi .dm-ck{margin-top:3px}
/* the whole card opens the file: Open stretches over it, and the other buttons sit above */
.dm-afx-card{position:relative}
.dm-afx-card .dm-afx-open::after{content:"";position:absolute;inset:0;border-radius:inherit}
.dm-afx-card .dm-afx-open{transform:none!important}
.dm-afx-card :is(.dm-afx-copy,.dm-afx-dl,.dm-afx-vb){position:relative;z-index:1}
.dm-afx .dm-copied .dm-afx-lbl{color:var(--dm-fg)}
/* the opened file: a reader over the stage, the stage fading back behind it */
.dm-afx-sheet{position:absolute;inset:0;z-index:20;border-radius:inherit;overflow:hidden}
.dm-afx-scrim{position:absolute;inset:0;background:color-mix(in srgb,var(--dm-stage) 78%,transparent)}
.dm-afx-pan{position:absolute;display:flex;flex-direction:column;background:var(--dm-surface);border-radius:var(--dm-r-card);box-shadow:var(--dm-shadow-pop);overflow:hidden;outline:none}
/* a view fills the stage; a page is narrower and grows out of the preview or sheet it came from */
.dm-afx-pan.dm-afx-view{inset:0}
.dm-afx-pan.dm-afx-page{inset:0;margin:auto;width:min(400px,100%);transform-origin:0 0}
.dm-afx-ph{display:flex;align-items:center;gap:10px;padding:10px var(--dm-s-2) 10px 14px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-afx-ph .dm-afx-ic{width:30px;height:30px;border-radius:var(--dm-r-sm)}
.dm-afx-ptx{flex:1;min-width:0}
.dm-afx-pt{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-afx-pm{font-size:12.5px;color:var(--dm-afx-t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-afx-pb{flex:1;min-height:0;overflow:auto;padding:14px 18px 18px;font-size:13.5px;line-height:1.6;outline:none}
.dm-afx-pb:focus-visible{box-shadow:inset 0 0 0 2px var(--dm-focus)}
.dm-afx-pb h3{margin:var(--dm-s-4) 0 var(--dm-s-1);font-size:12.5px;font-weight:500;color:var(--dm-afx-t2)}
.dm-afx-pb p{margin:0}
.dm-afx-pb :is(ul,ol){margin:0;padding-left:18px}
.dm-afx-pb li+li{margin-top:3px}
.dm-afx-pb li::marker{color:var(--dm-fg2)}
.dm-afx-tb{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}
.dm-afx-tb :is(th,td){padding:7px var(--dm-s-2);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);text-align:left;font-weight:400}
.dm-afx-tb thead th{font-size:12.5px;color:var(--dm-afx-t2);font-weight:500}
.dm-afx-tb .dm-num{text-align:right}
.dm-afx-tb tbody th{font-weight:500}
.dm-afx-pf{display:flex;align-items:center;gap:6px;padding:10px 14px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-afx-pf .dm-pill .dm-afx-lbl{min-width:58px;text-align:left}
.dm-afx-pf .dm-pill.dm-copied{color:var(--dm-fg)!important}
.dm-afx-pf .dm-pill.dm-copied .dm-i{color:var(--dm-ok)}
/* compact: an icon, the name, one line of meta and Open, dense enough to stack several in a thread */
.dm-afx-cp{max-width:440px;display:flex;flex-direction:column;gap:14px}
.dm-afx-cp-a{margin:0;font-size:14px;line-height:1.6;text-wrap:pretty}
.dm-afx-cp-list{display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-afx-cp-card{display:flex;align-items:center;gap:var(--dm-s-3);padding:10px var(--dm-s-2) 10px 10px;border-radius:var(--dm-r-in);background:var(--dm-surface);box-shadow:var(--dm-shadow);transition:background var(--dm-t-fast),box-shadow var(--dm-t-fast);overflow:hidden}
@media (hover:hover) and (pointer:fine){.dm-afx-cp-card:not(.dm-is-writing):hover{background:var(--dm-wash)}}
@media (hover:hover) and (pointer:fine){:root:not([data-style="soft"]) .dm-afx-cp-card:not(.dm-is-writing):hover:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}}
.dm-afx-cp-tx{flex:1;min-width:0}
.dm-afx-cp-t{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-afx-cp-act{display:flex;align-items:center;gap:2px;flex:none;transition:opacity var(--dm-t) var(--dm-ease)}
.dm-afx-cp-act .dm-pill{margin-left:var(--dm-s-1);gap:6px;padding:0 10px}
/* Open is the primary action, on the accent; its hover darkens it, so white text keeps 4.5:1 in dark mode too */
@media (hover:hover) and (pointer:fine){.dm-afx .dm-afx-open.dm-pill.dm-accent:hover{background:color-mix(in srgb,var(--dm-accent) 86%,#000)}}
.dm-is-writing .dm-afx-cp-act{opacity:0;visibility:hidden}
.dm-afx-cp-bar{position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--dm-accent-mark);transform-origin:left center;transform:scaleX(0);opacity:0;transition:transform .9s var(--dm-ease),opacity .3s var(--dm-ease)}
.dm-is-writing .dm-afx-cp-bar{opacity:1}
/* in a narrow column the meta drops the word "edited" before it truncates, and Open keeps only its icon */
@container (max-width:400px){.dm-afx-ed{display:none}.dm-afx-fin{gap:var(--dm-s-1)}.dm-afx-card .dm-afx-ic{width:32px;height:32px;border-radius:9px}.dm-afx-card :is(.dm-afx-copy,.dm-afx-dl,.dm-ib.dm-afx-open){width:28px;height:28px}.dm-afx-cp-act,.dm-afx-pv-act{gap:0}.dm-afx-cp-card{padding-right:6px;gap:10px}.dm-afx-pv-meta{gap:10px;padding-right:6px}.dm-afx-pv-meta .dm-afx-ic{display:none}.dm-afx-cp-act .dm-pill .dm-afx-lbl{display:none}.dm-afx-cp-act .dm-pill{width:28px;height:28px;padding:0;margin-left:2px}}
/* preview: the same card with the first page showing, so drafts are easy to tell apart */
.dm-afx-pv{width:min(100%,380px)}
.dm-afx-pv-card{border-radius:var(--dm-r-card);background:var(--dm-surface);box-shadow:var(--dm-shadow);transition:box-shadow var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-afx-pv-card:not(.dm-is-writing):hover{box-shadow:var(--dm-shadow-pop)}}
@media (hover:hover) and (pointer:fine){:root:not([data-style="soft"]) .dm-afx-pv-card:not(.dm-is-writing):hover:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}}
.dm-afx-pv-prev{position:relative;height:164px;overflow:hidden;padding:18px 22px 0;border-radius:var(--dm-r-card) var(--dm-r-card) 0 0;background:var(--dm-stage);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-afx-pv-page{height:100%;padding:14px var(--dm-s-4) 0;border-radius:var(--dm-r-sm) var(--dm-r-sm) 0 0;background:var(--dm-surface);box-shadow:var(--dm-shadow);transition:transform var(--dm-t) var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-afx-pv-card:not(.dm-is-writing):hover .dm-afx-pv-page{transform:translateY(-3px)}}
.dm-afx-pv-k{font-size:11px;line-height:16px;color:var(--dm-afx-t2)}
.dm-afx-pv-k .dm-afx-wr,.dm-is-writing .dm-afx-pv-k .dm-afx-fin{display:none}
.dm-is-writing .dm-afx-pv-k .dm-afx-wr{display:inline}
.dm-afx-pv-h{margin-top:2px;font-size:13px;font-weight:500;line-height:18px}
.dm-afx-pv-p{margin:6px 0 0;min-height:54px;font-size:11.5px;line-height:18px;color:var(--dm-fg);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.dm-afx-caret{display:inline-block;width:1.5px;height:12px;margin-left:1px;vertical-align:-2px;background:var(--dm-accent-mark);animation:dm-bpblink 1s steps(1,end) infinite}
/* the drawn lines stand for the rest of the page: decoration, kept faint */
.dm-afx-pv-ls{display:flex;flex-direction:column;gap:var(--dm-s-2);margin-top:10px;-webkit-mask-image:linear-gradient(#000 0,transparent 44px);mask-image:linear-gradient(#000 0,transparent 44px)}
.dm-afx-pv-ls i{display:block;height:4px;border-radius:2px;width:var(--dm-w);background:color-mix(in srgb,var(--dm-fg) 8%,var(--dm-surface));transform-origin:left center;transition:width var(--dm-t) var(--dm-ease)}
.dm-is-writing .dm-afx-pv-ls i{transform:scaleX(0)}
.dm-afx-pv-meta{display:flex;align-items:center;gap:var(--dm-s-3);padding:var(--dm-s-3) var(--dm-s-2) var(--dm-s-3) 14px}
.dm-afx-pv-tx{flex:1;min-width:0}
.dm-afx-pv-t{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-afx-pv-act{display:flex;align-items:center;gap:2px;flex:none;transition:opacity var(--dm-t) var(--dm-ease)}
.dm-is-writing .dm-afx-pv-act{opacity:0;visibility:hidden}
/* inline: one line inside the message, like a file you attached; versions and actions fold out on hover or focus */
.dm-afx-in{max-width:440px;display:flex;flex-direction:column;gap:14px}
.dm-afx-in-msg{display:flex;flex-direction:column;gap:10px}
.dm-afx-in-msg p{margin:0;font-size:14px;line-height:1.6;text-wrap:pretty}
.dm-afx-chip{display:inline-flex;align-items:center;align-self:flex-start;max-width:100%;height:34px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);transition:box-shadow var(--dm-t-fast),background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-afx-chip:not(.dm-is-writing):is(:hover,:focus-within,.dm-pin){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent),0 1px 2px rgba(0,0,0,.05)}}
.dm-afx-in-main{display:inline-flex;align-items:center;gap:var(--dm-s-2);min-width:0;height:34px;padding:0 var(--dm-s-3) 0 10px;border-radius:var(--dm-r-pill);font-size:13px}
.dm-afx-in-main:focus-visible{outline-offset:-2px!important}
.dm-afx-in-ic{display:grid;place-items:center;width:16px;height:16px;flex:none;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-afx-in-ic .dm-spin{display:none;width:11px;height:11px}
.dm-is-writing .dm-afx-in-ic .dm-spin{display:inline-block}
.dm-is-writing .dm-afx-in-ic svg{display:none}
.dm-afx-in-t{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.dm-afx-in-m{color:var(--dm-afx-t2);white-space:nowrap;font-size:12.5px}
.dm-afx-in-m .dm-afx-wr{display:none}
.dm-afx-in-m .dm-afx-fin{display:inline-flex;gap:var(--dm-s-1)}
.dm-is-writing .dm-afx-in-m .dm-afx-wr{display:inline}
.dm-is-writing .dm-afx-in-m .dm-afx-fin{display:none}
.dm-afx-tray{display:grid;grid-template-columns:0fr;opacity:0;transition:grid-template-columns var(--dm-t) var(--dm-ease),opacity var(--dm-t-fast) var(--dm-ease)}
.dm-afx-tray>span{display:flex;align-items:center;min-width:0;overflow:hidden}
.dm-afx-tray>span>:last-child{margin-right:3px}
@media (hover:hover) and (pointer:fine){.dm-afx-chip:not(.dm-is-writing):is(:hover,:focus-within,.dm-pin) .dm-afx-tray{grid-template-columns:1fr;opacity:1}}
.dm-afx-tray .dm-ib{width:28px;height:28px}
.dm-afx-tray .dm-ib:focus-visible{outline-offset:-2px}
.dm-afx-tsep{flex:none;width:1px;height:16px;margin-right:3px;background:var(--dm-line)}
@media (hover:none){.dm-afx-chip:not(.dm-is-writing) .dm-afx-tray{grid-template-columns:1fr;opacity:1}}
@container (max-width:400px){.dm-afx-in-m :is(.dm-afx-when,.dm-afx-msep){display:none}}
/* stack: the report as a stack of paper. The newest page is written onto it; Versions lays the sheets out to pick one */
.dm-afx-st{display:flex;flex-direction:column;align-items:center;gap:18px;--dm-sw:150px;--dm-sh:194px;--dm-fx:min(122px,calc((100cqw - var(--dm-sw) - 24px) / 2));--dm-ln:color-mix(in srgb,var(--dm-fg) 26%,var(--dm-surface))}
@container (max-width:360px){.dm-afx-st{--dm-sw:112px;--dm-sh:146px}}
.dm-afx-desk{position:relative;width:var(--dm-sw);height:calc(var(--dm-sh) + 24px)}
/* in Soft the stack casts a soft shadow on the desk, which fades as the sheets are laid out */
.dm-afx-desk::before{content:"";position:absolute;left:4%;right:4%;bottom:-10px;height:26px;border-radius:50%;background:radial-gradient(closest-side,rgba(0,0,0,.13),transparent);transition:opacity var(--dm-t) var(--dm-ease)}
:root[data-theme="dark"] .dm-afx-desk::before{background:radial-gradient(closest-side,rgba(0,0,0,.5),transparent)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-afx-desk::before{background:radial-gradient(closest-side,rgba(0,0,0,.5),transparent)}}
:root:not([data-style="soft"]) .dm-afx-desk:not(:where([data-style="soft"],[data-style="soft"] *))::before,.dm-afx-st.dm-fan .dm-afx-desk::before{opacity:0}
.dm-afx-stack{position:absolute;inset:0}
/* the top sheet stands straight, the older ones sit just behind it; pointing at the stack or focusing it, they peek out
   up and to the left, a few pixels each */
.dm-afx-sh{--dm-pk:-1.5px;position:absolute;left:0;top:12px;width:var(--dm-sw);height:var(--dm-sh);padding:0;text-align:left;border-radius:10px;background:var(--dm-surface);box-shadow:var(--dm-shadow);transform:translate(calc(var(--dm-d,0) * var(--dm-pk)),calc(var(--dm-d,0) * var(--dm-pk)));transition:transform var(--dm-t-slow) var(--dm-ease),box-shadow var(--dm-t) var(--dm-ease)}
.dm-afx-st:not(.dm-fan):not(.dm-is-writing):has(:focus-visible) .dm-afx-sh{--dm-pk:-4px}
@media (hover:hover) and (pointer:fine){.dm-afx-st:not(.dm-fan):not(.dm-is-writing) .dm-afx-desk:hover .dm-afx-sh{--dm-pk:-4px}}
.dm-afx-sh:focus-visible{outline-offset:3px}
.dm-afx-st:not(.dm-fan) .dm-afx-sh[aria-checked="true"]{box-shadow:var(--dm-shadow-pop)}
.dm-afx-st.dm-is-writing .dm-afx-sh{cursor:default}
.dm-afx-pg{position:absolute;inset:0;display:flex;flex-direction:column;padding:var(--dm-s-3);border-radius:inherit;overflow:hidden}
.dm-afx-st-k{display:flex;align-items:center;justify-content:space-between;font-size:11px;line-height:14px;color:var(--dm-afx-t2)}
.dm-afx-st-h{min-height:32px;margin-top:var(--dm-s-1);font-size:12.5px;font-weight:500;line-height:16px;color:var(--dm-fg)}
.dm-afx-st-ls{display:flex;flex-direction:column;gap:7px;margin-top:10px}
.dm-afx-r{position:relative;display:flex;align-items:center;gap:5px;height:4px}
.dm-afx-r i{display:block;height:3px;border-radius:2px;width:var(--dm-w);background:var(--dm-ln);transform-origin:left center}
.dm-afx-r b{flex:none;width:4px;height:4px;border-radius:50%;background:var(--dm-ln)}
.dm-afx-r.dm-n b{width:6px;height:6px;margin:0 -1px;background:var(--dm-accent-mark)}
.dm-afx-gap{height:3px}
.dm-afx-st .dm-blank .dm-afx-r :is(i,b){transform:scaleX(0)}
.dm-afx-pen{position:absolute;left:0;top:0;width:7px;height:7px;border-radius:50%;background:var(--dm-accent-mark);box-shadow:0 0 0 3px color-mix(in srgb,var(--dm-accent-mark) 25%,transparent);opacity:0;pointer-events:none}
@container (max-width:360px){.dm-afx-st-ls{gap:6px;margin-top:var(--dm-s-2)}.dm-afx-st-ls>:nth-child(n+7){display:none}}
.dm-afx-tab{position:absolute;left:50%;top:calc(100% + 10px);display:inline-flex;align-items:center;gap:var(--dm-s-1);transform:translateX(-50%);white-space:nowrap;font-size:11.5px;color:var(--dm-afx-t2);opacity:0;transition:opacity var(--dm-t) var(--dm-ease)}
.dm-afx-tab .dm-i{display:none;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-afx-st.dm-fan .dm-afx-tab{opacity:1}
.dm-afx-sh[aria-checked="true"] .dm-afx-tab{color:var(--dm-fg);font-weight:500}
.dm-afx-sh[aria-checked="true"] .dm-afx-tab .dm-i{display:inline-block}
@container (max-width:360px){.dm-afx-tab .dm-afx-ta{display:none}}
.dm-afx-cap{display:flex;flex-direction:column;align-items:center;max-width:100%;text-align:center;transition:margin var(--dm-t) var(--dm-ease)}
.dm-afx-st.dm-fan .dm-afx-cap{margin-top:22px}
.dm-afx-st-t{font-size:14px;font-weight:500}
.dm-afx-st-m{justify-content:center;max-width:100%}
.dm-afx-note{padding-top:2px;font-size:12.5px;color:var(--dm-fg)}
.dm-afx-st-act{display:flex;align-items:center;gap:var(--dm-s-1);transition:opacity var(--dm-t) var(--dm-ease)}
.dm-afx-st-act .dm-pill{gap:6px}
.dm-afx-st.dm-is-writing .dm-afx-st-act{opacity:0;visibility:hidden}
artifact-card.htmlHTML · 9 lines
<div class="card" data-c="artifact-card">
  <div class="stage-in">
    <div class="afx dm">
      <div class="dm-main afx-main"></div>
      <div class="demo"><div class="seg2" role="group" aria-label="Variant"><button aria-pressed="true" data-v="compact">Compact</button><button aria-pressed="false" data-v="preview">Preview</button><button aria-pressed="false" data-v="inline">Inline</button><button aria-pressed="false" data-v="stack">Stack</button></div></div>
    </div>
  </div>
</div>
artifact-card.cssHTML · 189 lines
/* ---------- Artifact card ---------- */
/* a file the agent made, shown four ways: a compact row, a card with its first page, a chip inside the message, and a stack
   of its versions. Each plays its writing state, then opens, copies, downloads and switches versions the same way */
/* the reader opens over the card's nearest positioned ancestor: on this page the stage, in an app its chat column */
.afx{--afx-t2:var(--muted)}
/* the variants share one room, as tall as the tallest moment (the compact card with its versions open), so switching
   never moves what is below and an open menu still sits centred */
.afx.dm>.afx-main{position:relative;min-height:452px;justify-content:center}
@container (max-width:400px){.afx.dm>.afx-main{min-height:474px}}
/* secondary text: the core's grey in light mode; in dark mode a lighter grey, because the core's reaches 4.5:1 there but
   not APCA Lc 60, which small text needs */
:root[data-theme="dark"] .afx{--afx-t2:#c0c0c0}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .afx{--afx-t2:#c0c0c0}}
.afx>.demo .seg2 button{color:var(--afx-t2)}
.afx>.demo .seg2 button[aria-pressed="true"]{color:var(--fg)}
.afx-sr{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.afx-w{position:relative;width:100%;transition:transform var(--t) var(--ease)}
.afx-ic{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;flex:none;background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
.afx-ic .spin{display:none}
.is-writing .afx-ic .spin{display:inline-block}
.is-writing .afx-ic svg{display:none}
.afx-meta{display:flex;align-items:center;min-width:0;height:20px;font-size:12.5px;color:var(--afx-t2);white-space:nowrap}
.afx-fin{display:flex;align-items:center;gap:5px;min-width:0}
.afx-meta .afx-wr{display:none;overflow:hidden;text-overflow:ellipsis}
.is-writing .afx-meta .afx-wr{display:inline}
.is-writing .afx-meta .afx-fin{display:none}
.afx-when{overflow:hidden;text-overflow:ellipsis;min-width:0}
/* versions: a quiet "v3" that opens a menu */
.afx-vb{position:relative;z-index:1;display:inline-flex;align-items:center;gap:1px;height:20px;padding:0 3px 0 5px;margin:0 -2px;border-radius:6px;font-size:12.5px;font-weight:500;color:var(--fg);transition:background var(--t-fast)}
.afx-vb[aria-expanded="true"]{background:var(--row)}
@media (hover:hover) and (pointer:fine){.afx-vb:hover{background:var(--row)}}
.afx-vb .i{color:var(--fg2)}
.afx-menu{width:264px;max-width:calc(100% - 16px)}
.afx-menu:not(.open){visibility:hidden;transition:opacity .2s var(--ease),transform .35s var(--ease),filter .2s var(--ease),visibility 0s .35s}
.afx-menu .ph{color:var(--afx-t2)}
.afx-menu .mi{align-items:flex-start;gap:var(--s-2)}
.afx-menu .mi:focus-visible{outline-offset:-2px;background:var(--row)}
.afx-mb{display:flex;flex-direction:column;min-width:0}
.afx-mw{font-weight:400;color:var(--afx-t2)}
.afx-menu .mi .d{color:var(--afx-t2);font-weight:400;white-space:normal}
@media (hover:hover) and (pointer:fine){.afx-menu .mi:is(:hover,.hl,[aria-checked="true"]) .d{color:var(--afx-t2);font-weight:400;white-space:normal}}
.afx-menu .mi .ck{margin-top:3px}
/* the whole card opens the file: Open stretches over it, and the other buttons sit above */
.afx-card{position:relative}
.afx-card .afx-open::after{content:"";position:absolute;inset:0;border-radius:inherit}
.afx-card .afx-open{transform:none!important}
.afx-card :is(.afx-copy,.afx-dl,.afx-vb){position:relative;z-index:1}
.afx .copied .afx-lbl{color:var(--fg)}
/* the opened file: a reader over the stage, the stage fading back behind it */
.afx-sheet{position:absolute;inset:0;z-index:20;border-radius:inherit;overflow:hidden}
.afx-scrim{position:absolute;inset:0;background:color-mix(in srgb,var(--stage) 78%,transparent)}
.afx-pan{position:absolute;display:flex;flex-direction:column;background:var(--surface);border-radius:var(--r-card);box-shadow:var(--shadow-pop);overflow:hidden;outline:none}
/* a view fills the stage; a page is narrower and grows out of the preview or sheet it came from */
.afx-pan.afx-view{inset:0}
.afx-pan.afx-page{inset:0;margin:auto;width:min(400px,100%);transform-origin:0 0}
.afx-ph{display:flex;align-items:center;gap:10px;padding:10px var(--s-2) 10px 14px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.afx-ph .afx-ic{width:30px;height:30px;border-radius:var(--r-sm)}
.afx-ptx{flex:1;min-width:0}
.afx-pt{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.afx-pm{font-size:12.5px;color:var(--afx-t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.afx-pb{flex:1;min-height:0;overflow:auto;padding:14px 18px 18px;font-size:13.5px;line-height:1.6;outline:none}
.afx-pb:focus-visible{box-shadow:inset 0 0 0 2px var(--focus)}
.afx-pb h3{margin:var(--s-4) 0 var(--s-1);font-size:12.5px;font-weight:500;color:var(--afx-t2)}
.afx-pb p{margin:0}
.afx-pb :is(ul,ol){margin:0;padding-left:18px}
.afx-pb li+li{margin-top:3px}
.afx-pb li::marker{color:var(--fg2)}
.afx-tb{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}
.afx-tb :is(th,td){padding:7px var(--s-2);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);text-align:left;font-weight:400}
.afx-tb thead th{font-size:12.5px;color:var(--afx-t2);font-weight:500}
.afx-tb .num{text-align:right}
.afx-tb tbody th{font-weight:500}
.afx-pf{display:flex;align-items:center;gap:6px;padding:10px 14px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.afx-pf .pill .afx-lbl{min-width:58px;text-align:left}
.afx-pf .pill.copied{color:var(--fg)!important}
.afx-pf .pill.copied .i{color:var(--ok)}
/* compact: an icon, the name, one line of meta and Open, dense enough to stack several in a thread */
.afx-cp{max-width:440px;display:flex;flex-direction:column;gap:14px}
.afx-cp-a{margin:0;font-size:14px;line-height:1.6;text-wrap:pretty}
.afx-cp-list{display:flex;flex-direction:column;gap:var(--s-2)}
.afx-cp-card{display:flex;align-items:center;gap:var(--s-3);padding:10px var(--s-2) 10px 10px;border-radius:var(--r-in);background:var(--surface);box-shadow:var(--shadow);transition:background var(--t-fast),box-shadow var(--t-fast);overflow:hidden}
@media (hover:hover) and (pointer:fine){.afx-cp-card:not(.is-writing):hover{background:var(--wash)}}
@media (hover:hover) and (pointer:fine){:root:not([data-style="soft"]) .afx-cp-card:not(.is-writing):hover:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}}
.afx-cp-tx{flex:1;min-width:0}
.afx-cp-t{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.afx-cp-act{display:flex;align-items:center;gap:2px;flex:none;transition:opacity var(--t) var(--ease)}
.afx-cp-act .pill{margin-left:var(--s-1);gap:6px;padding:0 10px}
/* Open is the primary action, on the accent; its hover darkens it, so white text keeps 4.5:1 in dark mode too */
@media (hover:hover) and (pointer:fine){.afx .afx-open.pill.accent:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}}
.is-writing .afx-cp-act{opacity:0;visibility:hidden}
.afx-cp-bar{position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--accent-mark);transform-origin:left center;transform:scaleX(0);opacity:0;transition:transform .9s var(--ease),opacity .3s var(--ease)}
.is-writing .afx-cp-bar{opacity:1}
/* in a narrow column the meta drops the word "edited" before it truncates, and Open keeps only its icon */
@container (max-width:400px){.afx-ed{display:none}.afx-fin{gap:var(--s-1)}.afx-card .afx-ic{width:32px;height:32px;border-radius:9px}.afx-card :is(.afx-copy,.afx-dl,.ib.afx-open){width:28px;height:28px}.afx-cp-act,.afx-pv-act{gap:0}.afx-cp-card{padding-right:6px;gap:10px}.afx-pv-meta{gap:10px;padding-right:6px}.afx-pv-meta .afx-ic{display:none}.afx-cp-act .pill .afx-lbl{display:none}.afx-cp-act .pill{width:28px;height:28px;padding:0;margin-left:2px}}
/* preview: the same card with the first page showing, so drafts are easy to tell apart */
.afx-pv{width:min(100%,380px)}
.afx-pv-card{border-radius:var(--r-card);background:var(--surface);box-shadow:var(--shadow);transition:box-shadow var(--t-fast)}
@media (hover:hover) and (pointer:fine){.afx-pv-card:not(.is-writing):hover{box-shadow:var(--shadow-pop)}}
@media (hover:hover) and (pointer:fine){:root:not([data-style="soft"]) .afx-pv-card:not(.is-writing):hover:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}}
.afx-pv-prev{position:relative;height:164px;overflow:hidden;padding:18px 22px 0;border-radius:var(--r-card) var(--r-card) 0 0;background:var(--stage);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.afx-pv-page{height:100%;padding:14px var(--s-4) 0;border-radius:var(--r-sm) var(--r-sm) 0 0;background:var(--surface);box-shadow:var(--shadow);transition:transform var(--t) var(--ease)}
@media (hover:hover) and (pointer:fine){.afx-pv-card:not(.is-writing):hover .afx-pv-page{transform:translateY(-3px)}}
.afx-pv-k{font-size:11px;line-height:16px;color:var(--afx-t2)}
.afx-pv-k .afx-wr,.is-writing .afx-pv-k .afx-fin{display:none}
.is-writing .afx-pv-k .afx-wr{display:inline}
.afx-pv-h{margin-top:2px;font-size:13px;font-weight:500;line-height:18px}
.afx-pv-p{margin:6px 0 0;min-height:54px;font-size:11.5px;line-height:18px;color:var(--fg);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.afx-caret{display:inline-block;width:1.5px;height:12px;margin-left:1px;vertical-align:-2px;background:var(--accent-mark);animation:bpblink 1s steps(1,end) infinite}
/* the drawn lines stand for the rest of the page: decoration, kept faint */
.afx-pv-ls{display:flex;flex-direction:column;gap:var(--s-2);margin-top:10px;-webkit-mask-image:linear-gradient(#000 0,transparent 44px);mask-image:linear-gradient(#000 0,transparent 44px)}
.afx-pv-ls i{display:block;height:4px;border-radius:2px;width:var(--w);background:color-mix(in srgb,var(--fg) 8%,var(--surface));transform-origin:left center;transition:width var(--t) var(--ease)}
.is-writing .afx-pv-ls i{transform:scaleX(0)}
.afx-pv-meta{display:flex;align-items:center;gap:var(--s-3);padding:var(--s-3) var(--s-2) var(--s-3) 14px}
.afx-pv-tx{flex:1;min-width:0}
.afx-pv-t{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.afx-pv-act{display:flex;align-items:center;gap:2px;flex:none;transition:opacity var(--t) var(--ease)}
.is-writing .afx-pv-act{opacity:0;visibility:hidden}
/* inline: one line inside the message, like a file you attached; versions and actions fold out on hover or focus */
.afx-in{max-width:440px;display:flex;flex-direction:column;gap:14px}
.afx-in-msg{display:flex;flex-direction:column;gap:10px}
.afx-in-msg p{margin:0;font-size:14px;line-height:1.6;text-wrap:pretty}
.afx-chip{display:inline-flex;align-items:center;align-self:flex-start;max-width:100%;height:34px;border-radius:var(--r-pill);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);transition:box-shadow var(--t-fast),background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.afx-chip:not(.is-writing):is(:hover,:focus-within,.pin){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent),0 1px 2px rgba(0,0,0,.05)}}
.afx-in-main{display:inline-flex;align-items:center;gap:var(--s-2);min-width:0;height:34px;padding:0 var(--s-3) 0 10px;border-radius:var(--r-pill);font-size:13px}
.afx-in-main:focus-visible{outline-offset:-2px!important}
.afx-in-ic{display:grid;place-items:center;width:16px;height:16px;flex:none;color:var(--accent-fg,var(--accent))}
.afx-in-ic .spin{display:none;width:11px;height:11px}
.is-writing .afx-in-ic .spin{display:inline-block}
.is-writing .afx-in-ic svg{display:none}
.afx-in-t{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.afx-in-m{color:var(--afx-t2);white-space:nowrap;font-size:12.5px}
.afx-in-m .afx-wr{display:none}
.afx-in-m .afx-fin{display:inline-flex;gap:var(--s-1)}
.is-writing .afx-in-m .afx-wr{display:inline}
.is-writing .afx-in-m .afx-fin{display:none}
.afx-tray{display:grid;grid-template-columns:0fr;opacity:0;transition:grid-template-columns var(--t) var(--ease),opacity var(--t-fast) var(--ease)}
.afx-tray>span{display:flex;align-items:center;min-width:0;overflow:hidden}
.afx-tray>span>:last-child{margin-right:3px}
@media (hover:hover) and (pointer:fine){.afx-chip:not(.is-writing):is(:hover,:focus-within,.pin) .afx-tray{grid-template-columns:1fr;opacity:1}}
.afx-tray .ib{width:28px;height:28px}
.afx-tray .ib:focus-visible{outline-offset:-2px}
.afx-tsep{flex:none;width:1px;height:16px;margin-right:3px;background:var(--line)}
@media (hover:none){.afx-chip:not(.is-writing) .afx-tray{grid-template-columns:1fr;opacity:1}}
@container (max-width:400px){.afx-in-m :is(.afx-when,.afx-msep){display:none}}
/* stack: the report as a stack of paper. The newest page is written onto it; Versions lays the sheets out to pick one */
.afx-st{display:flex;flex-direction:column;align-items:center;gap:18px;--sw:150px;--sh:194px;--fx:min(122px,calc((100cqw - var(--sw) - 24px) / 2));--ln:color-mix(in srgb,var(--fg) 26%,var(--surface))}
@container (max-width:360px){.afx-st{--sw:112px;--sh:146px}}
.afx-desk{position:relative;width:var(--sw);height:calc(var(--sh) + 24px)}
/* in Soft the stack casts a soft shadow on the desk, which fades as the sheets are laid out */
.afx-desk::before{content:"";position:absolute;left:4%;right:4%;bottom:-10px;height:26px;border-radius:50%;background:radial-gradient(closest-side,rgba(0,0,0,.13),transparent);transition:opacity var(--t) var(--ease)}
:root[data-theme="dark"] .afx-desk::before{background:radial-gradient(closest-side,rgba(0,0,0,.5),transparent)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .afx-desk::before{background:radial-gradient(closest-side,rgba(0,0,0,.5),transparent)}}
:root:not([data-style="soft"]) .afx-desk:not(:where([data-style="soft"],[data-style="soft"] *))::before,.afx-st.fan .afx-desk::before{opacity:0}
.afx-stack{position:absolute;inset:0}
/* the top sheet stands straight, the older ones sit just behind it; pointing at the stack or focusing it, they peek out
   up and to the left, a few pixels each */
.afx-sh{--pk:-1.5px;position:absolute;left:0;top:12px;width:var(--sw);height:var(--sh);padding:0;text-align:left;border-radius:10px;background:var(--surface);box-shadow:var(--shadow);transform:translate(calc(var(--d,0) * var(--pk)),calc(var(--d,0) * var(--pk)));transition:transform var(--t-slow) var(--ease),box-shadow var(--t) var(--ease)}
.afx-st:not(.fan):not(.is-writing):has(:focus-visible) .afx-sh{--pk:-4px}
@media (hover:hover) and (pointer:fine){.afx-st:not(.fan):not(.is-writing) .afx-desk:hover .afx-sh{--pk:-4px}}
.afx-sh:focus-visible{outline-offset:3px}
.afx-st:not(.fan) .afx-sh[aria-checked="true"]{box-shadow:var(--shadow-pop)}
.afx-st.is-writing .afx-sh{cursor:default}
.afx-pg{position:absolute;inset:0;display:flex;flex-direction:column;padding:var(--s-3);border-radius:inherit;overflow:hidden}
.afx-st-k{display:flex;align-items:center;justify-content:space-between;font-size:11px;line-height:14px;color:var(--afx-t2)}
.afx-st-h{min-height:32px;margin-top:var(--s-1);font-size:12.5px;font-weight:500;line-height:16px;color:var(--fg)}
.afx-st-ls{display:flex;flex-direction:column;gap:7px;margin-top:10px}
.afx-r{position:relative;display:flex;align-items:center;gap:5px;height:4px}
.afx-r i{display:block;height:3px;border-radius:2px;width:var(--w);background:var(--ln);transform-origin:left center}
.afx-r b{flex:none;width:4px;height:4px;border-radius:50%;background:var(--ln)}
.afx-r.n b{width:6px;height:6px;margin:0 -1px;background:var(--accent-mark)}
.afx-gap{height:3px}
.afx-st .blank .afx-r :is(i,b){transform:scaleX(0)}
.afx-pen{position:absolute;left:0;top:0;width:7px;height:7px;border-radius:50%;background:var(--accent-mark);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent-mark) 25%,transparent);opacity:0;pointer-events:none}
@container (max-width:360px){.afx-st-ls{gap:6px;margin-top:var(--s-2)}.afx-st-ls>:nth-child(n+7){display:none}}
.afx-tab{position:absolute;left:50%;top:calc(100% + 10px);display:inline-flex;align-items:center;gap:var(--s-1);transform:translateX(-50%);white-space:nowrap;font-size:11.5px;color:var(--afx-t2);opacity:0;transition:opacity var(--t) var(--ease)}
.afx-tab .i{display:none;color:var(--accent-fg,var(--accent))}
.afx-st.fan .afx-tab{opacity:1}
.afx-sh[aria-checked="true"] .afx-tab{color:var(--fg);font-weight:500}
.afx-sh[aria-checked="true"] .afx-tab .i{display:inline-block}
@container (max-width:360px){.afx-tab .afx-ta{display:none}}
.afx-cap{display:flex;flex-direction:column;align-items:center;max-width:100%;text-align:center;transition:margin var(--t) var(--ease)}
.afx-st.fan .afx-cap{margin-top:22px}
.afx-st-t{font-size:14px;font-weight:500}
.afx-st-m{justify-content:center;max-width:100%}
.afx-note{padding-top:2px;font-size:12.5px;color:var(--fg)}
.afx-st-act{display:flex;align-items:center;gap:var(--s-1);transition:opacity var(--t) var(--ease)}
.afx-st-act .pill{gap:6px}
.afx-st.is-writing .afx-st-act{opacity:0;visibility:hidden}
artifact-card.jsHTML · 450 lines
DM.def('artifact-card', (r, c) => {
  /* the file: one report in three versions, and the table it cites. A component that shows the card in its own chat can
     give it its own file with use() */
  const F = {
    look:'The three plans look alike at a glance.',
    year:'The yearly switch hides what a month costs.',
    cancel:'Nothing near the buttons says you can cancel any time.',
    faq:'The FAQ repeats the plan table instead of answering doubts.',
    price:'Team costs 16% more than similar plans elsewhere, and the page never says why.'
  };
  const REPORT = { title:'Pricing page review', kind:'Report', icon:'file-text', file:'pricing-page-review', ext:'md', mime:'text/markdown',
    steps:['Reading the page…', 'Writing the findings…', 'Ordering the fixes…'], versions:[
    { ago:'2 hours ago', when:'edited 2 hours ago', note:'First pass, four findings',
      changes:['Read the page from top to bottom', 'Listed four findings in page order'],
      summary:'People reach the plan table and stall. Most of what slows them down is clarity, not price.',
      findings:[F.look, F.year, F.cancel, F.faq] },
    { ago:'40 min ago', when:'edited 40 min ago', note:'Added a competitor price check',
      changes:['Compared Team with three competitors', 'Dropped the FAQ finding, which overlapped another'],
      summary:'People reach the plan table and stall. Team also costs more than similar plans elsewhere, and the page never says why.',
      findings:[F.look, F.year, F.cancel, F.price] },
    { ago:'just now', when:'edited just now', note:'Fixes, ordered by impact',
      changes:['Turned each finding into a fix', 'Ordered the fixes by how many people they reach'],
      summary:'People reach the plan table and stall. Three changes should move most of them on, and none of them touch the price.',
      findings:[F.look, F.year, F.cancel, F.price],
      fixes:['Show the monthly price beside the yearly one.', 'Mark Team as the plan most people choose.', 'Put “Cancel any time” under each button.'] }
  ] };
  const TABLE = { title:'Competitor prices', kind:'Table', icon:'table', file:'competitor-prices', ext:'csv', mime:'text/csv', versions:[
    { ago:'1 hour ago', when:'edited 1 hour ago', note:'Team and Business at three competitors', changes:['Collected list prices, billed monthly'],
      rows:[['Plan', 'Ours', 'Northwind', 'Halcyon', 'Fernway'], ['Team', '$29', '$22', '$25', '$28'], ['Business', '$79', '$69', '$75', '$89']] }] };

  const own = r === c.st, root = $('.afx', r), main = $('.afx-main', r), demo = $(':scope>.demo', root);
  // inside another component (Agent harness puts it in a chat) the card is just the file: no chat around it, no switcher
  if (!own){ root.classList.remove('dm'); if (demo) demo.remove(); }
  let art = REPORT, opts = {}, view = 'compact', K = null, now = { ready:false, m:null };

  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
  const ic = (n, s = '') => `<svg class="i${s ? ' ' + s : ''}" aria-hidden="true"><use href="#i-${n}"/></svg>`;
  const uid = () => 'afx' + Math.random().toString(36).slice(2, 9);
  const whenHTML = v => `<span class="afx-ed">edited </span>${esc(v.ago)}`;
  const meta = (a, i) => `${a.kind} · v${i + 1} · ${a.versions[i].when}`;
  const cssMs = (el, v, f = 1) => { const s = getComputedStyle(el).getPropertyValue(v).trim(); return (s.endsWith('ms') ? parseFloat(s) : parseFloat(s) * 1000) * f || 300; };
  const cssEase = el => getComputedStyle(el).getPropertyValue('--ease').trim() || 'ease';
  // what Copy copies, Download saves and the reader shows: Markdown for a report, CSV for a table
  function text(a, i){
    const v = a.versions[i];
    if (a.ext === 'csv') return v.rows.map(x => x.join(',')).join('\n') + '\n';
    let s = `# ${a.title}\n\nv${i + 1}, ${v.when}\n\n## Summary\n\n${v.summary}\n\n## Findings\n\n${v.findings.map(f => '- ' + f).join('\n')}\n`;
    if (v.fixes) s += `\n## What to fix first\n\n${v.fixes.map((f, k) => (k + 1) + '. ' + f).join('\n')}\n`;
    return s + `\n## Changes in v${i + 1}\n\n${v.changes.map(f => '- ' + f).join('\n')}\n`;
  }
  function body(a, i){
    const v = a.versions[i], li = l => l.map(x => `<li>${esc(x)}</li>`).join('');
    const chg = `<h3>Changes in v${i + 1}</h3><ul>${li(v.changes)}</ul>`;
    if (a.ext === 'csv') return `<table class="afx-tb"><thead><tr>${v.rows[0].map((h, k) => `<th scope="col"${k ? ' class="num"' : ''}>${esc(h)}</th>`).join('')}</tr></thead><tbody>${v.rows.slice(1).map(x => `<tr>${x.map((y, k) => k ? `<td class="num">${esc(y)}</td>` : `<th scope="row">${esc(y)}</th>`).join('')}</tr>`).join('')}</tbody></table>${chg}`;
    return `<p>${esc(v.summary)}</p><h3>Findings</h3><ul>${li(v.findings)}</ul>${v.fixes ? `<h3>What to fix first</h3><ol>${li(v.fixes)}</ol>` : ''}${chg}`;
  }
  // which version a card shows; the parts that depend on it subscribe
  const model = a => { const m = { a, i:a.versions.length - 1, subs:[] }; m.on = f => m.subs.push(f); m.set = i => { if (i === m.i) return; m.i = i; m.subs.forEach(f => f(i)); }; return m; };
  // each variant runs on its own clock, stopped when the switcher shows another, on the card's pace (a chat that replays
  // at speed keeps it)
  const sub = () => { const k = { dead:false, offs:[] };
    k.wait = ms => c.wait(ms).then(() => { if (k.dead) throw 'stop'; });
    k.every = (ms, fn) => { const id = c.every(ms, () => { if (!k.dead) fn(); }); k.offs.push(() => clearInterval(id)); return id; };
    k.on = (el, ev, fn, o) => { el.addEventListener(ev, fn, o); k.offs.push(() => el.removeEventListener(ev, fn, o)); };
    k.stop = () => { k.dead = true; k.offs.forEach(f => f()); k.offs = []; };
    return k; };
  c.offs.push(() => K && K.stop());

  /* a polite status line, so writing, copying, downloading and switching versions are announced too */
  const live = html('<div class="afx-live afx-sr" role="status" aria-live="polite"></div>');
  root.append(live);
  const say = msg => { live.textContent = ''; c.later(60, () => { live.textContent = msg; }); };
  /* feedback on a button that did something: a check, a word in the tooltip or the label, then back */
  function flash(btn, label){
    const use = $('use', btn), lb = $('.afx-lbl', btn);
    const o = btn._fl || (btn._fl = { href:use && use.getAttribute('href'), tip:btn.dataset.tip, txt:lb && lb.textContent });
    clearTimeout(o.t);
    if (use) use.setAttribute('href', o.href.replace(/[\w-]+$/, 'check'));
    if (lb) lb.textContent = label;
    btn.classList.add('copied');
    if (o.tip !== undefined){ btn.dataset.tip = label; showTip(btn); }
    o.t = setTimeout(() => {
      if (use) use.setAttribute('href', o.href);
      if (lb) lb.textContent = o.txt;
      btn.classList.remove('copied');
      if (o.tip !== undefined){ btn.dataset.tip = o.tip; if (tipFor === btn) hideTip(true); }
    }, 1600);
  }
  function doCopy(btn, m){
    try { navigator.clipboard.writeText(text(m.a, m.i)).catch(() => {}); } catch(e){}
    flash(btn, 'Copied'); say(`Copied ${m.a.title}, v${m.i + 1}, to the clipboard`);
  }
  function doDownload(btn, m){
    const name = `${m.a.file}-v${m.i + 1}.${m.a.ext}`, url = URL.createObjectURL(new Blob([text(m.a, m.i)], { type:m.a.mime }));
    const a = document.createElement('a'); a.href = url; a.download = name; a.hidden = true; document.body.append(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
    flash(btn, 'Downloaded'); say(`Downloaded ${name}`);
  }
  /* the variant moves up as a menu opens under its button, so the two sit centred together in the card's room (above the
     switcher, where it shows), as dropdowns do across the library. The menu opens where the button is going, so it never
     reaches past the room on the way. Returns how far the variant still has to move. In a chat, which scrolls, it stays */
  function glide(wrap, btn, menu){
    if (!own) return 0;
    const cur = new DOMMatrixReadOnly(getComputedStyle(wrap).transform).m42;
    if (!menu){ wrap.style.transform = ''; return -cur; }
    const w = wrap.getBoundingClientRect(), b = btn.getBoundingClientRect(), rr = root.getBoundingClientRect();
    const top0 = rr.top, bot0 = demo && demo.offsetParent ? demo.getBoundingClientRect().top - (parseFloat(getComputedStyle(root).rowGap) || 0) : rr.bottom;
    const mt = b.bottom - cur + 6, top = Math.min(w.top - cur, mt), bot = Math.max(w.bottom - cur, mt + menu.offsetHeight);
    const d = Math.max(top0 - top, (top0 + bot0) / 2 - (top + bot) / 2), to = Math.abs(d) > .5 ? Math.round(d) : 0;
    wrap.style.transform = to ? `translateY(${to}px)` : '';
    return to - cur;
  }
  /* versions: a menu button with one radio item per version, newest first. Arrow keys move, Enter picks, Esc closes */
  function vmenu(k, m, btn, wrap, o = {}){
    const id = uid(), vs = m.a.versions;
    const menu = html(`<div class="pop afx-menu" id="${id}" role="menu" aria-label="Versions of ${esc(m.a.title)}" inert><div class="ph" aria-hidden="true">Versions</div>${vs.map((v, j) => j).reverse().map(j => `<button type="button" class="mi" role="menuitemradio" tabindex="-1" data-i="${j}" aria-checked="false"><span class="afx-mb"><span>v${j + 1}<span class="afx-mw"> · ${esc(vs[j].ago)}</span></span><span class="d">${esc(vs[j].note)}</span></span>${ic('check', 's ck')}</button>`).join('')}</div>`);
    // on the card's own stage the menu sits in the room, not in the variant, so it can open where the variant is going
    const host = own ? main : wrap; host.append(menu);
    btn.setAttribute('aria-haspopup', 'menu'); btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-controls', id);
    const items = () => $$('.mi', menu);
    let open = false;
    const place = (dy = 0) => {
      const hr = host.getBoundingClientRect(), wr = wrap.getBoundingClientRect(), br = btn.getBoundingClientRect(), rr = root.getBoundingClientRect(), mw = menu.offsetWidth;
      const lo = Math.max(rr.left, wr.left), hi = Math.min(rr.right, wr.right) - mw, x = Math.max(lo, Math.min(br.left - 6, hi));
      menu.style.left = Math.round(x - hr.left) + 'px'; menu.style.top = Math.round(br.bottom + dy - hr.top + 6) + 'px';
    };
    // the visitor's open puts the focus on the picked version; a demo's open leaves the focus where it is
    const show = (focus = true) => {
      items().forEach(b => b.setAttribute('aria-checked', String(+b.dataset.i === m.i)));
      open = true; hideTip(); menu.inert = false; o.onOpen && o.onOpen(); place(glide(wrap, btn, menu)); menu.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
      if (focus) (items().find(b => b.getAttribute('aria-checked') === 'true') || items()[0]).focus({ preventScroll:true });
    };
    const hide = refocus => {
      if (!open) return; open = false; menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false');
      if (refocus) btn.focus({ preventScroll:true });
      menu.inert = true; glide(wrap, btn, null); o.onClose && o.onClose();
    };
    k.on(btn, 'click', e => { e.stopPropagation(); open ? hide(false) : show(e.isTrusted); });
    k.on(btn, 'keydown', e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); show(); } });
    k.on(menu, 'keydown', e => {
      const it = items(), j = it.indexOf(document.activeElement);
      if (e.key === 'ArrowDown'){ e.preventDefault(); it[(j + 1) % it.length].focus(); }
      else if (e.key === 'ArrowUp'){ e.preventDefault(); it[(j - 1 + it.length) % it.length].focus(); }
      else if (e.key === 'Home'){ e.preventDefault(); it[0].focus(); }
      else if (e.key === 'End'){ e.preventDefault(); it[it.length - 1].focus(); }
      else if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); hide(true); }
      else if (e.key === 'Tab'){ hide(false); }
    });
    k.on(menu, 'click', e => { const b = e.target.closest('.mi'); if (!b) return; m.set(+b.dataset.i); hide(menu.contains(document.activeElement)); });
    k.on(document, 'pointerdown', e => { if (open && !menu.contains(e.target) && !btn.contains(e.target)) hide(false); });
    k.on(window, 'resize', e => { if (e.isTrusted) hide(false); });
    return { hide };
  }
  /* the opened file: a view over the stage, or a page that grows out of the preview or sheet it came from. It is a modal
     dialog inside the card: focus moves in and stays in, Esc or the scrim closes it, and focus goes back to the opener */
  function sheet(k, m, wrap, { kind = 'view', opener, src = null } = {}){
    if ($(':scope>.afx-sheet', root)) return;
    const id = uid();
    const layer = html(`<div class="afx-sheet"><div class="afx-scrim"></div><div class="afx-pan afx-${kind}" role="dialog" aria-modal="true" aria-labelledby="${id}" tabindex="-1">
      <div class="afx-ph"><span class="afx-ic" aria-hidden="true">${ic(m.a.icon)}</span><div class="afx-ptx"><div class="afx-pt" id="${id}">${esc(m.a.title)}</div><div class="afx-pm">${esc(meta(m.a, m.i))}</div></div><button type="button" class="ib afx-x" aria-label="Close" data-tip="Close" data-tip-k="Esc">${ic('x')}</button></div>
      <div class="afx-pb" tabindex="0" role="region" aria-label="${esc(m.a.title)}, v${m.i + 1}">${body(m.a, m.i)}</div>
      <div class="afx-pf"><button type="button" class="pill sm line afx-copy">${ic('copy', 's')}<span class="afx-lbl">Copy</span></button><button type="button" class="pill sm line afx-dl">${ic('down', 's')}<span class="afx-lbl">Download</span></button></div>
    </div></div>`);
    root.append(layer); main.inert = true; if (demo) demo.inert = true; hideTip();
    const pan = $('.afx-pan', layer), scrim = $('.afx-scrim', layer), D = cssMs(root, '--t', kind === 'page' ? 1.3 : 1.1), E = cssEase(root);
    const morph = back => {
      const a = src.getBoundingClientRect(), b = pan.getBoundingClientRect();
      const f = { transform:`translate(${a.left - b.left}px,${a.top - b.top}px) scale(${a.width / b.width},${a.height / b.height})` };
      [...pan.children].forEach(x => x.animate(back ? [{ opacity:1 }, { opacity:0, offset:.4 }, { opacity:0 }] : [{ opacity:0 }, { opacity:0, offset:.45 }, { opacity:1 }], { duration:D, fill:back ? 'forwards' : 'none' }));
      return pan.animate(back ? [{ transform:'none' }, f] : [f, { transform:'none' }], { duration:D, easing:E, fill:back ? 'forwards' : 'none' }).finished;
    };
    if (!REDUCE){
      scrim.animate([{ opacity:0 }, { opacity:1 }], { duration:D, easing:'ease-out' });
      if (src) morph(false).catch(() => {});
      else pan.animate([{ transform:'translateY(16px)', opacity:0 }, { transform:'none', opacity:1 }], { duration:D, easing:E });
    }
    if (src) src.style.visibility = 'hidden';
    $('.afx-x', layer).focus({ preventScroll:true });
    let closing = false;
    const close = async () => {
      if (closing) return; closing = true; hideTip();
      if (!REDUCE){
        const out = D * .8;
        scrim.animate([{ opacity:1 }, { opacity:0 }], { duration:out, fill:'forwards' });
        try { await (src ? morph(true) : pan.animate([{ transform:'none', opacity:1 }, { transform:'translateY(16px)', opacity:0 }], { duration:out, easing:E, fill:'forwards' }).finished); } catch(e){}
      }
      if (k.dead) return;
      layer.remove(); main.inert = false; if (demo) demo.inert = false; if (src) src.style.visibility = '';
      opener.focus({ preventScroll:true });
    };
    k.on($('.afx-x', layer), 'click', close);
    k.on(scrim, 'click', close);
    k.on($('.afx-copy', layer), 'click', e => doCopy(e.currentTarget, m));
    k.on($('.afx-dl', layer), 'click', e => doDownload(e.currentTarget, m));
    k.on(layer, 'keydown', e => {
      if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); close(); return; }
      if (e.key !== 'Tab') return;
      const f = $$('button,[tabindex="0"]', pan), first = f[0], last = f[f.length - 1];
      if (e.shiftKey && (document.activeElement === first || document.activeElement === pan)){ e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last){ e.preventDefault(); first.focus(); }
    });
    say(`Opened ${m.a.title}, v${m.i + 1}`);
  }
  // Open: the card's own reader, or what the component around it does instead (open it beside the chat)
  const open = (k, m, wrap, how) => opts.open ? opts.open(m.i) : sheet(k, m, wrap, how);
  const openTip = btn => { if (opts.openLabel){ btn.setAttribute('aria-label', opts.openLabel); btn.dataset.tip = opts.openLabel; } };
  /* the writing state settles into the finished card: icon, meta and actions come in together */
  function settle(el, m){ el.classList.remove('is-writing'); const f = $('.afx-fin', el); if (f) enter(f); now.ready = true; say(`${m.a.title} is ready, v${m.i + 1}`); }
  async function phases(k, a, w, step){ for (let j = 0; j < a.steps.length; j++){ if (w) swap(w, a.steps[j]); step && step(j); await k.wait(1100); } }
  /* a version change updates the meta line and the version button */
  function bindMeta(el, m){
    const vb = $('.afx-vb', el), when = $('.afx-when', el);
    const sync = i => {
      if (vb){ $('.afx-vt', vb).textContent = 'v' + (i + 1); vb.setAttribute('aria-label', `v${i + 1} of ${m.a.versions.length} versions`); }
      $$('.afx-v', el).forEach(e => { e.textContent = 'v' + (i + 1); });
      if (when) swap(when, whenHTML(m.a.versions[i]), true);
    };
    if (when) when.innerHTML = whenHTML(m.a.versions[m.i]);
    sync(m.i); m.on(i => { sync(i); say(`Showing v${i + 1}, ${m.a.versions[i].when}`); });
  }
  // the meta line: the kind, the version (a menu when there are several) and when it was edited; the writing line stands in for it
  const metaHTML = (a, cls = '') => `<div class="afx-meta${cls}"><span class="afx-wr shim">${esc((a.steps || [''])[0])}</span><span class="afx-fin"><span>${esc(a.kind)}</span><span aria-hidden="true">·</span>${a.versions.length > 1 ? `<button type="button" class="afx-vb"><span class="afx-vt"></span>${ic('chev-d', 'xs')}</button>` : '<span class="afx-v"></span>'}<span aria-hidden="true">·</span><span class="afx-when"></span></span></div>`;
  const acts = (a, openHTML) => `<button type="button" class="ib afx-copy" aria-label="Copy ${esc(a.title)}" data-tip="Copy">${ic('copy')}</button><button type="button" class="ib afx-dl" aria-label="Download ${esc(a.title)}" data-tip="Download">${ic('down')}</button>${openHTML}`;

  /* compact: the pattern people already know, an icon, the name, one line of meta and Open */
  function compact(k){
    const cardHTML = (a, key, writing) => `<div class="afx-cp-card afx-card${writing ? ' is-writing' : ''}" data-a="${key}">
      <span class="afx-ic"><span class="spin" aria-hidden="true"></span>${ic(a.icon)}</span>
      <div class="afx-cp-tx"><div class="afx-cp-t">${esc(a.title)}</div>${metaHTML(a)}</div>
      <div class="afx-cp-act">${acts(a, `<button type="button" class="pill sm accent afx-open" aria-label="Open ${esc(a.title)}">${ic('panel', 's')}<span class="afx-lbl">Open</span></button>`)}</div>
      ${writing ? '<span class="afx-cp-bar" aria-hidden="true"></span>' : ''}
    </div>`;
    main.innerHTML = `<div class="afx-w afx-cp">${own ? `<div class="bub">Review our pricing page and tell me what to fix first.</div><p class="afx-cp-a">I went through the page from top to bottom and checked our prices against three competitors. Both are below.</p>` : ''}<div class="afx-cp-list">${own && art === REPORT ? cardHTML(TABLE, 'table', false) : ''}${cardHTML(art, 'main', true)}</div></div>`;
    const wrap = $('.afx-w', main);
    $$('.afx-cp-card', main).forEach(card => {
      const a = card.dataset.a === 'table' ? TABLE : art, m = model(a), op = $('.afx-open', card);
      if (a === art) now.m = m;
      bindMeta(card, m); openTip(op);
      const vb = $('.afx-vb', card); if (vb) vmenu(k, m, vb, wrap);
      k.on($('.afx-copy', card), 'click', e => doCopy(e.currentTarget, m));
      k.on($('.afx-dl', card), 'click', e => doDownload(e.currentTarget, m));
      k.on(op, 'click', () => { if (!card.classList.contains('is-writing')) open(k, m, wrap, { kind:'view', opener:op }); });
      if (card.dataset.a !== 'main') return;
      const bar = $('.afx-cp-bar', card);
      c.run(async () => {
        await k.wait(300);
        await phases(k, a, $('.afx-wr', card), j => { bar.style.transform = `scaleX(${(j + 1) / a.steps.length})`; });
        settle(card, m); bar.remove();
      });
    });
  }

  /* preview: the same card with the first page showing; the page grows into the reader when you open it */
  function preview(k){
    const a = art, m = model(a), W = [[96, 88, 70, 82], [92, 84, 90, 64], [90, 78, 86, 72]];
    main.innerHTML = `<div class="afx-w afx-pv"><div class="afx-pv-card afx-card is-writing">
      <div class="afx-pv-prev" aria-hidden="true"><div class="afx-pv-page">
        <div class="afx-pv-k"><span class="afx-wr">Writing…</span><span class="afx-fin"></span></div>
        <div class="afx-pv-h">${esc(a.title)}</div><p class="afx-pv-p"></p>
        <div class="afx-pv-ls"><i></i><i></i><i></i><i></i></div>
      </div></div>
      <div class="afx-pv-meta"><span class="afx-ic"><span class="spin" aria-hidden="true"></span>${ic(a.icon)}</span>
        <div class="afx-pv-tx"><div class="afx-pv-t">${esc(a.title)}</div>${metaHTML(a)}</div>
        <div class="afx-pv-act">${acts(a, `<button type="button" class="ib afx-open" aria-label="Open ${esc(a.title)}" data-tip="Open">${ic('max')}</button>`)}</div>
      </div>
    </div></div>`;
    const wrap = $('.afx-w', main), card = $('.afx-pv-card', main), op = $('.afx-open', card), p = $('.afx-pv-p', main), kick = $('.afx-pv-k .afx-fin', main);
    const fill = i => { $$('.afx-pv-ls i', main).forEach((e, j) => e.style.setProperty('--w', W[i % 3][j] + '%')); };
    const last = a.versions.length - 1, sum = i => a.versions[i].summary || '';
    now.m = m; bindMeta(card, m); fill(m.i); openTip(op); kick.textContent = `${a.kind} · v${m.i + 1}`;
    m.on(i => { swap(kick, `${a.kind} · v${i + 1}`); swap(p, sum(i)); fill(i); });
    const vb = $('.afx-vb', card); if (vb) vmenu(k, m, vb, wrap);
    k.on($('.afx-copy', card), 'click', e => doCopy(e.currentTarget, m));
    k.on($('.afx-dl', card), 'click', e => doDownload(e.currentTarget, m));
    k.on(op, 'click', () => { if (!card.classList.contains('is-writing')) open(k, m, wrap, { kind:'page', opener:op, src:$('.afx-pv-page', main) }); });
    c.run(async () => {
      await k.wait(300);
      await phases(k, a, $('.afx-meta .afx-wr', card), j => {
        if (j === 1){
          // the summary is written out a few letters at a time, with a caret
          const s = sum(last);
          if (REDUCE){ p.textContent = s; return; }
          let n = 0; const id = k.every(22, () => { n = Math.min(s.length, n + 3); p.innerHTML = esc(s.slice(0, n)) + '<span class="afx-caret"></span>'; if (n >= s.length) clearInterval(id); });
        }
        if (j === 2){ p.textContent = sum(last); if (!REDUCE) $$('.afx-pv-ls i', main).forEach((e, x) => e.animate([{ transform:'scaleX(0)' }, { transform:'scaleX(1)' }], { duration:420, delay:x * 180, easing:cssEase(root), fill:'forwards' })); }
      });
      p.textContent = sum(m.i);
      $$('.afx-pv-ls i', main).forEach(e => e.getAnimations().forEach(x => x.cancel()));
      settle(card, m);
    });
  }

  /* inline: one line inside the message, like a file you attached; versions and actions fold out on hover or focus */
  function inline(k){
    const a = art, m = model(a);
    main.innerHTML = `<div class="afx-w afx-in">${own ? '<div class="bub">Review our pricing page and tell me what to fix first.</div>' : ''}<div class="afx-in-msg">
      ${own ? '<p>I went through the pricing page from top to bottom and wrote up what to fix first.</p>' : ''}
      <span class="afx-chip is-writing">
        <button type="button" class="afx-in-main" disabled><span class="afx-in-ic"><span class="spin" aria-hidden="true"></span>${ic(a.icon, 's')}</span><span class="afx-in-t"><span class="afx-sr">Open </span>${esc(a.title)}</span><span class="afx-in-m"><span class="afx-wr shim">${esc(a.steps[0])}</span><span class="afx-fin"><span class="afx-v"></span><span class="afx-msep" aria-hidden="true">·</span><span class="afx-when"></span></span></span></button>
        <span class="afx-tray"><span>
          <span class="afx-tsep" aria-hidden="true"></span>
          ${a.versions.length > 1 ? `<button type="button" class="ib afx-vb3" aria-label="Versions" data-tip="Versions">${ic('history', 's')}</button>` : ''}
          <button type="button" class="ib afx-copy" aria-label="Copy ${esc(a.title)}" data-tip="Copy">${ic('copy', 's')}</button>
          <button type="button" class="ib afx-dl" aria-label="Download ${esc(a.title)}" data-tip="Download">${ic('down', 's')}</button>
        </span></span>
      </span>
      ${own ? '<p>The short version: the plan table is where people stall, and three small changes should move most of them on.</p>' : ''}
    </div></div>`;
    const wrap = $('.afx-w', main), chip = $('.afx-chip', main), btn = $('.afx-in-main', main), vb = $('.afx-vb3', main);
    now.m = m; bindMeta(chip, m);
    if (opts.openLabel) btn.dataset.tip = opts.openLabel;
    if (vb) vmenu(k, m, vb, wrap, { onOpen:() => chip.classList.add('pin'), onClose:() => chip.classList.remove('pin') });
    k.on($('.afx-copy', chip), 'click', e => doCopy(e.currentTarget, m));
    k.on($('.afx-dl', chip), 'click', e => doDownload(e.currentTarget, m));
    k.on(btn, 'click', () => open(k, m, wrap, { kind:'view', opener:btn }));
    c.run(async () => {
      await k.wait(300);
      await phases(k, a, $('.afx-in-m .afx-wr', chip));
      btn.disabled = false; settle(chip, m);
    });
  }

  /* stack: the report as a stack of paper. The newest page is written onto it; Versions lays the sheets out to pick one */
  function stack(k){
    const a = art, m = model(a), N = a.versions.length, top = N - 1, T = a.title;
    // the drawn lines stand for each page's text: p a paragraph line, b a bullet, n a numbered fix, s a gap
    const LINES = [
      [['p', 94], ['p', 72], ['s'], ['b', 82], ['b', 74], ['b', 66], ['b', 78], ['s'], ['p', 88], ['p', 52]],
      [['p', 92], ['p', 86], ['p', 56], ['s'], ['b', 80], ['b', 72], ['b', 86], ['b', 64], ['s'], ['p', 70]],
      [['p', 90], ['p', 78], ['s'], ['n', 84], ['n', 70], ['n', 78], ['s'], ['b', 76], ['b', 68], ['b', 82]]
    ];
    const lines = j => LINES[j === top ? 2 : j % 2].map(([t, w]) => t === 's' ? '<span class="afx-gap"></span>' : `<span class="afx-r ${t}">${t === 'p' ? '' : '<b></b>'}<i style="--w:${w}%"></i></span>`).join('');
    main.innerHTML = `<div class="afx-w afx-st is-writing">
      <div class="afx-desk"><div class="afx-stack" role="menu" aria-label="Versions of ${esc(T)}" aria-hidden="true">${a.versions.map((v, j) => `<button type="button" class="afx-sh${j === top ? ' blank' : ''}" role="menuitemradio" tabindex="-1" data-i="${j}" aria-checked="${j === top}" aria-label="v${j + 1}, ${esc(v.when)}: ${esc(v.note)}"><span class="afx-pg" aria-hidden="true"><span class="afx-st-k"><span>${j === top ? 'Writing…' : 'v' + (j + 1)}</span></span><span class="afx-st-h">${j === top ? '' : esc(T)}</span><span class="afx-st-ls">${lines(j)}</span>${j === top ? '<span class="afx-pen"></span>' : ''}</span><span class="afx-tab" aria-hidden="true">${ic('check', 'xs')}<span>v${j + 1}</span><span class="afx-ta"> · ${esc(v.ago)}</span></span></button>`).join('')}</div></div>
      <div class="afx-cap">
        <div class="afx-st-t">${esc(T)}</div>
        <div class="afx-meta afx-st-m"><span class="afx-wr shim">${esc(a.steps[0])}</span><span class="afx-fin"></span></div>
        <div class="fold shut afx-nw"><div><div class="afx-note"></div></div></div>
      </div>
      <div class="afx-st-act">
        ${N > 1 ? `<button type="button" class="pill sm line afx-fan" aria-haspopup="menu" aria-expanded="false">${ic('layers', 's')}<span>Versions</span></button>` : ''}
        ${acts(a, `<button type="button" class="pill sm accent afx-open" aria-label="Open ${esc(T)}">${ic('max', 's')}<span class="afx-lbl">Open</span></button>`)}
      </div>
    </div>`;
    const el = $('.afx-st', main), wrap = el, stk = $('.afx-stack', main), tog = $('.afx-fan', main), op = $('.afx-open', main);
    const sheets = $$('.afx-sh', main), note = $('.afx-note', main), nw = $('.afx-nw', main), fin = $('.afx-st-m .afx-fin', main);
    now.m = m; openTip(op);
    if (tog) tog.setAttribute('aria-controls', stk.id = uid());
    let fanned = false, peek = null, writing = true;
    // laid out, the sheets fan from left to right, oldest first; stacked, the shown version is on top and the rest sit
    // behind it in order, straight, a little further back each (the CSS peeks them out on hover and focus)
    const layout = () => sheets.forEach((sh, j) => {
      if (fanned){ const o = j - (N - 1) / 2; sh.style.transform = `translate(calc(${o} * var(--fx)),${Math.abs(o) * 6 + (peek === j ? -8 : 0)}px) rotate(${o * 6}deg)`; sh.style.zIndex = peek === j ? 9 : j + 1; }
      else { const rest = sheets.map((x, y) => y).reverse().filter(y => y !== m.i), d = j === m.i ? 0 : rest.indexOf(j) + 1; sh.style.transform = ''; sh.style.setProperty('--d', d); sh.style.zIndex = N - d; }
      sh.setAttribute('aria-checked', String(j === m.i));
    });
    // while the sheets are laid out, the caption reads out the one under the pointer or the focus
    const caption = j => {
      const i = j == null ? m.i : j;
      fin.innerHTML = `<span>${esc(a.kind)}</span><span aria-hidden="true">·</span><span class="afx-v">v${i + 1}</span><span aria-hidden="true">·</span><span class="afx-when">${whenHTML(a.versions[i])}</span>`;
      note.textContent = a.versions[i].note;
    };
    const setFan = (on, focus) => {
      if (writing) return;
      fanned = on; peek = null; el.classList.toggle('fan', on);
      tog.setAttribute('aria-expanded', String(on)); stk.setAttribute('aria-hidden', String(!on));
      sheets.forEach(sh => { sh.tabIndex = on && +sh.dataset.i === m.i ? 0 : -1; });
      nw.classList.toggle('shut', !on); layout(); caption();
      if (on && focus) sheets[m.i].focus({ preventScroll:true });
    };
    const pick = j => { m.set(j); setFan(false); tog.focus({ preventScroll:true }); };
    m.on(i => { layout(); caption(); say(`Showing v${i + 1}, ${a.versions[i].when}`); });
    layout(); caption();
    if (tog) k.on(tog, 'click', () => setFan(!fanned, true));
    sheets.forEach((sh, j) => {
      k.on(sh, 'click', () => { if (writing) return; if (fanned) pick(j); else op.click(); });
      const look = on => { if (!fanned) return; peek = on ? j : null; layout(); caption(peek); };
      k.on(sh, 'pointerenter', () => look(true)); k.on(sh, 'pointerleave', () => look(false));
      k.on(sh, 'focus', () => look(true)); k.on(sh, 'blur', () => look(false));
    });
    k.on(stk, 'keydown', e => {
      if (!fanned) return;
      const j = sheets.indexOf(document.activeElement); let n = null;
      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = Math.min(N - 1, j + 1);
      else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') n = Math.max(0, j - 1);
      else if (e.key === 'Home') n = 0; else if (e.key === 'End') n = N - 1;
      else if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); setFan(false); tog.focus({ preventScroll:true }); return; }
      else if (e.key === 'Tab'){ setFan(false); return; }
      if (n != null){ e.preventDefault(); sheets.forEach((sh, y) => { sh.tabIndex = y === n ? 0 : -1; }); sheets[n].focus(); }
    });
    k.on(document, 'pointerdown', e => { if (fanned && !stk.contains(e.target) && !(tog && tog.contains(e.target))) setFan(false); });
    k.on($('.afx-copy', main), 'click', e => doCopy(e.currentTarget, m));
    k.on($('.afx-dl', main), 'click', e => doDownload(e.currentTarget, m));
    k.on(op, 'click', () => { if (writing) return; if (fanned) setFan(false); open(k, m, wrap, { kind:'page', opener:op, src:sheets[m.i] }); });
    // writing: a blank sheet drops onto the stack, the title is typed, and a pen draws each line of the new page
    c.run(async () => {
      const sh = sheets[top], pen = $('.afx-pen', sh), title = $('.afx-st-h', sh), kick = $('.afx-st-k span', sh), E = cssEase(root);
      if (!REDUCE) sh.animate([{ transform:'translateY(-36px)', opacity:0 }, { transform:'none', opacity:1 }], { duration:cssMs(root, '--t-slow'), easing:E, composite:'add' });
      await k.wait(300);
      const w = $('.afx-st-m .afx-wr', main), st = a.steps;
      swap(w, st[0]);
      if (REDUCE){ await k.wait(500); title.textContent = T; }
      else for (let n = 1; n <= T.length; n++){ title.textContent = T.slice(0, n); await k.wait(40); }
      await k.wait(REDUCE ? 0 : 250);
      swap(w, st[1]);
      const rows = $$('.afx-r', sh).filter(x => x.offsetParent);
      for (let j = 0; j < rows.length; j++){
        const x = rows[j], line = $('i', x), dot = $('b', x);
        if (j === 3) swap(w, st[2]);
        if (REDUCE){ await k.wait(150); continue; }
        const x0 = x.offsetLeft + line.offsetLeft, x1 = x0 + line.offsetWidth, y = x.offsetTop + x.offsetHeight / 2 - 3.5, ms = 260 + line.offsetWidth * 1.4;
        if (dot) dot.animate([{ transform:'scaleX(0)' }, { transform:'none' }], { duration:120, fill:'forwards' });
        line.animate([{ transform:'scaleX(0)' }, { transform:'scaleX(1)' }], { duration:ms, easing:'cubic-bezier(.4,0,.3,1)', fill:'forwards' });
        pen.animate([{ transform:`translate(${x0 - 3}px,${y}px)`, opacity:1 }, { transform:`translate(${x1 - 3}px,${y}px)`, opacity:1 }], { duration:ms, easing:'cubic-bezier(.4,0,.3,1)', fill:'forwards' });
        await k.wait(ms + 70);
      }
      pen.getAnimations().forEach(x => x.cancel());
      $$('.afx-r :is(i,b)', sh).forEach(e => e.getAnimations().forEach(x => x.cancel()));
      sh.classList.remove('blank'); title.textContent = T; kick.textContent = 'v' + N;
      if (!REDUCE) sh.animate([{ transform:'translateY(-6px)' }, { transform:'none' }], { duration:cssMs(root, '--t'), easing:E, composite:'add' });
      writing = false; settle(el, m);
    });
  }

  /* the switcher under the stage: each variant replays its writing state when it comes in */
  const VIEWS = { compact, preview, inline, stack };
  function show(v){
    if (K) K.stop();
    const sh = $(':scope>.afx-sheet', root); if (sh) sh.remove();
    main.inert = false; if (demo) demo.inert = false; hideTip();
    K = sub(); view = v; now = { ready:false, m:null };
    VIEWS[v](K);
  }
  // picking a variant is the person taking over, so the story stops there
  if (demo) seg($('.seg2', demo), v => { if (v !== view){ c.mine = true; show(v); enter(main.firstElementChild); } });
  // for the component it sits in: show another file, and say what Open does there instead
  root._afx = { use(a, o = {}){ art = a; opts = o; show(view); }, get version(){ return now.m ? now.m.i : art.versions.length - 1; } };
  show('compact');
  // the preview's story: the report is written, then someone opens its versions, reads v2 and goes back to v3
  if (own) c.story(async s => {
    await s.until(() => now.ready); await s.beat(3);
    const vb = $('[data-a="main"] .afx-vb', main), item = i => $(`.afx-menu .mi[data-i="${i}"]`, main);
    await s.tap(vb); await s.beat(2);
    await s.tap(item(1)); await s.beat(4);
    await s.tap(vb); await s.beat(2);
    await s.tap(item(2)); await s.beat(3);
    s.away(); await s.beat(1);
  });
});
06

Questions

What people ask about it.

Which variant should I use?
Compact where several files can land in one thread. Preview when people need to tell drafts apart at a glance. Inline when the file belongs in the sentence, like an attachment. Stack when the versions are the point, like a document the agent keeps revising.
How do I show the agent writing the file?
Render the card with state="writing" as soon as the agent starts, and update status (and progress for the compact card) as it works. Pass the content you have so far, then set state to ready when the file is done.
Can Open show the file in my own panel?
Yes. Pass onOpen and the card calls it with the version shown instead of opening its reader; the Agent harness opens the report beside the chat this way. Without onOpen, the reader covers the card's nearest positioned ancestor, so give your chat column position: relative.
07

More in Conversation

Everything for asking, answering and replying, by text or by voice.

Live answerAnswers that stream in with numbered sources and next steps.
ComposerMessage input with attachments, what to work on, a mode and dictation.
Thread panelSwitch between agent threads and reply in place.
Rich text editorA minimal editor with an inline AI menu that streams a suggestion to accept or discard.
Tool callEach tool the agent calls, with its input and output.
Voice captureTalk to the agent, then fix what it misheard before sending.
Voice orbA hands-free voice chat, with an orb that shows who is talking.
Answer action barCopy, regenerate, rate and listen to an answer from one row under it.
All 15 in Conversation →