소스 검색

QA sweep: relative links, aria-current, footer parity, QA tooling

Site-wide QA pass across all 13 pages.

Fixes found by the sweep:
- index.html used root-relative links (/, /developer/) in the masthead,
  hero CTAs and footer; all are now page-relative so the tree deploys
  at any URL path without modification.
- index.html masthead Home link was missing aria-current="page" (the
  developer tree already marked Developer correctly).
- index.html colophon lacked the LGPL sentence the twelve developer
  pages carry; now identical everywhere.
- README structure section predated the developer section and the
  tools/ directory; updated, with a note on any-path deployment.

New QA tooling (tools/):
- qa-crawl.py serves the site and crawls every subresource of every
  linked page, asserting every request stays on 127.0.0.1 (catches CDN,
  Google Fonts, picsum leftovers) and that all assets resolve 200.
  Handles data: URIs, CSS url() font references and fragment links.
- qa-consistency.py programmatic 13-page audit: masthead nav identity
  (hrefs canonicalised across directory depths), footer semantic
  identity, head meta set with unique titles, aria-current placement,
  pager chain through all 11 detail pages in catalogue order ending at
  the developer index, breadcrumb trails, root-relative link audit and
  a11y spot checks (skip link first focusable, one h1, alt on images).
- qa-pages.py extended from 3 pages to all 13 and now asserts the
  sticky ToC (sticky + beside prose in the two-column layout, static in
  the single-column layout).

All green: check.py 13 pages; crawl zero external requests; 13x2x3
render sweep with zero overflow, zero broken images, light/dark body
background flip confirmed, ToC sticky behaviour correct.
saddle-ClankerSaddle 2 주 전
부모
커밋
160c8d43b2
5개의 변경된 파일과 606개의 추가작업 그리고 11개의 파일을 삭제
  1. 11 1
      README.md
  2. 8 8
      index.html
  3. 350 0
      tools/qa-consistency.py
  4. 186 0
      tools/qa-crawl.py
  5. 51 2
      tools/qa-pages.py

+ 11 - 1
README.md

@@ -8,10 +8,19 @@ the original site.
 ## Structure
 
     index.html              home page
+    developer/              Developer section: index.html (catalogue) + one
+                            documentation page per library (11 projects)
     assets/css/style.css    shared design system (light + dark via prefers-color-scheme)
     assets/img/             logo (light/dark), Milky Way hero, star and people photography
     assets/fonts/           self-hosted fonts (woff2, latin subset) + SIL Open Font License texts
     check.py                site checker: python3 check.py . (links, assets, no external refs)
+    tools/qa-crawl.py       serves the site and crawls every subresource, asserting
+                            every request stays on 127.0.0.1 (no CDN, no external fonts)
+    tools/qa-consistency.py programmatic 13-page audit: masthead/footer identity,
+                            aria-current placement, head meta set, pager chain,
+                            breadcrumbs, root-relative links, a11y spot checks
+    tools/qa-pages.py       headless-Chromium render sweep (overflow, broken images,
+                            light/dark body colour flip, sticky ToC) via CDP
     LICENSE                 GPL-3.0-or-later (full text)
     Original/               reference design — the original live site (do not edit)
     Proposed/               reference design — the AI-generated proposal (do not edit)
@@ -27,7 +36,8 @@ There is no build step. Copy (or serve) the repository root:
 
 Any static file server will do. The site makes **zero external requests** —
 fonts are local, images are local — so it works offline and on community
-networks as-is.
+networks as-is. All links are page-relative, so the tree can be deployed
+at any URL path (document root or a subdirectory) without modification.
 
 ## Conventions
 

+ 8 - 8
index.html

@@ -19,16 +19,16 @@
 
   <header class="masthead">
     <div class="container masthead__inner">
-      <a class="brand" href="/">
+      <a class="brand" href="./">
         <img class="brand__logo brand__logo--light" src="assets/img/astrologue-light.png" width="392" height="89" alt="Astrologue">
         <img class="brand__logo brand__logo--dark" src="assets/img/astrologue-dark.png" width="392" height="89" alt="Astrologue">
       </a>
       <nav class="site-nav" aria-label="Primary">
         <ul class="site-nav__list">
-          <li><a href="/">Home</a></li>
+          <li><a href="./" aria-current="page">Home</a></li>
           <li><a href="#news">News</a></li>
           <li><a href="#download">Download</a></li>
-          <li><a href="/developer/">Developer</a></li>
+          <li><a href="developer/">Developer</a></li>
           <li><a class="nav-cta" href="#download">Get Astrologue</a></li>
         </ul>
       </nav>
@@ -48,7 +48,7 @@
           <p class="lede dropcap">Astrologue aims to prove paradigm-shifting concepts in personal computing and put the power of general purpose computers back into the hands of regular people — an exciting project for anyone wanting to reduce their reliance on big tech.</p>
           <div class="hero__actions">
             <a class="btn btn--primary" href="#download">Download Astrologue</a>
-            <a class="btn btn--ghost" href="/developer/">Explore the technology</a>
+            <a class="btn btn--ghost" href="developer/">Explore the technology</a>
           </div>
           <p class="hero__note">Free software · GPL-3.0-or-later · developed in the open</p>
         </div>
@@ -136,7 +136,7 @@
           <h2>Download Astrologue</h2>
           <p class="cta-band__lede">Astrologue has not published a release yet. When it does, every image will appear here with its size, SHA-256 checksum, and a Minisign signature — no mirrors to trust blindly, no artifacts without a name.</p>
           <div class="hero__actions">
-            <a class="btn btn--ghost" href="/developer/">Explore the technology</a>
+            <a class="btn btn--ghost" href="developer/">Explore the technology</a>
           </div>
           <!-- Template: when a release ships, replace the row below with one row
                per artifact, linking the real filename and its .minisign file.
@@ -191,14 +191,14 @@
         <nav aria-label="Site">
           <p class="foot-title">Site</p>
           <ul class="foot-list">
-            <li><a href="/">Home</a></li>
+            <li><a href="./">Home</a></li>
             <li><a href="#news">News</a></li>
             <li><a href="#download">Download</a></li>
-            <li><a href="/developer/">Developer</a></li>
+            <li><a href="developer/">Developer</a></li>
           </ul>
         </nav>
       </div>
-      <p class="colophon">Set in Cinzel and EB Garamond (SIL Open Font License). Astrologue is free software, licensed GPL-3.0-or-later: take it, tend it, pass it on.</p>
+      <p class="colophon">Set in Cinzel and EB Garamond (SIL Open Font License). Astrologue is free software, licensed GPL-3.0-or-later: take it, tend it, pass it on. The libraries are LGPL-3.0-or-later, so they remain free to link.</p>
     </div>
   </footer>
 </body>

+ 350 - 0
tools/qa-consistency.py

@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+"""Site-wide consistency audit across all pages.
+
+Programmatic (not eyeball) checks:
+  1. masthead nav items and order identical across pages (paths normalised
+     to site-root-relative so differing ../ depths compare equal)
+  2. footer columns / freedoms / licence line semantically identical
+     (footer "Site" Home href also normalised)
+  3. aria-current="page" on exactly one masthead nav item, the RIGHT one
+     (Home on /, Developer across the developer tree)
+  4. head meta set: charset, viewport, UNIQUE title, description,
+     color-scheme, theme-color light+dark, favicon, og:title/description/type
+  5. pager chain across detail pages in catalogue order, ending at the
+     developer index
+  6. root-relative link audit (site should deploy at any path)
+  7. breadcrumb trail on developer pages
+  8. one h1 per page, skip-link first focusable, images have alt
+
+Usage: python3 tools/qa-consistency.py [repo-root]
+"""
+
+import pathlib
+import re
+import sys
+from html.parser import HTMLParser
+
+ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
+
+PAGES = ["index.html",
+         "developer/index.html",
+         "developer/invercargill/index.html",
+         "developer/invercargill-json/index.html",
+         "developer/inversion/index.html",
+         "developer/invercargill-sql/index.html",
+         "developer/invercargill-sql-inversion/index.html",
+         "developer/astralis/index.html",
+         "developer/statum/index.html",
+         "developer/spry/index.html",
+         "developer/locksmith/index.html",
+         "developer/vallama/index.html",
+         "developer/usm/index.html"]
+
+CATALOGUE = ["invercargill", "invercargill-json", "inversion",
+             "invercargill-sql", "invercargill-sql-inversion",
+             "astralis", "statum", "spry", "locksmith", "vallama", "usm"]
+
+errors = []
+
+
+def rootify(page_path, ref):
+    """Normalise a page-relative URL to a site-root-relative path."""
+    if ref.startswith(("http", "data:", "mailto:", "#", "//")):
+        return ref
+    if ref.startswith("/"):
+        return ref  # already root-relative
+    base = pathlib.PurePosixPath(page_path).parent
+    p = base / ref
+    parts = []
+    for seg in p.parts:
+        if seg == "..":
+            if parts:
+                parts.pop()
+        elif seg not in (".", ""):
+            parts.append(seg)
+    return "/".join(parts)
+
+
+def canon(page_path, ref):
+    """Canonical form for comparing link targets: root-relative, with
+    directory links and index.html pages normalised to the same key."""
+    r = rootify(page_path, ref)
+    if r.startswith(("#", "http", "data:", "mailto:")):
+        return r
+    if r.endswith("/index.html"):
+        r = r[: -len("index.html")]
+    elif r and not r.endswith("/"):
+        r += "/"
+    return r
+
+
+class PageParser(HTMLParser):
+    def __init__(self, path):
+        super().__init__(convert_charrefs=True)
+        self.path = path
+        self.metas = []
+        self.favicon = None
+        self.nav_links = []       # (label, href, aria_current)
+        self.footer_html = []
+        self.in_masthead = False
+        self.in_footer = False
+        self.h1s = 0
+        self.imgs_no_alt = 0
+        self.first_focusable = None
+        self.breadcrumbs = []     # (label, href or None)
+        self.in_breadcrumb = False
+        self.pager = {}           # 'prev'/'next' -> href
+        self.root_relative = []
+        self._pend_nav = None
+        self._pend_bc = None
+
+    # -- attribute-level harvesting (works for start and void tags) -----
+    def _attrs(self, tag, attrs):
+        d = dict(attrs)
+        classes = set(d.get("class", "").split())
+
+        if tag in ("a", "button", "input", "select", "textarea") and \
+                self.first_focusable is None and d.get("tabindex") != "-1":
+            self.first_focusable = (tag, d.get("class", "") or "",
+                                    d.get("href", "") or "")
+
+        if tag == "a":
+            href = d.get("href", "")
+            if href.startswith("/") and not href.startswith("//"):
+                self.root_relative.append(href)
+            if self.in_masthead:
+                self._pend_nav = [href, d.get("aria-current"), ""]
+            if self.in_breadcrumb:
+                self._pend_bc = [href, ""]
+            if "docs-pager__link--prev" in classes:
+                self.pager["prev"] = href
+            if "docs-pager__link--next" in classes:
+                self.pager["next"] = href
+
+        if tag == "img" and "alt" not in d:
+            self.imgs_no_alt += 1
+        if tag == "h1":
+            self.h1s += 1
+        if tag == "meta":
+            if d.get("charset"):
+                self.metas.append(("charset", "charset", d["charset"]))
+            for k in ("name", "property"):
+                if d.get(k):
+                    self.metas.append((k, d[k], d.get("content", "")))
+        if tag == "link":
+            rel = (d.get("rel") or "").lower()
+            if "icon" in rel.split():
+                self.favicon = d.get("href")
+            elif "stylesheet" in rel.split():
+                self.metas.append(("link", "stylesheet", d.get("href", "")))
+        if tag == "header" and "masthead" in classes:
+            self.in_masthead = True
+        if tag == "footer" and "site-footer" in classes:
+            self.in_footer = True
+        if tag == "nav" and "breadcrumb" in classes:
+            self.in_breadcrumb = True
+
+    def handle_starttag(self, tag, attrs):
+        self._attrs(tag, attrs)
+        if self.in_footer:
+            self.footer_html.append(self.get_starttag_text() or "")
+
+    def handle_startendtag(self, tag, attrs):
+        self._attrs(tag, attrs)
+        if self.in_footer:
+            self.footer_html.append(self.get_starttag_text() or "")
+
+    def handle_endtag(self, tag):
+        if tag == "header":
+            self.in_masthead = False
+        if tag == "footer":
+            self.in_footer = False
+        if tag == "nav":
+            self.in_breadcrumb = False
+        if self.in_footer:
+            self.footer_html.append(f"</{tag}>")
+
+    def handle_data(self, data):
+        if self.in_footer:
+            self.footer_html.append(data)
+        t = data.strip()
+        if not t:
+            return
+        if self._pend_nav is not None:
+            self.nav_links.append((t, self._pend_nav[0], self._pend_nav[1]))
+            self._pend_nav = None
+        if self._pend_bc is not None:
+            self.breadcrumbs.append((t, self._pend_bc[0]))
+            self._pend_bc = None
+
+
+def normalise_footer(parts, page_path):
+    """Serialise captured footer HTML with page-relative hrefs/srcs
+    rewritten root-relative so footers from any depth compare equal."""
+    html = re.sub(r"\s+", " ", "".join(parts)).strip()
+
+    def sub(m):
+        return f'{m.group(1)}="{rootify(page_path, m.group(2))}"'
+    return re.sub(r'(href|src)="([^"]*)"', sub, html)
+
+
+def main():
+    pages = {}
+    for rel in PAGES:
+        f = ROOT / rel
+        if not f.exists():
+            errors.append(f"missing page {rel}")
+            continue
+        text = f.read_text(encoding="utf-8")
+        p = PageParser(rel)
+        p.feed(text)
+        pages[rel] = p
+
+    # ---- 1 + 3: masthead nav identity + aria-current placement
+    ref_nav = None
+    for rel, p in pages.items():
+        sig = [(lbl, canon(rel, href)) for lbl, href, _ in p.nav_links]
+        if ref_nav is None:
+            ref_nav = sig
+        elif sig != ref_nav:
+            errors.append(f"nav differs on {rel}:\n"
+                          f"    got      {sig}\n    expected {ref_nav}")
+        cur = [n for n in p.nav_links if n[2] == "page"]
+        if len(cur) != 1:
+            errors.append(f"{rel}: {len(cur)} aria-current=page in nav "
+                          f"(expected 1): {cur}")
+        else:
+            expect = "Home" if rel == "index.html" else "Developer"
+            if cur[0][0] != expect:
+                errors.append(f"{rel}: aria-current on '{cur[0][0]}', "
+                              f"expected '{expect}'")
+
+    # ---- 2: footer identity
+    ref_footer = None
+    for rel, p in pages.items():
+        if not p.footer_html:
+            errors.append(f"{rel}: no footer captured")
+            continue
+        norm = normalise_footer(p.footer_html, rel)
+        if ref_footer is None:
+            ref_footer = norm
+        elif norm != ref_footer:
+            errors.append(f"footer differs on {rel}")
+
+    # ---- 4: head meta
+    titles = {}
+    for rel, p in pages.items():
+        m = {}
+        for kind, key, content in p.metas:
+            m.setdefault(f"{kind}:{key}", []).append(content)
+        text = (ROOT / rel).read_text(encoding="utf-8")
+        t = re.search(r"<title[^>]*>(.*?)</title>", text, re.S)
+        if not t:
+            errors.append(f"{rel}: no <title>")
+        else:
+            title = t.group(1).strip()
+            if title in titles:
+                errors.append(f"duplicate title '{title}' on "
+                              f"{titles[title]} and {rel}")
+            titles[rel] = title
+        if not any(k.startswith("charset:") for k in m):
+            errors.append(f"{rel}: no charset meta")
+        for k in ("name:viewport", "name:description", "name:color-scheme",
+                  "name:theme-color", "property:og:title",
+                  "property:og:description", "property:og:type"):
+            if k not in m:
+                errors.append(f"{rel}: missing {k}")
+        tc = m.get("name:theme-color", [])
+        if len(tc) != 2:
+            errors.append(f"{rel}: expected 2 theme-color metas "
+                          f"(light+dark), got {len(tc)}")
+        if p.favicon is None:
+            errors.append(f"{rel}: no favicon link")
+
+    # ---- 5: pager chain
+    detail = [f"developer/{s}/index.html" for s in CATALOGUE]
+    detail_keys = [canon(d, ".") for d in detail]
+    order, seen = [], set()
+    cur = detail[0]
+    while cur in pages:
+        if cur in seen:
+            errors.append(f"pager cycle at {cur}")
+            break
+        seen.add(cur)
+        order.append(cur)
+        nxt = pages[cur].pager.get("next")
+        if not nxt:
+            break
+        cur_key = canon(cur, nxt)
+        match = [d for d in detail if canon(d, ".") == cur_key]
+        cur = match[0] if match else cur_key
+    if order != detail:
+        errors.append(f"pager chain wrong:\n    got      {order}\n"
+                      f"    expected {detail}")
+    else:
+        last_next = pages[order[-1]].pager.get("next")
+        end = canon(order[-1], last_next) if last_next else None
+        print(f"pager chain OK: 11 pages in catalogue order, "
+              f"last next -> {end}")
+        if end != canon("developer/index.html", "."):
+            errors.append(f"pager chain does not end at developer index "
+                          f"(ends at {end})")
+    for i, pg in enumerate(detail):
+        if pg not in pages:
+            continue
+        got_prev = pages[pg].pager.get("prev")
+        if i == 0:
+            if got_prev is not None:
+                errors.append(f"{pg}: unexpected prev link {got_prev}")
+        elif not got_prev or canon(pg, got_prev) != detail_keys[i - 1]:
+            errors.append(f"{pg}: prev is {got_prev}, "
+                          f"expected {detail[i-1]}")
+
+    # ---- 6: root-relative links
+    for rel, p in pages.items():
+        for href in p.root_relative:
+            errors.append(f"root-relative link {href} on {rel}")
+
+    # ---- 7: breadcrumbs
+    for rel, p in pages.items():
+        if rel == "index.html":
+            if p.breadcrumbs:
+                errors.append(f"{rel}: unexpected breadcrumbs")
+            continue
+        if rel == "developer/index.html":
+            exp = [("Astrologue", "..")]
+        else:
+            exp = [("Astrologue", "../.."), ("Developer", "..")]
+        got = p.breadcrumbs
+        if [g[0] for g in got] != [e[0] for e in exp]:
+            errors.append(f"{rel}: breadcrumb labels "
+                          f"{[g[0] for g in got]} != {[e[0] for e in exp]}")
+        else:
+            for (gl, gh), (el, eh) in zip(got, exp):
+                if rootify(rel, gh) != rootify(rel, eh + "/"):
+                    errors.append(f"{rel}: breadcrumb '{gl}' href {gh} "
+                                  f"!= expected {eh}/")
+
+    # ---- 8: a11y spot checks
+    for rel, p in pages.items():
+        if not p.first_focusable or \
+                "skip-link" not in p.first_focusable[1]:
+            errors.append(f"{rel}: first focusable is not the skip link: "
+                          f"{p.first_focusable}")
+        if p.h1s != 1:
+            errors.append(f"{rel}: {p.h1s} h1 elements (expected 1)")
+        if p.imgs_no_alt:
+            errors.append(f"{rel}: {p.imgs_no_alt} img without alt")
+
+    if errors:
+        print(f"\n{len(errors)} ERROR(S):")
+        for e in errors:
+            print(f"  - {e}")
+        sys.exit(1)
+    print(f"OK: {len(pages)} pages consistent "
+          f"(nav, footer, meta, aria-current, pager, breadcrumbs, "
+          f"no root-relative links, a11y)")
+
+
+if __name__ == "__main__":
+    main()

+ 186 - 0
tools/qa-crawl.py

@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""QA crawl: serve the site locally and fetch every subresource of every page.
+
+Asserts that every request made while crawling goes to 127.0.0.1 — no CDN,
+no Google Fonts, no picsum leftovers. Pages are discovered by following
+local links from / so the crawl covers whatever is linked. CSS url(...)
+references (e.g. @font-face src) are fetched too.
+
+data:/mailto:/tel: URIs are self-contained by definition — never fetched,
+never counted as external. Fragment-only links are same-page anchors.
+
+Usage: python3 tools/qa-crawl.py [repo-root]
+"""
+
+import http.server
+import os
+import pathlib
+import re
+import socketserver
+import sys
+import threading
+import urllib.parse
+import urllib.request
+from html.parser import HTMLParser
+
+ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
+PORT = int(os.environ.get("QA_CRAWL_PORT", "0"))  # 0 = ephemeral
+
+RESOURCE_ATTRS = {"link": ["href"], "script": ["src"], "img": ["src", "srcset"],
+                  "source": ["src", "srcset"], "video": ["src", "poster"],
+                  "audio": ["src"], "track": ["src"], "embed": ["src"],
+                  "object": ["data"], "use": ["href", "xlink:href"]}
+NAV_ATTRS = {"a": ["href"], "area": ["href"], "form": ["action"]}
+
+SELF_CONTAINED = ("data:", "mailto:", "tel:", "javascript:")
+
+
+class Collector(HTMLParser):
+    def __init__(self):
+        super().__init__(convert_charrefs=True)
+        self.resources, self.links = [], []
+
+    def handle_starttag(self, tag, attrs):
+        d = dict(attrs)
+        if tag in RESOURCE_ATTRS:
+            for a in RESOURCE_ATTRS[tag]:
+                v = d.get(a)
+                if v:
+                    if a == "srcset":
+                        for part in v.split(","):
+                            self.resources.append(part.strip().split()[0])
+                    else:
+                        self.resources.append(v)
+        if tag in NAV_ATTRS:
+            v = d.get(NAV_ATTRS[tag][0])
+            if v:
+                self.links.append(v)
+
+
+class QuietHandler(http.server.SimpleHTTPRequestHandler):
+    def log_message(self, fmt, *args):
+        pass  # the crawler records requests itself
+
+
+def fetch(url):
+    req = urllib.request.Request(url, headers={"User-Agent": "qa-crawl/1.0"})
+    with urllib.request.urlopen(req, timeout=10) as r:
+        return r.status, r.read(), r.geturl()
+
+
+def norm(base, ref):
+    """Resolve ref against base; return (abs_url, fetchable_local)."""
+    if ref.startswith(SELF_CONTAINED):
+        return ref, True  # self-contained, nothing to fetch
+    p = urllib.parse.urlparse(ref)
+    if p.scheme or p.netloc:  # absolute — the thing we are hunting
+        return ref, False
+    return urllib.parse.urljoin(base, ref), True
+
+
+def defrag(u):
+    return u.split("#", 1)[0]
+
+
+seen_pages, seen_res, external, failures = set(), set(), [], []
+
+
+def fetch_resource(absu, referer):
+    if absu in seen_res:
+        return
+    seen_res.add(absu)
+    if absu.startswith(SELF_CONTAINED):
+        return  # self-contained, nothing to fetch
+    try:
+        st, body, fin = fetch(absu)
+        if st != 200:
+            failures.append(f"asset {absu} (on {referer}): HTTP {st}")
+            return
+        if absu.endswith(".css"):
+            # fetch url(...) references inside the stylesheet (fonts etc.);
+            # strip data: URIs first (respecting the quote style actually
+            # used) so nested url(#frag) refs inside them are not mistaken
+            # for stylesheet references
+            css = body.decode("utf-8", "replace")
+            css = re.sub(r'url\(\s*"(?:[^"]*?data:[^"]*?)"\s*\)', "url()", css)
+            css = re.sub(r"url\(\s*'(?:[^']*?data:[^']*?)'\s*\)", "url()", css)
+            css = re.sub(r"url\(\s*data:[^'\")]*\s*\)", "url()", css)
+            for ref in re.findall(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", css):
+                if ref.startswith(SELF_CONTAINED) or ref.startswith(("#", "%23")):
+                    continue
+                a, local = norm(absu, ref)
+                if not local:
+                    external.append(f"css url() {ref} (in {absu})")
+                    continue
+                fetch_resource(a, absu)
+    except Exception as e:
+        failures.append(f"asset {absu} (on {referer}): {e}")
+
+
+def crawl(start):
+    queue = [start]
+    while queue:
+        url = defrag(queue.pop(0))
+        if not url or url in seen_pages:
+            continue
+        try:
+            status, body, final = fetch(url)
+        except Exception as e:
+            failures.append(f"page {url}: {e}")
+            continue
+        if status != 200:
+            failures.append(f"page {url}: HTTP {status}")
+            continue
+        seen_pages.add(final)
+        final = defrag(final)
+        if not final.endswith((".html", ".htm", "/")):
+            continue  # non-HTML subresource, nothing to parse
+        text = body.decode("utf-8", "replace")
+        c = Collector()
+        c.feed(text)
+        for ref in c.resources:
+            absu, local = norm(final, ref)
+            if not local:
+                external.append(f"resource {ref} (on {final})")
+                continue
+            fetch_resource(absu, final)
+        for ref in c.links:
+            absu, local = norm(final, ref)
+            if not local:
+                external.append(f"link {ref} (on {final})")
+                continue
+            queue.append(absu)
+
+    print(f"pages crawled : {len(seen_pages)}")
+    for u in sorted(seen_pages):
+        print(f"  {u}")
+    print(f"assets fetched: {len(seen_res)}")
+    for u in sorted(seen_res):
+        print(f"  {u}")
+    if external:
+        print("\nNON-LOCAL REFERENCES:")
+        for e in external:
+            print(f"  {e}")
+    if failures:
+        print("\nFAILURES:")
+        for f in failures:
+            print(f"  {f}")
+    if external or failures:
+        sys.exit(1)
+    print("\nOK: every request hit 127.0.0.1 only; all assets resolved 200")
+
+
+def main():
+    handler = lambda *a, **kw: QuietHandler(*a, directory=str(ROOT), **kw)
+    socketserver.TCPServer.allow_reuse_address = True
+    with socketserver.TCPServer(("127.0.0.1", PORT), handler) as httpd:
+        port = httpd.server_address[1]  # resolves the ephemeral port
+        threading.Thread(target=httpd.serve_forever, daemon=True).start()
+        try:
+            crawl(f"http://127.0.0.1:{port}/index.html")
+        finally:
+            httpd.shutdown()
+
+
+if __name__ == "__main__":
+    main()

+ 51 - 2
tools/qa-pages.py

@@ -1,11 +1,15 @@
 #!/usr/bin/env python3
-"""Headless QA for the three A5 pages: light/dark render + overflow at 390/768/1280.
+"""Headless QA for all site pages: light/dark render + overflow at 390/768/1280.
 
 CDP per discovery #82: --force-prefers-color-scheme is silently ignored, so
 Emulation.setEmulatedMedia is the only reliable scheme flip. Metrics are set
 BEFORE navigate so layout never happens at another width; uncontained <code>
 inside <pre> is filtered when hunting real page-level overflow.
 
+Doc pages also get a sticky-ToC assertion: in the two-column docs layout
+(wide viewport) the ToC must be position:sticky and sit beside the prose
+column; in the single-column layout (narrow viewport) it must be static.
+
 Works over the browser endpoint (Target.createTarget + flatten sessions),
 which sidesteps the flaky /json/new page-target websocket on this Chromium.
 """
@@ -19,6 +23,16 @@ import websocket
 
 PORT = 9333
 PAGES = [
+    ("home", "index.html"),
+    ("developer", "developer/index.html"),
+    ("invercargill", "developer/invercargill/index.html"),
+    ("invercargill-json", "developer/invercargill-json/index.html"),
+    ("inversion", "developer/inversion/index.html"),
+    ("invercargill-sql", "developer/invercargill-sql/index.html"),
+    ("invercargill-sql-inversion", "developer/invercargill-sql-inversion/index.html"),
+    ("astralis", "developer/astralis/index.html"),
+    ("statum", "developer/statum/index.html"),
+    ("spry", "developer/spry/index.html"),
     ("locksmith", "developer/locksmith/index.html"),
     ("vallama", "developer/vallama/index.html"),
     ("usm", "developer/usm/index.html"),
@@ -133,6 +147,20 @@ def main():
                                     over.push(el.tagName + '.' + (el.className||''));
                                 }
                             }
+                            const toc = document.querySelector('.docs-toc');
+                            let tocInfo = null;
+                            if (toc) {
+                                const cs = getComputedStyle(toc);
+                                const layout = getComputedStyle(
+                                    document.querySelector('.docs-layout'));
+                                const prose = document.querySelector('.docs-prose');
+                                tocInfo = {
+                                    pos: cs.position,
+                                    twoCol: layout.gridTemplateColumns.split(' ').length === 2,
+                                    besideProse: prose ? toc.getBoundingClientRect().left
+                                        >= prose.getBoundingClientRect().right - 1 : null,
+                                };
+                            }
                             return JSON.stringify({
                                 scrollW: de.scrollWidth, clientW: w,
                                 bg: getComputedStyle(document.body).backgroundColor,
@@ -140,6 +168,7 @@ def main():
                                 imgsBroken: [...document.images]
                                     .filter(i => !i.complete || i.naturalWidth === 0)
                                     .map(i => i.getAttribute('src')),
+                                toc: tocInfo,
                             });
                         })()""", "returnByValue": True})
                         ["result"]["value"])
@@ -153,13 +182,33 @@ def main():
                     if data["imgsBroken"]:
                         problems.append(
                             "broken imgs: " + ",".join(data["imgsBroken"]))
+                    toc = data.get("toc")
+                    if toc:
+                        # sticky ToC expectation: two-column layout at wide
+                        # widths -> sticky + right of prose; one-column at
+                        # narrow widths -> static
+                        two_col_layout = toc["twoCol"]
+                        if two_col_layout and w >= 1000:
+                            if toc["pos"] != "sticky":
+                                problems.append(
+                                    f"toc pos={toc['pos']} in 2-col @ {w}")
+                            if toc["besideProse"] is False:
+                                problems.append(
+                                    "toc not beside prose column")
+                        if not two_col_layout and toc["pos"] != "static":
+                            problems.append(
+                                f"toc pos={toc['pos']} in 1-col @ {w}")
                     if problems:
                         failures.append(label + ": " + "; ".join(problems))
                         print(f"FAIL {label}: {'; '.join(problems)}",
                               file=sys.stderr)
                     else:
+                        tocst = ""
+                        if data.get("toc"):
+                            tocst = (f" toc={data['toc']['pos']}"
+                                     f"{'/2col' if data['toc']['twoCol'] else '/1col'}")
                         print(f"ok   {label} bg={data['bg']} "
-                              f"{data['scrollW']}/{data['clientW']}")
+                              f"{data['scrollW']}/{data['clientW']}{tocst}")
                     bg_by_page.setdefault((name, w), {})[scheme] = data["bg"]
 
             cdp.call("Target.closeTarget", {"targetId": tid})