,
+ * pre-wrap: it WRAPS at the reading column;
* • any other → the raw content as a text node inside
*
(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), otherwise. The XSS contract:
+ * renderer), for the prose set (phase 165 —
+ * isProseDoc),
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";
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 3a3c78f..eabacf9 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -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; }
diff --git a/tests/e2e/test_chat_answer_clusters.py b/tests/e2e/test_chat_answer_clusters.py
index a8b4089..b1bf52c 100644
--- a/tests/e2e/test_chat_answer_clusters.py
+++ b/tests/e2e/test_chat_answer_clusters.py
@@ -52,8 +52,11 @@ Test → observable mapping (Playwright Mapping Rule):
BOTH docs — the read text doc first (read order), then the
auto-cited image ref (``image_url`` present; the text ref's key is
ABSENT); the last brain bubble's ``.msg-body`` direct children are
- exactly ``[tool-calls-disclosure, bubble, msg-meta, msg-meta
- msg-images, msg-meta msg-actions]`` — the chips row (EXACTLY the
+ exactly ``[reasoning, bubble, msg-meta, msg-meta msg-images,
+ msg-meta msg-actions]`` — the ONE phase-161 reasoning container
+ ABOVE the answer (this turn's tool record lives inside its
+ ``.reasoning-body`` as a ``.tool-calls-disclosure`` block, the
+ phase-130 timeline re-targeted by phase 161 D1) then the chips row (EXACTLY the
text chip — NO chip for the image doc, phase 130 task 01) before
the images row (the figure, ``
`` loaded from the bytes route,
alt + caption settling to the canned description), no related row
@@ -205,11 +208,14 @@ assert all(len(q) <= 120 for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)), (
)
#: The .msg-body child sequence of a completed scripted-read bubble
-#: (phase 130's fix, DOM-asserted): the tool record (the scripted read),
-#: the answer bubble, the chips row (the bare .msg-meta), the images
-#: row, the actions row LAST. The live and restore paths share it.
+#: (phase 130's fix, DOM-asserted; re-targeted by phase 161 D1): the
+#: ONE reasoning container (the scripted read's tool record rides
+#: INSIDE it — .reasoning .tool-calls-disclosure, asserted beside the
+#: sequence), the answer bubble, the chips row (the bare .msg-meta),
+#: the images row, the actions row LAST. The live and restore paths
+#: share it.
EXPECTED_CHILD_CLASSES = [
- "tool-calls-disclosure",
+ "reasoning",
"bubble",
"msg-meta",
"msg-meta msg-images",
@@ -759,13 +765,20 @@ def test_answer_clusters_render_in_order(
expect(bubble).to_contain_text(TEXT_TITLE)
expect(bubble).to_contain_text(READ_BODY_QUOTE)
- # The DOM ORDER (the fix): tool record, answer, chips row, images
- # row, actions row LAST — asserted exactly (this sequence fails
- # against the pre-phase soup div, where the buttons shared the
- # chips row and the figure interleaved with it).
+ # The DOM ORDER (the fix): reasoning container (tool record
+ # inside), answer, chips row, images row, actions row LAST —
+ # asserted exactly (this sequence fails against the pre-phase
+ # soup div, where the buttons shared the chips row and the figure
+ # interleaved with it).
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES, (
_body_child_classes(page)
)
+ # The tool record lives INSIDE the ONE reasoning container above
+ # the answer (phase 161 D1/D5 re-target of the phase-130
+ # timeline) — the read line carries the read document's path.
+ tool_record = _last_brain(page).locator(".reasoning .tool-calls-disclosure")
+ expect(tool_record).to_have_count(1)
+ expect(tool_record.locator(".tool-call").last).to_contain_text(TEXT_SP)
# The clusters + the actions row's contents (all three buttons —
# admin, docs repo configured) + nothing after the actions row.
@@ -817,7 +830,9 @@ def test_image_without_chip_keeps_its_citation_via_figure(
img = page.locator("#doc-modal .doc-image-img")
expect(img).to_have_count(1)
expect(img).to_have_attribute("src", seeded["image_url"])
- expect(page.locator("#doc-modal pre.doc-raw")).to_have_text(
+ # Phase 165: the description renders in the wrapping .doc-prose
+ # block (an image description is prose — no scrolling pre).
+ expect(page.locator("#doc-modal .doc-prose")).to_have_text(
seeded["description"]
)
page.keyboard.press("Escape") # the phase-26 close affordance
@@ -877,10 +892,15 @@ def test_restore_renders_the_same_clusters(
expect(restored).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS)
# The IDENTICAL structure (the restore renders through the same
- # appenders — the child sequence is the live one, exactly).
+ # appenders — the child sequence is the live one, exactly) — the
+ # segments path re-renders the reasoning container with the tool
+ # record inside (phase 161 D4).
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES == live_children, (
_body_child_classes(page)
)
+ tool_record = _last_brain(page).locator(".reasoning .tool-calls-disclosure")
+ expect(tool_record).to_have_count(1)
+ expect(tool_record.locator(".tool-call").last).to_contain_text(TEXT_SP)
_assert_cluster_rows(
page, seeded,
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
diff --git a/tests/e2e/test_document_viewer.py b/tests/e2e/test_document_viewer.py
index c6c2462..c9b137e 100644
--- a/tests/e2e/test_document_viewer.py
+++ b/tests/e2e/test_document_viewer.py
@@ -38,10 +38,13 @@ Test → story mapping (Playwright Mapping Rule):
inner content).
7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
the Phase-08 surface colour, every asset same-origin or ``data:``.
-8. ``test_raw_code_block_themed_scrollbar`` — phase 116: the raw code
- block (``pre.doc-raw``) overflows horizontally, a scroll action moves
- it, the themed scrollbar tokens are in effect via computed style, and
- a screenshot records the themed (not native light-gray) bar.
+8. ``test_raw_code_block_themed_scrollbar`` — phase 116: the raw CODE
+ block (``pre.doc-raw`` — a seeded ``.json`` doc since phase 165;
+ prose — image descriptions and ``txt``/``text`` — now WRAPS in
+ ``.doc-prose`` and never lands here) overflows horizontally, a
+ scroll action moves it, the themed scrollbar tokens are in effect
+ via computed style, and a screenshot records the themed (not native
+ light-gray) bar.
"""
from __future__ import annotations
@@ -153,14 +156,20 @@ def _seed_long_line_doc() -> None:
a non-markdown doc whose one long line is deliberately longer than
the modal width — the shape of the TODO's quest .pl ``quest::say``
line. Direct DB seed, the ``test_modal_xss_safe`` pattern (the
- viewer is database-only; no chunks needed)."""
+ viewer is database-only; no chunks needed). Phase 165: the path is
+ ``.json`` — txt/text joined the PROSE set (wrapped in .doc-prose),
+ so the code-scroll fixture is a CODE doc, keeping the locked A1
+ proof on the branch that still scrolls."""
long_line = "quest::say(\"" + ("the quick brown fox jumps over the lazy dog " * 20) + "\")"
with SessionLocal() as db:
db.add(
Document(
source="docs",
- path="notes/long-line.txt",
- full_path="/tmp/long-line.txt",
+ # Phase 165: .json, NOT .txt — txt/text joined the PROSE
+ # set (wrapped in .doc-prose); the phase-116 scroll
+ # contract binds CODE, and json stays code-ish (D1).
+ path="notes/long-line.json",
+ full_path="/tmp/long-line.json",
title="Long Line Fixture",
content=(
"# long-line-fixture: the line below is deliberately\n"
@@ -551,7 +560,7 @@ def test_raw_code_block_themed_scrollbar(
login(page, app_url)
_drill(page, "docs", "notes")
- row = page.locator("#docs-tbody tr", has_text="long-line.txt")
+ row = page.locator("#docs-tbody tr", has_text="long-line.json")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
diff --git a/tests/e2e/test_image_documents.py b/tests/e2e/test_image_documents.py
index 19b3ad0..0b65334 100644
--- a/tests/e2e/test_image_documents.py
+++ b/tests/e2e/test_image_documents.py
@@ -387,7 +387,8 @@ def test_document_viewer_renders_image_and_description(
(phase 26): the ``is_image`` content block renders the PERSISTENT
bytes first (``.doc-image-img`` from the bytes route, ``alt`` =
the summary), and the description follows in the normal content
- slot (``pre.doc-raw`` — the plain-content path). The labeled
+ slot (``.doc-prose`` — the phase-165 wrapping prose arm: an image
+ description is prose, it WRAPS, never a scrolling 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)."""
@@ -406,8 +407,9 @@ def test_document_viewer_renders_image_and_description(
expect(img).to_have_attribute("alt", seeded["description"])
# The description below the image (the doc's readable content IS
- # the vision description).
- expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
+ # the vision description) — prose (phase 165), never the pre.
+ expect(modal.locator(".doc-prose")).to_have_text(seeded["description"])
+ expect(modal.locator("pre.doc-raw")).to_have_count(0)
# The verbatim case: no duplicate Summary panel.
expect(modal.locator(".doc-summary")).to_have_count(0)
# No "Image unavailable" note — the copy is intact.
diff --git a/tests/e2e/test_pdf_support.py b/tests/e2e/test_pdf_support.py
index 8f146e6..f251042 100644
--- a/tests/e2e/test_pdf_support.py
+++ b/tests/e2e/test_pdf_support.py
@@ -546,7 +546,8 @@ def test_document_viewer_renders_the_embedded_image_doc(
"""The embedded image's row (the synthetic ``#page=2#img=0.png``
path) opens the modal: the PERSISTENT copy renders first (the bytes
route, alt = the description), the description follows in the
- content slot, and the labeled Summary panel is SUPPRESSED for the
+ WRAPPING prose block (phase 165: .doc-prose — an image description
+ is prose, never a scrolling pre), and 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)."""
@@ -563,7 +564,8 @@ def test_document_viewer_renders_the_embedded_image_doc(
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("alt", seeded["description"])
- expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
+ expect(modal.locator(".doc-prose")).to_have_text(seeded["description"])
+ expect(modal.locator("pre.doc-raw")).to_have_count(0)
# The verbatim case: no duplicate Summary panel.
expect(modal.locator(".doc-summary")).to_have_count(0)
# No "Image unavailable" note — the copy is intact.
diff --git a/tests/e2e/test_prose_word_wrap.py b/tests/e2e/test_prose_word_wrap.py
new file mode 100644
index 0000000..7f915e5
--- /dev/null
+++ b/tests/e2e/test_prose_word_wrap.py
@@ -0,0 +1,494 @@
+"""Phase 165 E2E (Playwright): prose word wrap — the TODO.md L3 claim
+("Need word wrap for image explanations and other long text rather than
+scrolling horizontally") proven end-to-end, with ZERO horizontal
+overflow, on BOTH viewer surfaces: the ``/document.html`` page and the
+in-page modal opened from a Sources row.
+
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov
+
+Seeding is the direct-DB idiom of ``tests/e2e/test_document_viewer.py``
+(``_seed_long_line_doc`` — the viewer is database-only: no chunks, no
+fixture import, no mock LLM turn) + the image-document shape of
+``tests/e2e/test_image_documents.py``: an ``is_image`` row whose
+``content`` AND ``summary`` are the vision description (the importer
+invariant, phase 122 A3) and whose ``image_path`` points at a real PNG
+written into the module's scratch dir — so the ``image_url`` bytes
+route serves REAL pixels (probed over HTTP at seed time and again
+through the rendered ``
.naturalWidth``).
+
+Three seeded docs, one per rendering arm (phase 165 D1):
+
+* ``notes/landscape.png`` — image doc (prose by construction, whatever
+ its extension): a ~860-char SINGLE-PARAGRAPH vision description;
+* ``notes/prose-long.txt`` — the ``txt`` prose arm: one ~860-char line
+ PLUS a second paragraph (proves BOTH intra-line wrap and the
+ paragraph break — ``white-space: pre-wrap`` carries the ``\\n\\n`` as
+ a blank rendered line);
+* ``notes/code-long.py`` — the NEGATIVE control (locked A1/A3): one
+ ~900-char line that MUST keep overflowing horizontally in
+ ``pre.doc-raw`` — the wrapping fix must never wrap code.
+
+Per prose doc the suite asserts, on BOTH surfaces: the prose block
+exists exactly once and carries the FULL text (nothing clamped),
+``scrollWidth <= clientWidth + 1`` (the TODO's literal defect — no
+horizontal scrollbar), the block is taller than 2.2 line-heights (the
+text really wrapped), the computed ``white-space`` is ``pre-wrap``, and
+the block rides the 72rem reading column (``max-width: 1152px``, never
+wider than its content host). The image doc additionally keeps the
+phase-122 ordering (the ``
`` ABOVE the prose) and the suppressed
+Summary panel (``summary === content``).
+
+Test → observable mapping (Playwright Mapping Rule):
+1. ``test_image_doc_description_wraps_on_the_page`` — the standalone
+ viewer page: image above the wrapping ``.doc-prose``, full text,
+ zero horizontal overflow, summary suppressed, no ``pre.doc-raw``.
+2. ``test_image_doc_description_wraps_in_the_modal`` — the same doc
+ through the Sources-row modal: identical contract on
+ ``#doc-modal-content``.
+3. ``test_txt_doc_wraps_on_the_page`` — the ``.txt`` body on the page:
+ full raw text, the ``\\n\\n`` preserved AND rendered as a blank
+ line, no horizontal overflow, 72rem column.
+4. ``test_txt_doc_wraps_in_the_modal`` — the same through the modal.
+5. ``test_code_doc_still_scrolls_horizontally`` — the locked code path
+ on both surfaces: ``pre.doc-raw``, ``white-space: pre``,
+ ``scrollWidth > clientWidth`` (phase 116 A1 alive E2E, not just in
+ the CSS pins).
+"""
+from __future__ import annotations
+
+import base64
+from collections.abc import Iterator
+from datetime import UTC, datetime
+from urllib.parse import quote
+
+import httpx
+import pytest
+from playwright.sync_api import Page, expect
+from sqlalchemy import text
+
+from app.db import SessionLocal
+from app.models import Document
+from e2e.auth_helpers import login
+
+SOURCE = "docs"
+IMAGE_PATH = "notes/landscape.png"
+TXT_PATH = "notes/prose-long.txt"
+CODE_PATH = "notes/code-long.py"
+
+#: The mock-free stand-in for a vision description: ONE single-line
+#: paragraph (no ``\\n`` at all — exactly the shape that used to become
+#: one unbreakable line in the old ``pre.doc-raw``), long enough that
+#: rendering it unwrapped would overflow any viewport.
+DESCRIPTION = (
+ "A wide screenshot of the homelab dashboard on the wall display: the top row shows four "
+ "gauge panels, CPU load at eighteen percent, memory at six of thirty-two gigabytes, disk at "
+ "forty-one percent across the merged pool, and network throughput peaking at four hundred "
+ "megabits during the nightly backup window, while the middle band lists every service as a "
+ "green pill (proxmox, pihole, nginx proxy manager, gitea, immich, postgres, valkey, and the "
+ "rag indexer), and the bottom strip is a time-series graph of requests per second whose "
+ "legend reads ingress, api, and embeddings; the sidebar carries the clock, a weather "
+ "widget, and a small map pin marking the rack location beside the hall closet. Every label "
+ "on the capture stays legible at full width, from the uptime counter in the corner to the "
+ "footnote that names the exporter version and the scrape interval."
+)
+
+#: The ``.txt`` body: ONE ~860-char line (the intra-line wrap proof)
+#: plus a second paragraph (the paragraph-break proof).
+_FOX = "the quick brown fox jumps over the lazy dog "
+TXT_LINE = "notes/prose-long.txt reads: " + _FOX * 18
+TXT_PARA2 = (
+ "The second paragraph exists to prove the paragraph BREAK survives the prose block: "
+ "pre-wrap carries it."
+)
+TXT_CONTENT = TXT_LINE + "\n\n" + TXT_PARA2
+
+#: The negative control: one ~900-char code line — locked A1 (phase
+#: 116): code NEVER wraps, it keeps the horizontal scroller.
+CODE_LINE = "result = render_report(" + '"' + _FOX * 20 + '"' + ")"
+assert len(DESCRIPTION) >= 800, "the vision description must be a long single line"
+assert "\n" not in DESCRIPTION, "the description must be ONE line (the un-wrapped defect shape)"
+assert len(TXT_LINE) >= 800 and "\n" not in TXT_LINE
+assert len(CODE_LINE) >= 800 and "\n" not in CODE_LINE
+
+#: A real 1×1 transparent PNG (the phase-122 fixture bytes — the route
+#: serves the real bytes; naturalWidth 1 is the served-proof probe).
+PNG_1X1 = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
+ "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
+)
+
+
+# ---------------------------------------------------------------------------
+# DB helpers (the test_document_viewer direct-seed idiom)
+# ---------------------------------------------------------------------------
+
+
+def _truncate_kb() -> None:
+ """Fresh KB per module run (the E2E isolation pattern of
+ tests/e2e/test_image_documents.py — every catalogue table the
+ seeded rows could touch resets, so leftovers from an earlier
+ suite can never corrupt this one's counts or rows)."""
+ with SessionLocal() as db:
+ db.execute(text(
+ "TRUNCATE chunks, documents, query_log, kb_overview, "
+ "git_sources, sources_meta, folder_summaries"
+ ))
+ db.commit()
+
+
+@pytest.fixture(scope="module")
+def seeded(app_url: str, tmp_path_factory: pytest.TempPathFactory) -> Iterator[dict[str, str]]:
+ """Seed the three docs (image / txt / code) straight into the KB —
+ the viewer is database-only, so no chunks and no LLM are involved
+ (the ``_seed_long_line_doc`` pattern) — and write the image PNG
+ into the module's scratch dir so ``image_path`` points at bytes the
+ PUBLIC bytes route actually serves (probed here over HTTP with the
+ right Content-Type). Yields the identity map for the assertions;
+ truncates on teardown."""
+ try:
+ body = httpx.get(f"{app_url}/api/health", timeout=5).json()
+ except Exception: # noqa: BLE001 — unreachable is the skip case
+ body = {"db": "down"}
+ if body.get("db") != "up":
+ pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
+
+ _truncate_kb()
+ scratch = tmp_path_factory.mktemp("bor_prose_wrap")
+ png = scratch / "landscape.png"
+ png.write_bytes(PNG_1X1)
+ with SessionLocal() as db:
+ image_doc = Document(
+ source=SOURCE,
+ path=IMAGE_PATH,
+ full_path=str(png),
+ title="Landscape Screenshot",
+ content=DESCRIPTION,
+ summary=DESCRIPTION, # the importer invariant: summary IS the description
+ is_image=True,
+ image_path=str(png),
+ content_hash="a1" * 32,
+ indexed_at=datetime.now(UTC),
+ )
+ db.add(image_doc)
+ db.add(
+ Document(
+ source=SOURCE,
+ path=TXT_PATH,
+ full_path="/tmp/prose-long.txt",
+ title="Prose Long Txt",
+ content=TXT_CONTENT,
+ content_hash="b2" * 32,
+ indexed_at=datetime.now(UTC),
+ )
+ )
+ db.add(
+ Document(
+ source=SOURCE,
+ path=CODE_PATH,
+ full_path="/tmp/code-long.py",
+ title="Code Long Py",
+ content=CODE_LINE + "\n",
+ content_hash="c3" * 32,
+ indexed_at=datetime.now(UTC),
+ )
+ )
+ db.commit()
+ image_url = f"/api/documents/{image_doc.id}/image"
+
+ # The seeded image_url serves REAL bytes (phase 122's route is
+ # public — phase 124 A4): the render assertions below are about a
+ # genuinely delivered image, never a broken one.
+ served = httpx.get(f"{app_url}{image_url}", timeout=10)
+ assert served.status_code == 200, "the seeded image bytes must serve through the route"
+ assert served.headers["content-type"] == "image/png"
+
+ yield {
+ "image_url": image_url,
+ "description": DESCRIPTION,
+ }
+ _truncate_kb()
+
+
+# ---------------------------------------------------------------------------
+# Surface helpers
+# ---------------------------------------------------------------------------
+
+
+def _drill(page: Page, *names: str) -> None:
+ """Phase 97: drill the catalogue tree (exact name match, one per
+ name) to the level that holds the asserted file (the
+ test_document_viewer idiom)."""
+ for name in names:
+ page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
+
+
+def _open_doc_page(page: Page, app_url: str, doc_path: str) -> None:
+ """The STANDALONE surface: sign in, then the direct
+ /document.html?source=…&path=… URL (the same percent-encoding the
+ chips and row links carry)."""
+ login(page, app_url, next="/")
+ page.goto(
+ f"{app_url}/document.html?source={SOURCE}&path={quote(doc_path, safe='')}",
+ timeout=30_000,
+ )
+
+
+def _open_doc_modal(page: Page, app_url: str, path_needle: str) -> None:
+ """The IN-PAGE modal surface: the Sources row's path link (phase
+ 26 — no navigation, the modal opens over the catalogue)."""
+ login(page, app_url)
+ _drill(page, "docs", "notes")
+ row = page.locator("#docs-tbody tr", has_text=path_needle)
+ expect(row).to_have_count(1)
+ row.locator("td:nth-child(2) a.doc-link").click()
+ expect(page.locator("#doc-modal")).to_be_visible(timeout=30_000)
+
+
+def _assert_wrapping_prose(page: Page, content_sel: str, full_text: str) -> dict[str, object]:
+ """The phase-165 prose contract on ONE surface's content host:
+
+ * exactly ONE ``.doc-prose`` block, NO ``pre.doc-raw`` sibling;
+ * the FULL text — to_have_text (whitespace-normalized) AND the raw
+ ``textContent`` byte-for-byte (nothing clamped, nothing dropped);
+ * ``scrollWidth <= clientWidth + 1`` — THE TODO's literal defect:
+ no horizontal overflow, no sideways scrollbar (an 858-char
+ unwrapped line would report ~6000px here — this bar alone proves
+ the wrap once the full raw text is present);
+ * ``height > 2.2 × line-height`` — the block stands several line
+ boxes tall (the task's proof bar; with the two bars above it
+ means the FULL text occupies real wrapped lines, not a clipped
+ or scrolled single line);
+ * computed ``white-space == "pre-wrap"`` and ``max-width``
+ resolving to the 72rem reading column (1152px at the 16px root),
+ never wider than the content host.
+ """
+ prose = page.locator(f"{content_sel} .doc-prose")
+ expect(prose).to_have_count(1)
+ expect(prose).to_have_text(full_text, timeout=30_000)
+ expect(page.locator(f"{content_sel} pre.doc-raw")).to_have_count(0)
+ m = prose.evaluate(
+ """(el) => {
+ const host = el.parentElement;
+ const cs = getComputedStyle(el);
+ return {
+ raw: el.textContent,
+ inner: el.innerText,
+ scrollWidth: el.scrollWidth,
+ clientWidth: el.clientWidth,
+ height: el.getBoundingClientRect().height,
+ width: el.getBoundingClientRect().width,
+ hostWidth: host.getBoundingClientRect().width,
+ whiteSpace: cs.whiteSpace,
+ maxWidth: cs.maxWidth,
+ lineHeight: parseFloat(cs.lineHeight),
+ };
+ }"""
+ )
+ assert m["raw"] == full_text, "the prose block must hold the FULL content (raw equality)"
+ assert m["scrollWidth"] <= m["clientWidth"] + 1, (
+ f"horizontal overflow — the TODO defect is back: {m}"
+ )
+ assert m["lineHeight"] > 0, f"line-height did not resolve to px: {m}"
+ assert m["height"] > 2.2 * m["lineHeight"], (
+ f"the block is {m['height']}px tall, want > 2.2 line-heights "
+ f"({2.2 * m['lineHeight']:.0f}px) — the text did not wrap"
+ )
+ assert m["whiteSpace"] == "pre-wrap", f"white-space is {m['whiteSpace']!r}, not pre-wrap"
+ assert m["maxWidth"] == "1152px", (
+ f"max-width is {m['maxWidth']!r}, want 1152px (the 72rem --chat-column)"
+ )
+ assert m["width"] <= m["hostWidth"] + 1, (
+ f"the block ({m['width']}px) is wider than its content host ({m['hostWidth']}px)"
+ )
+ return m
+
+
+def _assert_code_scrolls(page: Page, content_sel: str) -> None:
+ """The locked code contract (phase 116 A1 / phase 165 A3) on ONE
+ surface: the code doc renders ``pre.doc-raw`` with
+ ``white-space: pre`` + ``overflow-x: auto`` and REAL horizontal
+ overflow — no prose block anywhere."""
+ pre = page.locator(f"{content_sel} pre.doc-raw")
+ expect(pre).to_have_count(1, timeout=30_000)
+ expect(pre).to_have_text(CODE_LINE + "\n", timeout=30_000)
+ expect(page.locator(f"{content_sel} .doc-prose")).to_have_count(0)
+ dims = pre.evaluate(
+ """(el) => {
+ const cs = getComputedStyle(el);
+ return {
+ sw: el.scrollWidth,
+ cw: el.clientWidth,
+ ws: cs.whiteSpace,
+ ox: cs.overflowX,
+ };
+ }"""
+ )
+ assert dims["sw"] > dims["cw"], f"the code line stopped overflowing: {dims}"
+ assert dims["ws"] == "pre", f"code white-space is {dims['ws']!r}, not pre (A3 broken)"
+ assert dims["ox"] == "auto", f"overflow-x is {dims['ox']!r}, not auto"
+
+
+# ---------------------------------------------------------------------------
+# 1–2. The image doc: the vision description wraps on both surfaces
+# ---------------------------------------------------------------------------
+
+
+def test_image_doc_description_wraps_on_the_page(
+ page: Page, app_url: str, seeded: dict[str, str]
+) -> None:
+ """The standalone /document.html page: the ``is_image`` doc renders
+ the ``
`` (from the served bytes route) ABOVE the prose block
+ (the phase-122 ordering), the labeled Summary panel stays
+ SUPPRESSED (summary === content), and the full ~860-char
+ description reads as wrapping prose — zero horizontal overflow,
+ pre-wrap, the 72rem column, no ``pre.doc-raw`` anywhere."""
+ page.set_default_timeout(30_000)
+ _open_doc_page(page, app_url, IMAGE_PATH)
+ expect(page.locator("#doc-title")).to_have_text("Landscape Screenshot")
+
+ _assert_wrapping_prose(page, "#doc-content", seeded["description"])
+
+ # The image is served AND rendered, and it sits ABOVE the prose
+ # (phase 122's ordering contract — the image first, the wrapping
+ # description below it).
+ img = page.locator("#doc-content .doc-image-img")
+ expect(img).to_have_count(1)
+ expect(img).to_have_attribute("src", seeded["image_url"])
+ expect(img).to_have_attribute("alt", seeded["description"])
+ expect(img).to_be_visible()
+ assert img.evaluate("el => el.naturalWidth") == 1, "the bytes route must deliver real PNG bytes"
+ assert page.evaluate(
+ """() => {
+ const host = document.querySelector('#doc-content');
+ const img = host.querySelector('.doc-image-img');
+ const prose = host.querySelector('.doc-prose');
+ return (img.compareDocumentPosition(prose)
+ & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
+ }"""
+ ), "the
must render ABOVE the prose block (phase 122 ordering)"
+
+ # The verbatim-description case keeps the Summary panel suppressed
+ # (the panel would duplicate the text right below the image).
+ expect(page.locator("#doc-content .doc-summary")).to_have_count(0)
+ expect(page.locator("#doc-content .doc-image-unavailable")).to_have_count(0)
+
+
+def test_image_doc_description_wraps_in_the_modal(
+ page: Page, app_url: str, seeded: dict[str, str]
+) -> None:
+ """The SAME doc through the Sources-row modal: the identical prose
+ contract on ``#doc-modal-content`` — one shared renderDocument core
+ means the modal wraps exactly like the page (no per-surface drift),
+ inside the narrower (1100px) panel."""
+ page.set_default_timeout(30_000)
+ _open_doc_modal(page, app_url, "landscape.png")
+ expect(page.locator("#doc-modal-title")).to_have_text("Landscape Screenshot")
+
+ _assert_wrapping_prose(page, "#doc-modal-content", seeded["description"])
+
+ img = page.locator("#doc-modal-content .doc-image-img")
+ expect(img).to_have_count(1)
+ expect(img).to_have_attribute("src", seeded["image_url"])
+ assert img.evaluate("el => el.naturalWidth") == 1
+ assert page.evaluate(
+ """() => {
+ const host = document.querySelector('#doc-modal-content');
+ const img = host.querySelector('.doc-image-img');
+ const prose = host.querySelector('.doc-prose');
+ return (img.compareDocumentPosition(prose)
+ & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
+ }"""
+ ), "the modal must keep the
ABOVE the prose block too"
+ expect(page.locator("#doc-modal-content .doc-summary")).to_have_count(0)
+
+
+# ---------------------------------------------------------------------------
+# 3–4. The .txt doc: a 860-char line wraps, the paragraph break survives
+# ---------------------------------------------------------------------------
+
+
+def _assert_paragraph_break(m: dict[str, object]) -> None:
+ """The SECOND paragraph proof (beyond the wrap proof in
+ _assert_wrapping_prose): the raw text node keeps the ``\\n\\n``
+ verbatim, and the presentation text (``innerText``, which in a
+ pre-wrap context carries the SOURCE hard breaks — soft wraps are
+ rendered line boxes, not innerText newlines) still shows the
+ three-paragraph source shape: long line, BLANK line, second
+ paragraph. The intra-line wrap itself is the scrollWidth/height
+ proof above — the 858-char source line is one innerText line that
+ measurably does NOT overflow horizontally."""
+ raw = str(m["raw"])
+ inner = str(m["inner"])
+ assert "\n\n" in raw, "the raw text node must keep the paragraph break"
+ assert "\n\n" in inner, (
+ "the pre-wrap presentation text must keep the blank paragraph line"
+ )
+ lines = inner.splitlines()
+ assert len(lines) == 3 and lines[1] == "", (
+ f"the paragraph shape is not (line, blank, paragraph): {lines}"
+ )
+
+
+def test_txt_doc_wraps_on_the_page(
+ page: Page, app_url: str, seeded: dict[str, str]
+) -> None:
+ # `seeded` is the module's seed+truncate barrier (this test reads
+ # the txt row it creates, not its return value).
+ """The standalone page, the ``txt`` prose arm: the ~860-char line
+ wraps inside the reading column (zero horizontal overflow — the
+ TODO's literal ask), the full body is present raw, the paragraph
+ break renders as a blank line, ``pre-wrap`` + the 72rem column are
+ in effect, and no ``pre.doc-raw`` remains for a prose format."""
+ page.set_default_timeout(30_000)
+ _open_doc_page(page, app_url, TXT_PATH)
+ expect(page.locator("#doc-title")).to_have_text("Prose Long Txt")
+ expect(page.locator("#doc-meta .format-badge")).to_have_text("txt")
+
+ m = _assert_wrapping_prose(page, "#doc-content", TXT_CONTENT)
+ _assert_paragraph_break(m)
+
+
+def test_txt_doc_wraps_in_the_modal(
+ page: Page, app_url: str, seeded: dict[str, str]
+) -> None:
+ # `seeded`: the module's seed barrier (see test 3).
+ """The same ``.txt`` doc through the modal: identical wrapping on
+ ``#doc-modal-content`` (the shared core, the shared CSS)."""
+ page.set_default_timeout(30_000)
+ _open_doc_modal(page, app_url, "prose-long.txt")
+ expect(page.locator("#doc-modal-title")).to_have_text("Prose Long Txt")
+
+ m = _assert_wrapping_prose(page, "#doc-modal-content", TXT_CONTENT)
+ _assert_paragraph_break(m)
+
+
+# ---------------------------------------------------------------------------
+# 5. The negative control: code keeps the horizontal scroller (locked A1/A3)
+# ---------------------------------------------------------------------------
+
+
+def test_code_doc_still_scrolls_horizontally(
+ page: Page, app_url: str, seeded: dict[str, str]
+) -> None:
+ # `seeded`: the module's seed barrier (see test 3).
+ """The wrapping fix is provably SCOPED: the ~900-char code line
+ still lands in ``pre.doc-raw`` and STILL overflows horizontally
+ with ``white-space: pre`` + ``overflow-x: auto`` on BOTH surfaces
+ — phase 116's locked A1 ("wrapping breaks code readability") alive
+ end-to-end, not just in the CSS pins (phase 165 D3)."""
+ page.set_default_timeout(30_000)
+
+ # Surface 1: the standalone page.
+ _open_doc_page(page, app_url, CODE_PATH)
+ expect(page.locator("#doc-title")).to_have_text("Code Long Py")
+ expect(page.locator("#doc-meta .format-badge")).to_have_text("py")
+ _assert_code_scrolls(page, "#doc-content")
+
+ # Surface 2: the in-page modal (the phase-116 themed scrollbar
+ # owner — tests/e2e/test_document_viewer.py pins the bar itself;
+ # here the overflow contract is what must not have drifted).
+ _open_doc_modal(page, app_url, "code-long.py")
+ expect(page.locator("#doc-modal-title")).to_have_text("Code Long Py")
+ _assert_code_scrolls(page, "#doc-modal-content")
diff --git a/tests/e2e/test_sticky_navbar.py b/tests/e2e/test_sticky_navbar.py
index 7b2b3a4..fa57ace 100644
--- a/tests/e2e/test_sticky_navbar.py
+++ b/tests/e2e/test_sticky_navbar.py
@@ -27,7 +27,10 @@ the short-page stretch. This suite proves the BROWSER behavior:
* the short-page layout is EXACTLY as it was: body stretched to the
viewport, footer pinned to the viewport bottom, the phase-52
pinned composer (``position: sticky; bottom: env(safe-area-inset-
- bottom)``) resting in its in-flow slot above the footer.
+ bottom)``) resting in its in-flow slot above the footer. The pin
+ is POST-boot (phase 88 keeps the cluster static until app.js adds
+ ``#view-chat.chat-booted``), so test 3 waits for that sentinel
+ before reading it — the house boot-settle pattern.
Seeding (A3): the long-scroll surfaces are the seeded Sources table
(``tests/fixtures/docs/`` — 13 docs, same harness as the phase-10/26
@@ -37,14 +40,27 @@ short unique docs + one ``doc-long.md`` with a ~40,000-char body).
mock embeddings server (the ``test_document_viewer.py`` pattern) — no
real LLM is involved.
+Phase 156 re-target (owner-locked A1): the catalogue card became the
+tables' OWN bounded scroller (``.table-wrap`` max-height
+min(70vh, 64rem)), so a Sources file level no longer grows the
+DOCUMENT past a full viewport — its scroll is the residual chrome
+height. Test 1 therefore proves the stick across the page's REAL
+scroll range (mid + bottom, card scroller pinned too); the DEEP-scroll
+proof (the audit's ~−1264px repro, scroll far past one viewport) is
+test 2's long-document surface, which still grows the document many
+viewports.
+
Test → story mapping (Playwright Mapping Rule):
-1. ``test_app_header_stuck_at_top_on_sources``
-2. ``test_doc_header_stuck_at_top_on_long_document``
+1. ``test_app_header_stuck_at_top_on_sources`` — stuck mid-page AND at
+ the page bottom; the 41-row table scrolls inside the phase-156 card
+2. ``test_doc_header_stuck_at_top_on_long_document`` — stuck at the
+ bottom of a many-viewport scroll (the deep-scroll proof)
3. ``test_short_page_stretch_and_resting_composer_intact``
"""
from __future__ import annotations
import asyncio
+import re
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
@@ -223,26 +239,49 @@ def test_app_header_stuck_at_top_on_sources(
page.click('#folders-tbody a.folder-link:text-is("gen")')
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS - 13)
- # The precondition the story needs: the page must actually scroll —
- # fail loudly if the table ever stops being long enough.
+ # Phase 156 (owner-locked A1): the 41-row table scrolls INSIDE the
+ # bounded card (max-height min(70vh, 64rem)) — the card is the rows'
+ # scroller, and the DOCUMENT keeps the residual page scroll (header
+ # / stats / toolbar / padding / footer). Preconditions, both fail
+ # loudly: the card must actually scroll (every row reachable), and
+ # the page must still scroll at all.
sh = page.evaluate("() => document.documentElement.scrollHeight")
- assert sh > 1.5 * VIEWPORT_H, (
- f"the Sources table must be long enough to scroll "
- f"(scrollHeight={sh}, want > {1.5 * VIEWPORT_H:.0f} at a "
- f"{VIEWPORT_W}×{VIEWPORT_H} viewport) — add more generated docs"
+ assert sh > VIEWPORT_H + 200, (
+ f"the Sources page must be scrollable (scrollHeight={sh}, want > "
+ f"{VIEWPORT_H + 200} at a {VIEWPORT_W}×{VIEWPORT_H} viewport) — "
+ "the page collapsed to a fixed layout"
)
+ card = page.evaluate(
+ """() => {
+ const c = document.querySelector(
+ ".table-wrap[aria-label='Indexed documents']");
+ return c ? { sh: c.scrollHeight, ch: c.clientHeight } : null;
+ }"""
+ )
+ assert card is not None and card["sh"] > card["ch"], (
+ f"the phase-156 card must be the table's scroller "
+ f"(scrollHeight={None if card is None else card['sh']}, "
+ f"clientHeight={None if card is None else card['ch']})"
+ )
+ max_scroll = sh - VIEWPORT_H
- # Mid-page scroll (~1 viewport down — the pre-fix un-pin point): the
- # navbar is still stuck at the top.
- page.evaluate("() => window.scrollTo(0, 800)")
+ # Mid-page scroll (half the document's available scroll): the
+ # navbar is still stuck at the top. The DEEP-scroll proof — a scroll
+ # far past one viewport, the audit's ~−1264px repro — is test 2's
+ # long-document surface: phase 156's bounded card left Sources as
+ # the residual-scroll page.
+ page.evaluate(f"() => window.scrollTo(0, {max_scroll // 2})")
assert (
- page.evaluate("() => window.scrollY") >= VIEWPORT_H - TOP_TOL_PX
+ abs(page.evaluate("() => window.scrollY") - max_scroll // 2) <= TOP_TOL_PX
), "the test scroll must land"
_assert_top_is_zero(page, ".app-header")
- # And at the very bottom (the audit's −1264px repro): stuck, and
- # still visible.
+ # And at the very bottom of the page scroll: stuck, and still
+ # visible.
page.evaluate("() => window.scrollTo(0, 999999)")
+ assert page.evaluate("() => window.scrollY") >= max_scroll - TOP_TOL_PX, (
+ "the test scroll must land at the document bottom"
+ )
_assert_top_is_zero(page, ".app-header")
expect(page.locator(".app-header")).to_be_visible()
@@ -290,12 +329,32 @@ def test_doc_header_stuck_at_top_on_long_document(
# ---------------------------------------------------------------------------
+def _wait_chat_booted(page: Page) -> None:
+ """The house boot-settle wait (test_chip_sizing_question_cap.py /
+ test_mobile_chat_hamburger_boot.py): app.js adds
+ ``#view-chat.chat-booted`` two frames AFTER the boot settles. The
+ phase-88 pre-boot rule
+ (``#view-chat:not(.chat-booted) .composer { position: static }``)
+ keeps the cluster NON-sticky until then — reading the pin before
+ the sentinel lands races app boot (the flake this removes: the
+ sticky assertion measured a pre-boot static composer)."""
+ page.wait_for_function(
+ "() => document.getElementById('view-chat')?."
+ "classList.contains('chat-booted')",
+ timeout=15_000,
+ )
+ expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b"))
+
+
def test_short_page_stretch_and_resting_composer_intact(
page: Page, app_url: str
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
expect(page.locator("#empty-state")).to_be_visible()
+ # The pin under test only exists POST-boot (phase 88 defers the
+ # sticky promotion) — settle the boot BEFORE any measurement.
+ _wait_chat_booted(page)
# The stretch holds: the body is exactly one viewport tall, and the
# grow must not invent scrollable space.
diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py
index df64a31..b7c958f 100644
--- a/tests/unit/test_document_viewer.py
+++ b/tests/unit/test_document_viewer.py
@@ -83,6 +83,17 @@ loosened):
bar, the phase-12/34 breach) + the ``#doc-copy-announcer``
(role=status) + the ``#doc-meta`` row the shared core builds the
control into.
+
+Phase 165 (task 01) — the prose content block (TODO.md L3): the
+prose-set constant (``PROSE_FORMATS`` + ``isProseDoc`` — the ONE
+single-site rule), ``renderDocument``'s three-way content branch
+(markdown → ``.doc-md``, prose (image descriptions, txt/text) →
+``.doc-prose`` via textContent (never innerHTML), everything else →
+``.doc-raw`` untouched), the ``isProseDoc`` truth table and the
+format table as node behavioral runs over the real module, and the
+``.doc-prose`` CSS (the 72rem reading column + ``pre-wrap`` +
+``overflow-wrap: anywhere``, tokens only) beside the ``.doc-raw``
+contract that stays locked (phase 116 A1 / phase 165 A3).
"""
from __future__ import annotations
@@ -1810,3 +1821,284 @@ def test_page_render_wires_the_copy_path_with_the_composed_string() -> None:
assert out["failOut"]["selectCount"] == 1
assert out["failOut"]["status"] == "Path ready — copy it from the field."
assert out["failOut"]["className"] == "doc-copy-pop" # the floating block
+
+
+# ---------------------------------------------------------------------------
+# Phase 165 (task 01) — the prose content block: PROSE_FORMATS /
+# isProseDoc / renderDocument's third branch / the .doc-prose CSS
+# (TODO.md L3 — image descriptions and txt/text docs WRAP)
+# ---------------------------------------------------------------------------
+
+STYLES_CSS = FRONTEND / "assets" / "styles.css"
+
+
+def _content_branch(js: str) -> str:
+ """The THREE-WAY content branch of renderDocument: from the
+ markdown ``if`` to the close of the raw ``else`` (the exact end
+ anchor: the raw arm's ``slot.appendChild(pre);`` + its closing
+ brace — the _stmt_block brace-walk would swallow the trailing
+ strip-mount statement, so this slice is literal)."""
+ start = js.index("if (doc.format === \"md\" || doc.format === \"markdown\") {")
+ end = js.index("slot.appendChild(pre);\n }") + len("slot.appendChild(pre);\n }")
+ return js[start:end]
+
+
+def _prose_branch(js: str) -> str:
+ """The prose arm alone: ``} else if (isProseDoc(doc)) {`` to its
+ closing brace after ``slot.appendChild(prose);``."""
+ start = js.index("} else if (isProseDoc(doc)) {")
+ end = js.index("slot.appendChild(prose);\n }") + len("slot.appendChild(prose);\n }")
+ return js[start:end]
+
+
+def _prose_rule(js: str) -> str:
+ """The phase-165 prose rule at module scope: the PROSE_FORMATS
+ const through the close of isProseDoc (extracted together — the
+ function references the const; the _fn brace-walk house pattern)."""
+ start = js.index("const PROSE_FORMATS")
+ end = js.index("}", js.index("function isProseDoc")) + 1
+ return js[start:end]
+
+
+def test_prose_set_is_one_named_constant() -> None:
+ """LOCKED A1: the prose rule lives in ONE named constant pair —
+ ``const PROSE_FORMATS = new Set(["txt", "text"])`` +
+ ``isProseDoc(doc)`` with the image arm UNCONDITIONAL (phase 122
+ A3 — an image doc's content IS the vision description, prose by
+ construction). Grep-verified single-site rule: the content-branch
+ block itself names no format literal — it calls isProseDoc."""
+ js = _read(DOCUMENT_JS)
+ rule = _prose_rule(js)
+ assert 'const PROSE_FORMATS = new Set(["txt", "text"]);' in rule
+ assert "doc.is_image === true || PROSE_FORMATS.has(doc.format)" in rule
+ branch = _content_branch(js)
+ assert "isProseDoc(doc)" in branch, "the prose arm gates on the ONE helper"
+ assert '"txt"' not in branch and '"text"' not in branch, (
+ "no second format literal in the content branch (single-site rule)"
+ )
+
+
+def test_content_branch_is_three_way_md_prose_raw() -> None:
+ """renderDocument's content branch is markdown → prose → raw, in
+ that order, all three appending into the SAME ``slot`` (phase 122
+ image block FIRST, phase 36 summary between, phase 163 additive
+ strip last — all unchanged). The markdown and raw arms stay
+ byte-identical to pre-phase (locked A3); the prose arm is
+ textContent-only — the XSS contract (never innerHTML for document
+ content) is PINNED on the branch source."""
+ js = _read(DOCUMENT_JS)
+ render = _fn(js, "renderDocument")
+ md_i = render.find('if (doc.format === "md" || doc.format === "markdown") {')
+ prose_i = render.find("} else if (isProseDoc(doc)) {", md_i)
+ raw_i = render.find("} else {", prose_i)
+ assert -1 < md_i < prose_i < raw_i, "the prose arm sits BETWEEN markdown and raw"
+ # The untouched markdown / raw arms (byte-identical contracts).
+ for stmt in (
+ 'wrap.className = "doc-md";',
+ "wrap.innerHTML = renderMarkdown(doc.content);",
+ "slot.appendChild(wrap);",
+ 'pre.className = "doc-raw";',
+ "pre.textContent = doc.content;",
+ "slot.appendChild(pre);",
+ ):
+ assert stmt in render, f"the pre-phase arms must keep {stmt!r}"
+ # The prose arm: div + class + text node + the same slot.
+ prose_branch = _prose_branch(js)
+ assert 'document.createElement("div")' in prose_branch
+ assert 'prose.className = "doc-prose";' in prose_branch
+ assert "prose.textContent = doc.content;" in prose_branch
+ assert "slot.appendChild(prose);" in prose_branch
+ assert "innerHTML" not in prose_branch, (
+ "the prose arm never touches innerHTML (the XSS contract)"
+ )
+
+
+#: (doc, expected isProseDoc) — LOCKED A1's exact set:
+#: {is_image: true} ∪ {format ∈ txt, text}; code, code-ish, pdf,
+#: unknown, extension-less (no format) and the string-``"true"``
+#: lookalike all stay on the scrolling side (locked A3).
+_PROSE_CASES: list[tuple[dict[str, object], bool]] = [
+ ({"is_image": True, "format": "png"}, True),
+ ({"is_image": True, "format": "webp"}, True),
+ ({"is_image": True, "format": "md"}, True), # the image arm wins
+ ({"is_image": True, "format": "pdf"}, True), # …regardless of format
+ ({"is_image": True}, True), # …even without one
+ ({"format": "txt"}, True),
+ ({"format": "text"}, True),
+ ({"is_image": False, "format": "txt"}, True),
+ ({"is_image": False, "format": "text"}, True),
+ ({"is_image": "true", "format": "py"}, False), # strict === true
+ ({"is_image": False, "format": "py"}, False),
+ ({"is_image": False, "format": "yaml"}, False),
+ ({"is_image": False, "format": "yml"}, False),
+ ({"is_image": False, "format": "json"}, False),
+ ({"is_image": False, "format": "j2"}, False),
+ ({"is_image": False, "format": "container"}, False), # quadlet family
+ ({"is_image": False, "format": "network"}, False),
+ ({"is_image": False, "format": "volume"}, False),
+ ({"is_image": False, "format": "kube"}, False),
+ ({"is_image": False, "format": "pdf"}, False), # phase 165 A4 (deferred)
+ ({"is_image": False, "format": "md"}, False),
+ ({"is_image": False, "format": "markdown"}, False),
+ ({"is_image": False, "format": "dockerfile"}, False),
+ ({"is_image": False, "format": "rst"}, False), # unknown suffix
+ ({"is_image": False, "format": ""}, False),
+ ({"is_image": False}, False), # extension-less (no format at all)
+]
+
+
+@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
+def test_is_prose_doc_truth_table() -> None:
+ """Behavioral (node): the REAL isProseDoc over the full truth
+ table — a future format cannot drift into prose by accident
+ (phase 165 D3's pin)."""
+ js = _read(DOCUMENT_JS)
+ script = (
+ _prose_rule(js)
+ + "\nconst cases = "
+ + json.dumps([c for c, _ in _PROSE_CASES])
+ + ";\nconsole.log(JSON.stringify(cases.map((d) => isProseDoc(d))));\n"
+ )
+ assert json.loads(_run_node(script)) == [e for _, e in _PROSE_CASES]
+
+
+# The renderDocument content-branch harness: the minimal fake DOM of
+# test_viewer_additive_toggle.py (the house pattern), the real
+# docImageBlock standing in for the image arm's first block.
+_CONTENT_BRANCH_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"); }
+"""
+
+
+@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
+def test_render_document_content_branch_format_table() -> None:
+ """Behavioral (node, fake DOM): the REAL renderDocument per
+ format — txt/text → .doc-prose (the TODO's wrapping ask), md /
+ markdown → .doc-md (the escape-first renderer, unchanged), and
+ py/yaml/json/j2/quadlet/pdf/""/unknown → .doc-raw (locked A1/A3:
+ the code scroller unchanged). An is_image doc lands in
+ .doc-prose whatever its format — never a doc-raw sibling."""
+ js = _read(DOCUMENT_JS)
+ script = (
+ _CONTENT_BRANCH_FAKE_DOM
+ + _prose_rule(js)
+ + _fn(js, "renderDocument")
+ + _fn(js, "docImageBlock")
+ + """
+const base = {
+ title: "T", path: "p", source: "S", content: "BODY", summary: null,
+ created_at: "c", indexed_at: "i", chunks: 1,
+};
+function classesFor(doc) {
+ const contentEl = makeEl("div");
+ renderDocument(doc, { titleEl: makeEl("h1"), metaEl: makeEl("div"), contentEl });
+ return contentEl.children.map((c) => c.className);
+}
+const out = {};
+for (const format of ["md", "markdown", "txt", "text", "py", "yaml", "yml",
+ "json", "j2", "conf", "pdf", "container", "network", "volume", "kube",
+ "dockerfile", "rst", ""]) {
+ out[format] = classesFor({ ...base, format });
+}
+out["image:png"] = classesFor({ ...base, format: "png", is_image: true,
+ image_url: "/api/documents/x/image" });
+console.log(JSON.stringify(out));
+"""
+ )
+ got = json.loads(_run_node(script))
+ expected = {
+ "md": ["doc-md"],
+ "markdown": ["doc-md"],
+ "txt": ["doc-prose"],
+ "text": ["doc-prose"],
+ "py": ["doc-raw"],
+ "yaml": ["doc-raw"],
+ "yml": ["doc-raw"],
+ "json": ["doc-raw"],
+ "j2": ["doc-raw"],
+ "conf": ["doc-raw"],
+ "pdf": ["doc-raw"], # phase 165 A4: PDF text stays raw (owner call)
+ "container": ["doc-raw"],
+ "network": ["doc-raw"],
+ "volume": ["doc-raw"],
+ "kube": ["doc-raw"],
+ "dockerfile": ["doc-raw"],
+ "rst": ["doc-raw"],
+ "": ["doc-raw"],
+ "image:png": ["doc-image", "doc-prose"], # image block first, prose below
+ }
+ assert got == expected
+
+
+def test_doc_prose_css_wraps_in_the_reading_column() -> None:
+ """styles.css: the ``.doc-prose`` rule (immediately after
+ ``.doc-raw``) is the .doc-md column with prose rhythm —
+ ``white-space: pre-wrap`` + ``overflow-wrap: anywhere`` (the
+ TODO's literal no-horizontal-scroll ask) inside the 72rem
+ ``var(--chat-column)`` reading column, phase-08 TOKENS only (zero
+ color literals — the :root-drift contract; --ink on --surface is
+ the documented AA pair), no animation. The ``.doc-raw`` rule
+ keeps the locked code contract (A3): ``white-space: pre`` +
+ ``overflow-x: auto``."""
+ css = _read(STYLES_CSS)
+ raw_i = css.find(".doc-raw {")
+ prose_i = css.find(".doc-prose {")
+ assert -1 < raw_i < prose_i, ".doc-prose must sit immediately after .doc-raw"
+ # Provenance comment directly above the rule (house style) — it
+ # cites the TODO, the defect and the locked decisions.
+ comment_start = css.rfind("/*", 0, prose_i)
+ provenance = css[comment_start:prose_i]
+ assert -1 < comment_start < prose_i
+ for citation in ("phase 165", "TODO.md L3", ".doc-md", ".doc-raw", "A1", "A2"):
+ assert citation in provenance, f"the comment must cite {citation!r}"
+ assert "no animation" in provenance
+ rule = css[prose_i : css.find("}", prose_i)]
+ for decl in (
+ "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",
+ ):
+ assert decl in rule, f".doc-prose must carry {decl!r}"
+ # Tokens only — zero color literals (the :root-drift test parses
+ # built-ins from this file; no new hue — the monochrome invariant).
+ assert not re.search(r"#[0-9a-fA-F]{3,8}\b", rule)
+ assert "rgb(" not in rule and "hsl(" not in rule and "color-mix(" not in rule
+ # The untouched code scroller (phase 116 locked A1, phase 165 A3).
+ raw_rule = css[raw_i : css.find("}", raw_i)]
+ assert "white-space: pre" in raw_rule
+ assert "overflow-x: auto" in raw_rule
diff --git a/tests/unit/test_image_documents.py b/tests/unit/test_image_documents.py
index 89e4b68..770181c 100644
--- a/tests/unit/test_image_documents.py
+++ b/tests/unit/test_image_documents.py
@@ -39,7 +39,10 @@ frontend source contracts: the viewer renders the ``
`` 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
+
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
diff --git a/tests/unit/test_prose_word_wrap.py b/tests/unit/test_prose_word_wrap.py
new file mode 100644
index 0000000..d1241a6
--- /dev/null
+++ b/tests/unit/test_prose_word_wrap.py
@@ -0,0 +1,409 @@
+"""Unit: phase 165 task 02 — the word-wrap fix is PROVABLY SCOPED.
+
+TODO.md L3 asks for word wrap on "image explanations and other long
+text". Phase 116's LOCKED A1 keeps horizontal scroll (NO line-wrap) for
+the code content — "wrapping breaks code readability" — so the fix must
+wrap exactly the prose set and nothing else. These pins make a future
+"we fixed wrapping" mean "we wrapped prose", never "we wrapped code" or
+"we wrapped wide tables":
+
+* **the prose set is pinned as data** — ``PROSE_FORMATS`` holds exactly
+ ``"txt"`` and ``"text"`` (a set-membership assertion over the source
+ line: adding a format is a deliberate, test-editing act), and
+ ``isProseDoc`` tests ``doc.is_image`` BEFORE the format arm (phase 122
+ A3 — an image doc's ``content`` IS the vision description, prose by
+ construction whatever its extension);
+* **the code scrollers are unchanged** — ``.doc-raw`` keeps
+ ``white-space: pre`` + ``overflow-x: auto``, ``.bubble pre`` keeps
+ ``overflow-x: auto`` with NO ``white-space`` added (the chat code
+ block), and ``.md-table-wrap`` / ``.md-table`` still carry the
+ phase-44 declaration (``overflow-x: auto`` on the wrapper,
+ ``min-width: max-content`` on the table) so wide tables still scroll
+ sideways inside the reading column;
+* **phase 116's scrollbar theme still applies** — the four
+ ``.doc-modal .doc-raw`` rules (``scrollbar-width``,
+ ``scrollbar-color`` and the three ``::-webkit-scrollbar`` pseudos) are
+ all present and still SCOPED to the modal (no global scrollbar
+ restyle — the TODO's scope);
+* **``.doc-prose`` never reaches for the raw scroller** — the block has
+ no ``overflow-x`` and no ``white-space: pre;``, only ``pre-wrap`` +
+ ``overflow-wrap: anywhere`` (the wrapping contract);
+* **the AA/contrast pin for the new block** — ``color: var(--ink)`` on
+ ``background: var(--surface)`` (the documented 14.5:1 pair, PLAN
+ §7.2) and ZERO color literals in the block (the phase-92
+ zero-literal / monochrome invariant — the ``:root``-drift test parses
+ built-ins out of styles.css).
+
+Repo-hygiene finding (task 02 step 3, ``grep -rn "doc-raw" frontend/``)
+— recorded here so the check is repeatable and its result on record:
+``doc-raw`` appears in exactly TWO writers, both expected. (1) The raw
+arm of ``app.renderDocument`` in ``frontend/assets/document.js`` (the
+ONE viewer core, ``pre.className = "doc-raw"``) plus its doc comments;
+(2) ``frontend/assets/styles.css`` (the base rule, the mobile squeeze,
+the phase-116 modal scrollbar rules and the ``[data-view="pages"]``
+hide list). Every other hit in ``frontend/`` is a comment.
+``frontend/doc-edit.html`` + ``assets/doc-edit.js`` do NOT render
+document content at all — they fill a ``