diff --git a/docs/plans/2026-02-16-interactive-redaction-ui-impl.md b/docs/plans/2026-02-16-interactive-redaction-ui-impl.md new file mode 100644 index 0000000..cea0b82 --- /dev/null +++ b/docs/plans/2026-02-16-interactive-redaction-ui-impl.md @@ -0,0 +1,1179 @@ +# Interactive Redaction UI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the upfront OCR + line-based UI with a redaction-centric workflow: OpenCV detects redaction boxes on upload, renders them as clickable canvas overlays, and does on-demand OCR/font detection when a user clicks a specific redaction. + +**Architecture:** FastAPI backend gains a new `detect_redactions.py` module (OpenCV) and a `/api/redaction/analyze` endpoint. The upload flow drops OCR/font detection. The vanilla JS frontend replaces the line list with a redaction list, renders clickable redaction indicators on canvas, and shows a per-redaction popover with solver controls. + +**Tech Stack:** Python 3.12+, FastAPI, OpenCV (cv2), Tesseract (pytesseract), Pillow, vanilla JS (no framework) + +**Design doc:** `docs/plans/2026-02-16-interactive-redaction-ui-design.md` + +--- + +### Task 1: Create redaction detection module + +**Files:** +- Create: `unredact/pipeline/detect_redactions.py` +- Create: `tests/test_detect_redactions.py` + +**Step 1: Write the failing test** + +```python +# tests/test_detect_redactions.py +import numpy as np +from PIL import Image + +from unredact.pipeline.detect_redactions import detect_redactions, Redaction + + +def test_detect_single_black_rectangle(): + """A white image with one black rectangle should yield one redaction.""" + img = Image.new("RGB", (800, 600), "white") + pixels = np.array(img) + # Draw a black rectangle (simulating a redaction) + pixels[200:230, 100:300] = [0, 0, 0] + img = Image.fromarray(pixels) + + redactions = detect_redactions(img) + assert len(redactions) == 1 + r = redactions[0] + assert abs(r.x - 100) < 5 + assert abs(r.y - 200) < 5 + assert abs(r.w - 200) < 10 + assert abs(r.h - 30) < 10 + + +def test_detect_no_redactions_on_clean_page(): + """A white page with no black rectangles should return empty list.""" + img = Image.new("RGB", (800, 600), "white") + redactions = detect_redactions(img) + assert redactions == [] + + +def test_detect_ignores_small_noise(): + """Small black spots (< min area) should be ignored.""" + img = Image.new("RGB", (800, 600), "white") + pixels = np.array(img) + pixels[100:105, 100:105] = [0, 0, 0] # 5x5 spot — too small + img = Image.fromarray(pixels) + + redactions = detect_redactions(img) + assert redactions == [] + + +def test_detect_multiple_redactions(): + """Multiple black rectangles should all be detected.""" + img = Image.new("RGB", (800, 600), "white") + pixels = np.array(img) + pixels[100:125, 50:250] = [0, 0, 0] + pixels[300:325, 100:400] = [0, 0, 0] + pixels[450:475, 200:500] = [0, 0, 0] + img = Image.fromarray(pixels) + + redactions = detect_redactions(img) + assert len(redactions) == 3 +``` + +**Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_detect_redactions.py -v` +Expected: FAIL with "ModuleNotFoundError: No module named 'unredact.pipeline.detect_redactions'" + +**Step 3: Write minimal implementation** + +```python +# unredact/pipeline/detect_redactions.py +from dataclasses import dataclass +import uuid + +import cv2 +import numpy as np +from PIL import Image + + +@dataclass +class Redaction: + """A detected redaction bounding box.""" + id: str + x: int + y: int + w: int + h: int + + +# Minimum area in pixels to consider (filters noise) +MIN_AREA = 500 + +# Minimum aspect ratio (width/height) — redactions are wider than tall +MIN_ASPECT = 1.5 + + +def detect_redactions(image: Image.Image) -> list[Redaction]: + """Detect black-filled rectangles in a page image. + + Converts to grayscale, thresholds for near-black pixels, finds contours, + and filters for rectangular shapes that look like redaction bars. + + Args: + image: PIL Image of a document page. + + Returns: + List of Redaction objects sorted top-to-bottom, left-to-right. + """ + arr = np.array(image.convert("RGB")) + gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY) + + # Threshold: pixels darker than 40 are "black" + _, binary = cv2.threshold(gray, 40, 255, cv2.THRESH_BINARY_INV) + + # Morphological close to merge adjacent redaction fragments + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3)) + binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) + + contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + redactions: list[Redaction] = [] + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + area = w * h + + if area < MIN_AREA: + continue + + # Aspect ratio filter: redactions are wider than tall + if h > 0 and w / h < MIN_ASPECT: + continue + + # Fill ratio: the contour should fill most of the bounding rect + contour_area = cv2.contourArea(contour) + if contour_area / area < 0.7: + continue + + redactions.append(Redaction( + id=uuid.uuid4().hex[:8], + x=x, y=y, w=w, h=h, + )) + + # Sort top-to-bottom, then left-to-right + redactions.sort(key=lambda r: (r.y, r.x)) + return redactions +``` + +**Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_detect_redactions.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add unredact/pipeline/detect_redactions.py tests/test_detect_redactions.py +git commit -m "feat: add OpenCV redaction detection module" +``` + +--- + +### Task 2: Simplify upload endpoint (remove upfront OCR) + +**Files:** +- Modify: `unredact/app.py` (upload handler, imports, data endpoint, remove overlay endpoint) +- Modify: `tests/test_app.py` (update tests for new behavior) + +**Step 1: Write the failing test** + +Update `tests/test_app.py` — replace old tests that assume OCR data with new tests that expect redaction data: + +```python +# Replace test_get_page_data with: +@pytest.mark.anyio +async def test_get_page_data_returns_redactions(pdf_bytes: bytes): + """Page data should return redaction bboxes, not OCR lines.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + "/api/upload", + files={"file": ("test.pdf", pdf_bytes, "application/pdf")}, + ) + doc_id = resp.json()["doc_id"] + + resp = await client.get(f"/api/doc/{doc_id}/page/1/data") + assert resp.status_code == 200 + data = resp.json() + assert "redactions" in data + assert isinstance(data["redactions"], list) + # Each redaction should have id, x, y, w, h + if data["redactions"]: + r = data["redactions"][0] + assert "id" in r + assert "x" in r + assert "y" in r + assert "w" in r + assert "h" in r + + +# Remove test_get_page_overlay entirely (endpoint removed) +# Remove old test_get_page_data +``` + +**Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_app.py::test_get_page_data_returns_redactions -v` +Expected: FAIL — old endpoint returns `{"lines": [...]}` + +**Step 3: Modify app.py** + +In `unredact/app.py`: + +1. Add import: `from unredact.pipeline.detect_redactions import detect_redactions` +2. Remove imports: `ocr_page`, `detect_fonts`, `render_overlay` +3. Simplify upload handler: + +```python +@app.post("/api/upload") +async def upload_pdf(file: UploadFile): + content = await file.read() + doc_id = uuid.uuid4().hex[:12] + + tmp = TemporaryDirectory() + pdf_path = Path(tmp.name) / "doc.pdf" + pdf_path.write_bytes(content) + + pages = rasterize_pdf(pdf_path) + + page_data = {} + for i, page_img in enumerate(pages, start=1): + redactions = detect_redactions(page_img) + page_data[i] = { + "original": page_img, + "redactions": redactions, + } + + _docs[doc_id] = { + "page_count": len(pages), + "pages": page_data, + "tmp": tmp, + } + + return {"doc_id": doc_id, "page_count": len(pages)} +``` + +4. Update data endpoint: + +```python +@app.get("/api/doc/{doc_id}/page/{page}/data") +async def get_page_data(doc_id: str, page: int): + doc = _docs.get(doc_id) + if not doc or page not in doc["pages"]: + return JSONResponse({"error": "not found"}, status_code=404) + + pd = doc["pages"][page] + redactions_json = [ + {"id": r.id, "x": r.x, "y": r.y, "w": r.w, "h": r.h} + for r in pd["redactions"] + ] + return {"redactions": redactions_json} +``` + +5. Remove the overlay endpoint (`get_page_overlay` function, lines 122-131). + +**Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_app.py -v` +Expected: All tests pass (remove/update tests that reference old endpoints) + +**Step 5: Commit** + +```bash +git add unredact/app.py tests/test_app.py +git commit -m "refactor: simplify upload to rasterize + redaction detect only" +``` + +--- + +### Task 3: Add /api/redaction/analyze endpoint + +**Files:** +- Modify: `unredact/app.py` (add new endpoint) +- Modify: `tests/test_app.py` (add test) + +**Step 1: Write the failing test** + +```python +# Add to tests/test_app.py: +@pytest.mark.anyio +async def test_redaction_analyze(pdf_bytes: bytes): + """POST /api/redaction/analyze should OCR the line around a redaction.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + # Upload + resp = await client.post( + "/api/upload", + files={"file": ("test.pdf", pdf_bytes, "application/pdf")}, + ) + doc_id = resp.json()["doc_id"] + + # Get redactions + resp = await client.get(f"/api/doc/{doc_id}/page/1/data") + redactions = resp.json()["redactions"] + if not redactions: + pytest.skip("No redactions detected in test PDF") + + r = redactions[0] + + # Analyze + resp = await client.post("/api/redaction/analyze", json={ + "doc_id": doc_id, + "page": 1, + "redaction": {"x": r["x"], "y": r["y"], "w": r["w"], "h": r["h"]}, + }) + assert resp.status_code == 200 + data = resp.json() + assert "segments" in data + assert "gap" in data + assert "font" in data + assert data["gap"]["w"] > 0 + assert data["font"]["name"] + assert data["font"]["size"] > 0 +``` + +**Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_app.py::test_redaction_analyze -v` +Expected: FAIL — 404 or endpoint not found + +**Step 3: Write the endpoint** + +Add to `unredact/app.py`: + +```python +from unredact.pipeline.ocr import ocr_page +from unredact.pipeline.font_detect import detect_font_for_line + + +class AnalyzeRequest(BaseModel): + doc_id: str + page: int + redaction: dict # {x, y, w, h} + + +@app.post("/api/redaction/analyze") +async def analyze_redaction(req: AnalyzeRequest): + doc = _docs.get(req.doc_id) + if not doc or req.page not in doc["pages"]: + return JSONResponse({"error": "not found"}, status_code=404) + + page_img = doc["pages"][req.page]["original"] + rx, ry, rw, rh = req.redaction["x"], req.redaction["y"], req.redaction["w"], req.redaction["h"] + + # Expand vertically to capture the full line, horizontally to page edges + # Use redaction's y-band ± some padding for the line crop + pad_y = rh # one redaction-height of vertical padding + crop_y1 = max(0, ry - pad_y) + crop_y2 = min(page_img.height, ry + rh + pad_y) + line_crop = page_img.crop((0, crop_y1, page_img.width, crop_y2)) + + # OCR just this line crop + lines = ocr_page(line_crop) + if not lines: + return JSONResponse({"error": "no text detected near redaction"}, status_code=422) + + # Find the line closest to the redaction's y-center + redaction_cy = (ry - crop_y1) + rh / 2 + best_line = min(lines, key=lambda l: abs((l.y + l.h / 2) - redaction_cy)) + + # Detect font for this line + font_match = detect_font_for_line(best_line) + + # Build segments: text before redaction, gap, text after redaction + # Convert redaction x to crop-relative coordinates (x is already page-relative, + # crop starts at x=0 so no adjustment needed for x) + segments = [] + gap = {"x": rx, "w": rw} + + left_chars = [c for c in best_line.chars if c.x + c.w <= rx] + right_chars = [c for c in best_line.chars if c.x >= rx + rw] + + left_text = "".join(c.text for c in left_chars).rstrip() + right_text = "".join(c.text for c in right_chars).lstrip() + + if left_text: + lx = left_chars[0].x + lw = (left_chars[-1].x + left_chars[-1].w) - lx + segments.append({"text": left_text, "x": lx, "w": lw}) + if right_text: + rx2 = right_chars[0].x + rw2 = (right_chars[-1].x + right_chars[-1].w) - rx2 + segments.append({"text": right_text, "x": rx2, "w": rw2}) + + # Adjust segment coordinates back to page-relative y + chars_json = [ + {"text": c.text, "x": c.x, "y": c.y + crop_y1, "w": c.w, "h": c.h, "conf": c.conf} + for c in best_line.chars + ] + + return { + "segments": segments, + "gap": gap, + "font": { + "name": font_match.font_name, + "id": _make_font_id(font_match.font_name), + "size": font_match.font_size, + "score": font_match.score, + }, + "line": { + "x": best_line.x, + "y": best_line.y + crop_y1, + "w": best_line.w, + "h": best_line.h, + "text": best_line.text, + }, + "chars": chars_json, + } +``` + +Note: We restore the `ocr_page` and `detect_font_for_line` imports that Task 2 removed. They're now used on-demand only, not at upload. + +**Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_app.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add unredact/app.py tests/test_app.py +git commit -m "feat: add /api/redaction/analyze endpoint for on-demand OCR" +``` + +--- + +### Task 4: Rewrite frontend — state model and upload flow + +**Files:** +- Modify: `unredact/static/app.js` (state, upload, page loading) + +This task replaces the state model and upload/page-loading flow. No tests (vanilla JS, tested manually). + +**Step 1: Replace state model (app.js lines 50-66)** + +Replace the old state block with: + +```javascript +const state = { + docId: null, + pageCount: 0, + currentPage: 1, + redactions: {}, // id -> {id, x, y, w, h, page, status, analysis, solution} + activeRedaction: null, // id of the currently active redaction + fonts: [], + fontsReady: false, + zoom: 1, + panX: 0, + panY: 0, + associates: null, +}; +``` + +**Step 2: Update loadPage (app.js lines 159-177)** + +Replace with: + +```javascript +async function loadPage(page) { + state.currentPage = page; + state.activeRedaction = null; + closePopover(); + updatePageControls(); + + docImage.src = `/api/doc/${state.docId}/page/${page}/original`; + + // Load redactions for this page if not already loaded + const pageRedactions = Object.values(state.redactions).filter(r => r.page === page); + if (pageRedactions.length === 0) { + const resp = await fetch(`/api/doc/${state.docId}/page/${page}/data`); + const data = await resp.json(); + for (const r of data.redactions) { + state.redactions[r.id] = { + ...r, + page: page, + status: "unanalyzed", + analysis: null, + solution: null, + }; + } + } + + renderRedactionList(); + renderCanvas(); +} +``` + +**Step 3: Update uploadFile to not wait for OCR** + +The upload handler no longer does OCR so it should be fast. Update to match new flow. + +**Step 4: Commit** + +```bash +git add unredact/static/app.js +git commit -m "refactor: replace line-based state with redaction-centric state model" +``` + +--- + +### Task 5: Rewrite frontend — left panel redaction list + +**Files:** +- Modify: `unredact/static/app.js` (replace `renderLineList` and `selectLine`) +- Modify: `unredact/static/index.html` (rename `line-list` to `redaction-list`) +- Modify: `unredact/static/style.css` (update left panel styles) + +**Step 1: Update HTML** + +In `index.html`, replace the left panel contents (line 37): + +```html +
+
+
+``` + +**Step 2: Write renderRedactionList in app.js** + +Replace `renderLineList` and `selectLine` with: + +```javascript +const redactionList = document.getElementById("redaction-list"); + +function renderRedactionList() { + const pageRedactions = Object.values(state.redactions) + .filter(r => r.page === state.currentPage) + .sort((a, b) => a.y - b.y || a.x - b.x); + + redactionList.innerHTML = ""; + + pageRedactions.forEach((r, idx) => { + const div = document.createElement("div"); + div.className = "redaction-item"; + if (state.activeRedaction === r.id) div.classList.add("selected"); + div.dataset.id = r.id; + + const numEl = document.createElement("div"); + numEl.className = "redaction-num"; + numEl.textContent = `#${idx + 1}`; + + const statusEl = document.createElement("div"); + statusEl.className = `redaction-status status-${r.status}`; + statusEl.textContent = r.status; + + const infoEl = document.createElement("div"); + infoEl.className = "redaction-info"; + + if (r.status === "solved" && r.solution) { + infoEl.textContent = r.solution.text; + infoEl.classList.add("solved-text"); + } else if (r.status === "analyzed" && r.analysis) { + const segs = r.analysis.segments.map(s => s.text).join(" ▮ "); + infoEl.textContent = segs || `${r.w}×${r.h}px`; + } else { + infoEl.textContent = `${r.w}×${r.h}px`; + } + + div.appendChild(numEl); + div.appendChild(statusEl); + div.appendChild(infoEl); + + div.addEventListener("click", () => activateRedaction(r.id)); + redactionList.appendChild(div); + }); +} + +function activateRedaction(id) { + state.activeRedaction = id; + const r = state.redactions[id]; + if (!r) return; + + renderRedactionList(); + + // Pan to the redaction + state.panX = r.x + r.w / 2; + state.panY = r.y + r.h / 2; + applyTransform(true); + + // If unanalyzed, trigger analysis + if (r.status === "unanalyzed") { + analyzeRedaction(id); + } else { + openPopover(id); + } + + renderCanvas(); +} +``` + +**Step 3: Update CSS** + +Add styles for `.redaction-item`, `.redaction-num`, `.redaction-status`, `.redaction-info` in `style.css`. Replace `.line-item` styles with similar redaction-item styles. Add status color classes: `.status-unanalyzed` (gray), `.status-analyzed` (yellow), `.status-solved` (green). + +**Step 4: Commit** + +```bash +git add unredact/static/app.js unredact/static/index.html unredact/static/style.css +git commit -m "feat: replace line list with redaction list in left panel" +``` + +--- + +### Task 6: Rewrite frontend — canvas rendering for redactions + +**Files:** +- Modify: `unredact/static/app.js` (replace `renderOverlay` with `renderCanvas`) + +**Step 1: Write renderCanvas** + +Replace the old `renderOverlay` function with a new `renderCanvas` that: + +```javascript +function renderCanvas() { + if (!docImage.naturalWidth) return; + + canvas.width = docImage.naturalWidth; + canvas.height = docImage.naturalHeight; + canvas.style.width = docImage.naturalWidth + "px"; + canvas.style.height = docImage.naturalHeight + "px"; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const pageRedactions = Object.values(state.redactions) + .filter(r => r.page === state.currentPage); + + for (const r of pageRedactions) { + const isActive = state.activeRedaction === r.id; + + if (r.status === "solved" && r.solution) { + // Draw solved text in green + const fontStr = `${r.solution.fontSize}px "${r.solution.fontName}"`; + ctx.font = fontStr; + ctx.fillStyle = "rgba(0, 220, 0, 0.85)"; + ctx.textBaseline = "top"; + ctx.fillText(r.solution.text, r.x, r.y); + } else if (r.preview) { + // Draw preview text in yellow + const a = r.analysis; + if (a) { + const fontStr = `${a.font.size}px "${a.font.name}"`; + ctx.font = fontStr; + ctx.fillStyle = "rgba(255, 200, 0, 0.85)"; + ctx.textBaseline = "top"; + ctx.fillText(r.preview, r.x, r.y); + } + // Subtle highlight + ctx.fillStyle = isActive + ? "rgba(255, 200, 0, 0.2)" + : "rgba(255, 200, 0, 0.1)"; + ctx.fillRect(r.x, r.y, r.w, r.h); + } else { + // Draw redaction indicator + ctx.fillStyle = isActive + ? "rgba(80, 120, 255, 0.35)" + : "rgba(80, 120, 255, 0.2)"; + ctx.fillRect(r.x, r.y, r.w, r.h); + + ctx.strokeStyle = isActive + ? "rgba(80, 120, 255, 0.9)" + : "rgba(80, 120, 255, 0.5)"; + ctx.lineWidth = isActive ? 2 : 1; + ctx.strokeRect(r.x, r.y, r.w, r.h); + } + } +} +``` + +**Step 2: Add canvas click handler for hit-testing** + +Enable pointer-events on the canvas and add click detection: + +```javascript +// In style.css, change #overlay-canvas pointer-events from "none" to "auto" + +canvas.addEventListener("click", (e) => { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const cx = (e.clientX - rect.left) * scaleX; + const cy = (e.clientY - rect.top) * scaleY; + + // Hit-test against redactions on current page + const hit = Object.values(state.redactions) + .filter(r => r.page === state.currentPage) + .find(r => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h); + + if (hit) { + e.stopPropagation(); // prevent pan + activateRedaction(hit.id); + } +}); +``` + +**Important:** The canvas needs `pointer-events: auto` but we need to be careful not to break pan/zoom. The click handler should only consume the event when hitting a redaction; otherwise let it bubble for panning. This requires adjusting the drag logic to check if the click hit a redaction. + +**Step 3: Update hover cursor** + +Add mousemove handler on canvas to change cursor when hovering over a redaction: + +```javascript +canvas.addEventListener("mousemove", (e) => { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const cx = (e.clientX - rect.left) * scaleX; + const cy = (e.clientY - rect.top) * scaleY; + + const hover = Object.values(state.redactions) + .filter(r => r.page === state.currentPage) + .some(r => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h); + + canvas.style.cursor = hover ? "pointer" : ""; +}); +``` + +**Step 4: Commit** + +```bash +git add unredact/static/app.js unredact/static/style.css +git commit -m "feat: canvas-native redaction overlays with hit-testing" +``` + +--- + +### Task 7: Rewrite frontend — analyze flow and popover + +**Files:** +- Modify: `unredact/static/app.js` (analyzeRedaction, popover) +- Modify: `unredact/static/index.html` (add popover container) +- Modify: `unredact/static/style.css` (popover styles) + +**Step 1: Add popover HTML to index.html** + +Add inside `#right-panel`, after `#doc-container`: + +```html + +``` + +**Step 2: Write analyzeRedaction in app.js** + +```javascript +async function analyzeRedaction(id) { + const r = state.redactions[id]; + if (!r) return; + + r.status = "analyzing"; + renderRedactionList(); + + const resp = await fetch("/api/redaction/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + doc_id: state.docId, + page: r.page, + redaction: { x: r.x, y: r.y, w: r.w, h: r.h }, + }), + }); + + if (!resp.ok) { + r.status = "error"; + renderRedactionList(); + return; + } + + const data = await resp.json(); + r.status = "analyzed"; + r.analysis = data; + renderRedactionList(); + openPopover(id); +} +``` + +**Step 3: Write openPopover / closePopover** + +```javascript +const popover = document.getElementById("redaction-popover"); +const popoverClose = document.getElementById("popover-close"); +const popoverContext = document.getElementById("popover-context"); +const popoverFont = document.getElementById("popover-font"); +const popoverGap = document.getElementById("popover-gap"); + +function openPopover(id) { + const r = state.redactions[id]; + if (!r || !r.analysis) return; + const a = r.analysis; + + // Populate context + const leftText = a.segments.length > 0 ? a.segments[0].text : ""; + const rightText = a.segments.length > 1 ? a.segments[1].text : ""; + popoverContext.textContent = `...${leftText} ▮▮▮ ${rightText}...`; + + // Font info + popoverFont.textContent = `${a.font.name} ${a.font.size}px (score: ${a.font.score.toFixed(1)})`; + + // Gap info + popoverGap.textContent = `Gap: ${a.gap.w}px`; + + // Position popover near the redaction + // Convert redaction center to screen coordinates + popover.hidden = false; + + // Position: anchor to top-right of right panel (simple, always visible) + popover.style.top = "4rem"; + popover.style.right = "0.75rem"; +} + +function closePopover() { + popover.hidden = true; + stopSolve(); +} + +popoverClose.addEventListener("click", closePopover); +``` + +**Step 4: Wire up solve in the popover** + +The existing `startSolve` function needs to be updated to read from the active redaction's analysis instead of from the old line/segment model. Update it to use `state.redactions[state.activeRedaction].analysis` for font, gap width, and context characters. + +```javascript +function startSolve() { + const r = state.redactions[state.activeRedaction]; + if (!r || !r.analysis) return; + const a = r.analysis; + + const fontId = a.font.id; + const fontSize = a.font.size; + + const gapWidth = a.gap.w; + + // Context: last char of left segment, first char of right segment + const leftCtx = a.segments.length > 0 + ? a.segments[0].text.slice(-1) + : ""; + const rightCtx = a.segments.length > 1 + ? a.segments[1].text.slice(0, 1) + : ""; + + // ... rest of solve logic stays the same (build body, fetch /api/solve, stream SSE) +} +``` + +**Step 5: Update handleSolveEvent to set preview on redaction** + +When a user clicks a solver result, set `r.preview = data.text` and re-render canvas. When they click Accept, set `r.status = "solved"`, `r.solution = {...}`, clear preview. + +**Step 6: Commit** + +```bash +git add unredact/static/app.js unredact/static/index.html unredact/static/style.css +git commit -m "feat: popover with on-demand analysis and solver integration" +``` + +--- + +### Task 8: Clean up — remove old code + +**Files:** +- Modify: `unredact/static/app.js` (remove dead code) +- Modify: `unredact/static/index.html` (remove old UI elements) +- Modify: `unredact/static/style.css` (remove old styles) +- Modify: `unredact/app.py` (remove unused overlay import) + +**Step 1: Remove from app.js** + +Remove these functions and their event listeners (they're dead code now): +- `renderLineList`, `selectLine`, `scrollToLine` +- `splitSegmentAtCursor`, `renderSegmentInputs`, `updateLineListPreview` +- `ensureOverride`, `ensureSegments`, `getSegments` +- `nudge`, `updatePosDisplay` +- `saveOverrideAndRender` +- Old `renderOverlay` +- Old `updateSolveButton` +- Old `acceptSolution` + +Remove old DOM references: +- `lineList`, `fontControls`, `fontSelect`, `sizeSlider`, `sizeValue`, `sizeDown`, `sizeUp` +- `posUp`, `posDown`, `posLeft`, `posRight`, `posReset`, `posDisplay` +- `textEditBar`, `segmentInputs`, `textReset` +- `solveBtn`, `solvePanel`, `solveClose` (old ones — now replaced by popover) + +**Step 2: Remove from index.html** + +Remove these elements: +- `#font-controls` div (lines 41-68) +- `#text-edit-bar` div (lines 69-73) +- Old `#solve-panel` div (lines 74-131) — replaced by popover + +**Step 3: Remove from style.css** + +Remove these style blocks: +- `#font-controls` and children +- `#text-edit-bar` and children +- `.seg-input`, `.seg-hint`, `.redaction-marker` +- `.pos-control`, `.dpad` and children +- Old `#solve-panel` (replaced by popover styles) + +**Step 4: Remove overlay.py references** + +In `app.py`, remove the `overlay` import if still present. The `overlay.py` module itself can stay (not hurting anything), but it's no longer imported or called. + +**Step 5: Commit** + +```bash +git add unredact/static/app.js unredact/static/index.html unredact/static/style.css unredact/app.py +git commit -m "refactor: remove old line-based UI code and overlay endpoint" +``` + +--- + +### Task 9: Add manual redaction marking (click-drag) + +**Files:** +- Modify: `unredact/static/app.js` (add drag-to-create-redaction) + +**Step 1: Add draw mode** + +When user holds Shift and drags on the canvas, draw a new redaction box: + +```javascript +let drawDrag = null; + +canvas.addEventListener("mousedown", (e) => { + if (!e.shiftKey) return; + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + drawDrag = { + startX: (e.clientX - rect.left) * scaleX, + startY: (e.clientY - rect.top) * scaleY, + }; + e.stopPropagation(); + e.preventDefault(); +}); + +window.addEventListener("mousemove", (e) => { + if (!drawDrag) return; + // Draw preview rectangle on canvas (render existing + new rect) + renderCanvas(); + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const cx = (e.clientX - rect.left) * scaleX; + const cy = (e.clientY - rect.top) * scaleY; + + const x = Math.min(drawDrag.startX, cx); + const y = Math.min(drawDrag.startY, cy); + const w = Math.abs(cx - drawDrag.startX); + const h = Math.abs(cy - drawDrag.startY); + + ctx.strokeStyle = "rgba(255, 100, 100, 0.8)"; + ctx.lineWidth = 2; + ctx.setLineDash([6, 3]); + ctx.strokeRect(x, y, w, h); + ctx.setLineDash([]); +}); + +window.addEventListener("mouseup", (e) => { + if (!drawDrag) return; + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const cx = (e.clientX - rect.left) * scaleX; + const cy = (e.clientY - rect.top) * scaleY; + + const x = Math.round(Math.min(drawDrag.startX, cx)); + const y = Math.round(Math.min(drawDrag.startY, cy)); + const w = Math.round(Math.abs(cx - drawDrag.startX)); + const h = Math.round(Math.abs(cy - drawDrag.startY)); + + drawDrag = null; + + // Only create if large enough + if (w < 20 || h < 5) { + renderCanvas(); + return; + } + + // Create manual redaction + const id = "m" + Date.now().toString(36); + state.redactions[id] = { + id, x, y, w, h, + page: state.currentPage, + status: "unanalyzed", + analysis: null, + solution: null, + }; + + renderRedactionList(); + renderCanvas(); + activateRedaction(id); +}); +``` + +**Step 2: Commit** + +```bash +git add unredact/static/app.js +git commit -m "feat: shift+drag to manually mark redaction boxes" +``` + +--- + +### Task 10: Update and clean up tests + +**Files:** +- Modify: `tests/test_app.py` (remove/update tests for removed endpoints) +- Modify: `tests/test_overlay.py` (mark as skip or remove if only testing removed overlay usage) + +**Step 1: Update test_app.py** + +- Remove `test_get_page_overlay` (endpoint removed) +- Remove old `test_get_page_data` (replaced in Task 2) +- Ensure all remaining tests pass + +**Step 2: Run full test suite** + +Run: `python -m pytest tests/ -v --ignore=tests/test_e2e.py` +Expected: All tests pass. Some tests (like test_overlay.py) may need import updates but the module itself still exists. + +**Step 3: Commit** + +```bash +git add tests/ +git commit -m "test: update test suite for redaction-centric architecture" +``` + +--- + +### Task 11: Popover CSS and visual polish + +**Files:** +- Modify: `unredact/static/style.css` + +**Step 1: Add popover styles** + +```css +#redaction-popover { + position: absolute; + top: 4rem; + right: 0.75rem; + width: 320px; + max-height: calc(100% - 5rem); + display: flex; + flex-direction: column; + background: rgba(20, 20, 40, 0.95); + border: 1px solid #333; + border-radius: 8px; + padding: 12px; + z-index: 30; + overflow: hidden; + backdrop-filter: blur(10px); +} + +/* ... plus styles for .popover-header, .popover-section, etc. */ +/* ... plus styles for .redaction-item, .redaction-num, .redaction-status */ +``` + +**Step 2: Add redaction list styles** + +```css +.redaction-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.15s; + font-size: 0.8rem; +} + +.redaction-item:hover { background: rgba(255, 255, 255, 0.05); } +.redaction-item.selected { border-left-color: #00d474; background: rgba(0, 212, 116, 0.1); } + +.redaction-num { color: #00d474; font-weight: 700; min-width: 2rem; } +.redaction-info { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.redaction-info.solved-text { color: #00d474; } + +.status-unanalyzed { color: #666; font-size: 0.65rem; } +.status-analyzed { color: #ffc800; font-size: 0.65rem; } +.status-solved { color: #00d474; font-size: 0.65rem; } +``` + +**Step 3: Commit** + +```bash +git add unredact/static/style.css +git commit -m "style: add popover and redaction list styles" +``` + +--- + +## Execution Order + +Tasks 1-3 are backend (can be done sequentially). +Tasks 4-9 are frontend (must be sequential, each builds on the previous). +Task 10 is cleanup (after both backend and frontend are done). +Task 11 is visual polish (can be done last). + +Dependencies: +- Task 2 depends on Task 1 (uses `detect_redactions`) +- Task 3 depends on Task 2 (builds on new upload flow) +- Task 4 depends on Tasks 2-3 (frontend needs new API shape) +- Tasks 5-9 depend on Task 4 (each builds on new state model) +- Task 10 depends on all above +- Task 11 is independent (CSS only)