Skip to content
UI Kit
Accent color

AI summary card

Summaries written by AI that show where they come from. A compact card with key points and sources, a meeting with decisions and action items, a document with the passage behind every point and several documents compared. Confidence and open questions stay visible, with copy and feedback on every card.

4 variantsOne of 5 components in AI answers

Summaries you can check

An AI summary saves reading time only when people can trust it. These cards show where every point comes from, keep confidence and open questions visible and give each card copy and feedback, so a summary becomes something to check rather than to believe.

Compact is one card for a thread with the gist, key points with numbered sources that open the quoted passage and more behind a fold that works without JavaScript. Meeting shows date, length and people, decisions with the moment to listen to and action items with owner and date to tick off, with confidence and what stayed unclear beside it. Document with passages numbers the key points with confidence and page, and choosing a point shows the passage it relies on, beside the list on desktops and right under the point on phones. Compare documents goes topic by topic across several documents and marks each row as same, different, missing or contradicting.

Put the sources right next to the claim, not in a list at the end. Say what the document does not cover, that prevents the most expensive misunderstandings. Keep the gist to two or three sentences and let the points carry the detail.

Variants

CompactNew in 1.2

One card for a thread. The gist, key points with numbered sources that open the quoted passage, more behind a fold that works without JavaScript, then copy and feedback.

MeetingNew in 1.2

Date, length and people, the gist, decisions with the moment to listen to, action items with owner and date to tick off. Confidence and what stayed unclear sit next to it.

Document with passagesNew in 1.2

Numbered key points with confidence and page. Choosing a point shows the passage it relies on, beside the list on the desktop and right under the point on phones. What the document does not say is listed too.

Compare documentsNew in 1.2

Several documents topic by topic, each row marked same, different, missing or contradicting. Differences only on request, a table from tablets on and cards on phones.

Details

What AI summary card brings along, read from the registry and the source.

Category
AI answers
Area
App
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/ai-summary-card.tsx
Brings along
utilskiticonrevealmotion
npm packages
None
Languages
English and German, 44 texts of its own
Studio
Adjust in code

Props

The AiSummaryCardProps interface from components/ui/ai-summary-card.tsx, with the comments from the code.

  • variantoptional
    AiSummaryCardVariant

    compactmeetingdocumentcompare

  • titleoptional
    string
  • contextoptional
    string

    What was summarized, e.g. "Thread · 23 messages"

  • tldroptional
    string

    The summary in one or two sentences

  • pointsoptional
    SummaryPoint[]
  • detailsoptional
    string[]

    compact: more paragraphs behind "Show more"

  • sourcesoptional
    SummarySource[]
  • confidenceoptional
    number

    Overall confidence in percent

  • unclearoptional
    string[]

    Things the summary could not settle

  • meetingoptional
    { date: string; duration: string; participants: string[] }

    meeting

  • decisionsoptional
    { text: string; source?: number }[]
  • actionsoptional
    MeetingAction[]
  • missingoptional
    string[]

    document, compare: facts the sources do not contain

  • docsoptional
    CompareDoc[]

    compare

  • rowsoptional
    CompareRow[]
  • generatedoptional
    string

    e.g. "Nova 2 · 2 min ago"

  • onFeedbackoptional
    (helpful: boolean, reason?: string) => void
  • onCopyoptional
    (text: string) => void

Built-in texts

AI summary card writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 44 texts
summaryEnglish AI summaryGerman KI-Zusammenfassung
keyPointsEnglish Key pointsGerman Kernpunkte
moreEnglish Show moreGerman Mehr anzeigen
lessEnglish Show lessGerman Weniger anzeigen
highEnglish High confidenceGerman Hohe Sicherheit
mediumEnglish Medium confidenceGerman Mittlere Sicherheit
lowEnglish Low confidenceGerman Geringe Sicherheit
confidenceEnglish ConfidenceGerman Sicherheit
confidenceOfEnglish Confidence {v}German Sicherheit {v}
unclearEnglish Not settledGerman Nicht geklärt
sourcesEnglish SourcesGerman Quellen
sourceEnglish Source {n}German Quelle {n}
openSourceEnglish Show passage from source {n}German Stelle aus Quelle {n} zeigen
copyEnglish Copy summaryGerman Zusammenfassung kopieren
copiedEnglish CopiedGerman Kopiert
copyShortEnglish CopyGerman Kopieren
helpfulEnglish HelpfulGerman Hilfreich
notHelpfulEnglish Not helpfulGerman Nicht hilfreich
whyEnglish What was off?German Was hat nicht gepasst?
reasonsEnglish Inaccurate|Something is missing|Too long|Wrong toneGerman Ungenau|Es fehlt etwas|Zu lang|Falscher Ton
thanksEnglish Thanks, this helps the next summary.German Danke, das hilft der nächsten Zusammenfassung.
decisionsEnglish DecisionsGerman Entscheidungen
actionsEnglish Action itemsGerman Aufgaben
ownerEnglish OwnerGerman Zuständig
dueEnglish Due {v}German bis {v}
openItemsEnglish {n} of {total} openGerman {n} von {total} offen
participantsEnglish ParticipantsGerman Teilnehmende
jumpEnglish Hear it at {v}German Bei {v} anhören
passageEnglish PassageGerman Fundstelle
passageOfEnglish Passage for “{name}”German Fundstelle zu „{name}“
pickEnglish Choose a point to see where it comes from.German Wähle einen Punkt, um zu sehen, woher er kommt.
inSourceEnglish In the sourceGerman In der Quelle
noSourceEnglish No source backs this point.German Keine Quelle stützt diesen Punkt.
missingEnglish Not in the documentsGerman Nicht in den Dokumenten
compareEnglish ComparisonGerman Vergleich
topicEnglish TopicGerman Thema
onlyDiffEnglish Only differencesGerman Nur Unterschiede
sameEnglish SameGerman Gleich
differsEnglish DiffersGerman Abweichend
conflictEnglish ContradictsGerman Widerspruch
missingStatusEnglish MissingGerman Fehlt
tallyEnglish {same} same, {differs} different, {conflict} contradictingGerman {same} gleich, {differs} abweichend, {conflict} im Widerspruch
emptyEnglish All documents agree.German Alle Dokumente stimmen überein.
honestEnglish Written by AI from the sources listed. It can leave things out or get them wrong, check the passages before you rely on it.German Von KI aus den genannten Quellen geschrieben. Sie kann etwas auslassen oder falsch verstehen, prüfe die Fundstellen, bevor du dich darauf verlässt.

Accessibility

Read from the source of the component.

  • AI summary card handles the keys Arrow up, Arrow down and Space itself.
  • Sets aria-controls, aria-describedby, aria-expanded, aria-label, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles cell, columnheader, meter, region, row, rowgroup, rowheader, status and table.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 931 lines
components/ui/ai-summary-card.tsx
'use client';

import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type AiSummaryCardVariant = 'compact' | 'meeting' | 'document' | 'compare';

export type SummaryConfidence = 'high' | 'medium' | 'low';

export interface SummarySource {
  id: number;
  title: string;
  kind?: 'doc' | 'mail' | 'chat' | 'web' | 'meeting';
  /** Where in the source, e.g. "p. 4", "12:40" or "Message 7" */
  locator?: string;
  /** The passage the point relies on. ==Marked words== are highlighted. */
  quote?: string;
  /** e.g. the author or speaker */
  author?: string;
}

export interface SummaryPoint {
  id: string;
  text: string;
  confidence?: SummaryConfidence;
  /** Source ids */
  sources?: number[];
  /** Why the point is uncertain */
  note?: string;
}

export interface MeetingAction {
  id: string;
  text: string;
  owner: string;
  due?: string;
  done?: boolean;
}

export interface CompareDoc {
  id: string;
  title: string;
  /** e.g. the date or version */
  meta?: string;
}

export interface CompareRow {
  id: string;
  topic: string;
  /** Value per document id */
  values: Record<string, string>;
  status: 'same' | 'differs' | 'conflict' | 'missing';
  note?: string;
}

export interface AiSummaryCardProps {
  variant?: AiSummaryCardVariant;
  title?: string;
  /** What was summarized, e.g. "Thread · 23 messages" */
  context?: string;
  /** The summary in one or two sentences */
  tldr?: string;
  points?: SummaryPoint[];
  /** compact: more paragraphs behind "Show more" */
  details?: string[];
  sources?: SummarySource[];
  /** Overall confidence in percent */
  confidence?: number;
  /** Things the summary could not settle */
  unclear?: string[];
  /** meeting */
  meeting?: { date: string; duration: string; participants: string[] };
  decisions?: { text: string; source?: number }[];
  actions?: MeetingAction[];
  /** document, compare: facts the sources do not contain */
  missing?: string[];
  /** compare */
  docs?: CompareDoc[];
  rows?: CompareRow[];
  /** e.g. "Nova 2 · 2 min ago" */
  generated?: string;
  onFeedback?: (helpful: boolean, reason?: string) => void;
  onCopy?: (text: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    summary: 'AI summary',
    keyPoints: 'Key points',
    more: 'Show more',
    less: 'Show less',
    high: 'High confidence',
    medium: 'Medium confidence',
    low: 'Low confidence',
    confidence: 'Confidence',
    confidenceOf: 'Confidence {v}',
    unclear: 'Not settled',
    sources: 'Sources',
    source: 'Source {n}',
    openSource: 'Show passage from source {n}',
    copy: 'Copy summary',
    copied: 'Copied',
    copyShort: 'Copy',
    helpful: 'Helpful',
    notHelpful: 'Not helpful',
    why: 'What was off?',
    reasons: 'Inaccurate|Something is missing|Too long|Wrong tone',
    thanks: 'Thanks, this helps the next summary.',
    decisions: 'Decisions',
    actions: 'Action items',
    owner: 'Owner',
    due: 'Due {v}',
    openItems: '{n} of {total} open',
    participants: 'Participants',
    jump: 'Hear it at {v}',
    passage: 'Passage',
    passageOf: 'Passage for “{name}”',
    pick: 'Choose a point to see where it comes from.',
    inSource: 'In the source',
    noSource: 'No source backs this point.',
    missing: 'Not in the documents',
    compare: 'Comparison',
    topic: 'Topic',
    onlyDiff: 'Only differences',
    same: 'Same',
    differs: 'Differs',
    conflict: 'Contradicts',
    missingStatus: 'Missing',
    tally: '{same} same, {differs} different, {conflict} contradicting',
    empty: 'All documents agree.',
    honest: 'Written by AI from the sources listed. It can leave things out or get them wrong, check the passages before you rely on it.',
  },
  de: {
    summary: 'KI-Zusammenfassung',
    keyPoints: 'Kernpunkte',
    more: 'Mehr anzeigen',
    less: 'Weniger anzeigen',
    high: 'Hohe Sicherheit',
    medium: 'Mittlere Sicherheit',
    low: 'Geringe Sicherheit',
    confidence: 'Sicherheit',
    confidenceOf: 'Sicherheit {v}',
    unclear: 'Nicht geklärt',
    sources: 'Quellen',
    source: 'Quelle {n}',
    openSource: 'Stelle aus Quelle {n} zeigen',
    copy: 'Zusammenfassung kopieren',
    copied: 'Kopiert',
    copyShort: 'Kopieren',
    helpful: 'Hilfreich',
    notHelpful: 'Nicht hilfreich',
    why: 'Was hat nicht gepasst?',
    reasons: 'Ungenau|Es fehlt etwas|Zu lang|Falscher Ton',
    thanks: 'Danke, das hilft der nächsten Zusammenfassung.',
    decisions: 'Entscheidungen',
    actions: 'Aufgaben',
    owner: 'Zuständig',
    due: 'bis {v}',
    openItems: '{n} von {total} offen',
    participants: 'Teilnehmende',
    jump: 'Bei {v} anhören',
    passage: 'Fundstelle',
    passageOf: 'Fundstelle zu „{name}“',
    pick: 'Wähle einen Punkt, um zu sehen, woher er kommt.',
    inSource: 'In der Quelle',
    noSource: 'Keine Quelle stützt diesen Punkt.',
    missing: 'Nicht in den Dokumenten',
    compare: 'Vergleich',
    topic: 'Thema',
    onlyDiff: 'Nur Unterschiede',
    same: 'Gleich',
    differs: 'Abweichend',
    conflict: 'Widerspruch',
    missingStatus: 'Fehlt',
    tally: '{same} gleich, {differs} abweichend, {conflict} im Widerspruch',
    empty: 'Alle Dokumente stimmen überein.',
    honest: 'Von KI aus den genannten Quellen geschrieben. Sie kann etwas auslassen oder falsch verstehen, prüfe die Fundstellen, bevor du dich darauf verlässt.',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AiSummaryCardProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value);
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes asc-up{from{opacity:0;transform:translateY(6px)}}@keyframes asc-flash{from{background-color:color-mix(in oklab,var(--primary) 22%,transparent)}}' +
  '@media (prefers-reduced-motion:no-preference){.asc-up{animation:asc-up .4s var(--ease-kit) var(--asc-d,0ms) both}.asc-flash{animation:asc-flash 1.2s ease-out both}}' +
  '.asc-details>summary{list-style:none}.asc-details>summary::-webkit-details-marker{display:none}';

function Style() {
  return (
    <style href="kit-ai-summary-card" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const iconBtn = 'inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-9 sm:min-w-9';
const KIND_ICON: Record<NonNullable<SummarySource['kind']>, IconName> = { doc: 'file', mail: 'mail', chat: 'chat', web: 'globe', meeting: 'video' };

const level = (n: number): SummaryConfidence => (n >= 80 ? 'high' : n >= 55 ? 'medium' : 'low');
const LEVEL_TONE: Record<SummaryConfidence, { text: string; bar: string; bg: string }> = {
  high: { text: 'text-success-foreground', bar: 'bg-success', bg: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)]' },
  medium: { text: 'text-warning-foreground', bar: 'bg-warning', bg: 'bg-[color-mix(in_oklab,var(--warning)_15%,transparent)]' },
  low: { text: 'text-destructive', bar: 'bg-destructive', bg: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]' },
};

/** Five bars and a word, with the exact value for screen readers */
function ConfidenceMeter({ value, t, className }: { value: number; t: Texts; className?: string }) {
  const locale = useLocale();
  const lv = level(value);
  const on = Math.max(1, Math.round(value / 20));
  return (
    <span role="meter" aria-label={t.confidence} aria-valuemin={0} aria-valuemax={100} aria-valuenow={value} aria-valuetext={`${t[lv]}, ${formatPercent(value, locale)}`} className={cn('inline-flex items-center gap-2', className)}>
      <span aria-hidden className="flex items-end gap-[3px]">
        {[0, 1, 2, 3, 4].map((i) => (
          <span key={i} className={cn('w-[5px] rounded-[2px]', i < on ? LEVEL_TONE[lv].bar : 'bg-track')} style={{ height: 6 + i * 2.5 }} />
        ))}
      </span>
      <span aria-hidden className={cn('text-[12px] font-medium', LEVEL_TONE[lv].text)}>
        {t[lv]} <span className="tabular-nums text-muted-foreground">· {formatPercent(value, locale)}</span>
      </span>
    </span>
  );
}

function LevelTag({ level: lv, t }: { level: SummaryConfidence; t: Texts }) {
  return <span className={cn('inline-flex h-5 shrink-0 items-center whitespace-nowrap rounded-[5px] px-1.5 text-[11px] font-medium', LEVEL_TONE[lv].bg, LEVEL_TONE[lv].text)}>{t[lv]}</span>;
}

/** Text with ==marked== words */
function Marked({ text }: { text: string }) {
  return (
    <>
      {text.split(/(==[^=]+==)/g).map((part, i) =>
        part.startsWith('==') ? (
          <mark key={i} className="rounded-[3px] bg-[color-mix(in_oklab,var(--warning)_30%,transparent)] px-0.5 text-foreground">
            {part.slice(2, -2)}
          </mark>
        ) : (
          <Fragment key={i}>{part}</Fragment>
        ),
      )}
    </>
  );
}

/** A small numbered marker that opens the source passage */
function SourceChip({ source, t, active, onClick }: { source: SummarySource; t: Texts; active?: boolean; onClick?: () => void }) {
  const cls = cn('relative inline-flex h-[1.4em] min-w-[1.4em] items-center justify-center rounded-[5px] px-1 align-[0.1em] text-[11px] font-semibold tabular-nums leading-none transition-colors after:absolute after:-inset-1', active ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground hover:bg-[color-mix(in_oklab,var(--primary)_18%,transparent)] hover:text-foreground');
  if (!onClick) return <span className={cls}>{source.id}</span>;
  return (
    <button type="button" aria-label={fill(t.openSource, { n: source.id })} aria-pressed={active} onClick={onClick} className={cls}>
      {source.id}
    </button>
  );
}

function Header({ t, title, context, confidence, generated, className }: { t: Texts; title?: string; context?: string; confidence?: number; generated?: string; className?: string }) {
  return (
    <div className={cn('flex flex-wrap items-start justify-between gap-x-4 gap-y-2', className)}>
      <div className="min-w-0">
        <p className="flex items-center gap-1.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">
          <Icon name="sparkle" size={14} />
          {t.summary}
          {context && <span className="font-normal text-muted-foreground">· {context}</span>}
        </p>
        {title && <h3 className="mt-1.5 text-balance text-[18px] font-semibold leading-snug tracking-[-0.015em] text-foreground sm:text-[20px]">{title}</h3>}
        {generated && <p className="mt-1 text-[12px] text-subtle-foreground">{generated}</p>}
      </div>
      {confidence !== undefined && <ConfidenceMeter value={confidence} t={t} className="pt-0.5" />}
    </div>
  );
}

/** Copy, helpful and not helpful with a reason */
function Feedback({ t, text, onFeedback, onCopy, className }: { t: Texts; text: string; onFeedback?: AiSummaryCardProps['onFeedback']; onCopy?: AiSummaryCardProps['onCopy']; className?: string }) {
  const [vote, setVote] = useState<boolean | null>(null);
  const [reason, setReason] = useState<string | null>(null);
  const [copied, setCopied] = useState(false);
  useEffect(() => {
    if (!copied) return;
    const id = window.setTimeout(() => setCopied(false), 1800);
    return () => window.clearTimeout(id);
  }, [copied]);
  const reasons = t.reasons.split('|');
  return (
    <div className={className}>
      <div className="flex flex-wrap items-center gap-1">
        <button
          type="button"
          onClick={() => {
            navigator.clipboard?.writeText(text).catch(() => undefined);
            onCopy?.(text);
            setCopied(true);
          }}
          className={iconBtn}
          aria-live="polite"
        >
          <Icon name={copied ? 'check' : 'copy'} size={15} className={copied ? 'text-success-foreground' : undefined} />
          <span className={copied ? 'text-success-foreground' : undefined}>
            {copied ? t.copied : (
              <>
                <span className="sm:hidden">{t.copyShort}</span>
                <span className="max-sm:hidden">{t.copy}</span>
              </>
            )}
          </span>
        </button>
        <span className="flex-1" />
        {[true, false].map((v) => (
          <button key={String(v)} type="button" aria-pressed={vote === v} aria-label={v ? t.helpful : t.notHelpful} title={v ? t.helpful : t.notHelpful} onClick={() => (setVote(v), setReason(null), v && onFeedback?.(true))} className={cn(iconBtn, 'px-0', vote === v && 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
            <Icon name={v ? 'thumbs-up' : 'thumbs-down'} size={15} />
          </button>
        ))}
      </div>
      {vote === false && !reason && (
        <div className="asc-up mt-2 rounded-xl border border-border bg-surface p-3">
          <p className="text-[12.5px] font-medium text-foreground">{t.why}</p>
          <div className="mt-2 flex flex-wrap gap-1.5">
            {reasons.map((r) => (
              <button key={r} type="button" onClick={() => (setReason(r), onFeedback?.(false, r))} className="inline-flex min-h-11 items-center rounded-lg border border-border bg-card px-3 text-[12.5px] font-medium text-foreground transition-colors hover:bg-accent sm:min-h-8">
                {r}
              </button>
            ))}
          </div>
        </div>
      )}
      <p className="text-[12.5px] text-muted-foreground empty:hidden" role="status">
        {(vote === true || reason) && <span className="asc-up mt-2 inline-flex items-center gap-1.5"><Icon name="check-circle" size={14} className="text-success-foreground" />{t.thanks}</span>}
      </p>
    </div>
  );
}

function Honest({ t }: { t: Texts }) {
  return (
    <p className="flex items-start gap-2 text-[12px] leading-snug text-muted-foreground">
      <Icon name="info" size={14} className="mt-px shrink-0" />
      <span>{t.honest}</span>
    </p>
  );
}

function Stage({ children, className, wide }: { children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <Style />
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[42rem]')}>{children}</div>
      </div>
    </section>
  );
}

function SectionLabel({ icon, children, aside }: { icon: IconName; children: React.ReactNode; aside?: React.ReactNode }) {
  return (
    <p className="flex items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
      <Icon name={icon} size={14} />
      {children}
      {aside && <span className="ml-auto font-normal">{aside}</span>}
    </p>
  );
}

function summaryText(p: AiSummaryCardProps) {
  return [p.title, p.tldr, ...(p.points ?? []).map((x) => `• ${x.text}`), ...(p.decisions ?? []).map((x) => `• ${x.text}`), ...(p.actions ?? []).map((a) => `☐ ${a.text} (${a.owner}${a.due ? `, ${a.due}` : ''})`)].filter(Boolean).join('\n');
}

/* ------------------------------------------------------------------ */
/* compact                                                             */
/* ------------------------------------------------------------------ */

function Compact(p: P) {
  const { t, title, context, tldr, points = [], details = [], sources = [], confidence, unclear = [], generated, onFeedback, onCopy, className } = p;
  const [open, setOpen] = useState<number | null>(null);
  const src = sources.find((s) => s.id === open);
  return (
    <Stage className={className}>
      <Reveal effect="scale" className={cn(card, 'relative overflow-hidden p-5 sm:p-6')}>
        <span aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--primary)_70%,transparent),transparent)]" />
        <Header t={t} title={title} context={context} confidence={confidence} generated={generated} />
        {tldr && <p className="mt-4 text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}
        {points.length > 0 && (
          <Stagger as="ul" step={60} className="mt-4 space-y-2.5">
            {points.map((x) => (
              <li key={x.id} className="flex gap-2.5 text-[14px] leading-relaxed text-foreground">
                <span aria-hidden className="mt-[9px] size-1.5 shrink-0 rounded-full bg-primary" />
                <span className="min-w-0">
                  {x.text}
                  {x.sources?.map((id) => {
                    const s = sources.find((y) => y.id === id);
                    return s ? (
                      <Fragment key={id}>
                        {' '}
                        <SourceChip source={s} t={t} active={open === id} onClick={() => setOpen(open === id ? null : id)} />
                      </Fragment>
                    ) : null;
                  })}
                  {x.confidence && x.confidence !== 'high' && (
                    <span className="ml-1.5 inline-block align-[0.05em]">
                      <LevelTag level={x.confidence} t={t} />
                    </span>
                  )}
                </span>
              </li>
            ))}
          </Stagger>
        )}
        {src && (
          <figure key={src.id} className="asc-up mt-4 rounded-xl border border-border bg-surface p-3.5" aria-live="polite">
            <figcaption className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
              <Icon name={KIND_ICON[src.kind ?? 'doc']} size={13} />
              <span className="font-medium text-foreground">{src.title}</span>
              {src.locator && <span>· {src.locator}</span>}
            </figcaption>
            {src.quote && (
              <blockquote className="mt-1.5 text-[13.5px] leading-relaxed text-foreground">
                <Marked text={src.quote} />
              </blockquote>
            )}
          </figure>
        )}
        {(details.length > 0 || unclear.length > 0) && (
          <details className="asc-details group mt-4">
            <summary className="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9 -ml-2.5">
              <Icon name="chevron-down" size={15} className="transition-transform duration-300 group-open:rotate-180 motion-reduce:transition-none" />
              <span className="group-open:hidden">{t.more}</span>
              <span className="hidden group-open:inline">{t.less}</span>
            </summary>
            <div className="asc-up mt-2 space-y-3">
              {details.map((x, i) => (
                <p key={i} className="text-pretty text-[14px] leading-relaxed text-muted-foreground">
                  {x}
                </p>
              ))}
              {unclear.length > 0 && (
                <div className="rounded-xl bg-[color-mix(in_oklab,var(--warning)_10%,var(--card))] p-3.5">
                  <p className="flex items-center gap-1.5 text-[12.5px] font-medium text-warning-foreground">
                    <Icon name="help-circle" size={14} />
                    {t.unclear}
                  </p>
                  <ul className="mt-1.5 space-y-1">
                    {unclear.map((u) => (
                      <li key={u} className="text-[13.5px] leading-relaxed text-foreground">
                        {u}
                      </li>
                    ))}
                  </ul>
                </div>
              )}
            </div>
          </details>
        )}
        {sources.length > 0 && (
          <div className="mt-4 border-t border-border pt-4">
            <SectionLabel icon="book-open">{t.sources}</SectionLabel>
            <ul className="mt-2 flex flex-wrap gap-1.5">
              {sources.map((s) => (
                <li key={s.id}>
                  <button type="button" aria-pressed={open === s.id} onClick={() => setOpen(open === s.id ? null : s.id)} className={cn('inline-flex min-h-11 max-w-full items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] transition-colors sm:min-h-8', open === s.id ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}>
                    <span className="tabular-nums text-subtle-foreground">{s.id}</span>
                    <Icon name={KIND_ICON[s.kind ?? 'doc']} size={13} />
                    <span className="truncate">{s.title}</span>
                  </button>
                </li>
              ))}
            </ul>
          </div>
        )}
        <Feedback t={t} text={summaryText(p)} onFeedback={onFeedback} onCopy={onCopy} className="mt-4 border-t border-border pt-3" />
      </Reveal>
      <Reveal delay={120} className="mt-4 px-1">
        <Honest t={t} />
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* meeting                                                             */
/* ------------------------------------------------------------------ */

const initials = (n: string) =>
  n
    .split(/\s+/)
    .map((w) => w[0])
    .join('')
    .slice(0, 2)
    .toUpperCase();

function Meeting(p: P) {
  const { t, title, context, tldr, decisions = [], actions: initial = [], sources = [], unclear = [], confidence, meeting, generated, onFeedback, onCopy, className } = p;
  const [actions, setActions] = useSynced(initial);
  const [open, setOpen] = useState<number | null>(null);
  const uid = useId();
  const openCount = actions.filter((a) => !a.done).length;
  const src = sources.find((s) => s.id === open);
  const passage = src && (
    <figure key={src.id} className="asc-up mt-2 rounded-xl border border-border bg-surface p-3" aria-live="polite">
      <figcaption className="flex flex-wrap items-center gap-x-1.5 text-[12px] text-muted-foreground">
        <Icon name="play-circle" size={13} />
        <span className="font-medium text-foreground">{src.author ?? src.title}</span>
        {src.locator && <span className="tabular-nums">· {src.locator}</span>}
      </figcaption>
      {src.quote && (
        <blockquote className="mt-1 text-[13.5px] leading-relaxed text-foreground">
          „<Marked text={src.quote} />“
        </blockquote>
      )}
    </figure>
  );
  return (
    <Stage className={className} wide>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start lg:gap-5">
        <Reveal className={cn(card, 'p-5 sm:p-6')}>
          <Header t={t} title={title} context={context} generated={generated} />
          {meeting && (
            <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-[12.5px] text-muted-foreground">
              <span className="inline-flex items-center gap-1.5">
                <Icon name="calendar" size={14} />
                {meeting.date}
              </span>
              <span className="inline-flex items-center gap-1.5">
                <Icon name="clock" size={14} />
                {meeting.duration}
              </span>
              <span className="inline-flex items-center gap-2">
                <span className="sr-only">{t.participants}</span>
                <span aria-hidden className="flex -space-x-1.5">
                  {meeting.participants.slice(0, 5).map((n, i) => (
                    <span key={n} title={n} className="grid size-6 place-items-center rounded-full border-2 border-card text-[9.5px] font-semibold text-foreground" style={{ background: `color-mix(in oklab, var(--primary) ${12 + (i % 4) * 9}%, var(--surface-2))` }}>
                      {initials(n)}
                    </span>
                  ))}
                </span>
                <span>{meeting.participants.join(', ')}</span>
              </span>
            </div>
          )}
          {tldr && <p className="mt-4 text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}

          {decisions.length > 0 && (
            <div className="mt-6">
              <SectionLabel icon="flag">{t.decisions}</SectionLabel>
              <ul className="mt-2.5 space-y-2">
                {decisions.map((x, i) => {
                  const s = sources.find((y) => y.id === x.source);
                  return (
                    <li key={i} className="asc-up flex gap-2.5 text-[14px] leading-relaxed text-foreground" style={{ '--asc-d': `${i * 60}ms` } as React.CSSProperties}>
                      <Icon name="check-circle" size={16} className="mt-[3px] shrink-0 text-success-foreground" />
                      <span className="min-w-0 flex-1">
                        {x.text}
                        {s?.locator && (
                          <button type="button" aria-pressed={open === s.id} aria-label={fill(t.jump, { v: s.locator })} onClick={() => setOpen(open === s.id ? null : s.id)} className={cn('relative ml-1.5 inline-flex h-6 items-center gap-1 rounded-md px-1.5 align-[0.05em] text-[11.5px] font-medium tabular-nums transition-colors after:absolute after:-inset-3 sm:after:-inset-1', open === s.id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground hover:text-foreground')}>
                            <Icon name="play" size={10} />
                            {s.locator}
                          </button>
                        )}
                        {open === s?.id && passage}
                      </span>
                    </li>
                  );
                })}
              </ul>
            </div>
          )}

          {actions.length > 0 && (
            <div className="mt-6">
              <SectionLabel icon="list-checks" aside={fill(t.openItems, { n: openCount, total: actions.length })}>
                {t.actions}
              </SectionLabel>
              <ul className="mt-2.5 divide-y divide-border rounded-xl border border-border">
                {actions.map((a) => (
                  <li key={a.id}>
                    <label className="flex min-h-12 cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]">
                      <input type="checkbox" checked={!!a.done} onChange={(e) => setActions((s) => s.map((x) => (x.id === a.id ? { ...x, done: e.target.checked } : x)))} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" aria-describedby={`${uid}-${a.id}`} />
                      <span className="min-w-0 flex-1">
                        <span className={cn('block text-[14px] leading-snug transition-colors', a.done ? 'text-muted-foreground line-through' : 'text-foreground')}>{a.text}</span>
                        <span id={`${uid}-${a.id}`} className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-muted-foreground">
                          <span className="inline-flex items-center gap-1.5">
                            <span aria-hidden className="grid size-5 place-items-center rounded-full bg-surface-2 text-[9px] font-semibold text-foreground">{initials(a.owner)}</span>
                            <span className="sr-only">{t.owner} </span>
                            {a.owner}
                          </span>
                          {a.due && <span className="tabular-nums">· {fill(t.due, { v: a.due })}</span>}
                        </span>
                      </span>
                    </label>
                  </li>
                ))}
              </ul>
            </div>
          )}
          <Feedback t={t} text={summaryText({ ...p, actions })} onFeedback={onFeedback} onCopy={onCopy} className="mt-6 border-t border-border pt-3" />
        </Reveal>

        <Reveal delay={120} className="space-y-4 lg:sticky lg:top-6">
          {confidence !== undefined && (
            <div className={cn(card, 'p-4')}>
              <p className="text-[12.5px] font-medium text-muted-foreground">{t.confidence}</p>
              <ConfidenceMeter value={confidence} t={t} className="mt-2" />
              {unclear.length > 0 && (
                <div className="mt-4 border-t border-border pt-3.5">
                  <p className="flex items-center gap-1.5 text-[12.5px] font-medium text-warning-foreground">
                    <Icon name="help-circle" size={14} />
                    {t.unclear}
                  </p>
                  <ul className="mt-2 space-y-2">
                    {unclear.map((u) => (
                      <li key={u} className="text-[13px] leading-snug text-foreground">
                        {u}
                      </li>
                    ))}
                  </ul>
                </div>
              )}
            </div>
          )}
          <Honest t={t} />
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* document                                                            */
/* ------------------------------------------------------------------ */

function DocumentView(p: P) {
  const { t, title, context, tldr, points = [], sources = [], missing = [], confidence, generated, onFeedback, onCopy, className } = p;
  const [sel, setSel] = useState<string | null>(points[0]?.id ?? null);
  const uid = useId();
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const point = points.find((x) => x.id === sel);
  const src = point?.sources?.map((id) => sources.find((s) => s.id === id)).filter((s): s is SummarySource => !!s) ?? [];
  const passage = (
    <div key={sel ?? 'none'} className="asc-up">
      {!point ? (
        <p className="text-[13px] text-muted-foreground">{t.pick}</p>
      ) : src.length ? (
        <div className="space-y-3">
          {src.map((s) => (
            <figure key={s.id}>
              <figcaption className="flex flex-wrap items-center gap-x-1.5 text-[12px] text-muted-foreground">
                <Icon name={KIND_ICON[s.kind ?? 'doc']} size={13} />
                <span className="font-medium text-foreground">{s.title}</span>
                {s.locator && <span className="tabular-nums">· {s.locator}</span>}
              </figcaption>
              {s.quote && (
                <blockquote className="asc-flash mt-1.5 rounded-lg border-l-2 border-[color-mix(in_oklab,var(--warning)_70%,var(--border))] py-1 pl-3 text-[13.5px] leading-relaxed text-foreground">
                  <Marked text={s.quote} />
                </blockquote>
              )}
            </figure>
          ))}
          {point.note && (
            <p className="flex items-start gap-1.5 rounded-lg bg-[color-mix(in_oklab,var(--warning)_10%,var(--card))] p-2.5 text-[12.5px] leading-snug text-foreground">
              <Icon name="alert" size={14} className="mt-px shrink-0 text-warning-foreground" />
              {point.note}
            </p>
          )}
        </div>
      ) : (
        <p className="flex items-start gap-1.5 text-[13px] leading-snug text-destructive">
          <Icon name="alert-circle" size={14} className="mt-px shrink-0" />
          {point.note ?? t.noSource}
        </p>
      )}
    </div>
  );
  return (
    <Stage className={className} wide>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start lg:gap-5">
        <Reveal className={cn(card, 'p-5 sm:p-6')}>
          <Header t={t} title={title} context={context} confidence={confidence} generated={generated} />
          {tldr && <p className="mt-4 text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}
          <div className="mt-6">
            <SectionLabel icon="list">{t.keyPoints}</SectionLabel>
            <ol className="mt-2.5 space-y-1.5">
              {points.map((x, i) => {
                const on = sel === x.id;
                return (
                  <li key={x.id}>
                    <button
                      ref={(el) => {
                        refs.current[i] = el;
                      }}
                      type="button"
                      aria-expanded={on}
                      aria-controls={`${uid}-passage`}
                      onClick={() => setSel(on ? null : x.id)}
                      onKeyDown={(e) => {
                        const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
                        if (!d) return;
                        e.preventDefault();
                        refs.current[(i + d + points.length) % points.length]?.focus();
                      }}
                      className={cn('flex w-full items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-colors', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-transparent hover:bg-accent')}
                    >
                      <span aria-hidden className={cn('mt-px grid size-6 shrink-0 place-items-center rounded-full text-[11.5px] font-semibold tabular-nums', on ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted-foreground')}>
                        {i + 1}
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="block text-[14px] leading-relaxed text-foreground">{x.text}</span>
                        <span className="mt-1.5 flex flex-wrap items-center gap-1.5">
                          {x.confidence && <LevelTag level={x.confidence} t={t} />}
                          {x.sources?.map((id) => {
                            const s = sources.find((y) => y.id === id);
                            return s?.locator ? (
                              <span key={id} className="inline-flex h-5 items-center gap-1 rounded-[5px] bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-1.5 text-[11px] tabular-nums text-muted-foreground">
                                <Icon name={KIND_ICON[s.kind ?? 'doc']} size={11} />
                                {s.locator}
                              </span>
                            ) : null;
                          })}
                        </span>
                      </span>
                      <Icon name="chevron-right" size={16} className={cn('mt-1 shrink-0 text-subtle-foreground transition-transform duration-300 max-lg:rotate-90 motion-reduce:transition-none', on && 'text-foreground max-lg:-rotate-90')} />
                    </button>
                    {/* On phones and tablets the passage opens under its point */}
                    {on && <div className="mx-3.5 mb-2 mt-1 border-l border-border pl-3 lg:hidden">{passage}</div>}
                  </li>
                );
              })}
            </ol>
          </div>
          {missing.length > 0 && (
            <div className="mt-6 rounded-xl border border-dashed border-border-strong p-3.5">
              <SectionLabel icon="search">{t.missing}</SectionLabel>
              <ul className="mt-2 space-y-1">
                {missing.map((m) => (
                  <li key={m} className="text-[13.5px] leading-relaxed text-foreground">
                    {m}
                  </li>
                ))}
              </ul>
            </div>
          )}
          <Feedback t={t} text={summaryText(p)} onFeedback={onFeedback} onCopy={onCopy} className="mt-6 border-t border-border pt-3" />
        </Reveal>
        <Reveal delay={120} className="space-y-4 max-lg:hidden lg:sticky lg:top-6">
          <div id={`${uid}-passage`} role="region" aria-label={point ? fill(t.passageOf, { name: point.text.slice(0, 40) }) : t.passage} className={cn(card, 'p-4')}>
            <p className="mb-3 flex items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
              <Icon name="quote" size={14} />
              {t.passage}
            </p>
            {passage}
          </div>
          <Honest t={t} />
        </Reveal>
      </div>
      <div className="mt-4 px-1 lg:hidden">
        <Honest t={t} />
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* compare                                                             */
/* ------------------------------------------------------------------ */

const STATUS: Record<CompareRow['status'], { key: 'same' | 'differs' | 'conflict' | 'missingStatus'; icon: IconName; cls: string }> = {
  same: { key: 'same', icon: 'check', cls: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)] text-success-foreground' },
  differs: { key: 'differs', icon: 'arrows-left-right', cls: 'bg-[color-mix(in_oklab,var(--warning)_15%,transparent)] text-warning-foreground' },
  conflict: { key: 'conflict', icon: 'alert-circle', cls: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive' },
  missing: { key: 'missingStatus', icon: 'minus', cls: 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground' },
};

function StatusTag({ status, t }: { status: CompareRow['status']; t: Texts }) {
  const s = STATUS[status];
  return (
    <span className={cn('inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 text-[11.5px] font-medium', s.cls)}>
      <Icon name={s.icon} size={12} />
      {t[s.key]}
    </span>
  );
}

function Compare(p: P) {
  const { t, title, context, tldr, docs = [], rows = [], confidence, missing = [], generated, onFeedback, onCopy, className } = p;
  const [only, setOnly] = useState(false);
  const shown = rows.filter((r) => !only || r.status !== 'same');
  const n = (s: CompareRow['status']) => rows.filter((r) => r.status === s).length;
  const cols = `minmax(9rem,0.8fr) repeat(${docs.length}, minmax(0,1fr)) 7.5rem`;
  return (
    <Stage className={className} wide>
      <Reveal className={cn(card, 'p-5 sm:p-6')}>
        <Header t={t} title={title} context={context} confidence={confidence} generated={generated} />
        {tldr && <p className="mt-4 max-w-[48rem] text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}
        <div className="mt-5 flex flex-wrap items-center justify-between gap-3">
          <p className="text-[13px] text-muted-foreground">{fill(t.tally, { same: n('same'), differs: n('differs') + n('missing'), conflict: n('conflict') })}</p>
          <label className="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-1 text-[13px] font-medium text-foreground sm:min-h-9">
            <input type="checkbox" checked={only} onChange={(e) => setOnly(e.target.checked)} className="size-4 accent-[var(--primary)]" />
            {t.onlyDiff}
          </label>
        </div>

        {/* A grid table from tablets on */}
        <div role="table" aria-label={t.compare} className="mt-3 hidden overflow-hidden rounded-xl border border-border md:block">
          <div role="rowgroup">
            <div role="row" className="grid border-b border-border bg-surface" style={{ gridTemplateColumns: cols }}>
              <span role="columnheader" className="px-3.5 py-2.5 text-[12px] font-medium text-muted-foreground">
                {t.topic}
              </span>
              {docs.map((d) => (
                <span key={d.id} role="columnheader" className="min-w-0 border-l border-border px-3.5 py-2.5">
                  <span className="flex items-center gap-1.5 text-[12.5px] font-medium text-foreground">
                    <Icon name="file" size={13} className="shrink-0 text-muted-foreground" />
                    <span className="truncate">{d.title}</span>
                  </span>
                  {d.meta && <span className="block truncate text-[11.5px] text-muted-foreground">{d.meta}</span>}
                </span>
              ))}
              <span role="columnheader" className="border-l border-border px-3.5 py-2.5 text-[12px] font-medium text-muted-foreground">
                {t.compare}
              </span>
            </div>
          </div>
          <div role="rowgroup">
            {shown.map((r, i) => (
              <div key={r.id} role="row" className={cn('asc-up grid border-b border-border last:border-b-0', r.status === 'conflict' && 'bg-[color-mix(in_oklab,var(--destructive)_4%,transparent)]')} style={{ gridTemplateColumns: cols, '--asc-d': `${i * 45}ms` } as React.CSSProperties}>
                <span role="rowheader" className="px-3.5 py-3 text-[13px] font-medium text-foreground">
                  {r.topic}
                  {r.note && <span className="mt-1 block text-[12px] font-normal leading-snug text-muted-foreground">{r.note}</span>}
                </span>
                {docs.map((d) => (
                  <span key={d.id} role="cell" className={cn('border-l border-border px-3.5 py-3 text-[13px] leading-snug', r.values[d.id] ? 'text-foreground' : 'text-subtle-foreground')}>
                    {r.values[d.id] || '·'}
                  </span>
                ))}
                <span role="cell" className="border-l border-border px-3.5 py-3">
                  <StatusTag status={r.status} t={t} />
                </span>
              </div>
            ))}
            {!shown.length && <p className="px-4 py-8 text-center text-[13px] text-muted-foreground">{t.empty}</p>}
          </div>
        </div>

        {/* Cards on phones */}
        <ul className="mt-3 space-y-2 md:hidden">
          {shown.map((r) => (
            <li key={r.id} className={cn('rounded-xl border border-border p-3.5', r.status === 'conflict' && 'border-[color-mix(in_oklab,var(--destructive)_35%,var(--border))]')}>
              <div className="flex items-start justify-between gap-3">
                <p className="text-[13.5px] font-medium text-foreground">{r.topic}</p>
                <StatusTag status={r.status} t={t} />
              </div>
              <dl className="mt-2.5 space-y-1.5">
                {docs.map((d) => (
                  <div key={d.id} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] gap-3 text-[13px]">
                    <dt className="truncate text-muted-foreground">{d.title}</dt>
                    <dd className={r.values[d.id] ? 'text-foreground' : 'text-subtle-foreground'}>{r.values[d.id] || '·'}</dd>
                  </div>
                ))}
              </dl>
              {r.note && <p className="mt-2 text-[12.5px] leading-snug text-muted-foreground">{r.note}</p>}
            </li>
          ))}
          {!shown.length && <li className="py-6 text-center text-[13px] text-muted-foreground">{t.empty}</li>}
        </ul>

        {missing.length > 0 && (
          <div className="mt-5 rounded-xl border border-dashed border-border-strong p-3.5">
            <SectionLabel icon="search">{t.missing}</SectionLabel>
            <ul className="mt-2 space-y-1">
              {missing.map((m) => (
                <li key={m} className="text-[13.5px] leading-relaxed text-foreground">
                  {m}
                </li>
              ))}
            </ul>
          </div>
        )}
        <Feedback t={t} text={[title, tldr, ...rows.map((r) => `${r.topic}: ${docs.map((d) => `${d.title} ${r.values[d.id] ?? ''}`).join(' | ')}`)].filter(Boolean).join('\n')} onFeedback={onFeedback} onCopy={onCopy} className="mt-5 border-t border-border pt-3" />
      </Reveal>
      <Reveal delay={120} className="mt-4 px-1">
        <Honest t={t} />
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * An AI summary as a card: a compact one with key points, sources and more
 * behind a fold, a meeting with decisions, action items and the moment to
 * listen to, a document with the passage behind every point, and several
 * documents compared topic by topic. Confidence and open questions are shown,
 * never hidden, with copy and feedback on every card.
 */
export function AiSummaryCard({ variant = 'compact', labels, ...props }: AiSummaryCardProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  switch (variant) {
    case 'meeting':
      return <Meeting {...p} />;
    case 'document':
      return <DocumentView {...p} />;
    case 'compare':
      return <Compare {...p} />;
    default:
      return <Compact {...p} />;
  }
}

Frequent questions

How do I install AI summary card?

With aiw add @ai-website/ai-summary-card. The command puts components/ui/ai-summary-card.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.

Which variants does AI summary card have?

4 variants, Compact, Meeting, Document with passages and Compare documents. You pick one with the variant prop, for example variant="meeting".

Can I translate the texts of AI summary card?

Yes. AI summary card carries 44 texts of its own in English and German, such as “AI summary”, “Key points” and “Show more”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with AI summary card?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI summary card with aiw_components and install it with aiw_add @ai-website/ai-summary-card. Name the variant in your prompt, for example variant="compact", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard