phase: 165_prose_word_wrap
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user