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
+3 -1
View File
@@ -17,7 +17,9 @@
* stateless endpoint the /document.html page uses — identical
* percent-encoding), and renders through document.js's shared
* renderDocument: md/markdown → .doc-md via the escape-first
* renderer, other formats → <pre class="doc-raw"> via textContent.
* renderer, the prose set (phase 165 — image descriptions,
* txt/text) → <div class="doc-prose">, other formats →
* <pre class="doc-raw"> via textContent.
* A !ok / network failure renders a short "document not found" line
* in the content area.
* • closeDocumentModal() — hides the overlay, removes the Escape/Tab
+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";
+35 -1
View File
@@ -5214,7 +5214,9 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
}
.doc-date-status:empty, .doc-date-error:empty { display: none; } /* an empty live line takes no space */
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
/* Raw (non-markdown, non-prose) formats: full-width mono pre,
horizontal scroll (locked A3, phase 165 — the prose set renders in
the wrapping .doc-prose block below, never here). */
.doc-raw {
width: 100%;
margin: 0;
@@ -5230,6 +5232,33 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
line-height: 1.5;
}
/* Prose (phase 165, task 01 — TODO.md L3, LOCKED A1/A2): the image
description and plain-text (txt/text) docs as WRAPPING prose — the
.doc-md column geometry (72rem reading column, the surface card)
with pre-wrap rhythm. The defect: these docs landed in the .doc-raw
pre above (white-space: pre), so every generated line was one
unbreakable line and a long sentence scrolled sideways. The content
stays a text node (pre-wrap carries the paragraph breaks — no
markdown parsing: a description is prose, not markup). Tokens only
(no new hue — the monochrome invariant; --ink on --surface is the
documented 14.5:1 AA pair). Static content — no animation (nothing
for prefers-reduced-motion to still). Code keeps the .doc-raw
scroller (phase 116 locked A1, here locked A3). */
.doc-prose {
width: 100%;
max-width: var(--chat-column);
margin-inline: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem 1.75rem;
color: var(--ink);
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.55;
}
/* Image documents (phase 122, task 04): the viewer's image block —
the document's persistent bytes (the /api/documents/{id}/image
route) above its description (the .doc-raw content slot below). The
@@ -5358,9 +5387,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
font-weight: 700; /* pressed = weight + fill, never color alone (B5) */
}
.doc-views .doc-pages { display: none; } /* Text default — hidden lazy imgs never fetch */
/* The text views hide behind the strip on Pages. .doc-prose (phase
165, task 01) joins the list DEFENSIVELY today — an image/txt doc
never page-qualifies — and becomes load-bearing the day the owner
adds "pdf" to PROSE_FORMATS (phase 165 D1 + phase 163 A2). */
.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-prose,
.doc-views[data-view="pages"] .doc-image { display: none; }
.doc-views[data-view="pages"] .doc-pages { display: grid; }