feat(apollo-vertex): add ConfidenceSignal component - #1001
feat(apollo-vertex): add ConfidenceSignal component#1001ChloeDalyUiPath wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new ConfidenceSignal UI component to the Apollo Vertex registry and documentation. The component is intended to communicate AI confidence levels (high/medium/low/unknown) via a signal-bar chip, optionally showing a hover/click popover with explanations, factor breakdowns, and CTAs.
Changes:
- Introduces
ConfidenceSignal+SignalBars, including an optional “acquire” animation and hover-open / 150ms-close popover behavior. - Registers the component in
apps/apollo-vertex/registry.jsonfor the Vertex registry build pipeline. - Adds a new docs page at
/components/confidence-signaland adds it to the components nav.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| apps/apollo-vertex/registry/confidence-signal/confidence-signal.tsx | Implements the ConfidenceSignal chip, popover content, and signal-bar SVG/animation. |
| apps/apollo-vertex/registry.json | Registers the new registry:ui entry for confidence-signal. |
| apps/apollo-vertex/app/components/confidence-signal/page.mdx | Adds component documentation and usage examples. |
| apps/apollo-vertex/app/components/_meta.ts | Adds “Confidence Signal” to the components navigation. |
Suppressed comments (5)
apps/apollo-vertex/registry/confidence-signal/confidence-signal.tsx:328
- Same issue for
nextStep:hrefis part of the prop type but isn’t used, so link-style next steps can’t be implemented without custom wrappers.
onClick={nextStep.onClick}
>
{nextStep.label}
<ArrowUpRight className="size-3" />
</Button>
apps/apollo-vertex/app/components/confidence-signal/page.mdx:33
- Same as above:
nextStepis shown withouthref/onClick, producing a no-op CTA in the docs example.
<div className="p-4 border rounded-lg mt-4 flex flex-wrap gap-4 items-center">
<ConfidenceSignal level="high" variant="med" />
<ConfidenceSignal level="medium" variant="med" nextStep={{ label: 'Review' }} />
<ConfidenceSignal level="low" variant="med" nextStep={{ label: 'Review' }} />
<ConfidenceSignal level="unknown" variant="med" />
</div>
apps/apollo-vertex/app/components/confidence-signal/page.mdx:42
- Same as above:
nextStepis shown withouthref/onClick, producing a no-op CTA in the docs example.
<div className="p-4 border rounded-lg mt-4 flex flex-wrap gap-4 items-center">
<ConfidenceSignal level="high" variant="max" />
<ConfidenceSignal level="medium" variant="max" nextStep={{ label: 'Review' }} />
<ConfidenceSignal level="low" variant="max" nextStep={{ label: 'Review' }} />
<ConfidenceSignal level="unknown" variant="max" />
</div>
apps/apollo-vertex/app/components/confidence-signal/page.mdx:74
- The popover example provides
nextStepwithouthref/onClick, which renders a no-op CTA in the docs.
{ label: 'Document match', value: '2 / 5', status: 'error' },
{ label: 'Historical accuracy', value: '61%' },
]}
nextStep={{ label: 'Review manually' }}
/>
apps/apollo-vertex/app/components/confidence-signal/page.mdx:84
- The acquire-animation example passes
nextStepwithouthref/onClick, producing a no-op CTA in the docs.
<div className="p-4 border rounded-lg mt-4 flex flex-wrap gap-4 items-center">
<ConfidenceSignal level="high" variant="max" animateIn />
<ConfidenceSignal level="medium" variant="max" animateIn nextStep={{ label: 'Review' }} />
</div>
| const ACQUIRE_KEYFRAMES = ` | ||
| @keyframes confidence-signal-acquire { | ||
| 0% { transform: scaleY(1); } | ||
| 30% { transform: scaleY(0.05); } | ||
| 100% { transform: scaleY(1); } | ||
| } | ||
| `; |
There was a problem hiding this comment.
Fixed. The SVG now carries a local @media (prefers-reduced-motion: reduce) rule that overrides the inline animation, so the bars render at their final state with no motion.
| const LEVEL_TEXT_CLASS: Record<ConfidenceLevel, string> = { | ||
| high: "text-success", | ||
| medium: "text-amber-700", | ||
| low: "text-destructive", | ||
| unknown: "text-foreground", | ||
| }; |
There was a problem hiding this comment.
Fixed. Medium now uses text-warning-foreground dark:text-warning, matching badge.tsx/alert.tsx. The split is needed because --warning is a light amber that fails contrast on a light background while --warning-foreground is near-black in both themes. Note the bar fills themselves stay literal hues on purpose: the signal metaphor relies on a fixed green/amber/red ramp reading identically in both themes, the way a battery or wifi icon does.
| {explainCta && ( | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| className="w-full" | ||
| onClick={explainCta.onClick} | ||
| > | ||
| {explainCta.label} | ||
| <ArrowUpRight className="size-3" /> | ||
| </Button> | ||
| )} |
There was a problem hiding this comment.
Fixed. ConfidenceSignalCta now renders an anchor via Button asChild when href is present, so the target is navigable, middle-clickable, and copyable. onClick still fires. Also tightened the type so at least one of href/onClick is now required.
| <div className="p-4 border rounded-lg mt-4 flex flex-wrap gap-4 items-center"> | ||
| <ConfidenceSignal level="high" variant="min" /> | ||
| <ConfidenceSignal level="medium" variant="min" nextStep={{ label: 'Review' }} /> | ||
| <ConfidenceSignal level="low" variant="min" nextStep={{ label: 'Review' }} /> | ||
| <ConfidenceSignal level="unknown" variant="min" /> | ||
| </div> |
There was a problem hiding this comment.
Fixed. Every example CTA now passes a real href. This is also enforced at the type level now, so a CTA with neither href nor onClick fails to compile rather than silently rendering a no-op.
| { label: 'Source quality', value: 'High', status: 'success' }, | ||
| { label: 'Data recency', value: '< 30 days', status: 'success' }, | ||
| ]} | ||
| explainCta={{ label: 'View audit trail' }} |
There was a problem hiding this comment.
Fixed, same as above. explainCta in the popover examples now points at a real target.
| const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); | ||
|
|
||
| React.useEffect( | ||
| () => () => { | ||
| if (closeTimer.current) clearTimeout(closeTimer.current); | ||
| }, | ||
| [], | ||
| ); | ||
|
|
||
| const handleEnter = () => { | ||
| if (closeTimer.current) clearTimeout(closeTimer.current); | ||
| setOpen(true); | ||
| }; | ||
| const handleLeave = () => { | ||
| closeTimer.current = setTimeout(() => setOpen(false), 150); | ||
| }; |
There was a problem hiding this comment.
We don't need to re-implement a tooltip. For consistency and simplicity's sake, let's use the existing Tooltip component.
There was a problem hiding this comment.
Done. Now uses the existing Tooltip from @/components/ui/tooltip rather than a hand-rolled one. Every chip carries it, which also covers variant="min" where the icon would otherwise be unlabelled.
| <div | ||
| key={factor.label} | ||
| className="flex items-center justify-between gap-2" | ||
| > | ||
| <span className="text-xs text-muted-foreground"> | ||
| {factor.label} | ||
| </span> | ||
| <span | ||
| className={cn( | ||
| "text-xs font-medium", | ||
| factor.status | ||
| ? FACTOR_STATUS_CLASS[factor.status] | ||
| : "text-foreground", | ||
| )} | ||
| > | ||
| {factor.value} | ||
| </span> | ||
| </div> |
There was a problem hiding this comment.
Should be it's own component so this component becomes more readable.
There was a problem hiding this comment.
Done. Split into confidence-signal-bars, -chip, -factors, -cta, and -levels, so the main file is now just composition (~150 lines).
| const chip = ( | ||
| <button | ||
| type="button" | ||
| data-slot="confidence-signal" | ||
| data-level={level} | ||
| className={cn( | ||
| "inline-flex items-center gap-1.5 text-xs font-semibold focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", | ||
| LEVEL_TEXT_CLASS[level], | ||
| hasPopoverContent && "cursor-pointer", | ||
| className, | ||
| )} | ||
| aria-label={LEVEL_LABEL[level]} | ||
| {...props} | ||
| > | ||
| <SignalBars level={level} animateIn={animateIn} /> | ||
| {label} | ||
| </button> | ||
| ); |
There was a problem hiding this comment.
Let's make this it's own component and pass things like label via props. That reduces this component's cognitive complexity quite a bit
There was a problem hiding this comment.
Done. This is now ConfidenceSignalChip, taking label, accessibleLabel, level, animateIn, and interactive as props.
| const LEVEL_LABEL: Record<ConfidenceLevel, string> = { | ||
| high: "High confidence", | ||
| medium: "Medium confidence", | ||
| low: "Low confidence", | ||
| unknown: "Unknown confidence", | ||
| }; | ||
|
|
||
| const LEVEL_SHORT_LABEL: Record<ConfidenceLevel, string> = { | ||
| high: "High", | ||
| medium: "Medium", | ||
| low: "Low", | ||
| unknown: "Unknown", | ||
| }; | ||
|
|
||
| const LEVEL_DEFAULT_EXPLANATION: Record<ConfidenceLevel, string> = { | ||
| high: "The output is well-supported and reliable.", | ||
| medium: "Some uncertainty remains — review before acting.", | ||
| low: "Limited evidence — verify this before relying on it.", | ||
| unknown: "The system cannot determine an answer reliably.", | ||
| }; |
There was a problem hiding this comment.
These should be translation keys.
There was a problem hiding this comment.
Done. Level labels, short labels, and default explanations are now translation keys under the confidence_signal_* prefix, resolved with useTranslation(). Added to locales/en.json in alphabetical order; no other locale files touched.
2303d26 to
bce2dca
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (5)
apps/apollo-vertex/registry/confidence-signal/confidence-signal.tsx:113
tooltipOpenis preserved while the popover is open. If the tooltip was open before opening the popover, closing the popover (e.g. by clicking outside) will immediately reopen the tooltip even when the pointer/focus is no longer on the trigger. ResettingtooltipOpenas part of the popoveronOpenChangeavoids this stale-state reopen/flash.
<Popover open={detailsOpen} onOpenChange={setDetailsOpen}>
apps/apollo-vertex/registry/confidence-signal/confidence-signal-factors.tsx:32
key={factor.label}is not guaranteed to be unique (labels can repeat), which can cause React key collisions and unstable row reconciliation. Use a stable unique key (e.g. include the index, or introduce anidonConfidenceFactor).
{factors.map((factor) => (
<ConfidenceSignalFactorRow key={factor.label} factor={factor} />
))}
apps/apollo-vertex/registry/confidence-signal/confidence-signal-levels.ts:17
ConfidenceCtacurrently allows providing neitherhrefnoronClick, which renders a CTA that looks interactive but does nothing (and the rest of this component set assumes at least one action). Consider enforcing “at least one of href/onClick” at the type level to prevent invalid CTAs.
label: string;
/** Renders the CTA as a link. Takes precedence over `onClick` alone. */
href?: string;
onClick?: () => void;
}
apps/apollo-vertex/registry/confidence-signal/confidence-signal-cta.tsx:28
if (cta.href)treats an empty-string href as “no href”, which would render a<button>instead of an<a>and drop link affordances. Checkinghref !== undefinedmatches the intended optionality and is robust against empty strings.
if (cta.href) {
apps/apollo-vertex/registry/confidence-signal/confidence-signal-chip.tsx:46
aria-labelandtype="button"are currently set before{...props}, so callers can accidentally override them via props spread (which contradicts the “always announced”accessibleLabelcontract and can reintroduce default submit-button behavior in forms). Spread props first, then settype/aria-labelso the component guarantees these attributes.
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 text-xs font-semibold focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
LEVEL_CONFIG[level].textClass,
interactive && "cursor-pointer",
className,
)}
aria-label={accessibleLabel}
{...props}
// After the spread: as a Tooltip/Popover trigger this chip is cloned with
// the trigger's own `data-slot`, which would otherwise mask its identity.
data-slot="confidence-signal"
data-level={level}
>
Adds a signal-bar AI confidence chip (high/medium/low/unknown) with min/med/max density variants, a tooltip on every chip, and a popover for factor breakdowns and next-step CTAs, plus an opt-in one-time acquire animation that respects prefers-reduced-motion. The action CTA (nextStep) is required for medium/low confidence per team decision, and optional for high/unknown, enforced via a discriminated union type. Level labels and default explanations resolve through react-i18next under the confidence_signal_* prefix. Registered in registry.json with docs at /components/confidence-signal.
bce2dca to
b6e36a1
Compare
|
Pushed an update addressing all review feedback. Rebased into the single commit rather than stacking fixups, per the repo's git workflow. @frankkluijtmans — your four points:
Copilot's earlier pass: Copilot's latest pass (3 suppressed comments), also fixed:
Checks: Two notes for reviewers:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
apps/apollo-vertex/registry/confidence-signal/confidence-signal.tsx:123
- The popover currently relies on nesting
PopoverTriggerinsideTooltipTrigger(via thetooltipvariable). After making the tooltip trigger directly on the chip, the popover needs its own trigger wrapper. Wrapping the tooltip+chip in aPopoverTrigger asChildon a simple DOM element avoids nested Radix triggers while still allowing clicks/Enter on the inner button to bubble and open the popover.
<Popover open={detailsOpen} onOpenChange={handleDetailsOpenChange}>
{tooltip}
<PopoverContent align="start" className="flex w-64 flex-col gap-3">
apps/apollo-vertex/registry/confidence-signal/confidence-signal-chip.tsx:26
ConfidenceSignalChipis used as the child of RadixTooltipTrigger asChild/PopoverTrigger asChild. For RadixasChildto work correctly (positioning, focus management), the child must accept aref. This component is a plain function component, so it doesn’t forward refs and can cause runtime warnings or broken tooltip/popover behavior. Convert it toReact.forwardRef<HTMLButtonElement, ConfidenceSignalChipProps>and pass the ref to the<button>.
function ConfidenceSignalChip({
level,
label,
accessibleLabel,
animateIn = false,
apps/apollo-vertex/registry/confidence-signal/confidence-signal.tsx:103
TooltipTrigger asChildis currently given aPopoverTriggerelement whenhasDetailsis true. RadixasChildrequires the child to be a DOM element or aforwardRefcomponent;PopoverTriggerhere is a wrapper component (notforwardRef), so the tooltip trigger ref/handlers won’t attach reliably. Consider making the tooltip always trigger directly on the chip, and move the popover trigger wrapper to the popover render path instead.
This issue also appears on line 121 of the same file.
<Tooltip open={tooltipOpen && !detailsOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
{hasDetails ? <PopoverTrigger asChild>{chip}</PopoverTrigger> : chip}
</TooltipTrigger>
Adds
ConfidenceSignal, a signal-bar AI confidence chip (high/medium/low/unknown) with threedensity variants (
min/med/max), a tooltip on every chip, and a popover for factor breakdownsand next-step CTAs, plus an opt-in one-time "acquire" animation. Registered in
registry.jsonwithdocs at
/components/confidence-signal.Visuals, popover structure/behaviour, and animation timing were matched against the team demo at
ai-confidence-demo-kappa.vercel.app: rounded signal-bar pills, faded same-hue tracks for unfilledbars, per-level default explanations, and per-factor status tints.
Note on the
nextSteprequirementAn earlier internal demo site documented the action CTA as required for
low/unknown. Per thelive team decision (Peter + Haidy), this PR requires it for
medium/lowinstead (optional forhigh/unknown), enforced at compile time via a discriminated union onConfidenceSignalProps.Both interpretations were considered; this follows the call, not the demo site.
Accessibility and conventions
variant="min"(icon only) is never unlabelled.nextStepstays reachable by keyboardand on touch. The tooltip is suppressed while the popover is open.
href(rendered as a link, so navigable/middle-clickable) and/oronClick.prefers-reduced-motion: reduce.react-i18nextunder theconfidence_signal_*prefix in
locales/en.json, per AGENTS.md. Onlyen.jsonwas touched.-levels,-bars,-chip,-factors,-cta) withtargetfields onevery
registry.jsonfile entry, per the multi-file registry guidance in AGENTS.md.Scope
Component only, per the agreed two-PR split. A follow-up PR adds usage guidance to
app/guidelines/ai-toolkitand the components overview entry.Checks
pnpm install,pnpm registry:build,pnpm format,pnpm lint,pnpm lint:deps, andpnpm typecheckall pass (lint:depsreports only the 10 pre-existing repo-wide warnings, 0errors). Server-rendered output verified at
/components/confidence-signal: all variants and levelsrender, i18n labels resolve, and the SVG markup matches the demo. No runtime errors in the dev
server log.