learning_ai_common_plat/packages/ai-ui/src/ProvenanceDrawer.tsx
saravanakumardb1 ec9e11b243 feat(ai-ui): complete Wave 13.C — PrivacyBadge + ProvenanceDrawer + DebugOverlay
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.
2026-05-27 16:53:03 -07:00

257 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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