early version of OCR viewer now integrated

This commit is contained in:
nathan
2026-05-13 10:03:38 -04:00
parent 77323821cb
commit c663627594
17 changed files with 5734 additions and 1517 deletions

View File

@@ -1,135 +0,0 @@
Here's your step-by-step guide. Give Copilot one phase at a time — do not paste them all at once.
PHASE 1 — CLEANUP: Remove all cross-highlighting code
Paste this first:
I need to clean up p44-ocr-viewer.html by removing all cross-panel word highlighting code that was never fully working. Do not change any CSS, layout, toolbar, PDF rendering, or OCR text rendering. Only remove the highlighting infrastructure.
Remove the following:
addHoverSpansToTextNodes() — the entire function
highlightBoxesByText() — the entire function
clearHighlightedBoxes() — the entire function
highlightSpansByText() — the entire function
clearHighlightedSpans() — the entire function
renderPlainTextWithLinks() — the entire function
buildIdQueue() — the entire function
dequeueId() — the entire function
The call to addHoverSpansToTextNodes(textOutput) inside showOCRPage()
The highlightSpansByText(title) fallback call inside makeWordBox()
The clearHighlightedSpans() call inside makeWordBox()
state.wordSequence if it was added
state.useJsonOverlay if it was added
All console.log statements added for debugging (the olmOCR word spans logs, PDF.js getTextContent logs)
Also remove the .word-span CSS rules from the stylesheet — the #text-output .word-span block.
After removal, verify:
showOCRPage() still renders text correctly
makeWordBox() still builds overlay boxes (they just won't trigger any cross-panel effects)
The #word-tip footer element can stay but its content should never be set
Sample data / JSON sidecar path must still work
PHASE 2 — Synchronized scrolling
After Phase 1 is working, paste this:
Add synchronized scrolling to p44-ocr-viewer.html. When the user scrolls either panel, the other panel scrolls to the same proportional vertical position.
Implementation:
Get references to both scroll containers: document.getElementById('image-panel-body') and document.getElementById('text-panel-body')
Add a scroll event listener to each. When one fires, calculate the scroll percentage: scrollTop / (scrollHeight - clientHeight). Apply that same percentage to the other panel's scrollTop.
Use a isSyncing boolean flag to prevent the two listeners from triggering each other in a loop. Set it true before programmatically scrolling, then false in a requestAnimationFrame callback after.
Add a toggle button to the toolbar labelled Sync Scroll with an active/inactive visual state (use the existing .tb-btn class). When inactive, the listeners are disconnected. Default state is active.
Constraints:
Do not change any existing scroll behaviour on page navigation
Do not change any CSS other than adding an .active state for the sync button that gives it border-color: var(--gold); color: var(--gold);
PHASE 3 — Click-to-zoom on the left panel
After Phase 2 is working, paste this:
Add click-to-zoom to the left panel (the PDF scan) in p44-ocr-viewer.html.
Implementation:
When the user clicks anywhere on #pdf-canvas, calculate the click position as a percentage of the canvas width and height.
Create a zoom overlay div with id zoom-lens that appears in the top-right corner of the left panel (position absolute, 280px × 200px, dark border, z-index 10). It contains a second canvas #zoom-canvas.
On click, draw a 3× magnified crop of the clicked region onto #zoom-canvas. The crop region is centered on the click point and covers 1/3 of the canvas width and height.
Clicking the same spot again, or clicking #zoom-lens itself, dismisses the zoom overlay.
Add a small ✕ close button in the top-right corner of #zoom-lens.
#zoom-lens styling: background: var(--surface), border: 1px solid var(--gold), border-radius: 4px. Use existing CSS variables throughout.
Constraints:
Do not affect PDF rendering or page navigation
The zoom lens must not appear until after the first click
PHASE 4 — Flag/annotation system
After Phase 3 is working, paste this:
Add a per-page flag/annotation system to p44-ocr-viewer.html. Reviewers can mark pages and the results persist to disk via the existing Python server.
Implementation:
Add three buttons to the right panel header (inside .panel-header, after the existing ocr-sync-label): ✓ Verified, ⚑ Needs Review, ✗ Error Found. Use the existing .tb-btn class. Add a fourth button Clear to remove a flag.
Store flags in a state.flags object keyed by page number: { 1: 'verified', 8: 'error', 12: 'review' }.
On page change, update the visual state of the three buttons to reflect the current page's flag (highlight the active one with border-color: var(--gold); color: var(--gold)).
Save flags by POSTing JSON to /save-flags on the local server. The payload is the entire state.flags object. If the POST fails silently (server doesn't support it yet), store in localStorage as a fallback with key p44-flags.
On startup, fetch /flags.json from the server. If it returns 200, load into state.flags. If it 404s, check localStorage fallback. If neither exists, start with empty flags.
Add a flags-summary display to the footer showing counts: e.g. ✓ 12 ⚑ 4 ✗ 2.
In launch_viewer.py, add a /save-flags POST handler that writes the received JSON to ./flags.json in the project root. Also serve ./flags.json as a static file (it already will be via SimpleHTTPRequestHandler).
Constraints:
Do not change PDF rendering, OCR rendering, or scroll sync
Flags must survive a browser refresh
PHASE 5 — Inline text editing
After Phase 4 is working, paste this:
Make the OCR text panel editable in p44-ocr-viewer.html so reviewers can correct OCR errors inline.
Implementation:
Add a toggle button to the right panel header labelled Edit using the existing .tb-btn class. When active, it shows Editing with gold styling.
When edit mode is active, set #text-output to contenteditable="true" and add a subtle inset border: outline: 1px solid var(--gold-dim) on the element.
When edit mode is toggled off, read the current innerHTML of #text-output, strip all HTML tags to get plain text, and save it as the corrected text for this page in state.corrections[pageNum].
Save corrections by POSTing to /save-corrections with payload { page: N, text: "..." }. If POST fails, store in localStorage under key p44-corrections.
On startup, fetch /corrections.json. If found, load into state.corrections. On page render, if state.corrections[pageNum] exists, display the corrected text instead of the raw olmOCR text.
In launch_viewer.py, add a /save-corrections POST handler that maintains a corrections.json file in the project root — a dict keyed by page number.
Add a visual indicator in the right panel header when the current page has a saved correction: a small gold dot or [edited] label next to the page number.
Constraints:
Switching pages while in edit mode should auto-save before navigating
Do not affect flags, scroll sync, zoom, or PDF rendering
PHASE 6 — Keyboard navigation
After Phase 5 is working, paste this:
Add keyboard navigation to p44-ocr-viewer.html.
Implementation:
Add a keydown event listener on document.
Map the following keys — but only when focus is NOT inside #text-output (to avoid interfering with editing) and NOT inside any input element:
ArrowRight or l → next page (goToPage(state.currentPage + 1))
ArrowLeft or j → previous page (goToPage(state.currentPage - 1))
v → mark current page Verified
r → mark current page Needs Review
e → mark current page Error Found
Escape → close zoom lens if open; exit edit mode if active
Add a small keyboard shortcut hint to the footer, right-aligned, in var(--text-dim) colour: ← → navigate v/r/e flag esc close
Constraints:
No key should fire while the user is typing in the edit panel or the page number input
Do not change any existing event listeners

View File

@@ -0,0 +1,485 @@
P44 OCR Viewer — War diary landing page (p44-landing.html)
Create a new standalone file p44-landing.html in the same directory as p44-ocr-viewer.html. Served by launch_viewer.py on the same localhost server. No frameworks, vanilla JS only.
PART 1 — Data config
At the top of the script, define the three war diaries as a static array. Progress is calculated from localStorage keys written by the viewer:
javascriptconst DIARIES = [
{
id: 'calgary-highlanders',
title: 'Calgary Highlanders',
subtitle: 'War Diary — September 1944',
tags: ['Infantry', 'Sep 1944', '343 pages'],
totalPages: 343,
viewerUrl: 'p44-ocr-viewer.html',
flagsKey: 'p44-flags',
pass2FlagsKey: 'p44-pass2-flags',
},
{
id: 'black-watch',
title: 'Black Watch (RHR) of Canada',
subtitle: 'War Diary — September 1944',
tags: ['Infantry', 'Sep 1944', '211 pages'],
totalPages: 211,
viewerUrl: 'p44-ocr-viewer.html',
flagsKey: 'p44-flags',
pass2FlagsKey: 'p44-pass2-flags',
},
{
id: '5cib',
title: '5th Canadian Infantry Brigade',
subtitle: 'War Diary — September 1944',
tags: ['Brigade HQ', 'Sep 1944', '178 pages'],
totalPages: 178,
viewerUrl: 'p44-ocr-viewer.html',
flagsKey: 'p44-flags',
pass2FlagsKey: 'p44-pass2-flags',
},
];
Note: All three diaries currently share the same localStorage keys since the viewer only supports one diary at a time. This is a known limitation — flagsKey and pass2FlagsKey are included in the config so they're easy to make per-diary later when the server handles multiple diaries.
PART 2 — Progress calculation
javascriptfunction getProgress(diary) {
let pass1Done = 0, pass2Done = 0;
try {
const flags = JSON.parse(localStorage.getItem(diary.flagsKey) || '{}');
pass1Done = Object.values(flags).filter(f => f === 'done').length;
} catch (_) {}
try {
const pass2Flags = JSON.parse(localStorage.getItem(diary.pass2FlagsKey) || '{}');
pass2Done = Object.values(pass2Flags).filter(f => f === 'done').length;
} catch (_) {}
return {
pass1Pct: Math.round((pass1Done / diary.totalPages) * 100),
pass2Pct: Math.round((pass2Done / diary.totalPages) * 100),
pass1Done,
pass2Done,
};
}
PART 3 — Role detection
javascriptfunction getCurrentRole() {
return localStorage.getItem('p44-role') || 'pass1';
}
PART 4 — Card renderer
javascriptfunction renderCard(diary) {
const role = getCurrentRole();
const prog = getProgress(diary);
const pass1Complete = prog.pass1Pct === 100;
const isViewOnly = role === 'pass1' && pass1Complete;
const tagHtml = diary.tags.map(t =>
'<span class="tag">' + t + '</span>'
).join('');
const actionBtn = isViewOnly
? '<button class="btn-view" onclick="openDiary(\'' + diary.id + '\')">View only</button>'
: '<button class="btn-open" onclick="openDiary(\'' + diary.id + '\')">Open diary</button>';
return `
<div class="card" id="card-${diary.id}">
<div class="card-top">
<div class="thumb" aria-hidden="true">
<div class="thumb-lines">
${Array(10).fill(0).map((_, i) =>
'<div class="thumb-line' + (i % 2 ? ' dim' : '') + '"></div>'
).join('')}
</div>
</div>
<div class="card-meta">
<div class="card-title">${diary.title}</div>
<div class="card-sub">${diary.subtitle}</div>
<div class="card-tags">${tagHtml}</div>
</div>
</div>
<div class="card-progress">
<div class="prog-row">
<span class="prog-label p1">Pass 1</span>
<div class="prog-track">
<div class="prog-fill p1" style="width:${prog.pass1Pct}%"></div>
</div>
<span class="prog-pct p1">${prog.pass1Pct}%</span>
</div>
<div class="prog-row">
<span class="prog-label p2">Pass 2</span>
<div class="prog-track">
<div class="prog-fill p2" style="width:${prog.pass2Pct}%"></div>
</div>
<span class="prog-pct p2">${prog.pass2Pct}%</span>
</div>
</div>
<div class="card-action">${actionBtn}</div>
</div>
`;
}
PART 5 — Open diary
javascriptfunction openDiary(diaryId) {
const diary = DIARIES.find(d => d.id === diaryId);
if (!diary) return;
window.location.href = diary.viewerUrl;
}
PART 6 — Init
javascriptfunction init() {
const role = getCurrentRole();
document.getElementById('role-badge').textContent =
role === 'pass2' ? 'Pass 2 — Senior Editor' : 'Pass 1 — Volunteer';
const container = document.getElementById('cards');
container.innerHTML = DIARIES.map(renderCard).join('');
}
document.addEventListener('DOMContentLoaded', init);
PART 7 — Full HTML file
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>P44 OCR — War Diary Transcription</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #111120;
font-family: 'Georgia', 'Times New Roman', serif;
color: #ddd4bb;
min-height: 100vh;
}
#topbar {
background: #16213e;
border-bottom: 1px solid #2e2e4e;
padding: 10px 24px;
display: flex;
align-items: center;
gap: 12px;
}
#topbar h1 {
font-size: 13px;
letter-spacing: .12em;
color: #c8a84b;
text-transform: uppercase;
}
#role-badge {
font-size: 9px;
padding: 2px 8px;
border: 1px solid #8a6f2e;
border-radius: 3px;
color: #8a6f2e;
letter-spacing: .06em;
margin-left: auto;
}
#content {
max-width: 720px;
margin: 0 auto;
padding: 28px 24px;
}
.section-label {
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: #8a6f2e;
margin-bottom: 16px;
}
#cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
background: #16213e;
border: 0.5px solid #2e2e4e;
border-radius: 6px;
padding: 16px 18px;
cursor: pointer;
transition: border-color .15s;
}
.card:hover { border-color: #c8a84b; }
.card-top {
display: flex;
align-items: flex-start;
gap: 14px;
}
.thumb {
width: 56px;
height: 72px;
background: #0f2a0f;
border: 0.5px solid #2e4e2e;
border-radius: 3px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.thumb-lines { width: 40px; }
.thumb-line {
height: 2px;
background: #2e4e2e;
border-radius: 1px;
margin-bottom: 3px;
}
.thumb-line.dim { background: #1e3e1e; }
.card-meta { flex: 1; min-width: 0; }
.card-title {
font-size: 13px;
color: #e8d9b0;
letter-spacing: .03em;
margin-bottom: 4px;
}
.card-sub {
font-size: 10px;
color: #8a6f2e;
letter-spacing: .05em;
margin-bottom: 10px;
}
.card-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag {
font-size: 9px;
padding: 2px 7px;
border-radius: 3px;
letter-spacing: .05em;
background: #0f1a3e;
color: #6a8fd8;
border: 0.5px solid #2e3e6e;
}
.card-progress {
margin-top: 14px;
border-top: 0.5px solid #2e2e4e;
padding-top: 12px;
}
.prog-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 7px;
}
.prog-row:last-child { margin-bottom: 0; }
.prog-label {
font-size: 9px;
letter-spacing: .08em;
text-transform: uppercase;
width: 52px;
flex-shrink: 0;
}
.prog-label.p1 { color: #c8a84b; }
.prog-label.p2 { color: #6a8fd8; }
.prog-track {
flex: 1;
height: 5px;
background: #0f1a0f;
border-radius: 3px;
overflow: hidden;
}
.prog-fill { height: 100%; border-radius: 3px; transition: width .3s ease; }
.prog-fill.p1 { background: #c8a84b; }
.prog-fill.p2 { background: #378ADD; }
.prog-pct {
font-size: 10px;
width: 32px;
text-align: right;
flex-shrink: 0;
}
.prog-pct.p1 { color: #c8a84b; }
.prog-pct.p2 { color: #6a8fd8; }
.card-action { margin-top: 12px; }
.btn-open, .btn-view {
padding: 4px 14px;
border-radius: 3px;
font-size: 10px;
font-family: 'Georgia', serif;
letter-spacing: .05em;
cursor: pointer;
background: transparent;
}
.btn-open {
border: 1px solid #c8a84b;
color: #c8a84b;
}
.btn-open:hover { background: rgba(200,168,75,.08); }
.btn-view {
border: 1px solid #5f5e5a;
color: #888780;
}
.btn-view:hover { background: rgba(136,135,128,.08); }
#footer {
text-align: center;
padding: 20px;
font-size: 10px;
color: #5f5e5a;
letter-spacing: .06em;
}
</style>
</head>
<body>
<div id="topbar">
<h1>⚜ P44 War Diary Transcription</h1>
<span id="role-badge">Pass 1 — Volunteer</span>
</div>
<div id="content">
<div class="section-label">War diaries available for transcription</div>
<div id="cards"></div>
</div>
<div id="footer">Project '44 — Canadian Research and Mapping Association</div>
<script>
const DIARIES = [
{
id: 'calgary-highlanders',
title: 'Calgary Highlanders',
subtitle: 'War Diary — September 1944',
tags: ['Infantry', 'Sep 1944', '343 pages'],
totalPages: 343,
viewerUrl: 'p44-ocr-viewer.html',
flagsKey: 'p44-flags',
pass2FlagsKey: 'p44-pass2-flags',
},
{
id: 'black-watch',
title: 'Black Watch (RHR) of Canada',
subtitle: 'War Diary — September 1944',
tags: ['Infantry', 'Sep 1944', '211 pages'],
totalPages: 211,
viewerUrl: 'p44-ocr-viewer.html',
flagsKey: 'p44-flags',
pass2FlagsKey: 'p44-pass2-flags',
},
{
id: '5cib',
title: '5th Canadian Infantry Brigade',
subtitle: 'War Diary — September 1944',
tags: ['Brigade HQ', 'Sep 1944', '178 pages'],
totalPages: 178,
viewerUrl: 'p44-ocr-viewer.html',
flagsKey: 'p44-flags',
pass2FlagsKey: 'p44-pass2-flags',
},
];
function getCurrentRole() {
return localStorage.getItem('p44-role') || 'pass1';
}
function getProgress(diary) {
let pass1Done = 0, pass2Done = 0;
try {
const flags = JSON.parse(localStorage.getItem(diary.flagsKey) || '{}');
pass1Done = Object.values(flags).filter(f => f === 'done').length;
} catch (_) {}
try {
const pass2Flags = JSON.parse(localStorage.getItem(diary.pass2FlagsKey) || '{}');
pass2Done = Object.values(pass2Flags).filter(f => f === 'done').length;
} catch (_) {}
return {
pass1Pct: Math.round((pass1Done / diary.totalPages) * 100),
pass2Pct: Math.round((pass2Done / diary.totalPages) * 100),
};
}
function openDiary(diaryId) {
const diary = DIARIES.find(d => d.id === diaryId);
if (diary) window.location.href = diary.viewerUrl;
}
function renderCard(diary) {
const role = getCurrentRole();
const prog = getProgress(diary);
const isViewOnly = role === 'pass1' && prog.pass1Pct === 100;
const tagHtml = diary.tags.map(t => '<span class="tag">' + t + '</span>').join('');
const thumbLines = Array(10).fill(0).map((_, i) =>
'<div class="thumb-line' + (i % 2 ? ' dim' : '') + '"></div>'
).join('');
const actionBtn = isViewOnly
? '<button class="btn-view" onclick="openDiary(\'' + diary.id + '\')">View only</button>'
: '<button class="btn-open" onclick="openDiary(\'' + diary.id + '\')">Open diary</button>';
return '<div class="card">' +
'<div class="card-top">' +
'<div class="thumb" aria-hidden="true"><div class="thumb-lines">' + thumbLines + '</div></div>' +
'<div class="card-meta">' +
'<div class="card-title">' + diary.title + '</div>' +
'<div class="card-sub">' + diary.subtitle + '</div>' +
'<div class="card-tags">' + tagHtml + '</div>' +
'</div>' +
'</div>' +
'<div class="card-progress">' +
'<div class="prog-row">' +
'<span class="prog-label p1">Pass 1</span>' +
'<div class="prog-track"><div class="prog-fill p1" style="width:' + prog.pass1Pct + '%"></div></div>' +
'<span class="prog-pct p1">' + prog.pass1Pct + '%</span>' +
'</div>' +
'<div class="prog-row">' +
'<span class="prog-label p2">Pass 2</span>' +
'<div class="prog-track"><div class="prog-fill p2" style="width:' + prog.pass2Pct + '%"></div></div>' +
'<span class="prog-pct p2">' + prog.pass2Pct + '%</span>' +
'</div>' +
'</div>' +
'<div class="card-action">' + actionBtn + '</div>' +
'</div>';
}
function init() {
const role = getCurrentRole();
document.getElementById('role-badge').textContent =
role === 'pass2' ? 'Pass 2 — Senior Editor' : 'Pass 1 — Volunteer';
document.getElementById('cards').innerHTML = DIARIES.map(renderCard).join('');
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
VERIFICATION CHECKLIST:
Page loads at http://localhost:XXXX/p44-landing.html
Three diary cards visible with correct titles and tags
Role badge in top right reflects p44-role from localStorage
Pass 1 progress bar reads from p44-flags — counts pages with flag done
Pass 2 progress bar reads from p44-pass2-flags — counts pages with flag done
Diary with Pass 1 at 100% shows "View only" button for Pass 1 role
All other diaries show "Open diary" button
Clicking "Open diary" or "View only" navigates to p44-ocr-viewer.html
Pass 2 role always sees "Open diary" on all cards regardless of progress
Footer shows CRMA credit

441
prompts/ocr-viewer-audit.md Normal file
View File

@@ -0,0 +1,441 @@
# P44 OCR Viewer — Full Rebuild Audit
*Combined: Copilot codebase audit + Nathan/Claude session history audit*
*Date: May 2026 — Pre-rebuild reference document*
---
## Purpose of this document
This document captures everything known about the current `p44-ocr-viewer` codebase — what works, what is broken, what is dead, and critically **why** things broke — so the rebuild starts from a complete picture rather than repeating the same mistakes.
---
## Part 1 — Copilot Codebase Audit
### 1. Feature Inventory
**PDF Rendering and Page Navigation**
Functions: `loadPDFBuffer()`, `renderCurrentPage()`, `buildPDFWordOverlay()`, `makeWordBox()`, `goToPage()`, `_pendingPage` queue.
Status: Working. DPR scaling, viewport fitting, zoom multiplier, and pending-page queue are all correct. `goToPage()` guards out-of-range values, debounces re-renders, and saves edits before navigating.
---
**Ctrl+Scroll Zoom on the Left Panel**
Functions: `wheel` event on `imagePanelBody`, `updateZoomLabel()`, `btnZoomReset` click handler.
Status: **Broken.** The handler has no `e.ctrlKey` check. It calls `e.preventDefault()` unconditionally on every wheel event when a PDF is loaded. In PDF mode, the user cannot scroll the left panel with the mouse wheel at all — every wheel movement zooms instead of scrolls. Sync-scroll from the left panel via wheel is also broken in PDF mode as a result.
---
**Synchronized Scrolling**
Functions: `onImgScroll()`, `onTxtScroll()`, `_syncScrolling` / `_syncActive` flags, `btnSyncScroll` click handler.
Status: Partially working. The proportional scroll logic and rAF anti-loop are correct. Toggle button works. However because the left-panel wheel handler kills normal scroll-via-wheel in PDF mode, users can only trigger left→right sync via the scrollbar. Right→left sync works normally.
---
**OCR Text Loading and Per-Page Rendering**
Functions: `loadOCRText()`, `parseOCRByPage()`, `showOCRPage()`, `escH()`.
Status: Working. `parseOCRByPage()` splits on `## Page N` headings into `state.ocrPages`. `showOCRPage()` reads a single page, checks for corrections, renders as `<pre>`. Startup race (OCR arrives before PDF) is handled correctly.
---
**Confidence Scoring Display (Per-Page)**
Functions: `buildConfidenceIndicatorHTML()`, `updateNavConfidenceColour()`, `confTier()`, `confTierLabel()`, `loadConfidenceOnStartup()`.
Status: Working. Loads from `REAL_CONFIDENCE_PATH` on startup. Per-page indicator bar with score, tier colour, uncertain word pills, and notes is injected above OCR text. Nav input/buttons change border colour to match tier.
---
**Confidence Summary Panel**
Functions: `buildConfidenceSummaryHTML()`, `btn-conf-summary` click handler, `goToPage(0)` special case.
Status: Working. Shows High/Med/Low counts, average score, distribution bar, and 10 lowest-scoring pages as clickable pills.
---
**Flag System (Pass 1 Flags)**
Functions: `setFlag()`, `updateFlagButtons()`, `updateFlagsSummary()`, `saveFlags()`, `loadFlagsOnStartup()`, `clearPass1Queue()`.
Status: Working. Pass 1 flags stored in `state.flags`. POSTed to `/save-flags` with localStorage fallback. Loaded from `/flags.json` on startup with localStorage fallback.
Note: Pass 2 flags stored only in `pass2Flags` module variable and saved only to `localStorage('p44_pass2_flags')` — never to the server. Asymmetry with Pass 1.
---
**Inline Text Editing — Pass 1**
Functions: `enterEditMode()`, `exitEditMode()`, `btnEdit` click handler, `updateCorrectionIndicator()`, `postCorrection()`, `loadCorrectionsOnStartup()`.
Status: Working. `enterEditMode()` snapshots raw OCR into `corrections[pageNum].original` on first open. `exitEditMode()` saves to `corrections[pageNum].pass1`. Uses `<textarea id="text-editor">``#text-output` is hidden during editing. Navigating away auto-saves.
---
**Inline Text Editing — Pass 2**
Functions: Same as Pass 1 with `pass2` key, `showPass2RevertBar()`.
Status: Working with a minor flow issue. `showOCRPage()` unconditionally calls `enterEditMode()` at the end. Pages with no OCR content return early before reaching that call — those pages arrive with edit mode OFF in Pass 2 role, inconsistently.
---
**Role Switcher (Pass 1 / Pass 2)**
Functions: `applyRole()`, `roleSelect` change handler, `currentRole` module variable.
Status: Working. Saves role to `localStorage('p44_dev_role')` and restores on startup. Changes flag button labels, shows/hides Pass 2 banner, updates nav button behaviour.
---
**Pass 2 Banner and Queue**
Functions: `updatePass2Banner()`, `updatePass2NavButtons()`, `getVerifiedPages()`.
Status: Working. Banner shows count of Pass 1-verified pages ready for review. Nav buttons in Pass 2 mode skip to next/previous verified page.
---
**Pass 2 Change Summary Panel**
Status: **Not implemented.** No change-summary panel exists in HTML or JS. No function builds a diff or change log. Only `#pass2-revert-bar` exists with two action buttons — no summary of what was changed.
---
**Revert to Pass 1 / Revert to Original**
Functions: `showPass2RevertBar()`. Two buttons injected dynamically into `#pass2-revert-bar`.
Status: Working in Pass 2 mode when page has a `pass1` correction. "Revert to Pass 1" sets `textEditor.value` to `corr.pass1`. "Revert to Original OCR" requires typing `REVERT`, then clears `state.corrections[pageNum]`. Neither revert auto-saves — user must navigate away or toggle Edit to persist.
---
**Correction Storage Model**
Current structure:
```javascript
state.corrections = {
[pageNum]: {
original: string, // raw OCR snapshot, taken once on first edit-open
pass1: string, // Pass 1 editor's text (absent if unchanged from original)
pass2: string, // Pass 2 editor's text (absent if unchanged from pass1/original)
}
}
```
Status: Consistent across all functions. `showOCRPage()` reads `pass2 || pass1 || original || rawContent` (Pass 2) or `pass1 || original || rawContent` (Pass 1). `exitEditMode()` prunes entry entirely when neither `pass1` nor `pass2` is present. `postCorrection()` sends `{ page: N, correction: corrObj }`.
Migration: `loadCorrectionsOnStartup()` migrates old flat-string corrections to `{ pass1: oldString }`. Migration runs on startup only and does not write migrated data back to the server.
---
**Keyboard Navigation**
Function: `document.addEventListener('keydown', ...)`.
Status: **Broken on Escape.** `ArrowRight/l`, `ArrowLeft/j`, `v/r/e` work. Escape handler references `zoomLens` — a variable never declared anywhere. Throws `ReferenceError: zoomLens is not defined` every time Escape is pressed. Click-to-zoom (Phase 3) was never implemented so the element was never created.
---
**Clear All Data Button**
Function: `clearAllLocalData()`.
Status: Working for local state. Wipes all five localStorage keys, resets state, resets role to Pass 1. Calls `fetch('/clear-all-data', { method: 'POST' })` but this endpoint does not exist in `launch_viewer.py`. Failure is silently swallowed. Server-side `flags.json` and `corrections.json` are NOT cleared.
---
**Sample Data / Load Sample Button**
Functions: `loadSampleData()`, `buildSampleImageDataURL()`, `SAMPLE_TEXT`, `SAMPLE_JSON`.
Status: Working. Status bar message still references "bidirectional highlighting" — a removed feature.
---
**Calgary Highlanders Auto-Load**
Function: `loadCalgaryHighlanders()`, `DOMContentLoaded` handler.
Status: Working when served via `launch_viewer.py`.
---
### 2. Dead Code
| Item | Location | Reason |
|------|----------|--------|
| `state.scale` (= 1.0) | State object | No scale dropdown exists in HTML. Never written after init, never read in any calculation. Vestigial. |
| `state.ocrRawFull` | `loadOCRText()` | Set for plain-text/sample mode but never read again anywhere. |
| `const base = ...` in `enterEditMode()` | Line ~1108 | Declared but never referenced. Orphan from a refactor. |
| `#scale-select` CSS block | CSS | No element with `id="scale-select"` exists in HTML. |
| `#text-output[contenteditable="true"]` CSS rule | CSS | Editor uses `<textarea id="text-editor">` not contenteditable on `#text-output`. Never fires. |
| `zoomLens` reference | JS line ~1200 | Used in Escape handler but never declared. Causes runtime ReferenceError. |
| Stale status message | `loadSampleData()` | References removed bidirectional highlighting feature. |
| `p44_diff_legend_dismissed` in `clearAllLocalData()` | JS | Key is never written anywhere in the codebase. Only cleared. |
---
### 3. State Audit
| Property | Type | Status |
|----------|------|--------|
| `mode` | `'pdf' \| 'image' \| 'none'` | Active |
| `pdfDoc` | PDF.js document or null | Active |
| `currentPage` | number | Active |
| `totalPages` | number | Active |
| `scale` | number (1.0, never changes) | **Dead** — initialized but never written or read in any live code path |
| `zoomScale` | number | Active |
| `wordData` | array | Active |
| `imageNaturalW` | number | Active (image mode only) |
| `imageNaturalH` | number | Active (image mode only) |
| `rendering` | boolean | Active |
| `ocrPages` | `{ [pageNum]: string }` | Active |
| `ocrRawFull` | string | **Dead** — written in plain-text path, never read again |
| `lastRenderTime` | number | Active |
| `flags` | object | Active |
| `corrections` | object | Active |
| `editMode` | boolean | Active |
| `confidenceData` | object | Active |
---
### 4. localStorage Audit
| Key | Written by | Read by | Notes |
|-----|-----------|---------|-------|
| `p44-flags` | `saveFlags()` | `loadFlagsOnStartup()` | Fallback when server unavailable |
| `p44-corrections` | `postCorrection()` fallback | `loadCorrectionsOnStartup()` | Also written during startup migration |
| `p44_pass2_flags` | `savePass2Flags()` | `loadPass2FlagsOnStartup()` | **Only storage for Pass 2 flags — no server endpoint** |
| `p44_dev_role` | `applyRole()` | Startup IIFE | Persists role across refreshes |
| `p44_diff_legend_dismissed` | **Never written** | **Never read** | Only appears in `clearAllLocalData()` as a `removeItem()` call. Feature was removed without cleanup. |
---
### 5. Known Broken or Incomplete
1. **`zoomLens` ReferenceError (hard crash on Escape).** `zoomLens` is never declared. Every Escape keypress throws `ReferenceError`.
2. **Wheel zoom intercepts all scroll, not Ctrl+scroll.** No `e.ctrlKey` check. Left panel cannot be scrolled with mouse wheel in PDF mode.
3. **`updateFlagsSummary()` silently ignores Pass 2 flags.** Footer counts only `state.flags` (Pass 1). Pass 2 flags never included in summary even when in Pass 2 mode.
4. **`savePass2Flags()` never saves to server.** Pass 2 flags are lost if browser storage is cleared or a different machine is used.
5. **`/clear-all-data` POST endpoint does not exist in `launch_viewer.py`.** Server files not cleared when user clicks "Clear all data".
6. **`state.ocrRawFull` is populated but never used.** Latent issue if plain-text path is extended.
7. **`p44_diff_legend_dismissed` cleared but never written.** Dead localStorage reference.
8. **`showPass2RevertBar()` adds fresh event listeners on each call.** Low risk but possible accumulation in edge cases.
9. **Corrections migration does not update server file.** `corrections.json` on disk stays in old format after migration. Re-runs from stale file on every server restart.
10. **Stale status message in `loadSampleData()`.** References removed bidirectional highlighting feature.
---
### 6. Data Model Audit — `state.corrections`
```javascript
state.corrections = {
1: {
original: "raw OCR text at moment editor first opened",
pass1: "Pass 1 volunteer's saved text", // absent if unchanged from original
pass2: "Pass 2 editor's saved text", // absent if unchanged from pass1/original
}
}
```
Consistent across all read/write sites. `original` is preserved in memory but pruned from persisted object if both `pass1` and `pass2` are absent. So `original` is a transient in-session anchor, not guaranteed to be in stored JSON.
---
## Part 2 — Nathan/Claude Session History Audit
*What actually happened during development — the decisions made, why things broke, and what to do differently in the rebuild.*
---
### The fundamental architectural mistake
**`textOutput` was used as display container, edit surface, and diff viewer simultaneously.**
These three roles have incompatible requirements:
- Display container needs rendered HTML (`innerHTML`)
- Edit surface needs plain text capture (`innerText`)
- Diff viewer needs highlight spans (`innerHTML` with CSS classes)
`innerHTML``innerText` on a div containing tables, headings, and diff spans is unpredictable. Tables become tab-separated text. Headings lose formatting. Diff spans get their text content extracted without markup. This single decision caused the majority of bugs encountered:
- HTML tags appearing as literal text in corrections
- Table structure being lost when pages were edited
- Pass 1 edits bleeding onto Pass 2 pages
- False-positive diffs from whitespace mismatches
- Confidence indicator text being captured into corrections
The fix — a separate `<textarea id="text-editor">` that is shown/hidden while `#text-output` is hidden/shown — was the correct architectural decision but took too long to reach because symptoms were being patched instead of root causes diagnosed.
**In the rebuild: display and editing must be separate elements from day one.**
---
### The markdown/HTML rendering dead end
olmOCR was asked to output "clean markdown" but war diary pages are tabular. The model correctly used HTML tables because markdown tables cannot handle multi-line cells with `<br>` tags. This produced inconsistent output — some pages as prose, some as HTML tables, some mixed.
A hand-rolled `convertMarkdown()` parser was built to handle this. It required repeated patching:
- First version missed indented HTML blocks (`<tr>` lines starting with spaces)
- Second version added depth tracking for HTML blocks
- Third version was still producing inconsistent output
Six separate debugging sessions were spent on markdown rendering issues that had nothing to do with the actual feature being worked on at the time.
**In the rebuild: OCR prompt outputs plain text only. No markdown parser needed. `<pre>` display of plain text. Zero rendering complexity.**
---
### The diff system — built and abandoned three times
**Attempt 1:** Text matching — highlighted all instances of the same word. "The" highlighted every "The" on the page. Fundamental design flaw — no unique link between words.
**Attempt 2:** `contentEditable` + `innerText` diff — caused cross-page bleed (page N edits appearing on page N+1) and false positives from HTML whitespace.
**Attempt 3:** `stripHTML()` on both sides — `stripHTML(markdown)` and `stripHTML(innerText)` produce different whitespace patterns for the same content even when text is identical. False positives on every page.
All three attempts failed for the same root reason: the input to the diff was contaminated HTML, not clean text. With plain text OCR this is now permanently solved. A simple word-array diff will work cleanly.
**In the rebuild: diff runs on plain text strings only. No stripHTML needed. Build it once.**
---
### Features added before foundations were stable
The build sequence was:
1. PDF rendering ✓
2. OCR loading ✓
3. Sync scroll ✓ (partially broken by zoom later)
4. Zoom — broke scroll
5. Flags ✓
6. Edit mode — introduced `contentEditable` architectural flaw
7. Confidence display ✓
8. Pass 2 mode + diff — built on broken edit foundation
9. Role switcher ✓
10. Change summary — never actually implemented despite multiple prompts
11. Revert system — partially implemented
Each feature after #6 was built on top of a broken edit/save loop. Every new feature added complexity that made the underlying bugs harder to isolate.
**In the rebuild: get edit/save 100% correct before building Pass 2 mode. Get Pass 2 mode working before building change summary. No feature ships until the feature before it is solid.**
---
### Copilot context loss in large files
When everything lived in one 2,176-line HTML file, Copilot would:
- Apply changes in the wrong function (similar names, wrong location)
- Miss functions entirely (outside its context window)
- Partially implement features (add HTML but forget JS, or vice versa)
- Introduce new bugs while fixing old ones (touching code it wasn't asked to touch)
Splitting into HTML/CSS/JS reduced this significantly. The remaining issues were smaller in scope and easier to diagnose.
**In the rebuild: three files from day one. Each prompt specifies exactly which file and which function. No prompt touches more than one file at a time unless unavoidable.**
---
### The OCR pipeline decision history
Original prompt: "Output clean markdown." → Inconsistent markdown+HTML output → hand-rolled parser → cascading failures.
Revised prompt: "Plain text only, no tables, transcribe war diary entries as prose." → Model made editorial decisions about what was "diary content" → omitted text that humans should see.
Final prompt: "Transcribe ALL text visible on the page. Do not decide what is important." → Correct.
**In the rebuild: use the final prompt. Never ask the model to interpret or select content.**
---
### What was never built but was promised
These features were designed in full detail during the session but never actually implemented:
1. **Pass 2 change summary panel**`buildChangeSummary()` function spec written, HTML/CSS specified, Copilot prompt written. Never confirmed working in a screenshot.
2. **`/clear-all-data` server endpoint** — specified for `launch_viewer.py`. Copilot prompt written. Endpoint confirmed missing by audit.
3. **Pass 2 flags saved to server** — asymmetry identified but never fixed.
4. **`/clear-all-data` in `launch_viewer.py`** — POST handler specified but never confirmed added.
5. **Scroll-to-region approximate highlighting** — discussed as a replacement for word-level cross-highlighting. Never implemented. Deprioritized in favour of other features.
---
### Persistent user frustrations (verbatim from session)
- *"stuff keeps breaking"* — after every Copilot fix session
- *"are we just applying bandaids?"* — after the fourth markdown patch
- *"it broke the output"* — diff showing tags as literal text
- *"now its marking stuff that wasnt changed as changed"* — false-positive diff on every word
- *"still getting the same old errors"* — after file split, still hitting edit/save bugs
- *"I am still getting the page numbers bleeding into the output"* — `## Page N` appearing in editor
---
## Part 3 — Rebuild Specification
### What to keep from the old codebase (working, clean, reuse as-is)
- `loadPDFBuffer()` and `renderCurrentPage()` — solid, no changes needed
- `buildPDFWordOverlay()` and `makeWordBox()` — solid
- `parseOCRByPage()` — clean, keep exactly as-is
- `loadConfidenceOnStartup()` and all confidence display functions — working
- `saveFlags()` / `loadFlagsOnStartup()` / `setFlag()` / `updateFlagButtons()` — working
- `applyRole()` / `getVerifiedPages()` / `updatePass2Banner()` — working
- `goToPage()` — working, keep the edit-save guard
- Sync scroll logic — working (fix wheel zoom first)
- CSS variables and visual design — keep the aesthetic
---
### What to rebuild from scratch
**Edit system — new architecture:**
```
#text-output — display only, never editable, always innerHTML
#text-editor — <textarea>, plain text only, shown only in edit mode
pre-populated from corrections.pass1 or corrections.original
hidden when not editing
```
No `contentEditable` anywhere. Ever.
**Correction data model — keep the object structure, rebuild the plumbing:**
```javascript
corrections[pageNum] = {
original: string, // snapshotted once on first edit-open
pass1: string, // saved by Pass 1 on exit
pass2: string, // saved by Pass 2 on exit
}
```
**Change summary — build it once, correctly:**
- Plain text word-array diff: `original.split(/\s+/)` vs `pass1.split(/\s+/)`
- Output a simple list: changed / added / deleted words
- Read-only panel above textarea in Pass 2 mode
- No inline highlighting — just a clean change log
**`launch_viewer.py` — add missing endpoints:**
- `/save-corrections` — write `corrections.json`
- `/save-flags` — write `flags.json` (already exists, verify)
- `/save-pass2-flags` — write `pass2_flags.json` (new)
- `/clear-all-data` — delete all three JSON files
---
### Build order for the rebuild
1. HTML/CSS shell — layout only, no JS
2. PDF loading and page navigation
3. OCR loading and plain text display (`<pre>`)
4. Confidence display
5. Flags (Pass 1)
6. **Edit mode — Pass 1 only, textarea architecture. Do not proceed until 100% correct.**
7. Corrections storage and server persistence
8. Role switcher and Pass 2 mode
9. Pass 2 change summary panel
10. Revert system
11. Sync scroll (fix wheel zoom at same time)
12. Keyboard navigation (fix `zoomLens` reference)
13. Clear all data (with working server endpoint)
**Each step must be confirmed working before the next step starts.**
---
### localStorage keys for the rebuild
| Key | Purpose | Server backup |
|-----|---------|--------------|
| `p44-flags` | Pass 1 flags | `flags.json` |
| `p44-pass2-flags` | Pass 2 flags | `pass2_flags.json` |
| `p44-corrections` | All corrections (object model) | `corrections.json` |
| `p44-role` | Current role | None needed |
Remove: `p44_diff_legend_dismissed` (feature gone), `p44_dev_role` (rename to `p44-role` for consistency).
---
*End of audit. This document is the complete reference for the rebuild.*

View File

@@ -0,0 +1,262 @@
P44 OCR Viewer — Remove diff system, add Pass 2 split panel view
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Complete removal of all diff logic, replace with a simple toggleable split panel for Pass 2.
PART 1 — Remove diff entirely
HTML — remove these elements:
<button id="btn-diff" ...>⇄ Diff</button>
<div id="diff-toolbar" ...></div>
<div id="diff-reference" ...></div>
<div id="editor-bottom"> wrapper — move its children (#line-numbers and #text-editor) back up directly inside #editor-wrap
CSS — remove all rules for:
#diff-toolbar
#diff-toolbar .diff-legend
#diff-toolbar .diff-count
.diff-swatch
.diff-swatch-del
.diff-swatch-ins
.diff-del
.diff-ins
#diff-reference
#diff-reference-label
#editor-bottom
JS — remove these functions entirely:
buildInlineDiff()
renderInlineDiff()
showDiffToolbar()
applyDiffToDisplay()
applyDiffToReference()
toggleDiff()
JS — remove these DOM refs:
const btnDiff
const diffToolbar
const diffReference
const editorBottom
JS — remove state.diffMode from the state object.
JS — in showOCRPage() remove the entire block added for diff auto-enable and toolbar display — the block starting with const corrForPage = state.corrections && state.corrections[pageNum] down to the closing } of the Pass 2 auto-diff block. Restore the original Pass 2 block at the bottom of showOCRPage():
javascript if (currentRole === 'pass2') {
if (!state.editMode) enterEditMode();
updatePass2NavButtons();
}
JS — in enterEditMode() remove the line applyDiffToReference(pageNum);
JS — in exitEditMode() remove:
diffReference.style.display = 'none';
if (state.diffMode) applyDiffToDisplay(pageNum);
JS — in goToPage() remove the four lines:
javascript state.diffMode = false;
btnDiff.classList.remove('active');
diffToolbar.style.display = 'none';
diffReference.style.display = 'none';
PART 2 — HTML (p44-ocr-viewer.html)
In the right panel header, add a toggle button immediately before #btn-edit:
html<button id="btn-split" class="tb-btn" title="Toggle split view — shows original OCR above for reference (Pass 2 only)" style="display:none;">⊟ Split</button>
Inside #text-panel-body, immediately before #text-output, add the original OCR reference pane:
html<div id="split-pane" style="display:none;">
<div id="split-pane-header">ORIGINAL OCR</div>
<div id="split-ocr-wrap">
<div id="split-line-numbers"></div>
<div id="split-ocr-output"></div>
</div>
</div>
<div id="split-divider" style="display:none;"></div>
PART 3 — CSS (p44-ocr-viewer.css)
Add at the end of the file:
css/* ── Split panel layout ───────────────────────────────────────── */
#text-panel-body {
display: flex;
flex-direction: column;
overflow: hidden;
}
#split-pane {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
#split-pane-header {
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--gold-dim);
padding: 5px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--surface);
}
#split-ocr-wrap {
display: flex;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 0;
}
#split-line-numbers {
width: 48px;
flex-shrink: 0;
padding: 0 8px 0 12px;
text-align: right;
font-size: 11px;
line-height: 1.75;
color: var(--gold-dim);
font-family: 'Courier New', monospace;
user-select: none;
border-right: 1px solid var(--border);
margin-right: 12px;
}
#split-ocr-output {
flex: 1;
min-width: 0;
font-size: 12.5px;
line-height: 1.75;
color: var(--text);
white-space: pre-wrap;
font-family: 'Georgia', 'Times New Roman', serif;
padding-right: 16px;
}
#split-divider {
height: 4px;
background: var(--border);
flex-shrink: 0;
cursor: row-resize;
border-top: 1px solid var(--gold-dim);
border-bottom: 1px solid var(--gold-dim);
}
/* When split is active, text-output and editor-wrap each take half */
#text-panel-body.split-active #text-output,
#text-panel-body.split-active #editor-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
}
#text-panel-body.split-active {
display: flex;
flex-direction: column;
}
PART 4 — JS (p44-ocr-viewer.js)
4a — DOM refs
Add:
javascriptconst btnSplit = document.getElementById('btn-split');
const splitPane = document.getElementById('split-pane');
const splitDivider = document.getElementById('split-divider');
const splitOcrOutput = document.getElementById('split-ocr-output');
const splitLineNums = document.getElementById('split-line-numbers');
4b — State flag
Add to the state object:
javascriptsplitMode: false,
4c — Line number generator for split pane
Add this new function above showOCRPage():
javascriptfunction buildSplitLineNumbers(text) {
const lines = (text || '').split('\n');
return lines.map((_, i) => i + 1).join('\n');
}
4d — showSplitPane(pageNum)
Add this new function:
javascriptfunction showSplitPane(pageNum) {
const corr = state.corrections && state.corrections[pageNum];
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
splitOcrOutput.textContent = originalText;
splitLineNums.textContent = buildSplitLineNumbers(originalText);
splitPane.style.display = 'flex';
splitDivider.style.display = 'block';
textPanelBody.classList.add('split-active');
}
4e — hideSplitPane()
Add this new function:
javascriptfunction hideSplitPane() {
splitPane.style.display = 'none';
splitDivider.style.display = 'none';
textPanelBody.classList.remove('split-active');
}
4f — toggleSplit()
Add this new function:
javascriptfunction toggleSplit() {
state.splitMode = !state.splitMode;
btnSplit.classList.toggle('active', state.splitMode);
if (state.splitMode) {
showSplitPane(state.currentPage);
} else {
hideSplitPane();
}
}
Add the event listener immediately after:
javascriptbtnSplit.addEventListener('click', toggleSplit);
4g — applyRole()
In applyRole(), in the pass2 branch add:
javascript btnSplit.style.display = 'inline-block';
In the pass1 branch add:
javascript btnSplit.style.display = 'none';
state.splitMode = false;
hideSplitPane();
4h — goToPage()
At the start of goToPage(), after if (state.editMode) exitEditMode();, add:
javascript if (state.splitMode) showSplitPane(n);
This refreshes the split pane content for the new page without toggling it off.
4i — showOCRPage()
At the end of showOCRPage(), after updatePass2NavButtons(), add:
javascript if (state.splitMode) showSplitPane(pageNum);
VERIFICATION CHECKLIST:
Pass 1 role: Split button not visible
Pass 2 role: Split button visible in panel header
Clicking Split in Pass 2: right panel splits into original OCR on top and editor on bottom, both with line numbers, both scroll independently
Clicking Split again: collapses back to single panel, editor fills full height
Navigating to a new page while split is on: both panes update to the new page's content, split stays on
Switching from Pass 2 to Pass 1 while split is on: split closes, button hides
Original OCR pane shows raw unedited text — not the Pass 1 corrected version
Line numbers visible in both panes and match the text lines
You said: Review this code.p44-ocr-viewer.jsjsp44-ocr-viewer.csscssp44-ocr-viewer.html140 lineshtmlReview this code. I have asked copilot numerous times to fix a bug but it cant figure it outThe text is not aligning properly or showing the wrong line breaks. this page is unedited, so the OCR and the editable text should look and behave the same exact way:2:24 p.m.Claude responded: Let me read the current files first.Let me read the current files first.Viewed 3 filesViewed 3 filesFound it. The bug is on line 602:
javascriptif (currentRole === 'pass2') {
if (!state.editMode) enterEditMode();
Pass 2 auto-enters edit mode on every showOCRPage() call. That means the split pane's top half is showing the original OCR in a read-only div, but the bottom half is the textarea — and the textarea uses a completely different rendering path (wrap measurement, line numbers calculated differently) than the read-only display div. They look different because they literally ARE different elements with different sizing, padding, and line-break behaviour.
The line number mismatch and text wrap differences you're seeing are all caused by this — the two panes are never showing text the same way.
Here's the Copilot fix:
P44 OCR Viewer — Fix split pane text rendering mismatch
Scope: p44-ocr-viewer.js only. Two targeted changes.
Fix 1 — Remove Pass 2 auto-edit on showOCRPage()
Find in showOCRPage() at the bottom:
javascript if (currentRole === 'pass2') {
if (!state.editMode) enterEditMode();
updatePass2NavButtons();
if (state.splitMode) showSplitPane(pageNum);
}
Replace with:
javascript if (currentRole === 'pass2') {
updatePass2NavButtons();
if (state.splitMode) showSplitPane(pageNum);
}
Fix 2 — Make split pane top use identical rendering to #text-output
Find in showSplitPane():
javascript splitOcrOutput.textContent = originalText;
updateSplitLineNumbers(originalText);
Replace with:
javascript splitOcrOutput.innerHTML =
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;' +
'line-height:1.75;color:var(--text);padding:16px 20px;">' +
escH(originalText) + '</pre>';
updateSplitLineNumbers(originalText);

View File

@@ -0,0 +1,721 @@
# P44 OCR Viewer — Rebuild Prompts
*Reference: ocr-viewer-audit.md — read this before starting any step.*
*Each step must be confirmed working before the next step starts.*
*Three files: p44-ocr-viewer.html / p44-ocr-viewer.css / p44-ocr-viewer.js*
---
## Before you start
Read `ocr-viewer-audit.md` in full. It contains the complete audit of the previous codebase, the root causes of every bug we hit, and the rebuild specification. Every architectural decision in these prompts comes from that document.
Key rules that must never be violated in this rebuild:
- `#text-output` is display only — never set `contentEditable` on it
- `#text-editor` is the only edit surface — always a `<textarea>`, always plain text
- No markdown parser — OCR output is plain text, display with `<pre>`
- No confidence scoring — removed by design
- Each step confirmed working before the next begins
---
## Step 1 — HTML/CSS shell
**No JavaScript. Layout and visual design only.**
Create three empty files: `p44-ocr-viewer.html`, `p44-ocr-viewer.css`, `p44-ocr-viewer.js`
Build the complete HTML structure and CSS. Nothing should be functional.
**HTML structure required:**
```
#app (grid: toolbar / pass2-banner / panels / footer)
#toolbar
h1 "⚜ P44 OCR VIEWER"
#btn-load-ch button "▶ Calgary Highlanders — Sep 44"
PDF override: label.file-btn > input#input-pdf[type=file accept=.pdf]
OCR override: label.file-btn > input#input-text[type=file accept=.txt,.md]
JSON override: label.file-btn > input#input-json[type=file accept=.json]
#btn-example "Load sample"
#btn-sync-scroll "Sync Scroll" (class active by default)
role switcher: label "ROLE" + select#role-select
option value="pass1" "Pass 1 — Volunteer"
option value="pass2" "Pass 2 — Senior Editor"
#btn-clear-all "⚠ Clear all data" (red-tinted border and text)
#pass2-banner (display:none by default)
span#pass2-banner-text
button#btn-pass2-reset "↺ Reset Pass 2"
#panels (two-column flex)
Left panel div.panel
div.panel-header
span "Source Document — Page Scan"
span#zoom-label "100%"
button#btn-zoom-reset "1:1"
div.page-nav
button#btn-prev "◀" disabled
input#page-input type=number value=1 min=1
button#btn-next "▶" disabled
span#page-label "/ —"
div.panel-body#image-panel-body
div#image-placeholder
div#img-spinner style=display:none
div#img-ph-text "War diary scan loads automatically."
div#pdf-container style=display:none
canvas#pdf-canvas
div#overlay
Right panel div.panel
div.panel-header
span "OCR Text Output"
span#ocr-sync-label
div#correction-dot style=display:none title="This page has been edited"
button#btn-flag-verified class="tb-btn flag-btn" "✓ Verified"
button#btn-flag-review class="tb-btn flag-btn" "⚑ Review"
button#btn-flag-error class="tb-btn flag-btn" "✗ Error"
button#btn-flag-clear class="tb-btn flag-btn" "Clear"
button#btn-edit class="tb-btn" "Edit"
div.panel-body#text-panel-body
div#text-placeholder
div#txt-spinner style=display:none
div#txt-ph-text "OCR transcript loads automatically."
div#text-output style=display:none
textarea#text-editor style=display:none
#footer
span#status-msg "Ready."
span#flags-summary
span#kbd-hint "← → navigate v/r/e flag esc close"
```
**CSS variables — use exactly these:**
```css
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--gold: #c8a84b;
--gold-dim: #8a6f2e;
--text: #ddd4bb;
--text-dim: #7a7260;
--text-muted:#4a4538;
--border: #2e2e4e;
--green: #4caf70;
--green-bg: #1a3326;
--amber: #e8a838;
--amber-bg: #2e2010;
--red: #d05050;
--red-bg: #2e1a1a;
--toolbar-h: 52px;
--footer-h: 34px;
}
```
**CSS layout rules:**
- `#app`: CSS grid `grid-template-rows: var(--toolbar-h) auto 1fr var(--footer-h)`, `height: 100vh`, `overflow: hidden`
- `#panels`: flex row, each `.panel` takes `width: 50%`, `display: flex`, `flex-direction: column`, `overflow: hidden`
- `.panel-body`: `flex: 1`, `overflow-y: auto`, `overflow-x: hidden`
- `#pass2-banner`: `display: none`, slim flex row, `background: #1a1200`, `border-bottom: 1px solid var(--gold-dim)`, `padding: 6px 14px`, `font-size: 11px`, `color: var(--amber)`
- `#text-editor`: `display: none`, `width: 100%`, `height: 100%`, `resize: none`, `font-family: Georgia serif`, `font-size: 12.5px`, `line-height: 1.75`, `background: var(--surface2)`, `color: var(--text)`, `border: none`, `border-top: 1px solid var(--gold-dim)`, `padding: 16px 20px`, `box-sizing: border-box`, `outline: none`
- `#text-editor:focus`: `border-top-color: var(--gold)`
- `.tb-btn`: `padding: 3px 9px`, `background: transparent`, `border: 1px solid var(--gold-dim)`, `border-radius: 3px`, `color: var(--text-dim)`, `font-family: Georgia serif`, `font-size: 10px`, `cursor: pointer`, `letter-spacing: 0.04em`
- `.tb-btn:hover`: `border-color: var(--gold)`, `color: var(--gold)`
- `.tb-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
- `.file-btn`: same as `.tb-btn` but as a `<label>` with hidden `<input type="file">` inside
- `#btn-clear-all`: `border-color: #d05050`, `color: #d05050`
- `#btn-clear-all:hover`: `border-color: #e07070`, `color: #e07070`
- `.flag-btn.active`: `border-color: var(--gold)`, `color: var(--gold)`
- `#correction-dot`: `width: 8px`, `height: 8px`, `border-radius: 50%`, `background: var(--gold)`, `display: none`, `flex-shrink: 0`
- `.panel-header`: `display: flex`, `align-items: center`, `gap: 6px`, `padding: 0 10px`, `height: 38px`, `background: var(--surface)`, `border-bottom: 1px solid var(--border)`, `flex-shrink: 0`, `font-size: 9px`, `letter-spacing: 0.1em`, `color: var(--text-dim)`, `text-transform: uppercase`
- `#toolbar`: `display: flex`, `align-items: center`, `gap: 8px`, `padding: 0 12px`, `background: var(--surface)`, `border-bottom: 2px solid var(--gold-dim)`
- `#toolbar h1`: `font-size: 13px`, `font-weight: normal`, `letter-spacing: 0.1em`, `color: var(--gold)`, `white-space: nowrap`
- `.divider`: `width: 1px`, `height: 26px`, `background: var(--border)`, `flex-shrink: 0`
- `#footer`: `display: flex`, `align-items: center`, `justify-content: space-between`, `padding: 0 14px`, `background: var(--surface)`, `border-top: 1px solid var(--border)`, `font-size: 10px`, `color: var(--text-dim)`
- `#pdf-container`: `position: relative`, `display: inline-block`
- `#overlay`: `position: absolute`, `top: 0`, `left: 0`, `pointer-events: none`
- `.word-box`: `position: absolute`, `cursor: crosshair`, `border: 1px solid transparent`, `border-radius: 2px`
- `.word-box:hover, .word-box.highlight`: `background: rgba(200,168,75,0.18)`, `border-color: var(--gold-dim)`
- `.panel-placeholder`: `display: flex`, `flex-direction: column`, `align-items: center`, `justify-content: center`, `height: 100%`, `color: var(--text-dim)`, `font-size: 12px`, `text-align: center`, `gap: 10px`
- `.spinner`: `width: 24px`, `height: 24px`, `border: 2px solid var(--border)`, `border-top-color: var(--gold)`, `border-radius: 50%`, `animation: spin 0.8s linear infinite`
- `@keyframes spin`: `to { transform: rotate(360deg) }`
- `#page-input`: `width: 48px`, `text-align: center`, `background: var(--surface2)`, `border: 1px solid var(--border)`, `color: var(--text)`, `font-family: Georgia serif`, `font-size: 12px`, `padding: 2px 4px`, `border-radius: 3px`
- `select#role-select`: same styling as `.tb-btn` but as a select element, `background: var(--surface2)`
- `#ocr-sync-label`: `font-size: 9px`, `color: var(--gold-dim)`, `letter-spacing: 0.06em`
**Do not write any JavaScript yet.**
Confirm when complete by listing every `id` and every CSS class defined.
---
## Step 2 — PDF loading and page navigation
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
Add the following in this order:
1. PDF.js worker setup:
```javascript
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
```
2. Data paths:
```javascript
const REAL_PDF_PATH = './Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf';
const REAL_TEXT_PATH = './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md';
```
3. State object — only these properties to start:
```javascript
const state = {
mode: 'none', // 'pdf' | 'image' | 'none'
pdfDoc: null,
currentPage: 1,
totalPages: 0,
zoomScale: 1.0, // Ctrl+scroll zoom multiplier (0.54.0)
wordData: [],
rendering: false,
lastRenderTime: 0,
ocrPages: {}, // { [pageNum]: string } — populated in Step 3
flags: {}, // populated in Step 5
corrections: {}, // populated in Step 6
editMode: false, // populated in Step 6
};
```
4. All DOM refs (getElementById for every id in the HTML)
5. `escH(s)` utility function
6. `setStatus(msg, warn)` utility function
7. `showImgSpinner(on)`, `showTxtSpinner(on)`, `showImgPlaceholder(html)`, `showTxtPlaceholder(html)` utility functions
8. `loadPDFBuffer(arrayBuffer)` — loads PDF into PDF.js, sets `state.pdfDoc`, `state.totalPages`, `state.mode = 'pdf'`, calls `renderCurrentPage()`
9. `renderCurrentPage()` — fits page to panel width, applies `state.zoomScale`, uses DPR for HiDPI, renders to `#pdf-canvas`, calls `buildPDFWordOverlay()`. Wrap `showOCRPage()` call in try/catch (showOCRPage is added in Step 3). Include `_pendingPage` queue and debounce guard.
10. `buildPDFWordOverlay(page, vp, dpr)` — builds word boxes from `state.wordData` if loaded, otherwise leaves overlay blank
11. `makeWordBox(id, x, y, w, h, title)` — creates positioned div with hover highlight
12. `goToPage(n)` — validates range, guards against re-render of same page, calls `renderCurrentPage()`. No edit mode save yet (added in Step 6).
13. `_pendingPage = null` module variable
14. `updateZoomLabel()` — updates `#zoom-label` text
15. Event listeners:
- `#btn-prev` click → `goToPage(state.currentPage - 1)`
- `#btn-next` click → `goToPage(state.currentPage + 1)`
- `#page-input` change → `goToPage(parseInt(value))`
- `#btn-zoom-reset` click → reset `state.zoomScale` to 1.0, re-render
- `window resize` → re-render if PDF loaded and not rendering
- `#input-pdf` change → read file, call `loadPDFBuffer()`
- `#input-json` change → read file, parse JSON, set `state.wordData`, re-render
16. Ctrl+scroll zoom on `#image-panel-body`:
- **MUST check `e.ctrlKey` — only zoom when Ctrl is held**
- Only call `e.preventDefault()` when `e.ctrlKey` is true
- Without Ctrl: let the event pass through normally for scrolling
- With Ctrl: adjust `state.zoomScale` by ±0.1, clamp to 0.54.0, re-render
- After re-render, scroll to keep cursor anchor point in view
17. `DOMContentLoaded` auto-load:
```javascript
window.addEventListener('DOMContentLoaded', () => {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
setTimeout(loadCalgaryHighlanders, 200);
}
});
```
18. `loadCalgaryHighlanders()` — fetches PDF only for now (OCR text fetch added in Step 3). Uses `Promise.allSettled`.
**Test: PDF loads, pages navigate with buttons and keyboard input, zoom works with Ctrl+scroll, normal scroll works without Ctrl.**
---
## Step 3 — OCR text loading and plain text display
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. `parseOCRByPage(rawText)`:
- Splits on `## Page N` headings
- Stores content between headings in `state.ocrPages[N]`
- **Does NOT include the `## Page N` heading line in the content**
- `buf = []` not `buf = [line]`
2. `loadOCRText(rawText)`:
- Calls `parseOCRByPage(rawText)`
- Does NOT call `showOCRPage()` — only calls it if `state.pdfDoc` is already loaded
- This prevents double-render on startup
3. `showOCRPage(pageNum)`:
- Clears `textOutput.innerHTML = ''`
- Sets `textOutput.dataset.page = pageNum`
- Hides placeholder, shows `textOutput`
- If no OCR pages loaded: shows "OCR text not loaded yet" message
- If no content for this page: shows "No OCR text for page N" message
- Gets display text: `corrections[pageNum]?.pass1 || state.ocrPages[pageNum]` (corrections object added in Step 6, guard with optional chaining for now)
- Renders as: `<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;line-height:1.75;padding:16px 20px;">{escH(displayText)}</pre>`
- Scrolls text panel to top
- Updates `#ocr-sync-label` text
4. Update `loadCalgaryHighlanders()` to also fetch `REAL_TEXT_PATH` and call `loadOCRText()`
5. Add `#input-text` change event listener → `loadOCRText()`
6. Add `showOCRPage(state.currentPage)` call inside `renderCurrentPage()` after PDF page renders
**Test: OCR text loads alongside PDF, correct page shown for each page number, no `## Page N` heading visible in text panel, plain text renders correctly.**
---
## Step 4 — Synchronized scrolling
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. Module variables:
```javascript
let _syncScrolling = false;
let _syncActive = true;
```
2. `onImgScroll()` — when left panel scrolls, scroll right panel to same proportional position. Use `requestAnimationFrame` to reset `_syncScrolling` flag.
3. `onTxtScroll()` — when right panel scrolls, scroll left panel to same proportional position.
4. Attach scroll listeners to `#image-panel-body` and `#text-panel-body`
5. `#btn-sync-scroll` click → toggle `_syncActive`, toggle `.active` class on button
**Test: Scrolling either panel scrolls the other proportionally. Toggle button disables sync. Normal mouse wheel scroll works on left panel (no zoom without Ctrl).**
---
## Step 5 — Flag system
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. `updateFlagButtons(pageNum)` — toggles `.active` class on flag buttons based on `state.flags[pageNum]`
2. `updateFlagsSummary()` — counts verified/review/error flags, updates `#flags-summary` text
3. `setFlag(type)` — sets or clears `state.flags[state.currentPage]`, calls `updateFlagButtons()`, `updateFlagsSummary()`, `saveFlags()`
4. `saveFlags()`:
- POST to `/save-flags` with `state.flags` as JSON body
- On failure: fallback to `localStorage.setItem('p44-flags', ...)`
5. `loadFlagsOnStartup()`:
- Fetch `/flags.json`
- On failure: fallback to `localStorage.getItem('p44-flags')`
- Call `updateFlagsSummary()` after loading
6. `clearPass1Queue()` — removes all `verified` flags, keeps review/error, confirm dialog required
7. Event listeners:
- `#btn-flag-verified``setFlag('verified')`
- `#btn-flag-review``setFlag('review')`
- `#btn-flag-error``setFlag('error')`
- `#btn-flag-clear``setFlag(null)`
- `#btn-clear-p1-queue``clearPass1Queue()`
8. Call `loadFlagsOnStartup()` on `DOMContentLoaded`
9. Call `updateFlagButtons(pageNum)` at the end of `showOCRPage()`
**Test: Flags save and persist across refresh. Footer summary updates. Clear queue removes only verified flags.**
---
## Step 6 — Edit mode (Pass 1 only)
**This is the most critical step. Read the architecture rules before starting.**
**Architecture rules — must not be violated:**
- `#text-output` is NEVER set to `contentEditable`. It is display only.
- `#text-editor` (`<textarea>`) is the ONLY edit surface.
- When edit mode is active: hide `#text-output`, show `#text-editor`
- When edit mode is inactive: hide `#text-editor`, show `#text-output`
- `#text-editor` always contains plain text — never HTML
**Correction data model:**
```javascript
state.corrections[pageNum] = {
original: string, // raw OCR snapshot, taken ONCE on first edit-open, never overwritten
pass1: string, // Pass 1 saved text (absent if unchanged from original)
pass2: string, // Pass 2 saved text (added in Step 8, absent for now)
}
```
**Add to `p44-ocr-viewer.js`:**
1. `updateCorrectionIndicator(pageNum)` — shows/hides `#correction-dot` based on whether `corrections[pageNum]?.pass1` exists
2. `postCorrection(pageNum, corrObj)`:
- POST to `/save-corrections` with `{ page: pageNum, correction: corrObj }`
- On failure: fallback to localStorage `p44-corrections`
3. `loadCorrectionsOnStartup()`:
- Fetch `/corrections.json`
- On failure: fallback to localStorage
- Remove any entries where both `pass1` and `pass2` are absent
- Set `state.corrections`
4. `enterEditMode()`:
```
- Get pageNum = state.currentPage
- If corrections[pageNum] does not exist: create { original: state.ocrPages[pageNum].trim() }
- If corrections[pageNum].original does not exist: set it to state.ocrPages[pageNum].trim()
- Set textEditor.value = corrections[pageNum].pass1 || corrections[pageNum].original
- Set textEditor.dataset.page = pageNum
- Hide textOutput (display:none)
- Show textEditor (display:block)
- Focus textEditor
- Set state.editMode = true
- Update btnEdit text to "Editing", add .active class
```
5. `exitEditMode()`:
```
- If !state.editMode: return
- Get pageNum = parseInt(textEditor.dataset.page)
- Get editedText = textEditor.value.trim()
- Get originalText = corrections[pageNum]?.original || state.ocrPages[pageNum]?.trim() || ''
- If editedText !== originalText AND editedText is not empty:
corrections[pageNum].pass1 = editedText
Else:
delete corrections[pageNum].pass1
- If neither pass1 nor pass2 exist: delete corrections[pageNum] entirely
- Call postCorrection(pageNum, corrections[pageNum] || null)
- Call updateCorrectionIndicator(pageNum)
- Hide textEditor (display:none), clear textEditor.value
- Show textOutput (display:block)
- Set state.editMode = false
- Update btnEdit text to "Edit", remove .active class
- Call showOCRPage(pageNum) to re-render display with saved correction
```
6. Update `goToPage()` — call `exitEditMode()` at the very top before any other logic
7. `#btn-edit` click → toggle edit mode
8. Update `showOCRPage()` display text logic:
```javascript
const corr = state.corrections[pageNum];
const displayText = (corr?.pass1) || state.ocrPages[pageNum] || '';
```
9. Call `loadCorrectionsOnStartup()` on `DOMContentLoaded`
10. Call `updateCorrectionIndicator(pageNum)` at end of `showOCRPage()`
**Test thoroughly before proceeding:**
- Enter edit mode: textarea appears, display hidden
- Make a change and exit: display re-renders with correction, gold dot appears
- Navigate away and back: correction persists, dot still shows
- Enter and exit without changing: no correction saved, no dot
- Refresh page: correction loads from localStorage/server, dot shows correctly
- Confirm `## Page N` never appears in the textarea
---
## Step 7 — Corrections persistence in launch_viewer.py
**Add to `launch_viewer.py` only. Do not change any viewer files.**
Add a `do_POST` method to `QuietHandler` that handles these endpoints:
1. `POST /save-flags` — read JSON body, write to `flags.json` in ROOT
2. `POST /save-corrections` — read JSON body (`{ page, correction }`), load existing `corrections.json`, update the entry for `page`, write back
3. `POST /save-pass2-flags` — read JSON body, write to `pass2_flags.json` in ROOT
4. `POST /clear-all-data` — delete `flags.json`, `corrections.json`, `pass2_flags.json` if they exist, respond 200
All endpoints:
- Read body with `self.rfile.read(int(self.headers['Content-Length']))`
- Parse with `json.loads()`
- Respond `200 OK` on success
- Respond `400` on parse error
- Never crash the server on bad input — wrap in try/except
**Test: Save a flag, restart the server, reload viewer — flag persists from file not just localStorage.**
---
## Step 8 — Role switcher and Pass 2 mode
**Add to `p44-ocr-viewer.js` only. Do not change HTML or CSS.**
1. Module variables:
```javascript
let currentRole = 'pass1';
let pass2Flags = {};
const PASS1_LABELS = { verified: '✓ Verified', review: '⚑ Review', error: '✗ Error' };
const PASS2_LABELS = { verified: '✓ Pass 2 approved', review: '⚑ Needs work', error: '✕ Rejected' };
```
2. `getVerifiedPages()` — returns sorted array of page numbers where `state.flags[n] === 'verified'`
3. `updatePass2NavButtons()` — in Pass 2 mode, disables prev/next if no verified pages before/after current
4. `updatePass2Banner()` — updates banner text with count of verified pages ready for review
5. `applyRole(role)`:
- Sets `currentRole`
- Saves to `localStorage('p44-role')`
- Shows/hides `#pass2-banner`
- Updates flag button labels to PASS1 or PASS2 labels
- Calls `updatePass2Banner()` if pass2
- Re-renders current page via `showOCRPage(state.currentPage)`
6. Update `#btn-prev` / `#btn-next` click handlers — in Pass 2 mode, jump between verified pages only
7. Update `showOCRPage()` — at the end, if `currentRole === 'pass2'`, call `enterEditMode()` if not already in edit mode. Also show pass2-specific display text: `corr?.pass2 || corr?.pass1 || state.ocrPages[pageNum]`
8. `savePass2Flags()` — POST to `/save-pass2-flags`, fallback to localStorage `p44-pass2-flags`
9. `loadPass2FlagsOnStartup()` — fetch `/pass2_flags.json`, fallback to localStorage
10. Update `setFlag()` — in Pass 2 mode, write to `pass2Flags` and call `savePass2Flags()` instead of `state.flags`
11. Update `updateFlagButtons()` — read from `pass2Flags` in Pass 2 mode
12. `resetPass2()` — clears `pass2Flags` and all corrections, confirm dialog, cannot be undone
13. `#btn-pass2-reset` click → `resetPass2()`
14. `#role-select` change → `applyRole(value)`
15. On `DOMContentLoaded`, restore role: `applyRole(localStorage.getItem('p44-role') || 'pass1')`
16. Call `loadPass2FlagsOnStartup()` on `DOMContentLoaded`
**Test: Switch roles, Pass 2 nav jumps between verified pages, Pass 2 flags stored separately, role persists on refresh.**
---
## Step 9 — Pass 2 change summary panel
**Add to HTML, CSS, and JS.**
**HTML — add inside `#text-panel-body` after `#text-placeholder`, before `#text-output`:**
```html
<div id="pass2-change-summary" style="display:none;"></div>
```
**CSS — add:**
```css
#pass2-change-summary {
display: none;
padding: 10px 20px;
background: var(--surface2);
border-bottom: 1px solid var(--gold-dim);
flex-shrink: 0;
font-size: 11px;
}
#pass2-change-summary .cs-title {
font-size: 10px;
color: var(--gold-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
#pass2-change-summary .cs-list {
color: var(--text-dim);
line-height: 1.8;
max-height: 100px;
overflow-y: auto;
margin-bottom: 8px;
font-family: Georgia, serif;
}
#pass2-change-summary .cs-changed { color: var(--amber); }
#pass2-change-summary .cs-added { color: var(--green); }
#pass2-change-summary .cs-deleted { color: var(--red); }
#pass2-change-summary .cs-actions { display: flex; gap: 8px; }
.cs-revert-btn {
font-size: 10px;
font-family: Georgia, serif;
padding: 3px 10px;
border-radius: 3px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
letter-spacing: 0.04em;
}
.cs-revert-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
.cs-revert-btn.danger { border-color: var(--red); color: var(--red); }
```
**JS — add:**
1. `buildChangeSummary(originalText, pass1Text)`:
- Split both strings on whitespace: `str.trim().split(/\s+/)`
- Walk both arrays simultaneously
- Produce array of `{ type: 'changed'|'added'|'deleted', from?, to?, word? }`
- Cap at 50 changes, add `{ type: 'truncated' }` if over
- Return empty array if strings are identical
2. `showPass2ChangeSummary(pageNum)`:
- Get `corr = state.corrections[pageNum]`
- If no `corr?.pass1` or `currentRole !== 'pass2'`: hide panel, return
- Build changes from `corr.original` vs `corr.pass1`
- If no changes: hide panel, return
- Render `cs-title` with change count
- Render `cs-list` with each change coloured by type
- Render two action buttons: "↺ Revert to Pass 1" and "⚠ Revert to original OCR"
- Show panel
- Wire revert buttons:
- Revert to Pass 1: `confirm()` dialog, set `textEditor.value = corr.pass1`, status message
- Revert to original: `prompt('Type REVERT to confirm')`, if confirmed delete `corrections[pageNum]`, call `postCorrection(pageNum, null)`, update indicator, hide summary, status message
3. Call `showPass2ChangeSummary(state.currentPage)` at end of `enterEditMode()`
4. Hide `#pass2-change-summary` at start of `exitEditMode()`
**Test: Switch to Pass 2, navigate to a page with Pass 1 corrections — change summary appears above textarea showing exactly what was changed. Revert to Pass 1 restores Pass 1 text. Revert to original requires typing REVERT and clears all corrections.**
---
## Step 10 — Keyboard navigation
**Add to `p44-ocr-viewer.js` only.**
1. `document.addEventListener('keydown', e => {...})`
2. Guard: if `state.editMode` is true, return immediately (let all keys go to textarea)
3. Guard: if focus is on any `INPUT`, `TEXTAREA`, or `SELECT`, return
4. Key mappings:
- `ArrowRight` or `l` → `goToPage(state.currentPage + 1)`
- `ArrowLeft` or `j` → `goToPage(state.currentPage - 1)`
- `v` → `setFlag('verified')`
- `r` → `setFlag('review')`
- `e` → `setFlag('error')`
- `Escape` → if edit mode active: `exitEditMode()`. Otherwise: nothing. **Do NOT reference `zoomLens` — that element does not exist.**
**Test: Arrow keys navigate pages, v/r/e set flags, Escape exits edit mode, no errors thrown.**
---
## Step 11 — Clear all data
**Add to `p44-ocr-viewer.js` and `launch_viewer.py`.**
**JS — add `clearAllLocalData()`:**
```javascript
function clearAllLocalData() {
if (!confirm(
'Clear ALL saved data?\n\n' +
'This will wipe:\n' +
' • All Pass 1 and Pass 2 flags\n' +
' • All text corrections\n' +
' • Role setting\n\n' +
'This cannot be undone.'
)) return;
localStorage.removeItem('p44-corrections');
localStorage.removeItem('p44-flags');
localStorage.removeItem('p44-pass2-flags');
localStorage.removeItem('p44-role');
state.corrections = {};
state.flags = {};
pass2Flags = {};
applyRole('pass1');
updateFlagButtons(state.currentPage);
updateFlagsSummary();
updateCorrectionIndicator(state.currentPage);
if (state.pdfDoc) showOCRPage(state.currentPage);
fetch('/clear-all-data', { method: 'POST' }).catch(() => {});
setStatus('All saved data cleared — fresh start.');
}
```
Wire `#btn-clear-all` click → `clearAllLocalData()`
**`launch_viewer.py` — confirm `/clear-all-data` endpoint exists from Step 7. If not added yet, add it now.**
**Test: Click clear, confirm, all flags gone, all corrections gone, role reset, page re-renders as raw OCR, server files deleted.**
---
## Step 12 — Sample data
**Add to `p44-ocr-viewer.js` only.**
1. Add `SAMPLE_TEXT` constant (plain prose — no markdown)
2. Add `SAMPLE_JSON` constant (word bbox data)
3. `buildSampleImageDataURL()` — canvas-drawn sample page image
4. `loadSampleData()`:
- Sets `state.mode = 'image'`
- Draws sample image to canvas
- Loads sample JSON word data
- Calls `loadOCRText(SAMPLE_TEXT)`
- Status: `'Sample data loaded.'` — **do not reference any removed features**
5. `#btn-example` click → `loadSampleData()`
**Test: Sample button loads image and OCR text, page displays correctly.**
---
## Step 13 — Final cleanup and launch_viewer.py review
**Review all three files against this checklist:**
**JS cleanup:**
- [ ] No reference to `zoomLens` anywhere
- [ ] No `contentEditable` set on `#text-output` anywhere
- [ ] No `convertMarkdown()` or `inlineMd()` function
- [ ] No `sanitizeCorrection()` function
- [ ] No `stripHTML()` function
- [ ] No word-level diff functions (`wordDiff`, `lcsWords`, `tokenizeWords`, `renderDiff`)
- [ ] No diff legend DOM refs or event listeners
- [ ] `state.scale` removed (it was dead — there is no scale dropdown)
- [ ] `state.ocrRawFull` removed (dead — never read)
- [ ] `p44_diff_legend_dismissed` NOT in `clearAllLocalData()` (key was never written)
- [ ] localStorage keys are consistent: `p44-flags`, `p44-pass2-flags`, `p44-corrections`, `p44-role`
**launch_viewer.py review:**
- [ ] `/save-flags` POST handler exists
- [ ] `/save-corrections` POST handler exists
- [ ] `/save-pass2-flags` POST handler exists
- [ ] `/clear-all-data` POST handler exists
- [ ] All handlers wrapped in try/except
- [ ] `Cache-Control: no-store` headers present on all responses
**Final test — full workflow:**
1. Load Calgary Highlanders
2. Navigate to page 10
3. Flag as Verified (Pass 1)
4. Enter edit mode, make a correction, exit
5. Refresh — correction and flag persist
6. Switch to Pass 2
7. Navigate to page 10 (only verified page)
8. Change summary shows Pass 1 edits
9. Make a Pass 2 correction
10. Revert to original (type REVERT)
11. Clear all data
12. Refresh — everything is gone
---
*End of rebuild prompts.*

View File

@@ -0,0 +1,162 @@
P44 OCR Viewer — Move original OCR toggle to left panel, replace raster with plain text
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Remove horizontal split pane entirely, replace with left panel toggle.
PART 1 — HTML (p44-ocr-viewer.html)
Remove the split button from the right panel header:
html<button id="btn-split" class="tb-btn" title="Toggle split view — shows original OCR above for reference (Pass 2 only)" style="display:none;">⊟ Split</button>
Add a toggle button to the left panel header, immediately after the zoom controls and before the page nav:
html<button id="btn-ocr-toggle" class="tb-btn" title="Toggle between page scan and original OCR text" style="display:none;">📄 OCR</button>
Remove these elements from #text-panel-body entirely:
html<div id="split-pane" style="display:none;">
<div id="split-pane-header">ORIGINAL OCR</div>
<div id="split-ocr-wrap">
<div id="split-line-numbers"></div>
<div id="split-ocr-output"></div>
</div>
</div>
<div id="split-divider" style="display:none;"></div>
Add a new OCR text overlay div inside #image-panel-body, immediately after the pdf-container div:
html<div id="ocr-overlay-panel" style="display:none;"></div>
PART 2 — CSS (p44-ocr-viewer.css)
Remove all rules for:
#split-pane
#split-pane-header
#split-ocr-wrap
#split-line-numbers
#split-ocr-output
#split-divider
#text-panel-body.split-active
.split-active
Add at the end of the file:
css/* ── OCR overlay panel (replaces raster in left panel) ───────── */
#ocr-overlay-panel {
display: none;
position: absolute;
inset: 0;
overflow-y: auto;
background: var(--surface);
padding: 20px 24px;
font-size: 13px;
line-height: 1.8;
color: var(--text);
font-family: 'Georgia', 'Times New Roman', serif;
white-space: pre-wrap;
word-break: break-word;
z-index: 10;
}
Also ensure #image-panel-body has position: relative — add it to the existing rule if not already present:
css#image-panel-body {
position: relative;
}
PART 3 — JS (p44-ocr-viewer.js)
3a — Remove all split pane code
Remove these DOM refs:
javascriptconst btnSplit = document.getElementById('btn-split');
const splitPane = document.getElementById('split-pane');
const splitDivider = document.getElementById('split-divider');
const splitOcrOutput = document.getElementById('split-ocr-output');
const splitLineNums = document.getElementById('split-line-numbers');
Remove state.splitMode and state.splitRatio from the state object.
Remove these functions entirely:
buildSplitLineNumbers()
updateSplitLineNumbers()
applySplitRatio()
resetSplitSizing()
startSplitResize()
showSplitPane()
hideSplitPane()
toggleSplit()
getWrapMeasureEl()
buildWrappedLineNumbers()
parseLineHeightPx()
Remove these event listeners:
javascriptbtnSplit.addEventListener('click', toggleSplit);
splitDivider.addEventListener('mousedown', startSplitResize);
Remove from the resize event listener the split mode block:
javascriptif (state.splitMode) {
applySplitRatio(state.splitRatio || 0.5);
const pageNum = state.currentPage;
const corr = state.corrections && state.corrections[pageNum];
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
updateSplitLineNumbers(originalText);
}
Remove from goToPage():
javascriptif (state.splitMode) showSplitPane(n);
Remove from showOCRPage():
javascriptif (state.splitMode) showSplitPane(pageNum);
Remove from applyRole() all references to btnSplit, state.splitMode, hideSplitPane().
3b — New DOM refs
Add:
javascriptconst btnOcrToggle = document.getElementById('btn-ocr-toggle');
const ocrOverlayPanel = document.getElementById('ocr-overlay-panel');
Add to state object:
javascriptocrOverlay: false,
3c — showOCROverlay(pageNum)
Add this new function:
javascriptfunction showOCROverlay(pageNum) {
const corr = state.corrections && state.corrections[pageNum];
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
ocrOverlayPanel.innerHTML =
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:13px;' +
'line-height:1.8;color:var(--text);margin:0;">' +
escH(originalText) + '</pre>';
ocrOverlayPanel.style.display = 'block';
pdfContainer.style.display = 'none';
imagePlaceholder.style.display = 'none';
}
3d — hideOCROverlay()
Add this new function:
javascriptfunction hideOCROverlay() {
ocrOverlayPanel.style.display = 'none';
if (state.mode === 'pdf') {
pdfContainer.style.display = 'inline-block';
} else {
imagePlaceholder.style.display = 'flex';
}
}
3e — toggleOCROverlay()
Add this new function:
javascriptfunction toggleOCROverlay() {
state.ocrOverlay = !state.ocrOverlay;
btnOcrToggle.classList.toggle('active', state.ocrOverlay);
if (state.ocrOverlay) {
showOCROverlay(state.currentPage);
} else {
hideOCROverlay();
}
}
Add event listener:
javascriptbtnOcrToggle.addEventListener('click', toggleOCROverlay);
3f — goToPage()
Add after if (state.editMode) exitEditMode();:
javascript if (state.ocrOverlay) showOCROverlay(n);
3g — showOCRPage()
Add after updateCorrectionIndicator(pageNum):
javascript if (state.ocrOverlay) showOCROverlay(pageNum);
3h — applyRole()
In the pass2 branch add:
javascript btnOcrToggle.style.display = 'inline-block';
In the pass1 branch add:
javascript btnOcrToggle.style.display = 'none';
state.ocrOverlay = false;
hideOCROverlay();
VERIFICATION CHECKLIST:
Pass 1 role: OCR toggle button not visible in left panel header
Pass 2 role: OCR toggle button visible in left panel header
Clicking OCR toggle: raster scan disappears, plain text original OCR fills the left panel, button highlights active
Clicking OCR toggle again: plain text disappears, raster scan returns
Navigating to a new page while OCR overlay is on: overlay updates to new page's original OCR, stays visible
Switching from Pass 2 to Pass 1 while overlay is on: overlay closes, scan returns
No horizontal split pane anywhere in the UI
Editor (right panel) unchanged — line numbers, textarea, all flags working as before

View File

@@ -0,0 +1,207 @@
P44 OCR Viewer — Pass 1 Flag System Redesign
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Make all three changes in sequence. Do not touch PDF rendering, zoom, sync scroll, Pass 2 logic, or the corrections storage model.
PART 1 — HTML (p44-ocr-viewer.html)
In the right panel header, replace the five buttons:
html<button id="btn-flag-verified" ...>✓ Verified</button>
<button id="btn-flag-review" ...>⚑ Review</button>
<button id="btn-flag-error" ...>✗ Error</button>
<button id="btn-flag-clear" ...>Clear</button>
<button id="btn-edit" ...>Edit</button>
With these three buttons:
html<button id="btn-flag-done" class="tb-btn flag-btn" title="Mark page as done — sends to Pass 2 queue. Cannot be undone.">✓ Done</button>
<button id="btn-flag-problem" class="tb-btn flag-btn" title="Flag page as a problem — OCR too bad to fix. Still goes to Pass 2.">⚠ Problem</button>
<button id="btn-edit" class="tb-btn" title="Toggle edit mode">Edit</button>
Add a new read-only banner element inside #text-panel-body, immediately before #pass2-revert-bar:
html<div id="done-banner" style="display:none;"></div>
PART 2 — CSS (p44-ocr-viewer.css)
Remove the CSS rules for #btn-flag-verified, #btn-flag-review, #btn-flag-error if any exist by those IDs. The .flag-btn and .flag-btn.active rules stay — they apply to the new buttons too.
Add these new rules at the end of the file:
css/* ── Done-page read-only banner (Pass 1) ─────────────────────── */
#done-banner {
display: none;
align-items: center;
gap: 8px;
padding: 7px 16px;
background: #0f2a0f;
border-bottom: 1px solid #2e6e2e;
font-size: 10px;
color: #5cb85c;
letter-spacing: 0.05em;
flex-shrink: 0;
}
/* ── Problem flag indicator on page nav input ─────────────────── */
#page-input.problem { border-color: #c08020; }
/* ── Greyed-out done state on the OCR text panel ─────────────── */
#text-output.done-readonly {
opacity: 0.55;
pointer-events: none;
user-select: none;
}
PART 3 — JS (p44-ocr-viewer.js)
Make the following changes in order. Each change is isolated — do not touch any other function.
3a — DOM refs
Replace:
javascriptconst btnFlagVerified = document.getElementById('btn-flag-verified');
const btnFlagReview = document.getElementById('btn-flag-review');
const btnFlagError = document.getElementById('btn-flag-error');
const btnFlagClear = document.getElementById('btn-flag-clear');
With:
javascriptconst btnFlagDone = document.getElementById('btn-flag-done');
const btnFlagProblem = document.getElementById('btn-flag-problem');
3b — Flag label constants
Replace the PASS2_BTN_LABELS and PASS1_BTN_LABELS constants with:
javascriptconst PASS2_BTN_LABELS = { done: '✓ Pass 2 approved', problem: '⚠ Needs work' };
const PASS1_BTN_LABELS = { done: '✓ Done', problem: '⚠ Problem' };
3c — updateFlagButtons(pageNum)
Replace the entire function body with:
javascriptfunction updateFlagButtons(pageNum) {
const flagSrc = currentRole === 'pass2' ? pass2Flags : state.flags;
const flag = flagSrc[pageNum] || null;
btnFlagDone.classList.toggle('active', flag === 'done');
btnFlagProblem.classList.toggle('active', flag === 'problem');
// Done pages are read-only in Pass 1 — disable both flag buttons and Edit
const isDone = currentRole === 'pass1' && flag === 'done';
btnFlagDone.disabled = isDone;
btnFlagProblem.disabled = isDone;
btnEdit.disabled = isDone;
btnEdit.style.opacity = isDone ? '0.35' : '';
}
3d — setFlag(type)
Replace the entire function body with:
javascriptfunction setFlag(type) {
const p = state.currentPage;
if (currentRole === 'pass2') {
if (type === null) { delete pass2Flags[p]; } else { pass2Flags[p] = type; }
updateFlagButtons(p);
savePass2Flags();
return;
}
// Pass 1 — Done is one-way: once set it cannot be cleared by the volunteer
if (state.flags[p] === 'done') return;
// Problem is toggleable — clicking it again removes it
if (type === 'problem' && state.flags[p] === 'problem') {
delete state.flags[p];
} else if (type === null) {
delete state.flags[p];
} else {
state.flags[p] = type;
}
updateFlagButtons(p);
updateFlagsSummary();
saveFlags();
}
3e — showOCRPage(pageNum)
At the end of showOCRPage(), after the existing block that calls updateFlagButtons() and updateCorrectionIndicator(), add the following. Do not change any existing lines in showOCRPage() — only add this block at the end (before the closing brace), replacing the existing Pass 2 auto-edit-mode call:
javascript // Pass 1 done-page read-only state
const doneBanner = document.getElementById('done-banner');
const isDonePage = currentRole === 'pass1' && state.flags[pageNum] === 'done';
if (isDonePage) {
// Show the final corrected text (same display logic as normal, already rendered above)
// Lock the panel visually
textOutput.classList.add('done-readonly');
doneBanner.innerHTML =
'✓ <strong>Done</strong> — submitted to Pass 2 queue. This page is locked.';
doneBanner.style.display = 'flex';
// Ensure editor is closed and stays closed
if (state.editMode) exitEditMode();
} else {
textOutput.classList.remove('done-readonly');
doneBanner.style.display = 'none';
}
// Pass 2 auto-edit mode (unchanged behaviour)
if (currentRole === 'pass2') {
if (!state.editMode) enterEditMode();
updatePass2NavButtons();
}
Note: this block replaces the existing two lines at the bottom of showOCRPage() that read:
javascript if (currentRole === 'pass2') {
if (!state.editMode) enterEditMode();
updatePass2NavButtons();
}
3f — applyRole()
In applyRole(), replace the two label-setting lines for pass2 branch:
javascript btnFlagVerified.textContent = PASS2_BTN_LABELS.verified;
btnFlagReview.textContent = PASS2_BTN_LABELS.review;
btnFlagError.textContent = PASS2_BTN_LABELS.error;
With:
javascript btnFlagDone.textContent = PASS2_BTN_LABELS.done;
btnFlagProblem.textContent = PASS2_BTN_LABELS.problem;
And replace the three label-setting lines for the pass1 branch:
javascript btnFlagVerified.textContent = PASS1_BTN_LABELS.verified;
btnFlagReview.textContent = PASS1_BTN_LABELS.review;
btnFlagError.textContent = PASS1_BTN_LABELS.error;
With:
javascript btnFlagDone.textContent = PASS1_BTN_LABELS.done;
btnFlagProblem.textContent = PASS1_BTN_LABELS.problem;
3g — Event listeners
Replace:
javascriptbtnFlagVerified.addEventListener('click', () => setFlag('verified'));
btnFlagReview.addEventListener('click', () => setFlag('review'));
btnFlagError.addEventListener('click', () => setFlag('error'));
btnFlagClear.addEventListener('click', () => setFlag(null));
With:
javascriptbtnFlagDone.addEventListener('click', () => setFlag('done'));
btnFlagProblem.addEventListener('click', () => setFlag('problem'));
3h — updateFlagsSummary()
Replace the counting logic inside updateFlagsSummary():
javascript Object.values(state.flags).forEach(f => {
if (f === 'verified') v++;
else if (f === 'review') r++;
else if (f === 'error') er++;
});
flagsSummary.textContent = v || r || er
? '✓ ' + v + ' ⚑ ' + r + ' ✗ ' + er
: '';
With:
javascript let done = 0, problem = 0;
Object.values(state.flags).forEach(f => {
if (f === 'done') done++;
else if (f === 'problem') problem++;
});
flagsSummary.textContent = done || problem
? '✓ ' + done + (problem ? ' ⚠ ' + problem : '')
: '';
3i — Keyboard shortcut
In the keydown handler, replace:
javascript case 'v':
setFlag('verified'); break;
case 'r':
setFlag('review'); break;
case 'e':
setFlag('error'); break;
With:
javascript case 'v':
setFlag('done'); break;
case 'p':
setFlag('problem'); break;
Update the footer kbd hint in p44-ocr-viewer.html from:
html<span id="kbd-hint">← → navigate &nbsp; v/r/e flag &nbsp; esc close</span>
To:
html<span id="kbd-hint">← → navigate &nbsp; v done &nbsp; p problem &nbsp; esc close</span>
3j — getVerifiedPages()
Replace:
javascript .filter(k => state.flags[k] === 'verified')
With:
javascript .filter(k => state.flags[k] === 'done')
VERIFICATION CHECKLIST — confirm each before closing the prompt:
Clicking ✓ Done on an unreviewed page: button highlights gold, page text goes grey, green Done banner appears, Edit button is disabled, cannot click Done or Problem again
Clicking ⚠ Problem on an unreviewed page: button highlights gold, page remains editable
Clicking ⚠ Problem again on a problem page: flag clears, button returns to inactive
Navigating to a Done page while in Pass 1 role: banner shows, editor locked, text visible as final corrected version
Switching to Pass 2 role: Done banner hidden, flag buttons show Pass 2 labels, auto-edit mode activates as before
Footer summary shows correct counts using ✓ and ⚠ symbols
Keyboard v sets Done, p sets/toggles Problem, no reference to old r or e keys anywhere

View File

@@ -0,0 +1,409 @@
P44 OCR Viewer — Pass 2 Diff View + Edit Mode Reference Pane
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Do not touch PDF rendering, zoom, Pass 1 flag logic, or the corrections storage model.
BACKGROUND — how corrections are stored
Each page's corrections live in state.corrections[pageNum] with this shape:
javascript
{
original: "raw OCR text as loaded",
pass1: "volunteer-corrected text",
pass2: "senior editor text"
}
The diff always compares original against pass1. Never use state.currentPage inside diff functions — always pass pageNum explicitly to avoid page-bleed bugs.
PART 1 — HTML (p44-ocr-viewer.html)
Inside #text-panel-body, replace:
html
<div id="pass2-revert-bar" style="display:none;"></div>
<div id="pass2-change-summary" style="display:none;"></div>
<div id="text-output" style="display:none;"></div>
<div id="editor-wrap" style="display:none;">
<div id="line-numbers"></div>
<textarea id="text-editor" aria-label="OCR text editor"></textarea>
</div>
With:
html
<div id="pass2-revert-bar" style="display:none;"></div>
<div id="diff-toolbar" style="display:none;"></div>
<div id="text-output" style="display:none;"></div>
<div id="editor-wrap" style="display:none;">
<div id="diff-reference" style="display:none;"></div>
<div id="editor-bottom">
<div id="line-numbers"></div>
<textarea id="text-editor" aria-label="OCR text editor"></textarea>
</div>
</div>
In the right panel header, add a diff toggle button immediately before #btn-edit:
html
<button id="btn-diff" class="tb-btn" title="Toggle diff view — shows Pass 1 changes highlighted">⇄ Diff</button>
Remove <div id="pass2-change-summary" style="display:none;"></div> entirely — it is replaced by the new diff system.
PART 2 — CSS (p44-ocr-viewer.css)
Add at the end of the file:
css
/* ── Diff toolbar ─────────────────────────────────────────────── */
#diff-toolbar {
display: none;
align-items: center;
gap: 10px;
padding: 5px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 10px;
color: var(--text-dim);
flex-shrink: 0;
}
#diff-toolbar .diff-legend {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
#diff-toolbar .diff-count {
font-size: 10px;
color: var(--gold-dim);
letter-spacing: 0.04em;
}
.diff-swatch {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text-dim);
}
.diff-swatch-del {
width: 24px; height: 10px;
background: rgba(208,80,80,0.22);
border-radius: 2px;
}
.diff-swatch-ins {
width: 24px; height: 10px;
background: rgba(92,184,92,0.18);
border-radius: 2px;
}
/* ── Diff inline highlights ───────────────────────────────────── */
.diff-del {
background: rgba(208,80,80,0.22);
color: #e07070;
border-radius: 2px;
padding: 0 2px;
text-decoration: line-through;
font-size: 11px;
}
.diff-ins {
background: rgba(92,184,92,0.18);
color: #7ed87e;
border-radius: 2px;
padding: 0 2px;
}
/* ── Editor wrap — vertical split ─────────────────────────────── */
#editor-wrap {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-top: 1px solid var(--gold-dim);
}
/* ── Diff reference pane (top half of editor split) ───────────── */
#diff-reference {
display: none;
flex: 1;
min-height: 0;
overflow-y: auto;
border-bottom: 1px solid var(--border);
padding: 12px 20px;
font-size: 12.5px;
line-height: 1.75;
color: var(--text);
background: var(--surface);
white-space: pre-wrap;
font-family: 'Georgia', 'Times New Roman', serif;
}
#diff-reference-label {
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--gold-dim);
margin-bottom: 8px;
padding-bottom: 5px;
border-bottom: 1px solid var(--border);
}
/* ── Editor bottom half ───────────────────────────────────────── */
#editor-bottom {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
Also update the existing #editor-wrap rule — remove flex-direction and border-top from it since those are now set above. The existing rule in the file should become:
css
#editor-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
The new rule added above replaces it fully — delete the old one to avoid conflict.
PART 3 — JS (p44-ocr-viewer.js)
3a — New state flag
Inside the state object, add:
javascript
diffMode: false,
3b — DOM refs
Add after the existing editor DOM refs:
javascript
const btnDiff = document.getElementById('btn-diff');
const diffToolbar = document.getElementById('diff-toolbar');
const diffReference = document.getElementById('diff-reference');
const editorBottom = document.getElementById('editor-bottom');
3c — Word-level diff engine
Add this new function. Place it directly above buildChangeSummary():
javascript
function buildInlineDiff(originalText, pass1Text) {
const orig = (originalText || '').split(/(\s+)/);
const edit = (pass1Text || '').split(/(\s+)/);
const result = [];
let i = 0, j = 0;
while (i < orig.length || j < edit.length) {
if (i >= orig.length) {
result.push({ type: 'ins', text: edit[j++] });
} else if (j >= edit.length) {
result.push({ type: 'del', text: orig[i++] });
} else if (orig[i] === edit[j]) {
result.push({ type: 'same', text: orig[i] });
i++; j++;
} else {
result.push({ type: 'del', text: orig[i++] });
result.push({ type: 'ins', text: edit[j++] });
}
}
return result;
}
3d — Diff renderer
Add this new function directly below buildInlineDiff():
javascript
function renderInlineDiff(pageNum) {
const corr = state.corrections && state.corrections[pageNum];
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
const pass1Text = corr && corr.pass1;
if (!pass1Text) return null;
const tokens = buildInlineDiff(originalText, pass1Text);
const delCount = tokens.filter(t => t.type === 'del' && t.text.trim()).length;
const insCount = tokens.filter(t => t.type === 'ins' && t.text.trim()).length;
const total = Math.max(delCount, insCount);
const html = tokens.map(t => {
if (t.type === 'same') return escH(t.text);
if (t.type === 'del') return '<span class="diff-del">' + escH(t.text) + '</span>';
if (t.type === 'ins') return '<span class="diff-ins">' + escH(t.text) + '</span>';
return '';
}).join('');
return { html, total };
}
3e — Diff toolbar renderer
Add this new function:
javascript
function showDiffToolbar(pageNum) {
const result = renderInlineDiff(pageNum);
if (!result) {
diffToolbar.style.display = 'none';
return;
}
diffToolbar.innerHTML =
'<div class="diff-legend">' +
'<span class="diff-swatch"><span class="diff-swatch-del"></span> removed</span>' +
'<span class="diff-swatch"><span class="diff-swatch-ins"></span> added</span>' +
'</div>' +
'<span class="diff-count">' + result.total + ' change' + (result.total === 1 ? '' : 's') + ' from Pass 1</span>';
diffToolbar.style.display = 'flex';
}
3f — Apply diff to display panel
Add this new function:
javascript
function applyDiffToDisplay(pageNum) {
if (!state.diffMode) return;
const result = renderInlineDiff(pageNum);
if (!result) return;
textOutput.innerHTML =
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;' +
'line-height:1.75;color:var(--text);padding:16px 20px;">' +
result.html + '</pre>';
}
3g — Apply diff to reference pane (edit mode)
Add this new function:
javascript
function applyDiffToReference(pageNum) {
const result = renderInlineDiff(pageNum);
if (!result || !state.diffMode) {
diffReference.style.display = 'none';
return;
}
diffReference.innerHTML =
'<div id="diff-reference-label">Pass 1 corrections — reference</div>' +
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;' +
'line-height:1.75;color:var(--text);margin:0;">' +
result.html + '</pre>';
diffReference.style.display = 'block';
}
3h — Toggle diff mode
Add this new function:
javascript
function toggleDiff(pageNum) {
const corr = state.corrections && state.corrections[pageNum];
const hasPass1 = corr && corr.pass1;
if (!hasPass1) {
setStatus('No Pass 1 corrections on this page — nothing to diff.');
return;
}
state.diffMode = !state.diffMode;
btnDiff.classList.toggle('active', state.diffMode);
if (state.editMode) {
applyDiffToReference(pageNum);
} else {
if (state.diffMode) {
applyDiffToDisplay(pageNum);
showDiffToolbar(pageNum);
} else {
showOCRPage(pageNum);
}
}
}
Add the event listener immediately after the function:
javascript
btnDiff.addEventListener('click', () => toggleDiff(state.currentPage));
3i — Update showOCRPage()
At the very end of showOCRPage(), after updateCorrectionIndicator(pageNum) and before the Pass 2 auto-edit block, add:
javascript
// Show diff toolbar if page has Pass 1 corrections
const corrForPage = state.corrections && state.corrections[pageNum];
const hasPass1 = corrForPage && corrForPage.pass1;
if (hasPass1) {
showDiffToolbar(pageNum);
} else {
diffToolbar.style.display = 'none';
state.diffMode = false;
btnDiff.classList.remove('active');
}
// Auto-enable diff in Pass 2 if Pass 1 corrections exist
if (currentRole === 'pass2' && hasPass1 && !state.editMode) {
state.diffMode = true;
btnDiff.classList.add('active');
applyDiffToDisplay(pageNum);
}
Replace the existing Pass 2 auto-edit block at the bottom of showOCRPage():
javascript
if (currentRole === 'pass2') {
if (!state.editMode) enterEditMode();
updatePass2NavButtons();
}
With:
javascript
if (currentRole === 'pass2') {
updatePass2NavButtons();
}
Note: Pass 2 no longer auto-enters edit mode on page load. The editor opens edit mode manually via the Edit button. This is intentional — the senior editor reviews the diff first, then decides to edit.
3j — Update enterEditMode()
At the end of enterEditMode(), after textEditor.focus(), add:
javascript
applyDiffToReference(pageNum);
3k — Update exitEditMode()
At the start of exitEditMode(), before any existing logic, add:
javascript
diffReference.style.display = 'none';
After the existing line textOutput.style.display = 'block';, add:
javascript
if (state.diffMode) applyDiffToDisplay(pageNum);
3l — Update goToPage()
At the start of goToPage(), before if (state.editMode) exitEditMode();, add:
javascript
state.diffMode = false;
btnDiff.classList.remove('active');
diffToolbar.style.display = 'none';
diffReference.style.display = 'none';
This ensures diff state is always reset on navigation — prevents any possibility of page-bleed.
VERIFICATION CHECKLIST:
Pass 2 lands on a page with Pass 1 corrections: diff toolbar appears automatically, ⇄ Diff button is active, inline highlights visible
Pass 2 lands on a page with no Pass 1 corrections: diff toolbar hidden, ⇄ Diff button inactive, clicking it shows status message
Clicking ⇄ Diff toggles highlights on/off without changing page or losing position
Clicking Edit while diff is on: reference pane appears above textarea showing highlighted diff, editor opens below
Clicking Edit while diff is off: reference pane hidden, editor fills full height
Toggling diff while in edit mode: reference pane appears/disappears above the open textarea
Navigating to any new page: diff mode resets to off, no highlights carry over from previous page
Pass 1 role: ⇄ Diff button present but only activates on pages with Pass 1 corrections

View File

@@ -0,0 +1,231 @@
P44 OCR Viewer — Remove diff system, add Pass 2 split panel view
Scope: p44-ocr-viewer.js, p44-ocr-viewer.css, p44-ocr-viewer.html. Complete removal of all diff logic, replace with a simple toggleable split panel for Pass 2.
PART 1 — Remove diff entirely
HTML — remove these elements:
<button id="btn-diff" ...>⇄ Diff</button>
<div id="diff-toolbar" ...></div>
<div id="diff-reference" ...></div>
<div id="editor-bottom"> wrapper — move its children (#line-numbers and #text-editor) back up directly inside #editor-wrap
CSS — remove all rules for:
#diff-toolbar
#diff-toolbar .diff-legend
#diff-toolbar .diff-count
.diff-swatch
.diff-swatch-del
.diff-swatch-ins
.diff-del
.diff-ins
#diff-reference
#diff-reference-label
#editor-bottom
JS — remove these functions entirely:
buildInlineDiff()
renderInlineDiff()
showDiffToolbar()
applyDiffToDisplay()
applyDiffToReference()
toggleDiff()
JS — remove these DOM refs:
const btnDiff
const diffToolbar
const diffReference
const editorBottom
JS — remove state.diffMode from the state object.
JS — in showOCRPage() remove the entire block added for diff auto-enable and toolbar display — the block starting with const corrForPage = state.corrections && state.corrections[pageNum] down to the closing } of the Pass 2 auto-diff block. Restore the original Pass 2 block at the bottom of showOCRPage():
javascript if (currentRole === 'pass2') {
if (!state.editMode) enterEditMode();
updatePass2NavButtons();
}
JS — in enterEditMode() remove the line applyDiffToReference(pageNum);
JS — in exitEditMode() remove:
diffReference.style.display = 'none';
if (state.diffMode) applyDiffToDisplay(pageNum);
JS — in goToPage() remove the four lines:
javascript state.diffMode = false;
btnDiff.classList.remove('active');
diffToolbar.style.display = 'none';
diffReference.style.display = 'none';
PART 2 — HTML (p44-ocr-viewer.html)
In the right panel header, add a toggle button immediately before #btn-edit:
html<button id="btn-split" class="tb-btn" title="Toggle split view — shows original OCR above for reference (Pass 2 only)" style="display:none;">⊟ Split</button>
Inside #text-panel-body, immediately before #text-output, add the original OCR reference pane:
html<div id="split-pane" style="display:none;">
<div id="split-pane-header">ORIGINAL OCR</div>
<div id="split-ocr-wrap">
<div id="split-line-numbers"></div>
<div id="split-ocr-output"></div>
</div>
</div>
<div id="split-divider" style="display:none;"></div>
PART 3 — CSS (p44-ocr-viewer.css)
Add at the end of the file:
css/* ── Split panel layout ───────────────────────────────────────── */
#text-panel-body {
display: flex;
flex-direction: column;
overflow: hidden;
}
#split-pane {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
#split-pane-header {
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--gold-dim);
padding: 5px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--surface);
}
#split-ocr-wrap {
display: flex;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 0;
}
#split-line-numbers {
width: 48px;
flex-shrink: 0;
padding: 0 8px 0 12px;
text-align: right;
font-size: 11px;
line-height: 1.75;
color: var(--gold-dim);
font-family: 'Courier New', monospace;
user-select: none;
border-right: 1px solid var(--border);
margin-right: 12px;
}
#split-ocr-output {
flex: 1;
min-width: 0;
font-size: 12.5px;
line-height: 1.75;
color: var(--text);
white-space: pre-wrap;
font-family: 'Georgia', 'Times New Roman', serif;
padding-right: 16px;
}
#split-divider {
height: 4px;
background: var(--border);
flex-shrink: 0;
cursor: row-resize;
border-top: 1px solid var(--gold-dim);
border-bottom: 1px solid var(--gold-dim);
}
/* When split is active, text-output and editor-wrap each take half */
#text-panel-body.split-active #text-output,
#text-panel-body.split-active #editor-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
}
#text-panel-body.split-active {
display: flex;
flex-direction: column;
}
PART 4 — JS (p44-ocr-viewer.js)
4a — DOM refs
Add:
javascriptconst btnSplit = document.getElementById('btn-split');
const splitPane = document.getElementById('split-pane');
const splitDivider = document.getElementById('split-divider');
const splitOcrOutput = document.getElementById('split-ocr-output');
const splitLineNums = document.getElementById('split-line-numbers');
4b — State flag
Add to the state object:
javascriptsplitMode: false,
4c — Line number generator for split pane
Add this new function above showOCRPage():
javascriptfunction buildSplitLineNumbers(text) {
const lines = (text || '').split('\n');
return lines.map((_, i) => i + 1).join('\n');
}
4d — showSplitPane(pageNum)
Add this new function:
javascriptfunction showSplitPane(pageNum) {
const corr = state.corrections && state.corrections[pageNum];
const originalText = (corr && corr.original) || state.ocrPages[pageNum] || '';
splitOcrOutput.textContent = originalText;
splitLineNums.textContent = buildSplitLineNumbers(originalText);
splitPane.style.display = 'flex';
splitDivider.style.display = 'block';
textPanelBody.classList.add('split-active');
}
4e — hideSplitPane()
Add this new function:
javascriptfunction hideSplitPane() {
splitPane.style.display = 'none';
splitDivider.style.display = 'none';
textPanelBody.classList.remove('split-active');
}
4f — toggleSplit()
Add this new function:
javascriptfunction toggleSplit() {
state.splitMode = !state.splitMode;
btnSplit.classList.toggle('active', state.splitMode);
if (state.splitMode) {
showSplitPane(state.currentPage);
} else {
hideSplitPane();
}
}
Add the event listener immediately after:
javascriptbtnSplit.addEventListener('click', toggleSplit);
4g — applyRole()
In applyRole(), in the pass2 branch add:
javascript btnSplit.style.display = 'inline-block';
In the pass1 branch add:
javascript btnSplit.style.display = 'none';
state.splitMode = false;
hideSplitPane();
4h — goToPage()
At the start of goToPage(), after if (state.editMode) exitEditMode();, add:
javascript if (state.splitMode) showSplitPane(n);
This refreshes the split pane content for the new page without toggling it off.
4i — showOCRPage()
At the end of showOCRPage(), after updatePass2NavButtons(), add:
javascript if (state.splitMode) showSplitPane(pageNum);
VERIFICATION CHECKLIST:
Pass 1 role: Split button not visible
Pass 2 role: Split button visible in panel header
Clicking Split in Pass 2: right panel splits into original OCR on top and editor on bottom, both with line numbers, both scroll independently
Clicking Split again: collapses back to single panel, editor fills full height
Navigating to a new page while split is on: both panes update to the new page's content, split stays on
Switching from Pass 2 to Pass 1 while split is on: split closes, button hides
Original OCR pane shows raw unedited text — not the Pass 1 corrected version
Line numbers visible in both panes and match the text lines

View File

@@ -0,0 +1,187 @@
PART 1 — HTML (p44-ocr-viewer.html)
Remove the Edit button:
html<button id="btn-edit" class="tb-btn" title="Toggle edit mode">Edit</button>
Remove the correction dot (no longer needed without edit mode state):
html<div id="correction-dot" title="This page has been edited"></div>
Remove #text-output div — the textarea replaces it permanently:
html<div id="text-output" style="display:none;"></div>
The #editor-wrap should now be visible by default — remove style="display:none;" from it:
html<div id="editor-wrap">
<div id="line-numbers"></div>
<textarea id="text-editor" aria-label="OCR text editor" wrap="soft"></textarea>
</div>
PART 2 — CSS (p44-ocr-viewer.css)
Remove rules for:
#correction-dot
#text-output
#text-output.done-readonly
#btn-edit
Ensure #editor-wrap fills the panel body — update its rule to:
css#editor-wrap {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
Ensure #text-panel-body is always a flex column:
css#text-panel-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
PART 3 — JS (p44-ocr-viewer.js)
3a — Remove DOM refs
Remove:
javascriptconst btnEdit = document.getElementById('btn-edit');
const correctionDot = document.getElementById('correction-dot');
const textOutput = document.getElementById('text-output');
Remove from state object:
javascripteditMode: false,
3b — Remove enterEditMode() and exitEditMode() entirely
Delete both functions completely.
3c — Remove btnEdit event listener
javascriptbtnEdit.addEventListener('click', () => {
if (state.editMode) exitEditMode();
else enterEditMode();
});
3d — Replace showOCRPage(pageNum)
Replace the entire function body with:
javascriptfunction showOCRPage(pageNum) {
textEditor.dataset.page = pageNum;
if (!Object.keys(state.ocrPages).length) {
textEditor.value = '';
ocrSyncLabel.textContent = '';
return;
}
const content = state.ocrPages[pageNum];
if (!content) {
textEditor.value = 'No OCR text for page ' + pageNum + '.';
ocrSyncLabel.textContent = 'page ' + pageNum + ' — no data';
updateLineNumbers();
return;
}
const corr = state.corrections && state.corrections[pageNum];
const editText = corr
? (currentRole === 'pass2'
? (corr.pass2 || corr.pass1 || corr.original || content)
: (corr.pass1 || corr.original || content))
: content;
textEditor.value = editText;
textEditor.scrollTop = 0;
ocrSyncLabel.textContent = 'page ' + pageNum;
updateLineNumbers();
updateFlagButtons(pageNum);
updateCorrectionIndicator(pageNum);
// Snapshot original on first visit
if (!state.corrections[pageNum] || !state.corrections[pageNum].original) {
if (!state.corrections[pageNum]) state.corrections[pageNum] = {};
state.corrections[pageNum].original = content.trim();
}
// Pass 1 done-page lock
const doneBanner = document.getElementById('done-banner');
const isDonePage = currentRole === 'pass1' && state.flags[pageNum] === 'done';
if (isDonePage) {
textEditor.readOnly = true;
textEditor.style.opacity = '0.55';
doneBanner.innerHTML = '✓ <strong>Done</strong> — submitted to Pass 2 queue. This page is locked.';
doneBanner.style.display = 'flex';
} else {
textEditor.readOnly = false;
textEditor.style.opacity = '';
doneBanner.style.display = 'none';
}
if (currentRole === 'pass2') {
updatePass2NavButtons();
if (state.ocrOverlay) showOCROverlay(pageNum);
showPass2RevertBar(pageNum);
}
}
3e — Replace saveCurrentPage()
This function replaces the save logic that was inside exitEditMode(). Add it as a new function:
javascriptfunction saveCurrentPage() {
const pageNum = parseInt(textEditor.dataset.page, 10);
if (!pageNum) return;
const editedText = textEditor.value.trim();
const passKey = currentRole === 'pass2' ? 'pass2' : 'pass1';
const originalText = (state.corrections[pageNum] && state.corrections[pageNum].original)
|| (state.ocrPages[pageNum] || '').trim();
if (!state.corrections[pageNum]) state.corrections[pageNum] = {};
state.corrections[pageNum].original = state.corrections[pageNum].original || originalText;
if (editedText && editedText !== originalText) {
state.corrections[pageNum][passKey] = editedText;
} else {
delete state.corrections[pageNum][passKey];
}
if (!state.corrections[pageNum].pass1 && !state.corrections[pageNum].pass2) {
delete state.corrections[pageNum];
}
postCorrection(pageNum, state.corrections[pageNum] || null);
updateCorrectionIndicator(pageNum);
}
3f — Update goToPage()
Replace:
javascriptfunction goToPage(n) {
if (state.editMode) exitEditMode();
if (state.ocrOverlay) showOCROverlay(n);
...
}
With:
javascriptfunction goToPage(n) {
saveCurrentPage();
if (state.ocrOverlay) showOCROverlay(n);
...
}
3g — Update updateCorrectionIndicator()
This function currently shows/hides correctionDot. Replace its entire body with:
javascriptfunction updateCorrectionIndicator(pageNum) {
const corr = state.corrections && state.corrections[pageNum];
const hasEdit = corr && (corr.pass1 || corr.pass2);
ocrSyncLabel.style.color = hasEdit ? 'var(--gold)' : 'var(--gold-dim)';
}
3h — Update updateFlagButtons(pageNum)
Remove all references to btnEdit — specifically remove:
javascript btnEdit.disabled = isDone;
btnEdit.style.opacity = isDone ? '0.35' : '';
3i — Keyboard handler
Remove from the keydown handler:
javascript if (state.editMode) return;
Replace with — skip shortcuts only when typing in the page number input:
javascript const tag = document.activeElement && document.activeElement.tagName;
if (tag === 'INPUT' && document.activeElement.id === 'page-input') return;
if (tag === 'TEXTAREA') return;
Remove from the keydown handler:
javascript case 'Escape':
if (state.editMode) { exitEditMode(); }
break;
3j — Remove showPass2ChangeSummary()
This was called from enterEditMode() which no longer exists. Remove the call if it appears anywhere else. The function itself can stay for now as it does no harm, but it should not be called automatically.
VERIFICATION CHECKLIST:
Page loads in Pass 1: textarea immediately visible and editable, no Edit button
Page loads in Pass 2: textarea immediately visible and editable, no Edit button
Typing in textarea and navigating away: changes saved automatically via saveCurrentPage()
Pass 1 Done page: textarea is readOnly, visually dimmed, done banner shows
Pass 1 Done page: navigating away and back still shows locked state
Line numbers update as user types
OCR toggle (Pass 2): left panel shows original OCR, right panel textarea unaffected
No references to state.editMode, enterEditMode, exitEditMode, btnEdit anywhere in the file
Revert bar still works in Pass 2 — reverts load text into textarea directly