feat(web): 2x reading column on wide desktops — 92rem at >=1500px (chat, shared, document view)

This commit is contained in:
2026-09-01 00:39:19 -04:00
parent 140b97ebf3
commit 7b7a834a1a
5 changed files with 727 additions and 34 deletions
+28 -8
View File
@@ -11,9 +11,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`` — at 1600px ``.chat-shell``
≤ 46rem (736px, +2% tolerance) and horizontally centered (±2%); at 768px
the column uses most of the width (no mid-column dead zones).
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).
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.
@@ -51,7 +55,8 @@ 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 — PLAN §7.1
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)
# Mock-LLM marker for a 3s pre-token window (see tests/e2e/mock_llm.py).
SLOW_QUESTION = "pretend to think slowly, please"
@@ -217,15 +222,18 @@ def test_no_horizontal_overflow_at_viewports(
def test_chat_column_capped_and_centered(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC1: chat column stays ≤46rem centered at wide viewports and still
uses most of the width on tablets (no mid-column dead zones)."""
"""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)."""
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 box["width"] <= CHAT_SHELL_CAP_PX * 1.02, (
f"chat column {box['width']:.0f}px exceeds the 46rem cap (+2%)"
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%)"
)
center = box["x"] + box["width"] / 2
assert abs(center - 1600 / 2) <= 0.02 * 1600, (
@@ -234,6 +242,18 @@ def test_chat_column_capped_and_centered(
finally:
page.close()
narrow = browser.new_page(viewport={"width": 1280, "height": 800})
try:
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%)"
)
finally:
narrow.close()
tablet = browser.new_page(viewport={"width": 768, "height": 1024})
try:
tablet.goto(f"{app_url}/")
+432
View File
@@ -0,0 +1,432 @@
"""Phase 58 E2E (Playwright): the 2x reading column on wide desktops —
measured bounding-box widths, not CSS pins.
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).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_wide_desktop_column.py -v --no-cov
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.
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).
"""
from __future__ import annotations
import asyncio
import re
import time
from datetime import UTC, datetime
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Browser, Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
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}
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
#: The seeded markdown document (direct row insert — the viewer is
#: database-only, so no fixture file is needed). Encoded viewer URL
#: values: slashes come out as %2F, same as the chips build them.
FIXTURE_SOURCE = "docs"
FIXTURE_PATH = "notes/wide-column-fixture.md"
FIXTURE_TITLE = "Wide Column Fixture"
FIXTURE_DOC_URL = "/document.html?source=docs&path=notes%2Fwide-column-fixture.md"
# ---------------------------------------------------------------------------
# KB seeding (house pattern — test_document_viewer.py / test_share_chat.py)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test
body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log + steering notes — deterministic
mock answers), then optionally re-import fixtures. ``saved_chats``
is deliberately NOT touched: the shared test cleans up its own
row in a ``finally``."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
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)."""
with SessionLocal() as db:
db.add(
Document(
source=FIXTURE_SOURCE,
path=FIXTURE_PATH,
full_path="/tmp/wide-column-fixture.md",
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."
),
summary="A phase-58 fixture summary for the wide-column pin.",
content_hash="w" * 64,
indexed_at=datetime.now(UTC),
)
)
db.commit()
# ---------------------------------------------------------------------------
# Measurement + chat-turn helpers
# ---------------------------------------------------------------------------
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"
)
def _assert_centered(page: Page, selector: str, viewport_w: int, label: str) -> None:
"""margin-inline: auto — the column center is within ±2% of the
viewport center (the test_responsive_polish.py assertion style)."""
box = page.locator(selector).first.bounding_box()
assert box is not None, f"{selector} not rendered ({label})"
center = box["x"] + box["width"] / 2
assert abs(center - viewport_w / 2) <= 0.02 * viewport_w, (
f"{label}: {selector} center {center:.1f}px is not within ±2% of "
f"the {viewport_w}px viewport center"
)
def _doc_overflow(page: Page) -> tuple[int, int]:
"""(scrollWidth, clientWidth) of the documentElement."""
return page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
) # pyright: ignore[reportReturnType]
def _assert_no_doc_overflow(page: Page, label: str) -> None:
scroll, client = _doc_overflow(page)
assert scroll <= client, f"horizontal overflow at {label}: {scroll} > {client}"
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully
landed (the ``done`` event restored the Send button)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
poll the admin list until the row with the auto-title appears with
the expected message count)."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = next(
(c for c in _chats(app_url, cookies) if c["title"] == title), None
)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
# ---------------------------------------------------------------------------
# 1. Chat: 1472px at 1920, back to 736px at 1280 (centered both ways)
# ---------------------------------------------------------------------------
def test_chat_column_wide_vs_base(
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:
wide.goto(f"{app_url}/")
wide.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_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:
base.goto(f"{app_url}/")
base.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_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
# ---------------------------------------------------------------------------
def test_document_column_wide_vs_base(
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))."""
_reset_db(mock_llm, seed=True)
_seed_summary_doc()
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
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")
_assert_width(
wide, ".doc-summary:has(+ .doc-md)", WIDE_PX, "summary panel @ 1920px"
)
finally:
wide.close()
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
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")
finally:
base.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
# ---------------------------------------------------------------------------
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})
try:
phone.goto(f"{app_url}/")
phone.locator("#suggestions .suggestion-chip").first.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})
try:
tablet.goto(f"{app_url}/")
tablet.locator("#suggestions .suggestion-chip").first.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")
finally:
tablet.close()
+14 -6
View File
@@ -19,7 +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 46rem column mapping + the static-chip and
* the shared shell's reading-column mapping (--chat-column token,
phase 58) + the static-chip and
invalid-state CSS (the ≤640px squeeze included).
"""
from __future__ import annotations
@@ -378,15 +379,22 @@ def test_brand_note_resolves_at_call_time() -> None:
# ---------- styles.css: the shared page ----------
def test_shared_shell_maps_to_the_46rem_column() -> None:
"""The PLAN §7 column contract: .shared-shell is the centered
46rem chat column (the conversation reads exactly like the chat
page's, so the existing .msg/.bubble CSS applies unchanged)."""
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."""
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: 46rem" in body, "the PLAN §7 centered chat column"
assert "max-width: var(--chat-column)" in body, (
"the PLAN §7 centered chat column (phase 58 token)"
)
assert "46rem" not in body.split("/*")[0], (
"no hard-coded cap — the width rides the token"
)
assert "margin-inline: auto" in body, "centered"
assert "display: flex" in body and "flex-direction: column" in body
+197
View File
@@ -0,0 +1,197 @@
"""Unit: the 2x reading column on wide desktops (phase 58, task 01).
The measured-width browser proof is E2E-gated by the phase-58 story
suite (task 02); 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
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 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 — ``.tuning-shell`` (a form, not a reading
surface) keeps its hard-coded ``max-width: 46rem`` at every width,
and it is the only literal ``max-width: 46rem`` rule left in the
file;
* 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).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _rule_block(css: str, selector: str) -> str:
"""The declaration block of a top-level (or nested) rule: the
``{…}`` following ``selector`` via balanced-brace counting."""
m = re.search(rf"^{re.escape(selector)} \{{", css, re.MULTILINE)
assert m, f"styles.css must define a rule for {selector}"
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:
return css[start : i + 1]
raise AssertionError(f"unbalanced braces in the {selector} rule")
# ---------- 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)."""
css = _css()
root = _rule_block(css, ":root")
assert "--chat-column: 46rem" in root, (
":root must declare the --chat-column base (46rem)"
)
pre = css[: css.index("--chat-column: 46rem")]
comment = pre[pre.rindex("/*") : pre.rindex("*/")]
assert "owner instruction 2026-08-31" in comment, (
"the token's comment must cite the owner instruction "
"(2026-08-31, TODO L5)"
)
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)."""
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; }"
)
# 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)."""
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"
)
# ---------- the four reading-column selectors ----------
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)."""
css = _css()
for selector in (
".chat-shell",
".shared-shell",
".doc-md",
".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)") == 4, (
"exactly the four reading-column selectors use the token"
)
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)."""
assert "width: 100%" in _rule_block(_css(), ".doc-md")
# ---------- the negative pins ----------
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."""
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"
)
def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the .tuning-shell rule is the ONLY rule with
a literal max-width: 46rem — every reading column rides the
token (the --chat-column base declaration is the other
non-rule occurrence of 46rem)."""
css = _css()
assert css.count("max-width: 46rem") == 1, (
"only .tuning-shell may keep a literal max-width: 46rem"
)
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
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."""
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.
chat_comment = css[: css.index(".chat-shell {")]
assert "46rem base" in chat_comment and "92rem" in chat_comment