From 5e13f8596558e322d7b59887158623b8fef6f89d Mon Sep 17 00:00:00 2001
From: Test User
Date: Thu, 9 Apr 2026 21:53:00 -0700
Subject: [PATCH] feat(web-ui): Glitch Capture entry point and form for PROOF9
(#568)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #568
Adds the [Capture Glitch] button to the PROOF9 page header alongside
[Run Gates], opening a full modal form that calls POST /api/v2/proof/requirements
and re-fetches the requirements table on success.
Changes:
- CaptureGlitchModal component: description (required), source dropdown,
scope textarea, 9-gate checkbox list (≥1 required), severity dropdown,
optional expiry date; validates before submit, shows inline errors,
resets state on reopen
- proofApi.capture() method added to api.ts
- CaptureGlitchRequest type added to types/index.ts
- Exported from proof/index.ts
- Wired into proof/page.tsx with captureOpen state + mutate() on success
- 12 new tests covering rendering, validation, submission, error, reset
- ProofPage.test.tsx mock updated to include CaptureGlitchModal +
GateEvidencePanel (were missing, causing 18 pre-existing test failures)
Tests: 715/715 pass. Build: clean.
---
.../proof/CaptureGlitchModal.test.tsx | 211 ++++++++++++++
.../components/proof/ProofPage.test.tsx | 2 +
web-ui/src/app/proof/page.tsx | 54 ++--
.../components/proof/CaptureGlitchModal.tsx | 257 ++++++++++++++++++
web-ui/src/components/proof/index.ts | 1 +
web-ui/src/lib/api.ts | 13 +
web-ui/src/types/index.ts | 9 +
7 files changed, 531 insertions(+), 16 deletions(-)
create mode 100644 web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx
create mode 100644 web-ui/src/components/proof/CaptureGlitchModal.tsx
diff --git a/web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx b/web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx
new file mode 100644
index 00000000..86f37e33
--- /dev/null
+++ b/web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx
@@ -0,0 +1,211 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { CaptureGlitchModal } from '@/components/proof/CaptureGlitchModal';
+import { proofApi } from '@/lib/api';
+import type { ProofRequirement } from '@/types';
+
+// ── Mocks ────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/api', () => ({
+ proofApi: {
+ capture: jest.fn(),
+ },
+}));
+
+const mockCapture = proofApi.capture as jest.MockedFunction;
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+const WORKSPACE = '/home/user/project';
+
+const DEFAULT_PROPS = {
+ open: true,
+ workspacePath: WORKSPACE,
+ onClose: jest.fn(),
+ onSuccess: jest.fn(),
+};
+
+const MOCK_REQ: ProofRequirement = {
+ id: 'REQ-001',
+ title: 'Test glitch',
+ description: 'Something broke in production',
+ severity: 'high',
+ source: 'production',
+ status: 'open',
+ glitch_type: null,
+ obligations: [],
+ evidence_rules: [],
+ waiver: null,
+ created_at: '2026-04-09T00:00:00Z',
+ satisfied_at: null,
+ created_by: 'human',
+ source_issue: null,
+ related_reqs: [],
+};
+
+function setup(props = DEFAULT_PROPS) {
+ return render();
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────
+
+describe('CaptureGlitchModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders dialog with title and description when open', () => {
+ setup();
+ expect(screen.getByRole('heading', { name: 'Capture Glitch' })).toBeInTheDocument();
+ expect(screen.getByText(/Convert a production failure/i)).toBeInTheDocument();
+ });
+
+ it('renders all form fields', () => {
+ setup();
+ expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Where was it found/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Scope/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Severity/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Expiry/i)).toBeInTheDocument();
+ });
+
+ it('renders all 9 gate checkboxes', () => {
+ setup();
+ const gates = ['unit', 'contract', 'e2e', 'visual', 'a11y', 'perf', 'sec', 'demo', 'manual'];
+ for (const gate of gates) {
+ expect(screen.getByRole('checkbox', { name: gate })).toBeInTheDocument();
+ }
+ });
+
+ it('renders Cancel and Capture Glitch buttons', () => {
+ setup();
+ expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Capture Glitch/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('validation', () => {
+ it('shows error when description is empty on submit', async () => {
+ setup();
+ // Check at least one gate
+ fireEvent.click(screen.getByRole('checkbox', { name: 'unit' }));
+ fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));
+ await waitFor(() => {
+ expect(screen.getByText(/Description is required/i)).toBeInTheDocument();
+ });
+ expect(mockCapture).not.toHaveBeenCalled();
+ });
+
+ it('shows error when no gates selected on submit', async () => {
+ setup();
+ fireEvent.change(screen.getByLabelText(/Description/i), {
+ target: { value: 'Something broke' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));
+ await waitFor(() => {
+ expect(screen.getByText(/Select at least one gate/i)).toBeInTheDocument();
+ });
+ expect(mockCapture).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('submission', () => {
+ it('calls proofApi.capture with correct fields and calls onSuccess', async () => {
+ mockCapture.mockResolvedValue(MOCK_REQ);
+ setup();
+
+ fireEvent.change(screen.getByLabelText(/Description/i), {
+ target: { value: 'Something broke in production' },
+ });
+ fireEvent.click(screen.getByRole('checkbox', { name: 'unit' }));
+ fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));
+
+ await waitFor(() => {
+ expect(mockCapture).toHaveBeenCalledWith(
+ WORKSPACE,
+ expect.objectContaining({
+ title: 'Something broke in production',
+ description: 'Something broke in production',
+ severity: 'high',
+ source: 'production',
+ created_by: 'human',
+ })
+ );
+ });
+ await waitFor(() => {
+ expect(DEFAULT_PROPS.onSuccess).toHaveBeenCalledWith(MOCK_REQ);
+ });
+ });
+
+ it('truncates description to 80 chars for title', async () => {
+ mockCapture.mockResolvedValue(MOCK_REQ);
+ setup();
+
+ const longDesc = 'A'.repeat(100);
+ fireEvent.change(screen.getByLabelText(/Description/i), {
+ target: { value: longDesc },
+ });
+ fireEvent.click(screen.getByRole('checkbox', { name: 'sec' }));
+ fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));
+
+ await waitFor(() => {
+ expect(mockCapture).toHaveBeenCalledWith(
+ WORKSPACE,
+ expect.objectContaining({ title: 'A'.repeat(80) })
+ );
+ });
+ });
+
+ it('shows inline error and keeps modal open on API failure', async () => {
+ mockCapture.mockRejectedValue(new Error('Network error'));
+ setup();
+
+ fireEvent.change(screen.getByLabelText(/Description/i), {
+ target: { value: 'Something broke' },
+ });
+ fireEvent.click(screen.getByRole('checkbox', { name: 'demo' }));
+ fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Failed to capture glitch/i)).toBeInTheDocument();
+ });
+ expect(DEFAULT_PROPS.onSuccess).not.toHaveBeenCalled();
+ // Modal still open
+ expect(screen.getByRole('heading', { name: 'Capture Glitch' })).toBeInTheDocument();
+ });
+
+ it('disables submit button while submitting', async () => {
+ let resolve!: (v: ProofRequirement) => void;
+ mockCapture.mockReturnValue(new Promise((r) => { resolve = r; }));
+ setup();
+
+ fireEvent.change(screen.getByLabelText(/Description/i), {
+ target: { value: 'Something broke' },
+ });
+ fireEvent.click(screen.getByRole('checkbox', { name: 'manual' }));
+ fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Capturing…/i)).toBeInTheDocument();
+ });
+ resolve(MOCK_REQ);
+ });
+ });
+
+ describe('cancel', () => {
+ it('calls onClose when Cancel is clicked', () => {
+ setup();
+ fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
+ expect(DEFAULT_PROPS.onClose).toHaveBeenCalled();
+ });
+ });
+
+ describe('state reset on reopen', () => {
+ it('clears form state when modal is reopened', () => {
+ const { rerender } = setup({ ...DEFAULT_PROPS, open: false });
+ rerender();
+ expect((screen.getByLabelText(/Description/i) as HTMLTextAreaElement).value).toBe('');
+ });
+ });
+});
diff --git a/web-ui/src/__tests__/components/proof/ProofPage.test.tsx b/web-ui/src/__tests__/components/proof/ProofPage.test.tsx
index fabcc768..92052d8d 100644
--- a/web-ui/src/__tests__/components/proof/ProofPage.test.tsx
+++ b/web-ui/src/__tests__/components/proof/ProofPage.test.tsx
@@ -25,7 +25,9 @@ jest.mock('@/components/proof', () => ({
WaiveDialog: () => null,
GateRunPanel: () => null,
GateRunBanner: () => null,
+ GateEvidencePanel: () => null,
RunHistoryPanel: () => null,
+ CaptureGlitchModal: () => null,
}));
jest.mock('next/link', () => {
const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => (
diff --git a/web-ui/src/app/proof/page.tsx b/web-ui/src/app/proof/page.tsx
index d67a7441..f5db6219 100644
--- a/web-ui/src/app/proof/page.tsx
+++ b/web-ui/src/app/proof/page.tsx
@@ -12,7 +12,7 @@ import {
TooltipProvider,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
-import { ProofStatusBadge, WaiveDialog, GateRunPanel, GateRunBanner, RunHistoryPanel, GateEvidencePanel } from '@/components/proof';
+import { ProofStatusBadge, WaiveDialog, GateRunPanel, GateRunBanner, RunHistoryPanel, GateEvidencePanel, CaptureGlitchModal } from '@/components/proof';
import { proofApi } from '@/lib/api';
import { useProofRun } from '@/hooks/useProofRun';
import { getSelectedWorkspacePath } from '@/lib/workspace-storage';
@@ -103,6 +103,7 @@ function ProofPageContent() {
const [workspacePath, setWorkspacePath] = useState(null);
const [workspaceReady, setWorkspaceReady] = useState(false);
const [waivedReq, setWaivedReq] = useState(null);
+ const [captureOpen, setCaptureOpen] = useState(false);
const [selectedRunId, setSelectedRunId] = useState(null);
const { runState, gateEntries, passed, runMessage, errorMessage, startRun, retry } = useProofRun();
@@ -199,21 +200,30 @@ function ProofPageContent() {
Learn more ↓
-
+
+
+
+
{isLoading && (
@@ -511,6 +521,18 @@ function ProofPageContent() {
/>
)}
+ {workspacePath && (
+ setCaptureOpen(false)}
+ onSuccess={() => {
+ setCaptureOpen(false);
+ mutate();
+ }}
+ />
+ )}
+
{/* In-page help reference */}
About PROOF9
diff --git a/web-ui/src/components/proof/CaptureGlitchModal.tsx b/web-ui/src/components/proof/CaptureGlitchModal.tsx
new file mode 100644
index 00000000..19dc06b8
--- /dev/null
+++ b/web-ui/src/components/proof/CaptureGlitchModal.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from '@/components/ui/select';
+import { proofApi } from '@/lib/api';
+import type { ProofRequirement, ProofSeverity, CaptureGlitchRequest } from '@/types';
+
+// The 9 PROOF9 gate types (mirrors Gate enum in codeframe/core/proof/models.py)
+const GATE_LIST = ['unit', 'contract', 'e2e', 'visual', 'a11y', 'perf', 'sec', 'demo', 'manual'] as const;
+
+const SOURCE_OPTIONS: { value: CaptureGlitchRequest['source']; label: string }[] = [
+ { value: 'production', label: 'Production' },
+ { value: 'qa', label: 'QA' },
+ { value: 'dogfooding', label: 'Dogfooding' },
+ { value: 'monitoring', label: 'Monitoring' },
+ { value: 'user_report', label: 'User Report' },
+];
+
+const SEVERITY_OPTIONS: { value: ProofSeverity; label: string }[] = [
+ { value: 'critical', label: 'Critical' },
+ { value: 'high', label: 'High' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'low', label: 'Low' },
+];
+
+export interface CaptureGlitchModalProps {
+ open: boolean;
+ workspacePath: string;
+ onClose: () => void;
+ onSuccess: (req: ProofRequirement) => void;
+}
+
+export function CaptureGlitchModal({ open, workspacePath, onClose, onSuccess }: CaptureGlitchModalProps) {
+ const [description, setDescription] = useState('');
+ const [source, setSource] = useState('production');
+ const [scopeText, setScopeText] = useState('');
+ const [selectedGates, setSelectedGates] = useState>(new Set());
+ const [severity, setSeverity] = useState('high');
+ const [expires, setExpires] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Reset all state when the modal opens
+ useEffect(() => {
+ if (open) {
+ setDescription('');
+ setSource('production');
+ setScopeText('');
+ setSelectedGates(new Set());
+ setSeverity('high');
+ setExpires('');
+ setSubmitting(false);
+ setError(null);
+ }
+ }, [open]);
+
+ function toggleGate(gate: string) {
+ setSelectedGates((prev) => {
+ const next = new Set(prev);
+ if (next.has(gate)) {
+ next.delete(gate);
+ } else {
+ next.add(gate);
+ }
+ return next;
+ });
+ }
+
+ async function handleSubmit() {
+ // Validate
+ if (!description.trim()) {
+ setError('Description is required');
+ return;
+ }
+ if (selectedGates.size === 0) {
+ setError('Select at least one gate');
+ return;
+ }
+
+ setError(null);
+ setSubmitting(true);
+
+ // Derive title from first line of description (max 80 chars)
+ const title = description.trim().split('\n')[0].slice(0, 80);
+
+ // Derive `where` from scope lines, falling back to source
+ const scopeLines = scopeText.split('\n').map((l) => l.trim()).filter(Boolean);
+ const where = scopeLines.length > 0 ? scopeLines.join(', ') : source;
+
+ const body: CaptureGlitchRequest = {
+ title,
+ description: description.trim(),
+ where,
+ severity,
+ source,
+ created_by: 'human',
+ };
+
+ try {
+ const result = await proofApi.capture(workspacePath, body);
+ onSuccess(result);
+ } catch (err: unknown) {
+ const detail = (err as { detail?: string })?.detail;
+ setError(detail ?? 'Failed to capture glitch. Please try again.');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/web-ui/src/components/proof/index.ts b/web-ui/src/components/proof/index.ts
index 47480292..1404aa50 100644
--- a/web-ui/src/components/proof/index.ts
+++ b/web-ui/src/components/proof/index.ts
@@ -5,3 +5,4 @@ export { GateRunPanel } from './GateRunPanel';
export { GateRunBanner } from './GateRunBanner';
export { GateEvidencePanel } from './GateEvidencePanel';
export { RunHistoryPanel } from './RunHistoryPanel';
+export { CaptureGlitchModal } from './CaptureGlitchModal';
diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts
index c4c09615..5c016a63 100644
--- a/web-ui/src/lib/api.ts
+++ b/web-ui/src/lib/api.ts
@@ -43,6 +43,7 @@ import type {
ProofStatusResponse,
ProofReqStatus,
WaiveRequest,
+ CaptureGlitchRequest,
RunProofRequest,
RunProofResponse,
RunStatusResponse,
@@ -622,6 +623,18 @@ export const proofApi = {
return response.data;
},
+ capture: async (
+ workspacePath: string,
+ body: CaptureGlitchRequest
+ ): Promise => {
+ const response = await api.post(
+ '/api/v2/proof/requirements',
+ body,
+ { params: { workspace_path: workspacePath } }
+ );
+ return response.data;
+ },
+
waive: async (
workspacePath: string,
reqId: string,
diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts
index 7ceb95f2..f179e7e7 100644
--- a/web-ui/src/types/index.ts
+++ b/web-ui/src/types/index.ts
@@ -343,6 +343,15 @@ export interface WaiveRequest {
approved_by: string;
}
+export interface CaptureGlitchRequest {
+ title: string;
+ description: string;
+ where: string;
+ severity: ProofSeverity;
+ source: 'production' | 'qa' | 'dogfooding' | 'monitoring' | 'user_report';
+ created_by: string;
+}
+
// Proof run types (mirrors proof_v2.py RunProofResponse + RunStatusResponse)
export interface RunProofRequest {
full: boolean;