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.
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
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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AiSummaryCardProps interface from components/ui/ai-summary-card.tsx, with the comments from the code.
variantoptionalAiSummaryCardVariantcompactmeetingdocumentcompare
titleoptionalstringcontextoptionalstringWhat was summarized, e.g. "Thread · 23 messages"
tldroptionalstringThe summary in one or two sentences
pointsoptionalSummaryPoint[]detailsoptionalstring[]compact: more paragraphs behind "Show more"
sourcesoptionalSummarySource[]confidenceoptionalnumberOverall confidence in percent
unclearoptionalstring[]Things the summary could not settle
meetingoptional{ date: string; duration: string; participants: string[] }meeting
decisionsoptional{ text: string; source?: number }[]actionsoptionalMeetingAction[]missingoptionalstring[]document, compare: facts the sources do not contain
docsoptionalCompareDoc[]compare
rowsoptionalCompareRow[]generatedoptionalstringe.g. "Nova 2 · 2 min ago"
onFeedbackoptional(helpful: boolean, reason?: string) => voidonCopyoptional(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
| Key | English | German |
|---|---|---|
| summary | English AI summary | German KI-Zusammenfassung |
| keyPoints | English Key points | German Kernpunkte |
| more | English Show more | German Mehr anzeigen |
| less | English Show less | German Weniger anzeigen |
| high | English High confidence | German Hohe Sicherheit |
| medium | English Medium confidence | German Mittlere Sicherheit |
| low | English Low confidence | German Geringe Sicherheit |
| confidence | English Confidence | German Sicherheit |
| confidenceOf | English Confidence {v} | German Sicherheit {v} |
| unclear | English Not settled | German Nicht geklärt |
| sources | English Sources | German Quellen |
| source | English Source {n} | German Quelle {n} |
| openSource | English Show passage from source {n} | German Stelle aus Quelle {n} zeigen |
| copy | English Copy summary | German Zusammenfassung kopieren |
| copied | English Copied | German Kopiert |
| copyShort | English Copy | German Kopieren |
| helpful | English Helpful | German Hilfreich |
| notHelpful | English Not helpful | German Nicht hilfreich |
| why | English What was off? | German Was hat nicht gepasst? |
| reasons | English Inaccurate|Something is missing|Too long|Wrong tone | German Ungenau|Es fehlt etwas|Zu lang|Falscher Ton |
| thanks | English Thanks, this helps the next summary. | German Danke, das hilft der nächsten Zusammenfassung. |
| decisions | English Decisions | German Entscheidungen |
| actions | English Action items | German Aufgaben |
| owner | English Owner | German Zuständig |
| due | English Due {v} | German bis {v} |
| openItems | English {n} of {total} open | German {n} von {total} offen |
| participants | English Participants | German Teilnehmende |
| jump | English Hear it at {v} | German Bei {v} anhören |
| passage | English Passage | German Fundstelle |
| passageOf | English Passage for “{name}” | German Fundstelle zu „{name}“ |
| pick | English Choose a point to see where it comes from. | German Wähle einen Punkt, um zu sehen, woher er kommt. |
| inSource | English In the source | German In der Quelle |
| noSource | English No source backs this point. | German Keine Quelle stützt diesen Punkt. |
| missing | English Not in the documents | German Nicht in den Dokumenten |
| compare | English Comparison | German Vergleich |
| topic | English Topic | German Thema |
| onlyDiff | English Only differences | German Nur Unterschiede |
| same | English Same | German Gleich |
| differs | English Differs | German Abweichend |
| conflict | English Contradicts | German Widerspruch |
| missingStatus | English Missing | German Fehlt |
| tally | English {same} same, {differs} different, {conflict} contradicting | German {same} gleich, {differs} abweichend, {conflict} im Widerspruch |
| empty | English All documents agree. | German Alle Dokumente stimmen überein. |
| honest | English 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
'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.