diff --git a/.gitignore b/.gitignore index 69734fa..a261c98 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ __pycache__/ .bot_token *.session* .local/ +# scratch/debug artifacts in the repo root (probes, logs) - never commit +/_* diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index ab0502b..d0ec271 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -121,3 +121,40 @@ def test_panel_typography_does_not_clip_cyrillic(): assert rule, selector line_height = re.search(r"line-height: ([\d.]+)", rule.group(0)) assert line_height and float(line_height.group(1)) >= 1.4, rule.group(0) + + +def test_sidebar_menu_never_clamps_under_its_footer(): + """Mobile kept Bootstrap's animated height, so the lower nav links slid under .sidebar-footer: + they had boxes in DevTools but no tap reached them. Desktop rail scrolls instead of overlapping.""" + html = (WEB_ROOT / "index.html").read_text(encoding="utf-8") + mobile = re.search(r"@media \(max-width: 991\.98px\) \{(.*?)\n\}", html, re.S) + assert mobile, "mobile media query missing" + assert "#sidebar-menu.show:not(.collapsing) { height: auto !important" in mobile.group(1) + assert "#sidebar-menu > #nav { flex: 0 0 auto; overflow: visible; }" in mobile.group(1) + assert ".sidebar-footer { flex: 0 0 auto;" in mobile.group(1) + desktop = re.search(r"@media \(min-width: 992px\) \{(.*?)\n\}", html, re.S) + assert desktop, "desktop media query missing" + assert "#sidebar-menu > #nav { flex: 1 1 auto; min-height: 0; overflow-y: auto" in desktop.group(1) + assert re.search(r"(?m)^\.sidebar-footer \{ flex: 0 0 auto;", html), "footer must not shrink" + + +def test_form_errors_are_labelled_for_their_situation(): + """A rejected source used to render «Не удалось загрузить данные» - a load failure title + for a plain validation mistake. failure() now takes a title, and the form passes its own.""" + html = (WEB_ROOT / "index.html").read_text(encoding="utf-8") + assert 'function failure(error,retry,title="Не удалось загрузить данные")' in html + assert '
200, "digest rendered nothing"
+
+ async def sources_and_telegram(self):
+ page = self.page
+ await self.goto_hash("#/sources", "#src-tbl")
+ # The table paints a placeholder row first; wait for the real registry.
+ await page.wait_for_function(
+ "document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000
+ )
+ rows_all = await page.locator("#src-tbl tbody tr").count()
+ needle = "telegram"
+ await page.locator("#s-q").fill(needle, timeout=T)
+ await page.wait_for_timeout(1800)
+ await self.settle()
+ rows_filtered = await page.locator("#src-tbl tbody tr").count()
+ visible = await page.evaluate(
+ """() => [...document.querySelectorAll('#src-tbl tbody tr')]
+ .slice(0, 25).map(tr => tr.textContent.toLowerCase())"""
+ )
+ off_topic = [row[:40] for row in visible if needle not in row]
+ self.out["scenarios"]["source_search"] = {
+ "rows_all": rows_all,
+ "needle": needle,
+ "rows_filtered": rows_filtered,
+ "off_topic_rows": len(off_topic),
+ }
+ assert rows_all > 100, f"source registry looks empty: {rows_all} rows"
+ assert 0 < rows_filtered < rows_all, f"source search broken: {rows_all} -> {rows_filtered}"
+ assert not off_topic, f"source search returned unrelated rows: {off_topic[:3]}"
+
+ await self.goto_hash("#/tg", "#tg-search-form")
+ status = await page.evaluate(
+ "async () => { const r = await fetch('/api/tg/status'); return {status: r.status, body: await r.json()}; }"
+ )
+ body = status["body"]
+ me = body.get("me") or {}
+ text = (await page.locator("#view").inner_text(timeout=T)).strip()
+ self.out["scenarios"]["telegram"] = {
+ "http": status["status"],
+ "enabled": body.get("enabled"),
+ "error": body.get("error"),
+ "account": me.get("first_name"),
+ "account_id": me.get("id"),
+ "session": body.get("session"),
+ "rendered_chars": len(text),
+ }
+ assert status["status"] == 200, f"tg status -> HTTP {status['status']}"
+ assert body.get("enabled") is True and not body.get("error"), f"tg session degraded: {body}"
+ assert me.get("first_name") and me.get("id"), f"tg account missing: {body}"
+ # the tab mounts a "Загрузка данных…" stub first; a real render is far longer
+ assert len(text) > 200, f"Telegram tab rendered a stub: {len(text)} chars -> {text[:80]!r}"
+
+ async def mobile(self):
+ page = self.page
+ await page.set_viewport_size({"width": 390, "height": 860})
+ # A query before the hash forces a real document load; hash-only goto is same-document.
+ await page.goto(self.url + "/?v=mobile#/feed", wait_until="domcontentloaded", timeout=30000)
+ await page.wait_for_selector("#list .item", timeout=25000)
+ await self.settle()
+ await asyncio.wait_for(page.locator("#sidebar-toggle").click(timeout=T), 25)
+ await page.wait_for_selector("#sidebar-menu.show:not(.collapsing)", timeout=10000)
+ await page.wait_for_timeout(400)
+ # Everything painted must be tappable. find_covered skips hidden nodes and closed
+ # groups, so a footer or overlay covering a real control fails the run.
+ covered = await find_covered(
+ page, "#sidebar-menu [data-nav], .sidebar-footer button, #sidebar-toggle", "nav"
+ )
+ geometry = await page.evaluate(
+ """() => { const nav = document.querySelector('#nav').getBoundingClientRect();
+ const foot = document.querySelector('.sidebar-footer').getBoundingClientRect();
+ return {navBottom: Math.round(nav.bottom), footerTop: Math.round(foot.top),
+ menuHeight: Math.round(document.querySelector('#sidebar-menu').getBoundingClientRect().height),
+ menuInlineHeight: document.querySelector('#sidebar-menu').style.height || ''}; }"""
+ )
+ # «Обзор» lives in the collapsible «Система» group: open it the way a user would.
+ await asyncio.wait_for(page.locator("#system-nav > summary").click(timeout=T), 25)
+ await page.wait_for_function("document.querySelector('#system-nav').open", timeout=10000)
+ covered_system = await find_covered(page, "#system-nav [data-nav]", "nav")
+ labels = [t.strip() for t in await page.locator("#sidebar-menu [data-nav]").all_inner_texts()]
+ # A real click hangs forever when something is painted over the target - that is the point.
+ await asyncio.wait_for(page.locator('#sidebar-menu [data-nav="overview"]').click(timeout=T), 30)
+ await page.wait_for_timeout(1200)
+ hash_after = await page.evaluate("location.hash")
+ closed = await page.evaluate("!document.querySelector('#sidebar-menu').classList.contains('show')")
+ await self.no_collision("mobile overview")
+ self.out["scenarios"]["mobile"] = {
+ "nav_labels": labels[:12],
+ "covered_controls": covered,
+ "covered_system_group": covered_system,
+ "sidebar_geometry": geometry,
+ "hash_after": hash_after,
+ "menu_closed_after_nav": closed,
+ }
+ assert not covered, f"mobile menu has untappable controls: {covered}"
+ assert not covered_system, f"«Система» group has untappable items: {covered_system}"
+ assert geometry["navBottom"] <= geometry["footerTop"] + 1, f"nav overlaps footer: {geometry}"
+ assert "overview" in hash_after, f"mobile navigation did not route: {hash_after}"
+ assert closed, "offcanvas stayed open after navigation"
+ assert labels and all(labels), "mobile navigation lost its labels"
+ await page.screenshot(path=str(self.shots / "mobile_overview.png"))
+ await page.set_viewport_size({"width": 1440, "height": 960})
+
+ async def add_source(self):
+ """Reject an invalid source through the real form: no mutation, friendly message."""
+ page = self.page
+ await page.set_viewport_size({"width": 1440, "height": 960})
+ await self.goto_hash("#/sources", "#src-tbl")
+ await page.wait_for_function(
+ "document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000
+ )
+ rows_before = await page.locator("#src-tbl tbody tr").count()
+ await asyncio.wait_for(page.locator('[data-act="add-src"]').click(timeout=T), 15)
+ await page.wait_for_selector("#modal.show #add-source-form", timeout=10000)
+ out = {"rows_before": rows_before}
+ # The submit button sits in the modal footer and is wired to the form by id,
+ # so "#add-source-form button[type=submit]" matches nothing.
+ submit = page.locator('#modal-box button[type="submit"][form="add-source-form"]')
+ out["covered_modal_controls"] = await find_covered(
+ page, "#modal.show button, #modal.show input, #modal.show select", None
+ )
+ # 1) empty required fields: native validation must block the write
+ await asyncio.wait_for(submit.click(timeout=T), 15)
+ await page.wait_for_timeout(600)
+ out["url_invalid_when_empty"] = await page.evaluate(
+ "!document.querySelector('#a-url').checkValidity()"
+ )
+ out["error_after_empty_submit"] = (
+ await page.locator("#add-source-error").inner_text()
+ ).strip()
+ # 2) a non-http scheme passes type=url but must be refused by the app
+ await page.fill("#a-name", "scenario-probe-do-not-add")
+ await page.fill("#a-url", "ftp://example.invalid/feed")
+ await asyncio.wait_for(submit.click(timeout=T), 15)
+ await page.wait_for_function(
+ "document.querySelector('#add-source-error').textContent.trim().length > 0", timeout=10000
+ )
+ out["error_text"] = (await page.locator("#add-source-error").inner_text()).strip()[:160]
+ out["modal_still_open"] = await page.evaluate(
+ "document.querySelector('#modal').classList.contains('show')"
+ )
+ await page.keyboard.press("Escape")
+ await page.wait_for_function(
+ "!document.querySelector('#modal').classList.contains('show')", timeout=10000
+ )
+ await self.goto_hash("#/sources", "#src-tbl")
+ await page.wait_for_function(
+ "document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000
+ )
+ out["rows_after"] = await page.locator("#src-tbl tbody tr").count()
+ self.out["scenarios"]["add_source"] = out
+ assert out["url_invalid_when_empty"], "empty URL passed native validation"
+ assert not out["error_after_empty_submit"], "native validation leaked an app error"
+ assert "http" in out["error_text"].lower(), f"no friendly validation message: {out['error_text']!r}"
+ assert "не удалось загрузить" not in out["error_text"].lower(), (
+ f"validation error disguised as a load failure: {out['error_text']!r}"
+ )
+ assert out["modal_still_open"], "modal closed despite the rejected source"
+ assert not out["covered_modal_controls"], f"modal controls untappable: {out['covered_modal_controls']}"
+ assert out["rows_after"] == rows_before, (
+ f"rejected source leaked into the registry: {rows_before} -> {out['rows_after']}"
+ )
+
+ async def keyboard(self):
+ """Keyboard-only user: / search, Esc, ? help, digit navigation."""
+ page = self.page
+ await page.set_viewport_size({"width": 1440, "height": 960})
+ await self.goto_hash("#/feed", "#list .item")
+ out = {"rows_before": await page.locator("#list .item").count()}
+ await page.keyboard.press("/")
+ out["slash_focus"] = await page.evaluate("document.activeElement?.id || ''")
+ needle = "telethon" # rare enough that a filtered page is smaller than the default one
+ await page.keyboard.type(needle, delay=40)
+ await page.wait_for_timeout(900)
+ out["suggestions"] = await page.evaluate("document.querySelectorAll('#sug button').length")
+ # Typing only fills the box: the list filters when the feed form is submitted.
+ await page.keyboard.press("Enter")
+ await page.wait_for_selector("#list .item", timeout=25000)
+ await page.wait_for_timeout(1200)
+ out["query"] = await page.input_value("#q")
+ out["hash_after_query"] = await page.evaluate("location.hash")
+ out["rows_after_query"] = await page.locator("#list .item").count()
+ # A match can live in the body text, so compare the rendered ids with the API answer
+ # instead of looking for the needle in visible text.
+ out["ui_ids"] = await page.evaluate(
+ "[...document.querySelectorAll('#list .item .reader-open')].map(b => Number(b.dataset.id))"
+ )
+ out["api_ids"] = await page.evaluate(
+ """async q => {
+ const r = await fetch('/api/items?q=' + encodeURIComponent(q) + '&limit=40&sort=fresh');
+ const d = await r.json();
+ return (d.items || []).map(i => Number(i.id));
+ }""",
+ needle,
+ )
+ await page.keyboard.press("Escape")
+ await page.wait_for_timeout(600)
+ out["suggestions_hidden"] = await page.evaluate(
+ "const s=document.querySelector('#sug'); !s || s.classList.contains('d-none')"
+ )
+ await page.evaluate("document.activeElement && document.activeElement.blur()")
+ await page.keyboard.press("?")
+ await page.wait_for_selector("#modal.show", timeout=10000)
+ # Bootstrap drops Escape during the fade-in transition
+ await page.wait_for_timeout(450)
+ out["help_title"] = (await page.locator("#modal-title").inner_text()).strip()
+ await page.keyboard.press("Escape")
+ await page.wait_for_function(
+ "!document.querySelector('#modal').classList.contains('show')", timeout=10000
+ )
+ await page.keyboard.press("3")
+ await page.wait_for_timeout(1000)
+ out["hash_after_3"] = await page.evaluate("location.hash")
+ await page.keyboard.press("2")
+ await page.wait_for_timeout(1000)
+ out["hash_after_2"] = await page.evaluate("location.hash")
+ await self.no_collision("keyboard flow")
+ self.out["scenarios"]["keyboard"] = out
+ assert out["slash_focus"] == "q", f"/ did not focus search: {out['slash_focus']!r}"
+ assert out["query"] == needle, f"search box lost the query: {out}"
+ assert "q=" + needle in out["hash_after_query"], f"query did not reach the route: {out}"
+ assert 0 < out["rows_after_query"] < out["rows_before"], f"search did not filter: {out}"
+ assert out["ui_ids"] and set(out["ui_ids"]) == set(out["api_ids"]), (
+ f"rendered rows differ from the API answer: {out['ui_ids'][:6]} vs {out['api_ids'][:6]}"
+ )
+ assert out["suggestions_hidden"], "suggestions stayed open after Escape"
+ assert out["help_title"], "help modal opened without a title"
+ assert "abuse" in out["hash_after_3"], f"digit 3 did not switch section: {out['hash_after_3']}"
+ assert "feed" in out["hash_after_2"], f"digit 2 did not return to feed: {out['hash_after_2']}"
+
+ async def adaptivity(self):
+ """Narrow and wide screens: no horizontal scrolling, no covered controls."""
+ page = self.page
+ routes = ("feed", "sources", "abuse", "overview")
+ results = {}
+ for width in (320, 390, 768, 1024, 1280, 1920):
+ await page.set_viewport_size({"width": width, "height": 900})
+ # query before the hash forces a real document load for this width
+ await page.goto(
+ f"{self.url}/?v=w{width}#/feed", wait_until="domcontentloaded", timeout=30000
+ )
+ await page.wait_for_selector("#list .item", timeout=25000)
+ per_route = {}
+ for route in routes:
+ await self.goto_hash(f"#/{route}")
+ await page.wait_for_timeout(900)
+ per_route[route] = await page.evaluate(
+ """() => {
+ const contained = el => {
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
+ const ox = getComputedStyle(p).overflowX;
+ if ((ox === 'auto' || ox === 'scroll' || ox === 'hidden')
+ && p.getBoundingClientRect().right <= window.innerWidth + 1) return true;
+ }
+ return false;
+ };
+ return {scrollW: document.documentElement.scrollWidth,
+ inner: window.innerWidth,
+ spilled: [...document.querySelectorAll('#view *')]
+ .filter(e => e.getBoundingClientRect().right > window.innerWidth + 1
+ && !contained(e))
+ .slice(0, 3)
+ .map(e => (e.id ? '#' + e.id : '') + '.'
+ + (e.className.toString().slice(0, 40)))};
+ }"""
+ )
+ results[width] = per_route
+ if width in (320, 768):
+ await self.goto_hash("#/feed", "#list .item")
+ results[f"covered@{width}"] = await find_covered(
+ page, "#page-actions .btn, #view .nav-tabs .nav-link, #q, #sidebar-toggle", None
+ )
+ self.out["scenarios"]["adaptivity"] = results
+ overflow = [
+ {"width": width, "route": route, **m}
+ for width, per_route in results.items()
+ if isinstance(per_route, dict)
+ for route, m in per_route.items()
+ if m["scrollW"] > m["inner"] + 1 or m["spilled"]
+ ]
+ covered = {k: v for k, v in results.items() if str(k).startswith("covered@") and v}
+ assert not overflow, f"horizontal overflow: {overflow[:4]}"
+ assert not covered, f"untappable controls on narrow screens: {covered}"
+ await page.set_viewport_size({"width": 1440, "height": 960})
+
+ async def zoom(self):
+ page = self.page
+ await page.set_viewport_size({"width": 1440, "height": 960})
+ for zoom in ("1", "1.25", "1.5"):
+ await page.evaluate(f"document.documentElement.style.zoom='{zoom}'")
+ for route in ("feed", "sources", "abuse", "overview"):
+ await self.goto_hash(f"#/{route}")
+ await page.wait_for_timeout(700)
+ await self.no_collision(f"{route} @ zoom {zoom}")
+ await page.evaluate("document.documentElement.style.zoom=''")
+
+ async def screenshots(self):
+ page = self.page
+ await page.set_viewport_size({"width": 1440, "height": 960})
+ await self.goto_hash("#/feed", "#list .item")
+ await page.screenshot(path=str(self.shots / "scenarios_feed.png"))
+ await self.goto_hash("#/sources", "#src-tbl")
+ await page.screenshot(path=str(self.shots / "scenarios_sources.png"))
+ await self.goto_hash("#/overview")
+ await page.wait_for_timeout(1200)
+ await page.screenshot(path=str(self.shots / "scenarios_overview.png"))
+ await page.set_viewport_size({"width": 390, "height": 860})
+ await self.goto_hash("#/feed", "#list .item")
+ await page.screenshot(path=str(self.shots / "scenarios_mobile.png"))
+ await page.set_viewport_size({"width": 1440, "height": 960})
+
+
+SCENARIOS = [
+ ("deep_link", 90),
+ ("exports", 90),
+ ("reader", 90),
+ ("digest", 90),
+ ("sources_and_telegram", 90),
+ ("add_source", 90),
+ ("keyboard", 120),
+ ("mobile", 90),
+ ("adaptivity", 240),
+ ("zoom", 150),
+ ("screenshots", 90),
+]
+
+
+async def run(url: str, shots: pathlib.Path) -> dict:
+ shots.mkdir(parents=True, exist_ok=True)
+ async with async_playwright() as pw:
+ browser = await pw.chromium.launch()
+ runner: Runner | None = None
+ try:
+ page = await browser.new_page(viewport={"width": 1440, "height": 960})
+ page.set_default_timeout(T)
+ page.set_default_navigation_timeout(30000)
+ runner = Runner(page, url, shots)
+ page.on("pageerror", lambda e: runner.errors.append(f"pageerror: {e}"))
+ page.on(
+ "console",
+ lambda m: runner.errors.append(f"console: {m.text}") if m.type == "error" else None,
+ )
+ for name, budget in SCENARIOS:
+ runner.out["stage"] = name
+ print("[scenario]", name, flush=True)
+ await asyncio.wait_for(getattr(runner, name)(), budget)
+ runner.out["stage"] = "done"
+ runner.out["page_errors"] = runner.errors[:8]
+ assert not runner.errors, f"browser errors during scenarios: {runner.errors[:4]}"
+ except BaseException as exc: # keep the report even when a scenario blows up
+ if runner is not None:
+ runner.out["error"] = f"{type(exc).__name__}: {exc}"[:400]
+ runner.out["page_errors"] = runner.errors[:8]
+ raise
+ finally:
+ if runner is not None:
+ (shots / "report.json").write_text(
+ json.dumps(runner.out, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ print(json.dumps(runner.out, ensure_ascii=False, indent=2))
+ try:
+ await asyncio.wait_for(browser.close(), timeout=20)
+ except Exception:
+ pass
+ return runner.out if runner else {}
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--url", default=BASE)
+ parser.add_argument("--shots", default="data/shots/scenarios")
+ args = parser.parse_args()
+ asyncio.run(run(args.url, pathlib.Path(args.shots)))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/reachability.py b/tools/reachability.py
new file mode 100644
index 0000000..b2ec6b0
--- /dev/null
+++ b/tools/reachability.py
@@ -0,0 +1,56 @@
+"""Pointer-reachability check for the OMNI panel.
+
+A control whose centre is covered by another element cannot be clicked by a human, and
+Playwright stalls on it instead of failing fast. Regression guard for the mobile sidebar
+bug where the collapsed menu kept a fixed height: nav links slid under `.sidebar-footer`,
+so the last items ("Обзор", "Запуски", "Ключи") were visible in DevTools but untappable.
+
+Used by tools/ui_smoke.py and tools/panel_scenarios.py.
+"""
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from playwright.async_api import Page
+
+JS = """(args) => {
+ const describe = (el) => {
+ if (!el) return 'none';
+ const chain = [];
+ let cur = el;
+ for (let i = 0; i < 3 && cur; i++) {
+ const cls = typeof cur.className === 'string' && cur.className
+ ? '.' + cur.className.trim().split(/\\s+/).slice(0, 2).join('.') : '';
+ chain.push(cur.tagName.toLowerCase() + (cur.id ? '#' + cur.id : '') + cls);
+ cur = cur.parentElement;
+ }
+ return chain.join(' < ');
+ };
+ const covered = [];
+ const els = [...document.querySelectorAll(args.selector)];
+ els.forEach((el, i) => {
+ const key = (args.keyAttr && el.dataset[args.keyAttr]) || el.id
+ || (el.textContent || '').trim().slice(0, 28) || (args.selector + '[' + i + ']');
+ const details = el.closest('details');
+ if (details && !details.open) return;
+ const style = getComputedStyle(el);
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return;
+ if (el.closest('#sug:not(.show), #drawer:not(.show), #modal:not(.show)')) return;
+ el.scrollIntoView({block: 'center'});
+ const r = el.getBoundingClientRect();
+ if (r.width < 2 || r.height < 2) return;
+ const x = r.left + r.width / 2, y = r.top + r.height / 2;
+ if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return;
+ const top = document.elementFromPoint(x, y);
+ if (top && (top === el || el.contains(top))) return;
+ covered.push({key, covered_by: describe(top), at: [Math.round(x), Math.round(y)],
+ box: {x: Math.round(r.left), y: Math.round(r.top),
+ w: Math.round(r.width), h: Math.round(r.height)}});
+ });
+ return covered.slice(0, 12);
+}"""
+
+
+async def find_covered(page: Page, selector: str, key_attr: Optional[str] = None) -> list[dict[str, Any]]:
+ """Return controls matched by `selector` whose centre is painted over by another element."""
+ return await page.evaluate(JS, {"selector": selector, "keyAttr": key_attr})
diff --git a/tools/ui_smoke.py b/tools/ui_smoke.py
index 5b8d152..c4ea0c1 100644
--- a/tools/ui_smoke.py
+++ b/tools/ui_smoke.py
@@ -12,6 +12,9 @@ from urllib.parse import parse_qs, quote, urljoin, urlsplit
from playwright.async_api import async_playwright
+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
+from reachability import find_covered # noqa: E402
+
async def run(url, out):
out.mkdir(parents=True, exist_ok=True)
@@ -96,6 +99,12 @@ async def run(url, out):
assert not hits, f"Text painted on text at {label}: {hits}"
report.setdefault("no_text_collision", []).append(label)
+ async def assert_reachable(label, selector, key_attr=None):
+ """Fail when a visible control is painted over: a human could not click it."""
+ covered = await find_covered(page, selector, key_attr)
+ assert not covered, f"Unreachable controls at {label}: {covered}"
+ report.setdefault("reachable", []).append(label)
+
async def assert_dark_theme(label):
colors = await page.evaluate("""() => {
const canvas = document.createElement('canvas');
@@ -130,6 +139,11 @@ async def run(url, out):
assert report["navigation_routes"] == 9
await assert_no_overflow("desktop")
await assert_no_text_collision("feed 1440")
+ await assert_reachable("desktop rail 1440", "#sidebar-menu [data-nav]", "nav")
+ await assert_reachable(
+ "desktop controls 1440",
+ "#page-actions .btn, #view .nav-tabs .nav-link, #export-csv, #export-json, #q",
+ )
await assert_dark_theme("feed")
step("feed")
await page.screenshot(path=str(out / "black_feed.png"))
@@ -268,6 +282,11 @@ async def run(url, out):
await page.locator("#sidebar-toggle").click()
await page.locator("#sidebar-menu").wait_for(state="visible")
assert await page.locator('[data-nav="sources"]').is_visible()
+ await assert_reachable(
+ "mobile menu 390",
+ "#sidebar-menu [data-nav], .sidebar-footer button, #sidebar-toggle",
+ "nav",
+ )
await assert_no_overflow("mobile navigation 390")
await assert_dark_theme("mobile navigation")
await page.screenshot(path=str(out / "black_mobile_navigation.png"))
diff --git a/web/index.html b/web/index.html
index 4f7f673..abd57c9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -170,7 +170,7 @@ a:hover { color: var(--omni-foreground); }
.navbar-vertical .nav-link-icon .icon { width: var(--omni-space-4); height: var(--omni-space-4); }
.navbar details > summary { cursor: pointer; }
.navbar details[open] > summary { margin-bottom: var(--omni-space-2); }
-.sidebar-footer { padding: var(--omni-space-4) var(--omni-space-3); }
+.sidebar-footer { flex: 0 0 auto; padding: var(--omni-space-4) var(--omni-space-3); }
#health { line-height: 1.6; }
.sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; }
#sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: auto; min-height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; }
@@ -277,15 +277,19 @@ pre { padding: var(--omni-space-4); }
@media (min-width: 992px) {
.navbar.navbar-vertical > .container-fluid { height: 100%; padding: var(--omni-space-6) var(--omni-space-2) 0; }
.navbar.navbar-vertical .navbar-brand { padding: 0 var(--omni-space-3) var(--omni-space-6); }
- #sidebar-menu { flex: 1; align-items: stretch; }
+ #sidebar-menu { display: flex; flex-direction: column; flex: 1; align-items: stretch; min-height: 0; }
+ #sidebar-menu > #nav { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
}
@media (max-width: 991.98px) {
.navbar.navbar-vertical { border-inline-end: 0; border-bottom: 1px solid var(--omni-border); }
.navbar.navbar-vertical > .container-fluid { padding: var(--omni-space-3) var(--omni-space-4); justify-content: space-between; flex-wrap: wrap; }
.navbar.navbar-vertical .navbar-brand { flex: 1; padding: 0; }
- #sidebar-menu { flex-basis: 100%; }
+ #sidebar-menu { flex-basis: 100%; flex-direction: column; align-items: stretch; }
+ /* Без фикс-высоты после раскрытия: иначе список уезжает под футер и пункты не нажимаются. */
+ #sidebar-menu.show:not(.collapsing) { height: auto !important; max-height: none !important; overflow: visible !important; }
+ #sidebar-menu > #nav { flex: 0 0 auto; overflow: visible; }
.navbar-vertical .navbar-nav { padding-inline: 0; }
- .sidebar-footer { margin-top: var(--omni-space-4) !important; padding-inline: 0; }
+ .sidebar-footer { flex: 0 0 auto; margin-top: var(--omni-space-4) !important; padding-inline: 0; }
.page-wrapper .container-xl { padding-inline: var(--omni-space-6); }
}
@media (max-width: 575.98px) {
@@ -407,7 +411,7 @@ async function api(path,opt={}){
const post=(path,body)=>api(path,{method:"POST",body:JSON.stringify(body||{})});
async function copy(value){if(!value){toast("Нет данных для копирования",true);return}try{if(!navigator.clipboard)throw new Error("clipboard");await navigator.clipboard.writeText(value);toast("Скопировано")}catch{toast("Не удалось скопировать. Разрешите доступ к буферу обмена.",true)}}
const retries=new Map();let retrySerial=0;
-function failure(error,retry){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return `Не удалось загрузить данные
${text(error.message||error)}${retry?``:""}`}
+function failure(error,retry,title="Не удалось загрузить данные"){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return `${esc(title)}
${text(error.message||error)}${retry?``:""}`}
function loading(label="Загрузка данных…",rows=3){return `${esc(label)}`}
function empty(title,description,action=""){return `${esc(title)}
${esc(description)}
${action?`${action}`:""}`}
const BUSY=new Set();
@@ -458,6 +462,7 @@ function paintNav(){
const systemOpen=$("#system-nav")?.open||["overview","runs","keys"].includes(S.view);
const link=id=>{const n=NAV.find(v=>v.id===id);return ` `};
$("#nav").innerHTML=`Рабочая областьИнструменты`;
+ const rail=$("#nav");if(rail.scrollHeight>rail.clientHeight)rail.querySelector(".nav-link.active")?.scrollIntoView({block:"nearest"});
}
function pageHeader(){const n=NAV.find(n=>n.id===S.view);document.title="OMNI — "+n.label;$("#crumb").textContent=n.label;$("#page-group").textContent=n.group;$("#page-description").textContent=n.description;
const primary={feed:'',sources:'',abuse:S.tab==="signals"?'':"",digest:'',overview:'Открыть ленту'};
@@ -548,7 +553,7 @@ async function startJob(path,body,key,el,label,after){return busy(el,key,async()
function showModal(title,body,footer){modalReturnFocus=document.activeElement;$("#modal-box").innerHTML=`${esc(title)}
${body}`;tabler.Modal.getOrCreateInstance($("#modal")).show()}
$("#modal").addEventListener("hidden.bs.modal",()=>{if(modalReturnFocus?.isConnected)modalReturnFocus.focus({preventScroll:true});modalReturnFocus=null});
function addSource(){showModal("Новый источник",``,'')}
-async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"));return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}}
+async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"),null,"Проверьте адрес источника");return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}}
async function bulkToggle(on,el){const names=selectedSrc();if(!names.length){toast("Выберите источники",true);return}await busy(el,"source-write",async()=>{const ctx=activeContext;for(const name of names)await post("/api/sources/toggle",{name,enabled:on});toast(`${on?"Включено":"Выключено"}: ${names.length}`);if(ctx.valid())await loadSources(ctx)})}
async function toggleSource(name,on,el,detail=false){await busy(el,"source-write",async()=>{const ctx=activeContext,reader=readerVersion;try{await post("/api/sources/toggle",{name,enabled:on});toast(on?"Источник включён":"Источник выключен");if(ctx.valid()&&S.view==="sources")await loadSources(ctx);if(detail&&reader===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(name)}catch(error){if(el?.type==="checkbox")el.checked=!on;throw error}})}
async function addWatch(q,type="all",el){if(q.trim().length<2){toast("Введите не менее двух символов",true);return}await busy(el,"watch-write",async()=>{const ctx=activeContext;await post("/api/watch/add",{q:q.trim(),type});toast("Правило отслеживания добавлено");if(ctx.valid()&&S.view==="watch")await render()})}