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
+176 -3
View File
@@ -39,7 +39,10 @@ frontend source contracts: the viewer renders the ``<img>`` from
Sources row's 48px lazy thumbnail falls back to the document glyph on
a failed fetch (the glyph swap keeps the module's single-innerHTML
pin). The ext→mime map pin (task 03's ``IMAGE_MIMES`` — one map, one
truth with the serve route's ``Content-Type``).
truth with the serve route's ``Content-Type``). Phase 165 (task 01,
TODO.md L3): the description now renders in the WRAPPING ``.doc-prose``
block, not the horizontally-scrolling ``.doc-raw`` pre — the node
behavioral pins for the image-doc content branch close this module.
Task 05 (the RAG display): the shared frame builder
(``retriever.source_ref_with_image`` — the chat API's cited AND
@@ -1038,8 +1041,10 @@ def test_viewer_renders_the_image_block_from_image_url_with_alt_summary() -> Non
assert 'note.className = "doc-image-unavailable"' in js
assert 'note.setAttribute("role", "status")' in js
assert "Image unavailable" in js
# The description stays in the normal content slot below (the
# existing plain-content path — doc-raw via textContent).
# The content slot below is a text node (never parsed as HTML) —
# the raw arm's statement; phase 165 (task 01) moved the IMAGE
# description to the wrapping .doc-prose arm (the node behavioral
# pins at the end of this module).
assert "pre.textContent = doc.content" in js
# ONE innerHTML in the module CODE (the XSS contract, unchanged —
# the escape-first markdown render; comments may name it, the
@@ -1489,3 +1494,171 @@ def test_chat_sources_figure_css_is_compact_and_themed() -> None:
"the hover is the flat underline (no background swap)"
)
assert "background" not in (hover.group(1) if hover else "")
# ---------------------------------------------------------------------------
# Phase 165 (task 01) — the image-doc content branch renders the
# description as WRAPPING prose (.doc-prose), never the scrolling pre
# (TODO.md L3: "Need word wrap for image explanations ... rather than
# scrolling horizontally"). Node behavioral runs over the real
# renderDocument + docImageBlock on the minimal fake DOM (the
# test_document_viewer.py house pattern).
# ---------------------------------------------------------------------------
import json # noqa: E402
import shutil # noqa: E402
import subprocess # noqa: E402
HAVE_NODE = shutil.which("node") is not None
_IMAGE_FAKE_DOM = """
function makeEl(tag) {
return {
tag, className: "", textContent: "", innerHTML: "", attrs: {}, children: [],
setAttribute(k, v) { this.attrs[k] = String(v); },
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
appendChild(c) { this.children.push(c); return c; },
append(...cs) { this.children.push(...cs); },
replaceChildren(...cs) { this.children = cs; },
addEventListener() {},
};
}
globalThis.document = { createElement: (t) => makeEl(t) };
function fmtDate(x) { return "DATE:" + x; }
function metaBadge(cls, text, title) {
const s = makeEl("span");
s.className = cls;
s.textContent = text;
if (title !== undefined) s.setAttribute("title", title);
return s;
}
function copyPathButton() { return makeEl("button"); }
function originAnchor() { return makeEl("a"); }
async function docAdminReady() { return false; }
function wireDateEdit() {}
function wireSummaryEdit() {}
function renderMarkdown(s) { return s; }
function docPagesStrip() { return makeEl("div"); }
"""
def _run_node(script: str) -> str:
proc = subprocess.run(
["node", "-e", script], capture_output=True, text=True, timeout=60
)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
def _fn(js: str, name: str) -> str:
"""The source of a top-level function (paren-walk THEN brace-walk —
the test_viewer_additive_toggle.py pattern; the module's
_function_body starts at the first brace, which on renderDocument
is the destructured parameter, not the body)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in document.js"
i = js.find("(", start)
depth = 0
close = i
while i < len(js):
if js[i] == "(":
depth += 1
elif js[i] == ")":
depth -= 1
if depth == 0:
close = i
break
i += 1
brace = js.find("{", close)
depth = 0
for j in range(brace, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {name}()")
def _image_render_script() -> str:
"""The real renderDocument + docImageBlock + the phase-165 prose
rule (the PROSE_FORMATS const, which isProseDoc reads — extracted
with the function because the const lives outside it)."""
js = DOCUMENT_JS.read_text(encoding="utf-8")
prose_start = js.index("const PROSE_FORMATS")
prose_end = js.index("}", js.index("function isProseDoc")) + 1
parts = [
js[prose_start:prose_end],
_fn(js, "renderDocument"),
_fn(js, "docImageBlock"),
]
return _IMAGE_FAKE_DOM + "\n".join(parts)
_DESCRIPTION = (
"A network diagram of the homelab VLANs: the core switch, the router, "
"and three labeled subnets with their gateways."
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_image_doc_content_branch_is_doc_prose() -> None:
"""The image doc's content branch (phase 165, LOCKED A1/A2): the
<img> block FIRST, then the description as a .doc-prose text node
— className "doc-prose", textContent the FULL description, and NO
doc-raw sibling (the pre was the defect: white-space: pre made
every generated line unbreakable). The verbatim-description
Summary suppression is unchanged (summary === content → no panel)."""
out = _run_node(
_image_render_script()
+ f"""
const doc = {{
title: "pic", path: "pic.png", source: "S", format: "png", is_image: true,
image_url: "/api/documents/x/image", summary: {json.dumps(_DESCRIPTION)},
content: {json.dumps(_DESCRIPTION)},
created_at: "c", indexed_at: "i", chunks: 2,
}};
const contentEl = makeEl("div");
renderDocument(doc, {{ titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }});
console.log(JSON.stringify(contentEl.children.map(
(c) => ({{ className: c.className, text: c.textContent }}))));
"""
)
children = json.loads(out)
assert [c["className"] for c in children] == ["doc-image", "doc-prose"], (
"the image block stays FIRST; the description is prose (phase 165) "
"— no doc-raw sibling and no duplicate Summary panel"
)
assert children[1]["text"] == _DESCRIPTION, "the full description, as a text node"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_image_doc_edited_summary_renders_above_the_prose_block() -> None:
"""The admin-edited-summary case (summary !== content): the
Summary panel renders between the image and the prose exactly as
before (phase 36/57 shape unchanged) — and the content still lands
in .doc-prose, never the pre."""
out = _run_node(
_image_render_script()
+ f"""
const doc = {{
title: "pic", path: "pic.png", source: "S", format: "png", is_image: true,
image_url: "/api/documents/x/image", summary: "An admin note.",
content: {json.dumps(_DESCRIPTION)},
created_at: "c", indexed_at: "i", chunks: 2,
}};
const contentEl = makeEl("div");
renderDocument(doc, {{ titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl }});
console.log(JSON.stringify(contentEl.children.map(
(c) => ({{ className: c.className, text: c.textContent }}))));
"""
)
children = json.loads(out)
assert [c["className"] for c in children] == [
"doc-image",
"doc-summary",
"doc-prose",
]
assert children[1]["text"] == "" # the panel body carries the summary text
assert children[2]["text"] == _DESCRIPTION