phase: 165_prose_word_wrap
All verification complete. Final report:
**Phase 165 — Prose word wrap (final verification pass)**
- Verified: `PROSE_FORMATS={txt,text}` + `isProseDoc` (single-site, image arm wins); `renderDocument` three-way branch (md → `.doc-md`, prose → `.doc-prose` text node, else `pre.doc-raw` byte-identical); `.doc-prose` CSS = 72rem column, `pre-wrap` + `overflow-wrap: anywhere`, tokens only, zero literals; `[data-view="pages"]` hide list includes `.doc-prose` as one grouped declaration; phase-116 scrollbar rules intact and modal-scoped; no `innerHTML` in the prose path.
- Tests/lint (all 100% green):
- `uv run pytest` → **3854 passed** (unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
- `uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov` → **5 passed** (isolation)
- Isolation regressions: document_viewer 8, image_documents 4, pdf_pages_in_sources 4, pdf_strip_additive_selfheal 5, edit_summaries 4, summary_in_viewer 4, chat_answer_clusters 4, garbage_pdf 3, pdf_support 6, sticky_navbar 3, smoke 3 — all passed
- Completion criteria: image description wraps, full text, both surfaces ✔; `.txt` wraps same way ✔; `py`/`json`/code keep `pre.doc-raw` + horizontal overflow + themed modal scrollbar (E2E negative control + pins) ✔; no innerHTML, AA `--ink`/`--surface` pair ✔; full gates green ✔; commit + phase-dir move are harness-owned (not executed per pipeline rules).
- Documentation: README already updated by task 03 (viewer section + image-docs section); full README/AGENTS.md review found no further inconsistencies — **verified consistent, no changes needed**.
- Notable deviations: `.doc-prose` omits `font-family: inherit` from 00_phase D2's snippet — task 01's authoritative "exact property set" excludes it and `div` inherits fonts anyway (behavior identical). PDF deferral (A4) intact: `format == "pdf"` stays in `.doc-raw`, owner call recorded.
- Next pending phase: `166_hide_empty_answer_bubble`.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user