diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a997bfc..99859b1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -37,7 +37,8 @@ jobs: - name: Build site # Zola fails the build on broken internal links. - run: zola build + # This artifact is a test preview; production has its own deploy build. + run: zola build --base-url http://127.0.0.1:8879 - name: Verify editor, demo outputs and publication downloads run: python3 -m unittest discover -s scripts -p 'test_*.py' -v @@ -59,6 +60,53 @@ jobs: path: public/ retention-days: 7 + navigation: + name: Navigation (${{ matrix.browser }}) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: scripts/requirements-browser.txt + + - name: Download test preview + uses: actions/download-artifact@v4 + with: + name: site-public + path: public + + - name: Install browser test tools + run: | + python -m pip install -r scripts/requirements-browser.txt + python -m playwright install --with-deps ${{ matrix.browser }} + + - name: Verify responsive navigation and keyboard interactions + run: | + python -m http.server 8879 --bind 127.0.0.1 --directory public > /tmp/navigation-server.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid"' EXIT + curl --fail --silent --output /dev/null --retry 10 --retry-connrefused --retry-delay 1 --retry-max-time 20 http://127.0.0.1:8879/ + python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser ${{ matrix.browser }} --artifacts navigation-results + + - name: Upload navigation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: navigation-${{ matrix.browser }} + path: navigation-results/ + retention-days: 7 + release-links: name: Check release channel links runs-on: ubuntu-latest diff --git a/config.toml b/config.toml index d8014fe..155c656 100644 --- a/config.toml +++ b/config.toml @@ -56,7 +56,7 @@ release_version = "1.21.19" navbar_items = [ { code = "en", nav_items = [ { url = "$BASE_URL/", name = "Home" }, - { url = "$BASE_URL/editor/", name = "Try the editor" }, + { url = "$BASE_URL/editor/", name = "Editor" }, { url = "$BASE_URL/capabilities", name = "Capabilities" }, { url = "$BASE_URL/how-tos", name = "How-tos" }, { url = "https://docs.terraphim.ai", name = "Docs" }, diff --git a/scripts/README-navigation.md b/scripts/README-navigation.md new file mode 100644 index 0000000..799b00d --- /dev/null +++ b/scripts/README-navigation.md @@ -0,0 +1,46 @@ +# Responsive navigation checks + +These checks exercise the generated site in Chromium, Firefox and WebKit on the +homepage and synonym-search tutorial at 15 widths from 320 to 2,560 CSS pixels, +with web fonts loaded and with font downloads blocked. + +Use **Zola 0.22.1**, matching CI. Newer Zola versions may require a separate +template migration. + +```sh +python3 -m venv /tmp/terraphim-navigation-venv +/tmp/terraphim-navigation-venv/bin/python -m pip install -r scripts/requirements-browser.txt +/tmp/terraphim-navigation-venv/bin/python -m playwright install chromium firefox webkit +zola build --base-url http://127.0.0.1:8879 +python3 -m http.server 8879 --bind 127.0.0.1 --directory public +``` + +In another terminal, run each engine: + +```sh +/tmp/terraphim-navigation-venv/bin/python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser chromium --artifacts /tmp/navigation-chromium +/tmp/terraphim-navigation-venv/bin/python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser firefox --artifacts /tmp/navigation-firefox +/tmp/terraphim-navigation-venv/bin/python scripts/check-navigation.py --base-url http://127.0.0.1:8879 --browser webkit --artifacts /tmp/navigation-webkit +``` + +The build base URL must match the server. Otherwise absolute asset URLs could +load production CSS instead of the patch. The checker rejects a stylesheet from +another origin and records the served stylesheet SHA-256 in `navigation.json`. +It also checks exact navigation destinations, including their origins. + +Checks cover single-line desktop text, visible non-overlapping controls, +compact/desktop selection, preserved links and actual keyboard actions: search +input focus, Escape to close search, opening and scrolling the compact menu to +Donate, and navigating to Editor. The open compact menu is resized to desktop +and back. WebKit uses Option+Tab to include links in keyboard traversal on macOS. + +Runs exit nonzero on failure and save JSON plus representative screenshots. +The normal-font run requires Space Grotesk to load; a font CDN failure is not +silently relabelled as success. A separate fallback run blocks font downloads. +CI runs all three engines and uploads evidence even when a check fails. +Its `site-public` artifact is a localhost test build; production deployment +builds separately using the production base URL. + +To verify a deployment, use `--base-url https://terraphim.ai`. Manual acceptance +also includes actual 200% browser zoom: viewport/device-scale overrides are not +browser zoom, and Playwright WebKit is not native Safari. diff --git a/scripts/check-navigation.py b/scripts/check-navigation.py new file mode 100644 index 0000000..155ad40 --- /dev/null +++ b/scripts/check-navigation.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Check real rendered navigation against a built site, preview or production.""" + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import sys +from urllib.parse import urljoin, urlparse + +from playwright.sync_api import sync_playwright + + +WIDTHS = (320, 390, 768, 899, 900, 1024, 1200, 1279, 1280, 1366, 1440, + 1799, 1800, 1920, 2560) +PAGES = ("/", "/how-tos/synonym-search/") +DESTINATIONS = ( + ("Home", "/"), ("Editor", "/editor/"), + ("Capabilities", "/capabilities"), ("How-tos", "/how-tos"), + ("Docs", "https://docs.terraphim.ai/"), ("Rust", "https://terraphim.rs/"), + ("Releases", "/releases"), ("Install", "/docs/installation"), + ("Community", "/#community"), ("Donate", "/docs/donate"), +) + +GEOMETRY = """() => { + const rect = el => { + const r = el.getBoundingClientRect(); + return {x:r.x, y:r.y, right:r.right, bottom:r.bottom, w:r.width, h:r.height}; + }; + const shown = el => !!el && getComputedStyle(el).display !== 'none' + && el.getBoundingClientRect().width > 0; + const desktop = shown(document.querySelector('.topbar')); + const mobile = shown(document.querySelector('.mobile-nav')); + const selector = desktop + ? '.topbar-brand, .topbar-link, .topbar-status, .topbar-search' + : '.mobile-nav-brand, .nav-search-btn, .nav-burger'; + const controls = [...document.querySelectorAll(selector)].map(el => { + const range = document.createRange(); range.selectNodeContents(el); + const textRects = el.matches('.topbar-link') ? [...range.getClientRects()] + .filter(r => r.width > 0).map(r => ({x:r.x,y:r.y,right:r.right,bottom:r.bottom})) : []; + const r = rect(el); + const hit = document.elementFromPoint((r.x+r.right)/2, (r.y+r.bottom)/2); + return {text:el.textContent.trim(), rect:r, textRects, + lines: new Set(textRects.map(r => Math.round(r.y))).size, + hit:!!hit && (hit === el || el.contains(hit))}; + }); + const links = selector => [...document.querySelectorAll(selector)] + .map(el => ({text:el.textContent.trim(), href:el.href})); + return {width:innerWidth, clientWidth:document.documentElement.clientWidth, + desktop, mobile, controls, desktopLinks:links('.topbar-link'), + mobileLinks:links('.nav-mobile-menu a'), + version:document.querySelector('.status-label').textContent.trim(), + stylesheet:[...document.styleSheets].map(s => s.href).find(h => h && h.includes('/css/warp.css')), + fonts:[...document.fonts].map(f => ({family:f.family,status:f.status}))}; +}""" + + +def geometry_errors(data, base_url): + """Assert observable layout rather than CSS declarations.""" + errors = [] + if data["desktop"] != (data["width"] >= 1280) or data["mobile"] == data["desktop"]: + errors.append("Wrong header mode or multiple visible headers") + previous = None + for control in data["controls"]: + r = control["rect"] + name = control["text"] or "icon control" + if r["x"] < -0.6 or r["right"] > data["clientWidth"] + 0.6: + errors.append(f"{name} extends beyond the viewport") + if r["y"] < -0.6 or r["bottom"] > 52.6: + errors.append(f"{name} extends beyond the header height") + if previous is not None and r["x"] < previous - 0.6: + errors.append(f"{name} overlaps the previous control") + previous = r["right"] + if control["lines"] > 1: + errors.append(f"{name} wraps onto {control['lines']} lines") + for text in control["textRects"]: + if text["y"] < 0 or text["bottom"] > 52: + errors.append(f"{name} text is vertically clipped") + if not control["hit"]: + errors.append(f"{name} is obscured or offscreen") + if not data["version"].startswith("Clients v"): + errors.append("Client release badge missing") + for kind in ("desktopLinks", "mobileLinks"): + actual = [(link["text"], link["href"]) for link in data[kind]] + expected = [(label, urljoin(base_url, destination)) for label, destination in DESTINATIONS] + if actual != expected: + errors.append(f"{kind} changed destinations or labels: {actual}") + return errors + + +def settle(page): + page.evaluate("() => document.fonts.ready") + page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))") + + +def tab_to(page, selector): + """Reach a control with actual Tab presses, including WebKit's full tab mode.""" + for _ in range(50): + if page.evaluate("s => document.activeElement.matches(s)", selector): + return + mac_webkit = sys.platform == "darwin" and page.context.browser.browser_type.name == "webkit" + page.keyboard.press("Alt+Tab" if mac_webkit else "Tab") + raise AssertionError(f"Keyboard cannot reach {selector}") + + +def interactions(page, url): + """Check actual controls and keyboard navigation on the rendered page.""" + outcomes = [] + for width in (1024, 1280): + page.set_viewport_size({"width": width, "height": 480}) + page.goto(url, wait_until="load") + settle(page) + desktop = width >= 1280 + search = "#nav-search-ribbon" if desktop else "#nav-search" + tab_to(page, search) + page.keyboard.press("Enter") + page.locator("#search-modal.is-active").wait_for(state="visible") + assert page.locator("#search").evaluate("el => el === document.activeElement") + page.keyboard.press("Escape") + page.locator("#search-modal").wait_for(state="hidden") + outcomes.append({"width": width, "searchKeyboardAndFocus": True}) + + # Reload to start a new keyboard traversal at the document start. + page.goto(url, wait_until="load") + if not desktop: + tab_to(page, ".nav-burger") + page.keyboard.press("Enter") + assert page.locator(".nav-burger").get_attribute("aria-expanded") == "true" + menu = page.locator(".nav-mobile-menu") + menu.wait_for(state="visible") + assert menu.locator("a").count() == 10 + tab_to(page, '.nav-mobile-menu a[href$="/docs/donate"]') + last = menu.locator("a").last + visible = last.evaluate("""el => { + const r=el.getBoundingClientRect(), m=el.parentElement.getBoundingClientRect(); + const hit=document.elementFromPoint((r.left+r.right)/2,(r.top+r.bottom)/2); + return r.top >= m.top && r.bottom <= Math.min(m.bottom,innerHeight)+1 + && (hit===el || el.contains(hit)); + }""") + assert visible, "Last compact-menu destination is not visible after keyboard scroll" + page.keyboard.press("Enter") + page.wait_for_url(lambda url: urlparse(url).path.rstrip("/") == "/docs/donate", + wait_until="domcontentloaded") + outcomes.append({"width": width, "lastDestinationKeyboardNavigation": page.url}) + page.goto(url, wait_until="load") + tab_to(page, ".nav-burger") + page.keyboard.press("Enter") + page.set_viewport_size({"width": 1280, "height": 480}) + settle(page) + assert not page.locator(".nav-mobile-menu").is_visible() + assert page.locator(".topbar").is_visible() + page.set_viewport_size({"width": 1024, "height": 480}) + settle(page) + assert page.locator(".nav-mobile-menu").is_visible() + assert not page.locator(".topbar").is_visible() + outcomes.append({"width": width, "resizeRetainsOpenState": True}) + selector = '.topbar-link[href$="/editor/"]' if desktop else '.nav-mobile-menu a[href$="/editor/"]' + tab_to(page, selector) + page.keyboard.press("Enter") + page.wait_for_url("**/editor/", wait_until="domcontentloaded") + outcomes.append({"width": width, "editorKeyboardNavigation": page.url}) + return outcomes + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", required=True) + parser.add_argument("--browser", choices=("chromium", "firefox", "webkit"), required=True) + parser.add_argument("--artifacts", type=Path, required=True) + args = parser.parse_args() + args.artifacts.mkdir(parents=True, exist_ok=True) + report = {"checkedAt": datetime.now(timezone.utc).isoformat(), "baseUrl": args.base_url, + "browser": args.browser, "geometry": [], "interactions": [], "errors": []} + try: + with sync_playwright() as playwright: + browser = getattr(playwright, args.browser).launch() + report["browserVersion"] = browser.version + for fallback in (False, True): + context = browser.new_context(viewport={"width": 1280, "height": 800}, + reduced_motion="reduce") + if fallback: + context.route("**/*", lambda route: route.abort() if + route.request.resource_type == "font" or + urlparse(route.request.url).netloc in ("fonts.googleapis.com", "fonts.gstatic.com") + else route.continue_()) + page = context.new_page() + page.set_default_timeout(15000) + for index, path in enumerate(PAGES): + url = urljoin(args.base_url.rstrip("/") + "/", path.lstrip("/")) + page.goto(url, wait_until="load") + settle(page) + if index == 0 and not fallback: + css_url = urljoin(args.base_url.rstrip("/") + "/", "css/warp.css") + css = page.request.get(css_url) + assert css.ok, f"Stylesheet returned HTTP {css.status}" + report["stylesheet"] = {"url": css_url, + "sha256": hashlib.sha256(css.body()).hexdigest()} + for width in WIDTHS: + page.set_viewport_size({"width": width, "height": 800}) + settle(page) + data = page.evaluate(GEOMETRY) + errors = geometry_errors(data, args.base_url) + if data["stylesheet"] != urljoin(args.base_url.rstrip("/") + "/", "css/warp.css"): + errors.append("Rendered stylesheet is not from the site under test") + loaded = any("Space Grotesk" in f["family"] and f["status"] == "loaded" + for f in data["fonts"]) + if not fallback and not loaded: + errors.append("Normal-font run did not load Space Grotesk") + data.update({"path": path, "fallbackFonts": fallback, "errors": errors}) + report["geometry"].append(data) + prefix = f"page-{index}-{'fallback' if fallback else 'normal'}-{width}" + report["errors"].extend(f"{prefix}: {e}" for e in errors) + if width in (1024, 1280, 2560) and index == 0: + page.screenshot(path=str(args.artifacts / f"{prefix}.png")) + if not fallback: + try: + report["interactions"].append({"path": path, "outcomes": interactions(page, url)}) + except Exception as error: + report["errors"].append(f"{path} interactions: {error}") + page.screenshot(path=str(args.artifacts / f"page-{index}-interaction-failure.png")) + context.close() + browser.close() + except Exception as error: + report["errors"].append(f"Browser check could not complete: {error}") + report["passed"] = not report["errors"] and len(report["geometry"]) == len(WIDTHS) * len(PAGES) * 2 + (args.artifacts / "navigation.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps({"browser": args.browser, "passed": report["passed"], + "geometryCases": len(report["geometry"]), "errors": report["errors"][:12]})) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/requirements-browser.txt b/scripts/requirements-browser.txt new file mode 100644 index 0000000..984b515 --- /dev/null +++ b/scripts/requirements-browser.txt @@ -0,0 +1 @@ +playwright==1.63.0 diff --git a/themes/WarpDrive/static/css/warp.css b/themes/WarpDrive/static/css/warp.css index b3d480c..b2a9ef2 100644 --- a/themes/WarpDrive/static/css/warp.css +++ b/themes/WarpDrive/static/css/warp.css @@ -1256,7 +1256,7 @@ a.waypoint-link:hover .waypoint-cta { } /* ============================================================ - TOP BAR: unified nav bar, hidden on mobile (<900px) + TOP BAR: unified nav bar, hidden below 1280px ============================================================ */ .topbar { display: none; @@ -1313,8 +1313,10 @@ a.waypoint-link:hover .waypoint-cta { .topbar-link { display: flex; align-items: center; + flex-shrink: 0; + white-space: nowrap; height: 52px; - padding: 0 1rem; + padding: 0 0.75rem; font-family: 'Space Grotesk', sans-serif; font-size: 0.8rem; font-weight: 500; @@ -1333,8 +1335,8 @@ a.waypoint-link:hover .waypoint-cta { content: ''; position: absolute; bottom: 0; - left: 1rem; - right: 1rem; + left: 0.75rem; + right: 0.75rem; height: 2px; background: var(--warp-blue); opacity: 0; @@ -1391,7 +1393,7 @@ a.waypoint-link:hover .waypoint-cta { } /* ============================================================ - MOBILE NAV (shown below 900px) + COMPACT NAV (shown below 1280px) ============================================================ */ .mobile-nav { position: fixed; @@ -1471,6 +1473,9 @@ a.waypoint-link:hover .waypoint-cta { border-bottom: 1px solid var(--border); z-index: 99; padding: 0.5rem 0; + max-height: calc(100vh - 52px); + max-height: calc(100dvh - 52px); + overflow-y: auto; } .nav-mobile-menu.is-open { display: block; } @@ -1494,7 +1499,7 @@ a.waypoint-link:hover .waypoint-cta { } /* Hide mobile nav on wide screens where topbar shows */ -@media (min-width: 900px) { +@media (min-width: 1280px) { .mobile-nav { display: none; } .nav-mobile-menu { display: none !important; } .topbar { display: block; } @@ -2208,8 +2213,6 @@ main { padding-top: 52px; } .section { padding: 5rem 3rem; } .hero { padding: 6rem 3rem 5rem; } - /* Wider nav link spacing on large screens */ - .topbar-link { padding: 0 1.25rem; } } /* ============================================================ @@ -2248,7 +2251,7 @@ main { padding-top: 52px; } .deploy-grid { max-width: 1000px; } .topbar-brand span { font-size: 0.95rem; } - .topbar-link { font-size: 0.9rem; padding: 0 1.5rem; } + .topbar-link { font-size: 0.9rem; } .topbar-logo { height: 28px; } .status-label { font-size: 0.75rem; } diff --git a/themes/WarpDrive/templates/base.html b/themes/WarpDrive/templates/base.html index 8cdf7c9..2462267 100644 --- a/themes/WarpDrive/templates/base.html +++ b/themes/WarpDrive/templates/base.html @@ -78,7 +78,7 @@ - +
@@ -104,7 +104,7 @@
- +