phase: 165_prose_word_wrap
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All verification complete. Final report:

**Phase 165 — Prose word wrap (final verification pass)**
- Verified: `PROSE_FORMATS={txt,text}` + `isProseDoc` (single-site, image arm wins); `renderDocument` three-way branch (md → `.doc-md`, prose → `.doc-prose` text node, else `pre.doc-raw` byte-identical); `.doc-prose` CSS = 72rem column, `pre-wrap` + `overflow-wrap: anywhere`, tokens only, zero literals; `[data-view="pages"]` hide list includes `.doc-prose` as one grouped declaration; phase-116 scrollbar rules intact and modal-scoped; no `innerHTML` in the prose path.
- Tests/lint (all 100% green):
  - `uv run pytest` → **3854 passed** (unit + integration)
  - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%)
  - `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
  - `uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov` → **5 passed** (isolation)
  - Isolation regressions: document_viewer 8, image_documents 4, pdf_pages_in_sources 4, pdf_strip_additive_selfheal 5, edit_summaries 4, summary_in_viewer 4, chat_answer_clusters 4, garbage_pdf 3, pdf_support 6, sticky_navbar 3, smoke 3 — all passed
- Completion criteria: image description wraps, full text, both surfaces ✔; `.txt` wraps same way ✔; `py`/`json`/code keep `pre.doc-raw` + horizontal overflow + themed modal scrollbar (E2E negative control + pins) ✔; no innerHTML, AA `--ink`/`--surface` pair ✔; full gates green ✔; commit + phase-dir move are harness-owned (not executed per pipeline rules).
- Documentation: README already updated by task 03 (viewer section + image-docs section); full README/AGENTS.md review found no further inconsistencies — **verified consistent, no changes needed**.
- Notable deviations: `.doc-prose` omits `font-family: inherit` from 00_phase D2's snippet — task 01's authoritative "exact property set" excludes it and `div` inherits fonts anyway (behavior identical). PDF deferral (A4) intact: `format == "pdf"` stays in `.doc-raw`, owner call recorded.
- Next pending phase: `166_hide_empty_answer_bubble`.
This commit is contained in:
2026-10-07 22:57:40 -04:00
parent 8d70aa946c
commit b0c7aed613
36 changed files with 2198 additions and 64 deletions
+40 -5
View File
@@ -9,10 +9,14 @@
* so the two surfaces can never drift:
*
* • md / markdown → the shared escape-first renderer (markdown.js)
* in a ≤46rem centered column;
* in the 72rem centered reading column;
* • prose (phase 165, LOCKED A1 — isProseDoc: an image doc's
* description or a txt/text doc) → the content
* as a text node inside <div class="doc-prose">,
* pre-wrap: it WRAPS at the reading column;
* • any other → the raw content as a text node inside
* <pre class="doc-raw"> (mono, horizontal
* scroll);
* scroll — locked A3: code never wraps);
* • doc.summary non-empty (phase 36 — phase 30 summaries exist
* only on non-markdown docs) → a labeled
* .doc-summary section ABOVE the content;
@@ -111,7 +115,8 @@
* the summary, the WCAG alt contract; a NULL summary falls back to
* the title), and the description (doc.content — the ONLY readable
* text of the doc) follows in the EXISTING plain-content slot below
* (the .doc-raw path; a description is prose, not markdown). The
* (phase 165: the .doc-prose path — a description IS prose, so it
* wraps; it was never markdown, and never belonged in the pre). The
* labeled Summary panel is suppressed for the verbatim-description
* case (summary === content — the importer invariant; the panel
* would duplicate the text right below the image); an admin-edited
@@ -313,6 +318,26 @@ function copyPathButton() {
return b;
}
/* ---------- prose set (phase 165, task 01 — LOCKED A1) ----------
* The ONE place the prose rule lives (TODO.md L3: "Need word wrap for
* image explanations and other long text rather than scrolling
* horizontally"). The defect: the description used to land in the
* `pre` and each generated line became one unbreakable line — a
* horizontal scrollbar where every other text surface wraps.
*
* The image arm is UNCONDITIONAL (phase 122 A3: an image doc's
* `content` IS the vision description — prose by construction,
* whatever its extension). The `format` arm covers plain-text docs;
* `doc.format` comes from the server's doc_format() (the lowercased
* suffix). Code and code-ish formats (py, yaml, json, j2, the quadlet
* family, pdf — locked A1/A3: phase 116's scroll contract stands)
* never enter this set. */
const PROSE_FORMATS = new Set(["txt", "text"]);
function isProseDoc(doc) {
return doc.is_image === true || PROSE_FORMATS.has(doc.format);
}
/* ---------- shared renderer (phase 26, task 02) ----------
* Populates the three elements every render surface provides: a title,
* a .doc-meta badge row (source · format · mono path · created ·
@@ -321,7 +346,8 @@ function copyPathButton() {
* section first (phase 36: only when doc.summary is non-empty —
* markdown docs and fail-soft rows carry none, so they render exactly
* as before), then .doc-md for md/markdown (the shared escape-first
* renderer), <pre class="doc-raw"> otherwise. The XSS contract:
* renderer), <div class="doc-prose"> for the prose set (phase 165 —
* isProseDoc), <pre class="doc-raw"> otherwise. The XSS contract:
* innerHTML only through renderMarkdown; every document-derived
* string (summary text included) is a text node. */
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
@@ -385,7 +411,7 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
// 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
// markdown/.doc-prose/.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
// importer's content/embeddings/summary are untouched); they are
@@ -467,6 +493,15 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
wrap.className = "doc-md";
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
slot.appendChild(wrap);
} else if (isProseDoc(doc)) {
// Phase 165 (task 01, LOCKED A2): the prose arm — a TEXT NODE in
// the wrapping .doc-prose block (pre-wrap carries the newlines;
// no markdown parsing of a description). The XSS contract is
// untouched: the content is never parsed as HTML.
const prose = document.createElement("div");
prose.className = "doc-prose";
prose.textContent = doc.content; // text node: never parsed as HTML
slot.appendChild(prose);
} else {
const pre = document.createElement("pre");
pre.className = "doc-raw";