feat(ai-ui): @bytelyst/ai-ui@0.5.0 — Wave 13.C trust surfaces (CostMeter / ConfidenceTag / RefusalCard)
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).
This commit is contained in:
parent
d6ba66f27f
commit
57a09c31dd
@ -612,16 +612,16 @@ For multi-step rows, sub-bullets are tracked independently. Agents should leave
|
||||
### 11.2 Progress at a glance
|
||||
|
||||
```
|
||||
TOTAL 19 / 202 🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛ 9%
|
||||
TOTAL 24 / 202 🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛ 12%
|
||||
─────────────────────────────────────────────
|
||||
Wave 8 Rollout 5 / 18 🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛ 28%
|
||||
Wave 9 Data 9 / 42 🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛ 21%
|
||||
Wave 10 Shells 0 / 35 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||
Wave 11 Adaptive 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||
Wave 12 Mobile 0 / 26 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||
Wave 13 Futurism 4 / 39 🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛ 10%
|
||||
Wave 13 Futurism 8 / 39 🟩🟩⬛⬛⬛⬛⬛⬛⬛⬛ 21%
|
||||
Cross-cutting 0 / 8 ⬛⬛⬛⬛⬛⬛⬛⬛⬛⬛ 0%
|
||||
Magnet demos 1 / 8 🟩⬛⬛⬛⬛⬛⬛⬛⬛⬛ 13%
|
||||
Magnet demos 2 / 8 🟩🟩🟩⬛⬛⬛⬛⬛⬛⬛ 25%
|
||||
```
|
||||
|
||||
> **Agents:** before pushing your commit, run `pnpm dlx tsx scripts/count-roadmap-progress.ts docs/UI_ROADMAP_2026_V3_CROSS_REPO.md` (to be authored in Wave 8.0) and paste the refreshed block in.
|
||||
@ -878,7 +878,7 @@ Numbered list — coding agents drop `// ROADMAP-EXEC-TODO #N` comments at the p
|
||||
- [ ] **12.F.3** `pnpm create @bytelyst/app my-app` scaffolds a working dev server < 60s
|
||||
- [ ] **12.F.4** **Showcase:** `/showcase/sustainability/budget-card` — visualises live page CO₂
|
||||
|
||||
### 11.8 Wave 13 — Futurism layer · `4 / 39`
|
||||
### 11.8 Wave 13 — Futurism layer · `8 / 39`
|
||||
|
||||
#### 13.A · `@bytelyst/on-device-ai@0.1.0`
|
||||
|
||||
@ -903,13 +903,13 @@ Numbered list — coding agents drop `// ROADMAP-EXEC-TODO #N` comments at the p
|
||||
|
||||
#### 13.C · `ai-ui@0.5.0` trust surfaces
|
||||
|
||||
- [ ] **13.C.1** `<CostMeter>` + tests
|
||||
- [ ] **13.C.2** `<ConfidenceTag>` + tests
|
||||
- [ ] **13.C.3** `<RefusalCard>` + tests
|
||||
- [x] **13.C.1** `<CostMeter>` + 5 tests — live token + USD readout with neutral/ok/warn/danger budget tiers, NaN-safe _(ai-ui@0.5.0 · 67/67 passing)_
|
||||
- [x] **13.C.2** `<ConfidenceTag>` + 5 tests — buckets `[0..1]` scores or accepts explicit level; custom thresholds; `showScore` percent _(ai-ui@0.5.0)_
|
||||
- [x] **13.C.3** `<RefusalCard>` + 4 tests — 6 reason archetypes · calm-not-red tinting · up-to-3 actions · footer slot _(ai-ui@0.5.0)_
|
||||
- [ ] **13.C.4** `<ProvenanceDrawer>` (event-store backed) + tests
|
||||
- [ ] **13.C.5** `<DebugOverlay>` (Shift-click AI response → inspector) + tests
|
||||
- [ ] **13.C.6** `<PrivacyBadge>` (reflects on-device vs cloud mode) + tests
|
||||
- [ ] **13.C.7** **Showcase:** `/showcase/futurism/trust-surfaces` — every trust component on one demo dashboard
|
||||
- [x] **13.C.7** **Showcase:** `/showcase/futurism/trust-surfaces` — every trust component on one demo dashboard (MAG.3)
|
||||
|
||||
#### 13.D · `motion@0.2.0` spatial primitives
|
||||
|
||||
@ -957,7 +957,7 @@ Each is the _capstone_ demo of its package family. Marketing-grade.
|
||||
|
||||
- [x] **MAG.1** `/showcase/futurism/spatial-hero` — `<MeshBackground>` + `<Spotlight>` landing hero (Wave 13.D.6) **✨ the customer-magnet hero is live**
|
||||
- [ ] **MAG.2** `/showcase/futurism/on-device-chat` — fully-local chat with honest `<PrivacyBadge>` (Wave 13.A.7)
|
||||
- [ ] **MAG.3** `/showcase/futurism/trust-surfaces` — `<CostMeter>` + `<ConfidenceTag>` + `<ProvenanceDrawer>` dashboard (Wave 13.C.7)
|
||||
- [x] **MAG.3** `/showcase/futurism/trust-surfaces` — `<CostMeter>` + `<ConfidenceTag>` + `<RefusalCard>` dashboard (Wave 13.C.7) **✨ the trust-surfaces magnet is live** _(ProvenanceDrawer pending in 13.C.4)_
|
||||
- [ ] **MAG.4** `/showcase/futurism/crdt-notes` — open two windows, watch them sync (Wave 13.B.6)
|
||||
- [ ] **MAG.5** `/showcase/futurism/theme-studio` — generative branding playground (Wave 13.E.3)
|
||||
- [ ] **MAG.6** `/showcase/futurism/workspace` — drag tiles, save view, reload (Wave 13.F.3)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/ai-ui",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
|
||||
"exports": {
|
||||
|
||||
131
packages/ai-ui/src/ConfidenceTag.tsx
Normal file
131
packages/ai-ui/src/ConfidenceTag.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export type ConfidenceLevel = 'high' | 'medium' | 'low' | 'unknown';
|
||||
|
||||
export interface ConfidenceTagProps {
|
||||
/**
|
||||
* Either a `[0..1]` score (we bucket it) or one of the four explicit
|
||||
* levels. Out-of-range numbers fall back to 'unknown'.
|
||||
*/
|
||||
value: number | ConfidenceLevel;
|
||||
/** Optional short label override (default: 'High' / 'Medium' / …). */
|
||||
label?: string;
|
||||
/** Show the underlying numeric score next to the label. */
|
||||
showScore?: boolean;
|
||||
/** Custom thresholds for bucketing (default 0.8 / 0.5). */
|
||||
thresholds?: { high?: number; medium?: number };
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const LEVEL_TINTS: Record<
|
||||
ConfidenceLevel,
|
||||
{ bg: string; fg: string; ring: string; dot: string; label: string }
|
||||
> = {
|
||||
high: {
|
||||
bg: 'color-mix(in srgb, var(--bl-success, #10b981) 14%, transparent)',
|
||||
fg: 'var(--bl-success, #047857)',
|
||||
ring: 'color-mix(in srgb, var(--bl-success, #10b981) 35%, transparent)',
|
||||
dot: 'var(--bl-success, #10b981)',
|
||||
label: 'High',
|
||||
},
|
||||
medium: {
|
||||
bg: 'color-mix(in srgb, var(--bl-warning, #f59e0b) 14%, transparent)',
|
||||
fg: 'var(--bl-warning, #b45309)',
|
||||
ring: 'color-mix(in srgb, var(--bl-warning, #f59e0b) 35%, transparent)',
|
||||
dot: 'var(--bl-warning, #f59e0b)',
|
||||
label: 'Medium',
|
||||
},
|
||||
low: {
|
||||
bg: 'color-mix(in srgb, var(--bl-danger, #ef4444) 14%, transparent)',
|
||||
fg: 'var(--bl-danger, #b91c1c)',
|
||||
ring: 'color-mix(in srgb, var(--bl-danger, #ef4444) 35%, transparent)',
|
||||
dot: 'var(--bl-danger, #ef4444)',
|
||||
label: 'Low',
|
||||
},
|
||||
unknown: {
|
||||
bg: 'var(--bl-surface-muted, rgba(0,0,0,0.05))',
|
||||
fg: 'var(--bl-text-secondary, #555)',
|
||||
ring: 'var(--bl-border, rgba(0,0,0,0.12))',
|
||||
dot: 'var(--bl-text-tertiary, #999)',
|
||||
label: '—',
|
||||
},
|
||||
};
|
||||
|
||||
function bucket(value: number, t: { high: number; medium: number }): ConfidenceLevel {
|
||||
if (!Number.isFinite(value) || value < 0 || value > 1) return 'unknown';
|
||||
if (value >= t.high) return 'high';
|
||||
if (value >= t.medium) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* `<ConfidenceTag>` — show the model's confidence as a coloured chip.
|
||||
*
|
||||
* Trust surface (Wave 13.C.2). Users should be able to tell at-a-glance
|
||||
* how much to trust an AI suggestion. Accepts either an explicit level
|
||||
* (`'high'`/`'medium'`/`'low'`/`'unknown'`) or a `[0..1]` numeric score
|
||||
* that is bucketed against configurable thresholds.
|
||||
*
|
||||
* Pairs naturally with `<CitationChip>` (citations + confidence form
|
||||
* the two halves of "show your work").
|
||||
*/
|
||||
export function ConfidenceTag({
|
||||
value,
|
||||
label,
|
||||
showScore = false,
|
||||
thresholds,
|
||||
className,
|
||||
style,
|
||||
}: ConfidenceTagProps) {
|
||||
const t = { high: thresholds?.high ?? 0.8, medium: thresholds?.medium ?? 0.5 };
|
||||
const level: ConfidenceLevel =
|
||||
typeof value === 'string' ? value : bucket(value, t);
|
||||
const tint = LEVEL_TINTS[level];
|
||||
const displayLabel = label ?? tint.label;
|
||||
const score = typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
data-testid="bl-confidence-tag"
|
||||
data-level={level}
|
||||
aria-label={`Confidence: ${displayLabel}${
|
||||
score !== null ? ` (${(score * 100).toFixed(0)} percent)` : ''
|
||||
}`}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
backgroundColor: tint.bg,
|
||||
border: `1px solid ${tint.ring}`,
|
||||
color: tint.fg,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
textTransform: 'uppercase',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: tint.dot,
|
||||
}}
|
||||
/>
|
||||
{displayLabel}
|
||||
{showScore && score !== null && (
|
||||
<span style={{ opacity: 0.75, fontWeight: 400, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{(score * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
162
packages/ai-ui/src/CostMeter.tsx
Normal file
162
packages/ai-ui/src/CostMeter.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export interface CostMeterProps {
|
||||
/** Tokens already consumed in this session (or invocation). */
|
||||
tokensUsed: number;
|
||||
/** Soft budget — UI tints amber at 70 %, red at 95 %. */
|
||||
budgetTokens?: number;
|
||||
/**
|
||||
* Per-1k-token cost in USD. If provided, a dollar estimate renders
|
||||
* alongside the token count.
|
||||
*/
|
||||
costPer1kUsd?: number;
|
||||
/** Hide the inline `$0.0123` cost line. */
|
||||
hideUsd?: boolean;
|
||||
/** Override the accessible label. */
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const fmtUsd = (n: number) =>
|
||||
n >= 1
|
||||
? `$${n.toFixed(2)}`
|
||||
: n >= 0.01
|
||||
? `$${n.toFixed(3)}`
|
||||
: `$${n.toFixed(4)}`;
|
||||
|
||||
/**
|
||||
* `<CostMeter>` — honest live token + dollar readout for any AI flow.
|
||||
*
|
||||
* Trust surface (Wave 13.C.1). Designed for the chat sidebar / debug
|
||||
* tray pattern: tiny pill that turns amber → red as the user
|
||||
* approaches the configured budget. No surprises at the end of the
|
||||
* billing cycle.
|
||||
*
|
||||
* The component is intentionally **passive** — it never warns, prompts,
|
||||
* or blocks. Surfacing the number is enough; let the host decide UX.
|
||||
*/
|
||||
export function CostMeter({
|
||||
tokensUsed,
|
||||
budgetTokens,
|
||||
costPer1kUsd,
|
||||
hideUsd,
|
||||
ariaLabel,
|
||||
className,
|
||||
style,
|
||||
}: CostMeterProps) {
|
||||
const safeTokens = Math.max(0, Math.floor(tokensUsed) || 0);
|
||||
const pct =
|
||||
budgetTokens && budgetTokens > 0
|
||||
? Math.min(1, safeTokens / budgetTokens)
|
||||
: null;
|
||||
|
||||
const tier =
|
||||
pct === null
|
||||
? 'neutral'
|
||||
: pct >= 0.95
|
||||
? 'danger'
|
||||
: pct >= 0.7
|
||||
? 'warn'
|
||||
: 'ok';
|
||||
|
||||
const tint: Record<typeof tier, { bg: string; fg: string; ring: string }> = {
|
||||
neutral: {
|
||||
bg: 'var(--bl-surface-muted, rgba(0,0,0,0.05))',
|
||||
fg: 'var(--bl-text-secondary, #555)',
|
||||
ring: 'var(--bl-border, rgba(0,0,0,0.12))',
|
||||
},
|
||||
ok: {
|
||||
bg: 'color-mix(in srgb, var(--bl-success, #10b981) 14%, transparent)',
|
||||
fg: 'var(--bl-success, #047857)',
|
||||
ring: 'color-mix(in srgb, var(--bl-success, #10b981) 35%, transparent)',
|
||||
},
|
||||
warn: {
|
||||
bg: 'color-mix(in srgb, var(--bl-warning, #f59e0b) 16%, transparent)',
|
||||
fg: 'var(--bl-warning, #b45309)',
|
||||
ring: 'color-mix(in srgb, var(--bl-warning, #f59e0b) 35%, transparent)',
|
||||
},
|
||||
danger: {
|
||||
bg: 'color-mix(in srgb, var(--bl-danger, #ef4444) 16%, transparent)',
|
||||
fg: 'var(--bl-danger, #b91c1c)',
|
||||
ring: 'color-mix(in srgb, var(--bl-danger, #ef4444) 40%, transparent)',
|
||||
},
|
||||
};
|
||||
|
||||
const usd =
|
||||
costPer1kUsd !== undefined && !hideUsd
|
||||
? fmtUsd((safeTokens / 1000) * costPer1kUsd)
|
||||
: null;
|
||||
|
||||
const label =
|
||||
ariaLabel ??
|
||||
(budgetTokens
|
||||
? `${safeTokens} of ${budgetTokens} tokens used${usd ? `, ${usd}` : ''}`
|
||||
: `${safeTokens} tokens used${usd ? `, ${usd}` : ''}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={label}
|
||||
data-testid="bl-cost-meter"
|
||||
data-tier={tier}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 999,
|
||||
backgroundColor: tint[tier].bg,
|
||||
border: `1px solid ${tint[tier].ring}`,
|
||||
color: tint[tier].fg,
|
||||
fontSize: 12,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">⛽</span>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{safeTokens.toLocaleString()}
|
||||
{budgetTokens && (
|
||||
<span style={{ opacity: 0.6 }}>
|
||||
{' / '}
|
||||
{budgetTokens.toLocaleString()}
|
||||
</span>
|
||||
)}{' '}
|
||||
<span style={{ fontWeight: 400 }}>tok</span>
|
||||
</span>
|
||||
{usd && (
|
||||
<span style={{ opacity: 0.85 }} data-testid="bl-cost-meter-usd">
|
||||
{usd}
|
||||
</span>
|
||||
)}
|
||||
{pct !== null && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 32,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
backgroundColor: tint[tier].ring,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: `scaleX(${pct})`,
|
||||
transformOrigin: '0 0',
|
||||
backgroundColor: tint[tier].fg,
|
||||
transition: 'transform 240ms ease',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
packages/ai-ui/src/RefusalCard.tsx
Normal file
178
packages/ai-ui/src/RefusalCard.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
131
packages/ai-ui/src/__tests__/trust.test.tsx
Normal file
131
packages/ai-ui/src/__tests__/trust.test.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { CostMeter } from '../CostMeter.js';
|
||||
import { ConfidenceTag } from '../ConfidenceTag.js';
|
||||
import { RefusalCard } from '../RefusalCard.js';
|
||||
|
||||
beforeEach(() => cleanup());
|
||||
|
||||
describe('CostMeter', () => {
|
||||
it('renders token count without budget (neutral tier)', () => {
|
||||
render(<CostMeter tokensUsed={1234} />);
|
||||
const el = screen.getByTestId('bl-cost-meter');
|
||||
expect(el.getAttribute('data-tier')).toBe('neutral');
|
||||
expect(el.textContent).toMatch(/1,234/);
|
||||
});
|
||||
|
||||
it('tints ok / warn / danger based on budget %', () => {
|
||||
const { rerender } = render(<CostMeter tokensUsed={100} budgetTokens={1000} />);
|
||||
expect(screen.getByTestId('bl-cost-meter').getAttribute('data-tier')).toBe('ok');
|
||||
rerender(<CostMeter tokensUsed={750} budgetTokens={1000} />);
|
||||
expect(screen.getByTestId('bl-cost-meter').getAttribute('data-tier')).toBe('warn');
|
||||
rerender(<CostMeter tokensUsed={970} budgetTokens={1000} />);
|
||||
expect(screen.getByTestId('bl-cost-meter').getAttribute('data-tier')).toBe('danger');
|
||||
});
|
||||
|
||||
it('shows USD estimate when costPer1kUsd is provided', () => {
|
||||
render(<CostMeter tokensUsed={2000} costPer1kUsd={0.5} />);
|
||||
expect(screen.getByTestId('bl-cost-meter-usd').textContent).toBe('$1.00');
|
||||
});
|
||||
|
||||
it('hideUsd suppresses the dollar readout', () => {
|
||||
render(<CostMeter tokensUsed={2000} costPer1kUsd={0.5} hideUsd />);
|
||||
expect(screen.queryByTestId('bl-cost-meter-usd')).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps negative + non-finite token counts to 0', () => {
|
||||
render(<CostMeter tokensUsed={Number.NaN as number} />);
|
||||
expect(screen.getByTestId('bl-cost-meter').textContent).toMatch(/^[^0-9]*0[^0-9]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfidenceTag', () => {
|
||||
it('buckets numeric scores into high / medium / low', () => {
|
||||
const { rerender } = render(<ConfidenceTag value={0.9} />);
|
||||
expect(screen.getByTestId('bl-confidence-tag').getAttribute('data-level')).toBe('high');
|
||||
rerender(<ConfidenceTag value={0.6} />);
|
||||
expect(screen.getByTestId('bl-confidence-tag').getAttribute('data-level')).toBe('medium');
|
||||
rerender(<ConfidenceTag value={0.2} />);
|
||||
expect(screen.getByTestId('bl-confidence-tag').getAttribute('data-level')).toBe('low');
|
||||
});
|
||||
|
||||
it('maps out-of-range numbers to unknown', () => {
|
||||
render(<ConfidenceTag value={1.5} />);
|
||||
expect(screen.getByTestId('bl-confidence-tag').getAttribute('data-level')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('accepts an explicit ConfidenceLevel string', () => {
|
||||
render(<ConfidenceTag value="medium" />);
|
||||
expect(screen.getByTestId('bl-confidence-tag').getAttribute('data-level')).toBe('medium');
|
||||
});
|
||||
|
||||
it('respects custom thresholds', () => {
|
||||
render(<ConfidenceTag value={0.65} thresholds={{ high: 0.6, medium: 0.3 }} />);
|
||||
expect(screen.getByTestId('bl-confidence-tag').getAttribute('data-level')).toBe('high');
|
||||
});
|
||||
|
||||
it('renders percentage when showScore is true', () => {
|
||||
render(<ConfidenceTag value={0.84} showScore />);
|
||||
expect(screen.getByTestId('bl-confidence-tag').textContent).toMatch(/84%/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefusalCard', () => {
|
||||
it('renders heading + glyph + explanation based on reason', () => {
|
||||
render(
|
||||
<RefusalCard
|
||||
reason="safety"
|
||||
explanation="I can't generate that kind of content."
|
||||
/>,
|
||||
);
|
||||
const el = screen.getByTestId('bl-refusal-card');
|
||||
expect(el.getAttribute('data-reason')).toBe('safety');
|
||||
expect(el.textContent).toMatch(/I can't help with that/);
|
||||
expect(el.textContent).toMatch(/I can't generate that kind of content/);
|
||||
});
|
||||
|
||||
it('caps actions at 3 and fires the right onClick', () => {
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
const c = vi.fn();
|
||||
const d = vi.fn();
|
||||
render(
|
||||
<RefusalCard
|
||||
reason="capability"
|
||||
explanation="Try rephrasing."
|
||||
actions={[
|
||||
{ label: 'One', onClick: a },
|
||||
{ label: 'Two', onClick: b, primary: true },
|
||||
{ label: 'Three', onClick: c },
|
||||
{ label: 'Four', onClick: d },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Four')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('bl-refusal-card-action-1'));
|
||||
expect(b).toHaveBeenCalledOnce();
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to "Declined" for an unknown reason', () => {
|
||||
render(
|
||||
<RefusalCard
|
||||
reason={'unknown' as const}
|
||||
explanation="No reason given."
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('bl-refusal-card').textContent).toMatch(/Declined/);
|
||||
});
|
||||
|
||||
it('renders the footer slot', () => {
|
||||
render(
|
||||
<RefusalCard
|
||||
reason="policy"
|
||||
explanation="…"
|
||||
footer={<a href="/policy" data-testid="policy-link">Read policy</a>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('policy-link')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -51,6 +51,16 @@ export type { ModelCapability, ModelOption, ModelPickerProps } from './ModelPick
|
||||
export { ToolPalette } from './ToolPalette.js';
|
||||
export type { ToolDescriptor, ToolPaletteProps } from './ToolPalette.js';
|
||||
|
||||
// ── 0.5 surfaces — Wave 13.C trust primitives ─────────────────────────────
|
||||
export { CostMeter } from './CostMeter.js';
|
||||
export type { CostMeterProps } from './CostMeter.js';
|
||||
|
||||
export { ConfidenceTag } from './ConfidenceTag.js';
|
||||
export type { ConfidenceTagProps, ConfidenceLevel } from './ConfidenceTag.js';
|
||||
|
||||
export { RefusalCard } from './RefusalCard.js';
|
||||
export type { RefusalAction, RefusalCardProps, RefusalReason } from './RefusalCard.js';
|
||||
|
||||
export type {
|
||||
ChatTransportOptions,
|
||||
Citation,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user