# Context Cards

> Retrieved knowledge chunks with sources, entering in sequence.

- **Slug:** `context-cards`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/ContextCards.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/context-cards
- **All files in one bundle:** https://viewership.ai/ui/components/context-cards.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/context-cards.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/context-cards/`:

   - `ContextCards.astro` (raw: https://viewership.ai/ui/raw/context-cards/ContextCards.astro.txt)

npm packages: none.

## Usage

```astro
---
import ContextCards from "../components/viewership/context-cards/ContextCards.astro";
---

<ContextCards />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import ContextCards, { type ContextChunk } from "./ContextCards.astro";

const chunks: ContextChunk[] = [
  {
    title: "Brand voice rule",
    chars: 290,
    body: "Headlines stay under 60 characters, with no exclamation marks, before a draft goes to client review.",
    source: "Brand Voice Guide.pdf",
    badge: "PDF",
    tone: "red",
  },
  {
    title: "Weekly traffic row",
    chars: 1250,
    body: "Q4 sessions by channel: organic +18%, paid search +6%, email -11%; pause ad groups under 40 clicks weekly.",
    source: "GA4 Channel Export.csv",
    badge: "CSV",
    tone: "green",
  },
];
---

<div class="stage">
  <ContextCards chunks={chunks} count={32} />
</div>

<style>
  .stage {
    display: flex;
    justify-content: center;
  }
</style>
```

## Props

| 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. |

## Source

### `src/components/viewership/context-cards/ContextCards.astro`

```astro
---
/**
 * 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>
```
