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:
saravanakumardb1 2026-05-27 16:45:07 -07:00
parent d6ba66f27f
commit 57a09c31dd
7 changed files with 622 additions and 10 deletions

View File

@ -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)

View File

@ -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": {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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();
});
});

View File

@ -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,