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.
This commit is contained in:
parent
57a09c31dd
commit
ec9e11b243
259
packages/ai-ui/src/DebugOverlay.tsx
Normal file
259
packages/ai-ui/src/DebugOverlay.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export interface DebugOverlayPayload {
|
||||
/** Stable id surfaced in the inspector — falls back to React key. */
|
||||
id?: string;
|
||||
/** Free-form title for the modal header. */
|
||||
title?: string;
|
||||
/** Arbitrary plain-data payload — rendered as syntax-light JSON. */
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface DebugOverlayProps {
|
||||
/** What the user normally sees — wrap an AI bubble, tool card, etc. */
|
||||
children: ReactNode;
|
||||
/** The structured payload to reveal on Shift-click. */
|
||||
payload: DebugOverlayPayload;
|
||||
/**
|
||||
* Modifier required to open the inspector.
|
||||
* - `shift` (default) — accessible, no platform-conflict
|
||||
* - `alt` — for cases where Shift is already used
|
||||
* - `meta` — ⌘ on mac, Win key elsewhere
|
||||
*/
|
||||
modifier?: 'shift' | 'alt' | 'meta';
|
||||
/** Bypass entirely (production mode). */
|
||||
disabled?: boolean;
|
||||
/** Optional className applied to the wrapper. */
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<DebugOverlay>` — Shift-click any wrapped surface to inspect its
|
||||
* underlying data. Wave 13.C.5 (MAG.8).
|
||||
*
|
||||
* Wraps a single child element and patches its onClick. The modifier
|
||||
* is configurable; production sites can pass `disabled` to render the
|
||||
* child unchanged.
|
||||
*
|
||||
* Render JSON via a built-in `<pre>`; the host can wrap a custom
|
||||
* formatter by stringifying upstream.
|
||||
*/
|
||||
export function DebugOverlay({
|
||||
children,
|
||||
payload,
|
||||
modifier = 'shift',
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}: DebugOverlayProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Style cursor hint on the wrapper when active.
|
||||
const wrapperStyle: CSSProperties = {
|
||||
display: 'inline-block',
|
||||
cursor: disabled ? undefined : 'help',
|
||||
...style,
|
||||
};
|
||||
|
||||
// Patch the single child to intercept clicks.
|
||||
const onWrapperClick = (e: MouseEvent) => {
|
||||
if (disabled) return;
|
||||
const matchMod =
|
||||
modifier === 'shift'
|
||||
? e.shiftKey
|
||||
: modifier === 'alt'
|
||||
? e.altKey
|
||||
: e.metaKey || e.ctrlKey;
|
||||
if (!matchMod) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
triggerRef.current = (e.currentTarget as HTMLElement) ?? null;
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-testid="bl-debug-overlay-wrapper"
|
||||
data-modifier={modifier}
|
||||
data-disabled={disabled ? 'true' : 'false'}
|
||||
className={className}
|
||||
style={wrapperStyle}
|
||||
onClick={onWrapperClick}
|
||||
>
|
||||
{patchSingleChild(children)}
|
||||
</span>
|
||||
{open && (
|
||||
<DebugOverlayModal
|
||||
payload={payload}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pass-through; we only intercept on the outer span. */
|
||||
function patchSingleChild(children: ReactNode): ReactNode {
|
||||
const arr = Children.toArray(children);
|
||||
if (arr.length !== 1 || !isValidElement(arr[0])) return children;
|
||||
// Keep the child untouched — the wrapper span handles the click.
|
||||
return cloneElement(arr[0] as ReactElement);
|
||||
}
|
||||
|
||||
function DebugOverlayModal({
|
||||
payload,
|
||||
onClose,
|
||||
}: {
|
||||
payload: DebugOverlayPayload;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
let pretty = '';
|
||||
try {
|
||||
pretty = JSON.stringify(payload.data, null, 2);
|
||||
} catch {
|
||||
pretty = '[unserialisable payload]';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="bl-debug-overlay-modal-root"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close inspector"
|
||||
onClick={onClose}
|
||||
data-testid="bl-debug-overlay-backdrop"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid="bl-debug-overlay-modal"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 'min(720px, 96vw)',
|
||||
maxHeight: '88vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bl-surface-card, #fff)',
|
||||
color: 'var(--bl-text-primary, #111)',
|
||||
borderRadius: 16,
|
||||
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.32)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<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 }}
|
||||
>
|
||||
{payload.title ?? 'Inspector'}
|
||||
{payload.id && (
|
||||
<span style={{ opacity: 0.55, fontWeight: 400 }}>
|
||||
{' · '}
|
||||
<code style={{ fontSize: 12 }}>{payload.id}</code>
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
padding: 4,
|
||||
color: 'var(--bl-text-secondary, #555)',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<pre
|
||||
data-testid="bl-debug-overlay-payload"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 16,
|
||||
overflow: 'auto',
|
||||
background: 'var(--bl-surface-muted, #f6f6f6)',
|
||||
color: 'var(--bl-text-primary, #111)',
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{pretty}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
packages/ai-ui/src/PrivacyBadge.tsx
Normal file
126
packages/ai-ui/src/PrivacyBadge.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
256
packages/ai-ui/src/ProvenanceDrawer.tsx
Normal file
256
packages/ai-ui/src/ProvenanceDrawer.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -129,3 +129,105 @@ describe('RefusalCard', () => {
|
||||
expect(screen.getByTestId('policy-link')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Wave 13.C.4-.6 ──────────────────────────────────────────────────────
|
||||
|
||||
import { PrivacyBadge } from '../PrivacyBadge.js';
|
||||
import { ProvenanceDrawer, type ProvenanceEvent } from '../ProvenanceDrawer.js';
|
||||
import { DebugOverlay } from '../DebugOverlay.js';
|
||||
|
||||
describe('PrivacyBadge', () => {
|
||||
it('records mode + label for each of the 4 modes', () => {
|
||||
const modes = ['on-device', 'cloud', 'hybrid', 'unknown'] as const;
|
||||
modes.forEach((m) => {
|
||||
cleanup();
|
||||
render(<PrivacyBadge mode={m} />);
|
||||
const el = screen.getByTestId('bl-privacy-badge');
|
||||
expect(el.getAttribute('data-mode')).toBe(m);
|
||||
});
|
||||
});
|
||||
it('renders detail line when provided', () => {
|
||||
render(<PrivacyBadge mode="cloud" detail="gpt-4o-mini" />);
|
||||
expect(screen.getByTestId('bl-privacy-badge').textContent).toMatch(/gpt-4o-mini/);
|
||||
});
|
||||
it('iconOnly variant suppresses text content', () => {
|
||||
render(<PrivacyBadge mode="on-device" iconOnly />);
|
||||
const el = screen.getByTestId('bl-privacy-badge');
|
||||
expect(el.textContent ?? '').toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
const EVENTS: ProvenanceEvent[] = [
|
||||
{ id: 'e1', at: '2026-05-27T19:30:00Z', kind: 'prompt', summary: 'User asked about X' },
|
||||
{ id: 'e2', at: '2026-05-27T19:30:01Z', kind: 'model', summary: 'gpt-4o picked the answer', details: <code>tokens=412</code> },
|
||||
];
|
||||
|
||||
describe('ProvenanceDrawer', () => {
|
||||
it('renders null when closed', () => {
|
||||
render(<ProvenanceDrawer open={false} onClose={() => {}} events={EVENTS} />);
|
||||
expect(screen.queryByTestId('bl-provenance-drawer')).toBeNull();
|
||||
});
|
||||
it('renders dialog + events when open', () => {
|
||||
render(<ProvenanceDrawer open onClose={() => {}} events={EVENTS} />);
|
||||
expect(screen.getByTestId('bl-provenance-drawer').getAttribute('aria-modal')).toBe('true');
|
||||
expect(screen.getAllByTestId('bl-provenance-row')).toHaveLength(2);
|
||||
});
|
||||
it('calls onClose when backdrop is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProvenanceDrawer open onClose={onClose} events={EVENTS} />);
|
||||
fireEvent.click(screen.getByTestId('bl-provenance-drawer-backdrop'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('calls onClose on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProvenanceDrawer open onClose={onClose} events={EVENTS} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
it('shows the empty-state copy when events=[]', () => {
|
||||
render(<ProvenanceDrawer open onClose={() => {}} events={[]} />);
|
||||
expect(screen.getByTestId('bl-provenance-drawer').textContent).toMatch(/No events/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DebugOverlay', () => {
|
||||
it('does NOT open on plain click', () => {
|
||||
render(
|
||||
<DebugOverlay payload={{ title: 'Bubble', data: { a: 1 } }}>
|
||||
<span data-testid="child">hello</span>
|
||||
</DebugOverlay>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('child'));
|
||||
expect(screen.queryByTestId('bl-debug-overlay-modal')).toBeNull();
|
||||
});
|
||||
it('opens on Shift-click and shows JSON payload', () => {
|
||||
render(
|
||||
<DebugOverlay payload={{ id: 'm1', title: 'Bubble', data: { hello: 'world' } }}>
|
||||
<span data-testid="child">hello</span>
|
||||
</DebugOverlay>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('bl-debug-overlay-wrapper'), { shiftKey: true });
|
||||
const pre = screen.getByTestId('bl-debug-overlay-payload');
|
||||
expect(pre.textContent).toMatch(/"hello": "world"/);
|
||||
});
|
||||
it('respects disabled — no modal opens even with Shift', () => {
|
||||
render(
|
||||
<DebugOverlay disabled payload={{ data: {} }}>
|
||||
<span data-testid="child">hello</span>
|
||||
</DebugOverlay>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('bl-debug-overlay-wrapper'), { shiftKey: true });
|
||||
expect(screen.queryByTestId('bl-debug-overlay-modal')).toBeNull();
|
||||
});
|
||||
it('alt modifier honoured when configured', () => {
|
||||
render(
|
||||
<DebugOverlay modifier="alt" payload={{ data: 1 }}>
|
||||
<span data-testid="child">x</span>
|
||||
</DebugOverlay>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('bl-debug-overlay-wrapper'), { shiftKey: true });
|
||||
expect(screen.queryByTestId('bl-debug-overlay-modal')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('bl-debug-overlay-wrapper'), { altKey: true });
|
||||
expect(screen.getByTestId('bl-debug-overlay-modal')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -61,6 +61,21 @@ export type { ConfidenceTagProps, ConfidenceLevel } from './ConfidenceTag.js';
|
||||
export { RefusalCard } from './RefusalCard.js';
|
||||
export type { RefusalAction, RefusalCardProps, RefusalReason } from './RefusalCard.js';
|
||||
|
||||
export { PrivacyBadge } from './PrivacyBadge.js';
|
||||
export type { PrivacyBadgeProps, PrivacyMode } from './PrivacyBadge.js';
|
||||
|
||||
export { ProvenanceDrawer } from './ProvenanceDrawer.js';
|
||||
export type {
|
||||
ProvenanceDrawerProps,
|
||||
ProvenanceEvent,
|
||||
} from './ProvenanceDrawer.js';
|
||||
|
||||
export { DebugOverlay } from './DebugOverlay.js';
|
||||
export type {
|
||||
DebugOverlayPayload,
|
||||
DebugOverlayProps,
|
||||
} from './DebugOverlay.js';
|
||||
|
||||
export type {
|
||||
ChatTransportOptions,
|
||||
Citation,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user