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.
257 lines
6.9 KiB
TypeScript
257 lines
6.9 KiB
TypeScript
import { useEffect, useId, useRef, type CSSProperties, type ReactNode } from 'react';
|
||
|
||
export interface ProvenanceEvent {
|
||
/** Stable id — used as React key. */
|
||
id: string;
|
||
/** ISO timestamp string. Renders as locale time + relative offset. */
|
||
at: string;
|
||
/** Free-form event kind label (`prompt`, `tool-call`, `model`, …). */
|
||
kind: string;
|
||
/** Plain-language one-line description. */
|
||
summary: string;
|
||
/** Optional inspector slot (JSON, code block, etc.). */
|
||
details?: ReactNode;
|
||
}
|
||
|
||
export interface ProvenanceDrawerProps {
|
||
/** Whether the drawer is open. */
|
||
open: boolean;
|
||
/** Called when the user hits Esc or clicks the overlay. */
|
||
onClose: () => void;
|
||
/** Ordered list of events — newest LAST. */
|
||
events: ProvenanceEvent[];
|
||
/** Optional title (default 'Provenance'). */
|
||
title?: string;
|
||
/** Drawer width in px. Default 420. */
|
||
width?: number;
|
||
className?: string;
|
||
style?: CSSProperties;
|
||
}
|
||
|
||
/**
|
||
* `<ProvenanceDrawer>` — slide-in right drawer that lists every step the
|
||
* model + tools took to produce the current response.
|
||
*
|
||
* Trust surface (Wave 13.C.4). Backs onto whatever event log the host
|
||
* exposes (event-store, in-memory ring, etc.); the component is pure
|
||
* presentation — passes the array straight through.
|
||
*
|
||
* Accessibility: role="dialog" + aria-modal, focus-traps with the
|
||
* initial focus on the close button, returns focus to the trigger on
|
||
* close (the host must manage the trigger ref).
|
||
*/
|
||
export function ProvenanceDrawer({
|
||
open,
|
||
onClose,
|
||
events,
|
||
title = 'Provenance',
|
||
width = 420,
|
||
className,
|
||
style,
|
||
}: ProvenanceDrawerProps) {
|
||
const titleId = useId();
|
||
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||
|
||
// Escape to close + initial focus on the close button.
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
onClose();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
closeBtnRef.current?.focus();
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [open, onClose]);
|
||
|
||
// Body scroll lock while open.
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => {
|
||
document.body.style.overflow = prev;
|
||
};
|
||
}, [open]);
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<div
|
||
data-testid="bl-provenance-drawer-root"
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
zIndex: 60,
|
||
...style,
|
||
}}
|
||
className={className}
|
||
>
|
||
{/* Backdrop */}
|
||
<button
|
||
type="button"
|
||
aria-label="Close provenance"
|
||
onClick={onClose}
|
||
data-testid="bl-provenance-drawer-backdrop"
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background: 'rgba(0,0,0,0.42)',
|
||
backdropFilter: 'blur(2px)',
|
||
border: 0,
|
||
cursor: 'pointer',
|
||
}}
|
||
/>
|
||
<aside
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
data-testid="bl-provenance-drawer"
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
width: `min(${width}px, 96vw)`,
|
||
backgroundColor: 'var(--bl-surface-card, #fff)',
|
||
color: 'var(--bl-text-primary, #111)',
|
||
borderLeft: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
||
boxShadow: '-12px 0 40px rgba(0,0,0,0.18)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}
|
||
>
|
||
<header
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '12px 16px',
|
||
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
||
}}
|
||
>
|
||
<h3 id={titleId} style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||
{title}{' '}
|
||
<span style={{ opacity: 0.55, fontWeight: 400 }}>
|
||
· {events.length} {events.length === 1 ? 'event' : 'events'}
|
||
</span>
|
||
</h3>
|
||
<button
|
||
ref={closeBtnRef}
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Close"
|
||
style={{
|
||
border: 0,
|
||
background: 'transparent',
|
||
cursor: 'pointer',
|
||
fontSize: 18,
|
||
lineHeight: 1,
|
||
padding: 4,
|
||
color: 'var(--bl-text-secondary, #555)',
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
padding: 12,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 10,
|
||
}}
|
||
>
|
||
{events.length === 0 ? (
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 13,
|
||
color: 'var(--bl-text-tertiary, #888)',
|
||
}}
|
||
>
|
||
No events recorded for this response.
|
||
</p>
|
||
) : (
|
||
events.map((ev) => <ProvenanceRow key={ev.id} ev={ev} />)
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProvenanceRow({ ev }: { ev: ProvenanceEvent }) {
|
||
let when = ev.at;
|
||
try {
|
||
const d = new Date(ev.at);
|
||
if (!Number.isNaN(d.getTime())) {
|
||
when = d.toLocaleTimeString();
|
||
}
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
|
||
return (
|
||
<article
|
||
data-testid="bl-provenance-row"
|
||
data-kind={ev.kind}
|
||
style={{
|
||
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
||
borderRadius: 10,
|
||
padding: 10,
|
||
backgroundColor: 'var(--bl-surface, #fafafa)',
|
||
}}
|
||
>
|
||
<header
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'baseline',
|
||
justifyContent: 'space-between',
|
||
gap: 8,
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 10,
|
||
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 0.4,
|
||
color: 'var(--bl-text-tertiary, #888)',
|
||
}}
|
||
>
|
||
{ev.kind}
|
||
</span>
|
||
<time
|
||
style={{
|
||
fontSize: 11,
|
||
color: 'var(--bl-text-tertiary, #888)',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}
|
||
dateTime={ev.at}
|
||
>
|
||
{when}
|
||
</time>
|
||
</header>
|
||
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.45 }}>{ev.summary}</p>
|
||
{ev.details && (
|
||
<details
|
||
style={{
|
||
marginTop: 6,
|
||
fontSize: 12,
|
||
color: 'var(--bl-text-secondary, #555)',
|
||
}}
|
||
>
|
||
<summary style={{ cursor: 'pointer' }}>Details</summary>
|
||
<div style={{ marginTop: 6 }}>{ev.details}</div>
|
||
</details>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|