The remaining three trust surfaces ship in this commit, completing
Wave 13.C and unlocking MAG.8 (debug-overlay).
──────────────────────────────────────────────────────────────────
<PrivacyBadge> · Wave 13.C.6
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/PrivacyBadge.tsx (new)
- 4 modes: on-device · cloud · hybrid · unknown
- Token-tinted (success/info/accent/neutral) — instant trust
signal without forcing the user into settings
- Optional `detail` line (model id, device name, routing policy)
- `iconOnly` variant for sidebar / tray placements
- role=status + composite aria-label
──────────────────────────────────────────────────────────────────
<ProvenanceDrawer> · Wave 13.C.4
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/ProvenanceDrawer.tsx (new)
- Slide-in right drawer listing every step the model + tools took
- Pure presentation — host passes the event array straight through
- role=dialog + aria-modal + aria-labelledby
- Initial focus on close button; Esc + backdrop both close
- Body scroll lock while open (restores prior overflow value)
- Empty-state copy when events=[]
- Per-row <details> slot for inspecting full payload
──────────────────────────────────────────────────────────────────
<DebugOverlay> · Wave 13.C.5 (MAG.8 magnet)
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/DebugOverlay.tsx (new)
- Wraps any child surface — Shift-click reveals a modal inspector
with the raw JSON payload
- Modifier configurable: shift (default) / alt / meta
- Production toggle: `disabled` short-circuits the wrapper (no
cursor-hint, no click interception)
- role=dialog + aria-modal + Esc to close + focus restore on close
- Stringifies payload safely (catches non-serialisable values)
- Cursor: help on the wrapper to hint discoverability
──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
✓ pnpm -F @bytelyst/ai-ui test → 79/79 passing (was 67/67)
+12 new cases:
- PrivacyBadge (3)
- ProvenanceDrawer (5: closed-state · dialog semantics ·
backdrop · Escape · empty-state)
- DebugOverlay (4: plain-click ignored · Shift opens · disabled
bypass · alt modifier)
✓ Exports wired in src/index.ts (PrivacyBadge, ProvenanceDrawer,
DebugOverlay + all types)
──────────────────────────────────────────────────────────────────
Roadmap tracker (lands in subsequent commit)
──────────────────────────────────────────────────────────────────
13.C.4 ProvenanceDrawer shipped
13.C.5 DebugOverlay shipped
13.C.6 PrivacyBadge shipped
MAG.8 the debug-overlay magnet (showcase lands in paired commit)
Wave 13.C is now complete (7/7). Wave 13 Futurism: 9/39 → 12/39.
127 lines
3.6 KiB
TypeScript
127 lines
3.6 KiB
TypeScript
import type { CSSProperties } from 'react';
|
|
|
|
export type PrivacyMode = 'on-device' | 'cloud' | 'hybrid' | 'unknown';
|
|
|
|
export interface PrivacyBadgeProps {
|
|
/** Where does the inference actually run? */
|
|
mode: PrivacyMode;
|
|
/** Short label override (default: 'On device' / 'Cloud' / …). */
|
|
label?: string;
|
|
/**
|
|
* Optional one-line attribution — e.g. provider/model id, or the
|
|
* device name. Rendered as a smaller secondary string.
|
|
*/
|
|
detail?: string;
|
|
/** Render with only the dot — no text. Default false. */
|
|
iconOnly?: boolean;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
const TINTS: Record<
|
|
PrivacyMode,
|
|
{ bg: string; fg: string; ring: string; dot: string; label: string; icon: string }
|
|
> = {
|
|
'on-device': {
|
|
bg: 'color-mix(in srgb, var(--bl-success, #10b981) 12%, transparent)',
|
|
fg: 'var(--bl-success, #047857)',
|
|
ring: 'color-mix(in srgb, var(--bl-success, #10b981) 35%, transparent)',
|
|
dot: 'var(--bl-success, #10b981)',
|
|
label: 'On device',
|
|
icon: '📴',
|
|
},
|
|
cloud: {
|
|
bg: 'color-mix(in srgb, var(--bl-info, #0ea5e9) 12%, transparent)',
|
|
fg: 'var(--bl-info, #075985)',
|
|
ring: 'color-mix(in srgb, var(--bl-info, #0ea5e9) 35%, transparent)',
|
|
dot: 'var(--bl-info, #0ea5e9)',
|
|
label: 'Cloud',
|
|
icon: '☁️',
|
|
},
|
|
hybrid: {
|
|
bg: 'color-mix(in srgb, var(--bl-accent, #6366f1) 12%, transparent)',
|
|
fg: 'var(--bl-accent, #4338ca)',
|
|
ring: 'color-mix(in srgb, var(--bl-accent, #6366f1) 35%, transparent)',
|
|
dot: 'var(--bl-accent, #6366f1)',
|
|
label: 'Hybrid',
|
|
icon: '🔀',
|
|
},
|
|
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: 'Unknown',
|
|
icon: '❓',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* `<PrivacyBadge>` — honest indicator of where AI inference happens.
|
|
*
|
|
* Trust surface (Wave 13.C.6). Every chat / agent UI should make
|
|
* privacy state visible to the user — never hidden in settings.
|
|
*
|
|
* - `on-device` — fully local (e.g. WebLLM, ollama, Apple Intelligence)
|
|
* - `cloud` — server-side (OpenAI, Anthropic, your own infra)
|
|
* - `hybrid` — routing decides per-request (router/llm-router)
|
|
* - `unknown` — host can't determine; render rather than hide
|
|
*/
|
|
export function PrivacyBadge({
|
|
mode,
|
|
label,
|
|
detail,
|
|
iconOnly = false,
|
|
className,
|
|
style,
|
|
}: PrivacyBadgeProps) {
|
|
const tint = TINTS[mode] ?? TINTS.unknown;
|
|
const text = label ?? tint.label;
|
|
|
|
return (
|
|
<span
|
|
role="status"
|
|
data-testid="bl-privacy-badge"
|
|
data-mode={mode}
|
|
aria-label={`Privacy mode: ${text}${detail ? ` (${detail})` : ''}`}
|
|
className={className}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: iconOnly ? 0 : 6,
|
|
padding: iconOnly ? '4px' : '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,
|
|
}}
|
|
/>
|
|
{!iconOnly && (
|
|
<>
|
|
<span>{text}</span>
|
|
{detail && (
|
|
<span style={{ opacity: 0.7, fontWeight: 400, textTransform: 'none' }}>
|
|
· {detail}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|