Want me to move this week's ad budget?
Shift budget to Northwind Spring Search with ROAS 4.2x
Swap the hero creative to Trail Test video for the spring launch.
Fall back to an even split across every campaign.
Other options
Agent suggestion with a confidence meter and alternatives.
AgentComponent3 dependencies Beautiful UI, MIT
Shift budget to Northwind Spring Search with ROAS 4.2x
Swap the hero creative to Trail Test video for the spring launch.
Fall back to an even split across every campaign.
Other options
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- Paste into src/components/viewership/recommendation-card/
---
import RecommendationCard from "../components/viewership/recommendation-card/RecommendationCard.astro";
---
<RecommendationCard /> | 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). |
| Event | Detail | When |
|---|---|---|
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. |
---
/**
* 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> Ported from Beautiful UI, MIT. Notices.