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;
|
--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
|
||||||
|
|||||||
@@ -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}/")
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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