diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 42e3af9..e87a86c 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -33,6 +33,11 @@
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--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; }
@@ -394,9 +399,11 @@ html::after {
/* Chat is a vertical conversation: a centered, capped column is the
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 {
- max-width: 46rem;
+ max-width: var(--chat-column);
margin-inline: auto;
display: flex;
flex-direction: column;
@@ -413,8 +420,9 @@ html::after {
on desktop. The ≤640px block flips this to a vertical stack
(flex-direction: column + align-items: stretch — full-width pills,
New chat above Share; the existing ≤640px pill rules apply to the
- stacked pills unchanged). The 46rem column contract is untouched
- (PLAN §7). */
+ stacked pills unchanged). The reading-column contract is untouched
+ (--chat-column: 46rem base, 92rem at >=1500px — PLAN §7 lineage).
+ */
.chat-actions {
display: flex;
flex-direction: row;
@@ -493,7 +501,8 @@ html::after {
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
renderer wraps every table in .md-table-wrap — the horizontal
scroller, so a wide table scrolls inside the bubble instead of
- breaking the 46rem column — around a semantic
+ breaking the reading column (--chat-column: 46rem base, 92rem at
+ >=1500px) — around a semantic
(escape-first cells; alignment colons render left, owner decision).
Phase-08 tokens only: --line hairline borders and the thead tinted
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
--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)
- theme. Inline rows only: appending lines never shifts the 46rem chat
- column (no new container), and the rows are not interactive — no
- focus targets. */
+ theme. Inline rows only: appending lines never shifts the chat
+ column (46rem base; 92rem at >=1500px — no new container), and the
+ rows are not interactive — no focus targets. */
.tool-calls {
display: flex;
flex-direction: column;
@@ -1144,7 +1153,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Composer ---------- */
/* Phase 52 (2026-08-30, TODO.md L3): the composer is PINNED to the
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
viewport at every scroll position and settles back into its normal
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/: the anonymous read-only conversation (owner-locked
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
unchanged) with NO composer, so the column contract holds for a
guest. Zero interactive controls (owner-locked): the chips are
@@ -2150,7 +2161,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
fonts. */
.shared-shell {
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;
display: flex;
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-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 {
width: 100%;
- max-width: 46rem;
+ max-width: var(--chat-column);
margin-inline: auto;
background: var(--surface);
border: 1px solid var(--line);
@@ -2361,12 +2375,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin-bottom: 1rem;
overflow-wrap: anywhere;
}
-/* md/markdown: the panel matches the .doc-md ≤46rem centered reading
- column — it is the column's label. Raw formats stay full width (the
+/* md/markdown: the panel matches the .doc-md centered reading column
+ (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
without :has() the panel degrades to that full-width default. */
.doc-summary:has(+ .doc-md) {
- max-width: 46rem;
+ max-width: var(--chat-column);
margin-inline: auto;
}
.doc-summary-title {
@@ -2528,7 +2543,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Document modal (phase 26) ----------
"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
- 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,
actions, scroll container). Phase-08 tokens only; NO blur (the
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.
- .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. */
.doc-modal-content {
flex: 1;
@@ -2951,8 +2968,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.history-actions { flex-wrap: wrap; }
/* Phase 51: the shared page squeezes like the chat column — 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
- the page) and .msg-body's 92% override above applies. */
+ shell keeps its base 46rem column (the >=1500px 92rem override
+ 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-note { font-size: 0.88rem; }
.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
180ms slide+fade; open/close snaps (the visibility/opacity flip
applies instantly) and stays correct. BOTH states are named: the
diff --git a/tests/e2e/test_responsive_polish.py b/tests/e2e/test_responsive_polish.py
index 0ab7abe..41fbb51 100644
--- a/tests/e2e/test_responsive_polish.py
+++ b/tests/e2e/test_responsive_polish.py
@@ -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}/")
diff --git a/tests/e2e/test_wide_desktop_column.py b/tests/e2e/test_wide_desktop_column.py
new file mode 100644
index 0000000..de0d3c6
--- /dev/null
+++ b/tests/e2e/test_wide_desktop_column.py
@@ -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/`` 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()
diff --git a/tests/unit/test_shared_page.py b/tests/unit/test_shared_page.py
index b8c66d3..44e99a1 100644
--- a/tests/unit/test_shared_page.py
+++ b/tests/unit/test_shared_page.py
@@ -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
diff --git a/tests/unit/test_wide_column_css.py b/tests/unit/test_wide_column_css.py
new file mode 100644
index 0000000..e4caafa
--- /dev/null
+++ b/tests/unit/test_wide_column_css.py
@@ -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