# Recommendation Card

> Agent suggestion with a confidence meter and alternatives.

- **Slug:** `recommendation-card`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/RecommendationCard.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/recommendation-card
- **All files in one bundle:** https://viewership.ai/ui/components/recommendation-card.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/recommendation-card.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/recommendation-card/`:

   - `RecommendationCard.astro` (raw: https://viewership.ai/ui/raw/recommendation-card/RecommendationCard.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `button`: `Button.astro` (docs: https://viewership.ai/ui/components/button.md)
   - `entity-chip`: `EntityChip.astro`, `Monogram.astro` (docs: https://viewership.ai/ui/components/entity-chip.md)
   - `value-pill`: `ValuePill.astro` (docs: https://viewership.ai/ui/components/value-pill.md)

npm packages: none.

## Usage

```astro
---
import RecommendationCard from "../components/viewership/recommendation-card/RecommendationCard.astro";
---

<RecommendationCard />
```

## Example

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

```astro
---
import RecommendationCard from "./RecommendationCard.astro";
import type { RecommendationOption } from "./RecommendationCard.astro";

const options: RecommendationOption[] = [
  {
    key: "shift",
    body: [
      "Shift budget to",
      { entity: "Northwind Spring Search", color: "var(--vs-green)" },
      "with ROAS",
      { value: "4.2x", tone: "green" },
    ],
    short: "Shift budget to Spring Search · 4.2x",
    signal: 3,
    tone: "green",
    label: "High confidence",
    cta: "Accept",
    ctaVariant: "accent",
  },
  {
    key: "swap",
    body: ["Swap the hero creative to", { value: "Trail Test video" }, "for the spring launch."],
    short: "Swap hero creative to Trail Test",
    signal: 2,
    tone: "orange",
    label: "Needs review",
    cta: "Configure",
    ctaVariant: "primary",
  },
  {
    key: "even",
    body: ["Fall back to an", { strong: "even split" }, "across every campaign."],
    short: "Even split across every campaign",
    signal: 0,
    tone: "neutral",
    label: "No signal",
    cta: "Accept even split",
    ctaVariant: "primary",
  },
];
---

<div class="stage">
  <RecommendationCard title="Want me to move this week's ad budget?" options={options} />
</div>

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

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `string` |  | The agent's question. |
| `options` | `RecommendationOption[]` |  | { key, body, short, signal (0–3), tone, label, cta, ctaVariant? }. body is a string or parts: strings, { entity, color? }, { value, tone? }, { strong }. |
| `selected` | `number` | `0` | Initial option index. |
| `labels` | `Partial<RecommendationLabels>` |  | alternatives, otherOptions, accepted, confidence (meter name). |
| `vs-recommendation-accept` | `CustomEvent<{ key: string; index: number }>` |  | Fires on accept. The card only shows "Accepted"; do the work in your handler. |
| `vs-recommendation-select` | `CustomEvent<{ key: string; index: number }>` |  | Fires when an alternative is promoted. |
| `vs-recommendation-toggle` | `CustomEvent<{ open: boolean }>` |  | Fires when the drawer toggles. |

## Source

### `src/components/viewership/recommendation-card/RecommendationCard.astro`

```astro
---
/**
 * RecommendationCard: agent suggestion with a confidence meter,
 * alternatives and an accept button.
 *
 * Ported from Beautiful UI `components/primitives/RecommendationCard.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * The card does no work itself. It reports choices as events. Bodies are
 * built from parts: strings, `{ entity }`, `{ value }` and `{ strong }`.
 *
 * Events (bubbling, on the root):
 *   vs-recommendation-accept  detail: { key, index }
 *   vs-recommendation-select  detail: { key, index }
 *   vs-recommendation-toggle  detail: { open }
 *
 * @example
 * <RecommendationCard title="Publish this draft?" options={[{ key: "a", body: "Publish the Spring Trail draft.", short: "Publish", signal: 3, tone: "green", label: "High confidence", cta: "Accept" }]} />
 */
import type { HTMLAttributes } from "astro/types";
import Button from "../button/Button.astro";
import EntityChip from "../entity-chip/EntityChip.astro";
import ValuePill from "../value-pill/ValuePill.astro";

type Tone = "green" | "orange" | "red" | "accent" | "neutral";
type CtaVariant = "primary" | "secondary" | "ghost" | "accent" | "success" | "quiet";

/** An EntityChip inside the body. */
export interface RecommendationEntityPart {
  entity: string;
  color?: string;
  monogram?: string;
}
/** A ValuePill inside the body. */
export interface RecommendationValuePart {
  value: string;
  tone?: Tone;
}
/** Emphasized text inside the body. */
export interface RecommendationStrongPart {
  strong: string;
}
export type RecommendationBodyPart = string | RecommendationEntityPart | RecommendationValuePart | RecommendationStrongPart;

export interface RecommendationOption {
  /** Stable id, reported in events. */
  key: string;
  /** The recommendation in prose. */
  body: string | RecommendationBodyPart[];
  /** One-line summary used in the alternatives drawer. */
  short: string;
  /** Confidence, 0–3 bars. */
  signal: number;
  tone: Tone;
  /** Confidence label, e.g. "High confidence". */
  label: string;
  /** Primary action text. */
  cta: string;
  ctaVariant?: CtaVariant;
}

export interface RecommendationLabels {
  alternatives: string;
  otherOptions: string;
  accepted: string;
  /** Accessible name of the meter. */
  confidence: string;
}

type Props = HTMLAttributes<"div"> & {
  title: string;
  options: RecommendationOption[];
  /** Index of the initial recommendation. */
  selected?: number;
  labels?: Partial<RecommendationLabels>;
};

const { title, options = [], selected = 0, labels, class: className, ...rest } = Astro.props;

const l: RecommendationLabels = {
  alternatives: "Alternatives",
  otherOptions: "Other options",
  accepted: "Accepted",
  confidence: "Confidence",
  ...labels,
};

const uid = `vs-rec-${Math.random().toString(36).slice(2, 8)}`;
const sel = Math.min(Math.max(0, selected), Math.max(0, options.length - 1));
const active = options[sel];
const TONE: Record<Tone, string> = {
  green: "var(--vs-green)",
  orange: "var(--vs-orange)",
  red: "var(--vs-red)",
  accent: "var(--vs-accent)",
  neutral: "var(--vs-ink-3)",
};
const toneOf = (tone: Tone) => TONE[tone] ?? TONE.neutral;
const bars = [0, 1, 2];
const parts = (body: RecommendationOption["body"]): RecommendationBodyPart[] => (typeof body === "string" ? [body] : body);
const config = options.map((o) => ({
  key: o.key,
  signal: o.signal,
  tone: toneOf(o.tone),
  label: o.label,
  cta: o.cta,
  ctaVariant: o.ctaVariant ?? "primary",
}));
---

<div
  class:list={["vs-recommendation-card", className]}
  data-selected={sel}
  data-accepted-label={l.accepted}
  {...rest}
>
  <script is:inline type="application/json" data-vs-config set:html={JSON.stringify(config).replace(/</g, "\\u003c")} />
  <div class="pad">
    <span class="title">{title}</span>
    <div class="bodies" aria-live="polite">
      {options.map((o, i) => (
        <p class="body" data-body={i} hidden={i !== sel}>
          {parts(o.body).map((part, j) => (
            <>
              {j > 0 && !(typeof part === "string" && /^[.,;:!?)]/.test(part)) ? " " : ""}
              {typeof part === "string" ? (
                part
              ) : "entity" in part ? (
                <EntityChip name={part.entity} color={part.color} monogram={part.monogram} />
              ) : "value" in part ? (
                <ValuePill tone={part.tone}>{part.value}</ValuePill>
              ) : (
                <span class="strong">{part.strong}</span>
              )}
            </>
          ))}
        </p>
      ))}
    </div>
  </div>

  <div class="drawer" id={`${uid}-drawer`} inert data-drawer>
    <div class="drawer-clip">
      <div class="drawer-inner">
        <p class="drawer-label" id={`${uid}-others`}>{l.otherOptions}</p>
        <div role="group" aria-labelledby={`${uid}-others`}>
          {options.map((o, i) => (
            <button type="button" class="alt" data-alt={i} hidden={i === sel}>
              <span class="meter" aria-hidden="true" style={`--vs-rec-tone: ${toneOf(o.tone)}`}>
                {bars.map((b) => <span class="bar" data-on={b < o.signal ? "" : undefined}></span>)}
              </span>
              <span class="alt-short">{o.short}</span>
              <span class="alt-label">{o.label}</span>
            </button>
          ))}
        </div>
      </div>
    </div>
  </div>

  <div class="footer">
    <span
      class="confidence"
      role="meter"
      aria-label={l.confidence}
      aria-valuemin="0"
      aria-valuemax="3"
      aria-valuenow={active?.signal ?? 0}
      aria-valuetext={active?.label}
      data-meter
    >
      <span class="meter" style={`--vs-rec-tone: ${active ? toneOf(active.tone) : TONE.neutral}`} data-meter-bars>
        {bars.map((b) => <span class="bar" data-on={active && b < active.signal ? "" : undefined}></span>)}
      </span>
      <span class="confidence-label" data-meter-label>{active?.label}</span>
    </span>

    <span class="actions">
      <Button variant="secondary" size="sm" class="alt-toggle" aria-expanded="false" aria-controls={`${uid}-drawer`} data-toggle>
        {l.alternatives}
      </Button>
      <Button variant={active?.ctaVariant ?? "primary"} size="sm" class="cta" data-accept>
        {active?.cta}
      </Button>
    </span>
  </div>
  <span class="vh" aria-live="polite" data-live></span>
</div>

<script>
  interface OptionConfig {
    key: string;
    signal: number;
    tone: string;
    label: string;
    cta: string;
    ctaVariant: string;
  }

  function init(root: HTMLElement) {
    const config = JSON.parse(root.querySelector("[data-vs-config]")?.textContent || "[]") as OptionConfig[];
    const bodies = [...root.querySelectorAll<HTMLElement>("[data-body]")];
    const alts = [...root.querySelectorAll<HTMLButtonElement>("[data-alt]")];
    const drawer = root.querySelector<HTMLElement>("[data-drawer]")!;
    const toggle = root.querySelector<HTMLButtonElement>("[data-toggle]")!;
    const accept = root.querySelector<HTMLButtonElement>("[data-accept]")!;
    const meter = root.querySelector<HTMLElement>("[data-meter]")!;
    const meterBars = root.querySelector<HTMLElement>("[data-meter-bars]")!;
    const meterLabel = root.querySelector<HTMLElement>("[data-meter-label]")!;
    const live = root.querySelector<HTMLElement>("[data-live]")!;
    const acceptedLabel = root.dataset.acceptedLabel ?? "Accepted";
    if (!config.length) return;

    let selected = Number(root.dataset.selected ?? 0);
    let accepted = false;

    const renderCta = () => {
      const o = config[selected]!;
      accept.className = accept.className.replace(/\bvs-button--(primary|secondary|ghost|accent|success|quiet)\b/, `vs-button--${accepted ? "success" : o.ctaVariant}`);
      accept.textContent = accepted ? acceptedLabel : o.cta;
    };

    const select = (index: number) => {
      const o = config[index];
      if (!o) return;
      selected = index;
      accepted = false;
      bodies.forEach((b, i) => {
        b.hidden = i !== index;
        if (i === index) {
          b.removeAttribute("data-enter");
          void b.offsetWidth;
          b.setAttribute("data-enter", "");
        }
      });
      alts.forEach((a, i) => (a.hidden = i === index));
      meterBars.style.setProperty("--vs-rec-tone", o.tone);
      [...meterBars.children].forEach((bar, b) => bar.toggleAttribute("data-on", b < o.signal));
      meterLabel.textContent = o.label;
      meter.setAttribute("aria-valuenow", String(o.signal));
      meter.setAttribute("aria-valuetext", o.label);
      renderCta();
    };

    toggle.addEventListener("click", () => {
      const open = toggle.getAttribute("aria-expanded") !== "true";
      toggle.setAttribute("aria-expanded", String(open));
      drawer.toggleAttribute("data-open", open);
      drawer.inert = !open;
      root.dispatchEvent(new CustomEvent("vs-recommendation-toggle", { bubbles: true, detail: { open } }));
    });

    alts.forEach((alt) => {
      alt.addEventListener("click", () => {
        const index = Number(alt.dataset.alt);
        const position = alts.filter((a) => !a.hidden).indexOf(alt);
        select(index);
        // The picked row leaves the list; keep focus on the row now in its place.
        const visible = alts.filter((a) => !a.hidden);
        (visible[Math.min(position, visible.length - 1)] ?? toggle).focus();
        root.dispatchEvent(new CustomEvent("vs-recommendation-select", { bubbles: true, detail: { key: config[index]!.key, index } }));
      });
    });

    accept.addEventListener("click", () => {
      if (accepted) return;
      accepted = true;
      renderCta();
      live.textContent = acceptedLabel;
      root.dispatchEvent(new CustomEvent("vs-recommendation-accept", { bubbles: true, detail: { key: config[selected]!.key, index: selected } }));
    });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-recommendation-card:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-recommendation-card {
    width: 100%;
    max-width: 380px;
    overflow: hidden;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .vh {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }
  .pad {
    padding: 12px;
  }
  .title {
    font-size: 14px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .body {
    min-height: 48px;
    margin: 6px 0 0;
    font-size: 13px;
    line-height: 1.625;
    color: var(--vs-ink-2);
  }
  .body[data-enter] {
    animation: vs-fade-in 180ms ease-out both;
  }
  .strong {
    font-weight: 500;
    color: var(--vs-ink);
  }

  /* ── alternatives drawer: a distinctly new section of the card ── */
  .drawer {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transition:
      grid-template-rows 300ms var(--vs-ease-link),
      opacity 300ms var(--vs-ease-link);
  }
  .drawer[data-open] {
    grid-template-rows: 1fr;
    opacity: 1;
  }
  .drawer-clip {
    min-height: 0;
    overflow: hidden;
  }
  .drawer-inner {
    padding: 8px;
    border-top: 1px solid var(--vs-line);
    background: var(--vs-surface);
  }
  .drawer-label {
    margin: 0;
    padding: 0 6px 4px;
    font-size: 11px;
    font-weight: 500;
    color: var(--vs-ink-3);
  }
  .alt {
    display: flex;
    width: 100%;
    align-items: center;
    gap: 10px;
    padding: 6px;
    border: none;
    border-radius: var(--vs-radius-control);
    background: transparent;
    font: inherit;
    text-align: left;
    cursor: pointer;
    transition: background-color 100ms ease-out;
  }
  .alt:hover {
    background: var(--vs-hover);
  }
  .alt:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .alt[hidden] {
    display: none;
  }
  .alt-short {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    font-size: 12.5px;
    color: var(--vs-ink);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .alt-label {
    flex-shrink: 0;
    font-size: 11px;
    color: var(--vs-ink-3);
  }

  /* ── meter ── */
  .meter {
    display: flex;
    align-items: flex-end;
    gap: 2px;
  }
  .bar {
    width: 4px;
    height: 10px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-line-strong);
    transition: background-color 300ms ease-out;
  }
  .bar[data-on] {
    background: var(--vs-rec-tone);
  }

  /* ── footer ── */
  .footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px;
    background: var(--vs-surface);
  }
  .confidence {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .confidence-label {
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink-2);
  }
  .actions {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-right: -2px;
  }
  .actions :global(.alt-toggle) {
    padding: 0 10px;
    font-size: 12.5px;
  }
  .actions :global(.cta) {
    font-size: 12.5px;
  }

  @media (prefers-reduced-motion: reduce) {
    .drawer,
    .bar {
      transition: none;
    }
    .body[data-enter] {
      animation: none;
    }
  }
  @media (forced-colors: active) {
    .bar {
      background: GrayText;
    }
    .bar[data-on] {
      background: CanvasText;
    }
  }
</style>
```
