From ec9e11b243896304309dfcf43306a127cea1e0d3 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 16:53:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai-ui):=20complete=20Wave=2013.C=20?= =?UTF-8?q?=E2=80=94=20PrivacyBadge=20+=20ProvenanceDrawer=20+=20DebugOver?= =?UTF-8?q?lay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining three trust surfaces ship in this commit, completing Wave 13.C and unlocking MAG.8 (debug-overlay). ────────────────────────────────────────────────────────────────── · 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 ────────────────────────────────────────────────────────────────── · 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
slot for inspecting full payload ────────────────────────────────────────────────────────────────── · 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. --- packages/ai-ui/src/DebugOverlay.tsx | 259 ++++++++++++++++++++ packages/ai-ui/src/PrivacyBadge.tsx | 126 ++++++++++ packages/ai-ui/src/ProvenanceDrawer.tsx | 256 +++++++++++++++++++ packages/ai-ui/src/__tests__/trust.test.tsx | 102 ++++++++ packages/ai-ui/src/index.ts | 15 ++ 5 files changed, 758 insertions(+) create mode 100644 packages/ai-ui/src/DebugOverlay.tsx create mode 100644 packages/ai-ui/src/PrivacyBadge.tsx create mode 100644 packages/ai-ui/src/ProvenanceDrawer.tsx diff --git a/packages/ai-ui/src/DebugOverlay.tsx b/packages/ai-ui/src/DebugOverlay.tsx new file mode 100644 index 00000000..d8b55b66 --- /dev/null +++ b/packages/ai-ui/src/DebugOverlay.tsx @@ -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; +} + +/** + * `` — 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 `
`; 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(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 (
+    <>
+      
+        {patchSingleChild(children)}
+      
+      {open && (
+         {
+            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(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 (
+    
+ + +
+          {pretty}
+        
+ +
+ ); +} diff --git a/packages/ai-ui/src/PrivacyBadge.tsx b/packages/ai-ui/src/PrivacyBadge.tsx new file mode 100644 index 00000000..6bde03e7 --- /dev/null +++ b/packages/ai-ui/src/PrivacyBadge.tsx @@ -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: '❓', + }, +}; + +/** + * `` — 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 ( + + + ); +} diff --git a/packages/ai-ui/src/ProvenanceDrawer.tsx b/packages/ai-ui/src/ProvenanceDrawer.tsx new file mode 100644 index 00000000..fd8bf6ff --- /dev/null +++ b/packages/ai-ui/src/ProvenanceDrawer.tsx @@ -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; +} + +/** + * `` — 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(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 ( +
+ {/* Backdrop */} + + +
+ {events.length === 0 ? ( +

+ No events recorded for this response. +

+ ) : ( + events.map((ev) => ) + )} +
+ +
+ ); +} + +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 ( +
+
+ + {ev.kind} + + +
+

{ev.summary}

+ {ev.details && ( +
+ Details +
{ev.details}
+
+ )} +
+ ); +} diff --git a/packages/ai-ui/src/__tests__/trust.test.tsx b/packages/ai-ui/src/__tests__/trust.test.tsx index f14a32f3..301406d5 100644 --- a/packages/ai-ui/src/__tests__/trust.test.tsx +++ b/packages/ai-ui/src/__tests__/trust.test.tsx @@ -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(); + const el = screen.getByTestId('bl-privacy-badge'); + expect(el.getAttribute('data-mode')).toBe(m); + }); + }); + it('renders detail line when provided', () => { + render(); + expect(screen.getByTestId('bl-privacy-badge').textContent).toMatch(/gpt-4o-mini/); + }); + it('iconOnly variant suppresses text content', () => { + render(); + 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: tokens=412 }, +]; + +describe('ProvenanceDrawer', () => { + it('renders null when closed', () => { + render( {}} events={EVENTS} />); + expect(screen.queryByTestId('bl-provenance-drawer')).toBeNull(); + }); + it('renders dialog + events when open', () => { + render( {}} 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(); + fireEvent.click(screen.getByTestId('bl-provenance-drawer-backdrop')); + expect(onClose).toHaveBeenCalledOnce(); + }); + it('calls onClose on Escape', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + it('shows the empty-state copy when events=[]', () => { + render( {}} events={[]} />); + expect(screen.getByTestId('bl-provenance-drawer').textContent).toMatch(/No events/); + }); +}); + +describe('DebugOverlay', () => { + it('does NOT open on plain click', () => { + render( + + hello + , + ); + fireEvent.click(screen.getByTestId('child')); + expect(screen.queryByTestId('bl-debug-overlay-modal')).toBeNull(); + }); + it('opens on Shift-click and shows JSON payload', () => { + render( + + hello + , + ); + 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( + + hello + , + ); + 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( + + x + , + ); + 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(); + }); +}); diff --git a/packages/ai-ui/src/index.ts b/packages/ai-ui/src/index.ts index 8ded5a3e..6ea4dfaf 100644 --- a/packages/ai-ui/src/index.ts +++ b/packages/ai-ui/src/index.ts @@ -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,