Three new primitives — every product chat / agent surface should
adopt these to make the model honest about what it is doing.
──────────────────────────────────────────────────────────────────
<CostMeter> · Wave 13.C.1
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/CostMeter.tsx (new)
- Live token + (optional) USD readout
- 4 tiers: neutral (no budget) · ok (<70 %) · warn (>=70 %) ·
danger (>=95 %) — token-tinted via color-mix() so all four
palettes degrade gracefully on browsers without var() support
- NaN-safe — non-finite / negative inputs floor to 0
- role=status + aria-live=polite + aria-label assembles a
screen-reader-friendly sentence
- Mini-bar visual indicator at the end of the pill when budget
is provided
- Pure passive surface — never warns / prompts / blocks
──────────────────────────────────────────────────────────────────
<ConfidenceTag> · Wave 13.C.2
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/ConfidenceTag.tsx (new)
- Accepts `number | 'high' | 'medium' | 'low' | 'unknown'`
- Default thresholds 0.8 / 0.5 — both overridable
- Out-of-range numerics map to `unknown` (no false confidence)
- Optional `showScore` renders a tabular-nums percent suffix
- 4 token-tinted palettes (success / warning / danger /
neutral) — pair naturally with <CitationChip> for the full
'show your work' story
──────────────────────────────────────────────────────────────────
<RefusalCard> · Wave 13.C.3
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/RefusalCard.tsx (new)
- 6 reason archetypes — safety / policy / capability /
authorization / rate-limit / unknown — each with a typed
heading + glyph
- Calm warning palette (never red) — refusals are not errors
- Up to 3 actionable next steps (further entries silently
clipped) — one is markable `primary` to render as filled CTA
- Optional `footer` slot for policy doc links
- role=note + composite aria-label covering heading +
explanation
──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
✓ pnpm -F @bytelyst/ai-ui test → 67/67 passing (was 53/53)
+14 new trust-surface tests in src/__tests__/trust.test.tsx
✓ Exports wired in src/index.ts under '0.5 surfaces' section
✓ package.json 0.4.0 → 0.5.0
──────────────────────────────────────────────────────────────────
Roadmap tracker — 5 boxes flipped (§11)
──────────────────────────────────────────────────────────────────
13.C.1 CostMeter shipped
13.C.2 ConfidenceTag shipped
13.C.3 RefusalCard shipped
13.C.7 trust-surfaces showcase (lands in paired showcase commit)
MAG.3 the trust-surfaces customer-magnet ✨
Wave 13 Futurism: 5/39 → 9/39 (23%)
Magnet demos: 1/8 → 2/8 (25%)
TOTAL: 19/202 → 24/202 (12%)
Vendored snapshot + showcase /ai-ui/* + /futurism/trust-surfaces
routes land in the paired showcase commit.
Pending in 13.C: ProvenanceDrawer (.4) · DebugOverlay (.5) ·
PrivacyBadge (.6).
179 lines
5.1 KiB
TypeScript
179 lines
5.1 KiB
TypeScript
import type { CSSProperties, ReactNode } from 'react';
|
|
|
|
export type RefusalReason =
|
|
| 'safety'
|
|
| 'policy'
|
|
| 'capability'
|
|
| 'authorization'
|
|
| 'rate-limit'
|
|
| 'unknown';
|
|
|
|
export interface RefusalAction {
|
|
label: string;
|
|
onClick: () => void;
|
|
/** Render this action as the primary CTA. Default: false. */
|
|
primary?: boolean;
|
|
}
|
|
|
|
export interface RefusalCardProps {
|
|
/**
|
|
* Why the model declined. Drives the icon + the friendly heading
|
|
* shown above the explanation.
|
|
*/
|
|
reason: RefusalReason;
|
|
/** Plain-language explanation. Keep it short, kind, specific. */
|
|
explanation: string;
|
|
/** Optional title override; defaults to a reason-specific heading. */
|
|
title?: string;
|
|
/** Up to 3 actions the user can take next. */
|
|
actions?: RefusalAction[];
|
|
/** Optional appendix slot — links to policy doc, support, etc. */
|
|
footer?: ReactNode;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
const HEADINGS: Record<RefusalReason, { title: string; icon: string }> = {
|
|
safety: { title: "I can't help with that", icon: '🛡️' },
|
|
policy: { title: 'Outside policy', icon: '📕' },
|
|
capability: { title: "I'm not sure I can help here", icon: '🤔' },
|
|
authorization: { title: 'Permission required', icon: '🔒' },
|
|
'rate-limit': { title: 'Easy there — slow down', icon: '⏳' },
|
|
unknown: { title: 'Declined', icon: '⚪' },
|
|
};
|
|
|
|
/**
|
|
* `<RefusalCard>` — honest, kind, structured "no" from the model.
|
|
*
|
|
* Trust surface (Wave 13.C.3). The pattern every product reinvents
|
|
* poorly: when the LLM declines, we shouldn't dump the raw refusal
|
|
* string into the chat. Instead we render a small card with:
|
|
* - a reason-typed heading + glyph
|
|
* - one paragraph of plain-language explanation
|
|
* - up to three actionable next steps
|
|
*
|
|
* Calm, not alarming. Token-tinted with the warning palette but never
|
|
* red — refusals are not errors.
|
|
*/
|
|
export function RefusalCard({
|
|
reason,
|
|
explanation,
|
|
title,
|
|
actions,
|
|
footer,
|
|
className,
|
|
style,
|
|
}: RefusalCardProps) {
|
|
const heading = HEADINGS[reason] ?? HEADINGS.unknown;
|
|
const acts = (actions ?? []).slice(0, 3);
|
|
|
|
return (
|
|
<section
|
|
role="note"
|
|
aria-label={`${heading.title}: ${explanation}`}
|
|
data-testid="bl-refusal-card"
|
|
data-reason={reason}
|
|
className={className}
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 12,
|
|
maxWidth: 480,
|
|
padding: 16,
|
|
borderRadius: 14,
|
|
backgroundColor:
|
|
'color-mix(in srgb, var(--bl-warning, #f59e0b) 8%, var(--bl-surface-card, #fff))',
|
|
border: '1px solid color-mix(in srgb, var(--bl-warning, #f59e0b) 35%, transparent)',
|
|
color: 'var(--bl-text-primary, #111)',
|
|
...style,
|
|
}}
|
|
>
|
|
<header style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span
|
|
aria-hidden="true"
|
|
style={{
|
|
fontSize: 18,
|
|
lineHeight: 1,
|
|
display: 'inline-grid',
|
|
placeItems: 'center',
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 10,
|
|
backgroundColor:
|
|
'color-mix(in srgb, var(--bl-warning, #f59e0b) 18%, transparent)',
|
|
}}
|
|
>
|
|
{heading.icon}
|
|
</span>
|
|
<h4
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
color: 'var(--bl-warning-strong, var(--bl-warning, #b45309))',
|
|
}}
|
|
>
|
|
{title ?? heading.title}
|
|
</h4>
|
|
</header>
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 14,
|
|
lineHeight: 1.55,
|
|
color: 'var(--bl-text-secondary, #444)',
|
|
}}
|
|
>
|
|
{explanation}
|
|
</p>
|
|
{acts.length > 0 && (
|
|
<div
|
|
role="group"
|
|
aria-label="Available actions"
|
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
|
|
>
|
|
{acts.map((a, i) => (
|
|
<button
|
|
key={`${a.label}-${i}`}
|
|
type="button"
|
|
onClick={a.onClick}
|
|
data-testid={`bl-refusal-card-action-${i}`}
|
|
style={{
|
|
padding: '6px 12px',
|
|
borderRadius: 999,
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
border: a.primary
|
|
? '1px solid transparent'
|
|
: '1px solid var(--bl-border, rgba(0,0,0,0.14))',
|
|
backgroundColor: a.primary
|
|
? 'var(--bl-accent, #6366f1)'
|
|
: 'transparent',
|
|
color: a.primary
|
|
? 'var(--bl-on-accent, #fff)'
|
|
: 'var(--bl-text-primary, #111)',
|
|
}}
|
|
>
|
|
{a.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{footer && (
|
|
<footer
|
|
style={{
|
|
marginTop: 4,
|
|
paddingTop: 10,
|
|
borderTop: '1px dashed color-mix(in srgb, var(--bl-warning, #f59e0b) 25%, transparent)',
|
|
fontSize: 12,
|
|
color: 'var(--bl-text-tertiary, #666)',
|
|
}}
|
|
>
|
|
{footer}
|
|
</footer>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|