phase: 163_pdf_strip_additive_selfheal
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:
+101
-8
@@ -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) ----------
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user