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