phase: 100_page_width_consistency
**Phase 100 — final verification pass: all green.** - Verified the shipped CSS contract directly: `--chat-column: 72rem` in `:root`; 0 literal `max-width: 46rem`; no `@media (min-width: 1500px)` block; exactly 4 token-capped reading columns; tuning/theme/doc-edit shells cap-free, structurally `.sources-shell`; `mock_llm.py` diff is comment-only. - Defect found & fixed (phase-93 suite): `test_theme_semantic_completion.py::test_reset_removes_tag_byte_identical` raced theme.js's post-PUT refetch — it asserted the `#bor-theme` tag was gone right after the result line, but tag removal lands in the reconcile after the re-fetch (failed ~1 in 5 runs after `test_chat_rag`, reproduced). Fixed with an auto-waiting `expect(...).to_have_count(0)` (settled-state wait, the file's existing house pattern). 6/6 clean on the repro loop after. - Tests/lint/coverage: `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/`; `uv run ruff check .` + `uv run pyright` → 0 errors. - E2E in isolation (all passed): `test_wide_desktop_column.py` 3 (the phase suite — chat==tuning==theme==RAG ±4px at 1280 & 1920, ≈1152px; shared ≈1152px; standalone doc ≈1112px; modal unchanged ≈1100px; 360px overflow-free), `test_ui_customization` 4, `test_admin_theme_tab` 5, `test_document_viewer` 7, `test_save_share_ux` 5, `test_sticky_navbar` 3, `test_markdown_tables` 6, `test_responsive_polish` 7, `test_chat_rag` 3, `test_theme_semantic_completion` 8. - Completion criteria: ① measured 72rem everywhere (≥~1200px, ±4px) + full-width below + 360px clean — **PASS** (E2E); ② zero 46rem rules / no 1500px block / four token selectors — **PASS** (grep + unit pins); ③ B4 byte-identical no-op + mobile squeeze — **PASS** (theme + responsive suites green); ④ full suite / coverage / lint — **PASS**; ⑤ atomic commit — left to the harness per executor protocol (all changes in the working tree, uncommitted). - Deviations: none from phase decisions; only change in this pass is the race fix above (test-only, behavior unchanged). - Next pending phase: `98_sync_summary_visibility` (numeric order in `todo/`; also pending: 99, 101–105).
This commit is contained in:
@@ -327,7 +327,8 @@ Implements just enough of the aipi surface:
|
||||
a deliberately wide 5-column table — byte-stable, so the story E2E
|
||||
can assert the rendered ``<table class="md-table">`` shape, the
|
||||
escaped XSS line, and the wrapper's horizontal scroll inside the
|
||||
46rem column. Checked BEFORE the ``DEFLECT_MODE`` branch (a
|
||||
72rem container column (phase 100). Checked BEFORE the
|
||||
``DEFLECT_MODE`` branch (a
|
||||
deflection prompt never carries the marker, same reasoning as
|
||||
``SUMMARY_MODE``), so a marker question always gets the table
|
||||
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
|
||||
@@ -584,7 +585,8 @@ TABLE_TRIGGER = "show me a table"
|
||||
#: The fixed table answer (phase 44) — byte-stable on purpose: the story
|
||||
#: E2E asserts the rendered table shape, the escaped ``<img onerror>``
|
||||
#: line (the XSS payload must survive the mock byte-for-byte), and the
|
||||
#: wide table's ``scrollWidth > clientWidth`` inside the 46rem column.
|
||||
#: wide table's ``scrollWidth > clientWidth`` inside the 72rem container
|
||||
#: column (phase 100).
|
||||
#: Phase 74 (chat history, TODO L4): a user message containing this
|
||||
#: substring (case-insensitive) gets the deterministic HISTORY ECHO
|
||||
#: (``_history_echo`` below — see the module docstring): the prior-turn
|
||||
@@ -1653,7 +1655,8 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
# <img onerror> XSS probe line (it must survive the mock
|
||||
# byte-for-byte so the E2E can prove the renderer neutralizes
|
||||
# it), and a wide 5-column table (guarantees scrollWidth >
|
||||
# clientWidth inside the 46rem column). Byte-stable. Checked
|
||||
# clientWidth inside the 72rem container column, phase 100).
|
||||
# Byte-stable. Checked
|
||||
# BEFORE the DEFLECT_MODE branch: a deflection prompt never
|
||||
# carries the marker (it lives in the user message, same
|
||||
# reasoning as SUMMARY_MODE), so a marker question always gets
|
||||
|
||||
@@ -24,7 +24,9 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
renders as escaped text; no dialog fires.
|
||||
6. ``test_standalone_page_still_works`` — the dedicated ``/document.html``
|
||||
page keeps its phase-10 contract (title/content/badges, not-found,
|
||||
dark theme, no-CDN, a11y frame, ≤736px md column).
|
||||
dark theme, no-CDN, a11y frame, the 72rem-container md column —
|
||||
phase 100: ≈1112px at the 1280px fixture viewport, the container's
|
||||
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:``.
|
||||
"""
|
||||
@@ -406,9 +408,16 @@ def test_standalone_page_still_works(
|
||||
expect(page.locator(".doc-shell")).to_have_attribute("aria-live", "polite")
|
||||
assert page.evaluate("() => document.activeElement && document.activeElement.id") == "main"
|
||||
|
||||
# Markdown column centered and capped at 46rem (736px at 16px root).
|
||||
# Markdown column: the 72rem container's inner content (phase 100 —
|
||||
# the 46rem cap and its wide-desktop doubling are retired): at the
|
||||
# 1280px fixture viewport the container is 1152px border-box, so
|
||||
# .doc-md (width:100% inside it) measures 1152 − 2×1.25rem = 1112px.
|
||||
box = page.locator("#doc-content .doc-md").bounding_box()
|
||||
assert box is not None and box["width"] <= 736 + 1
|
||||
assert box is not None, "the standalone .doc-md column is not rendered"
|
||||
assert abs(box["width"] - 1112) <= 4, (
|
||||
f"the standalone .doc-md column is {box['width']:.0f}px, "
|
||||
f"want 1112px (the 72rem container's inner content) ±4px"
|
||||
)
|
||||
|
||||
assert errors == [], f"console crashes: {errors}"
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
NOT deflected (the honesty-gate interplay is part of the contract).
|
||||
2. ``test_wide_table_scrolls`` — the wide table's wrapper has
|
||||
``scrollWidth > clientWidth`` and horizontal scroll moves it; the
|
||||
page itself has no horizontal overflow (the 46rem column holds).
|
||||
page itself has no horizontal overflow (the 72rem container column
|
||||
holds — phase 100).
|
||||
3. ``test_table_xss_safe`` — the ``<img onerror>`` line renders as
|
||||
visible, escaped text: zero injected ``<img>`` nodes, no dialog.
|
||||
4. ``test_viewer_table_renders`` — the fixture's pipe table opens from
|
||||
@@ -219,8 +220,8 @@ def test_wide_table_scrolls(
|
||||
after = wrap.evaluate("el => el.scrollLeft")
|
||||
assert after > before, "the wrapper must scroll horizontally"
|
||||
|
||||
# The 46rem chat column must not break the page: no horizontal
|
||||
# document overflow (PLAN §7.1).
|
||||
# The 72rem container column must not break the page: no horizontal
|
||||
# document overflow (PLAN §7.1, as revised by phase 100).
|
||||
page_scroll, page_client = page.evaluate(
|
||||
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
|
||||
)
|
||||
|
||||
@@ -12,12 +12,13 @@ Test → story mapping:
|
||||
1. ``test_no_horizontal_overflow_at_viewports`` — 360/375/768/1280/1600 on
|
||||
both pages: ``documentElement.scrollWidth <= clientWidth``.
|
||||
2. ``test_chat_column_capped_and_centered`` — the reading column rides
|
||||
--chat-column (46rem base; 92rem at >=1500px, phase 58 / owner
|
||||
instruction 2026-08-31 TODO L5): at 1600px (a wide desktop) the
|
||||
``.chat-shell`` is 92rem (1472px, ±2%) and horizontally centered
|
||||
(±2%); at 1280px (below the wide breakpoint) it stays ≤ 46rem
|
||||
(736px, +2%); at 768px the column uses most of the width (no
|
||||
mid-column dead zones).
|
||||
--chat-column (72rem — EQUAL to the .container's cap at every
|
||||
viewport, phase 100 / owner instruction 2026-09-12: the 46rem base
|
||||
+ the >=1500px 92rem doubling are retired): at 1600px (a wide
|
||||
desktop) the ``.chat-shell`` is the 72rem container (1152px, ±2%)
|
||||
and horizontally centered (±2%); at 1280px the same 1152px (the
|
||||
container cap binds, not the viewport); at 768px the column is
|
||||
full-width (no mid-column dead zones).
|
||||
3. ``test_sources_table_full_width`` — at 1280px ``.table-wrap`` ≥ 80% of
|
||||
the container; below 640px the table keeps its 640px min-width and the
|
||||
wrapper scrolls horizontally instead of squeezing.
|
||||
@@ -55,8 +56,11 @@ REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
|
||||
VIEWPORTS = ((360, 740), (375, 812), (768, 1024), (1280, 800), (1600, 900))
|
||||
CHAT_SHELL_CAP_PX = 46 * 16 # 736px — the --chat-column base (PLAN §7.1 lineage)
|
||||
CHAT_SHELL_WIDE_PX = 92 * 16 # 1472px — the 2x wide override (phase 58, >=1500px)
|
||||
# Phase 100: the ONE width — the 72rem container, border-box (the
|
||||
# 2×1.25rem gutters are inside the measured box). The 46rem base and
|
||||
# the 92rem wide-desktop doubling are retired (owner instruction
|
||||
# 2026-09-12: "match the width of the RAG page for all other pages").
|
||||
CHAT_SHELL_CONTAINER_PX = 72 * 16 # 1152px — the --chat-column = container cap
|
||||
|
||||
# Mock-LLM marker for a 3s pre-token window (see tests/e2e/mock_llm.py).
|
||||
SLOW_QUESTION = "pretend to think slowly, please"
|
||||
@@ -225,18 +229,19 @@ def test_no_horizontal_overflow_at_viewports(
|
||||
def test_chat_column_capped_and_centered(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC1 (phase 58 contract): the reading column doubles to 92rem on
|
||||
wide desktops (>=1500px — 1600px here), stays at the 46rem base
|
||||
below the breakpoint (1280px), and still uses most of the width on
|
||||
tablets (no mid-column dead zones)."""
|
||||
"""AC1 (phase 100 contract): the reading column is the 72rem
|
||||
container at every viewport — 1600px here (the cap binds, not the
|
||||
viewport) and 1280px alike (1152px, ±2%, centered), and still
|
||||
full-width on tablets (no mid-column dead zones). The phase-58
|
||||
46rem/92rem contract it used to pin is retired."""
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 900})
|
||||
try:
|
||||
page.goto(f"{app_url}/")
|
||||
box = page.locator(".chat-shell").bounding_box()
|
||||
assert box is not None
|
||||
assert CHAT_SHELL_WIDE_PX * 0.98 <= box["width"] <= CHAT_SHELL_WIDE_PX * 1.02, (
|
||||
f"at 1600px (>=1500px) the chat column is {box['width']:.0f}px, "
|
||||
f"not the 92rem wide override (±2%)"
|
||||
assert CHAT_SHELL_CONTAINER_PX * 0.98 <= box["width"] <= CHAT_SHELL_CONTAINER_PX * 1.02, (
|
||||
f"at 1600px the chat column is {box['width']:.0f}px, "
|
||||
f"not the 72rem container (1152px, ±2%)"
|
||||
)
|
||||
center = box["x"] + box["width"] / 2
|
||||
assert abs(center - 1600 / 2) <= 0.02 * 1600, (
|
||||
@@ -250,9 +255,11 @@ def test_chat_column_capped_and_centered(
|
||||
narrow.goto(f"{app_url}/")
|
||||
box = narrow.locator(".chat-shell").bounding_box()
|
||||
assert box is not None
|
||||
assert box["width"] <= CHAT_SHELL_CAP_PX * 1.02, (
|
||||
f"at 1280px (<1500px) the chat column {box['width']:.0f}px exceeds "
|
||||
f"the 46rem base cap (+2%)"
|
||||
# 1280 > 1152: the container cap binds — the SAME 72rem width as
|
||||
# at 1600px (the cap no longer depends on the viewport at all).
|
||||
assert box["width"] <= CHAT_SHELL_CONTAINER_PX * 1.02, (
|
||||
f"at 1280px the chat column {box['width']:.0f}px exceeds "
|
||||
f"the 72rem container cap (+2%)"
|
||||
)
|
||||
finally:
|
||||
narrow.close()
|
||||
|
||||
@@ -41,7 +41,8 @@ unshare, layout) are unchanged.
|
||||
* **Layout (L6 / A5)** — desktop 1280×800: ``#new-chat-btn`` and
|
||||
``#share-chat-btn`` share one horizontal row inside ``.chat-actions``
|
||||
(overlapping y bands, Share's x beyond New chat's x + width, each
|
||||
pill at intrinsic width — never the full 46rem column); mobile
|
||||
pill at intrinsic width — never the full 72rem container column);
|
||||
mobile
|
||||
390×844: stacked vertically (Share below New chat, full-width);
|
||||
360px wide: no horizontal page overflow;
|
||||
* **Admin still works (A1 sanity)** — the same auto-save machinery
|
||||
@@ -502,7 +503,7 @@ def test_action_row_layout(page: Page, app_url: str, mock_llm: int, db_ready: No
|
||||
|
||||
# Desktop (1280×800): one horizontal row — overlapping y bands,
|
||||
# Share to the right of New chat, each pill at its INTRINSIC width
|
||||
# (never the full 46rem chat column).
|
||||
# (never the full 72rem container column — phase 100).
|
||||
nb = new_btn.bounding_box()
|
||||
sb = share_btn.bounding_box()
|
||||
assert nb is not None and sb is not None
|
||||
|
||||
@@ -265,7 +265,7 @@ def test_doc_header_stuck_at_top_on_long_document(
|
||||
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
|
||||
|
||||
# The doc must actually scroll (the ~40,000-char body fills many
|
||||
# viewports in the 46rem column).
|
||||
# viewports in the 72rem container column — phase 100).
|
||||
sh = page.evaluate("() => document.documentElement.scrollHeight")
|
||||
assert sh > VIEWPORT_H, (
|
||||
f"the long document must make the page scroll "
|
||||
|
||||
@@ -979,10 +979,11 @@ def test_reset_removes_tag_byte_identical(page: Page, app_url: str, db_ready: No
|
||||
# The tag is GONE from the live document (theme.js reconciles the
|
||||
# #bor-theme tag's DOM text to the settled, now-default values —
|
||||
# the no-op case removes the tag and clears the <html>
|
||||
# overrides)…
|
||||
assert 'id="bor-theme"' not in page.content(), (
|
||||
"the reset document must be tag-free"
|
||||
)
|
||||
# overrides). The removal lands in the post-PUT refetch's
|
||||
# reconcile (the result line above only signals the PUT landed),
|
||||
# so WAIT for the settled tag-free state rather than racing the
|
||||
# refetch round-trip…
|
||||
expect(page.locator("#bor-theme")).to_have_count(0, timeout=30_000)
|
||||
# …and a FRESH load serves NO tag at all (the all-NULL row is the
|
||||
# no-op injection).
|
||||
r = httpx.get(app_url + "/", timeout=10)
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
"""Phase 58 E2E (Playwright): the 2x reading column on wide desktops —
|
||||
measured bounding-box widths, not CSS pins.
|
||||
"""Phase 100 E2E (Playwright): every page matches the RAG page's width —
|
||||
the full 72rem container at every viewport, measured bounding boxes.
|
||||
|
||||
TODO.md L5 (owner instruction 2026-08-31, roadmap confirmation D2 +
|
||||
expansion): "The chat response needs to be 2x wider on wide desktops.
|
||||
there's a lot of unused space." — extended to the document view. The
|
||||
owner-locked contract: viewport >=1500px doubles ``--chat-column`` to
|
||||
92rem (1472px at the 16px root) for the four reading shells —
|
||||
``.chat-shell``, ``.shared-shell``, ``.doc-md`` and
|
||||
``.doc-summary:has(+ .doc-md)`` — while everything below the breakpoint
|
||||
renders exactly as before (46rem / 736px) and ``.tuning-shell`` (a
|
||||
form, not a reading surface) never widens (CSS-pinned by the unit
|
||||
suite, task 01 — the browser proof of the MEASURED width is this file).
|
||||
Owner request (chat, 2026-09-12): "The theme, tuning, and chat pages
|
||||
are still pretty narrow, I want you to match the width of the RAG page
|
||||
for all other pages to keep things consistent." — SUPERSEDES the
|
||||
phase-58 2026-08-31 46rem/92rem reading-column contract (recorded per
|
||||
the phase-94/96/97 convention in `.agents/phases/todo/
|
||||
100_page_width_consistency/00_phase.md`, decisions D1–D4). The phase-58
|
||||
suite this file used to be updates IN PLACE to the new contract (the
|
||||
phase-97 task-07/08 precedent: a completed phase's suite follows its
|
||||
changed contract; its seeding helpers are kept).
|
||||
|
||||
The measured contract (the 16px root, the ±4px tolerance is the task
|
||||
spec):
|
||||
|
||||
* the container is ``min(100%, 72rem)`` border-box with the 2×1.25rem
|
||||
gutters INSIDE the box — at any viewport ≥ ~1200px every shell
|
||||
(``.container.*-shell``) measures **1152px** (72rem), identical on
|
||||
the chat page, Tuning, Theme, and the RAG page;
|
||||
* the standalone ``document.html`` page's ``.doc-md`` is the container's
|
||||
inner content — **1112px** (1152 − 2×1.25rem);
|
||||
* the same-page document MODAL is untouched (D2): the panel stays
|
||||
**~1100px** and the panel binds the ``.doc-md`` inside it —
|
||||
**1058px** (1100 − 2×1px borders − 2×1.25rem of
|
||||
``.doc-modal-content`` padding), NOT 1152;
|
||||
* below the container cap everything is full-width as before: at 360px
|
||||
no horizontal overflow and the shell is the viewport width; at 900px
|
||||
chat/tuning/theme all measure 900px (equal — the cap never bound
|
||||
below 72rem anyway).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
@@ -18,33 +35,30 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
Test → contract mapping:
|
||||
|
||||
1. ``test_chat_column_wide_vs_base`` — ``/`` at 1920×1080: the
|
||||
``.chat-shell`` bounding box is 1472px (92rem, ±4px) and centered;
|
||||
at 1280×800 (below the wide breakpoint) it is back to 736px
|
||||
(46rem, ±4px).
|
||||
2. ``test_document_column_wide_vs_base`` — a seeded markdown document
|
||||
that carries a summary: at 1920 both ``.doc-md`` and the adjacent
|
||||
``.doc-summary:has(+ .doc-md)`` panel are 1472px; at 1280
|
||||
``.doc-md`` is 736px.
|
||||
3. ``test_shared_column_wide`` — a real auto-saved conversation shared
|
||||
by token: ``/shared/<token>`` at 1920 renders ``.shared-shell`` at
|
||||
1472px in a fresh anonymous context.
|
||||
4. ``test_narrow_unchanged`` — 360px: no horizontal overflow and the
|
||||
``.chat-shell`` is the existing mobile rule — full-bleed at the
|
||||
viewport width (the shell IS its .container; the 0.9rem mobile
|
||||
gutters live in its own padding, inside the measured box), 900px:
|
||||
the shell holds the 46rem base (736px, not the 1472px wide rule —
|
||||
at 900px a leaked wide override would pin the shell to the 860px
|
||||
content box instead, so 736px is the discriminator) — the wide
|
||||
rule does not leak below 1500px.
|
||||
1. ``test_all_columns_match_the_rag_page`` — the core pin: at
|
||||
1280×800 the ``.chat-shell`` (``/``), ``.tuning-shell``
|
||||
(``/tuning.html``), ``.theme-shell`` (``/theme.html``) and the RAG
|
||||
page's ``.sources-shell`` (``/sources.html``) bounding boxes are ALL
|
||||
EQUAL (±4px) — the owner's "match the width of the RAG page" as one
|
||||
assertion; at 1920×1080 the same four are still equal to each other
|
||||
and each measures ≈1152px (72rem, ±4px).
|
||||
2. ``test_reader_columns_wide`` — the token's consumers at 1920×1080:
|
||||
the modal panel is still ~1100px (D2: the modal is UNCHANGED) with
|
||||
its ``.doc-md`` at ~1058px; ``/shared/<token>``'s ``.shared-shell``
|
||||
is ≈1152px; the standalone ``document.html`` page's ``.doc-md`` is
|
||||
≈1112px (the 72rem container's inner content).
|
||||
3. ``test_narrow_unchanged`` — the no-regression leg: at 360×800 no
|
||||
horizontal overflow and ``.chat-shell`` is the viewport width
|
||||
(full-bleed — the mobile gutters sit in the container's padding,
|
||||
inside the measured box); at 900×600 chat/tuning/theme are all
|
||||
900px wide, equal to each other.
|
||||
|
||||
DB isolation: the shared-chat row is deleted in a ``finally`` (admin
|
||||
cookie — the house pattern of test_share_chat.py, whose distinctive
|
||||
question text keeps the auto-title unique); the fixture document rows
|
||||
follow the story-fixture convention of the sibling suites (truncate +
|
||||
re-import; the summary document is a direct row insert, the
|
||||
test_document_viewer.py XSS-fixture pattern — the viewer is
|
||||
database-only).
|
||||
re-import; the viewer fixture is a direct row insert — the viewer is
|
||||
database-only, the test_document_viewer.py XSS-fixture pattern).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -70,14 +84,22 @@ from e2e.auth_helpers import login
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
|
||||
# The two proof viewports (task spec): wide desktop vs just below the
|
||||
# 1500px breakpoint, at the 16px root the phase-58 rem contract:
|
||||
# 92rem = 1472px, 46rem = 736px. ±4px tolerance (task spec).
|
||||
WIDE_PX = 92 * 16 # 1472px — the 2x wide override (>=1500px)
|
||||
BASE_PX = 46 * 16 # 736px — the --chat-column base
|
||||
TOL_PX = 4
|
||||
WIDE_VIEWPORT: ViewportSize = {"width": 1920, "height": 1080}
|
||||
BASE_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
|
||||
# The phase-100 measured contract (16px root, the task spec):
|
||||
# * CONTAINER_PX — 72rem border-box, the 2×1.25rem gutters INSIDE the
|
||||
# box; every .container.*-shell measures this at any viewport
|
||||
# >= ~1200px (the RAG page's width — the owner's ask).
|
||||
# * DOC_MD_STANDALONE_PX — the standalone document page's .doc-md: the
|
||||
# container's inner content (CONTAINER_PX minus the gutters).
|
||||
# * MODAL_PANEL_PX / DOC_MD_MODAL_PX — the SAME-PAGE modal is untouched
|
||||
# (D2): the 1100px border-box panel (minus its 1px borders) and
|
||||
# .doc-modal-content's 2×1.25rem padding bind the .doc-md inside it.
|
||||
CONTAINER_PX = 72 * 16 # 1152px — the 72rem container (border-box)
|
||||
DOC_MD_STANDALONE_PX = CONTAINER_PX - 2 * 20 # 1112px — inner content
|
||||
MODAL_PANEL_PX = 1100 # the phase-26 panel ceiling (D2: unchanged)
|
||||
DOC_MD_MODAL_PX = MODAL_PANEL_PX - 2 - 2 * 20 # 1058px — panel-inner
|
||||
TOL_PX = 4 # the task spec's ±4px tolerance
|
||||
VIEWPORT_1280: ViewportSize = {"width": 1280, "height": 800}
|
||||
VIEWPORT_1920: ViewportSize = {"width": 1920, "height": 1080}
|
||||
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
@@ -139,11 +161,11 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
|
||||
|
||||
def _seed_summary_doc() -> None:
|
||||
"""One markdown document carrying a stored summary — the ONLY shape
|
||||
that renders both reading-column pins on one page: ``.doc-summary``
|
||||
(the phase-36 panel) directly above ``.doc-md`` (the ``:has(
|
||||
+ .doc-md)`` sibling match). Direct row insert — the viewer is
|
||||
database-only (the test_document_viewer.py XSS-fixture pattern)."""
|
||||
"""One markdown document carrying a stored summary — the viewer
|
||||
fixture the phase-100 suite opens in BOTH the same-page modal and
|
||||
the standalone document page (the test_document_viewer.py
|
||||
XSS-fixture pattern: a direct row insert, the viewer is
|
||||
database-only)."""
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
Document(
|
||||
@@ -153,11 +175,11 @@ def _seed_summary_doc() -> None:
|
||||
title=FIXTURE_TITLE,
|
||||
content=(
|
||||
"# Wide Column Fixture\n\n"
|
||||
"Phase-58 width pin: a markdown document with a "
|
||||
"summary, so the viewer renders the .doc-summary "
|
||||
"panel directly above the .doc-md column."
|
||||
"Phase-100 width pin: a markdown document with a "
|
||||
"summary, opened in the same-page modal and the "
|
||||
"standalone document page."
|
||||
),
|
||||
summary="A phase-58 fixture summary for the wide-column pin.",
|
||||
summary="A phase-100 fixture summary for the width pin.",
|
||||
content_hash="w" * 64,
|
||||
indexed_at=datetime.now(UTC),
|
||||
)
|
||||
@@ -170,14 +192,29 @@ def _seed_summary_doc() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _drill(page: Page, *names: str) -> None:
|
||||
"""Phase 97: the catalog is the drill-down tree — click through the
|
||||
source/folder rows (exact name match, one per name) to the level
|
||||
that holds the asserted file."""
|
||||
for name in names:
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
|
||||
|
||||
|
||||
def _box_width(page: Page, selector: str, label: str) -> float:
|
||||
"""The element's bounding-box width (the measured rendered width,
|
||||
not the computed style) — None means the element is not rendered,
|
||||
which is a test error, not a measurement."""
|
||||
box = page.locator(selector).first.bounding_box()
|
||||
assert box is not None, f"{selector} not rendered ({label})"
|
||||
return box["width"]
|
||||
|
||||
|
||||
def _assert_width(page: Page, selector: str, expected_px: int, label: str) -> None:
|
||||
"""The element's bounding-box width is ``expected_px`` ±4px (task
|
||||
spec) — the measured rendered width, not the computed style."""
|
||||
box = page.locator(selector).first.bounding_box()
|
||||
assert box is not None, f"{selector} not rendered ({label})"
|
||||
assert abs(box["width"] - expected_px) <= TOL_PX, (
|
||||
f"{label}: {selector} is {box['width']:.1f}px, "
|
||||
f"want {expected_px}px ±{TOL_PX}px"
|
||||
width = _box_width(page, selector, label)
|
||||
assert abs(width - expected_px) <= TOL_PX, (
|
||||
f"{label}: {selector} is {width:.1f}px, want {expected_px}px ±{TOL_PX}px"
|
||||
)
|
||||
|
||||
|
||||
@@ -265,170 +302,206 @@ def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _measure_shells(
|
||||
browser: Browser, app_url: str, viewport: ViewportSize
|
||||
) -> dict[str, float]:
|
||||
"""One signed-in page at ``viewport``; measure the bounding-box
|
||||
width of every shell under test after its view is shown:
|
||||
|
||||
* ``.chat-shell`` on ``/`` (the chat view — the div IS
|
||||
``.container.chat-shell``);
|
||||
* ``.tuning-shell`` on ``/tuning.html``, ``.theme-shell`` on
|
||||
``/theme.html``, and the RAG page's ``.sources-shell`` on
|
||||
``/sources.html`` (admin deep-links — A11's one shell document).
|
||||
"""
|
||||
page = browser.new_page(viewport=viewport)
|
||||
widths: dict[str, float] = {}
|
||||
try:
|
||||
login(page, app_url, next="/")
|
||||
page.locator(".chat-shell").wait_for(state="visible", timeout=10_000)
|
||||
widths[".chat-shell"] = _box_width(page, ".chat-shell", "chat")
|
||||
for path, selector in (
|
||||
("/tuning.html", ".tuning-shell"),
|
||||
("/theme.html", ".theme-shell"),
|
||||
("/sources.html", ".sources-shell"),
|
||||
):
|
||||
page.goto(app_url + path)
|
||||
page.locator(selector).wait_for(state="visible", timeout=10_000)
|
||||
widths[selector] = _box_width(page, selector, path)
|
||||
finally:
|
||||
page.close()
|
||||
return widths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Chat: 1472px at 1920, back to 736px at 1280 (centered both ways)
|
||||
# 1. The core pin: chat == tuning == theme == RAG page (±4px), at both
|
||||
# proof viewports; each ≈1152px (the 72rem container) at 1920
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_column_wide_vs_base(
|
||||
def test_all_columns_match_the_rag_page(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""The chat page's .chat-shell doubles at the 1500px breakpoint:
|
||||
92rem (1472px, ±4px) at 1920×1080, centered; 46rem (736px, ±4px)
|
||||
at 1280×800 — the base below the breakpoint."""
|
||||
wide = browser.new_page(viewport=WIDE_VIEWPORT)
|
||||
try:
|
||||
login(wide, app_url, next="/") # phase 79: the chips need a session
|
||||
wide.locator("#suggestions .suggestion-chip").first.wait_for(
|
||||
state="visible", timeout=10_000
|
||||
"""The owner's "match the width of the RAG page" as ONE assertion:
|
||||
at 1280×800 the four shells are all EQUAL (±4px); at 1920×1080
|
||||
they are still equal to each other and each measures ≈1152px
|
||||
(72rem at the 16px root, ±4px — the box includes the container's
|
||||
2×1.25rem padding, border-box)."""
|
||||
# --- 1280×800 (just above the container cap — the owner's report) ---
|
||||
widths = _measure_shells(browser, app_url, VIEWPORT_1280)
|
||||
ref_selector, ref = next(iter(widths.items()))
|
||||
for selector, width in widths.items():
|
||||
assert abs(width - ref) <= TOL_PX, (
|
||||
f"@1280px {selector} is {width:.1f}px but {ref_selector} is "
|
||||
f"{ref:.1f}px — the columns must match the RAG page (±{TOL_PX}px)"
|
||||
)
|
||||
_assert_width(wide, ".chat-shell", WIDE_PX, "chat @ 1920px")
|
||||
_assert_centered(wide, ".chat-shell", 1920, "chat @ 1920px")
|
||||
finally:
|
||||
wide.close()
|
||||
|
||||
base = browser.new_page(viewport=BASE_VIEWPORT)
|
||||
try:
|
||||
login(base, app_url, next="/") # phase 79: the chips need a session
|
||||
base.locator("#suggestions .suggestion-chip").first.wait_for(
|
||||
state="visible", timeout=10_000
|
||||
# --- 1920×1080: equal AND each the 72rem container (≈1152px) ------
|
||||
widths = _measure_shells(browser, app_url, VIEWPORT_1920)
|
||||
ref_selector, ref = next(iter(widths.items()))
|
||||
for selector, width in widths.items():
|
||||
assert abs(width - ref) <= TOL_PX, (
|
||||
f"@1920px {selector} is {width:.1f}px but {ref_selector} is "
|
||||
f"{ref:.1f}px — the columns must match the RAG page (±{TOL_PX}px)"
|
||||
)
|
||||
assert abs(width - CONTAINER_PX) <= TOL_PX, (
|
||||
f"@1920px {selector} is {width:.1f}px, want "
|
||||
f"{CONTAINER_PX}px (the 72rem container) ±{TOL_PX}px"
|
||||
)
|
||||
_assert_width(base, ".chat-shell", BASE_PX, "chat @ 1280px")
|
||||
_assert_centered(base, ".chat-shell", 1280, "chat @ 1280px")
|
||||
finally:
|
||||
base.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Document viewer: .doc-md (and its .doc-summary panel) at both
|
||||
# widths — the owner-expanded surface
|
||||
# 2. The token's consumers at 1920×1080: the modal (unchanged, D2), the
|
||||
# shared page, and the standalone document page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_document_column_wide_vs_base(
|
||||
def test_reader_columns_wide(
|
||||
browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""A seeded md document (with a summary) renders .doc-md at 1472px
|
||||
at 1920 and 736px at 1280 — and the adjacent .doc-summary panel
|
||||
matches the column at 1920 (.doc-summary:has(+ .doc-md))."""
|
||||
"""At 1920×1080: the same-page document MODAL is untouched (D2 —
|
||||
the panel is still ~1100px and IT binds the ``.doc-md`` inside,
|
||||
~1058px, not 1152); ``/shared/<token>``'s ``.shared-shell`` is
|
||||
≈1152px; the standalone ``document.html`` page's ``.doc-md`` is
|
||||
≈1112px (the 72rem container's inner content)."""
|
||||
_reset_db(mock_llm, seed=True)
|
||||
_seed_summary_doc()
|
||||
|
||||
wide = browser.new_page(viewport=WIDE_VIEWPORT)
|
||||
page = browser.new_page(viewport=VIEWPORT_1920)
|
||||
try:
|
||||
login(wide, app_url, next="/") # phase 79: the viewer content is gated
|
||||
wide.goto(app_url + FIXTURE_DOC_URL)
|
||||
expect(wide.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
|
||||
expect(wide.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
|
||||
expect(
|
||||
wide.locator(".doc-summary:has(+ .doc-md)")
|
||||
).to_have_count(1, timeout=15_000)
|
||||
_assert_width(wide, "#doc-content .doc-md", WIDE_PX, "document @ 1920px")
|
||||
# (a) the same-page modal — the 1100px panel stays its effective
|
||||
# ceiling (D2: pin the PANEL, not the 1152 the column would be).
|
||||
login(page, app_url, next="/sources.html")
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(
|
||||
state="visible", timeout=15_000
|
||||
)
|
||||
_drill(page, FIXTURE_SOURCE, "notes")
|
||||
row = page.locator("#docs-tbody tr", has_text="wide-column-fixture.md")
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
expect(page.locator(".doc-modal")).to_be_visible()
|
||||
expect(page.locator("#doc-modal-title")).to_have_text(FIXTURE_TITLE)
|
||||
_assert_width(page, "#doc-modal-panel", MODAL_PANEL_PX, "modal panel @ 1920px")
|
||||
_assert_width(
|
||||
wide, ".doc-summary:has(+ .doc-md)", WIDE_PX, "summary panel @ 1920px"
|
||||
page,
|
||||
"#doc-modal-content .doc-md",
|
||||
DOC_MD_MODAL_PX,
|
||||
"modal .doc-md @ 1920px",
|
||||
)
|
||||
finally:
|
||||
wide.close()
|
||||
|
||||
base = browser.new_page(viewport=BASE_VIEWPORT)
|
||||
try:
|
||||
login(base, app_url, next="/") # phase 79: the viewer content is gated
|
||||
base.goto(app_url + FIXTURE_DOC_URL)
|
||||
expect(base.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
|
||||
expect(base.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
|
||||
_assert_width(base, "#doc-content .doc-md", BASE_PX, "document @ 1280px")
|
||||
# (b) the standalone document page: the 72rem container's inner
|
||||
# content (1152 − 2×1.25rem gutters).
|
||||
page.goto(app_url + FIXTURE_DOC_URL)
|
||||
expect(page.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
|
||||
expect(page.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
|
||||
_assert_width(
|
||||
page,
|
||||
"#doc-content .doc-md",
|
||||
DOC_MD_STANDALONE_PX,
|
||||
"standalone .doc-md @ 1920px",
|
||||
)
|
||||
|
||||
# (c) the shared page: .shared-shell at the full 72rem container.
|
||||
page.goto(app_url + "/")
|
||||
q = "How is my Kubernetes cluster set up? (wide-column)"
|
||||
_ask(page, q)
|
||||
cookies = _admin_cookies(page)
|
||||
saved_row = _wait_saved_row(app_url, cookies, _auto_title(q))
|
||||
chat_id: str = saved_row["id"]
|
||||
anon_ctx = None
|
||||
try:
|
||||
# Public since phase 55 — the share endpoint takes no session.
|
||||
r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", timeout=10)
|
||||
assert r.status_code == 200
|
||||
share_url = r.json()["share_url"]
|
||||
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
|
||||
|
||||
anon_ctx = browser.new_context(viewport=VIEWPORT_1920)
|
||||
anon = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + share_url)
|
||||
expect(anon.locator("#shared-title")).to_have_text(
|
||||
_auto_title(q), timeout=15_000
|
||||
)
|
||||
expect(anon.locator(".msg.brain .bubble")).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
_assert_width(anon, ".shared-shell", CONTAINER_PX, "shared @ 1920px")
|
||||
_assert_centered(anon, ".shared-shell", 1920, "shared @ 1920px")
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
finally:
|
||||
base.close()
|
||||
page.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Shared page: .shared-shell at 1472px for a guest at 1920
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_column_wide(
|
||||
page: Page,
|
||||
browser: Browser,
|
||||
app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
"""A real auto-saved conversation, shared by token, renders its
|
||||
.shared-shell at 1472px (±4px) at 1920 in a FRESH anonymous
|
||||
context (no cookies — the guest's only credential is the token)."""
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (wide-column)"
|
||||
_ask(page, q)
|
||||
|
||||
cookies = _admin_cookies(page)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(q))
|
||||
chat_id: str = row["id"]
|
||||
anon_ctx = None
|
||||
try:
|
||||
# Public since phase 55 — the share endpoint takes no session.
|
||||
r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", timeout=10)
|
||||
assert r.status_code == 200
|
||||
share_url = r.json()["share_url"]
|
||||
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
|
||||
|
||||
anon_ctx = browser.new_context(viewport=WIDE_VIEWPORT)
|
||||
anon = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + share_url)
|
||||
expect(anon.locator("#shared-title")).to_have_text(
|
||||
_auto_title(q), timeout=15_000
|
||||
)
|
||||
expect(anon.locator(".msg.brain .bubble")).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
_assert_width(anon, ".shared-shell", WIDE_PX, "shared @ 1920px")
|
||||
_assert_centered(anon, ".shared-shell", 1920, "shared @ 1920px")
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Below the breakpoint: 360px and 900px are byte-for-byte the old
|
||||
# rules — no overflow, no leaked wide column
|
||||
# 3. Below the container cap: full-width as today — 360px overflow-free,
|
||||
# 900px chat == tuning == theme (the cap never bound below 72rem)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_narrow_unchanged(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""The min-width:1500px override must not leak below the
|
||||
breakpoint: at 360px no horizontal overflow and the shell is the
|
||||
existing mobile rule — full-bleed at the viewport width (the shell
|
||||
IS its .container, so the 0.9rem mobile gutters sit in its own
|
||||
padding, inside the measured box); at 900px the shell holds the
|
||||
46rem base (736px — a leaked 92rem rule would pin it to the 860px
|
||||
content box instead, so 736px is the discriminator)."""
|
||||
phone = browser.new_page(viewport={"width": 360, "height": 740})
|
||||
"""The mobile/tablet layouts are UNCHANGED (D3): at 360×800 no
|
||||
horizontal overflow and ``.chat-shell`` is the viewport width
|
||||
(full-bleed — the shell IS its .container, so the mobile gutters sit
|
||||
in its padding, inside the measured box); at 900×600 the
|
||||
chat/tuning/theme shells are ALL 900px wide, equal to each other
|
||||
(the cap never bound below 72rem anyway)."""
|
||||
phone = browser.new_page(viewport={"width": 360, "height": 800})
|
||||
try:
|
||||
login(phone, app_url, next="/") # phase 79: the chips need a session
|
||||
phone.locator("#suggestions .suggestion-chip").first.wait_for(
|
||||
state="visible", timeout=10_000
|
||||
)
|
||||
login(phone, app_url, next="/")
|
||||
phone.locator(".chat-shell").wait_for(state="visible", timeout=10_000)
|
||||
_assert_no_doc_overflow(phone, "chat @ 360px")
|
||||
_assert_width(phone, ".chat-shell", 360, "chat @ 360px")
|
||||
finally:
|
||||
phone.close()
|
||||
|
||||
tablet = browser.new_page(viewport={"width": 900, "height": 800})
|
||||
tablet = browser.new_page(viewport={"width": 900, "height": 600})
|
||||
try:
|
||||
login(tablet, app_url, next="/") # phase 79: the chips need a session
|
||||
tablet.locator("#suggestions .suggestion-chip").first.wait_for(
|
||||
state="visible", timeout=10_000
|
||||
)
|
||||
login(tablet, app_url, next="/")
|
||||
tablet.locator(".chat-shell").wait_for(state="visible", timeout=10_000)
|
||||
_assert_no_doc_overflow(tablet, "chat @ 900px")
|
||||
_assert_width(tablet, ".chat-shell", BASE_PX, "chat @ 900px")
|
||||
_assert_centered(tablet, ".chat-shell", 900, "chat @ 900px")
|
||||
widths = {".chat-shell": _box_width(tablet, ".chat-shell", "chat @ 900px")}
|
||||
for path, selector in (
|
||||
("/tuning.html", ".tuning-shell"),
|
||||
("/theme.html", ".theme-shell"),
|
||||
):
|
||||
tablet.goto(app_url + path)
|
||||
tablet.locator(selector).wait_for(state="visible", timeout=10_000)
|
||||
widths[selector] = _box_width(tablet, selector, f"{path} @ 900px")
|
||||
ref_selector, ref = next(iter(widths.items()))
|
||||
for selector, width in widths.items():
|
||||
assert abs(width - ref) <= TOL_PX, (
|
||||
f"@900px {selector} is {width:.1f}px but {ref_selector} is "
|
||||
f"{ref:.1f}px — the cap must not bind below the container cap"
|
||||
)
|
||||
assert abs(width - 900) <= TOL_PX, (
|
||||
f"@900px {selector} is {width:.1f}px, want the full 900px "
|
||||
f"viewport width ±{TOL_PX}px"
|
||||
)
|
||||
finally:
|
||||
tablet.close()
|
||||
|
||||
@@ -10,8 +10,9 @@ without a browser:
|
||||
|
||||
* the house shell (AGENTS.md rule 5 + the login.html/shared.html
|
||||
minimal-flow-page lineage): skip-link, the SLIM header (brand +
|
||||
"Back to chat" — no nav), the 46rem base column (hard-coded — a form
|
||||
column, NOT ``--chat-column``), the ``container`` frame;
|
||||
"Back to chat" — no nav), the full 72rem container (the phase-59
|
||||
form-column cap is retired — owner instruction 2026-09-12), the
|
||||
``container`` frame;
|
||||
* the form contract: ``#draft-title`` / ``#draft-path`` /
|
||||
``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact
|
||||
"Push to docs branch" copy) + the back link, ``#push-status``
|
||||
@@ -455,21 +456,25 @@ def test_trim_git_detail_behavior_is_pinned_by_the_markers() -> None:
|
||||
# ---------- styles.css: the new classes ----------
|
||||
|
||||
|
||||
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
|
||||
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
|
||||
form column, not a reading column — it must NOT ride
|
||||
--chat-column, so phase 58's wide-desktop doubling never stretches
|
||||
the form), centered, a flex column on the container frame."""
|
||||
def test_doc_edit_shell_rides_the_full_container() -> None:
|
||||
""".doc-edit-shell: the full 72rem container (owner instruction
|
||||
2026-09-12 — the phase-59 form-column cap is superseded): no
|
||||
max-width, no margin-inline (the .container ancestor centers), a
|
||||
flex column on the container frame."""
|
||||
css = _css()
|
||||
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .doc-edit-shell"
|
||||
body = block.group(1)
|
||||
assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
|
||||
assert "max-width" not in body, (
|
||||
"no cap — the shell rides the .container's 72rem frame"
|
||||
)
|
||||
assert "--chat-column" not in body, (
|
||||
"the form column does not ride --chat-column (phase 58 must "
|
||||
"not stretch it)"
|
||||
"the shell rides the .container frame, not the reading-column "
|
||||
"token"
|
||||
)
|
||||
assert "margin-inline" not in body, (
|
||||
"the .container ancestor centers (no shell-level centering)"
|
||||
)
|
||||
assert "margin-inline: auto" in body
|
||||
assert "flex-direction: column" in body
|
||||
|
||||
|
||||
|
||||
@@ -206,7 +206,8 @@ def test_header_comment_notes_the_table_pass() -> None:
|
||||
def test_table_wrap_is_the_horizontal_scroller() -> None:
|
||||
""".md-table-wrap { overflow-x: auto } — the wrapper (not the
|
||||
table) is the scroller, so a wide table scrolls inside the bubble
|
||||
instead of breaking the 46rem column (story AC5)."""
|
||||
instead of breaking the 72rem column (story AC5 — the width
|
||||
contract is now the 72rem container, phase 100)."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.md-table-wrap\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define .md-table-wrap"
|
||||
|
||||
@@ -6,8 +6,8 @@ be at the bottom of the screen on an EMPTY chat too, which sticky alone
|
||||
cannot do).
|
||||
|
||||
The chat page scrolls at the DOCUMENT level and `.chat-shell` (the
|
||||
centered 46rem column, PLAN §7.1) is the composer's sticky containing
|
||||
block. The pin is therefore TWO rules, and both are pinned here:
|
||||
centered 72rem column — the .container width, phase 100) is the
|
||||
composer's sticky containing block. The pin is therefore TWO rules, and both are pinned here:
|
||||
|
||||
* `.messages { flex: 1 1 auto }` absorbs the free space of a short page,
|
||||
so the composer's RESTING (in-flow) position is already the bottom of
|
||||
|
||||
@@ -450,8 +450,9 @@ def test_modal_css_targets_and_contrast_pairs() -> None:
|
||||
9.3:1, the err-line border — the .tuning-delete / .steering-delete
|
||||
convention; the hover inverts to --bg on --err-line, 5.2:1);
|
||||
Cancel is the ghost ink-soft family (5.1:1 on --surface); the
|
||||
error line is the err pair; the panel caps at the 46rem
|
||||
chat-column width or the viewport."""
|
||||
error line is the err pair; the panel caps at the chat-column
|
||||
width (the --chat-column token — 72rem, owner instruction
|
||||
2026-09-12) or the viewport."""
|
||||
css = _css()
|
||||
btn = css[css.find(".remove-confirm-btn {"):]
|
||||
btn = btn[: btn.find("\n}")]
|
||||
@@ -473,4 +474,6 @@ def test_modal_css_targets_and_contrast_pairs() -> None:
|
||||
assert "var(--err-ink)" in err and "var(--err-bg)" in err
|
||||
panel = css[css.find(".remove-confirm-panel {"):]
|
||||
panel = panel[: panel.find("\n}")]
|
||||
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
|
||||
assert "min(var(--chat-column)" in panel, (
|
||||
"the chat-column cap via the token (or the viewport)"
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@ silent regression is caught without a browser:
|
||||
chat page's interactive builders, and the rendered messages contain
|
||||
no button/form/link — the chips are plain spans, the source chips
|
||||
carry no ``href``;
|
||||
* the shared shell's reading-column mapping (--chat-column token,
|
||||
phase 58) + the static-chip and
|
||||
* the shared shell's reading-column mapping (--chat-column token —
|
||||
the 72rem container width, phase 100) + the static-chip and
|
||||
invalid-state CSS (the ≤640px squeeze included).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -384,17 +384,18 @@ def test_brand_note_resolves_at_call_time() -> None:
|
||||
|
||||
|
||||
def test_shared_shell_maps_to_the_reading_column() -> None:
|
||||
"""The PLAN §7 column contract (phase 58): .shared-shell is the
|
||||
centered chat column riding the --chat-column token (46rem base,
|
||||
92rem at >=1500px wide desktops — owner instruction 2026-08-31,
|
||||
TODO L5 / D2). The conversation reads exactly like the chat
|
||||
page's, so the existing .msg/.bubble CSS applies unchanged."""
|
||||
"""The 72rem-everywhere contract (phase 100, owner instruction
|
||||
2026-09-12): .shared-shell is the centered chat column riding the
|
||||
--chat-column token — equal to the .container's cap, so the shared
|
||||
page reads at the RAG page's width. The conversation reads exactly
|
||||
like the chat page's, so the existing .msg/.bubble CSS applies
|
||||
unchanged."""
|
||||
css = _css()
|
||||
block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .shared-shell"
|
||||
body = block.group(1)
|
||||
assert "max-width: var(--chat-column)" in body, (
|
||||
"the PLAN §7 centered chat column (phase 58 token)"
|
||||
"the centered chat column riding the token (the 72rem width)"
|
||||
)
|
||||
assert "46rem" not in body.split("/*")[0], (
|
||||
"no hard-coded cap — the width rides the token"
|
||||
|
||||
@@ -499,7 +499,8 @@ def test_dialog_button_and_target_contrast_pairs() -> None:
|
||||
brand-soft hover (12.4:1); Save is the solid brand family (--bg
|
||||
text on --brand 5.2:1, the .new-chat-btn convention) with the
|
||||
lightened hover; the error line is the err pair; the panel caps
|
||||
at the 46rem chat-column width or the viewport; the visible
|
||||
at the chat-column width (the --chat-column token — 72rem, owner
|
||||
instruction 2026-09-12) or the viewport; the visible
|
||||
label is ink-soft (5.1:1) — never a label-less textarea."""
|
||||
css = _css()
|
||||
btn = _css_rule(css, ".ignore-editor-btn")
|
||||
@@ -517,7 +518,9 @@ def test_dialog_button_and_target_contrast_pairs() -> None:
|
||||
err = _css_rule(css, ".ignore-editor-error")
|
||||
assert "var(--err-ink)" in err and "var(--err-bg)" in err
|
||||
panel = _css_rule(css, ".ignore-editor-panel")
|
||||
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
|
||||
assert "min(var(--chat-column)" in panel, (
|
||||
"the chat-column cap via the token (or the viewport)"
|
||||
)
|
||||
label = _css_rule(css, ".ignore-editor-label")
|
||||
assert "display: block" in label and "var(--ink-soft)" in label, (
|
||||
"a visible block label (WCAG — never aria-label-only)"
|
||||
|
||||
+125
-110
@@ -1,29 +1,33 @@
|
||||
"""Unit: the 2x reading column on wide desktops (phase 58, task 01).
|
||||
"""Unit: every page matches the RAG page's 72rem width (phase 100).
|
||||
|
||||
The measured-width browser proof is E2E-gated by the phase-58 story
|
||||
suite (task 02); like the other frontend-adjacent unit files (the
|
||||
The measured-width browser proof is E2E-gated by the phase's width
|
||||
suite (task 03); like the other frontend-adjacent unit files (the
|
||||
test_save_chat_ui.py pattern), this module pins the styles.css markers
|
||||
the wide-column contract depends on, so a silent regression is caught
|
||||
the width contract depends on, so a silent regression is caught
|
||||
without a browser:
|
||||
|
||||
* the ``--chat-column`` custom property in ``:root`` — 46rem base
|
||||
(the PLAN §7 column lineage) with the provenance comment (owner
|
||||
instruction 2026-08-31, TODO L5 / D2);
|
||||
* the ``@media (min-width: 1500px)`` block at the bottom of the
|
||||
responsive region — the SINGLE place that doubles the token to
|
||||
92rem (2x);
|
||||
* the ``--chat-column`` custom property in ``:root`` — 72rem, EQUAL to
|
||||
the ``.container``'s 72rem cap (owner instruction 2026-09-12:
|
||||
"match the width of the RAG page for all other pages" — supersedes
|
||||
the 2026-08-31 instruction), with the provenance comment;
|
||||
* NO ``@media (min-width: 1500px)`` block anywhere in the file — the
|
||||
phase-58 wide-desktop doubling is deleted in full (the token is
|
||||
72rem at every viewport);
|
||||
* the four reading-column selectors — ``.chat-shell``,
|
||||
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
|
||||
each capped with ``max-width: var(--chat-column)`` and NOTHING else
|
||||
in the file uses the token (exactly four rules);
|
||||
* the negative pin — the form columns (``.tuning-shell``; and from
|
||||
phase 59, task 06, ``.doc-edit-shell`` — forms, not reading
|
||||
surfaces) are the only literal ``max-width: 46rem`` rules left in
|
||||
the file, kept hard-coded so the wide-desktop doubling never
|
||||
stretches a form;
|
||||
* the "46rem column contract" block comments were updated to name the
|
||||
base value + the wide override (the stale "≤46rem" contract claims
|
||||
are gone from the reading-column comments).
|
||||
in the file uses the token for a max-width (exactly four 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``)
|
||||
carry no cap, no token, and no ``margin-inline`` — structurally
|
||||
``.sources-shell``;
|
||||
* the two source-page dialogs (phase 69 remove-confirm, phase 89
|
||||
ignore-editor) cap at the chat-column width THROUGH THE TOKEN —
|
||||
``min(var(--chat-column), calc(100vw - 2rem))`` — or the viewport;
|
||||
* the stale width comments are gone — no "46rem base" / "92rem at
|
||||
>=1500px" / "1500px" width claims remain (the "46rem column
|
||||
contract" wording was rewritten to the 72rem contract, 2026-09-12).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,57 +62,52 @@ def _rule_block(css: str, selector: str) -> str:
|
||||
# ---------- the --chat-column token ----------
|
||||
|
||||
|
||||
def test_root_declares_chat_column_46rem_base() -> None:
|
||||
""":root declares --chat-column: 46rem (the PLAN §7 base) with the
|
||||
owner-provenance comment (instruction 2026-08-31, TODO L5)."""
|
||||
def test_root_declares_chat_column_72rem_equal_to_the_container() -> None:
|
||||
""":root declares --chat-column: 72rem — EQUAL to the .container's
|
||||
72rem cap (one width for everything) — with the owner-provenance
|
||||
comment (instruction 2026-09-12)."""
|
||||
css = _css()
|
||||
root = _rule_block(css, ":root")
|
||||
assert "--chat-column: 46rem" in root, (
|
||||
":root must declare the --chat-column base (46rem)"
|
||||
assert "--chat-column: 72rem" in root, (
|
||||
":root must declare the --chat-column width (72rem)"
|
||||
)
|
||||
pre = css[: css.index("--chat-column: 46rem")]
|
||||
# The token equals the container's cap (the RAG page's width).
|
||||
container = _rule_block(css, ".container")
|
||||
assert "max-width: 72rem" in container, (
|
||||
".container's 72rem cap is the width the token now equals"
|
||||
)
|
||||
pre = css[: css.index("--chat-column: 72rem")]
|
||||
comment = pre[pre.rindex("/*") : pre.rindex("*/")]
|
||||
assert "owner instruction 2026-08-31" in comment, (
|
||||
assert "owner instruction 2026-09-12" in comment, (
|
||||
"the token's comment must cite the owner instruction "
|
||||
"(2026-08-31, TODO L5)"
|
||||
"(2026-09-12 — match the width of the RAG page)"
|
||||
)
|
||||
|
||||
|
||||
def test_wide_media_block_doubles_the_token() -> None:
|
||||
"""A @media (min-width: 1500px) block sets --chat-column: 92rem on
|
||||
:root — the single wide override (2x the base)."""
|
||||
def test_the_wide_desktop_doubling_is_retired() -> None:
|
||||
"""No @media (min-width: 1500px) block remains (the phase-58 2x
|
||||
override is deleted in full, 2026-09-12) and the token is never
|
||||
assigned the old wide value — 72rem at every viewport."""
|
||||
css = _css()
|
||||
m = re.search(r"@media \(min-width: 1500px\) \{", css)
|
||||
assert m, "styles.css must carry the @media (min-width: 1500px) block"
|
||||
start = css.index("{", m.start())
|
||||
depth = 0
|
||||
for i in range(start, len(css)):
|
||||
if css[i] == "{":
|
||||
depth += 1
|
||||
elif css[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
block = css[m.start() : i + 1]
|
||||
break
|
||||
else:
|
||||
raise AssertionError("unbalanced braces in the wide media block")
|
||||
assert ":root { --chat-column: 92rem; }" in block, (
|
||||
"the wide block must set :root { --chat-column: 92rem; }"
|
||||
assert "@media (min-width: 1500px)" not in css, (
|
||||
"the wide-desktop media block must be deleted (retired 2026-09-12)"
|
||||
)
|
||||
assert "--chat-column: 92rem" not in css, (
|
||||
"the token is never doubled to the old wide value"
|
||||
)
|
||||
# The wide block is the ONLY min-width:1500 media in the file and
|
||||
# the only place 92rem is assigned to the token.
|
||||
assert css.count("@media (min-width: 1500px)") == 1
|
||||
assert css.count("--chat-column: 92rem") == 1
|
||||
|
||||
|
||||
def test_wide_block_lives_in_the_bottom_responsive_region() -> None:
|
||||
"""The min-width sibling sits alongside the max-width responsive
|
||||
blocks at the bottom of the file (after the <=640px block)."""
|
||||
def test_no_literal_46rem_width_remains() -> None:
|
||||
"""Flipped negative pin (phase 100 D1): ZERO literal
|
||||
max-width: 46rem rules remain in the file — the phase-27/59/91
|
||||
form-column caps are retired, and the dialog panels ride the
|
||||
token, not a 46rem literal."""
|
||||
css = _css()
|
||||
wide = css.index("@media (min-width: 1500px)")
|
||||
mobile = css.rindex("@media (max-width: 640px)")
|
||||
assert wide > mobile, (
|
||||
"the wide override belongs in the bottom media-query region"
|
||||
assert css.count("max-width: 46rem") == 0, (
|
||||
"no literal max-width: 46rem rule may remain (retired 2026-09-12)"
|
||||
)
|
||||
assert "min(46rem" not in css, (
|
||||
"the dialog panels must ride the token, not a 46rem literal"
|
||||
)
|
||||
|
||||
|
||||
@@ -119,7 +118,7 @@ def test_the_four_reading_columns_use_the_token() -> None:
|
||||
""".chat-shell, .shared-shell, .doc-md and
|
||||
.doc-summary:has(+ .doc-md) each cap with
|
||||
max-width: var(--chat-column) — and exactly those four rules use
|
||||
the token (no other selector)."""
|
||||
the token for a max-width (no other selector)."""
|
||||
css = _css()
|
||||
for selector in (
|
||||
".chat-shell",
|
||||
@@ -135,73 +134,89 @@ def test_the_four_reading_columns_use_the_token() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_shared_shell_keeps_the_centered_column_comment() -> None:
|
||||
""".shared-shell's inline comment keeps the "centered chat column"
|
||||
wording and notes the wide override (task 01 work item)."""
|
||||
css = _css()
|
||||
rule = css[css.index(".shared-shell {") : css.index(".shared-shell {") + 400]
|
||||
assert "the PLAN §7 centered chat column" in rule
|
||||
assert "92rem at >=1500px" in rule, "the comment must note the wide override"
|
||||
|
||||
|
||||
def test_doc_md_keeps_width_100_under_the_cap() -> None:
|
||||
""".doc-md stays width:100% under the token cap (the modal's
|
||||
1100px panel remains its effective ceiling there)."""
|
||||
1100px panel remains its effective ceiling there — phase 100 D2)."""
|
||||
assert "width: 100%" in _rule_block(_css(), ".doc-md")
|
||||
|
||||
|
||||
# ---------- the negative pins ----------
|
||||
# ---------- the form shells + the dialog panels ----------
|
||||
|
||||
|
||||
def test_tuning_shell_stays_hardcoded_46rem() -> None:
|
||||
""".tuning-shell (the form column, out of scope) keeps its
|
||||
hard-coded max-width: 46rem at every width — it never widens."""
|
||||
def test_the_form_shells_ride_the_full_container() -> None:
|
||||
"""The form shells (.tuning-shell — phase 27, .theme-shell —
|
||||
phase 91, .doc-edit-shell — phase 59) carry NO cap, NO token, and
|
||||
NO margin-inline: the .container ancestor centers them, so they
|
||||
ride the 72rem frame like .sources-shell (flex column,
|
||||
gap 1.25rem, flex: 1 — the owner instruction 2026-09-12 retires
|
||||
the form-column caps)."""
|
||||
css = _css()
|
||||
tuning = _rule_block(css, ".tuning-shell")
|
||||
assert "max-width: 46rem" in tuning, (
|
||||
".tuning-shell must stay hard-coded 46rem (negative pin)"
|
||||
)
|
||||
assert "var(--chat-column)" not in tuning, (
|
||||
".tuning-shell must NOT reference the reading-column token"
|
||||
)
|
||||
for selector in (".tuning-shell", ".theme-shell", ".doc-edit-shell"):
|
||||
block = _rule_block(css, selector)
|
||||
assert "max-width" not in block, (
|
||||
f"{selector} must carry no cap (retired 2026-09-12)"
|
||||
)
|
||||
assert "var(--chat-column)" not in block, (
|
||||
f"{selector} rides the .container frame, not the token"
|
||||
)
|
||||
assert "margin-inline" not in block, (
|
||||
f"{selector} is centered by the .container ancestor"
|
||||
)
|
||||
for decl in (
|
||||
"display: flex",
|
||||
"flex-direction: column",
|
||||
"gap: 1.25rem",
|
||||
"flex: 1",
|
||||
):
|
||||
assert decl in block, (
|
||||
f"{selector} keeps the .sources-shell shape ({decl})"
|
||||
)
|
||||
|
||||
|
||||
def test_no_other_hardcoded_46rem_rule_remains() -> None:
|
||||
"""After the switch, the form columns are the ONLY rules with a
|
||||
literal max-width: 46rem: .tuning-shell (phase 27),
|
||||
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
|
||||
FORM column, not a reading column, so it must not ride
|
||||
--chat-column and phase 58's wide-desktop doubling must never
|
||||
stretch the form), and .theme-shell (phase 91 task 04 — the
|
||||
admin Theme editor is a form column too: the palette grid +
|
||||
fieldsets must never ride the wide-desktop doubling). Every
|
||||
reading column rides the token (the --chat-column base
|
||||
declaration is the other non-rule occurrence of 46rem)."""
|
||||
def test_the_dialog_panels_ride_the_token() -> None:
|
||||
"""The two source-page dialogs (phase 69 remove-confirm, phase 89
|
||||
ignore-editor) cap at the chat-column width THROUGH THE TOKEN —
|
||||
min(var(--chat-column), calc(100vw - 2rem)) — or the viewport,
|
||||
whichever is narrower (the 46rem literals ride no more)."""
|
||||
css = _css()
|
||||
assert css.count("max-width: 46rem") == 3, (
|
||||
"only the form columns (.tuning-shell, .doc-edit-shell, "
|
||||
".theme-shell) may keep a literal max-width: 46rem"
|
||||
)
|
||||
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".theme-shell")
|
||||
for selector in (".remove-confirm-panel", ".ignore-editor-panel"):
|
||||
block = _rule_block(css, selector)
|
||||
assert "min(var(--chat-column), calc(100vw - 2rem))" in block, (
|
||||
f"{selector} must ride the token (the chat-column width "
|
||||
"or the viewport)"
|
||||
)
|
||||
|
||||
|
||||
def test_comments_cite_the_wide_override_with_provenance() -> None:
|
||||
"""The block comments that claimed the "46rem column contract" now
|
||||
name base 46rem + the 2x wide override, with the owner
|
||||
instruction (2026-08-31, TODO L5) as the provenance at the token
|
||||
and the media block."""
|
||||
# ---------- the comment contract ----------
|
||||
|
||||
|
||||
def test_comments_carry_the_72rem_contract() -> None:
|
||||
"""The stale width claims are gone from the file: no "≤46rem",
|
||||
"46rem base", "92rem at >=1500px" or "1500px" wording (the
|
||||
2026-08-31 contract was rewritten to the 72rem contract), with the
|
||||
owner instruction (2026-09-12) as the provenance at the token and
|
||||
the reading-column comments naming 72rem."""
|
||||
css = _css()
|
||||
# The stale "≤46rem" contract claims are gone from the file.
|
||||
assert "≤46rem" not in css, (
|
||||
"the stale '≤46rem' contract wording must be updated"
|
||||
)
|
||||
# Provenance at the two authoritative spots (token + wide block).
|
||||
token_idx = css.index("--chat-column: 46rem")
|
||||
wide_idx = css.index("@media (min-width: 1500px)")
|
||||
assert "owner instruction 2026-08-31" in css[max(0, token_idx - 400) : token_idx]
|
||||
assert "owner instruction 2026-08-31" in css[max(0, wide_idx - 500) : wide_idx]
|
||||
# The chat-shell comment names base + override.
|
||||
assert "46rem base" not in css, "no stale '46rem base' claim may remain"
|
||||
assert "92rem at >=1500px" not in css, (
|
||||
"no stale wide-override claim may remain"
|
||||
)
|
||||
assert "1500px" not in css, (
|
||||
"the wide-desktop breakpoint wording is retired (2026-09-12)"
|
||||
)
|
||||
# Provenance at the authoritative spot (the token).
|
||||
token_idx = css.index("--chat-column: 72rem")
|
||||
assert "owner instruction 2026-09-12" in css[max(0, token_idx - 400) : token_idx]
|
||||
# The chat-shell + shared-shell comments name the 72rem contract.
|
||||
chat_comment = css[: css.index(".chat-shell {")]
|
||||
assert "46rem base" in chat_comment and "92rem" in chat_comment
|
||||
assert "72rem" in chat_comment, (
|
||||
"the chat-shell comment must name the 72rem contract"
|
||||
)
|
||||
shared_idx = css.index(".shared-shell {")
|
||||
shared_rule = css[shared_idx : shared_idx + 400]
|
||||
assert "72rem" in shared_rule, (
|
||||
"the shared-shell comment must name the 72rem contract"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user