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
+56 -20
View File
@@ -33,6 +33,11 @@
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--header-h: 64px; --header-h: 64px;
/* Reading column: 46rem base (PLAN §7 lineage); 2x on wide desktops
(owner instruction 2026-08-31, TODO L5 / D2 — chat + shared +
document view). */
--chat-column: 46rem;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -394,9 +399,11 @@ html::after {
/* Chat is a vertical conversation: a centered, capped column is the /* Chat is a vertical conversation: a centered, capped column is the
correct layout here (PLAN §UI/UX). The surrounding frame keeps it correct layout here (PLAN §UI/UX). The surrounding frame keeps it
from collapsing into a hairline on wide screens. */ from collapsing into a hairline on wide screens. The cap is the
--chat-column token: 46rem base, 2x (92rem) at >=1500px wide
desktops (owner instruction 2026-08-31, TODO.md L5 / D2). */
.chat-shell { .chat-shell {
max-width: 46rem; max-width: var(--chat-column);
margin-inline: auto; margin-inline: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -413,8 +420,9 @@ html::after {
on desktop. The ≤640px block flips this to a vertical stack on desktop. The ≤640px block flips this to a vertical stack
(flex-direction: column + align-items: stretch — full-width pills, (flex-direction: column + align-items: stretch — full-width pills,
New chat above Share; the existing ≤640px pill rules apply to the New chat above Share; the existing ≤640px pill rules apply to the
stacked pills unchanged). The 46rem column contract is untouched stacked pills unchanged). The reading-column contract is untouched
(PLAN §7). */ (--chat-column: 46rem base, 92rem at >=1500px — PLAN §7 lineage).
*/
.chat-actions { .chat-actions {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -493,7 +501,8 @@ html::after {
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared /* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
renderer wraps every table in .md-table-wrap — the horizontal renderer wraps every table in .md-table-wrap — the horizontal
scroller, so a wide table scrolls inside the bubble instead of scroller, so a wide table scrolls inside the bubble instead of
breaking the 46rem column — around a semantic <table class="md-table"> breaking the reading column (--chat-column: 46rem base, 92rem at
>=1500px) — around a semantic <table class="md-table">
(escape-first cells; alignment colons render left, owner decision). (escape-first cells; alignment colons render left, owner decision).
Phase-08 tokens only: --line hairline borders and the thead tinted Phase-08 tokens only: --line hairline borders and the thead tinted
from the plain surface family — --ink on --surface is 14.5:1 (PLAN from the plain surface family — --ink on --surface is 14.5:1 (PLAN
@@ -590,9 +599,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
own icon, own accent left border. Contrast: --accent-ink on the row's own icon, own accent left border. Contrast: --accent-ink on the row's
--surface ≈10.4:1 (11.6:1 on the page bg), and --ink on --brand-soft --surface ≈10.4:1 (11.6:1 on the page bg), and --ink on --brand-soft
in the path `code` ≈11.5:1 — all comfortably AA in the (single dark) in the path `code` ≈11.5:1 — all comfortably AA in the (single dark)
theme. Inline rows only: appending lines never shifts the 46rem chat theme. Inline rows only: appending lines never shifts the chat
column (no new container), and the rows are not interactive — no column (46rem base; 92rem at >=1500px — no new container), and the
focus targets. */ rows are not interactive — no focus targets. */
.tool-calls { .tool-calls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1144,7 +1153,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Composer ---------- */ /* ---------- Composer ---------- */
/* Phase 52 (2026-08-30, TODO.md L3): the composer is PINNED to the /* Phase 52 (2026-08-30, TODO.md L3): the composer is PINNED to the
viewport bottom. The page scrolls at the document level and viewport bottom. The page scrolls at the document level and
`.chat-shell` (the centered 46rem column) is the composer's sticky `.chat-shell` (the centered reading column — 46rem base, 92rem at
>=1500px) is the composer's sticky
containing block, so the box sticks to the bottom edge of the containing block, so the box sticks to the bottom edge of the
viewport at every scroll position and settles back into its normal viewport at every scroll position and settles back into its normal
flow position (above the footer) once the document bottom is flow position (above the footer) once the document bottom is
@@ -2138,7 +2148,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Shared page (phase 51, task 03) ---------- /* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked /shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered 2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
46rem chat column — the conversation reads exactly like the chat chat column (--chat-column: 46rem base, 92rem at >=1500px) — the
conversation reads exactly like the chat
page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply
unchanged) with NO composer, so the column contract holds for a unchanged) with NO composer, so the column contract holds for a
guest. Zero interactive controls (owner-locked): the chips are guest. Zero interactive controls (owner-locked): the chips are
@@ -2150,7 +2161,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
fonts. */ fonts. */
.shared-shell { .shared-shell {
width: 100%; width: 100%;
max-width: 46rem; /* the PLAN §7 centered chat column */ max-width: var(--chat-column); /* the PLAN §7 centered chat column
(46rem base; 92rem at >=1500px — phase 58) */
margin-inline: auto; margin-inline: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2313,10 +2325,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#doc-content { display: flex; flex-direction: column; } #doc-content { display: flex; flex-direction: column; }
.doc-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); } .doc-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
/* Markdown: the centered, ≤46rem reading column (PLAN §7.1). */ /* Markdown: the centered reading column — 46rem base, 2x (92rem) at
>=1500px (PLAN §7.1 lineage; owner instruction 2026-08-31, TODO.md
L5 / D2). */
.doc-md { .doc-md {
width: 100%; width: 100%;
max-width: 46rem; max-width: var(--chat-column);
margin-inline: auto; margin-inline: auto;
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -2361,12 +2375,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin-bottom: 1rem; margin-bottom: 1rem;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* md/markdown: the panel matches the .doc-md ≤46rem centered reading /* md/markdown: the panel matches the .doc-md centered reading column
column — it is the column's label. Raw formats stay full width (the (46rem base; 92rem at >=1500px — the same --chat-column token) —
it is the column's label. Raw formats stay full width (the
.doc-raw default above), matching the full-width pre; in engines .doc-raw default above), matching the full-width pre; in engines
without :has() the panel degrades to that full-width default. */ without :has() the panel degrades to that full-width default. */
.doc-summary:has(+ .doc-md) { .doc-summary:has(+ .doc-md) {
max-width: 46rem; max-width: var(--chat-column);
margin-inline: auto; margin-inline: auto;
} }
.doc-summary-title { .doc-summary-title {
@@ -2528,7 +2543,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Document modal (phase 26) ---------- /* ---------- Document modal (phase 26) ----------
"New documents should open in an almost-fullscreen modal, not in a new "New documents should open in an almost-fullscreen modal, not in a new
page" (TODO.md L4). The overlay reuses the viewer page's .doc-meta page" (TODO.md L4). The overlay reuses the viewer page's .doc-meta
badge classes, the .doc-md ≤46rem reading column, and the .doc-raw badge classes, the .doc-md reading column (46rem base; 92rem at
>=1500px), and the .doc-raw
pre — this block only adds the chrome (backdrop, panel, header, pre — this block only adds the chrome (backdrop, panel, header,
actions, scroll container). Phase-08 tokens only; NO blur (the actions, scroll container). Phase-08 tokens only; NO blur (the
phase-08 no-blur perf anchor); no new assets; system fonts. phase-08 no-blur perf anchor); no new assets; system fonts.
@@ -2667,7 +2683,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
/* The scroll container: vertical scroll lives HERE, never the viewport. /* The scroll container: vertical scroll lives HERE, never the viewport.
.doc-md keeps its ≤46rem centered reading column inside; .doc-raw keeps .doc-md keeps its centered reading column inside (46rem base;
92rem at >=1500px, capped there by the 1100px panel); .doc-raw keeps
its own overflow-x. tabindex="-1" in the markup is the JS focus target. */ its own overflow-x. tabindex="-1" in the markup is the JS focus target. */
.doc-modal-content { .doc-modal-content {
flex: 1; flex: 1;
@@ -2951,8 +2968,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.history-actions { flex-wrap: wrap; } .history-actions { flex-wrap: wrap; }
/* Phase 51: the shared page squeezes like the chat column — the /* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the title and the note step down (the empty-state-title family); the
shell keeps its 46rem column (it is already the narrowest box on shell keeps its base 46rem column (the >=1500px 92rem override
the page) and .msg-body's 92% override above applies. */ never applies here — it is already the narrowest box on the
page) and .msg-body's 92% override above applies. */
#shared-title { font-size: 1.35rem; } #shared-title { font-size: 1.35rem; }
.shared-note { font-size: 0.88rem; } .shared-note { font-size: 0.88rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; } .footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
@@ -2973,6 +2991,24 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
} }
/* Phase 58: wide desktops (viewport >=1500px) read the 2x column —
--chat-column doubles to 92rem for the four reading shells (chat,
shared, the document viewer's .doc-md and its summary panel). The
chat and shared shells ARE their .container (the token max-width
overrides the .container's 72rem cap — same specificity, later in
the file), but the document page's .container.doc-shell WRAPS the
column, so the 72rem cap would bind first and pin .doc-md at
~1112px: the wide block lifts the shell's cap to the column plus
the container's two 1.25rem gutters, letting .doc-md's own 92rem
cap bind (1472px at the 16px root). Everything below 1500px
renders exactly as before, and .tuning-shell (a form, not a
reading surface) keeps its hard-coded 46rem at every width
(owner instruction 2026-08-31, TODO.md L5 / D2). */
@media (min-width: 1500px) {
:root { --chat-column: 92rem; }
.doc-shell { max-width: calc(var(--chat-column) + 2 * 1.25rem); }
}
/* Phase 46: prefers-reduced-motion stills the mobile menu — no /* Phase 46: prefers-reduced-motion stills the mobile menu — no
180ms slide+fade; open/close snaps (the visibility/opacity flip 180ms slide+fade; open/close snaps (the visibility/opacity flip
applies instantly) and stays correct. BOTH states are named: the applies instantly) and stays correct. BOTH states are named: the
+28 -8
View File
@@ -11,9 +11,13 @@ Test → story mapping:
1. ``test_no_horizontal_overflow_at_viewports`` — 360/375/768/1280/1600 on 1. ``test_no_horizontal_overflow_at_viewports`` — 360/375/768/1280/1600 on
both pages: ``documentElement.scrollWidth <= clientWidth``. both pages: ``documentElement.scrollWidth <= clientWidth``.
2. ``test_chat_column_capped_and_centered`` — at 1600px ``.chat-shell`` 2. ``test_chat_column_capped_and_centered`` — the reading column rides
≤ 46rem (736px, +2% tolerance) and horizontally centered (±2%); at 768px --chat-column (46rem base; 92rem at >=1500px, phase 58 / owner
the column uses most of the width (no mid-column dead zones). 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 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 the container; below 640px the table keeps its 640px min-width and the
wrapper scrolls horizontally instead of squeezing. wrapper scrolls horizontally instead of squeezing.
@@ -51,7 +55,8 @@ REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs" FIXTURES = REPO / "tests" / "fixtures" / "docs"
VIEWPORTS = ((360, 740), (375, 812), (768, 1024), (1280, 800), (1600, 900)) 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). # Mock-LLM marker for a 3s pre-token window (see tests/e2e/mock_llm.py).
SLOW_QUESTION = "pretend to think slowly, please" 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( def test_chat_column_capped_and_centered(
browser: Browser, app_url: str, db_ready: None browser: Browser, app_url: str, db_ready: None
) -> None: ) -> None:
"""AC1: chat column stays ≤46rem centered at wide viewports and still """AC1 (phase 58 contract): the reading column doubles to 92rem on
uses most of the width on tablets (no mid-column dead zones).""" 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}) page = browser.new_page(viewport={"width": 1600, "height": 900})
try: try:
page.goto(f"{app_url}/") page.goto(f"{app_url}/")
box = page.locator(".chat-shell").bounding_box() box = page.locator(".chat-shell").bounding_box()
assert box is not None assert box is not None
assert box["width"] <= CHAT_SHELL_CAP_PX * 1.02, ( assert CHAT_SHELL_WIDE_PX * 0.98 <= box["width"] <= CHAT_SHELL_WIDE_PX * 1.02, (
f"chat column {box['width']:.0f}px exceeds the 46rem cap (+2%)" 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 center = box["x"] + box["width"] / 2
assert abs(center - 1600 / 2) <= 0.02 * 1600, ( assert abs(center - 1600 / 2) <= 0.02 * 1600, (
@@ -234,6 +242,18 @@ def test_chat_column_capped_and_centered(
finally: finally:
page.close() 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}) tablet = browser.new_page(viewport={"width": 768, "height": 1024})
try: try:
tablet.goto(f"{app_url}/") 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 chat page's interactive builders, and the rendered messages contain
no button/form/link — the chips are plain spans, the source chips no button/form/link — the chips are plain spans, the source chips
carry no ``href``; 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). invalid-state CSS (the ≤640px squeeze included).
""" """
from __future__ import annotations from __future__ import annotations
@@ -378,15 +379,22 @@ def test_brand_note_resolves_at_call_time() -> None:
# ---------- styles.css: the shared page ---------- # ---------- styles.css: the shared page ----------
def test_shared_shell_maps_to_the_46rem_column() -> None: def test_shared_shell_maps_to_the_reading_column() -> None:
"""The PLAN §7 column contract: .shared-shell is the centered """The PLAN §7 column contract (phase 58): .shared-shell is the
46rem chat column (the conversation reads exactly like the chat centered chat column riding the --chat-column token (46rem base,
page's, so the existing .msg/.bubble CSS applies unchanged).""" 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() css = _css()
block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css) block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .shared-shell" assert block, "styles.css must style .shared-shell"
body = block.group(1) 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 "margin-inline: auto" in body, "centered"
assert "display: flex" in body and "flex-direction: column" in body 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