feat(ui): responsive + WCAG AA polish pass across chat and sources — v1 feature complete
This commit is contained in:
@@ -9,6 +9,13 @@ the relevant notes with **Postgres 17 + pgvector** cosine search, feeds the
|
|||||||
If it doesn't have notes for your question, it admits it:
|
If it doesn't have notes for your question, it admits it:
|
||||||
*"I haven't done anything like that"* — plus suggestions for what it **does** know.
|
*"I haven't done anything like that"* — plus suggestions for what it **does** know.
|
||||||
|
|
||||||
|
> **Updated your notes?** The knowledge base is refreshed by re-running the
|
||||||
|
> import — it's idempotent and only re-embeds what changed:
|
||||||
|
> ```bash
|
||||||
|
> uv run python -m scripts.import_docs --prune
|
||||||
|
> ```
|
||||||
|
> Details in [Updating the documents](#updating-the-documents).
|
||||||
|
|
||||||
- **Stack:** FastAPI · Pydantic v2 · SQLAlchemy 2 · Alembic · pgvector ·
|
- **Stack:** FastAPI · Pydantic v2 · SQLAlchemy 2 · Alembic · pgvector ·
|
||||||
vanilla HTML/CSS/JS (no CDN) · Playwright E2E
|
vanilla HTML/CSS/JS (no CDN) · Playwright E2E
|
||||||
- **Planning:** architecture, LOCKED decisions and the phase roadmap live
|
- **Planning:** architecture, LOCKED decisions and the phase roadmap live
|
||||||
@@ -60,10 +67,14 @@ uv run uvicorn app.main:app --reload
|
|||||||
# → http://localhost:8000 (chat) http://localhost:8000/sources.html (KB)
|
# → http://localhost:8000 (chat) http://localhost:8000/sources.html (KB)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 📝 **After this, day-to-day is just: edit markdown → re-run the import.**
|
||||||
|
> See [Updating the documents](#updating-the-documents) below.
|
||||||
|
|
||||||
## Updating the documents
|
## Updating the documents
|
||||||
|
|
||||||
The knowledge base is refreshed by **re-running the import**. It is
|
**This is the workflow you'll use most.** The knowledge base is refreshed by
|
||||||
idempotent and delta-based (sha256 per file):
|
**re-running the import**. It is idempotent and delta-based (sha256 per
|
||||||
|
file), so a refresh after a normal editing session takes seconds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# After editing/adding/removing markdown in your projects:
|
# After editing/adding/removing markdown in your projects:
|
||||||
@@ -74,13 +85,18 @@ uv run python -m scripts.import_docs --prune # also drop deleted files
|
|||||||
uv run python -m scripts.import_docs --source ~/SomeOtherDocs
|
uv run python -m scripts.import_docs --source ~/SomeOtherDocs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then check the **Sources** page (`http://localhost:8000/sources.html`):
|
||||||
|
the *documents* / *chunks* counters and *last indexed* timestamp should
|
||||||
|
reflect the new files, and each document row shows when it was last
|
||||||
|
embedded.
|
||||||
|
|
||||||
|
- The import prints one line per file (`import: added|updated|unchanged|
|
||||||
|
pruned …`) and ends with a greppable summary (`import: summary files=…
|
||||||
|
added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…`), so it
|
||||||
|
is safe to run from a cron job or after every commit.
|
||||||
- Only **`*.md`** files are indexed. Directories like `.venv`,
|
- Only **`*.md`** files are indexed. Directories like `.venv`,
|
||||||
`node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`
|
`node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`
|
||||||
are skipped (see `.agent/PLAN.md` anchor A9).
|
are skipped (see `.agent/PLAN.md` anchor A9).
|
||||||
- Every file is logged on its own line (`import: added|updated|unchanged|
|
|
||||||
pruned …`), and the run ends with a one-line summary (`import: summary
|
|
||||||
files=… added=… updated=… unchanged=… pruned=… chunks=… embed_batches=…`)
|
|
||||||
so the counts are greppable in logs.
|
|
||||||
- Unchanged files are **not re-embedded** — only new/changed ones, so
|
- Unchanged files are **not re-embedded** — only new/changed ones, so
|
||||||
refreshes are cheap.
|
refreshes are cheap.
|
||||||
- To sanity-check the LLM backend (models + embedding dimension) after any
|
- To sanity-check the LLM backend (models + embedding dimension) after any
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ body {
|
|||||||
}
|
}
|
||||||
.msg-body {
|
.msg-body {
|
||||||
max-width: 85%;
|
max-width: 85%;
|
||||||
|
/* min-width: 0 — as a flex item this overrides min-width:auto so a
|
||||||
|
long unbroken token can wrap (overflow-wrap: anywhere) instead of
|
||||||
|
widening the bubble past the column (AC7, phase 07). */
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -235,6 +239,8 @@ body {
|
|||||||
padding: 0.15rem 0.6rem;
|
padding: 0.15rem 0.6rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
/* min-width: 0 so the nowrap pill ellipsizes instead of widening its row */
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -252,6 +258,13 @@ body {
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
}
|
}
|
||||||
|
/* As flex items these chips must be allowed to shrink (min-width:auto
|
||||||
|
would let a long title-derived chip exceed the column on phones —
|
||||||
|
phase 07 overflow fix); the label text then wraps inside the pill. */
|
||||||
|
.maybe-try .suggestion-chip {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* typing indicator */
|
/* typing indicator */
|
||||||
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; }
|
.typing { display: inline-flex; gap: 5px; padding: 0.9rem 1rem; }
|
||||||
@@ -334,8 +347,6 @@ body {
|
|||||||
padding: 0.55rem 0.5rem;
|
padding: 0.55rem 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.composer textarea:focus { outline: none; }
|
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main" class="app-main">
|
<main id="main" class="app-main" tabindex="-1">
|
||||||
<div class="container chat-shell" data-state="empty">
|
<div class="container chat-shell" data-state="empty">
|
||||||
<div class="kb-banner" id="kb-banner" role="status" hidden>
|
<div class="kb-banner" id="kb-banner" role="status" hidden>
|
||||||
<span aria-hidden="true">⚠️</span>
|
<span aria-hidden="true">⚠️</span>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main" class="app-main">
|
<main id="main" class="app-main" tabindex="-1">
|
||||||
<div class="container sources-shell">
|
<div class="container sources-shell">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>Knowledge base</h1>
|
<h1>Knowledge base</h1>
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
"""Phase 07 E2E (Playwright): responsive + WCAG 2.1 AA polish sweep.
|
||||||
|
|
||||||
|
Story: ``.agent/user_stories/responsive-polish.md``
|
||||||
|
This phase IS the visual/a11y verification (PLAN §7 end-to-end); the suite
|
||||||
|
below is the acceptance test. Run in isolation (DB must be up:
|
||||||
|
``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov
|
||||||
|
|
||||||
|
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).
|
||||||
|
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.
|
||||||
|
4. ``test_a11y_landmarks_and_labels`` — landmarks on both pages, skip link
|
||||||
|
focuses ``#main``, the composer input has a programmatically associated
|
||||||
|
label, every button has an accessible name, and a real keyboard Tab
|
||||||
|
walk shows a visible ``:focus-visible`` outline on every focusable.
|
||||||
|
5. ``test_contrast_pairs_pass_aa`` — the PLAN §7.2 color pairs, computed
|
||||||
|
from computed styles (WCAG relative-luminance ratio ≥ 4.5:1).
|
||||||
|
6. ``test_reduced_motion_respected`` — with ``reducedMotion: 'reduce'``
|
||||||
|
the typing dots have no running animation and the spinner is either
|
||||||
|
stopped or slowed to ≥2s; the turn still completes.
|
||||||
|
7. ``test_long_content_wraps_without_overflow`` — a 60+ char file path in
|
||||||
|
the Sources table ellipsizes (full path in ``title``) and 60+ char
|
||||||
|
unbroken tokens in chat bubbles wrap without any document overflow.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from playwright.sync_api import Browser, Page, expect
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
|
from app.rag.llm import LLMClient
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Mock-LLM marker for a 3s pre-token window (see tests/e2e/mock_llm.py).
|
||||||
|
SLOW_QUESTION = "pretend to think slowly, please"
|
||||||
|
|
||||||
|
TYPING = "#typing-indicator"
|
||||||
|
ANSWER = ".msg.brain .bubble:not(.typing)"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# KB seeding (same pattern as the earlier story suites)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _import_dirs(dirs: list[Path], 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([Path(d) for d in dirs], LLMClient(settings))
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||||
|
"""Truncate the KB (and query log), then optionally re-import fixtures."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||||
|
db.commit()
|
||||||
|
if not seed:
|
||||||
|
return None
|
||||||
|
return _run_in_thread(_import_dirs([FIXTURES], mock_port))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Browser helpers
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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 _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
|
||||||
|
"""Real keyboard Tab walk; returns each focused element's outline."""
|
||||||
|
first_key: str | None = None
|
||||||
|
seen: list[dict[str, str]] = []
|
||||||
|
for _ in range(max_tabs):
|
||||||
|
page.keyboard.press("Tab")
|
||||||
|
info = page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
const cls = String(el.className).split(" ")[0];
|
||||||
|
const label = (el.getAttribute("aria-label")
|
||||||
|
|| el.textContent || "").trim().slice(0, 24);
|
||||||
|
return {
|
||||||
|
key: el.tagName + "#" + (el.id || "") + "." + cls + ":" + label,
|
||||||
|
outline_style: cs.outlineStyle,
|
||||||
|
outline_width: cs.outlineWidth,
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
if info["key"].startswith("BODY"):
|
||||||
|
continue # focus has not entered the document yet
|
||||||
|
if first_key is None:
|
||||||
|
first_key = info["key"]
|
||||||
|
seen.append(info)
|
||||||
|
if len(seen) > 1 and info["key"] == first_key:
|
||||||
|
break # wrapped back to the first focusable
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# WCAG 2.1 contrast (computed from computed styles, not eyeballed)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb(value: str) -> tuple[int, int, int]:
|
||||||
|
value = value.strip()
|
||||||
|
hex_match = re.match(r"^#([0-9a-f]{6})$", value, re.IGNORECASE)
|
||||||
|
if hex_match:
|
||||||
|
h = hex_match.group(1)
|
||||||
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||||
|
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
|
||||||
|
assert match, f"unparsable color: {value!r}"
|
||||||
|
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
|
||||||
|
def chan(c: int) -> float:
|
||||||
|
s = c / 255
|
||||||
|
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
||||||
|
|
||||||
|
r, g, b = (chan(c) for c in rgb)
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_ratio(fg: str, bg: str) -> float:
|
||||||
|
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
|
||||||
|
if l1 < l2:
|
||||||
|
l1, l2 = l2, l1
|
||||||
|
return (l1 + 0.05) / (l2 + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_aa(pair: Any, label: str) -> None:
|
||||||
|
fg, bg = str(pair[0]), str(pair[1])
|
||||||
|
ratio = contrast_ratio(fg, bg)
|
||||||
|
assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Tests (story → test mapping, see module docstring)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_horizontal_overflow_at_viewports(
|
||||||
|
browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC2: no horizontal page overflow at any target viewport, both pages."""
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
for width, height in VIEWPORTS:
|
||||||
|
page = browser.new_page(viewport={"width": width, "height": height})
|
||||||
|
try:
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
page.locator("#suggestions .suggestion-chip").first.wait_for(
|
||||||
|
state="visible", timeout=10_000
|
||||||
|
)
|
||||||
|
_assert_no_doc_overflow(page, f"chat @ {width}px")
|
||||||
|
|
||||||
|
page.goto(f"{app_url}/sources.html")
|
||||||
|
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||||
|
_assert_no_doc_overflow(page, f"sources @ {width}px")
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
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)."""
|
||||||
|
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%)"
|
||||||
|
)
|
||||||
|
center = box["x"] + box["width"] / 2
|
||||||
|
assert abs(center - 1600 / 2) <= 0.02 * 1600, (
|
||||||
|
f"chat column center {center:.0f}px is not within ±2% of the viewport center"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
tablet = browser.new_page(viewport={"width": 768, "height": 1024})
|
||||||
|
try:
|
||||||
|
tablet.goto(f"{app_url}/")
|
||||||
|
box = tablet.locator(".chat-shell").bounding_box()
|
||||||
|
assert box is not None
|
||||||
|
assert box["width"] >= 0.85 * 768, (
|
||||||
|
f"at 768px the chat column ({box['width']:.0f}px) leaves a dead zone"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tablet.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_table_full_width(
|
||||||
|
browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC1: the Sources table is full-width (≥80% of the container) at
|
||||||
|
1280px; below 640px it keeps a 640px min-width and scrolls
|
||||||
|
horizontally inside its wrapper instead of squeezing."""
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||||
|
try:
|
||||||
|
page.goto(f"{app_url}/sources.html")
|
||||||
|
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||||
|
wrap_box = page.locator(".table-wrap").bounding_box()
|
||||||
|
shell_box = page.locator(".sources-shell").bounding_box()
|
||||||
|
assert wrap_box is not None and shell_box is not None
|
||||||
|
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
|
||||||
|
f"table wrapper {wrap_box['width']:.0f}px < 80% of container "
|
||||||
|
f"{shell_box['width']:.0f}px"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
mobile = browser.new_page(viewport={"width": 375, "height": 812})
|
||||||
|
try:
|
||||||
|
mobile.goto(f"{app_url}/sources.html")
|
||||||
|
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||||
|
scroll, client = mobile.evaluate(
|
||||||
|
"() => { const el = document.querySelector('.table-wrap');"
|
||||||
|
" return [el.scrollWidth, el.clientWidth]; }"
|
||||||
|
)
|
||||||
|
assert scroll > client, (
|
||||||
|
"at 375px the table must keep its min-width and scroll horizontally"
|
||||||
|
)
|
||||||
|
_assert_no_doc_overflow(mobile, "sources @ 375px (scrolling wrapper)")
|
||||||
|
finally:
|
||||||
|
mobile.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a11y_landmarks_and_labels(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC3: landmarks, working skip link, labeled input, named controls,
|
||||||
|
and a visible :focus-visible outline on every keyboard focusable."""
|
||||||
|
for path in ("/", "/sources.html"):
|
||||||
|
page.goto(f"{app_url}{path}")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Landmarks (PLAN §7.2).
|
||||||
|
assert page.locator("header.app-header").count() == 1, f"header missing on {path}"
|
||||||
|
assert page.locator("nav[aria-label]").count() == 1, f"labeled nav missing on {path}"
|
||||||
|
assert page.locator("main#main").count() == 1, f"main#main missing on {path}"
|
||||||
|
assert page.locator("footer.app-footer").count() == 1, f"footer missing on {path}"
|
||||||
|
|
||||||
|
# Skip link: present and its target actually receives focus.
|
||||||
|
# The link is off-screen until focused, so it is driven by the
|
||||||
|
# keyboard (Tab to it, Enter to follow) — the real user path.
|
||||||
|
skip = page.locator('a.skip-link[href="#main"]')
|
||||||
|
assert skip.count() == 1, f"skip link missing on {path}"
|
||||||
|
page.keyboard.press("Tab")
|
||||||
|
assert page.evaluate("() => document.activeElement.className") == "skip-link", (
|
||||||
|
f"first Tab must land on the skip link ({path})"
|
||||||
|
)
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
assert page.evaluate("() => document.activeElement.id") == "main", (
|
||||||
|
f"skip link must move focus to #main ({path})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every button has an accessible name; every img has alt text.
|
||||||
|
unnamed = page.evaluate(
|
||||||
|
"""() => [...document.querySelectorAll("button")]
|
||||||
|
.filter((b) => !(b.getAttribute("aria-label") || b.textContent.trim()))
|
||||||
|
.length"""
|
||||||
|
)
|
||||||
|
assert unnamed == 0, f"{unnamed} button(s) without an accessible name on {path}"
|
||||||
|
bad_imgs = page.evaluate(
|
||||||
|
"() => [...document.querySelectorAll('img')].filter((i) => !i.alt).length"
|
||||||
|
)
|
||||||
|
assert bad_imgs == 0, f"{bad_imgs} <img> without alt on {path}"
|
||||||
|
|
||||||
|
# A real keyboard Tab walk: every focused element shows a visible
|
||||||
|
# :focus-visible outline (solid, >= 2px). Start from the top of the
|
||||||
|
# document (the skip-link check left focus on #main).
|
||||||
|
page.evaluate(
|
||||||
|
"() => { if (document.activeElement instanceof HTMLElement)"
|
||||||
|
" document.activeElement.blur(); }"
|
||||||
|
)
|
||||||
|
seen = _tab_outline_walk(page)
|
||||||
|
assert len(seen) >= 3, f"expected several focusables on {path}, tabbed {len(seen)}"
|
||||||
|
for info in seen:
|
||||||
|
width_px = float(info["outline_width"].replace("px", ""))
|
||||||
|
assert info["outline_style"] == "solid" and width_px >= 2, (
|
||||||
|
f"no visible focus outline on {info['key']} "
|
||||||
|
f"({info['outline_style']} {info['outline_width']}) on {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The composer input (chat page) is programmatically labeled.
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
labeled = page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const input = document.querySelector("#message-input");
|
||||||
|
return !!input
|
||||||
|
&& (!!document.querySelector('label[for="message-input"]')
|
||||||
|
|| !!input.getAttribute("aria-label"));
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
assert labeled, "#message-input has no associated label"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contrast_pairs_pass_aa(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC4: every PLAN §7.2 color pair computed from the live computed
|
||||||
|
styles meets WCAG 2.1 AA (>= 4.5:1)."""
|
||||||
|
# Chat page: ink/surface, white/brand, chip-ink/chip-bg, deflection pair.
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
|
||||||
|
pairs = page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
|
||||||
|
const root = getComputedStyle(document.documentElement);
|
||||||
|
return {
|
||||||
|
ink_on_surface: [
|
||||||
|
cs(".empty-state-title", "color"),
|
||||||
|
cs(".empty-state", "backgroundColor"),
|
||||||
|
],
|
||||||
|
white_on_brand: [
|
||||||
|
cs(".send-btn", "color"), cs(".send-btn", "backgroundColor"),
|
||||||
|
],
|
||||||
|
chip: [
|
||||||
|
cs(".suggestion-chip", "color"),
|
||||||
|
cs(".suggestion-chip", "backgroundColor"),
|
||||||
|
],
|
||||||
|
deflection: [
|
||||||
|
root.getPropertyValue("--accent-ink").trim(),
|
||||||
|
root.getPropertyValue("--accent-bg").trim(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
_assert_aa(pairs["ink_on_surface"], "ink on surface (chat)")
|
||||||
|
_assert_aa(pairs["white_on_brand"], "white on brand (send button)")
|
||||||
|
_assert_aa(pairs["chip"], "chip ink on chip bg")
|
||||||
|
_assert_aa(pairs["deflection"], "deflection ink on deflection bg")
|
||||||
|
|
||||||
|
# Sources page: ink-soft/surface, white/brand (active nav).
|
||||||
|
page.goto(f"{app_url}/sources.html")
|
||||||
|
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
|
||||||
|
pairs = page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
|
||||||
|
const root = getComputedStyle(document.documentElement);
|
||||||
|
return {
|
||||||
|
ink_soft_on_surface: [
|
||||||
|
cs(".stat-label", "color"), cs(".stat-card", "backgroundColor"),
|
||||||
|
],
|
||||||
|
white_on_brand: [
|
||||||
|
cs(".nav-link.is-active", "color"),
|
||||||
|
cs(".nav-link.is-active", "backgroundColor"),
|
||||||
|
],
|
||||||
|
deflection: [
|
||||||
|
root.getPropertyValue("--accent-ink").trim(),
|
||||||
|
root.getPropertyValue("--accent-bg").trim(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
_assert_aa(pairs["ink_soft_on_surface"], "ink-soft on surface (stat labels)")
|
||||||
|
_assert_aa(pairs["white_on_brand"], "white on brand (active nav)")
|
||||||
|
_assert_aa(pairs["deflection"], "deflection ink on deflection bg (sources)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reduced_motion_respected(
|
||||||
|
browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC6: with prefers-reduced-motion the typing dots do not animate (and
|
||||||
|
the spinner is stopped or slowed to >=2s); the turn still completes.
|
||||||
|
A control pass on the default context proves the dots really animate
|
||||||
|
without the preference (so the check is discriminating)."""
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
|
||||||
|
# Control: default context — the dots run the `typing` animation.
|
||||||
|
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||||
|
try:
|
||||||
|
page.goto(app_url)
|
||||||
|
page.fill("#message-input", SLOW_QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
expect(page.locator(TYPING)).to_be_visible(timeout=1_000)
|
||||||
|
anim_name = page.evaluate(
|
||||||
|
"() => getComputedStyle"
|
||||||
|
"(document.querySelector('#typing-indicator .typing span')).animationName"
|
||||||
|
)
|
||||||
|
assert anim_name == "typing", f"control: dots should animate by default, got {anim_name!r}"
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
# Reduced motion: no running (fast) animation on dots or spinner.
|
||||||
|
context = browser.new_context(
|
||||||
|
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
|
||||||
|
)
|
||||||
|
rpage = context.new_page()
|
||||||
|
try:
|
||||||
|
rpage.goto(app_url)
|
||||||
|
rpage.fill("#message-input", SLOW_QUESTION)
|
||||||
|
rpage.click("#send-btn")
|
||||||
|
expect(rpage.locator(TYPING)).to_be_visible(timeout=1_000)
|
||||||
|
report = rpage.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const calm = (cs) =>
|
||||||
|
cs.animationName === "none" || parseFloat(cs.animationDuration) >= 2;
|
||||||
|
return {
|
||||||
|
dots: [...document.querySelectorAll("#typing-indicator .typing span")]
|
||||||
|
.map((s) => calm(getComputedStyle(s))),
|
||||||
|
spinner: calm(getComputedStyle(document.querySelector("#send-btn .spinner"))),
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
assert all(report["dots"]), (
|
||||||
|
"typing dots must not animate (or must be >=2s) under prefers-reduced-motion"
|
||||||
|
)
|
||||||
|
assert report["spinner"], (
|
||||||
|
"spinner must not animate (or must be >=2s) under prefers-reduced-motion"
|
||||||
|
)
|
||||||
|
# Feedback is calmed, never removed — the turn still completes.
|
||||||
|
expect(rpage.locator(ANSWER)).to_be_visible(timeout=30_000)
|
||||||
|
expect(rpage.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||||
|
expect(rpage.locator(TYPING)).to_be_hidden()
|
||||||
|
finally:
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_content_wraps_without_overflow(
|
||||||
|
browser: Browser, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""AC7: a 60+ char file path ellipsizes in the Sources table (full path
|
||||||
|
in the title attribute) and 60+ char unbroken tokens wrap in chat
|
||||||
|
bubbles — never breaking the bubble or the page."""
|
||||||
|
# A doc whose relative path is well over 60 chars.
|
||||||
|
src = tmp_path / "longkb"
|
||||||
|
(src / "deep").mkdir(parents=True)
|
||||||
|
long_name = "a" * 40 + "_backup_rotation_and_restore_procedures.md"
|
||||||
|
assert len(long_name) >= 60
|
||||||
|
(src / "deep" / long_name).write_text(
|
||||||
|
"# Long Path Test\n\n" + "Content line for retrieval. " * 60 + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
_reset_db(mock_llm, seed=True)
|
||||||
|
summary = _run_in_thread(_import_dirs([src], mock_llm))
|
||||||
|
assert summary is not None and summary.added == 1
|
||||||
|
|
||||||
|
# Sources @ 360px: the long path ellipsizes, full path stays in `title`.
|
||||||
|
phone = browser.new_page(viewport={"width": 360, "height": 740})
|
||||||
|
try:
|
||||||
|
phone.goto(f"{app_url}/sources.html")
|
||||||
|
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
|
||||||
|
row.wait_for(state="visible", timeout=10_000)
|
||||||
|
cell = row.get_by_role("cell").nth(1)
|
||||||
|
assert cell.get_attribute("title") == f"deep/{long_name}"
|
||||||
|
scroll, client = cell.evaluate(
|
||||||
|
"(el) => [el.scrollWidth, el.clientWidth]"
|
||||||
|
)
|
||||||
|
assert scroll > client, "the 60+ char path must be visually ellipsized"
|
||||||
|
_assert_no_doc_overflow(phone, "sources @ 360px (long path)")
|
||||||
|
finally:
|
||||||
|
phone.close()
|
||||||
|
|
||||||
|
# Chat @ 375px: unbroken 60-char tokens wrap inside both bubbles.
|
||||||
|
chat = browser.new_page(viewport={"width": 375, "height": 812})
|
||||||
|
try:
|
||||||
|
chat.goto(app_url)
|
||||||
|
chat.set_default_timeout(30_000)
|
||||||
|
chat.fill("#message-input", f"what do the notes say about {'x' * 60}")
|
||||||
|
chat.click("#send-btn")
|
||||||
|
answer = chat.locator(ANSWER)
|
||||||
|
answer.wait_for(state="visible", timeout=30_000)
|
||||||
|
expect(chat.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||||
|
|
||||||
|
for sel in (".msg.user .bubble", ".msg.brain .bubble"):
|
||||||
|
bubble = chat.locator(sel).first
|
||||||
|
scroll, client = bubble.evaluate("(el) => [el.scrollWidth, el.clientWidth]")
|
||||||
|
assert scroll <= client + 2, (
|
||||||
|
f"{sel}: long token did not wrap (scrollWidth {scroll} > clientWidth {client})"
|
||||||
|
)
|
||||||
|
_assert_no_doc_overflow(chat, "chat @ 375px (long tokens)")
|
||||||
|
finally:
|
||||||
|
chat.close()
|
||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -48,12 +50,17 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
|||||||
assert r.json() == {"suggestions": override}
|
assert r.json() == {"suggestions": override}
|
||||||
|
|
||||||
|
|
||||||
def test_index_html_served_locally(client) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""No-CDN check: the page is served by FastAPI and references only
|
("path", "marker"),
|
||||||
same-origin assets (no https:// script/link tags)."""
|
[("/", "Brain of Reese"), ("/sources.html", "Knowledge base")],
|
||||||
r = client.get("/")
|
)
|
||||||
|
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
||||||
|
"""No-CDN check (PLAN §7.3, re-verified on BOTH pages in phase 07):
|
||||||
|
each page is served by FastAPI and references only same-origin assets
|
||||||
|
(no https:// script/link tags)."""
|
||||||
|
r = client.get(path)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "Brain of Reese" in r.text
|
assert marker in r.text
|
||||||
assert 'src="https://' not in r.text
|
assert 'src="https://' not in r.text
|
||||||
assert 'href="https://' not in r.text
|
assert 'href="https://' not in r.text
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user