feat(web): 2x reading column on wide desktops — 92rem at >=1500px (chat, shared, document view)
This commit is contained in:
+56
-20
@@ -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 <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).
|
||||
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/<token>: 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
|
||||
|
||||
@@ -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}/")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user