Bläddra i källkod

T12 mobile audit + polish: zero horizontal overflow at 360/768/1280

test/mobile_audit.py boots the real app via the e2e harness, seeds every page family's fixtures (goal with a live plan review, task with a rich event log, comments with long tokens, stacks/rules/chats/discoveries), then renders all 18 routes in headless chromium at 360/768/1280 with full statum hydration (the auth frame is seeded into localStorage before statum.js runs). The probe reports any box whose right edge exits the viewport (page-level overflow plus per-element culprits), sub-44px touch targets under emulated coarse pointers, and opens the first dialog per page to assert it fits.

Findings fixed in app.css (Goal 41 T12 section): the closed Admin submenu (abs-pos, min-width 11rem) widened every page by 6px - contained via overflow-x:clip on .topbar .nav plus a width clamp; the /tasks goal filter select forced +352px - flex shrink + max-width; long tokens in prose spilled cards on /discoveries, /projects/2 and task comments - overflow-wrap:anywhere on the prose surfaces; layout children got min-width:0; inputs cap at 100%. Touch targets: nav toggles, chips, log filters, tab labels, choice rows, dialog buttons and every full-size button (including spry's zero-specificity bare buttons) meet the 44px floor under any-pointer:coarse and max-width:640px.

Audit result: 0 overflow findings and 0 dialog-fit failures at all three widths (before: every page +6px, /tasks +352, /discoveries +229, /projects/2 +127, /tasks/2 +178). test/bundles/t12mobile.py asserts the server-side invariants and runs the browser audit when chromium is present (skips gracefully otherwise; T12MOBILE_BROWSER=1 requires it). docs/spry-conversion.md gains the mobile containment contract. test/mobile_bisect.py is the development diagnostic that named the +6px culprit.
saddle-ClankerSaddle 16 timmar sedan
förälder
incheckning
e8d17f0fbe
6 ändrade filer med 1104 tillägg och 0 borttagningar
  1. 52 0
      docs/spry-conversion.md
  2. 132 0
      src/pages/app.css
  3. 114 0
      test/bundles/t12mobile.py
  4. 1 0
      test/harness.py
  5. 680 0
      test/mobile_audit.py
  6. 125 0
      test/mobile_bisect.py

+ 52 - 0
docs/spry-conversion.md

@@ -334,3 +334,55 @@ the partial (`<pstm-fragment name="breadcrumb" crumbs="…"/>`). The
 four per-page copies (Task/Goal/Project/Chat CrumbView) were
 unified away; do not add a page-local one.
 
+## The mobile containment contract (T12)
+
+The mobile audit (`test/mobile_audit.py`) renders every route in
+headless Chromium at 360 / 768 / 1280 and fails on any box whose
+right edge exits the viewport (page-level overflow is the aggregate).
+These are the patterns that keep it green — every new page follows
+them:
+
+1. **Wide tables sit in `.table-wrap`** (spry's containment:
+   `overflow-x: auto`). The table may scroll inside its wrap on a
+   phone; the page never scrolls sideways. A table outside a wrap is
+   the classic page-overflow bug.
+2. **Long unbroken tokens wrap anywhere**: prose containers (`.md`,
+   `.card-body`, `.comment`, chat text, `.event-pre`, `.terminal`)
+   carry `overflow-wrap: anywhere` (T12 rule) — SSH keys, URLs,
+   hashes and base64 break at any character instead of spilling the
+   card. `code` spans use `word-break: break-word`.
+3. **Flex/grid children may shrink**: layout utilities
+   (`.with-sidebar`, `.act-consult`, `.cards`, `.grid-2`) have
+   `min-width: 0` children and `minmax(min(100%, …), 1fr)` tracks, so
+   a wide child cannot stretch its column past the viewport. When you
+   build a new layout, give the children `.min-w-0` (or rely on the
+   T12 rules) and put the wide thing inside a containment surface.
+4. **Selects cap at the column**: a select's intrinsic width is its
+   longest option — goal titles run long. `form.cluster > select`
+   carries `flex: 0 1 auto; min-width: 0; max-width: 100%`, so filter
+   rows wrap instead of widening.
+5. **Dropdown menus never widen the page**: spry's `.submenu` is
+   absolutely positioned (`min-width: 11rem`); a closed
+   `visibility:hidden` menu still occupies the scroll area. The
+   topbar nav carries `overflow-x: clip` (combines with visible-y, so
+   open dropdowns still drop) and every `.submenu` is clamped to
+   `max-width: calc(100vw - 1.5rem)`.
+6. **Touch targets >= 44px on phones**: nav toggles, filter chips,
+   log filters, tab labels, choice rows, dialog buttons and every
+   full-size button meet the 44px floor under
+   `@media (any-pointer: coarse) and (max-width: 640px)`. Bare
+   `<button>`s count too (spry styles them at zero specificity —
+   include them in any new selector). Compact `.btn-sm` is the
+   deliberate exception (dense table rows).
+7. **Dialogs fit the phone**: spry's `dialog { width: min(92vw, …) }`
+   does the work; the audit opens the first dialog on each page and
+   asserts it sits fully inside the viewport. Never give a dialog a
+   fixed pixel width.
+8. **Inputs never force width**: `cols=`/`size=` floor a control's
+   intrinsic width; the global `input, select, textarea {
+   max-width: 100% }` (T12) caps them at the column.
+
+The audit is wired into the suite as the `t12mobile` bundle: the
+server-side invariants run always; the browser audit runs when
+chromium is present (CI without chromium skips it gracefully — pass
+`T12MOBILE_BROWSER=1` to require it).

+ 132 - 0
src/pages/app.css

@@ -1666,3 +1666,135 @@ td .truncate.repo-url {
     justify-content: center;
     min-height: 40vh;
 }
+
+/* ==========================================================================
+   Goal 41 T12 — mobile polish (the audit's containment patterns)
+   ==========================================================================
+   The mobile audit (test/mobile_audit.py) renders every route in
+   headless Chromium at 360/768/1280 and flags any box whose right
+   edge exits the viewport. Every rule here answers a finding; the
+   patterns are the binding mobile contract for future pages (see
+   docs/spry-conversion.md, "The mobile containment contract"). */
+
+/* (1) The topbar's dropdown menus: spry's .submenu is absolutely
+   positioned at left:0 with min-width:11rem, so a menu anchored on a
+   li near the viewport's right edge (Admin, Account) extends past it
+   and widens the page even while closed (visibility:hidden boxes
+   still occupy the scroll area). Clipping the nav's x axis contains
+   them — `clip` (unlike `hidden`) combines with `visible` on y, so
+   the open dropdown still drops below the bar unclipped; the menu
+   itself never exceeds the bar's width, and the width clamp keeps it
+   inside a phone viewport when open. */
+.topbar .nav {
+    overflow-x: clip;
+    overflow-y: visible;
+}
+.submenu {
+    max-width: calc(100vw - 1.5rem);
+}
+
+/* (2) Filter selects in the task-queue cluster: a select's intrinsic
+   width (its longest option — goal titles run long) forced the whole
+   row past the edge. Flex children may shrink to their content only
+   down to a sane floor; selects cap at the viewport's width. */
+.cluster > select {
+    max-width: 100%;
+}
+form.cluster > select {
+    flex: 0 1 auto;
+    min-width: 0;
+}
+
+/* (3) Long unbroken tokens (ssh keys, URLs, hashes, base64) in prose
+   containers: overflow-wrap:anywhere lets them break at any character
+   instead of spilling the card. Applied to every prose surface the
+   audit found (markdown bodies, card bodies, comments, chat text);
+   code inside them keeps word-break for inline code spans. */
+.md, .card-body, .comment, .chat-message, .footnote, .hint,
+.discovery-body, .plan-review, .event-pre, .terminal {
+    overflow-wrap: anywhere;
+}
+.md code, .card-body code, .comment code {
+    word-break: break-word;
+}
+
+/* (4) Grid/flex children collapse: spry's grid tracks keep content
+   minima, so a wide child (table, pre) stretches its column past the
+   viewport. min-width:0 on the direct children of the layout
+   utilities restores shrinkability; the contained scroll surfaces
+   (.table-wrap, pre, .terminal) then clip as designed. */
+.with-sidebar > *,
+.act-consult > *,
+.cards > *,
+.grid-2 > * {
+    min-width: 0;
+}
+.cards,
+.grid-2 {
+    grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
+}
+
+/* (5) Tables: spry's .table-wrap owns containment (overflow-x:auto);
+   the audit's page-level check passes once (1)-(4) stop the page
+   itself scrolling. Dense tables stay dense — they scroll inside
+   their wrap on phone widths by design. */
+
+/* (6) Touch targets at phone widths: nav toggles, filter chips and
+   log filters are the controls a phone user taps most; 44px is the
+   minimum comfortable target (WCAG 2.2 target size). Keyed on
+   any-pointer (a phone's primary input is coarse even when a stylus
+   or trackpad is attached) and scoped to narrow viewports so desktop
+   rows stay compact — heights grow through min-height, keeping the
+   chips' pill grammar and the sub-nav's measured geometry intact
+   (the strip's own height is read at click time by the jump engine,
+   so taller anchors still clear it). */
+@media (any-pointer: coarse) and (max-width: 640px) {
+    .nav .nav-toggle,
+    .goal-filter,
+    .log-toolbar .log-filter,
+    .tabs .tab-list label,
+    .dialog-footer .btn,
+    .card-footer .btn,
+    label.choice,
+    label.tab-link {
+        min-height: 44px;
+    }
+    /* Every full-size button meets the 44px floor — spry styles bare
+       <button> at ZERO specificity (:where), so the selector must
+       include them, not just .btn. Compact .btn-sm grammar (dense
+       table rows, quiet secondary actions) is deliberately excluded. */
+    button:not(.btn-sm):not(.nav-toggle):not(.log-filter):not(.goal-filter),
+    .btn:not(.btn-sm) {
+        min-height: 44px;
+    }
+    .tabs .tab-list label {
+        display: inline-flex;
+        align-items: center;
+    }
+    label.choice {
+        display: inline-flex;
+        align-items: center;
+    }
+}
+
+/* (7) The sub-nav strip and the log toolbar scroll their own axis on
+   narrow screens instead of widening the page (the anchors keep
+   their nowrap grammar; the strip scrolls like a chip rail). */
+.subnav,
+.log-toolbar {
+    overflow-x: auto;
+    -webkit-overflow-scrolling: touch;
+}
+
+/* (8) Inputs and textareas never force width: cols=/size= floor a
+   form control's intrinsic width; max-width caps it at the column. */
+input, select, textarea {
+    max-width: 100%;
+}
+
+/* (9) The dashboard's ambient expander body: its footnote list items
+   are prose; the wrap rules in (3) cover them — this re-asserts the
+   container may shrink (flex base auto held it open). */
+.dash-ambient-body {
+    min-width: 0;
+}

+ 114 - 0
test/bundles/t12mobile.py

@@ -0,0 +1,114 @@
+"""The mobile-layout bundle (goal 41 T12): the server-side invariants
+of the mobile containment contract (docs/spry-conversion.md), plus —
+when chromium is present — the full browser audit (test/mobile_audit)
+that renders every route at 360/768/1280 and fails on any horizontal
+overflow or dialog-fit failure.
+
+CI without chromium: the browser part skips gracefully (print + pass
+a SKIP marker, never a FAIL) unless T12MOBILE_BROWSER=1 demands it."""
+
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from harness import *  # noqa: F401,F403
+
+WIDE_TABLE_RE = re.compile(r"<table[^>]*>", re.IGNORECASE)
+
+# Every page that ships a <table> in its compiled markup; the audit's
+# route set mirrors this list.
+PAGES_WITH_TABLES = ("/tasks", "/goals", "/projects", "/chats",
+                     "/saddles", "/users")
+
+
+def run(ctx):
+    admin = ctx.auth
+
+    print("== every wide table sits in .table-wrap ==")
+    for uri in PAGES_WITH_TABLES:
+        page = http_get_page(admin, uri)
+        tables = WIDE_TABLE_RE.findall(page)
+        check(bool(tables), "%s renders at least one table" % uri)
+        for match in WIDE_TABLE_RE.finditer(page):
+            before = page[:match.start()]
+            # the NEAREST enclosing div before the table must be the
+            # wrap (allow attributes between class names)
+            opening = before.rfind("<div")
+            enclosing = before[opening:match.start()] if opening != -1 else ""
+            check("table-wrap" in enclosing,
+                  "%s: table inside .table-wrap" % uri)
+
+    print("== no fixed pixel widths above the phone viewport ==")
+    # Any width/min-width style attribute or CSS in the page markup
+    # above 360px is a phone-overflow factory; the audit found them in
+    # dialogs and hero forms.
+    for uri in ("/", "/tasks", "/goals", "/projects", "/rules",
+                "/stacks", "/discoveries", "/chats", "/saddles",
+                "/users", "/models", "/profile",
+                "/tasks/2", "/goals/1", "/projects/2"):
+        page = http_get_page(admin, uri)
+        offenders = re.findall(
+            r'(?:min-width|width)\s*[:=]\s*"?(\d{4,})', page)
+        check(not offenders,
+              "%s: no fixed width >= 1000px in markup" % uri)
+
+    print("== dialogs use the fitting width grammar ==")
+    # spry's dialog rule is width: min(92vw, <cap>rem); the app never
+    # overrides it with a fixed width. Assert the dialog markup keeps
+    # the shared classes and app.css carries no fixed dialog width.
+    status, css = http("GET", "/_statum/resource/app.css")
+    check(status == 200, "app.css served")
+    fixed = [line for line in css.splitlines()
+             if "dialog" in line and re.search(r"width:\s*\d", line)]
+    check(not fixed, "app.css sets no fixed dialog width: %s" % fixed[:2])
+    for uri in ("/tasks", "/goals", "/rules", "/projects"):
+        page = http_get_page(admin, uri)
+        for dialog_id in re.findall(r'<dialog[^>]*id="([^"]+)"', page):
+            check(bool(dialog_id), "dialog %s has an id" % dialog_id)
+        check('class="dialog-sm"' in page or "<dialog" not in page,
+              "%s dialogs carry the .dialog-sm width grammar" % uri)
+
+    print("== the touch-target rules exist in the served css ==")
+    check("@media (any-pointer: coarse) and (max-width: 640px)" in css,
+          "the phone touch-target media block is served")
+    check(re.search(r"\.nav \.nav-toggle[^}]*min-height:\s*44px", css)
+          is not None,
+          "nav toggles meet the 44px floor on phones")
+
+    print("== browser audit (rendered layout at 360/768/1280) ==")
+    chromium = None
+    for name in ("chromium-browser", "chromium", "google-chrome",
+                 "google-chrome-stable"):
+        import shutil
+        chromium = chromium or shutil.which(name)
+    if not chromium and not os.environ.get("T12MOBILE_BROWSER"):
+        print("SKIP no chromium binary — the browser audit is skipped"
+              " (set T12MOBILE_BROWSER=1 to require it)")
+        return
+    import mobile_audit
+    findings = mobile_audit.run_audit(
+        os.environ.get("T12MOBILE_APP") or APP, (360, 768, 1280))
+    check(findings is not None, "the browser audit ran")
+    if findings is None:
+        return
+    overflows = []
+    dialogs_bad = []
+    for route in findings:
+        for width in findings[route]:
+            data = findings[route][width]
+            if data.get("error"):
+                overflows.append((route, width, data["error"]))
+                continue
+            for offender in data.get("offenders") or []:
+                overflows.append((route, width, offender["path"]))
+            for dialog in data.get("dialogs") or []:
+                if not dialog.get("fits", True):
+                    dialogs_bad.append((route, width, dialog))
+    check(not overflows,
+          "zero horizontal overflow at 360/768/1280 (%d findings: %s)"
+          % (len(overflows), overflows[:3]))
+    check(not dialogs_bad,
+          "every dialog fits the viewport at phone width (%s)"
+          % dialogs_bad[:2])

+ 1 - 0
test/harness.py

@@ -1372,6 +1372,7 @@ BUNDLES_ORDER = ("pages", "t7log", "dispatch", "subtasks", "lineage",
                  "advisor_arc", "advisor_fix", "advisor_refine", "advisor_utf8",
                  "canon",
                  "projectdelete",
+                 "t12mobile",
                  "canon_deploy")
 
 

+ 680 - 0
test/mobile_audit.py

@@ -0,0 +1,680 @@
+#!/usr/bin/env python3
+"""The mobile/viewport layout audit (goal 41 T12).
+
+Boots the REAL app through the e2e harness (start_bundle: real binary,
+real statum wire, fake saddle daemon), seeds representative data for
+every page family, then renders each route in headless Chromium at
+three widths (360 = phone, 768 = tablet, 1280 = desktop) and reports
+every element that overflows its box horizontally, plus page-level
+horizontal scrolling.
+
+Rendering is FULL hydration, not static HTML: the audit seeds the
+harness login's auth frame into localStorage via the Chrome DevTools
+Protocol (Page.addScriptToEvaluateOnNewDocument) BEFORE statum.js runs,
+so every stm-* template evaluates against real server state, CSS
+applies, and client engines (sub-nav, log feed, filters) boot.
+
+Usage:
+    python3 test/mobile_audit.py [app_binary] [--json OUT.json]
+        [--widths 360,768,1280] [--routes a,b] [--keep]
+
+Exit code 0 = zero findings. chromium-browser/chromium is required; the
+script tells you when it is missing (the t12mobile bundle gates on the
+same probe and skips its browser part there).
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+
+import harness  # noqa: E402  (module-level import: sets REPO/APP defaults)
+
+# The harness's default scratch dir is a top-level /tmp path the
+# janitor reaps mid-run (a known fleet gotcha) — default to a
+# repo-local one unless the caller set their own.
+if "WEBSTACK_WORK" not in os.environ:
+    os.environ["WEBSTACK_WORK"] = os.path.join(
+        harness.REPO, "build", "ws-mobile")
+
+CHROME_CANDIDATES = ("chromium-browser", "chromium", "google-chrome",
+                     "google-chrome-stable")
+
+# Every route the app ships, with the fixtures each needs. Detail-page
+# ids are resolved from the seeded data at runtime (see seed()).
+ROUTE_PAGES = ("/", "/tasks", "/goals", "/projects", "/rules",
+               "/stacks", "/discoveries", "/chats", "/saddles",
+               "/users", "/models", "/profile")
+DETAIL_PAGES = ("/tasks/{task_id}", "/tasks/{task_id}/log",
+                "/goals/{goal_id}", "/projects/{project_id}",
+                "/stacks/{stack_id}", "/chats/{chat_id}")
+
+DEFAULT_WIDTHS = (360, 768, 1280)
+
+
+def find_chromium():
+    for name in CHROME_CANDIDATES:
+        path = shutil.which(name)
+        if path:
+            return path
+    return None
+
+
+# ---------------------------------------------------------------------------
+# The in-page probe: runs inside the rendered page (Runtime.evaluate),
+# after statum has hydrated. Reports every horizontally-overflowing
+# element (scrollWidth > clientWidth + tolerance), page-level overflow,
+# tiny touch targets in the sticky chrome, and dialog widths.
+# ---------------------------------------------------------------------------
+
+PROBE_JS = r"""
+(() => {
+  const out = {href: location.pathname, ready: !!(window.statum && window.statum.version),
+               pageOverflowX: null, offenders: [], touch: [], dialogs: []};
+  const de = document.documentElement;
+  out.pageOverflowX = de.scrollWidth - de.clientWidth;
+  const label = (el) => {
+    if (el.id) return el.tagName.toLowerCase() + '#' + el.id;
+    let sel = el.tagName.toLowerCase();
+    if (el.classList && el.classList.length) sel += '.' + [...el.classList].slice(0, 3).join('.');
+    return sel;
+  };
+  const path = (el) => {
+    const parts = [];
+    let node = el;
+    while (node && node.nodeType === 1 && parts.length < 4) {
+      parts.unshift(label(node));
+      node = node.parentElement;
+    }
+    return parts.join(' > ');
+  };
+  const VISIBLE = (el) => {
+    const cs = getComputedStyle(el);
+    if (cs.display === 'none' || cs.visibility === 'hidden') return false;
+    const r = el.getBoundingClientRect();
+    return r.width > 0 && r.height > 0;
+  };
+    const all = document.querySelectorAll('*');
+  // A box that sticks out past the viewport's right edge is a page
+  // scroll contributor (in-flow or abs-positioned alike). Boxes fully
+  // inside — including .table-wrap/.subnav/terminal that scroll
+  // internally — are fine.
+  const docW = de.clientWidth;
+  const contained = (el) => {
+    for (let a = el.parentElement; a; a = a.parentElement) {
+      const ax = getComputedStyle(a).overflowX;
+      if (ax !== 'visible') return true;   // clipped/scrolling ancestor
+    }
+    return false;
+  };
+  for (const el of all) {
+    if (!VISIBLE(el)) continue;
+    if (contained(el)) continue;
+    const r = el.getBoundingClientRect();
+    if (r.right > docW + 0.5 && r.width > 0) {
+      const cs = getComputedStyle(el);
+      out.offenders.push({
+        path: path(el), tag: el.tagName.toLowerCase(),
+        right: Math.round(r.right), width: Math.round(r.width),
+        over: Math.round(r.right - docW),
+        pos: cs.position, white: cs.whiteSpace,
+      });
+    }
+  }
+  // Cap the offender list: 40 richest entries (biggest overflow first).
+  out.offenders.sort((a, b) => b.over - a.over);
+  out.offenders = out.offenders.slice(0, 40);
+  // Touch targets: interactive controls in the sticky chrome + any
+  // chips — must be >= 44px tall at phone widths to be tappable.
+  const controls = document.querySelectorAll(
+    'button, [role=button], a.badge, .chip, summary, .dialog-close, ' +
+    '.subnav a, .tabs label, label.choice, .btn');
+  for (const el of controls) {
+    if (!VISIBLE(el)) continue;
+    // Checkboxes/radios ride inside label.choice — the LABEL is the
+    // touch target; the input itself is intentionally small.
+    if (el.tagName === 'INPUT') continue;
+    const r = el.getBoundingClientRect();
+    if (r.height > 0 && r.height < 44 && el.offsetParent !== null) {
+      const cs = getComputedStyle(el);
+      // Inline text links inside paragraphs are reading flow, not
+      // controls — skip pure anchors without a class.
+      if (el.tagName === 'A' && !el.className.trim()) continue;
+      out.touch.push({path: path(el), h: Math.round(r.height),
+                      display: cs.display});
+    }
+  }
+  out.touch = out.touch.slice(0, 30);
+  // Dialog boxes: closed dialogs have no layout — measure one by
+  // opening it if the page carries the app's dialog helper, then
+  // close it again. The fit contract is spry's width: min(92vw, 24rem)
+  // for .dialog-sm; an OPEN dialog must sit fully inside the viewport.
+  if (window.openAppDialog) {
+    const dlg = document.querySelector('dialog');
+    if (dlg && !dlg.open) {
+      try {
+        window.openAppDialog(dlg.id);
+        const r = dlg.getBoundingClientRect();
+        out.dialogs.push({id: dlg.id || 'first', left: Math.round(r.left),
+                          right: Math.round(r.right),
+                          fits: r.right <= de.clientWidth + 0.5
+                                && r.left >= -0.5});
+        dlg.close();
+      } catch (e) { /* the page's own open hooks may differ */ }
+    }
+  }
+  return out;
+})()
+"""
+
+
+class Chrome:
+    """Minimal CDP client over --headless --remote-debugging-port.
+
+    Deliberately dependency-light: python3-websocket-client only (no
+    selenium/playwright). One tab per audit; pages are navigated with
+    Page.navigate + a settled-DOM wait loop.
+    """
+
+    def __init__(self, binary, port):
+        self.log = open(os.path.join(tempfile.gettempdir(),
+                                     "mobile-audit-chrome.log"), "w")
+        self.proc = subprocess.Popen(
+            [binary, "--headless=new", "--no-sandbox", "--disable-gpu",
+             "--disable-dev-shm-usage", "--remote-debugging-port=%d" % port,
+             "--user-data-dir=%s" % tempfile.mkdtemp(prefix="audit-chrome-"),
+             "--no-first-run", "--remote-allow-origins=*",
+             "about:blank"],
+            stdout=self.log, stderr=subprocess.STDOUT)
+        self.port = port
+        self.ws = None
+        self.msg_id = 0
+        self._await_http(port)
+
+    @staticmethod
+    def _await_http(port, timeout=30):
+        deadline = time.time() + timeout
+        url = "http://127.0.0.1:%d/json/version" % port
+        while time.time() < deadline:
+            try:
+                with urllib.request.urlopen(url, timeout=2) as response:
+                    if response.status == 200:
+                        return True
+            except OSError:
+                time.sleep(0.25)
+        raise SystemExit("chromium devtools endpoint never came up on %d" % port)
+
+    def connect(self):
+        import websocket  # python3-websocket-client
+
+        with urllib.request.urlopen(
+                "http://127.0.0.1:%d/json/list" % self.port) as response:
+            targets = json.loads(response.read().decode())
+        page = next(t for t in targets if t["type"] == "page")
+        self.ws = websocket.create_connection(page["webSocketDebuggerUrl"],
+                                              timeout=30)
+        self.touch_media = False
+        self._send("Target.createTarget" if False else "Page.enable")
+        self._send("Runtime.enable")
+        self._send("Network.enable")
+
+    def _send(self, method, params=None, timeout=30):
+        self.msg_id += 1
+        self.ws.send(json.dumps({"id": self.msg_id, "method": method,
+                                 "params": params or {}}))
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            self.ws.settimeout(max(0.5, deadline - time.time()))
+            try:
+                message = json.loads(self.ws.recv())
+            except websocket.WebSocketTimeoutException:
+                break
+            if message.get("id") == self.msg_id:
+                return message
+        return {"error": "timeout waiting for %s" % method}
+
+    def _eval(self, expression, await_promise=False):
+        result = self._send("Runtime.evaluate",
+                            {"expression": expression,
+                             "returnByValue": True,
+                             "awaitPromise": await_promise}, timeout=60)
+        value = (result.get("result") or {}).get("result", {})
+        return value.get("value")
+
+    def add_init_script(self, script):
+        self._send("Page.addScriptToEvaluateOnNewDocument", {"source": script})
+
+    def set_viewport(self, width, height=900):
+        # Coarse pointer at phone widths: headless reports pointer:fine
+        # by default, which would hide every @media (pointer: coarse)
+        # rule from the audit. Emulate a finger on phones so the touch
+        # -target findings (and their fixes) are actually exercised.
+        self.touch_media = width <= 500
+        self._send("Emulation.setDeviceMetricsOverride",
+                   {"width": width, "height": height,
+                    "deviceScaleFactor": 1, "mobile": width <= 500})
+
+    def goto(self, url, settle_s=3.0, timeout_s=45):
+        """Navigate and wait for statum hydration + network quiet."""
+        # Touch input at phone widths: headless reports NO pointer type
+        # at all (both pointer:fine and pointer:coarse match nothing),
+        # so Emulation.setEmulatedMedia cannot exercise the touch
+        # rules. setTouchEmulationEnabled flips any-pointer: coarse,
+        # which the CSS keys on (phones are any-pointer: coarse too).
+        if self.touch_media:
+            self._send("Emulation.setTouchEmulationEnabled",
+                       {"enabled": True, "maxTouchPoints": 5})
+        self._send("Page.navigate", {"url": url}, timeout=60)
+        deadline = time.time() + timeout_s
+        hydrated = False
+        while time.time() < deadline:
+            state = self._eval(
+                "document.readyState === 'complete' && "
+                "!!(window.statum && window.statum.version) && "
+                "(window.__auditHalt ? 'halt' : true)")
+            if state == "halt":
+                return False
+            if state is True:
+                hydrated = True
+                break
+            time.sleep(0.2)
+        if not hydrated:
+            print("  (warning: %s never fully hydrated)" % url)
+        # Let afterEvaluate hooks, subnav engine and fonts settle.
+        time.sleep(settle_s)
+        return True
+
+    def probe(self):
+        return self._eval(PROBE_JS)
+
+    def screenshot(self, path):
+        self._send("Page.captureScreenshot", {"format": "png"}, timeout=60)
+
+    def close(self):
+        try:
+            if self.ws:
+                self.ws.close()
+            self.proc.terminate()
+            self.proc.wait(timeout=10)
+        except OSError:
+            pass
+        self.log.close()
+
+
+# ---------------------------------------------------------------------------
+# Fixtures: representative data for every page family, planted through
+# the same wire/sqlite paths the bundles use.
+# ---------------------------------------------------------------------------
+
+def seed(ctx):
+    """Returns the format-map for DETAIL_PAGES routes.
+
+    Every write COMMITS immediately and every read opens a fresh
+    connection: the wire act() calls below write through the app
+    process, and a python-held open transaction would both hide their
+    commits from this snapshot and block the app's writer.
+    """
+    auth = ctx.auth
+    now = "strftime('%s','now')"
+
+    def sql(statement, args=()):
+        conn = harness.db()
+        try:
+            conn.execute(statement, args)
+            conn.commit()
+        finally:
+            conn.close()
+
+    def one(statement):
+        conn = harness.db()
+        try:
+            return conn.execute(statement).fetchone()[0]
+        finally:
+            conn.close()
+
+    # -- a second project (links) ------------------------------------
+    sql("insert into projects (name, git_url, default_branch, summary,"
+        " created_at) values ('audit-second',"
+        " 'https://example.com/very/long/path/to/second/repo.git', 'main',"
+        " 'A summary line for the second audit project.', %s)" % now)
+
+    # -- tasks in every lifecycle shape -------------------------------
+    for statement in (
+            "insert into tasks (project_id, title, status, priority,"
+            " created_at) values (1, 'Audit queued task', 'queued', 5,"
+            " strftime('%s','now'))",
+            "insert into tasks (project_id, title, status, priority,"
+            " created_at) values (1, 'Audit working task', 'working', 5,"
+            " strftime('%s','now'))",
+            "insert into tasks (project_id, title, status, priority,"
+            " created_at) values (1, 'Audit blocked task', 'blocked', 5,"
+            " strftime('%s','now'))",
+            "insert into tasks (project_id, title, status, priority, summary,"
+            " completed_at, created_at) values (1, 'Audit done task with a"
+            " very long completion summary heading', 'done', 5,"
+            " 'Done: the audit harness landed cleanly\n\nBody paragraphs"
+            " render under the headline.',"
+            " strftime('%s','now'), strftime('%s','now','-60 seconds'))",
+            "insert into tasks (project_id, title, status, priority,"
+            " created_at) values (1, 'Audit failed task', 'failed', 5,"
+            " strftime('%s','now'))"):
+        sql(statement)
+    task_id = one("select id from tasks where title = 'Audit working task'")
+    blocked_id = one("select id from tasks where title = 'Audit blocked task'")
+
+    # The blocked task owns a pending block (the pile surface) ...
+    sql("insert into task_blocks (task_id, type, question, options,"
+        " created_at) values (?, 'question',"
+        " 'Which datastore should the audit harness back its fixtures"
+        " with, given the fleet runs on cheap hardware?',"
+        " '[\\\"SQLite\\\",\\\"Postgres\\\"]', %s)" % now, (blocked_id,))
+    # ... and one answered block (the history fold).
+    sql("insert into task_blocks (task_id, type, question, answer,"
+        " answered_at, answered_by, created_at) values"
+        " (?, 'question', 'Hold or fold?', 'Hold — the audit says the"
+        " layout holds.', %s, 'user:admin', %s)" % (now, now), (blocked_id,))
+    sql("insert into task_comments (task_id, author, body, created_at)"
+        " values (?, 'user:admin', 'An audit comment with a quite long"
+        " unbroken SSH-style token"
+        " ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleVeryLongKeyBody"
+        "ThatDoesNotWrap user@host', %s)" % now, (task_id,))
+
+    # A working session on the working task + a rich event log.
+    sql("insert into sessions (task_id, saddle_id, started_at)"
+        " values (?, ?, %s)" % now, (task_id, ctx.fake_id))
+    session_id = one("select max(id) from sessions")
+    events = [
+        ("comment", "user:admin", {"body": "Long comment body for the"
+         " audit feed: " + "wrap-check-word " * 12}),
+        ("tool_call", "model", {"tool": "run_command",
+         "args": {"command": "meson setup build && ninja -C build"
+                  " && python3 test/web_stack_test.py ./build/clanker-saddle"
+                  " --bundle t7log --parallel 8"}}),
+        ("tool_result", "model", {"result": "ok: the exit code was 0 and"
+         " the output line was quite long indeed, well past the wrap point"
+         " of a narrow terminal viewport 123456789012345678901234567890"}),
+        ("progress", "model", {"text": "Overseeing the audit fixture"
+         " fan-out with a long progress line."}),
+        ("verdict", "model", {"verdict": "accept"}),
+        ("turn_text", "model", {"text": "A turn of narrative text that"
+         " spans a few sentences so the log feed has to wrap it in the"
+         " narrow viewport."}),
+        ("usage", "model", {"prompt_tokens": 12000, "completion_tokens": 900,
+                            "cached_tokens": 11000}),
+    ]
+    for seq, (kind, actor, payload) in enumerate(events, start=1):
+        sql("insert into events (session_id, seq, kind, actor, scope_type,"
+            " scope_id, payload, created_at) values (?, ?, ?, ?, 'task', ?,"
+            " ?, %s - ?)" % now,
+            (session_id, seq, kind, actor, task_id, json.dumps(payload), seq))
+    # The blocked task carries an open block event so its pile card renders.
+    sql("insert into events (session_id, seq, kind, actor, scope_type,"
+        " scope_id, payload, created_at) values (NULL, 9001, 'question',"
+        " 'model', 'task', ?, ?, %s)" % now,
+        (blocked_id, json.dumps({"question": "Which datastore..."})))
+
+    # -- a goal with the full review surface --------------------------
+    # The honest wire flow (the goalui pattern): script the fake agent
+    # to raise a plan review, create the goal, START it — the app then
+    # mints the goal_run task and the agent parks blocked on the plan
+    # question, which gives /goals/<id> its live review surface.
+    ctx.fake.goal_script = [(
+        "plan", "# Audit plan\n\n## Steps\n\n1. A very long numbered step"
+        " that has to wrap inside the plan review card on a narrow screen"
+        " without spilling sideways\n2. Second step")]
+    goal_create = harness.slot_public(auth, "/goals", "goals")["create"]
+    goal_id = None
+    for attempt in range(3):
+        harness.act(auth, goal_create, {
+            "project_id": "1", "title": "Audit goal with a long title that"
+            " must wrap on a narrow phone viewport without spilling",
+            "brief": "Drive the mobile audit end to end across the fleet's"
+            " surfaces with representative fixture depth.",
+            "branch_name": "goal/audit-t12"})
+        goal_id = harness.wait_for(
+            lambda: (one("select count(*) from goals where title like"
+                         " 'Audit goal%'") and "yes") or False,
+            timeout=10, what="the audit goal row") and one(
+            "select id from goals where title like 'Audit goal%'")
+        if goal_id:
+            break
+        # A dispatch tick can hold the writer; retry the create once.
+        time.sleep(1)
+    harness.act(auth, harness.goal_state(goal_id)["start"], {})
+    harness.wait_for(
+        lambda: (one("select count(*) from tasks where goal_id = %d"
+                     " and kind = 'goal_run'" % goal_id)
+                 and "yes") or False,
+        timeout=60, what="the audit goal's run task")
+    run_task = one("select id from tasks where goal_id = %d"
+                   " and kind = 'goal_run'" % goal_id)
+    harness.wait_for(
+        lambda: (one("select status from tasks where id = %d" % run_task)
+                 == "blocked" and "yes") or False,
+        timeout=60, what="the audit goal agent parks on its plan review")
+    # One answered plan revision (the history fold surface).
+    sql("insert into task_blocks (task_id, type, question, answer,"
+        " answered_at, answered_by, created_at) values (?, 'plan',"
+        " '# Earlier plan revision\n\nOlder body.', 'Add a step for"
+        " the mobile audit, please.', %s, 'user:admin', %s)" % (now, now),
+        (run_task,))
+
+    # -- stacks --------------------------------------------------------
+    stacks_create = harness.slot_public(auth, "/stacks", "stacks")["create"]
+    harness.act(auth, stacks_create, {"name": "audit-stack"})
+    stack_id = one("select id from stacks where name = 'audit-stack'")
+
+    # -- rules ---------------------------------------------------------
+    rules_create = harness.slot_public(auth, "/rules", "rules")["create"]
+    harness.act(auth, rules_create, {
+        "name": "audit-rule",
+        "body": "Always build with meson before running the suite; the"
+        " canonical builddir is ./builddir for canon_deploy."})
+
+    # -- chats (advisor) -------------------------------------------------
+    sql("insert into advisor_chats (user_id, title, status, created_at,"
+        " updated_at) values (1, 'Audit chat about layout widths',"
+        " 'active', %s, %s)" % (now, now))
+    chat_id = one("select max(id) from advisor_chats")
+    for kind, content in (
+            ("user", "What widths should the audit check for horizontal"
+             " overflow?"),
+            ("assistant", "360 for phones, 768 for tablets, 1280 for"
+             " desktops — the audit probes all three."),
+            ("question", "Which bundler do you use for the audit wire?")):
+        sql("insert into advisor_messages (chat_id, kind, content,"
+            " created_at) values (?, ?, ?, %s)" % now,
+            (chat_id, kind, content))
+
+    # -- discoveries ----------------------------------------------------
+    sql("insert into discoveries (body, source_task_id, created_at,"
+        " updated_at) values (?, ?, %s, %s)" % (now, now),
+        ("**Chromium headless tip** — the CDP probe pattern: seed the"
+         " statum auth frame into localStorage via"
+         " Page.addScriptToEvaluateOnNewDocument before statum.js runs"
+         " and every page hydrates fully. body/"
+         "wouldyouwrapthislongunbrokentoken"
+         "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAnotherExtremelyLongKey"
+         "ForWrapTesting user@host", task_id))
+
+    return {"task_id": task_id, "goal_id": goal_id,
+            "project_id": 2, "stack_id": stack_id, "chat_id": chat_id}
+
+
+def run_audit(app, widths, routes=None, json_out=None, keep=False):
+    chromium = find_chromium()
+    if not chromium:
+        print("SKIP no chromium binary found (looked for: %s)"
+              % ", ".join(CHROME_CANDIDATES))
+        return None
+
+    ctx = harness.start_bundle("t12mobile")
+    harness.CTX = ctx  # goal_state() reads the module's current context
+    try:
+        ids = seed(ctx)
+    except Exception:
+        # Never orphan the app process on a seeding failure — a later
+        # free_port() TOCTOU then kills the NEXT run's app at bind.
+        try:
+            ctx.app.terminate()
+            ctx.app.wait(timeout=10)
+        except OSError:
+            pass
+        raise
+    auth = ctx.auth
+
+    # Re-derive the auth frame exactly as the browser would hold it:
+    # the login POST's set directive carries snapshot_frame{content}.
+    frames = harness.directives("/login")
+    content = json.loads(frames[0]["snapshot_frame"]["content"])
+    login_action = content["public"]["login"]
+    status, body = harness.http("POST", login_action["uri"],
+                                {"username": "admin",
+                                 "password": "adminpass123"})
+    auth_frame = None
+    for directive in json.loads(body):
+        if directive["type"] == "set":
+            frame = json.loads(directive["snapshot_frame"]["content"])
+            if frame["slot"]["type"] == "auth":
+                auth_frame = directive["snapshot_frame"]
+    if auth_frame is None:
+        raise SystemExit("no auth frame in login response")
+
+    port = harness.free_port()
+    chrome = Chrome(chromium, port)
+    try:
+        chrome.connect()
+        # Seed localStorage BEFORE any page script runs. The frame is
+        # the exact JSON statum.js persists (persistEntry: the frame,
+        # not the snapshot).
+        chrome.add_init_script(
+            "try { localStorage.setItem('statum:auth', %s); } catch (e) {}"
+            % json.dumps(json.dumps(auth_frame)))
+        chrome.set_viewport(widths[0])
+
+        all_routes = [r.format(**ids) for r in
+                      (routes or (list(ROUTE_PAGES) + list(DETAIL_PAGES)))]
+        findings = {}
+        for route in all_routes:
+            findings[route] = {}
+            for width in widths:
+                chrome.set_viewport(width)
+                ok = chrome.goto(harness.WEB + route)
+                if not ok:
+                    findings[route][width] = {"error": "navigation halted"}
+                    continue
+                findings[route][width] = chrome.probe() or {}
+                findings[route][width]["url"] = route
+                findings[route][width]["width"] = width
+        if json_out:
+            with open(json_out, "w") as handle:
+                json.dump(findings, handle, indent=1)
+        return findings
+    finally:
+        chrome.close()
+        if not keep:
+            try:
+                ctx.app.terminate()
+                ctx.app.wait(timeout=10)
+            except OSError:
+                pass
+            try:
+                ctx.fake.shutdown()
+            except AttributeError:
+                pass
+
+
+def summarise(findings):
+    """Prints the per-page/width report; returns a flat finding list."""
+    rows = []
+    dialog_bad = []
+    print("\n%-34s %6s %8s  %s" % ("route", "width", "pageDX", "offenders"))
+    for route in sorted(findings):
+        for width in sorted(findings[route]):
+            data = findings[route][width]
+            if "error" in data:
+                print("%-34s %6d  ERROR %s" % (route, width, data["error"]))
+                rows.append((route, width, "error", data["error"]))
+                continue
+            page_dx = data.get("pageOverflowX") or 0
+            offenders = data.get("offenders") or []
+            touch = data.get("touch") or []
+            for dialog in data.get("dialogs") or []:
+                if not dialog.get("fits", True):
+                    dialog_bad.append(
+                        (route, width, "dialog %s spills (right=%s)"
+                         % (dialog.get("id"), dialog.get("right"))))
+            mark = "" if (page_dx <= 0 and not offenders) else "  <-- FIX"
+            print("%-34s %6d %8d %3d%s" % (route, width, page_dx,
+                                           len(offenders), mark))
+            for offender in offenders:
+                rows.append((route, width, "overflow",
+                             "%s right=%d w=%d (%+d) pos=%s ws=%s"
+                             % (offender["path"], offender.get("right", 0),
+                                offender.get("width", 0), offender["over"],
+                                offender.get("pos"), offender.get("white"))))
+            for target in touch:
+                rows.append((route, width, "touch",
+                             "%s h=%dpx" % (target["path"], target["h"])))
+    for route, width, note in dialog_bad:
+        rows.append((route, width, "dialog", note))
+        print("  DIALOG   %s @%s %s" % (route, width, note))
+    return rows
+
+
+def main():
+    app = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") \
+        else os.path.join(harness.REPO, "builddir", "clanker-saddle")
+    sys.argv = [a for a in sys.argv
+                if a != app or sys.argv.index(a) == 0]
+    widths = DEFAULT_WIDTHS
+    routes = None
+    json_out = os.environ.get("MOBILE_AUDIT_JSON")
+    keep = False
+    args = sys.argv[1:]
+    i = 0
+    while i < len(args):
+        if args[i] == "--widths":
+            widths = [int(w) for w in args[i + 1].split(",")]
+            i += 2
+        elif args[i] == "--routes":
+            routes = args[i + 1].split(",")
+            i += 2
+        elif args[i] == "--json":
+            json_out = args[i + 1]
+            i += 2
+        elif args[i] == "--keep":
+            keep = True
+            i += 1
+        else:
+            i += 1
+    if not os.path.exists(app):
+        raise SystemExit("app binary not found at %s (build first)" % app)
+    findings = run_audit(app, widths, routes, json_out, keep)
+    if findings is None:
+        return 0
+    rows = summarise(findings)
+    overflow = [r for r in rows if r[2] == "overflow"]
+    dialogs = [r for r in rows if r[2] == "dialog"]
+    touch = [r for r in rows if r[2] == "touch"]
+    print("\nSUMMARY: %d overflow findings, %d dialog-fit findings,"
+          " %d sub-44px touch targets"
+          % (len(overflow), len(dialogs), len(touch)))
+    for row in overflow:
+        print("  OVERFLOW %s @%s %s" % (row[0], row[1], row[3]))
+    for row in dialogs:
+        print("  DIALOG   %s @%s %s" % (row[0], row[1], row[3]))
+    for row in touch:
+        print("  TOUCH    %s @%s %s" % (row[0], row[1], row[3]))
+    return 1 if (overflow or dialogs) else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 125 - 0
test/mobile_bisect.py

@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""One-off diagnostic: which subtree creates the page-level +N px?
+
+Bisects by hiding top-level subtrees (topbar vs main etc.) and reading
+documentElement.scrollWidth, then descends into the guilty container's
+children. Prints the culprit chain. Not part of the bundle; a T12
+development aid kept for reproducibility.
+"""
+import json
+import os
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+
+import mobile_audit as ma  # noqa: E402
+import harness  # noqa: E402
+
+if "WEBSTACK_WORK" not in os.environ:
+    os.environ["WEBSTACK_WORK"] = os.path.join(
+        harness.REPO, "build", "ws-mobile")
+
+
+def bisect(chrome):
+    js = r"""
+(() => {
+  const de = document.documentElement;
+  const base = de.scrollWidth - de.clientWidth;
+  const report = {base: base, steps: []};
+  const direct = [];
+  // step 1: each direct child of body toggled alone
+  for (const child of document.body.children) {
+    const prev = child.style.display;
+    child.style.display = 'none';
+    report.steps.push({hide: child.tagName + '.' + child.className,
+                       dx: de.scrollWidth - de.clientWidth});
+    child.style.display = prev;
+  }
+  // step 2: every element with scrollWidth > clientWidth (visible ox),
+  // skipping elements inside a scroll container
+  const inScroller = (el) => {
+    for (let a = el.parentElement; a; a = a.parentElement) {
+      const ox = getComputedStyle(a).overflowX;
+      if (ox !== 'visible') return true;
+    }
+    return false;
+  };
+  for (const el of document.querySelectorAll('*')) {
+    if (inScroller(el)) continue;
+    const cs = getComputedStyle(el);
+    if (cs.position === 'fixed') continue;
+    if (el.scrollWidth - el.clientWidth > 4 && el.clientWidth > 0) {
+      direct.push({el: el.tagName + '.' + (el.className || ''),
+                   over: el.scrollWidth - el.clientWidth,
+                   rectRight: Math.round(el.getBoundingClientRect().right)});
+    }
+  }
+  // step 3: text nodes whose range right edge exceeds the viewport
+  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
+  const texts = [];
+  let node;
+  while ((node = walker.nextNode())) {
+    const range = document.createRange();
+    range.selectNodeContents(node);
+    const rects = range.getClientRects();
+    for (const r of rects) {
+      if (r.width > 0 && r.right > de.clientWidth + 0.5) {
+        texts.push({text: node.textContent.slice(0, 60),
+                    right: Math.round(r.right),
+                    parent: node.parentElement.tagName + '.'
+                            + (node.parentElement.className || '')});
+        break;
+      }
+    }
+  }
+  report.direct = direct.slice(0, 20);
+  report.texts = texts.slice(0, 20);
+  return report;
+})()
+"""
+    return chrome._eval(js)
+
+
+def main():
+    route = sys.argv[1] if len(sys.argv) > 1 else "/rules"
+    width = int(sys.argv[2]) if len(sys.argv) > 2 else 360
+    chromium = ma.find_chromium()
+    ctx = harness.start_bundle("t12dbg")
+    harness.CTX = ctx
+    try:
+        ids = ma.seed(ctx)
+        # fresh login frame for the browser
+        frames = harness.directives("/login")
+        content = json.loads(frames[0]["snapshot_frame"]["content"])
+        action = content["public"]["login"]
+        status, body = harness.http("POST", action["uri"],
+                                    {"username": "admin",
+                                     "password": "adminpass123"})
+        frame = None
+        for directive in json.loads(body):
+            if directive["type"] == "set":
+                candidate = json.loads(directive["snapshot_frame"]["content"])
+                if candidate["slot"]["type"] == "auth":
+                    frame = directive["snapshot_frame"]
+        chrome = ma.Chrome(chromium, harness.free_port())
+        try:
+            chrome.connect()
+            chrome.add_init_script(
+                "try { localStorage.setItem('statum:auth', %s); } catch (e) {}"
+                % json.dumps(json.dumps(frame)))
+            chrome.set_viewport(width)
+            chrome.goto(harness.WEB + route)
+            print(json.dumps(bisect(chrome), indent=1))
+        finally:
+            chrome.close()
+    finally:
+        try:
+            ctx.app.terminate()
+            ctx.app.wait(timeout=10)
+        except OSError:
+            pass
+
+
+if __name__ == "__main__":
+    main()