All chunks 32
Headlines stay under 60 characters, with no exclamation marks, before a draft goes to client review.
Q4 sessions by channel: organic +18%, paid search +6%, email -11%; pause ad groups under 40 clicks weekly.
Retrieved knowledge chunks with sources, entering in sequence.
AgentComponentNo dependencies Beautiful UI, MIT
Headlines stay under 60 characters, with no exclamation marks, before a draft goes to client review.
Q4 sessions by channel: organic +18%, paid search +6%, email -11%; pause ad groups under 40 clicks weekly.
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- ---
import ContextCards from "../components/viewership/context-cards/ContextCards.astro";
---
<ContextCards /> | Prop | Type | Default | Description |
|---|---|---|---|
chunks | { title; chars: string | number; body; source; badge; tone?: "red" | "green" | "orange" | "accent"; href? }[] | [] | Retrieved chunks. A source chip with `href` is a link; otherwise a button that emits `vs-context-source`. |
header | string | "All chunks" | Header label and section name. |
count | string | number | chunks.length | Count beside the header. |
autoplay | boolean | true | Play entrance once in view. false shows the final state. |
---
/**
* ContextCards: retrieved knowledge chunks with their sources, entering in
* sequence. A source chip is a link (with `href`) or a button that emits
* `vs-context-source`.
*
* Ported from Beautiful UI `components/primitives/ContextCards.tsx`
* (MIT, © 2026 Shane Levine).
*
* @example
* <ContextCards chunks={chunks} count={32} />
*
* Events: `vs-context-source` (detail: { index, source, title }) when a
* button-style source chip is activated.
*/
import type { HTMLAttributes } from "astro/types";
export type ContextChunk = {
title: string;
/** Size line, e.g. "290 characters". A number is formatted as "N characters". */
chars: string | number;
body: string;
/** Source document name. */
source: string;
/** Short file-type badge, e.g. "PDF". */
badge: string;
tone?: "red" | "green" | "orange" | "accent";
/** Optional link for the source chip. */
href?: string;
};
type Props = HTMLAttributes<"section"> & {
chunks?: ContextChunk[];
/** Header label. */
header?: string;
/** Count beside the header (e.g. total chunks in the index). Defaults to chunks.length. */
count?: string | number;
/** Play the entrance when scrolled into view. false renders the final state. */
autoplay?: boolean;
};
const { chunks = [], header = "All chunks", count, autoplay = true, class: className, ...rest } = Astro.props;
const chars = (c: ContextChunk["chars"]) => (typeof c === "number" ? `${c.toLocaleString("en-US")} characters` : c);
---
<section
class:list={["vs-context-cards", className]}
data-autoplay={autoplay ? "" : undefined}
aria-label={header}
{...rest}
>
<div class="header">
<span class="title">{header}</span>
<span class="count">{count ?? chunks.length}</span>
</div>
{chunks.map((chunk, i) => {
const Source = chunk.href ? "a" : "button";
return (
<article class="card" style={`--i: ${i}`} aria-label={chunk.title}>
<div class="bar">
<span class="chunk-title">
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"></path></svg>
<span class="truncate">{chunk.title}</span>
</span>
<span class="chars">{chars(chunk.chars)}</span>
</div>
<p class="body">{chunk.body}</p>
<div class="foot">
<Source
class="source"
href={chunk.href}
type={chunk.href ? undefined : "button"}
data-source-index={chunk.href ? undefined : i}
aria-label={`Open source: ${chunk.source}`}
>
<span class:list={["badge", `tone-${chunk.tone ?? "accent"}`]} aria-hidden="true">{chunk.badge}</span>
{chunk.source}
<svg aria-hidden="true" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7M7 7h10v10"></path></svg>
</Source>
</div>
</article>
);
})}
</section>
<script>
function init(root: HTMLElement) {
root.querySelectorAll<HTMLButtonElement>("button.source").forEach((button) => {
button.addEventListener("click", () => {
const card = button.closest("article");
root.dispatchEvent(
new CustomEvent("vs-context-source", {
bubbles: true,
detail: {
index: Number(button.dataset.sourceIndex),
source: button.textContent?.trim() ?? "",
title: card?.getAttribute("aria-label") ?? "",
},
}),
);
});
});
// Entrance: hidden until scrolled into view, then played once.
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!("autoplay" in root.dataset) || reduce || !("IntersectionObserver" in window)) return;
root.dataset.enter = "wait";
const io = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
io.disconnect();
root.dataset.enter = "play";
},
{ threshold: 0.2 },
);
io.observe(root);
document.addEventListener("astro:before-swap", () => io.disconnect(), { once: true });
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-context-cards:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>
<style>
.vs-context-cards {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 380px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 2px;
}
.title {
font-size: 13px;
font-weight: 600;
color: var(--vs-ink);
}
.count {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
border-radius: 6px;
background: var(--vs-inset);
box-shadow: var(--vs-shadow-hairline);
font-size: 11.5px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--vs-ink-2);
}
.card {
overflow: hidden;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
}
.bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--vs-line);
}
.chunk-title {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--vs-ink);
}
.chunk-title svg {
flex-shrink: 0;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chars {
flex-shrink: 0;
margin-left: auto;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--vs-ink-3);
}
.body {
margin: 0;
padding: 8px 12px 4px;
font-size: 12.5px;
line-height: 1.625;
color: var(--vs-ink-2);
}
.foot {
padding: 0 12px 12px;
}
.source {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
border: none;
border-radius: var(--vs-radius-pill);
background: var(--vs-inset);
box-shadow: var(--vs-shadow-btn);
color: var(--vs-ink-2);
font: inherit;
font-size: 12px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition:
opacity 300ms var(--vs-ease-out),
transform 300ms var(--vs-ease-out),
background-color 300ms var(--vs-ease-out);
}
.source:hover {
background: var(--vs-hover);
}
.source:focus-visible {
outline: none;
box-shadow: var(--vs-shadow-btn), var(--vs-focus-ring);
}
.badge {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
font-size: 7px;
font-weight: 700;
color: var(--vs-surface);
}
.tone-red {
background: var(--vs-red);
}
.tone-green {
background: var(--vs-green);
}
.tone-orange {
background: var(--vs-orange);
}
.tone-accent {
background: var(--vs-accent);
}
/* entrance: only once the script has taken over (no-JS shows the final state) */
.vs-context-cards[data-enter="wait"] :is(.header, .card, .source) {
opacity: 0;
}
.vs-context-cards[data-enter="play"] .header {
animation: vs-fade-in 400ms ease-out both;
}
.vs-context-cards[data-enter="play"] .card {
animation: vs-fade-up 400ms var(--vs-ease-out) calc(var(--i) * 100ms) both;
}
.vs-context-cards[data-enter="play"] .source {
animation: vs-pop-in 300ms var(--vs-ease-out) calc(700ms + var(--i) * 80ms) both;
}
@media (prefers-reduced-motion: reduce) {
.vs-context-cards[data-enter] :is(.header, .card, .source) {
opacity: 1;
animation: none;
}
}
</style> Ported from Beautiful UI, MIT. Notices.