Skip to content

Layout Lint

#!/usr/bin/env python3
"""Live-browser layout lint: DOM-as-text findings instead of a screenshot.

Loads a running app's URL in a headless browser at each of several viewport
widths and inspects the rendered DOM/CSSOM for geometry and contrast issues
that don't require a human eye — viewport overflow, touch targets under 44px,
WCAG text contrast under 4.5:1, overlapping elements, siblings that should
share a left edge but don't, and font-size sprawl.

Usage:
    python layout_lint.py http://localhost:5007/app_name
    python layout_lint.py http://localhost:5007/app_name --widths 1400,768,390

Exits 0 with no output if clean. Exits 1 and prints one line per violation:
    [WIDTHpx] [CHECK_ID] message

Requires playwright (`pip install playwright && playwright install chromium`),
already a dependency of the screenshot loop in iterating-on-panel-apps.md.
Point it at an already-running `panel serve`; it doesn't start one.

Deliberately excluded: hierarchy, whitespace rhythm, whether the design looks
"templated" — anything needing taste rather than a threshold. This tool is the
numbers-only half; judging the rest needs an eye on a rendered page, so run it
first and spend a screenshot only on what it cannot reach (see the screenshot
step in iterating-on-panel-apps.md).
"""

from __future__ import annotations

import argparse
import re
import sys
from dataclasses import dataclass

DEFAULT_WIDTHS = (1400, 768, 390)
VIEWPORT_HEIGHT = 900
DEFAULT_TIMEOUT_MS = 30000
SETTLE_MS = 150  # post-load pause so transitions finish before geometry is read
MIN_TOUCH_TARGET = 44
MIN_CONTRAST_RATIO = 4.5
MAX_DISTINCT_FONT_SIZES = 6
ALIGNMENT_EPSILON = 2.0  # px
OVERFLOW_EPSILON = 1  # px, absorbs sub-pixel scrollWidth rounding
OVERLAP_MIN_FRACTION = 0.3  # of the smaller element's area
MIN_ALIGNED_SIBLINGS = 3  # below this, a left-edge difference isn't a pattern
MAX_REPORTED_CULPRITS = 3  # one overflow usually implicates many elements

RGB_RE = re.compile(r"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+))?\)")

# One flat list of visible elements carrying the geometry/style data every check
# needs. Kept to a single evaluate() call per width so the Python/JS round trip
# happens once, not once per check.
DOM_EXTRACT_JS = """
() => {
  function selectorFor(el) {
    const tag = el.tagName.toLowerCase();
    const id = el.id ? '#' + el.id : '';
    const cls = el.classList.length ? '.' + el.classList[0] : '';
    return tag + id + cls;
  }
  function effectiveBackground(el) {
    let node = el;
    while (node) {
      const bg = getComputedStyle(node).backgroundColor;
      if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
      node = node.parentElement;
    }
    return 'rgb(255, 255, 255)';
  }

  const all = Array.from(document.querySelectorAll('body *'));
  const visible = [];
  const indexOf = new Map();
  for (const el of all) {
    const rect = el.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) continue;
    const style = getComputedStyle(el);
    if (style.display === 'none' || style.visibility === 'hidden'
        || parseFloat(style.opacity) === 0) continue;
    indexOf.set(el, visible.length);
    visible.push(el);
  }

  const elements = visible.map((el, i) => {
    const rect = el.getBoundingClientRect();
    const style = getComputedStyle(el);
    let parentIdx = -1;
    let p = el.parentElement;
    while (p) {
      if (indexOf.has(p)) { parentIdx = indexOf.get(p); break; }
      p = p.parentElement;
    }
    const hasDirectText = Array.from(el.childNodes).some(
      n => n.nodeType === 3 && n.textContent.trim().length > 0
    );
    const isInteractive = (
      ['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)
      || el.getAttribute('role') === 'button'
      || style.cursor === 'pointer'
    );
    const hasOwnBackground = (() => {
      const bg = style.backgroundColor;
      return bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
    })();
    return {
      idx: i,
      parentIdx: parentIdx,
      sel: selectorFor(el),
      tag: el.tagName,
      classes: Array.from(el.classList),
      left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
      width: rect.width, height: rect.height,
      position: style.position,
      color: style.color,
      bgColor: hasDirectText ? effectiveBackground(el) : null,
      fontSize: parseFloat(style.fontSize),
      hasDirectText: hasDirectText,
      isInteractive: isInteractive,
      hasOwnBackground: hasOwnBackground,
    };
  });

  return {
    scrollWidth: document.documentElement.scrollWidth,
    clientWidth: document.documentElement.clientWidth,
    elements: elements,
  };
}
"""


@dataclass
class Violation:
    check: str
    width: int
    message: str

    def format(self) -> str:
        return f"[{self.width}px] [{self.check}] {self.message}"


# ---------------------------------------------------------------------------
# Color math (WCAG 2.1 contrast)
# ---------------------------------------------------------------------------


def _parse_rgb(css_color: str | None) -> tuple[float, float, float] | None:
    if not css_color:
        return None
    match = RGB_RE.match(css_color)
    if not match:
        return None
    return float(match.group(1)), float(match.group(2)), float(match.group(3))


def _relative_luminance(rgb: tuple[float, float, float]) -> float:
    def channel(c: float) -> float:
        c = c / 255
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4

    r, g, b = rgb
    return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)


def _contrast_ratio(rgb1: tuple[float, float, float], rgb2: tuple[float, float, float]) -> float:
    l1, l2 = _relative_luminance(rgb1), _relative_luminance(rgb2)
    lighter, darker = max(l1, l2), min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)


# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------


def _rects_overlap(a: dict, b: dict) -> bool:
    left, right = max(a["left"], b["left"]), min(a["right"], b["right"])
    top, bottom = max(a["top"], b["top"]), min(a["bottom"], b["bottom"])
    if right <= left or bottom <= top:
        return False
    overlap_area = (right - left) * (bottom - top)
    smaller_area = min(a["width"] * a["height"], b["width"] * b["height"])
    return smaller_area > 0 and (overlap_area / smaller_area) >= OVERLAP_MIN_FRACTION


def _is_ancestor(ancestor_idx: int, node_idx: int, by_idx: dict[int, dict]) -> bool:
    node = by_idx.get(node_idx)
    while node is not None and node["parentIdx"] != -1:
        if node["parentIdx"] == ancestor_idx:
            return True
        node = by_idx.get(node["parentIdx"])
    return False


# ---------------------------------------------------------------------------
# Checks — each takes the extracted DOM dict + the viewport width being tested
# ---------------------------------------------------------------------------


def check_overflow(dom: dict, width: int) -> list[Violation]:
    """Horizontal overflow at this width.

    Reports only root-cause elements — those overflowing while their own parent
    doesn't — so a single unconstrained child doesn't produce a line per
    descendant caught in the same overflow.
    """
    if dom["scrollWidth"] <= dom["clientWidth"] + OVERFLOW_EPSILON:
        return []
    by_idx = dom["by_idx"]
    limit = width + OVERFLOW_EPSILON
    culprits = [
        e
        for e in dom["elements"]
        if e["right"] > limit
        and ((parent := by_idx.get(e["parentIdx"])) is None or parent["right"] <= limit)
    ]
    culprits.sort(key=lambda e: -e["right"])
    return [
        Violation(
            "VIEWPORT_OVERFLOW",
            width,
            f"`{e['sel']}` extends to {e['right']:.0f}px, beyond the {width}px viewport — "
            "check for a missing width_option/Container clamp or an unconstrained-width child.",
        )
        for e in culprits[:MAX_REPORTED_CULPRITS]
    ]


def check_touch_targets(dom: dict, width: int) -> list[Violation]:
    """Interactive elements under the 44x44px minimum touch target.

    Grouped by selector so N identical small icon buttons produce one line.
    """
    groups: dict[str, list[dict]] = {}
    for e in dom["elements"]:
        if e["isInteractive"] and min(e["width"], e["height"]) < MIN_TOUCH_TARGET:
            groups.setdefault(e["sel"], []).append(e)

    violations = []
    for sel, members in groups.items():
        smallest = min(members, key=lambda e: min(e["width"], e["height"]))
        count = f"{len(members)} elements matching" if len(members) > 1 else "Element"
        violations.append(
            Violation(
                "TOUCH_TARGET_TOO_SMALL",
                width,
                f"{count} `{sel}` under {MIN_TOUCH_TARGET}x{MIN_TOUCH_TARGET}px "
                f"(smallest: {smallest['width']:.0f}x{smallest['height']:.0f}px).",
            )
        )
    return violations


def check_contrast(dom: dict, width: int) -> list[Violation]:
    """WCAG text contrast under 4.5:1.

    The effective background is the nearest non-transparent ancestor, resolved
    in JS because that walk needs live computed styles.
    """
    violations = []
    seen: set[tuple[str, str, str]] = set()
    for e in dom["elements"]:
        if not e["hasDirectText"] or e["bgColor"] is None:
            continue
        fg, bg = _parse_rgb(e["color"]), _parse_rgb(e["bgColor"])
        if fg is None or bg is None:
            continue
        ratio = _contrast_ratio(fg, bg)
        if ratio >= MIN_CONTRAST_RATIO:
            continue
        key = (e["sel"], e["color"], e["bgColor"])
        if key in seen:
            continue
        seen.add(key)
        violations.append(
            Violation(
                "LOW_CONTRAST",
                width,
                f"`{e['sel']}` text contrast is {ratio:.2f}:1 (needs \u2265 "
                f"{MIN_CONTRAST_RATIO}:1) — {e['color']} on {e['bgColor']}.",
            )
        )
    return violations


def check_overlap(dom: dict, width: int) -> list[Violation]:
    """Unintentional overlap between two content-bearing elements.

    Restricted to normal flow (`position: static/relative`), since absolute and
    fixed positioning is how Dialogs, SpeedDials, and Tooltips *intentionally*
    float over content; and to elements that carry meaning (own background,
    direct text, or interactive) rather than every structural wrapper div, which
    would make this O(n^2) over noise.
    """
    by_idx = dom["by_idx"]
    candidates = [
        e
        for e in dom["elements"]
        if e["position"] in ("static", "relative")
        and (e["hasOwnBackground"] or e["hasDirectText"] or e["isInteractive"])
    ]
    violations = []
    seen_pairs: set[tuple[str, str]] = set()
    for i, a in enumerate(candidates):
        for b in candidates[i + 1 :]:
            if a["idx"] == b["parentIdx"] or b["idx"] == a["parentIdx"]:
                continue
            if _is_ancestor(a["idx"], b["idx"], by_idx) or _is_ancestor(b["idx"], a["idx"], by_idx):
                continue
            if not _rects_overlap(a, b):
                continue
            key = (min(a["sel"], b["sel"]), max(a["sel"], b["sel"]))
            if key in seen_pairs:
                continue
            seen_pairs.add(key)
            violations.append(
                Violation(
                    "ELEMENT_OVERLAP",
                    width,
                    f"`{a['sel']}` and `{b['sel']}` visually overlap.",
                )
            )
    return violations


def check_misaligned_left_edges(dom: dict, width: int) -> list[Violation]:
    """Stacked siblings that should share a left edge but don't.

    Grouped on parent + tag + class signature — a strong signal they're meant to
    read as identical rows/cards. Vertically stacked only: a side-by-side row or
    Grid of same-signature elements is a different, intentional layout.
    """
    groups: dict[tuple, list[dict]] = {}
    for e in dom["elements"]:
        groups.setdefault((e["parentIdx"], e["tag"], tuple(e["classes"])), []).append(e)

    violations = []
    for (_parent_idx, tag, classes), members in groups.items():
        if len(members) < MIN_ALIGNED_SIBLINGS:
            continue
        tops = [e["top"] for e in members]
        if max(tops) - min(tops) < ALIGNMENT_EPSILON:
            continue
        lefts = [e["left"] for e in members]
        if max(lefts) - min(lefts) <= ALIGNMENT_EPSILON:
            continue
        median_left = sorted(lefts)[len(lefts) // 2]
        outlier = max(members, key=lambda e: abs(e["left"] - median_left))
        cls_str = "." + ".".join(classes) if classes else ""
        violations.append(
            Violation(
                "MISALIGNED_LEFT_EDGE",
                width,
                f"{len(members)} siblings matching `{tag.lower()}{cls_str}` don't share a "
                f"left edge (range {min(lefts):.0f}\u2013{max(lefts):.0f}px) — e.g. "
                f"`{outlier['sel']}`.",
            )
        )
    return violations


def check_font_scale(dom: dict, width: int) -> list[Violation]:
    """Informational: how many distinct font-sizes are in use.

    Not a hard pass/fail — the intended type scale isn't knowable from the DOM —
    but a page with a dozen distinct sizes almost never has a deliberate one.
    """
    sizes = sorted(
        {round(e["fontSize"], 1) for e in dom["elements"] if e["hasDirectText"] and e["fontSize"]}
    )
    if len(sizes) <= MAX_DISTINCT_FONT_SIZES:
        return []
    return [
        Violation(
            "FONT_SCALE_SPRAWL",
            width,
            f"{len(sizes)} distinct font-sizes in use "
            f"({', '.join(f'{s:g}px' for s in sizes)}) — consider consolidating to a "
            "smaller type scale (informational, not a hard rule).",
        )
    ]


def run_checks(dom: dict, width: int) -> list[Violation]:
    # Parent lookups are needed by more than one check, so index once here.
    dom["by_idx"] = {e["idx"]: e for e in dom["elements"]}
    return [
        *check_overflow(dom, width),
        *check_touch_targets(dom, width),
        *check_contrast(dom, width),
        *check_overlap(dom, width),
        *check_misaligned_left_edges(dom, width),
        *check_font_scale(dom, width),
    ]


def lint_url(url: str, widths=DEFAULT_WIDTHS, timeout: int = DEFAULT_TIMEOUT_MS) -> list[Violation]:
    from playwright.sync_api import Error as PlaywrightError
    from playwright.sync_api import sync_playwright

    all_violations: list[Violation] = []
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        try:
            page.goto(url, wait_until="networkidle")
        except PlaywrightError as e:
            browser.close()
            raise ConnectionError(f"Could not load {url} — is the app being served? ({e})") from e
        for width in widths:
            page.set_viewport_size({"width": width, "height": VIEWPORT_HEIGHT})
            # Same loading-overlay wait as the screenshot loop in
            # iterating-on-panel-apps.md; a page with no .pn-loading element
            # passes immediately, since the selector then matches nothing. A
            # timeout is a finding in its own right, not a crash that discards
            # every width already checked.
            try:
                page.wait_for_function(
                    "() => !document.querySelector('.pn-loading')", timeout=timeout
                )
            except PlaywrightError:
                all_violations.append(
                    Violation(
                        "LOAD_TIMEOUT",
                        width,
                        f"App did not finish loading within {timeout}ms at {width}px "
                        "(a .pn-loading overlay never cleared) — checks at this width "
                        "were skipped.",
                    )
                )
                continue
            page.wait_for_timeout(SETTLE_MS)
            all_violations += run_checks(page.evaluate(DOM_EXTRACT_JS), width)
        browser.close()
    return all_violations


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("url")
    parser.add_argument(
        "--widths",
        default=",".join(str(w) for w in DEFAULT_WIDTHS),
        help="comma-separated viewport widths, default 1400,768,390",
    )
    args = parser.parse_args(argv)
    widths = [int(w) for w in args.widths.split(",")]

    try:
        violations = lint_url(args.url, widths=widths)
    except ConnectionError as e:
        print(f"layout_lint: {e}", file=sys.stderr)
        return 2
    for v in violations:
        print(v.format())
    if not violations:
        print(f"layout_lint: clean ({len(widths)} widths checked)")
        return 0
    print(
        f"\nlayout_lint: {len(violations)} violation(s) across {len(widths)} widths",
        file=sys.stderr,
    )
    return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))