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
+292
View File
@@ -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
+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
+409
View File
@@ -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 ``<textarea>`` (``bodyInput.value
= draft.body``, textContent for the status/error lines) for the admin
draft editor, so the body never becomes markup and there is no scroller
to wrap; ``frontend/shared.html`` + ``assets/shared.js`` render
CHAT history through ``renderMarkdown`` into ``.bubble`` (the shared
renderer's own ``.md-table-wrap`` / ``.bubble pre`` scrollers, asserted
byte-identical above), never ``.doc-raw`` / ``.doc-md`` /
``.doc-prose``. Neither is a second content renderer, so the prose rule
correctly lives in the ONE place: ``isProseDoc`` + ``.doc-prose``.
ASSUMPTION (phase 165 A4, owner call — recorded in ``00_phase.md``
"Deferred"): PDF-extracted text (``format == "pdf"``) is deliberately
OUT of the prose set and stays in ``.doc-raw``. Three completed suites
pin ``pre.doc-raw`` for PDFs (``test_pdf_pages_in_sources.py``,
``test_pdf_strip_additive_selfheal.py``, ``test_garbage_pdf.py``) and
the phase-163 strip contract depends on it; changing it is an owner
call, one line (``PROSE_FORMATS.add("pdf")`` + those pins) once made —
not an executor choice and not an oversight.
All assertions are static-source (the ``test_markdown_tables.py`` /
``test_date_editor.py`` house pattern): no app, no browser. The
measured wrapping proof is the phase's E2E suite
(``tests/e2e/test_prose_word_wrap.py``, task 03).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
assert DOCUMENT_JS.is_file(), f"missing frontend file: {DOCUMENT_JS}"
return DOCUMENT_JS.read_text(encoding="utf-8")
def _css() -> str:
assert STYLES_CSS.is_file(), f"missing frontend file: {STYLES_CSS}"
return STYLES_CSS.read_text(encoding="utf-8")
def _rule(selector: str, css: str | None = None) -> str:
"""The FULL rule for ``selector`` (selector text + its ``{…}``
block) matched at line start, so ``.doc-raw`` cannot accidentally
match ``.doc-modal .doc-raw`` or ``.doc-views … .doc-raw`` (the
``_rule_block`` brace-walk of ``test_wide_column_css.py``)."""
text = _css() if css is None else css
m = re.search(rf"^{re.escape(selector)} \{{", text, re.MULTILINE)
assert m, f"styles.css must define a rule for {selector}"
start = text.index("{", m.start())
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[m.start() : i + 1]
raise AssertionError(f"unbalanced braces in the {selector} rule")
def _decls(selector: str) -> str:
"""Only the declaration block of ``selector`` (the braces stripped)."""
rule = _rule(selector)
return rule[rule.index("{") + 1 : rule.rindex("}")]
def _rules() -> list[tuple[str, str]]:
"""Every (selector, declarations) pair in styles.css, comments
stripped (so a provenance comment naming a property never counts as
a declaration) — the scope pins below walk this list."""
text = re.sub(r"/\*.*?\*/", "", _css(), flags=re.DOTALL)
pairs: list[tuple[str, str]] = []
for m in re.finditer(r"([^{}]+)\{", text):
selector = re.sub(r"\s+", " ", m.group(1)).strip()
# An at-rule header (@media …) is a container, not a selector.
if not selector or selector.startswith("@"):
continue
start = m.end()
depth = 1
i = start
while i < len(text) and depth:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
pairs.append((selector, text[start : i - 1]))
return pairs
def _hide_list_rule() -> str:
"""The phase-163 ``[data-view="pages"]`` hide list — a MULTI-selector
rule (the selectors are comma-separated lines, ``.doc-prose``
joined it in task 01), so it is sliced from its first selector to
the ``display: none; }`` that closes it rather than matched with
the single-selector ``_rule`` helper."""
css = _css()
start = css.index('.doc-views[data-view="pages"] .doc-summary')
end = css.index("display: none; }", start) + len("display: none; }")
return css[start:end]
def _code(text: str) -> str:
"""Comment-free source: block comments (``/* … */``, the JSDoc
headers that legitimately NAME the classes they describe) and
whole-line ``//`` comments stripped — so a writer scan counts CODE
only, not prose about the code."""
no_block = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
return "\n".join(
line for line in no_block.splitlines() if not line.lstrip().startswith("//")
)
def _prose_rule_js(js: str) -> str:
"""The phase-165 prose rule at module scope: ``const
PROSE_FORMATS`` through the close of ``isProseDoc`` (the
``test_document_viewer.py`` extraction — the function references
the const, so they are read as one unit)."""
start = js.index("const PROSE_FORMATS")
end = js.index("}", js.index("function isProseDoc")) + 1
return js[start:end]
# ---------- the prose set is pinned as data (LOCKED A1) ----------
def test_prose_formats_set_contains_exactly_txt_and_text() -> None:
"""``PROSE_FORMATS`` contains EXACTLY {"txt", "text"} — parsed out
of the source line as a set of quoted members, so adding (or
renaming) a format fails this test and has to be an intentional,
test-editing decision (phase 165 D3: "a future format cannot drift
into prose by accident"). Code, code-ish and pdf are NOT here."""
line = re.search(r"const PROSE_FORMATS = new Set\(\[(.*?)\]\);", _js())
assert line, "PROSE_FORMATS must be a Set literal at module scope"
members = set(re.findall(r'"([^"]*)"', line.group(1)))
assert members == {"txt", "text"}, (
f"the prose format set is {sorted(members)}, not exactly ['text', 'txt'] "
"(locked A1: image descriptions + plain-text prose only)"
)
# The literal is the only place those strings gate the content arm.
assert len(re.findall(r"const PROSE_FORMATS", _js())) == 1, (
"the prose set is declared ONCE (the single-site rule)"
)
def test_is_prose_doc_reads_is_image_before_the_format_arm() -> None:
"""``isProseDoc`` tests ``doc.is_image === true`` FIRST and the
``PROSE_FORMATS`` arm second (locked A1 + phase 122 A3): the image
arm is unconditional, so an image doc is prose whatever its
extension — and the strict ``=== true`` keeps a string lookalike
out (the truth table in test_document_viewer.py covers behavior)."""
rule = _prose_rule_js(_js())
returns = re.findall(r"return (.*?);", rule)
assert len(returns) == 1, f"isProseDoc is a one-return predicate, got {returns}"
body = returns[0]
image_i = body.index("doc.is_image")
format_i = body.index("PROSE_FORMATS.has(doc.format)")
assert image_i < format_i, "the is_image arm must be evaluated first"
assert body[image_i:].startswith("doc.is_image === true ||"), (
"the image arm is strict (=== true) and ORs into the format arm"
)
# ---------- the code scrollers are present and unchanged (A3) ----------
def test_doc_raw_keeps_the_locked_pre_and_horizontal_scroller() -> None:
""".doc-raw — the code/code-ish/unknown/PDF content block — is
byte-identical to the pre-phase declaration in the two properties
that ARE the contract: white-space: pre (no wrapping) and
overflow-x: auto (the scroller). Phase 116's locked A1, restated
verbatim as phase 165's locked A3."""
decls = _decls(".doc-raw")
assert re.search(r"white-space:\s*pre;", decls), (
".doc-raw must keep white-space: pre — code never wraps (locked A3)"
)
assert re.search(r"overflow-x:\s*auto", decls), (
".doc-raw must keep overflow-x: auto — the horizontal scroller"
)
assert "pre-wrap" not in decls, ".doc-raw must not gain pre-wrap"
def test_bubble_pre_scrolls_and_never_gains_a_white_space_rule() -> None:
"""The chat/table-surface code block: .bubble pre still scrolls
sideways (overflow-x: auto) and still declares NO white-space —
the phase-165 fix touched the viewer's prose arm only, so a code
span in a chat answer wraps exactly as it did before (wrapping
breaks code readability — the reason behind phase 116's locked A1)."""
decls = _decls(".bubble pre")
assert re.search(r"overflow-x:\s*auto", decls), (
".bubble pre must keep its horizontal scroller"
)
assert "white-space" not in decls, (
".bubble pre must carry no white-space declaration (unchanged)"
)
def test_markdown_table_scrollers_match_the_phase_44_declaration() -> None:
"""Wide GFM tables still scroll, never wrap: .md-table-wrap is
STILL the one-declaration scroller (overflow-x: auto) and
.md-table still carries the phase-44 task-03 set —
border-collapse + width: 100% + min-width: max-content + 0.9rem —
byte-for-byte, so the wrapper stays the real scroller and the
72rem reading column never breaks."""
assert _rule(".md-table-wrap") == ".md-table-wrap { overflow-x: auto; }", (
"the table wrapper must stay the single-declaration horizontal scroller"
)
decls = _decls(".md-table")
for declaration in (
"border-collapse: collapse",
"width: 100%",
"min-width: max-content",
"font-size: 0.9rem",
):
assert declaration in decls, f".md-table must keep {declaration!r}"
# ---------- phase 116's scrollbar theme still applies (A1) ----------
def test_phase_116_modal_scrollbar_rules_are_all_present_and_scoped() -> None:
"""The four .doc-modal .doc-raw rules — scrollbar-width: thin,
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track),
and the ::-webkit-scrollbar / -track / -thumb pseudos — survive
this phase intact and stay SCOPED under .doc-modal (no global
scrollbar restyle). What still lands in .doc-raw still gets the
themed bar: the prose block has no scrollbar to theme because it
never overflows horizontally."""
css = _css()
firefox = _rule(".doc-modal .doc-raw")
assert re.search(r"scrollbar-width:\s*thin", firefox)
assert re.search(
r"scrollbar-color:\s*var\(--scrollbar-thumb\) var\(--scrollbar-track\)", firefox
)
for pseudo, needle in (
(".doc-modal .doc-raw::-webkit-scrollbar", "height: 8px"),
(".doc-modal .doc-raw::-webkit-scrollbar-track", "background: var(--scrollbar-track)"),
(".doc-modal .doc-raw::-webkit-scrollbar-thumb", "background: var(--scrollbar-thumb)"),
):
rule = _rule(pseudo, css)
assert needle in rule, f"{pseudo} must keep {needle!r}"
# Scope pin (phase 116: "no global scrollbar restyle"): every rule
# that themes a scrollbar — a scrollbar-color declaration or a
# ::-webkit-scrollbar pseudo — is scoped under `.doc-modal .doc-raw`.
themed = [
sel
for sel, decls in _rules()
if "scrollbar-color" in decls or "scrollbar" in sel
]
assert len(themed) == 4, f"expected the 4 modal scrollbar rules, got {themed}"
for selector in themed:
assert selector.startswith(".doc-modal .doc-raw"), (
f"scrollbar rule {selector!r} escaped the modal scope (phase 116 scope pin)"
)
# The prose block never asks for a themed bar (it has none to theme).
assert not [s for s, d in _rules() if ".doc-prose" in s and "scrollbar" in d + s], (
".doc-prose must carry no scrollbar theming"
)
# ---------- .doc-prose never reaches for the raw scroller ----------
def test_doc_prose_declares_no_horizontal_scroller_and_no_pre() -> None:
""".doc-prose is the OPPOSITE contract of .doc-raw: no overflow-x
of any kind, and white-space is pre-WRAP (never the bare ``pre``
that made every generated line unbreakable). The wrapping comes
from white-space: pre-wrap + overflow-wrap: anywhere inside the
72rem column — nothing else."""
decls = _decls(".doc-prose")
assert "overflow-x" not in decls, (
".doc-prose must never declare a horizontal scroller (the TODO's ask)"
)
assert re.search(r"white-space:\s*pre-wrap", decls)
assert not re.search(r"white-space:\s*pre;", decls), (
".doc-prose must not carry the bare pre (the defect this phase fixes)"
)
assert re.search(r"overflow-wrap:\s*anywhere", decls)
assert re.search(r"max-width:\s*var\(--chat-column\)", decls), (
"the prose block rides the 72rem reading column"
)
# ---------- the AA/contrast pin for the new block ----------
def test_doc_prose_uses_the_documented_aa_pair_and_zero_literals() -> None:
""".doc-prose pairs color: var(--ink) with background:
var(--surface) — the documented 14.5:1 pair (PLAN §7.2, WCAG AA) —
and carries NO color literal at all (no hex, no rgb()/rgba(), no
hsl(), no color-mix): the phase-92 zero-literal invariant that
keeps the block correct under every saved theme, and what the
:root-drift test relies on when it parses styles.css."""
decls = _decls(".doc-prose")
assert re.search(r"color:\s*var\(--ink\)", decls)
assert re.search(r"background:\s*var\(--surface\)", decls)
for pattern, name in (
(r"#[0-9a-fA-F]{3,8}\b", "hex"),
(r"\brgba?\(", "rgb()/rgba()"),
(r"\bhsla?\(", "hsl()/hsla()"),
(r"color-mix\(", "color-mix()"),
):
assert not re.search(pattern, decls), f".doc-prose must carry no {name} literal"
# ---------- the raw writers are the two expected ones (step 3) ----------
def test_doc_raw_writers_are_the_raw_branch_and_the_phase_116_css_only() -> None:
"""The task-02 hygiene pass as an executable pin: the only FILE that
writes the doc-raw element is document.js's raw arm (one
``className`` assignment, inside renderDocument), and the only
other doc-raw writers in frontend/ are styles.css rules (the base
rule, the mobile squeeze, the phase-116 modal scrollbar rules and
the [data-view="pages"] hide list). doc-edit.js renders a
<textarea> and shared.js renders chat bubbles — neither is a second
content renderer, so neither needs the prose rule (see the module
docstring for the full finding)."""
js = _js()
assert js.count('className = "doc-raw"') == 1, (
"document.js must assign doc-raw in exactly one place (the raw arm)"
)
# A WRITER is code that emits the class (JS className/innerHTML, an
# HTML class attribute) — a doc comment naming it is not one.
writer = re.compile(r'className\s*=\s*"doc-raw"|class="[^"]*\bdoc-raw\b')
for path in sorted(FRONTEND.rglob("*")):
if not path.is_file() or path.suffix not in {".js", ".html"}:
continue
writes = len(writer.findall(_code(path.read_text(encoding="utf-8"))))
if path == DOCUMENT_JS:
assert writes == 1, f"{path.name} must hold exactly the one raw arm"
else:
assert writes == 0, (
f"{path.name} must not write doc-raw — the writers are "
"renderDocument's raw arm and styles.css only"
)
# The two expected writers DO hold it (the pin is not vacuous).
assert "doc-raw" in _css(), "styles.css is the second expected doc-raw writer"
def test_prose_block_mounts_where_the_raw_block_did() -> None:
"""The prose arm appends into the SAME ``slot`` the raw arm uses,
so the phase-122 image block stays FIRST, the phase-36 summary
panel between, and the phase-163 additive strip last — the ordering
both surfaces rely on. The [data-view="pages"] hide list carries
.doc-prose alongside .doc-raw (the additive/PDF case's text view),
so Pages still hides the text view for a prose doc."""
js = _js()
for arm in ("wrap", "prose", "pre"):
assert js.count(f"slot.appendChild({arm});") == 1, (
f"the {arm} arm must mount into slot exactly once"
)
# One slot binding, three arms (the shared mount point), and the
# strip still lands in the wrapper last.
assert "let slot = contentEl;" in js, "the three arms share one slot binding"
assert "views.appendChild(docPagesStrip(doc, pageUrls));" in js
hide = _hide_list_rule()
for cls in (".doc-summary", ".doc-md", ".doc-raw", ".doc-prose", ".doc-image"):
assert cls in hide, f"the [data-view=\"pages\"] hide list must include {cls}"
assert hide.count("display: none;") == 1, "the hide list stays ONE grouped declaration"
+24 -6
View File
@@ -82,6 +82,15 @@ def _code(js: str) -> str:
return re.sub(r"//.*?$|/\*.*?\*/", "", js, flags=re.S | re.M)
def _prose_rule(js: str) -> str:
"""The phase-165 prose rule (the PROSE_FORMATS const + isProseDoc,
module scope) — renderDocument's prose gate, needed beside the
extracted functions for the node runs."""
start = js.index("const PROSE_FORMATS")
end = js.index("}", js.index("function isProseDoc")) + 1
return js[start:end]
def _run_node(script: str) -> str:
proc = subprocess.run(
["node", "-e", script], capture_output=True, text=True, timeout=60
@@ -193,8 +202,9 @@ def test_view_toggle_css() -> None:
style, never color alone, B5) + the CSS-driven view swap
(``.doc-views .doc-pages`` hidden by default; ``[data-view=
"pages"]`` hides the Summary panel / .doc-md / .doc-raw /
.doc-image and shows the strip as the grid). Provenance comment
above the block (house style)."""
.doc-prose (phase 165, task 01) / .doc-image and shows the strip as
the grid — one grouped declaration). Provenance comment above the
block (house style)."""
css = _read(STYLES_CSS)
toggle_i = css.find(".doc-view-toggle {")
assert toggle_i != -1, "styles.css must style .doc-view-toggle"
@@ -212,10 +222,18 @@ def test_view_toggle_css() -> None:
"the pressed state is text weight + fill — never color alone (B5)"
)
# The CSS-driven swap: hidden strip by default; pages hides the
# text view and shows the strip.
# text view and shows the strip. The hide list is ONE grouped
# declaration (phase 165: .doc-prose joins it — defensive today,
# load-bearing if the owner ever adds "pdf" to PROSE_FORMATS).
assert '.doc-views .doc-pages { display: none; }' in css
for sel in (".doc-summary", ".doc-md", ".doc-raw", ".doc-image"):
assert f'.doc-views[data-view="pages"] {sel}' in css
expected = ",\n".join(
f'.doc-views[data-view="pages"] {sel}'
for sel in (".doc-summary", ".doc-md", ".doc-raw", ".doc-prose", ".doc-image")
) + " { display: none; }"
assert css.count(expected) == 1, (
"the text-view hide list is exactly ONE grouped declaration "
"over .doc-summary/.doc-md/.doc-raw/.doc-prose/.doc-image"
)
assert '.doc-views[data-view="pages"] .doc-pages { display: grid; }' in css
@@ -266,7 +284,7 @@ function describe(el) {
def _render_script() -> str:
js = _read(DOCUMENT_JS)
parts = [
parts = [_prose_rule(js)] + [
_fn(js, n)
for n in ("renderDocument", "docViewToggle", "viewToggleButton", "docPagesStrip")
]
+12 -6
View File
@@ -17,9 +17,12 @@ without a browser:
``.shared-shell``, ``.doc-md``, ``.doc-image`` (phase 122, task 04),
``.doc-pages`` (phase 148, task 03 — the page strip),
``.doc-view-toggle`` (phase 163, task 04 — the Text/Pages toggle
above the additive views), and ``.doc-summary:has(+ .doc-md)`` —
each capped with ``max-width: var(--chat-column)`` and NOTHING else
in the file uses the token for a max-width (exactly seven rules);
above the additive views), ``.doc-prose`` (phase 165, task 01 — the
wrapping prose block: image descriptions and txt/text docs ride the
SAME reading column as their .doc-md sibling), and
``.doc-summary:has(+ .doc-md)`` — each capped with
``max-width: var(--chat-column)`` and NOTHING else
in the file uses the token for a max-width (exactly eight rules);
* the flipped negative pin — ZERO literal ``max-width: 46rem`` rules
remain (the phase-27/59/91 form-column caps are retired): the form
shells (``.tuning-shell``, ``.theme-shell``, ``.doc-edit-shell``)
@@ -124,8 +127,10 @@ def test_the_reading_columns_use_the_token() -> None:
viewer's page strip rides the SAME reading column in the shared
#doc-content slot), .doc-view-toggle (phase 163, task 04 — the
Text/Pages toggle sits above the additive views, aligned with
them) and .doc-summary:has(+ .doc-md) each cap with
max-width: var(--chat-column) — and exactly those seven rules use
them), .doc-prose (phase 165, task 01 — the wrapping prose block
is .doc-md's column with prose rhythm) and
.doc-summary:has(+ .doc-md) each cap with
max-width: var(--chat-column) — and exactly those eight rules use
the token for a max-width (no other selector)."""
css = _css()
for selector in (
@@ -135,12 +140,13 @@ def test_the_reading_columns_use_the_token() -> None:
".doc-image",
".doc-pages",
".doc-view-toggle",
".doc-prose",
".doc-summary:has(+ .doc-md)",
):
assert "max-width: var(--chat-column)" in _rule_block(css, selector), (
f"{selector} must cap with max-width: var(--chat-column)"
)
assert css.count("max-width: var(--chat-column)") == 7, (
assert css.count("max-width: var(--chat-column)") == 8, (
"exactly the reading-column selectors use the token"
)