phase: 163_pdf_strip_additive_selfheal
Build and Push Containers / build-and-push-app (push) Successful in 2m3s
Build and Push Containers / build-and-push-db (push) Successful in 21s

All gates verified. Final report:

**Phase 163 — final verification pass (all 5 tasks already complete; verification-only run)**

- Verified D1–D4 in code: `page_strip_mode` column + migration 0027, `DocContent` absent-never-null rule keyed off `page_count`; `_persist_pdf_pages` render → atomic tmp+`os.replace` writes → stale-tail-last + `strip_class` stamp; `_regate_pdf_strip` on both unchanged paths (mirror gate, heal, repair, fail-soft, one INFO line only when acting); viewer mode branch + Text/Pages toggle (Text default, `aria-pressed`, no innerHTML, lazy imgs).
- `uv run pytest --cov=app --cov-report=term-missing` → **3796 tests, exit 0, coverage 99%** (>90%)
- `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation
- `test_pdf_pages_in_sources + test_pictorial_pdf + test_garbage_pdf` → 10 passed; `test_document_viewer(_context) + test_image_documents` → 20 passed
- `uv run ruff check .` clean; `uv run pyright` → 0 errors; alembic **0027 downgrade→upgrade CLI round-trip clean** (+ integration pins)
- Completion criteria: additive view w/ toggle ✅ · primary DOM byte-identity ✅ · write-first order/mid-write-failure/atomic pins ✅ · heal + repair E2E ✅ · zero vision/LLM on fast path (mock-captured) ✅ · non-qualifying payload byte-identity ✅ · docs pass ✅
- **Documentation**: README.md + AGENTS.md reviewed in full — the phase's updates are accurate; all config-table defaults cross-checked against `app/config.py` (no drift), every page route, script, and E2E command referenced exists, every `.env.example` var documented — **verified consistent, no changes needed**.
- Notable: live `Letumdei` repair is owner-runtime post-deploy per D5 (next production Sync self-heals; no live changes made); the modified `.agents/screenshots/doc_modal_scrollbar_1280.png` is an E2E-regenerated artifact left for the pipeline commit.
- **Next pending phase:** `164_chat_error_honesty`
This commit is contained in:
2026-10-07 13:57:30 -04:00
parent 74eb2e265c
commit 6ba46e735a
52 changed files with 4729 additions and 212 deletions
+101 -8
View File
@@ -144,8 +144,9 @@
* wire for every other document, so they render the EXACT pre-phase
* DOM on BOTH surfaces — the load-bearing byte-identity property),
* #doc-content renders a .doc-pages strip INSTEAD of the text
* content AND the .doc-summary panel: one <figure class="doc-page">
* per URL — a lazy <img> (alt = "Page N of <title>", the WCAG alt
* content AND the .doc-summary panel FOR THE PRIMARY CLASS ONLY
* (phase 163 — the absent/`primary` mode; see the next paragraph):
* one <figure class="doc-page"> per URL — a lazy <img> (alt = "Page N of <title>", the WCAG alt
* contract — the title as the fallback context) + a "Page N"
* <figcaption> — while the meta badge row renders exactly as today.
* The owner's contract: the per-page descriptions stay in the DB for
@@ -160,6 +161,26 @@
* per-surface copy); createElement + properties/textContent only (the
* never-build-HTML rule — no innerHTML).
*
* Phase 163 (task 04, LOCKED A1/A2/A6): the strip's CLASS decides the
* view. page_strip_mode === "additive" (the pictorial class — a USABLE
* text layer, so the pages are a convenience, not the content) keeps
* the TEXT doc EXACTLY as a non-qualifying doc renders it (Summary
* panel + content — the pre-148 branch reused), and the strip sits one
* Text/Pages toggle away: a .doc-view-toggle group (role=group,
* aria-label="Document view", two <button aria-pressed>) above a
* .doc-views wrapper holding the text view + the strip; Text is
* pressed by DEFAULT (A6 — never a sparse-ratio flip), Pages flips the
* wrapper's data-view (CSS shows the strip / hides the text — the
* strip node stays MOUNTED, its lazy imgs never refetch on flip-flop).
* The text is NEVER hidden for this class. The absent/"primary" mode
* (the fallback class — the screenshots ARE the content, and every
* pre-163 stamped row) keeps the strip-only DOM above byte-identically
* (the tolerant read: only the exact "additive" string switches on the
* toggle); falsy page_urls stay untouched. The wrapper exists ONLY on
* additive docs — every other document's DOM is unchanged. Toggle
* labels are fixed strings (no doc-derived text); createElement +
* properties/textContent only.
*
* Phase 153 (task 02): the meta row's "Copy path" control — the
* read-tool `source/path` form in the clipboard with one click
* (ASSUMPTION A1 — the exact `source/path` the read tool, the
@@ -361,7 +382,9 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
// Phase 148 (task 03): the page strip — the payload's page_urls
// (task 02's endpoint: the exact 1-based route list for a
// page-qualifying PDF; ABSENT on the wire for every other
// document) is the ONE branch that replaces the content slot: the
// document) is the ONE branch that replaces the content slot (phase
// 163: the PRIMARY class only — an "additive" doc falls through to
// the text rendering with the strip behind a toggle, D4/A1): the
// markdown/.doc-raw content block AND the labeled Summary panel
// (including its admin edit wiring — moot without the panel) are
// NOT built. The per-page descriptions stay in the DB for Q&A (the
@@ -372,16 +395,40 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
// below (byte-identical for every other doc — the load-bearing
// property).
const pageUrls = Array.isArray(doc.page_urls) ? doc.page_urls : null;
if (pageUrls && pageUrls.length > 0) {
const stripEligible = pageUrls !== null && pageUrls.length > 0;
// Phase 163 (task 04, LOCKED A2): the strip's CLASS picks the view.
// Only the exact "additive" string switches on the toggle — absent
// (pre-163 rows, and the omission rule's non-qualifying docs) or
// "primary" read as primary (the tolerant-reading house rule), so
// the fallback class keeps the strip-only DOM byte-identically.
const additive = stripEligible && doc.page_strip_mode === "additive";
if (stripEligible && !additive) {
contentEl.appendChild(docPagesStrip(doc, pageUrls));
return;
}
// Phase 163 (task 04, LOCKED A1/A6): an ADDITIVE doc renders the
// text doc EXACTLY as a non-qualifying one does (Summary panel +
// content below, reused unchanged) INSIDE a .doc-views wrapper —
// the Text/Pages toggle above it flips the wrapper's data-view
// (CSS-driven show/hide, never a re-render); the strip mounts once
// AFTER the text view and stays mounted across flips (lazy imgs,
// display:none while hidden, never fetch). Every non-additive doc
// keeps slot === contentEl — its DOM is byte-identical to pre-phase.
let views = null;
let slot = contentEl;
if (additive) {
views = document.createElement("div");
views.className = "doc-views";
views.setAttribute("data-view", "text"); // LOCKED A6: Text is the default view
contentEl.append(docViewToggle(views), views);
slot = views;
}
// Phase 122 (task 04): an image document — the persistent bytes
// render as the <img> block FIRST; the description (= doc.content)
// follows in the normal content slot below (the plain-content path
// — the last branch in this function).
if (doc.is_image) {
contentEl.appendChild(docImageBlock(doc));
slot.appendChild(docImageBlock(doc));
}
// Phase 36: the summary panel — labeled section ABOVE the original
// content, on BOTH surfaces (page + modal) through this one core.
@@ -405,7 +452,7 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
body.className = "doc-summary-text";
body.textContent = doc.summary; // text node — XSS contract unchanged
section.append(title, body);
contentEl.appendChild(section);
slot.appendChild(section);
// Phase 57 (task 02, D4): the section above is the phase-36 shape
// for EVERYONE — only an authenticated admin (docAdminReady(), the
// module-cached whoami promise) then gains the header row + Edit
@@ -419,13 +466,59 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
const wrap = document.createElement("div");
wrap.className = "doc-md";
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
contentEl.appendChild(wrap);
slot.appendChild(wrap);
} else {
const pre = document.createElement("pre");
pre.className = "doc-raw";
pre.textContent = doc.content; // text node: never parsed as HTML
contentEl.appendChild(pre);
slot.appendChild(pre);
}
// Phase 163 (task 04): the additive strip mounts once, AFTER the
// text view, inside the wrapper — hidden by [data-view="text"]
// (the lazy imgs never fetch until Pages is chosen; the node
// persists across flips — no refetch).
if (views !== null) {
views.appendChild(docPagesStrip(doc, pageUrls));
}
}
/* ---------- view toggle (phase 163, task 04 — LOCKED A1/A6) ----------
* The Text/Pages toggle of an ADDITIVE strip doc: one
* <div class="doc-view-toggle" role="group" aria-label="Document
* view"> with two <button type="button" class="doc-view-btn"
* aria-pressed>. Text is pressed initially (A6 — no sparse-ratio
* default flip). A click mutates ONLY the two aria-pressed states and
* the .doc-views wrapper's data-view attribute — the CSS shows/hides
* the text view and the strip (never a re-render, never a refetch).
* The labels are FIXED strings (no doc-derived text inside the
* toggle); createElement + properties/textContent only (the
* never-build-HTML rule — no innerHTML). */
function docViewToggle(viewsEl) {
const group = document.createElement("div");
group.className = "doc-view-toggle";
group.setAttribute("role", "group");
group.setAttribute("aria-label", "Document view");
const textBtn = viewToggleButton("Text");
const pagesBtn = viewToggleButton("Pages");
const select = (view) => {
viewsEl.setAttribute("data-view", view);
textBtn.setAttribute("aria-pressed", view === "text" ? "true" : "false");
pagesBtn.setAttribute("aria-pressed", view === "pages" ? "true" : "false");
};
textBtn.setAttribute("aria-pressed", "true"); // LOCKED A6: Text pressed by default
pagesBtn.setAttribute("aria-pressed", "false");
textBtn.addEventListener("click", () => select("text"));
pagesBtn.addEventListener("click", () => select("pages"));
group.append(textBtn, pagesBtn);
return group;
}
function viewToggleButton(label) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "doc-view-btn";
btn.textContent = label; // fixed string — never doc-derived
return btn;
}
/* ---------- image block (phase 122, task 04) ----------
+53
View File
@@ -5311,6 +5311,59 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
padding: 0.35rem 0 0;
}
/* Additive strip view (phase 163, task 04): the pictorial-class PDF
— a USABLE text layer — keeps the text doc (Summary panel +
content) with the page strip one Text/Pages toggle away (LOCKED
A1/A6). The toggle is the house ghost-pill idiom (the .auth-link
family): the two pills sit adjacent in the labeled group, ink-soft
on the transparent pill (AA on --bg/--surface), hover the
brand-soft/brand-ink pair; the PRESSED button is the brand fill +
heavier weight — text + style, never color alone (B5) — with --bg
ink on --brand (5.2:1, the .share-chat-btn pair). ≥44px targets
(§7.1); :focus-visible rides the global 3px ring. The view swap is
CSS-driven off the .doc-views wrapper's data-view: the strip is
display:none by default (its lazy imgs never fetch while hidden)
and shows on [data-view="pages"], which hides the text view instead
— the nodes stay mounted across flips (no re-render, no refetch).
Phase-08 tokens only (no new hue — the monochrome invariant). */
.doc-view-toggle {
display: flex;
gap: 0.4rem;
width: 100%;
max-width: var(--chat-column);
margin-inline: auto;
margin-bottom: 0.75rem;
}
.doc-view-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.doc-view-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.doc-view-btn[aria-pressed="true"] {
background: var(--brand);
border-color: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 (AA) */
font-weight: 700; /* pressed = weight + fill, never color alone (B5) */
}
.doc-views .doc-pages { display: none; } /* Text default — hidden lazy imgs never fetch */
.doc-views[data-view="pages"] .doc-summary,
.doc-views[data-view="pages"] .doc-md,
.doc-views[data-view="pages"] .doc-raw,
.doc-views[data-view="pages"] .doc-image { display: none; }
.doc-views[data-view="pages"] .doc-pages { display: grid; }
/* Designed not-found state (no emoji — plain SVG mark, phase 08 rule). */
.doc-not-found {
width: 100%;