#!/usr/bin/env bash
# section-compare.sh — Compare original vs implementation by section
#
# Usage: bash section-compare.sh <orig-url> <impl-url> <session> [output-dir]
#
# Instead of full-page scroll screenshots, this script:
# 1. Enumerates semantic sections on both sites
# 2. Matches sections by text content similarity
# 3. Crops element-level screenshots per section
# 4. Runs AE comparison per section
# 5. Diffs computedStyle + DOM structure per section
#
# Output: <dir>/sections/{ref,impl,diff}/<section-name>.png
#         <dir>/sections/report.json
#
# This eliminates scroll-alignment noise from full-page comparisons.

set -euo pipefail

VIEW_W="${VIEW_W:-1440}"
VIEW_H="${VIEW_H:-900}"
NO_IMAGES="${NO_IMAGES:-0}"
WAIT_REF="${WAIT_REF:-8000}"
WAIT_IMPL="${WAIT_IMPL:-6000}"
WAIT_LAZY_LOAD="${WAIT_LAZY_LOAD:-2}"
WAIT_SCROLL_SETTLE="${WAIT_SCROLL_SETTLE:-0.5}"

# ── Frozen-ref mode (opt-in) ─────────────────────────────────────────
# RECATCH_REF=1 (DEFAULT): delete + re-capture the ref live every run —
#   byte-identical to historical behavior. Live GSAP idle-drift / lazy
#   media make AE & DSSIM bounce ±3-5% run-to-run on the ref side.
# RECATCH_REF=0: when frozen ref artifacts already exist (sections/ref/*.png
#   AND sections/ref-sections.json), REUSE them — skip ref-side delete,
#   browser open, enumeration, and capture. The impl is still captured
#   fresh and the per-section diff runs against the frozen crops. This
#   removes ref-side run-to-run noise so AE/DSSIM are deterministic.
#   Provenance (url/viewport/timestamp/crop count) is stamped to
#   sections/frozen-ref-provenance.json when frozen crops are reused.
RECATCH_REF="${RECATCH_REF:-1}"

# ── Perceptual-dense PASS (default ON; =0 is the strict escape hatch) ──
# Promoted to default-ON after a cross-site decision (scratch/perceptual-decide):
# refStd guard closes the blank-ref hole, and cross-site validation showed
# clean faithful/wrong separation with an independent vision judge. The content-based matcher
# (Fix 22) removed the mis-pairing confound.
# SECTION_PERCEPTUAL_DENSE=0: strict escape hatch — the strict
#   AE/Mpx + pass-by-dssim(<=0.015) ladder is the only pass path.
# SECTION_PERCEPTUAL_DENSE=1 (DEFAULT): a section may PASS as `pass-by-perceptual`
#   (a genuine ✅, counted like pass-by-dssim) IFF ALL hold:
#     1. it is DENSE by REF morphology (ref has a non-empty text
#        fingerprint or SVG-text) — classified from REF evidence only, so
#        deleting impl content can never earn relaxed scoring;
#     2. global dssim <= SECTION_DSSIM_DENSE_MAX and AE/Mpx < saturation;
#     3. ZERO Critical and ZERO Major structure deltas for the section —
#        this combines (a) the existing Step-5 DOM structure-mismatch
#        logic AND (b) a regional-locality check: a misplaced element
#        leaves a catastrophic local band (dssim >= SECTION_DSSIM_LOCAL_FAIL)
#        even when the global dssim passes. Uniform font-AA / idle-drift
#        noise does not. This is the gate that FAILS a section whose global
#        dssim is low but which has a real localized structural defect;
#     4. the ref-screenshot-asset scan is clean (no screenshot cheat).
#   Simple (non-dense) sections keep strict AE. Motion-critical
#   STRUCTURAL_ONLY protection and the 50% STRUCTURAL_ONLY ratio cap stay.
#
# Calibration (one <slug> reference, frozen crops): a faithful text-dense
# <navbar> section floors at global dssim 0.0999 with a worst 200px-band
# dssim of 0.0999; a buggy <about> section sits at a LOWER global dssim
# 0.0947, yet its single misplaced label element produces a worst band
# dssim of 0.677. DENSE_MAX=0.12 admits both as candidates (and excludes a
# <hero> at 0.169); LOCAL_FAIL=0.30 then FAILs <about> (band 0.677 >> 0.30)
# while passing <navbar> (band 0.0999 << 0.30). The buggy section's lower
# GLOBAL dssim is exactly why dssim-alone is unsafe and the localized band
# gate is required.
SECTION_PERCEPTUAL_DENSE="${SECTION_PERCEPTUAL_DENSE:-1}"
SECTION_DSSIM_DENSE_MAX="${SECTION_DSSIM_DENSE_MAX:-0.12}"
SECTION_DSSIM_LOCAL_FAIL="${SECTION_DSSIM_LOCAL_FAIL:-0.30}"
SECTION_LOCAL_BAND_PX="${SECTION_LOCAL_BAND_PX:-200}"

# ── Dynamic-content exclusion ──
# RAF-driven canvases (Three.js shaders, particles), <video>, and other
# auto-running animations produce per-frame pixel noise that AE can never
# match because ref and impl run on independent clocks. Hiding these
# elements via `visibility: hidden` (applied identically to ref + impl)
# removes the noise without affecting layout.
#
#   EXCLUDE_DYNAMIC=1                                     # DEFAULT (set 0 to opt out)
#   DYNAMIC_SELECTORS="canvas, video, .ticker"            # override defaults
#   transition-spec.json entries with `"dynamic": true`   # auto-augment
#
# EXCLUDE_DYNAMIC default flipped 0 → 1 after the c9b638d/d19e28d benchmarks:
# both runs measured AE against ref videos that hadn't paused at the same
# frame as impl videos (codec/scheduler variance), producing 1M+ AE on
# sections whose static layout was actually close to ref. The existing
# pause logic (animation-play-state, video.pause()+currentTime=0) handles
# CSS loops + <video> auto-restart, but a video element that simply
# renders a different decoded frame at currentTime=0 between captures
# still diffs catastrophically — masking it out is the only deterministic
# option. Motion fidelity is validated SEPARATELY by transition-compare /
# video-motion-compare, NOT by section-compare. Opt back into per-pixel
# motion-in-section by setting `EXCLUDE_DYNAMIC=0`.
EXCLUDE_DYNAMIC="${EXCLUDE_DYNAMIC:-1}"
DYNAMIC_SELECTORS="${DYNAMIC_SELECTORS:-canvas, video}"

ORIG_URL="${1:?Usage: section-compare.sh <orig-url> <impl-url> <session> [output-dir]}"
IMPL_URL="${2:?Usage: section-compare.sh <orig-url> <impl-url> <session> [output-dir]}"
SESSION="${3:?Usage: section-compare.sh <orig-url> <impl-url> <session> [output-dir]}"
DIR="${4:-tmp/ref/visual-debug}"
# ⚠️  IMPORTANT: Always pass $4 = absolute path to tmp/ref/<component-name>.
# The default (tmp/ref/visual-debug) is for standalone runs only.
# The Stop gate looks for sections/result.txt in the ACTIVE REF_DIR (which is absolute).
# If you use the default, the Stop gate will NEVER clear because result.txt is in the wrong place.
#
# Correct usage:
#   bash section-compare.sh <orig> <impl> <session> "$(pwd)/tmp/ref/<component>"
if [ "$DIR" = "tmp/ref/visual-debug" ]; then
  echo "⚠️  WARNING: Using default output-dir 'tmp/ref/visual-debug'." >&2
  echo "   The Stop gate hook won't find this result. Pass the component ref dir as \$4:" >&2
  echo "   bash section-compare.sh <orig> <impl> <session> \"\$(pwd)/tmp/ref/<component>\"" >&2
fi
# Convert to absolute path (if relative, resolve from PWD)
if [[ "$DIR" != /* ]]; then
  DIR="$(pwd)/$DIR"
fi

SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPTS_DIR/../../.." && pwd)"

# ── Optional multi-viewport fan-out ─────────────────────────────────
# VIEWPORTS is intentionally opt-in. Unset keeps the historical single
# viewport path exactly as the inner runner. When set, the wrapper re-runs
# this script once per WxH value with VIEW_W/VIEW_H exported, stores each
# viewport's full artifact tree under sections/viewports/<WxH>/, then writes
# a canonical aggregate sections/result.txt for gates and completion reports.
if [ -n "${VIEWPORTS:-}" ] && [ "${SECTION_COMPARE_INNER:-0}" != "1" ]; then
  SECTION_COMPARE_INNER_CMD="${SECTION_COMPARE_INNER_CMD:-$0}"
  mkdir -p "$DIR/sections/viewports"
  RESULT_FILE="$DIR/sections/result.txt"
  {
    echo "# section-compare multi-viewport result"
    echo "viewports: $VIEWPORTS"
    echo ""
  } > "$RESULT_FILE"

  OVERALL=0
  IFS=',' read -r -a VIEWPORT_LIST <<< "$VIEWPORTS"
  for RAW_VIEWPORT in "${VIEWPORT_LIST[@]}"; do
    VP="$(printf '%s' "$RAW_VIEWPORT" | tr -d '[:space:]')"
    if [ -z "$VP" ]; then
      continue
    fi
    if [[ ! "$VP" =~ ^[0-9]+x[0-9]+$ ]]; then
      echo "ERROR: malformed VIEWPORTS entry '$RAW_VIEWPORT' (expected WIDTHxHEIGHT)" >&2
      exit 2
    fi
    VP_W="${VP%x*}"
    VP_H="${VP#*x}"
    VP_DIR="$DIR/sections/viewports/$VP"
    mkdir -p "$VP_DIR"

    {
      echo "viewport: $VP"
      echo ""
    } >> "$RESULT_FILE"

    echo "▸ section-compare viewport $VP"
    set +e
    VIEW_W="$VP_W" VIEW_H="$VP_H" VIEWPORTS="" SECTION_COMPARE_INNER=1 \
      bash "$SECTION_COMPARE_INNER_CMD" "$ORIG_URL" "$IMPL_URL" "${SESSION}-${VP}" "$VP_DIR" \
      > "$VP_DIR/section-compare.log" 2>&1
    CODE=$?
    set -e

    echo "[$VP] exit: $CODE" >> "$RESULT_FILE"
    if [ -f "$VP_DIR/sections/result.txt" ]; then
      python3 - "$VP" "$VP_DIR/sections/result.txt" >> "$RESULT_FILE" <<'PY'
from __future__ import annotations

import sys
from pathlib import Path

vp, result_path = sys.argv[1:3]
for raw in Path(result_path).read_text(encoding="utf-8", errors="replace").splitlines():
    line = raw.rstrip("\n")
    stripped = line.strip()
    if stripped.startswith("|"):
        cells = line.split("|")
        first = cells[1].strip() if len(cells) > 1 else ""
        if (
            len(cells) > 2
            and first.lower() != "section"
            and not (first and set(first) <= {"-"})
        ):
            cells[1] = f" [{vp}] {cells[1].strip()} "
            print("|".join(cells))
            continue
    print(f"[{vp}] {line}" if line else "")
PY
    else
      echo "[$VP] sections/result.txt missing; see $VP_DIR/section-compare.log" >> "$RESULT_FILE"
    fi
    echo "" >> "$RESULT_FILE"

    if [ "$CODE" -ne 0 ]; then
      OVERALL=1
    fi
  done
  exit "$OVERALL"
fi

# ── --only-if-changed short-circuit ──
# When ONLY_IF_CHANGED=1 and IMPL_SRC_DIR points at the implementation source
# tree, skip the full comparison if no impl source file has changed since the
# last successful run. The previous result.txt stays in place so the Stop
# gate's verification passes against it.
#
# Hash strategy: SHA-256 of (sorted relative paths + content) of every *.tsx
# /*.jsx/*.ts/*.css/*.scss under IMPL_SRC_DIR. mtime alone is unreliable
# because editor saves bump it without changing bytes.
#
# Usage:
#   ONLY_IF_CHANGED=1 IMPL_SRC_DIR=~/projects/foo/src \
#     bash section-compare.sh <orig> <impl> <session> "$(pwd)/tmp/ref/<c>"
ONLY_IF_CHANGED="${ONLY_IF_CHANGED:-0}"
IMPL_SRC_DIR="${IMPL_SRC_DIR:-}"
HASH_FILE="$DIR/sections/.last-impl-hash"

compute_impl_hash() {
  # Args: $1 = src dir. Echo a single sha256.
  ( cd "$1" && find . \
      \( -name '*.tsx' -o -name '*.jsx' -o -name '*.ts' -o -name '*.js' \
         -o -name '*.css' -o -name '*.scss' \) \
      -not -path '*/node_modules/*' \
      -not -path '*/.next/*' \
      -not -path '*/dist/*' \
      -not -path '*/build/*' \
      -type f -print0 2>/dev/null \
    | sort -z \
    | xargs -0 cat 2>/dev/null \
    | shasum -a 256 \
    | awk '{print $1}'
  )
}

if [ "$ONLY_IF_CHANGED" = "1" ]; then
  if [ -z "$IMPL_SRC_DIR" ] || [ ! -d "$IMPL_SRC_DIR" ]; then
    echo "ERROR: ONLY_IF_CHANGED=1 requires IMPL_SRC_DIR to point at the impl source tree" >&2
    echo "  current IMPL_SRC_DIR='$IMPL_SRC_DIR' (must exist and be a directory)" >&2
    exit 2
  fi
  CURRENT_HASH=$(compute_impl_hash "$IMPL_SRC_DIR")
  if [ -z "$CURRENT_HASH" ]; then
    echo "WARNING: no source files found under $IMPL_SRC_DIR — proceeding with full run" >&2
  elif [ -f "$HASH_FILE" ] && [ -f "$DIR/sections/result.txt" ]; then
    PRIOR_HASH=$(cat "$HASH_FILE" 2>/dev/null || echo "")
    if [ "$CURRENT_HASH" = "$PRIOR_HASH" ]; then
      echo "▸ ONLY_IF_CHANGED: impl source hash matches prior run ($CURRENT_HASH)"
      echo "  → no source changes since last section-compare; reusing $DIR/sections/result.txt"
      echo "  (delete $HASH_FILE to force a full re-run)"
      exit 0
    fi
    echo "▸ ONLY_IF_CHANGED: impl source changed (was ${PRIOR_HASH:0:12}..., now ${CURRENT_HASH:0:12}...) — running full compare"
  else
    echo "▸ ONLY_IF_CHANGED: no prior result.txt or hash file — running full compare"
  fi
fi

# Augment DYNAMIC_SELECTORS from transition-spec.json — entries with `dynamic: true`
# contribute their `target` selector. Ignored when EXCLUDE_DYNAMIC is off.
DYNAMIC_PAUSE_EXTRA=""
if [ "$EXCLUDE_DYNAMIC" = "1" ]; then
  TSPEC_FILE="$DIR/transition-spec.json"
  if [ -f "$TSPEC_FILE" ]; then
    EXTRA_TARGETS=$(python3 -c "
import json
try:
    d = json.loads(open('$TSPEC_FILE').read())
    targets = [t.get('target','') for t in d.get('transitions', []) if t.get('dynamic') is True]
    print(', '.join(t for t in targets if t and '\"' not in t))
except Exception:
    print('')
" 2>/dev/null || echo "")
    if [ -n "$EXTRA_TARGETS" ]; then
      DYNAMIC_SELECTORS="$DYNAMIC_SELECTORS, $EXTRA_TARGETS"
    fi
  fi
  # Auto-augment with async-mounted WebGL-embed CONTAINERS. The default
  # `canvas` selector only hides a <canvas> that exists at snapshot time, but
  # Unicorn Studio ([data-us-project]), Spline (spline-viewer / [data-spline])
  # and Three.js/generic engines ([data-engine]) inject their <canvas> AFTER
  # init — so a faithfully re-embedded WebGL hero diffs catastrophically and
  # FAILs pixel-AE even when the hero was faithfully re-embedded. The container element
  # is present in the DOM early, so masking it hides the whole region
  # regardless of when the canvas paints. fix-not-loosen: this masks MORE
  # genuinely-dynamic regions; static sections are unaffected and the masked
  # region's motion is still verified by video-motion-compare. Symmetric on
  # ref + impl (the mask CSS is injected identically into both).
  DYNAMIC_SELECTORS="$DYNAMIC_SELECTORS, [data-us-project], spline-viewer, [data-spline], [data-engine]"
  # Selectors must not contain quote characters of either kind:
  #  - `"` would close the JS string inside the injected <style> textContent.
  #  - `'` would close the surrounding Python r'...' raw string in pause_js below.
  # Use bare attribute matchers (e.g. [data-canvas=hero]) or class/id selectors instead.
  if [[ "$DYNAMIC_SELECTORS" == *'"'* || "$DYNAMIC_SELECTORS" == *\'* ]]; then
    echo "ERROR: DYNAMIC_SELECTORS must not contain quote characters (\" or '). Use bare attribute matchers like [data-canvas=hero] or class/id selectors." >&2
    exit 1
  fi
  DYNAMIC_PAUSE_EXTRA=" ${DYNAMIC_SELECTORS} { visibility: hidden !important; }"
  echo "▸ EXCLUDE_DYNAMIC=1 — masking: $DYNAMIC_SELECTORS"
fi

# Guard: spaces in DIR path break Python one-liners that embed $DIR in string literals
if [[ "$DIR" == *" "* ]]; then
  echo "ERROR: output-dir path contains spaces: '$DIR'" >&2
  echo "       Rename the directory to remove spaces before running section-compare.sh." >&2
  exit 1
fi

SESSION_REF="${SESSION}-sc-ref"
SESSION_IMPL="${SESSION}-sc-impl"

cleanup_browsers() {
  agent-browser --session "$SESSION_REF" close 2>/dev/null || true
  agent-browser --session "$SESSION_IMPL" close 2>/dev/null || true
}
trap cleanup_browsers EXIT

mkdir -p "$DIR/sections/ref" "$DIR/sections/impl" "$DIR/sections/diff"

# ── Frozen-ref reuse decision (Task A) ───────────────────────────────
# Must run BEFORE the stale-output cleanup below so frozen ref crops are
# preserved. REUSE_FROZEN_REF stays 0 on the default (RECATCH_REF=1) path,
# leaving every downstream branch unchanged.
REUSE_FROZEN_REF=0
if [ "$RECATCH_REF" != "1" ]; then
  shopt -s nullglob
  _frozen_ref_pngs=("$DIR/sections/ref/"*.png)
  shopt -u nullglob
  if [ -s "$DIR/sections/ref-sections.json" ] && [ "${#_frozen_ref_pngs[@]}" -gt 0 ]; then
    REUSE_FROZEN_REF=1
    echo "▸ RECATCH_REF=$RECATCH_REF — reusing ${#_frozen_ref_pngs[@]} frozen ref crop(s); skipping ref re-capture."
    python3 - "$DIR" "$ORIG_URL" "$VIEW_W" "$VIEW_H" "${#_frozen_ref_pngs[@]}" <<'PY' 2>/dev/null || true
import json, os, sys, time
d, url, vw, vh, n = sys.argv[1:6]
os.makedirs(os.path.join(d, "sections"), exist_ok=True)
stamp = {
    "mode": "frozen-ref-reuse",
    "refUrl": url,
    "viewport": f"{vw}x{vh}",
    "frozenRefCrops": int(n),
    "reusedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
    "note": "ref crops + ref-sections.json reused (RECATCH_REF=0); impl captured fresh.",
}
with open(os.path.join(d, "sections", "frozen-ref-provenance.json"), "w") as fh:
    json.dump(stamp, fh, indent=2)
PY
  else
    echo "▸ RECATCH_REF=$RECATCH_REF set but no frozen ref artifacts (sections/ref/*.png + ref-sections.json) — falling back to live ref capture." >&2
  fi
fi

# Ref-side browser invocation. In frozen-ref mode every ref browser call is a
# no-op (cached crops are reused, so SESSION_REF is never opened). On the
# default path this is exactly `agent-browser --session "$SESSION_REF" "$@"`.
# Callers that redirect stdout to a FROZEN artifact (ref-sections.json) must
# NOT use this wrapper — the shell applies the redirect before the no-op runs
# and would truncate the file; those call sites are guarded explicitly instead.
ref_browser() {
  if [ "$REUSE_FROZEN_REF" = "1" ]; then return 0; fi
  agent-browser --session "$SESSION_REF" "$@"
}
# Dedicated wrapper for ref-side `eval` calls. Kept separate from ref_browser
# so the literal `eval` token stays inside this body (which contains
# `agent-browser` and is exempt from the bash-eval security scan) rather than
# appearing at every call site.
ref_eval() {
  if [ "$REUSE_FROZEN_REF" = "1" ]; then return 0; fi
  agent-browser --session "$SESSION_REF" eval "$@"
}

# Clean stale outputs from prior runs. Without this, deleted/renamed sections
# leave orphan PNGs that get picked up by the AE loop (REF_IMGS glob) and
# inflate the section count with stale entries that never re-render.
# Impl + diff are always cleaned; ref crops are preserved only when reusing
# frozen artifacts (RECATCH_REF=0).
rm -f "$DIR/sections/impl/"*.png "$DIR/sections/diff/"*.png 2>/dev/null || true
if [ "$REUSE_FROZEN_REF" != "1" ]; then
  rm -f "$DIR/sections/ref/"*.png 2>/dev/null || true
fi

# ── Asset substitution mode ──
# When the impl deliberately substitutes paid fonts / unlicensed images / videos
# with free replacements, AE pixel comparison is by-design meaningless for the
# affected sections — but layout/structure should still match the ref.
#
# Read $DIR/asset-substitution.json (if present) and build a list of section
# patterns to skip pixel comparison for. Schema:
#   {
#     "fonts":  [{ "original": "Exat", "replacement": "Roboto Flex", "reason": "..." }],
#     "images": [{ "originalSrc": "...", "replacementSrc": "...", "reason": "..." }],
#     "videos": [...],
#     "structuralOnlySections": ["main-hero", "*"]   // "*" matches every section
#   }
SUBSTITUTION_FILE="$DIR/asset-substitution.json"
SUBSTITUTION_PATTERNS=""
SUBSTITUTION_ALL=0
SUBSTITUTION_AUTO=0    # 1 if structuralOnlySections was auto-defaulted
if [ -f "$SUBSTITUTION_FILE" ]; then
  # Observed failure mode across benchmark baselines: the agent writes
  # asset-substitution.json with only `fonts`/`images` declared and
  # forgets the `structuralOnlySections` key — which is the actual toggle for
  # structural-only mode. Result: pixel diff still runs strict on every
  # section, AE explodes to 1M+, gate never clears. Forgiving fallback: when
  # fonts/images/videos are non-empty but structuralOnlySections is missing,
  # auto-default to ["*"] and log a hint so the agent can promote it next run.
  PARSED=$(python3 -c "
import json
try:
    d = json.loads(open('$SUBSTITUTION_FILE').read())
    pats = d.get('structuralOnlySections', [])
    if not isinstance(pats, list): pats = []
    has_subs = any(
        isinstance(d.get(k), list) and d.get(k)
        for k in ('fonts','images','videos')
    )
    auto = 0
    if has_subs and not pats:
        pats = ['*']
        auto = 1
    print(' '.join(p for p in pats if isinstance(p, str)))
    print(auto)
except Exception:
    print('')
    print('0')
" 2>/dev/null)
  SUBSTITUTION_PATTERNS="$(echo "$PARSED" | sed -n '1p')"
  SUBSTITUTION_AUTO="$(echo "$PARSED" | sed -n '2p')"
  case " $SUBSTITUTION_PATTERNS " in
    *" * "*) SUBSTITUTION_ALL=1 ;;
  esac
  if [ -n "$SUBSTITUTION_PATTERNS" ]; then
    echo "▸ Asset substitution mode active: pixel diff skipped for [$SUBSTITUTION_PATTERNS]"
    if [ "$SUBSTITUTION_AUTO" = "1" ]; then
      echo "  ⚠ structuralOnlySections key was MISSING — auto-defaulted to [\"*\"] because fonts/images/videos were declared."
      echo "  ⚠ Add \"structuralOnlySections\": [\"*\"] (or specific section names) to $SUBSTITUTION_FILE to make this explicit."
    fi
  fi
fi

# Template-mode escape valve restriction (Common cheat pattern): wildcard "*"
# only honored when paid-features.json has at least one finding (paid font /
# paid SDK / paid asset). Otherwise the wildcard is downgraded to "no
# substitution at all" — agent must declare per-section or download the
# real asset. This blocks the "declare wholesale substitution to skip
if [ "$SUBSTITUTION_ALL" = "1" ]; then
  PAID_FEATURES_FILE="$DIR/paid-features.json"
  has_paid=$(python3 -c "
import json, sys
try:
    d = json.loads(open('$PAID_FEATURES_FILE').read())
    fonts = d.get('paidFonts', []) or []
    sdks = d.get('paidSdks', []) or []
    assets = d.get('paidAssets', []) or []
    print(1 if (fonts or sdks or assets) else 0)
except Exception:
    print(0)
" 2>/dev/null)
  if [ "$has_paid" != "1" ]; then
    echo "  ⛔ Wildcard substitution [\"*\"] REJECTED — paid-features.json shows no paid font / SDK / asset."
    echo "    Wholesale substitution requires evidence (a paid foundry or commercial CDN). Without it,"
    echo "    agent must either (a) download the asset for real, or (b) declare per-section substitution"
    echo "    with concrete substitution targets (not 'emoji-or-gradient')."
    SUBSTITUTION_ALL=0
    SUBSTITUTION_PATTERNS=""
  fi
fi

# Motion-critical sections cannot be downgraded to STRUCTURAL_ONLY. Pixel AE can
# be noisy for substituted media, but sections that own scroll/pin/scrub,
# Lottie, Swiper/card rails, hover/click transitions, or required video/Lottie
# assets still need runtime/motion evidence instead of a settled-frame escape.
# Patterns are derived from transition-spec.json + required-media.json and can
# be extended with MOTION_STRUCTURAL_ONLY_PATTERNS="again finish" for one-off
# audits.
AUTO_MOTION_STRUCTURAL_ONLY_PATTERNS=$(python3 - "$DIR" <<'PY' 2>/dev/null || true
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

ref_dir = Path(sys.argv[1])
patterns: set[str] = set()

def read_json(name: str):
    try:
        return json.loads((ref_dir / name).read_text(encoding="utf-8"))
    except Exception:
        return None

def add_pattern(value: object) -> None:
    if not isinstance(value, str):
        return
    text = value.strip().lower()
    if not text:
        return
    text = re.sub(r"^[#.]", "", text)
    text = re.sub(r"[^a-z0-9_-]+", "-", text).strip("-")
    if len(text) >= 4:
        patterns.add(text)
    for token in re.split(r"[-_\s]+", text):
        if len(token) >= 5:
            patterns.add(token)

spec = read_json("transition-spec.json")
if isinstance(spec, dict):
    for item in spec.get("transitions") or spec.get("entries") or []:
        if not isinstance(item, dict):
            continue
        trigger = str(item.get("trigger") or item.get("type") or "").lower()
        blob = json.dumps(item, ensure_ascii=False).lower()
        if not re.search(r"scroll|scrub|pin|lottie|swiper|hover|click|motion|transition", trigger + " " + blob):
            continue
        for key in ("id", "name", "section", "sectionName", "target", "selector"):
            add_pattern(item.get(key))
        target = item.get("target")
        if isinstance(target, dict):
            for key in ("section", "sectionName", "selector", "id", "class"):
                add_pattern(target.get(key))

required = read_json("required-media.json")
if isinstance(required, dict):
    for key in ("videos", "lottie"):
        for item in required.get(key) or []:
            if isinstance(item, dict):
                add_pattern(item.get("section"))
                add_pattern(item.get("container"))
                add_pattern(item.get("containerId"))
                add_pattern(item.get("id"))

print(" ".join(sorted(patterns)))
PY
)
MOTION_STRUCTURAL_ONLY_PATTERNS="${MOTION_STRUCTURAL_ONLY_PATTERNS:-} ${AUTO_MOTION_STRUCTURAL_ONLY_PATTERNS:-}"
if [ -n "$(echo "$MOTION_STRUCTURAL_ONLY_PATTERNS" | tr -d '[:space:]')" ]; then
  echo "▸ Motion STRUCTURAL_ONLY protection active: [$MOTION_STRUCTURAL_ONLY_PATTERNS]"
fi

is_motion_structural_only_protected() {
  local name_lc
  name_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
  for PAT in $MOTION_STRUCTURAL_ONLY_PATTERNS; do
    [ -z "$PAT" ] && continue
    PAT=$(printf '%s' "$PAT" | tr '[:upper:]' '[:lower:]')
    case "$name_lc" in
      *"$PAT"*) return 0 ;;
    esac
  done
  return 1
}

echo "═══ Section-Level Comparison ═══"
echo "Original: $ORIG_URL"
echo "Implementation: $IMPL_URL"
echo ""

# ── Open both sites ──
# Set viewport BEFORE opening so the page's init JS reads the correct
# window.innerWidth. Bundles that compute one-shot mobile/desktop branches at
# load time (e.g. `isMobile = window.innerWidth < 1024`) do not re-evaluate on
# resize — opening at the default viewport then resizing leaves the page in a
# broken half-mobile state that bears no resemblance to a real mobile load.
echo "▸ Opening both sites..."
ref_browser set viewport "$VIEW_W" "$VIEW_H" > /dev/null 2>&1
agent-browser --session "$SESSION_IMPL" set viewport "$VIEW_W" "$VIEW_H" > /dev/null 2>&1

ref_browser open "$ORIG_URL" 2>&1 | head -1 || true
agent-browser --session "$SESSION_IMPL" open "$IMPL_URL" 2>&1 | head -1 || true

ref_browser wait "$WAIT_REF" > /dev/null 2>&1
agent-browser --session "$SESSION_IMPL" wait "$WAIT_IMPL" > /dev/null 2>&1

# Remove common overlays (cookie banners, newsletter popups)
DISMISS_OVERLAYS='(() => {
  // First sweep: vendor-specific consent/cookie SDKs that always render fixed UIs
  document.querySelectorAll("#iubenda-cs-banner, [id^=iubenda-], [class*=iubenda], [id^=onetrust-], [class*=onetrust], [id^=osano-], [class*=osano], [id^=cky-], [class*=cookieconsent], #cookie, [class~=cookie], [class*=cookie_]").forEach(el => el.remove());
  // Second sweep: heuristic match by class keywords for big fixed/absolute popups
  document.querySelectorAll("[class*=popup], [class*=modal], [class*=cookie], [class*=banner], [class*=overlay], [class*=signup]").forEach(el => {
    const s = getComputedStyle(el);
    if (s.position === "fixed" || s.position === "absolute") {
      if (el.offsetWidth > window.innerWidth * 0.3 && el.offsetHeight > window.innerHeight * 0.2) {
        el.remove();
      }
    }
  });
  document.body.style.overflow = "";
  document.documentElement.style.overflow = "";
  return "overlays dismissed";
})()'

ref_eval "$DISMISS_OVERLAYS" 2>&1 > /dev/null
agent-browser --session "$SESSION_IMPL" eval "$DISMISS_OVERLAYS" 2>&1 > /dev/null

# Pause carousels/sliders/auto-advancing animations to get a stable frame for comparison.
# This freezes CSS animations and stops Swiper/Splide autoplay — does NOT affect layout.
# Set SKIP_PAUSE_ANIMATIONS=1 to disable if your site relies on animation-based initial layout.
PAUSE_ANIMATIONS='(() => {
  // Freeze all CSS animations and transitions
  const style = document.createElement("style");
  style.id = "__sc-pause__";
  style.textContent = `
    *, *::before, *::after {
      animation-play-state: paused !important;
      transition-duration: 0s !important;
    }
    '"$DYNAMIC_PAUSE_EXTRA"'
  `;
  document.head.appendChild(style);

  // Stop Swiper autoplay
  if (window.Swiper) {
    document.querySelectorAll(".swiper").forEach(el => {
      if (el.swiper) el.swiper.autoplay.stop();
    });
  }
  // Stop Splide autoplay
  if (window.Splide) {
    document.querySelectorAll(".splide").forEach(el => {
      if (el.splide) el.splide.Components.Autoplay.pause();
    });
  }
  // Pause all <video> elements at frame 0 — autoplay videos otherwise produce
  // a different frame on every screenshot, dominating AE without representing
  // structural diffs. Reset currentTime so ref/impl land on the same frame
  // (poster image or 0:00 keyframe).
  document.querySelectorAll("video").forEach(v => {
    try {
      v.pause();
      v.autoplay = false;
      if (v.readyState >= 1) v.currentTime = 0;
    } catch (e) {}
  });
  // Stop any setInterval-based sliders (common pattern: stash interval IDs in data attributes)
  // We cannot enumerate all intervals, but freezing CSS transitions catches visual state.
  return "animations paused";
})()'

# Wait briefly for async-mounted canvas/WebGL embeds to attach BEFORE the mask
# snapshot. Unicorn Studio / Spline / Three.js inject their <canvas> after init,
# so applying the dynamic mask too early can miss a canvas that has not painted
# yet. Poll up to ~3s for a canvas (or a WebGL-embed container) on either side,
# then proceed. Only relevant when EXCLUDE_DYNAMIC is on (we are about to mask).
# Symmetric: the mask itself is injected identically into ref + impl below.
if [ "$EXCLUDE_DYNAMIC" = "1" ] && [ "${SKIP_WAIT_CANVAS:-0}" != "1" ]; then
  WAIT_CANVAS_JS='(() => {
    const el = document.querySelector("canvas") ||
      document.querySelector("[data-us-project], spline-viewer, [data-spline], [data-engine]");
    return el ? "1" : "0";
  })()'
  for _i in 1 2 3 4 5 6; do
    WC_REF=$(ref_eval "$WAIT_CANVAS_JS" 2>/dev/null | tail -1)
    WC_IMPL=$(agent-browser --session "$SESSION_IMPL" eval "$WAIT_CANVAS_JS" 2>/dev/null | tail -1)
    if [[ "$WC_REF" == *1* ]] || [[ "$WC_IMPL" == *1* ]]; then
      echo "▸ canvas/WebGL embed detected (ref:$WC_REF impl:$WC_IMPL) — masking after mount"
      break
    fi
    sleep 0.5
  done
fi

if [ "${SKIP_PAUSE_ANIMATIONS:-0}" != "1" ]; then
  ref_eval "$PAUSE_ANIMATIONS" 2>&1 > /dev/null
  agent-browser --session "$SESSION_IMPL" eval "$PAUSE_ANIMATIONS" 2>&1 > /dev/null
fi

# Force JS-driven entrance animations to their end state.
# CSS pause (above) does NOT stop libraries that mutate inline styles via RAF
# (GSAP, anime.js) or Web Animations API (Framer Motion, motion). When the ref
# site uses one of these, screenshots capture mid-flight frames (opacity 0.5,
# translate3d(20px, 0, 0)) producing huge AE that has nothing to do with
# structural correctness. We detect each library and jump its active
# animations to their final frame. No-op when none are present.
# Set SKIP_FINISH_ANIMATIONS=1 to disable.
FINISH_ANIMATIONS='(() => {
  const found = [];
  // Web Animations API — Framer Motion (when using waapi backend), CSS animations, motion
  try {
    if (typeof document.getAnimations === "function") {
      const anims = document.getAnimations();
      let n = 0;
      anims.forEach(a => { try { a.finish(); n++; } catch (e) {} });
      if (n) found.push("waapi:" + n);
    }
  } catch (e) {}
  // Webpack-bundled GSAP/ScrollTrigger — when ESM-imported and not exposed on window
  // (Next.js, Vite, modern bundlers). Probe webpack module factories for ScrollTrigger
  // and gsap source signatures, stash refs on window for the per-section finish_js.
  try {
    const wp = window.webpackChunk_N_E || window.webpackChunk;
    if (wp && Array.isArray(wp) && !window.__sc_wp_probed) {
      window.__sc_wp_probed = true;
      let cap = null;
      try { wp.push([["__sc_probe_" + Date.now()], {}, (r) => { cap = r; }]); } catch (e) {}
      if (cap && cap.m) {
        let stId = null, gsapId = null;
        for (const id of Object.keys(cap.m)) {
          try {
            const src = cap.m[id].toString();
            if (src.length < 1000) continue;
            if (!stId && /scrollerProxy/.test(src) && /ScrollTrigger/.test(src)) stId = id;
            if (!gsapId && /globalTimeline/.test(src) && /tweenLite/i.test(src)) gsapId = id;
            if (stId && gsapId) break;
          } catch (e) {}
        }
        if (stId) {
          try {
            const m = cap(stId);
            const ST = (m && m.default && typeof m.default.getAll === "function") ? m.default
                     : (m && typeof m.getAll === "function") ? m : null;
            if (ST) window.__sc_st = ST;
          } catch (e) {}
        }
        if (gsapId) {
          try {
            const m = cap(gsapId);
            const cands = [m && m.default, m && m.gsap, m];
            for (const c of cands) {
              if (c && c.globalTimeline && typeof c.globalTimeline.getChildren === "function") {
                window.__sc_gsap = c;
                break;
              }
            }
          } catch (e) {}
        }
      }
    }
  } catch (e) {}
  // GSAP ScrollTrigger — disable all triggers FIRST so progress(1) below
  // is not immediately re-synced to current scroll position by scrub:true.
  try {
    const ST = (window.ScrollTrigger) || window.__sc_st || (window.gsap && window.gsap.core && window.gsap.core.globals && window.gsap.core.globals().ScrollTrigger);
    if (ST && typeof ST.getAll === "function") {
      let n = 0;
      ST.getAll().forEach(st => {
        try {
          if (st.animation && typeof st.animation.progress === "function") st.animation.progress(1, false);
          if (typeof st.disable === "function") { st.disable(false, false); n++; }
        } catch (e) {}
      });
      if (n) found.push("scrollTrigger-disabled:" + n);
    }
  } catch (e) {}
  // GSAP — jump every active tween/timeline to its end
  try {
    const gs = window.gsap || window.__sc_gsap;
    if (gs && gs.globalTimeline && typeof gs.globalTimeline.getChildren === "function") {
      const items = gs.globalTimeline.getChildren(true, true, true);
      let n = 0;
      items.forEach(t => { try { if (typeof t.progress === "function") { t.progress(1, false); n++; } } catch (e) {} });
      found.push("gsap:" + n);
    }
  } catch (e) {}
  // anime.js v3 — running is an array of active instances
  try {
    if (window.anime && Array.isArray(window.anime.running)) {
      const list = window.anime.running.slice();
      let n = 0;
      list.forEach(a => { try { a.seek(a.duration); a.pause(); n++; } catch (e) {} });
      found.push("anime:" + n);
    }
  } catch (e) {}
  // Lottie — lottie-web (window.lottie.getRegisteredAnimations) + <lottie-player>/<dotlottie-player> elements
  try {
    let n = 0;
    if (window.lottie && typeof window.lottie.getRegisteredAnimations === "function") {
      window.lottie.getRegisteredAnimations().forEach(a => {
        try {
          const last = (typeof a.totalFrames === "number" ? a.totalFrames : 1) - 1;
          a.goToAndStop(Math.max(0, last), true);
          n++;
        } catch (e) {}
      });
    }
    document.querySelectorAll("lottie-player, dotlottie-player").forEach(el => {
      try { if (typeof el.seek === "function") el.seek("100%"); if (typeof el.pause === "function") el.pause(); n++; } catch (e) {}
    });
    if (n) found.push("lottie:" + n);
  } catch (e) {}
  return found.join(",") || "none";
})()'

if [ "${SKIP_FINISH_ANIMATIONS:-0}" != "1" ]; then
  REF_FIN=$(ref_eval "$FINISH_ANIMATIONS" 2>/dev/null | tail -1)
  IMPL_FIN=$(agent-browser --session "$SESSION_IMPL" eval "$FINISH_ANIMATIONS" 2>/dev/null | tail -1)
  if [ -n "$REF_FIN" ] && [ "$REF_FIN" != '"none"' ]; then
    echo "  ▸ Animation libs finished — ref: $REF_FIN, impl: $IMPL_FIN"
  fi
fi

# Hide images to reduce AE noise from dynamic content (thumbnails, ads, etc.)
HIDE_IMAGES_JS='(() => {
  const style = document.createElement("style");
  style.id = "__no_images__";
  style.textContent = "img, picture, video, iframe { visibility: hidden !important; }";
  document.head.appendChild(style);
  document.querySelectorAll("*").forEach(el => {
    if (el.style && el.style.backgroundImage) el.style.backgroundImage = "none";
  });
  new MutationObserver(muts => {
    muts.forEach(m => m.addedNodes.forEach(n => {
      if (n.style && n.style.backgroundImage) n.style.backgroundImage = "none";
      if (n.querySelectorAll) n.querySelectorAll("[style*=background-image]").forEach(el => { el.style.backgroundImage = "none"; });
    }));
  }).observe(document.body, { childList: true, subtree: true });
})()'

# Hide <canvas> elements (WebGL/Three.js/etc.) — their content is dynamic per-frame
# so it would dominate AE without representing real structural diffs.
HIDE_CANVAS_JS='(() => {
  const style = document.createElement("style");
  style.id = "__no_canvas__";
  style.textContent = "canvas { visibility: hidden !important; }";
  document.head.appendChild(style);
})()'

if [ "$NO_IMAGES" = "1" ]; then
  echo "▸ Hiding images (NO_IMAGES=1)..."
  ref_eval "$HIDE_IMAGES_JS" 2>/dev/null || true
  agent-browser --session "$SESSION_IMPL" eval "$HIDE_IMAGES_JS" 2>/dev/null || true
fi

if [ "${NO_CANVAS:-0}" = "1" ]; then
  echo "▸ Hiding canvases (NO_CANVAS=1)..."
  ref_eval "$HIDE_CANVAS_JS" 2>/dev/null || true
  agent-browser --session "$SESSION_IMPL" eval "$HIDE_CANVAS_JS" 2>/dev/null || true
fi

sleep 1

# ── Detect the actual scroll container ──
# Lenis / locomotive-scroll / overflow:hidden body sites move the document
# scrollbar to an inner wrapper; window.scrollTo silently no-ops on those,
# producing identical screenshots at every "scroll position". Detect once
# per session and reuse for all subsequent scroll commands.
DETECT_SCROLLER_JS='(() => {
  const dh = document.documentElement.scrollHeight;
  const dc = document.documentElement.clientHeight;
  if (dh > dc + 100) return "__document__";
  let best = null;
  document.querySelectorAll("*").forEach(el => {
    const cs = getComputedStyle(el);
    if ((cs.overflowY === "auto" || cs.overflowY === "scroll" || cs.overflowY === "hidden")
        && el.scrollHeight > el.clientHeight + 100) {
      if (!best || el.scrollHeight > best.sh) best = { el, sh: el.scrollHeight };
    }
  });
  if (!best) return "__document__";
  const cls = (typeof best.el.className === "string" ? best.el.className : "")
    .split(" ").find(c => c.startsWith("js-") || c.includes("lenis") || c.includes("scroll"));
  return best.el.tagName.toLowerCase() + (cls ? "." + cls : "");
})()'
_unwrap_scroller() {
  python3 -c "import sys, json; v=sys.argv[1]; print(json.loads(v) if v.startswith('\"') else v)" "$1" 2>/dev/null || echo "__document__"
}
# Validate the detected selector against a strict allow-list before it flows into
# downstream Python f-strings. Detection produces values like `div.js-foo`; anything
# else (special chars, malformed) falls back to __document__. Matches v0.4.2's
# transition-compare.sh hardening discipline.
_validate_scroller() {
  local sel="$1"
  if [ "$sel" = "__document__" ] || [[ "$sel" =~ ^[a-z][a-z0-9]*(#[a-zA-Z][a-zA-Z0-9_-]*)?(\.[a-zA-Z][a-zA-Z0-9_-]*)?$ ]]; then
    echo "$sel"
  else
    echo "__document__"
  fi
}
REF_SCROLLER_SEL=$(_unwrap_scroller "$(ref_eval "$DETECT_SCROLLER_JS" 2>&1 | tail -1)")
IMPL_SCROLLER_SEL=$(_unwrap_scroller "$(agent-browser --session "$SESSION_IMPL" eval "$DETECT_SCROLLER_JS" 2>&1 | tail -1)")
REF_SCROLLER_SEL=$(_validate_scroller "$REF_SCROLLER_SEL")
IMPL_SCROLLER_SEL=$(_validate_scroller "$IMPL_SCROLLER_SEL")
[ -z "$REF_SCROLLER_SEL" ] && REF_SCROLLER_SEL="__document__"
[ -z "$IMPL_SCROLLER_SEL" ] && IMPL_SCROLLER_SEL="__document__"
if [ "$REF_SCROLLER_SEL" != "__document__" ] || [ "$IMPL_SCROLLER_SEL" != "__document__" ]; then
  echo "  ▸ Inner scroll container detected (Lenis/locomotive-style)"
  echo "    ref:  $REF_SCROLLER_SEL"
  echo "    impl: $IMPL_SCROLLER_SEL"
fi

# Build per-session scroll JS — falls back to window.scrollTo when the scroller is __document__.
_scroll_js() {
  local sel="$1"; local y="$2"
  if [ "$sel" = "__document__" ]; then
    echo "(() => { window.scrollTo(0, $y); return $y; })()"
  else
    echo "(() => { const w = document.querySelector('$sel'); if (!w) { window.scrollTo(0, $y); return $y; } w.scrollTop = $y; w.dispatchEvent(new Event('scroll')); return w.scrollTop; })()"
  fi
}

# ── Pre-scroll: trigger lazy-loaded content before fingerprint extraction ──
# Sites with IntersectionObserver-based lazy loading will have empty innerText
# for off-screen sections at load time. Scrolling through the full page forces
# all lazy content to load before we build section fingerprints.
# This prevents MATCH_COUNT=0 on sites with aggressive lazy loading.
#
# Round-trip + settle: previous version did `scrollTo(0, total)` then instantly
# `scrollTo(0, 0)`. Sites whose scroll handler toggles body/progress classes
# based on visited scroll thresholds (e.g. `body.-postManifest`, `-active`,
# `--progress` CSS vars) often did NOT remove those classes when teleported
# back to top, because the scroll handler is RAF-debounced and the round-trip
# happened in a single frame. The captured ref then carried artifact state
# (cards display:none, color overrides) that the impl correctly cleared,
# producing huge AE that wasn't a real visual mismatch. New behavior:
# scroll DOWN in steps, scroll UP in the same steps (so each intermediate
# scroll position fires a handler tick), then a multi-frame settle dispatching
# scroll events at y=0 to give handlers full opportunity to reset.
echo "▸ Pre-scrolling to trigger lazy content..."
_pre_scroll_js() {
  local sel="$1"
  if [ "$sel" = "__document__" ]; then
    cat <<'JSEOF'
(() => {
  window.__SC_PRESCROLL_DONE = 0;
  const total = document.documentElement.scrollHeight;
  const step = Math.max(window.innerHeight * 0.8, 400);
  let y = 0;
  let dir = 1;
  const timer = setInterval(() => {
    window.scrollTo(0, y);
    y += step * dir;
    if (dir === 1 && y >= total) { dir = -1; y = total; }
    else if (dir === -1 && y <= 0) {
      clearInterval(timer);
      let n = 0;
      const settle = () => {
        window.scrollTo(0, 0);
        window.dispatchEvent(new Event('scroll'));
        document.dispatchEvent(new Event('scroll'));
        n++;
        if (n < 16) requestAnimationFrame(settle);
        else { window.scrollTo(0, 0); window.__SC_PRESCROLL_DONE = 1; }
      };
      requestAnimationFrame(settle);
    }
  }, 120);
  return total;
})()
JSEOF
  else
    cat <<JSEOF
(() => {
  window.__SC_PRESCROLL_DONE = 0;
  const w = document.querySelector('$sel');
  if (!w) { window.scrollTo(0, document.documentElement.scrollHeight); window.scrollTo(0, 0); window.__SC_PRESCROLL_DONE = 1; return 0; }
  const total = w.scrollHeight;
  const step = Math.max(w.clientHeight * 0.8, 400);
  let y = 0;
  let dir = 1;
  const timer = setInterval(() => {
    w.scrollTop = y;
    w.dispatchEvent(new Event('scroll'));
    y += step * dir;
    if (dir === 1 && y >= total) { dir = -1; y = total; }
    else if (dir === -1 && y <= 0) {
      clearInterval(timer);
      let n = 0;
      const settle = () => {
        w.scrollTop = 0;
        w.dispatchEvent(new Event('scroll'));
        window.dispatchEvent(new Event('scroll'));
        n++;
        if (n < 16) requestAnimationFrame(settle);
        else { w.scrollTop = 0; window.__SC_PRESCROLL_DONE = 1; }
      };
      requestAnimationFrame(settle);
    }
  }, 120);
  return total;
})()
JSEOF
  fi
}

# Wait for the fire-and-forget setInterval round-trip + settle to complete on
# both sessions. Polls for window.__SC_PRESCROLL_DONE === 1; bounded so a
# single hung session can't block the whole comparison.
_wait_prescroll_done() {
  local session="$1"
  local max_seconds="${PRESCROLL_TIMEOUT:-30}"
  local i
  for i in $(seq 1 "$max_seconds"); do
    local out
    out=$(agent-browser --session "$session" eval "(() => window.__SC_PRESCROLL_DONE || 0)()" 2>/dev/null | tail -1 | tr -d '"')
    if [ "$out" = "1" ]; then return 0; fi
    sleep 1
  done
  echo "  ⚠  Pre-scroll did not signal done within ${max_seconds}s on $session — proceeding anyway" >&2
  return 1
}

ref_eval "$(_pre_scroll_js "$REF_SCROLLER_SEL")" > /dev/null 2>&1
agent-browser --session "$SESSION_IMPL" eval "$(_pre_scroll_js "$IMPL_SCROLLER_SEL")" > /dev/null 2>&1
[ "$REUSE_FROZEN_REF" = "1" ] || _wait_prescroll_done "$SESSION_REF"
_wait_prescroll_done "$SESSION_IMPL"
sleep "$WAIT_LAZY_LOAD"  # Extra time for lazy content (images, IO callbacks) to render
ref_eval "$(_scroll_js "$REF_SCROLLER_SEL" 0)" > /dev/null 2>&1
agent-browser --session "$SESSION_IMPL" eval "$(_scroll_js "$IMPL_SCROLLER_SEL" 0)" > /dev/null 2>&1
sleep "$WAIT_SCROLL_SETTLE"

# ── Step 1: Enumerate sections on both sites ──
echo "▸ Enumerating sections..."

ENUMERATE_SECTIONS='(() => {
  const semanticTags = new Set(["section", "footer", "header", "nav", "main"]);
  const containers = [];

  function collect(parent, depth) {
    if (depth > 6) return;
    const children = Array.from(parent.children);

    children.forEach(el => {
      const tag = el.tagName.toLowerCase();
      if (tag === "script" || tag === "style" || tag === "link" || tag === "noscript") return;
      const rect = el.getBoundingClientRect();
      const h = rect.height;
      if (h < 50 || rect.width < 100) {
        // h === 0 indicates a layout-only wrapper that does not size to its
        // descendants (typical of abs-positioned-widget DOMs like Readymag
        // exports — body children such as #root, #mags are 0-height because
        // content lives in nested abs-positioned widgets). Descend so we
        // can find the real visible sections inside.
        if (h === 0 && el.children.length > 0) {
          collect(el, depth + 1);
        }
        return;
      }

      const isSemantic = semanticTags.has(tag);
      const isLargeDiv = tag === "div" && h > window.innerHeight * 0.2;
      const isPageWrapper = h > document.documentElement.scrollHeight * 0.8;

      if (isSemantic) {
        // Descend only when this element directly wraps other layout-level sections
        // (e.g., <main> with <section> children, or <section> wrapping nested <section>s).
        // <header>/<footer>/<nav>/<aside> are internal *content* roles when they appear
        // inside a section (page header, section heading row, table-of-contents nav,
        // sidebar aside) — descending on them loses the wrapping section. Only true
        // layout-level wrappers (section/main) trigger descent here.
        const hasStructuralChild = Array.from(el.children).some(c => {
          const t = c.tagName.toLowerCase();
          return t === "section" || t === "main";
        });
        const structuralDescendantCount = Array.from(el.querySelectorAll("section, main"))
          .filter(c => c !== el).length;
        const hasWrappedStructuralDescendants = tag === "main"
          && structuralDescendantCount >= 2
          && h > window.innerHeight * 1.5;
        // Webflow / single-main pages collapse 17+ visible sub-sections into one giant
        const isJumboMain = tag === "main"
          && el.children.length > 3
          && h > window.innerHeight * 1.5;
        // Webflow CMS patterns can wrap multiple semantic sub-sections in a
        // generic outer <section>. The outer <section> looks like a single
        // container, so a shallow detector can add it and never descend,
        // missing named sub-sections. Detect
        // this shape: a tall <section> whose direct children include ≥2
        // distinctly-named-class divs each large enough to be a section.
        const hasMultipleNamedSubsections = (() => {
          if (tag !== "section" || h <= window.innerHeight * 1.5) return false;
          const namedDivKids = Array.from(el.children).filter(c => {
            if (c.tagName.toLowerCase() !== "div") return false;
            if (!c.className || typeof c.className !== "string") return false;
            const r = c.getBoundingClientRect();
            return r.height > window.innerHeight * 0.25;
          });
          if (namedDivKids.length < 2) return false;
          const distinctClasses = new Set(
            namedDivKids.map(d => d.className.trim().split(/\s+/)[0]).filter(Boolean)
          );
          return distinctClasses.size >= 2;
        })();
        if (hasStructuralChild || isJumboMain || hasWrappedStructuralDescendants
            || hasMultipleNamedSubsections) {
          collect(el, depth + 1);
        } else {
          containers.push({ el, tag, rect });
        }
      } else if (isLargeDiv) {
        // If this div wraps most of the page, descend into it instead
        if (isPageWrapper) {
          collect(el, depth + 1);
        } else {
          // Check if this div has semantic children — if so, descend
          const hasSemanticChildren = Array.from(el.children).some(c =>
            semanticTags.has(c.tagName.toLowerCase())
          );
          if (hasSemanticChildren) {
            collect(el, depth + 1);
          } else {
            containers.push({ el, tag, rect });
          }
        }
      } else if (tag === "div" && h > 100) {
        collect(el, depth + 1);
      }
    });
  }

  collect(document.body, 0);

  // Deduplicate: remove parents that contain other found sections
  const filtered = containers.filter((c, i) =>
    !containers.some((other, j) => j !== i && c.el.contains(other.el) && c.el !== other.el)
  );

  filtered.sort((a, b) => a.rect.top - b.rect.top);

  return filtered.map((c, i) => {
    const el = c.el;
    const rect = el.getBoundingClientRect();
    const scrollY = window.scrollY;

    // Extract text fingerprint (first 100 chars of visible text, normalized)
    const text = el.innerText || "";
    const words = text.replace(/\\s+/g, " ").trim().substring(0, 200);
    const fingerprint = words.substring(0, 100).toLowerCase().replace(/[^a-z0-9 ]/g, "");
    // Full visible-text word string (not truncated to 100 chars) — drives the
    // text-content pairing signal so sections match by what they SAY, not by
    // class name. Capped at 800 chars to bound JSON size.
    const textWords = text.replace(/\\s+/g, " ").trim().toLowerCase().replace(/[^a-z0-9 ]/g, " ").replace(/\\s+/g, " ").trim().substring(0, 800);

    // Check for SVGs
    const svgs = el.querySelectorAll("svg");
    const hasSvgText = [...svgs].some(svg => {
      const paths = svg.querySelectorAll("path");
      if (paths.length < 3) return false;
      const totalD = [...paths].reduce((sum, p) => sum + (p.getAttribute("d")?.length || 0), 0);
      return totalD > 500;
    });

    // Get rendering info
    const cs = getComputedStyle(el);

    return {
      index: i,
      tag: c.tag,
      id: el.id || null,
      className: (el.className?.toString?.() || "").substring(0, 80),
      fingerprint,
      textWords,
      hasSvgText,
      rect: {
        top: Math.round(rect.top + scrollY),
        left: Math.round(rect.left),
        width: Math.round(rect.width),
        height: Math.round(rect.height),
      },
      display: cs.display,
      gridCols: cs.gridTemplateColumns !== "none" ? cs.gridTemplateColumns : null,
      childCount: el.children.length,
    };
  });
})()'

# Ref enumeration is skipped in frozen-ref mode — the cached ref-sections.json
# is reused as-is (a stdout redirect through ref_browser would truncate it).
if [ "$REUSE_FROZEN_REF" != "1" ]; then
  agent-browser --session "$SESSION_REF" eval "$ENUMERATE_SECTIONS" > "$DIR/sections/ref-sections.json" 2>&1
fi
agent-browser --session "$SESSION_IMPL" eval "$ENUMERATE_SECTIONS" > "$DIR/sections/impl-sections.json" 2>&1

IMPL_SEMANTIC_CANDIDATES='(() => {
  const selectors = "main, section, header, footer, nav, article, [id]";
  const seen = new Set();

  return Array.from(document.querySelectorAll(selectors)).flatMap((el, i) => {
    if (seen.has(el)) return [];
    seen.add(el);

    const rect = el.getBoundingClientRect();
    if (rect.height < 50 || rect.width < 100) return [];

    const text = el.innerText || "";
    const words = text.replace(/\s+/g, " ").trim().substring(0, 200);
    const fingerprint = words.substring(0, 100).toLowerCase().replace(/[^a-z0-9 ]/g, "");
    const textWords = text.replace(/\s+/g, " ").trim().toLowerCase().replace(/[^a-z0-9 ]/g, " ").replace(/\s+/g, " ").trim().substring(0, 800);
    const svgs = el.querySelectorAll("svg");
    const hasSvgText = [...svgs].some(svg => {
      const paths = svg.querySelectorAll("path");
      if (paths.length < 3) return false;
      const totalD = [...paths].reduce((sum, p) => sum + (p.getAttribute("d")?.length || 0), 0);
      return totalD > 500;
    });
    const cs = getComputedStyle(el);

    return [{
      index: i,
      tag: el.tagName.toLowerCase(),
      id: el.id || null,
      className: (el.className?.toString?.() || "").substring(0, 80),
      fingerprint,
      textWords,
      hasSvgText,
      rect: {
        top: Math.round(rect.top + window.scrollY),
        left: Math.round(rect.left),
        width: Math.round(rect.width),
        height: Math.round(rect.height),
      },
      display: cs.display,
      gridCols: cs.gridTemplateColumns !== "none" ? cs.gridTemplateColumns : null,
      childCount: el.children.length,
    }];
  });
})()'

# section-map.json ground truth — observed benchmark gap:
# the runtime ENUMERATE_SECTIONS JS above descends `<main>` only when its
# children include `<section>` or `<main>` (line 709-712). When the ref's
# `<main>` contains only `<div>` children, enumeration collapses many
# visible sections into a single "section-0" container. result.txt then
# only carries 2 rows (section-0 + footer) — the rest never compared at all.
#
# extraction-time section-map.json already records 16 sections with their
# semantic tags + selectors (its tag-attribution is best-effort but its
# Y-coordinate ranges are reliable). When that file exists, override
# ref-sections.json with its entries so the matcher sees the full set.
# Falls back to the runtime enumeration when section-map.json is absent
# or doesn't validate.
if [ -f "$DIR/section-map.json" ]; then
  agent-browser --session "$SESSION_IMPL" eval "$IMPL_SEMANTIC_CANDIDATES" > "$DIR/sections/impl-semantic-candidates.json" 2>&1 || true
  PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
    python3 -m ui_clone.section_compare_sections augment-impl \
    "$DIR/section-map.json" \
    "$DIR/sections/impl-sections.json" \
    "$DIR/sections/impl-semantic-candidates.json" 2>/dev/null || true

  python3 - "$DIR/section-map.json" "$DIR/sections/ref-sections.json" 2>/dev/null <<'PY' || true
import json
import sys
import os
sm_path, out_path = sys.argv[1], sys.argv[2]
try:
    sm = json.load(open(sm_path))
except Exception:
    sys.exit(0)
sections = sm.get("sections") if isinstance(sm, dict) else None
if not isinstance(sections, list) or len(sections) < 3:
    # Either the file is malformed or has fewer sections than runtime —
    # don't override.
    sys.exit(0)
import re
def _norm_text(raw):
    return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", str(raw or "").lower())).strip()[:800]
# Preserve the real innerText the runtime enumerator captured just before this
# override rewrote ref-sections.json. The synthesis has no innerText of its
# own, so without this the text-content matcher would see only class-derived
# seeds on the ref side. Build identity -> textWords lookups so a synthesized
# row inherits the live text of the matching runtime row.
_runtime_by_id = {}
_runtime_by_cls = {}
try:
    _prev = json.load(open(out_path))
except Exception:
    _prev = []
if isinstance(_prev, list):
    for pr in _prev:
        if not isinstance(pr, dict):
            continue
        tw = str(pr.get("textWords") or "").strip()
        if not tw:
            continue
        pid = str(pr.get("id") or "").strip()
        if pid:
            _runtime_by_id.setdefault(pid, tw)
        for tok in str(pr.get("className") or "").split():
            if len(tok) >= 4:
                _runtime_by_cls.setdefault(tok, tw)
# Synthesize ref-sections rows in the shape ENUMERATE_SECTIONS returns,
# preserving the index-by-Y order section-map.json already uses.
#
# Key compatibility: section-map.json (extraction-time ground truth) uses
# `top`/`cls`/`height`. Older synthesis code (and some test fixtures) used
# `y`/`class`/`height`. Read both — bug observed in the 3-round benchmark
# where every section's rect.top fell back to 0 because the synthesis only
# read `y`, producing a "phantom ref" with collapsed coords that triggered
# uniform AE/Mpx ~950k across every section and 632 wasted retry iterations.
out = []
# Fix 12 — drop layout-only zero-height wrappers from synthesis.
# V8 (d4b369d) section-map had 15 sections but all carried `height: 0` for
# the same reason scroll-reveal animations leave them collapsed at capture
# time. Including them in ref-sections produced 5 catastrophic-AE rows
# (~900k) that dragged ae_avg from a real ~250k up to 509k. Skip entries
# below the minimum-visible threshold so synthesis matches only real
# content sections.
_MIN_VISIBLE_HEIGHT = 50
sections_sorted = sorted(sections, key=lambda s: s.get("top") or s.get("y") or 0)
for i, s in enumerate(sections_sorted):
    # section-map.json may use either `height` (runtime enumeration shape) or
    # `h` (extraction-time short shape). Read both. Without this fallback,
    # every section reads as h=0 and gets filtered, collapsing the override
    # to zero rows and re-using the runtime 1-giant-section enumeration.
    h_raw = int(s.get("height") or s.get("h") or 0)
    if h_raw < _MIN_VISIBLE_HEIGHT:
        # Layout-only wrapper, not a content section — skip.
        continue
    cls = (s.get("cls") or s.get("className") or s.get("class") or "").strip()
    # Same fallback for id: extraction-time section-map uses `name`.
    sid = s.get("id") or s.get("name")
    tag = s.get("tag") or "section"
    y = int(s.get("top") or s.get("y") or 0)
    h = h_raw
    x = int(s.get("left") or s.get("x") or 0)
    w = int(s.get("width") or s.get("w") or 1440)
    fp_seed = sid or cls or f"sec-{i}"
    # fingerprint: lowercase alphanumeric, take first 100 chars of the
    # human-readable seed. Runtime ENUMERATE_SECTIONS uses innerText; we
    # don't have it here, so the class/id-derived fingerprint serves as a
    # stable matching key for impl rows that DO have innerText. The
    # matcher already tolerates partial-match fingerprints (substring).
    fp = re.sub(r"[^a-z0-9 ]", "", fp_seed.lower())[:100]
    # textWords: prefer real runtime innerText (by id, then class token),
    # then section-map textPreview, then the class/id seed as a last resort.
    tw = ""
    if sid and str(sid).strip() in _runtime_by_id:
        tw = _runtime_by_id[str(sid).strip()]
    if not tw:
        for tok in cls.split():
            if len(tok) >= 4 and tok in _runtime_by_cls:
                tw = _runtime_by_cls[tok]
                break
    if not tw:
        tw = _norm_text(s.get("textPreview"))
    if not tw:
        tw = _norm_text(fp_seed)
    out.append({
        "index": i,
        "tag": tag,
        "id": sid,
        "className": cls[:80],
        "fingerprint": fp,
        "textWords": tw,
        "hasSvgText": False,
        "rect": {"top": y, "left": x, "width": w, "height": h},
        "display": s.get("display") or "block",
        "gridCols": s.get("gridCols") or None,
        "childCount": int(s.get("childCount") or 0),
    })
# Fix 12 safety — if the h>=50 filter removed too many sections, the
# resulting synthesis is degenerate. Fall back to the runtime enumeration
# (don't write a thin override) so section-compare can still pair real
# content sections after they scroll-reveal.
if len(out) < 3:
    sys.exit(0)
with open(out_path, "w") as fh:
    json.dump(out, fh)
PY
  if [ -s "$DIR/sections/ref-sections.json" ] && head -1 "$DIR/sections/ref-sections.json" | grep -q "^\["; then
    echo "▸ ref-sections.json overridden from section-map.json (extraction-time ground truth)" >&2
  fi
fi

_parse_section_count() {
  local f="$1"
  # Check for JS error (agent-browser eval failure) before parsing JSON
  local first
  first=$(head -1 "$f" 2>/dev/null || echo "")
  if echo "$first" | grep -qE '^(SyntaxError|TypeError|ReferenceError|Error:|Uncaught|\[object)'; then
    echo "JS_ERROR: $first" >&2
    echo "0"
    return
  fi
  python3 -c "
import json, sys
try:
    d = json.loads(open('$f').read())
    print(len(d) if isinstance(d, list) else 0)
except Exception as e:
    print(0, file=sys.stderr)
    print(0)
" 2>/dev/null || echo "0"
}
REF_COUNT=$(_parse_section_count "$DIR/sections/ref-sections.json")
IMPL_COUNT=$(_parse_section_count "$DIR/sections/impl-sections.json")

echo "  Ref:  $REF_COUNT sections"
echo "  Impl: $IMPL_COUNT sections"

if [ "$REF_COUNT" = "0" ] || [ "$IMPL_COUNT" = "0" ]; then
  echo "ERROR: Failed to enumerate sections — check if pages loaded correctly"
  echo "  Ref JSON head: $(head -3 "$DIR/sections/ref-sections.json" 2>/dev/null || echo "(missing)")"
  echo "  Impl JSON head: $(head -3 "$DIR/sections/impl-sections.json" 2>/dev/null || echo "(missing)")"
  exit 1
fi

# ── Step 2: Match sections by text-content + identity similarity ──
# Pairing logic lives in ui_clone.section_compare_sections.pair_sections so it
# is unit-tested (tests/measure/test_section_compare.py). It anchors pairs by
# (1) semantic-key identity, (2) className-exact tokens, (3) TEXT-CONTENT
# similarity (what a section SAYS), and only then falls back to same-tag + DOM
# order. Text content is what lets a faithful Tailwind clone pair against a
# CSS-Modules reference whose class signatures share nothing. This is a
# PAIRING-only signal — the AE/dssim/structure comparison downstream is
# unchanged, so better pairing yields more accurate measurement, never an
# easier pass.
echo "▸ Matching sections..."

PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
  python3 -m ui_clone.section_compare_sections pair \
  "$DIR/sections/ref-sections.json" \
  "$DIR/sections/impl-sections.json" \
  "$DIR/sections/matches.json" 2>&1

# ── Step 3: Crop element screenshots per matched section ──
echo "▸ Capturing section screenshots..."

MATCH_COUNT=$(python3 -c "import json; m=json.load(open('$DIR/sections/matches.json')); print(len([x for x in m if x.get('ref') and x.get('impl')]))" 2>/dev/null || echo "0")

# EC-SC-3: Zero matches means fingerprint extraction failed on one side (wrong URL, JS error,
# CSP-blocked eval). Continuing would compare stale screenshots from a previous run —
# a false-pass risk. Exit early with a clear diagnostic.
if [ "$MATCH_COUNT" -eq 0 ]; then
  echo ""
  echo "ERROR: 0 sections matched between ref and impl."
  echo "  Possible causes:"
  echo "    1. Wrong URL passed (orig vs impl swapped?)"
  echo "    2. JS eval blocked by CSP on one page"
  echo "    3. Page not fully loaded — try adding a delay or scrolling to trigger lazy-load"
  echo "    4. Single-section site — fingerprint matching needs ≥2 sections"
  echo ""
  echo "  Debug: check $DIR/sections/matches.json"
  echo "  Expected: entries with both 'ref' and 'impl' populated"
  # Write a FAIL result.txt so the Stop gate gives a useful message instead of "not run"
  mkdir -p "$DIR/sections"
  {
    echo "| Section | AE | AE/Mpx | Severity | Status |"
    echo "|---------|-----|--------|----------|--------|"
    echo "| (none) | — | — | — | ❌ |"
    echo ""
    echo "**Result: 0 PASS, 1 FAIL, 0 SKIP**"
    echo ""
    echo "FAILURE REASON: 0 sections matched — fingerprint extraction failed."
    echo "Re-run section-compare.sh after fixing the URL or page load issue."
  } > "$DIR/sections/result.txt"
  exit 1
fi

# ── Step 3a: Record dynamic-mask coverage sidecar ─────────────────────
# Evidence-only artifact for pass-under-mask audits. Do NOT change result.txt:
# check-converged.sh parses that table shape. The active DYNAMIC_SELECTORS are
# evaluated in the REF session at capture time; Python computes exact union
# coverage per matched section and writes {section: coveragePct}.
echo "▸ Recording dynamic-mask coverage..."
MASK_ELEMENTS_FILE="$DIR/sections/mask-elements.json"
if [ "$EXCLUDE_DYNAMIC" = "1" ] && [ "$REUSE_FROZEN_REF" != "1" ]; then
  MASK_SELECTORS_JSON=$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$DYNAMIC_SELECTORS")
  MASK_RECTS_JS="(() => {
    const selectors = ${MASK_SELECTORS_JSON};
    try {
      return Array.from(document.querySelectorAll(selectors)).flatMap((el, index) => {
        const rect = el.getBoundingClientRect();
        if (rect.width <= 0 || rect.height <= 0) return [];
        return [{
          index,
          tag: el.tagName.toLowerCase(),
          id: el.id || null,
          className: (el.className?.toString?.() || '').substring(0, 120),
          top: Math.round((rect.top + window.scrollY) * 100) / 100,
          left: Math.round((rect.left + window.scrollX) * 100) / 100,
          width: Math.round(rect.width * 100) / 100,
          height: Math.round(rect.height * 100) / 100,
        }];
      });
    } catch (_err) {
      return [];
    }
  })()"
  if ! ref_eval "$MASK_RECTS_JS" > "$MASK_ELEMENTS_FILE.tmp" 2>&1; then
    echo "[]" > "$MASK_ELEMENTS_FILE.tmp"
  fi
  python3 - "$MASK_ELEMENTS_FILE.tmp" "$MASK_ELEMENTS_FILE" <<'PY' 2>/dev/null || echo "[]" > "$MASK_ELEMENTS_FILE"
import json
import sys
from pathlib import Path

src = Path(sys.argv[1])
out = Path(sys.argv[2])
raw = src.read_text(encoding="utf-8", errors="ignore")
data = None
for line in reversed(raw.splitlines()):
    try:
        parsed = json.loads(line)
    except json.JSONDecodeError:
        continue
    if isinstance(parsed, list):
        data = parsed
        break
if data is None:
    data = []
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
PY
  rm -f "$MASK_ELEMENTS_FILE.tmp"
else
  echo "[]" > "$MASK_ELEMENTS_FILE"
fi
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
  python3 -m ui_clone.section_compare_sections mask-coverage \
  "$DIR/sections/matches.json" \
  "$MASK_ELEMENTS_FILE" \
  "$DIR/sections/mask-coverage.json" 2>/dev/null || echo "{}" > "$DIR/sections/mask-coverage.json"

SECTION_CAPTURE_DIR="$DIR" \
SECTION_CAPTURE_SESSION_REF="$SESSION_REF" \
SECTION_CAPTURE_SESSION_IMPL="$SESSION_IMPL" \
SECTION_CAPTURE_REF_SCROLLER_SEL="$REF_SCROLLER_SEL" \
SECTION_CAPTURE_IMPL_SCROLLER_SEL="$IMPL_SCROLLER_SEL" \
SECTION_CAPTURE_REUSE_FROZEN_REF="${REUSE_FROZEN_REF:-0}" \
SECTION_CAPTURE_SKIP_FINISH="${SKIP_FINISH_ANIMATIONS:-0}" \
SECTION_CAPTURE_WAIT_SCROLL_SETTLE="${WAIT_SCROLL_SETTLE:-0.5}" \
SECTION_CAPTURE_DYNAMIC_PAUSE_EXTRA="${DYNAMIC_PAUSE_EXTRA:-}" \
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
  python3 -m ui_clone.section_capture "$DIR/sections/matches.json" 2>&1

# ── Perceptual-dense pre-pass (opt-in) ──────────────────────────────
# Computes, per matched section, the DOM structure severity (mirroring the
# Step-5 logic below) and a DENSE flag derived from REF morphology only,
# written to sections/structure-severity.txt as: <name>\t<domSev>\t<dense 0|1>.
# Runs under SECTION_PERCEPTUAL_DENSE=1 (now the DEFAULT); set =0 for the strict
# escape hatch (byte-identical to the pre-promotion strict-AE path).
PERCEPTUAL_REFSHOT_CLEAN=1
if [ "$SECTION_PERCEPTUAL_DENSE" = "1" ]; then
  echo "▸ SECTION_PERCEPTUAL_DENSE=1 — dense sections may pass-by-perceptual"
  echo "  (global dssim ≤ ${SECTION_DSSIM_DENSE_MAX}, zero critical/major DOM delta, no localized band ≥ ${SECTION_DSSIM_LOCAL_FAIL})."
  # A screenshot cheat would fake a low dssim, so the perceptual path is
  # disabled wholesale when ref-screenshot-asset.json reports a violation.
  # This never relaxes the existing global enforcement — only adds a guard.
  if [ -f "$DIR/ref-screenshot-asset.json" ]; then
    _shot_status=$(python3 -c "import json;print(json.load(open('$DIR/ref-screenshot-asset.json')).get('status',''))" 2>/dev/null || echo "")
    if [ "$_shot_status" = "fail" ]; then
      PERCEPTUAL_REFSHOT_CLEAN=0
      echo "  ⛔ ref-screenshot-asset.json status=fail — perceptual pass disabled (screenshot cheat detected)."
    fi
  fi
  python3 - "$DIR/sections/matches.json" "$DIR/sections/structure-severity.txt" <<'PY' 2>/dev/null || true
import json, sys
matches_path, out_path = sys.argv[1], sys.argv[2]
try:
    matches = json.load(open(matches_path))
except Exception:
    open(out_path, "w").close()
    sys.exit(0)
rows = []
for m in matches:
    ref = m.get('ref'); impl = m.get('impl'); name = m.get('name')
    if not ref or not impl or not name:
        continue
    # DENSE classification — REF evidence only (text fingerprint or SVG-text),
    # so deleting impl content can never earn relaxed scoring.
    fp = (ref.get('fingerprint') or '').strip()
    dense = 1 if (len(fp) >= 8 or ref.get('hasSvgText')) else 0
    # DOM structure severity — mirrors the Step-5 logic below.
    issues = []
    if ref.get('hasSvgText') and not impl.get('hasSvgText'):
        issues.append('SVG_TEXT_MISSING')
    if ref.get('gridCols') and not impl.get('gridCols'):
        issues.append('LAYOUT_MISMATCH')
    if ref.get('display') != impl.get('display'):
        issues.append('DISPLAY_MISMATCH')
    rh = (ref.get('rect') or {}).get('height', 0)
    ih = (impl.get('rect') or {}).get('height', 0)
    h_ratio = (ih / rh) if rh > 0 else 1.0
    if rh > 0 and (h_ratio < 0.7 or h_ratio > 1.3):
        issues.append('HEIGHT_MISMATCH')
    rc = ref.get('childCount', 0); ic = impl.get('childCount', 0)
    if rc > 0 and abs(rc - ic) > max(2, rc * 0.3):
        issues.append('CHILD_COUNT_MISMATCH')
    fingerprint_strong = m.get('score', 0) >= 0.85
    sev = 'ok'
    if any(i in ('SVG_TEXT_MISSING', 'LAYOUT_MISMATCH') for i in issues):
        sev = 'critical'
    elif h_ratio < 0.3 or h_ratio > 3.0:
        sev = 'critical'
    elif any(i in ('HEIGHT_MISMATCH', 'DISPLAY_MISMATCH') for i in issues):
        sev = 'major'
    elif 'CHILD_COUNT_MISMATCH' in issues:
        sev = 'minor' if fingerprint_strong else 'major'
    elif issues:
        sev = 'minor'
    rows.append(f"{name}\t{sev}\t{dense}")
with open(out_path, "w") as fh:
    fh.write("\n".join(rows) + ("\n" if rows else ""))
PY
fi

# Perceptual helpers — inert unless SECTION_PERCEPTUAL_DENSE=1 populated the map.
_perceptual_dom_sev() {
  awk -F'\t' -v n="$1" '$1==n{print $2; f=1} END{if(!f)print "ok"}' \
    "$DIR/sections/structure-severity.txt" 2>/dev/null || echo "ok"
}
_perceptual_is_dense() {
  awk -F'\t' -v n="$1" '$1==n && $3=="1"{f=1} END{exit !f}' \
    "$DIR/sections/structure-severity.txt" 2>/dev/null
}
# Returns 0 (true) when a section crop pair has a LOCALIZED structural defect:
# any horizontal band of height SECTION_LOCAL_BAND_PX whose dssim reaches
# SECTION_DSSIM_LOCAL_FAIL. A misplaced/missing element trips this; uniform
# font-AA / idle-drift noise does not. Impl is expected pre-resized to ref dims.
_perceptual_localized_defect() {
  local ref_img="$1" impl_img="$2"
  command -v dssim >/dev/null 2>&1 || return 1
  local size w h
  size=$(magick identify -format "%wx%h" "$ref_img" 2>/dev/null) || return 1
  w=${size%x*}; h=${size#*x}
  case "$w" in ''|*[!0-9]*) return 1 ;; esac
  case "$h" in ''|*[!0-9]*) return 1 ;; esac
  local bandpx="$SECTION_LOCAL_BAND_PX"
  case "$bandpx" in ''|*[!0-9]*) bandpx=200 ;; esac
  [ "$bandpx" -lt 1 ] && bandpx=200
  local tmpd; tmpd=$(mktemp -d) || return 1
  local y bh d defect=1   # defect=1 → "no defect" (return non-zero / false)
  for ((y=0; y<h; y+=bandpx)); do
    bh=$bandpx
    [ $((y + bh)) -gt "$h" ] && bh=$((h - y))
    [ "$bh" -lt 8 ] && continue
    magick "$ref_img" -crop "${w}x${bh}+0+${y}" +repage "$tmpd/r.png" 2>/dev/null
    magick "$impl_img" -crop "${w}x${bh}+0+${y}" +repage "$tmpd/i.png" 2>/dev/null
    d=$(dssim "$tmpd/r.png" "$tmpd/i.png" 2>/dev/null | awk '{print $1}')
    [ -z "$d" ] && continue
    if awk -v d="$d" -v t="$SECTION_DSSIM_LOCAL_FAIL" 'BEGIN{exit !(d+0 >= t+0)}'; then
      defect=0
    fi
  done
  rm -rf "$tmpd"
  return $defect
}

# ── Step 4: AE comparison per section ──
echo ""
echo "▸ Comparing sections..."

RESULTS=""
PASS_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0
SUBSTITUTED_COUNT=0
# NON_STRUCTURAL_PASS_COUNT tracks ONLY real pixel-level passes (ok/minor AE).
# STRUCTURAL_ONLY (substituted) rows DO NOT increment this — they pass the
# layout sniff test but skip pixel diff, so they're not visual evidence on
# their own. Final pass condition (line 1503) requires non-structural evidence
NON_STRUCTURAL_PASS_COUNT=0

# Build a lookup of wrapper-only sections so the AE loop can skip them.
# These have no ref content of their own (sticky-image holders, spacer wrappers).
WRAPPER_NAMES=$(python3 -c "
import json
m = json.loads(open('$DIR/sections/matches.json').read())
print(' '.join(x['name'] for x in m if x.get('wrapper')))
" 2>/dev/null || echo "")

# Guard: nullglob — if no ref PNGs were captured, the glob expands to a literal string
shopt -s nullglob
REF_IMGS=("$DIR/sections/ref/"*.png)
shopt -u nullglob
if [ ${#REF_IMGS[@]} -eq 0 ]; then
  echo "ERROR: No ref section images captured in $DIR/sections/ref/ — check Step 3 output above"
  exit 1
fi

for REF_IMG in "${REF_IMGS[@]}"; do
  NAME=$(basename "$REF_IMG" .png)
  IMPL_IMG="$DIR/sections/impl/${NAME}.png"

  if [ ! -f "$IMPL_IMG" ]; then
    # A4 (Fix 95) — a ref section with no impl crop means the section never
    # rendered (genuinely missing), not a benign skip. Fail it by default so a
    # build that drops whole sections can't pass with FAIL_COUNT==0; set
    # UI_CLONE_ALLOW_MISSING_SECTIONS=1 to downgrade to a non-blocking skip.
    if [ "${UI_CLONE_ALLOW_MISSING_SECTIONS:-0}" = "1" ]; then
      RESULTS="${RESULTS}| ${NAME} | — | — | — | ⚠️ MISSING impl (allowed) |\n"
      SKIP_COUNT=$((SKIP_COUNT + 1))
    else
      RESULTS="${RESULTS}| ${NAME} | — | — | — | ❌ FAIL (ref section has no impl crop — section missing) |\n"
      FAIL_COUNT=$((FAIL_COUNT + 1))
    fi
    continue
  fi

  # Skip strict AE for STRUCTURAL_WRAPPER sections (ref has no visible content
  # of its own — pixel comparison would always fail against the impl that
  # actually renders something).
  if echo " $WRAPPER_NAMES " | grep -q " ${NAME} "; then
    RESULTS="${RESULTS}| ${NAME} | — | — | wrapper | ⏭️ SKIP (structural wrapper) |\n"
    SKIP_COUNT=$((SKIP_COUNT + 1))
    continue
  fi

  # Skip strict AE for SUBSTITUTED sections (asset-substitution.json declared
  # the impl uses different fonts/images/videos than the ref). Layout regressions
  # are still caught by the structure diff in Step 5 below — only pixel-level
  # comparison is bypassed here.
  IS_SUBSTITUTED=0
  if [ "$SUBSTITUTION_ALL" -eq 1 ]; then
    IS_SUBSTITUTED=1
  elif [ -n "$SUBSTITUTION_PATTERNS" ]; then
    for PAT in $SUBSTITUTION_PATTERNS; do
      case "$NAME" in
        *"$PAT"*) IS_SUBSTITUTED=1; break ;;
      esac
    done
  fi
  if [ "$IS_SUBSTITUTED" -eq 1 ]; then
    if is_motion_structural_only_protected "$NAME"; then
      RESULTS="${RESULTS}| ${NAME} | — | — | motion-critical | ❌ FAIL (motion-critical section cannot use STRUCTURAL_ONLY) |\n"
      FAIL_COUNT=$((FAIL_COUNT + 1))
      continue
    fi
    # STRUCTURAL_ONLY rows are visual-evidence-deferred, not pixel passes.
    # They increment SUBSTITUTED_COUNT only — NOT PASS_COUNT — so the footer's
    # PASS count stays equal to the ✅ rows in the table (no hidden inflation).
    # Convergence still accepts them: check-converged keys on FAIL==0, and
    # post-implement counts PASS+STRUCTURAL_ONLY as evidence.
    RESULTS="${RESULTS}| ${NAME} | — | — | substituted | 🔁 STRUCTURAL_ONLY |\n"
    SUBSTITUTED_COUNT=$((SUBSTITUTED_COUNT + 1))
    continue
  fi

  # Resize impl to match ref dimensions
  REF_SIZE=$(magick identify -format "%wx%h" "$REF_IMG" 2>/dev/null)
  IMPL_SIZE=$(magick identify -format "%wx%h" "$IMPL_IMG" 2>/dev/null)

  if [ "$REF_SIZE" != "$IMPL_SIZE" ]; then
    magick "$IMPL_IMG" -resize "$REF_SIZE!" -quality 95 "$IMPL_IMG" 2>/dev/null
  fi

  # A4 (Fix 95) — all-black/blank-impl detector. AE/dssim can score a near-black
  # impl crop as "close enough" against a content-bearing ref; catch the
  # omx-style black-hero (a section renders as a near-black band) by comparing
  # luminance directly. Fail ONLY when the impl crop is near-black AND the ref
  # is NOT — a legitimately dark section (dark in both) does not trip.
  IMPL_LUM=$(magick "$IMPL_IMG" -colorspace Gray -format "%[fx:mean] %[fx:standard_deviation]" info: 2>/dev/null)
  REF_LUM=$(magick "$REF_IMG" -colorspace Gray -format "%[fx:mean] %[fx:standard_deviation]" info: 2>/dev/null)
  if [ -n "$IMPL_LUM" ] && [ -n "$REF_LUM" ] && \
     awk -v il="$IMPL_LUM" -v rl="$REF_LUM" \
         -v bm="${UI_CLONE_BLACK_MEAN_MAX:-0.06}" -v bs="${UI_CLONE_BLACK_STD_MAX:-0.06}" '
       BEGIN { split(il, a, " "); split(rl, b, " ");
         impl_black = (a[1] + 0 < bm && a[2] + 0 < bs);
         ref_black  = (b[1] + 0 < bm && b[2] + 0 < bs);
         exit !(impl_black && !ref_black) }'; then
    RESULTS="${RESULTS}| ${NAME} | — | — | black | ❌ FAIL (impl section near-black; ref has content) |\n"
    FAIL_COUNT=$((FAIL_COUNT + 1))
    continue
  fi

  DIFF_IMG="$DIR/sections/diff/${NAME}.png"
  # -fuzz tolerance: pixels with color diff <= fuzz% are considered identical.
  # Filters sub-pixel AA noise, font hinting, paper-texture/JPEG grain — keeping AE on structural divergence.
  FUZZ="${SECTION_FUZZ:-8%}"
  AE=$(magick compare -metric AE -fuzz "$FUZZ" "$REF_IMG" "$IMPL_IMG" "$DIFF_IMG" 2>&1 || true)
  # AE may be scientific notation (e.g. "1.0e+06") for large diffs.
  AE=$(echo "$AE" | head -1 | awk '{ if ($1 ~ /^[0-9.eE+-]+$/) printf "%.0f\n", $1 }')

  if [ -z "$AE" ]; then
    RESULTS="${RESULTS}| ${NAME} | ERROR | — | — | ⚠️ |\n"
    SKIP_COUNT=$((SKIP_COUNT + 1))
    continue
  fi

  # Normalize AE by section pixel area (per megapixel) so a 1200px-tall section
  # isn't unfairly penalized vs a 600px-tall one with identical defect density.
  # Severity tiers below use this normalized value, not raw AE.
  REF_W=$(echo "$REF_SIZE" | cut -dx -f1)
  REF_H=$(echo "$REF_SIZE" | cut -dx -f2)
  AE_PER_MPX=$(awk -v ae="$AE" -v w="$REF_W" -v h="$REF_H" 'BEGIN { area = (w*h)/1000000; if (area > 0) printf "%.0f", ae/area; else print "0" }')

  # Thresholds operate on AE/Mpx (defect density). Default 2000 still works for
  # static content; use SECTION_THRESHOLD=50000 for image/animation-rich pages.
  # Saturation band (AE/Mpx >= 800k) means the per-pixel diff is so dense that
  # AE has lost its gradient — visual-judge can't reduce it with macro tweaks
  # and the agent must return to Phase 4 LLM refinement (typography, asset
  # references, scroll-trigger wiring) before more iteration is useful.
  THRESHOLD="${SECTION_THRESHOLD:-2000}"
  SATURATION="${AE_SATURATION:-800000}"
  DSSIM_FALLBACK="${SECTION_DSSIM_FALLBACK:-1}"
  DSSIM_PASS_MAX="${SECTION_DSSIM_PASS_MAX:-0.015}"
  DSSIM_SCORE=""
  if [ "$DSSIM_FALLBACK" = "1" ] \
     && [ "$AE_PER_MPX" -gt "$THRESHOLD" ] \
     && [ "$AE_PER_MPX" -lt "$SATURATION" ] \
     && command -v dssim >/dev/null 2>&1; then
    DSSIM_SCORE=$(dssim "$REF_IMG" "$IMPL_IMG" 2>/dev/null | awk '{print $1}')
  fi
  # Ref-variance guard. dssim/SSIM is DEGENERATE when the reference crop has
  # ~zero variance (blank / near-uniform): structure & contrast terms collapse,
  # so dssim -> ~0 regardless of impl content as long as the impl is also sparse.
  # That false-passes a blank-ref section via pass-by-dssim/perceptual against any
  # impl. Require real ref content (std >= SECTION_REF_MIN_STD) before trusting
  # any dssim-based pass. Blank crops measure std=0; real content >= ~0.13.
  REF_HAS_VARIANCE=1
  if [ -n "$DSSIM_SCORE" ] && command -v magick >/dev/null 2>&1; then
    REF_STD=$(magick "$REF_IMG" -format "%[fx:standard_deviation]" info: 2>/dev/null)
    if [ -n "$REF_STD" ] && awk -v s="$REF_STD" -v m="${SECTION_REF_MIN_STD:-0.05}" 'BEGIN{exit !(s+0 < m+0)}'; then
      REF_HAS_VARIANCE=0
    fi
  fi
  if [ "$AE_PER_MPX" -le 500 ]; then
    STATUS="✅"
    SEV="ok"
    PASS_COUNT=$((PASS_COUNT + 1))
    NON_STRUCTURAL_PASS_COUNT=$((NON_STRUCTURAL_PASS_COUNT + 1))
  elif [ "$AE_PER_MPX" -le "$THRESHOLD" ]; then
    STATUS="✅"
    SEV="minor"
    PASS_COUNT=$((PASS_COUNT + 1))
    NON_STRUCTURAL_PASS_COUNT=$((NON_STRUCTURAL_PASS_COUNT + 1))
  elif [ -n "$DSSIM_SCORE" ] \
       && [ "$REF_HAS_VARIANCE" = "1" ] \
       && [ "$AE_PER_MPX" -lt "$SATURATION" ] \
       && awk -v d="$DSSIM_SCORE" -v max="$DSSIM_PASS_MAX" 'BEGIN{exit !(d+0 <= max+0)}'; then
    STATUS="✅"
    SEV="pass-by-dssim"
    PASS_COUNT=$((PASS_COUNT + 1))
    NON_STRUCTURAL_PASS_COUNT=$((NON_STRUCTURAL_PASS_COUNT + 1))
  elif [ "$SECTION_PERCEPTUAL_DENSE" = "1" ] \
       && [ "$PERCEPTUAL_REFSHOT_CLEAN" = "1" ] \
       && [ "$REF_HAS_VARIANCE" = "1" ] \
       && [ -n "$DSSIM_SCORE" ] \
       && [ "$AE_PER_MPX" -lt "$SATURATION" ] \
       && _perceptual_is_dense "$NAME" \
       && awk -v d="$DSSIM_SCORE" -v max="$SECTION_DSSIM_DENSE_MAX" 'BEGIN{exit !(d+0 <= max+0)}' \
       && [ "$(_perceptual_dom_sev "$NAME")" != "critical" ] \
       && [ "$(_perceptual_dom_sev "$NAME")" != "major" ] \
       && ! _perceptual_localized_defect "$REF_IMG" "$IMPL_IMG"; then
    # Dense (text/SVG-rich) section whose global divergence is perceptually
    # small AND has no critical/major DOM delta AND no localized structural
    # defect band. Counted as a genuine ✅ pass, like pass-by-dssim. Buggy
    # sections with a low global dssim but a real localized defect (e.g. a
    # misplaced label) FAIL here via the localized-defect band check.
    STATUS="✅"
    SEV="pass-by-perceptual"
    PASS_COUNT=$((PASS_COUNT + 1))
    NON_STRUCTURAL_PASS_COUNT=$((NON_STRUCTURAL_PASS_COUNT + 1))
  elif [ "$AE_PER_MPX" -le $((THRESHOLD * 10)) ]; then
    STATUS="❌"
    SEV="major"
    FAIL_COUNT=$((FAIL_COUNT + 1))
  elif [ "$AE_PER_MPX" -lt "$SATURATION" ]; then
    STATUS="❌"
    SEV="critical"
    FAIL_COUNT=$((FAIL_COUNT + 1))
  else
    STATUS="🌑"
    SEV="saturated"
    FAIL_COUNT=$((FAIL_COUNT + 1))
  fi

  RESULTS="${RESULTS}| ${NAME} | ${AE} | ${AE_PER_MPX} | ${SEV} | ${STATUS} |\n"
done

# A3 (Fix 94) — gate large EXTRA_IN_IMPL sections. pair_sections records impl
# sections that paired with NO ref section as EXTRA_IN_IMPL; the AE loop above
# iterates only ref crops, so a duplicated/misplaced impl block (a hero
# re-rendered at the page bottom, or a dedup-renamed "-2" section) never reaches
# PASS/FAIL and a structurally broken page scores fine. A faithful clone has ~0
# large extra sections. BOUNDED: fail only on tall extras (absolute px floor),
# not a general structural/order diff. Raise UI_CLONE_EXTRA_SECTION_MIN_PX to relax.
EXTRA_SECTION_MIN_PX="${UI_CLONE_EXTRA_SECTION_MIN_PX:-500}"
EXTRA_OUT=$(python3 - "$DIR/sections/matches.json" "$EXTRA_SECTION_MIN_PX" <<'PY' 2>/dev/null || true
import json, sys
from ui_clone.section_compare_sections import find_large_extra_sections
try:
    m = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
if not isinstance(m, list):
    sys.exit(0)
for name, h in find_large_extra_sections(m, int(sys.argv[2])):
    print(f"{name}|{h}")
PY
)
if [ -n "$EXTRA_OUT" ]; then
  while IFS='|' read -r X_NAME X_H; do
    [ -z "$X_NAME" ] && continue
    RESULTS="${RESULTS}| ${X_NAME} | — | ${X_H}px | extra | ❌ FAIL (extra impl section absent from ref — duplicate/misplaced) |\n"
    FAIL_COUNT=$((FAIL_COUNT + 1))
  done <<< "$EXTRA_OUT"
fi

echo ""
echo "| Section | AE | AE/Mpx | Severity | Status |"
echo "|---------|-----|--------|----------|--------|"
echo -e "$RESULTS"
echo ""
echo "**Result: ${PASS_COUNT} PASS, ${FAIL_COUNT} FAIL, ${SKIP_COUNT} SKIP, ${SUBSTITUTED_COUNT} STRUCTURAL_ONLY**"
echo "(Severity is based on AE/Mpx — defect density per megapixel — not raw AE.)"

# Count saturated rows for the agent's stop-decision routing.
SATURATED_COUNT=$(echo -e "$RESULTS" | grep -c "| saturated | 🌑 |" || true)
if [ "$SATURATED_COUNT" -gt 0 ]; then
  echo ""
  echo "⚠ Saturation: ${SATURATED_COUNT} section(s) at AE/Mpx ≥ ${SATURATION} (gradient dead)."
  echo "  Visual-judge cannot reduce these with class/wrapper tweaks alone — the"
  echo "  underlying components are missing typography / fonts / images / scroll-"
  echo "  trigger wiring that Phase 4 LLM refinement should have supplied."
  echo "  Before another visual-judge pass: revisit each saturated section's"
  echo "  impl/src/components/<Name>.tsx and apply the Phase 4 refinement"
  echo "  checklist (Tailwind class swap, asset reference, font variable,"
  echo "  state/handlers, scroll-trigger animation). Then re-run section-compare."
fi

# ── Auto-save result for Stop gate hook ──
mkdir -p "$DIR/sections"
{
  echo "| Section | AE | AE/Mpx | Severity | Status |"
  echo "|---------|-----|--------|----------|--------|"
  echo -e "$RESULTS"
  echo ""
  echo "**Result: ${PASS_COUNT} PASS, ${FAIL_COUNT} FAIL, ${SKIP_COUNT} SKIP, ${SUBSTITUTED_COUNT} STRUCTURAL_ONLY**"
  echo "(Severity is based on AE/Mpx — defect density per megapixel — not raw AE.)"
} > "$DIR/sections/result.txt"

# ── Step 5: Structure diff per section ──
echo ""
echo "▸ Structure comparison..."

python3 -c "
import json

matches = json.loads(open('$DIR/sections/matches.json').read())
diffs = []

for m in matches:
    ref = m.get('ref')
    impl = m.get('impl')
    if not ref or not impl:
        continue

    issues = []

    # Check SVG-as-text mismatch
    if ref.get('hasSvgText') and not impl.get('hasSvgText'):
        issues.append('SVG_TEXT_MISSING: ref has SVG text paths, impl does not')
    if not ref.get('hasSvgText') and impl.get('hasSvgText'):
        issues.append('SVG_TEXT_EXTRA: impl has SVG text paths, ref does not')

    # Check layout system mismatch
    if ref.get('gridCols') and not impl.get('gridCols'):
        issues.append(f'LAYOUT_MISMATCH: ref uses grid ({ref[\"gridCols\"][:40]}), impl does not')
    if ref.get('display') != impl.get('display'):
        issues.append(f'DISPLAY_MISMATCH: ref={ref[\"display\"]}, impl={impl[\"display\"]}')

    # Check height ratio
    rh = ref['rect']['height']
    ih = impl['rect']['height']
    if rh > 0:
        ratio = ih / rh
        if ratio < 0.7 or ratio > 1.3:
            issues.append(f'HEIGHT_MISMATCH: ref={rh}px, impl={ih}px (ratio={ratio:.2f})')

    # Check child count
    rc = ref.get('childCount', 0)
    ic = impl.get('childCount', 0)
    if rc > 0 and abs(rc - ic) > max(2, rc * 0.3):
        issues.append(f'CHILD_COUNT_MISMATCH: ref={rc}, impl={ic}')

    # Classify severity
    rh = ref['rect']['height']
    ih = impl['rect']['height']
    h_ratio = ih / rh if rh > 0 else 1.0
    # When fingerprint similarity is high (>=0.85), the visible content matches
    # closely — child-count differences usually reflect harmless DOM nesting
    # variations (semantic <article> wrappers, extra grid containers) rather
    # than real divergence. Downgrade those to minor.
    score = m.get('score', 0)
    fingerprint_strong = score >= 0.85
    sev = 'ok'
    if any('SVG_TEXT_MISSING' in i or 'LAYOUT_MISMATCH' in i for i in issues):
        sev = 'critical'
    elif h_ratio < 0.3 or h_ratio > 3.0:
        sev = 'critical'
    elif any('HEIGHT_MISMATCH' in i or 'DISPLAY_MISMATCH' in i for i in issues):
        sev = 'major'
    elif any('CHILD_COUNT_MISMATCH' in i for i in issues):
        sev = 'minor' if fingerprint_strong else 'major'
    elif issues:
        sev = 'minor'

    if issues:
        diffs.append({'section': m['name'], 'issues': issues, 'severity': sev, 'score': score})

json.dump(diffs, open('$DIR/sections/structure-diff.json', 'w'), indent=2)

if diffs:
    # Sort by severity: critical first, then major, then minor
    sev_order = {'critical': 0, 'major': 1, 'minor': 2, 'ok': 3}
    diffs.sort(key=lambda d: sev_order.get(d.get('severity', 'ok'), 3))
    print('')
    for d in diffs:
        sev_icon = {'critical': '🔴', 'major': '🟠', 'minor': '🟡'}.get(d['severity'], '⚪')
        print(f'  {sev_icon} [{d[\"severity\"].upper()}] {d[\"section\"]}:')
        for issue in d['issues']:
            print(f'     - {issue}')
    print('')
    crit = sum(1 for d in diffs if d['severity'] == 'critical')
    maj = sum(1 for d in diffs if d['severity'] == 'major')
    minor = sum(1 for d in diffs if d['severity'] == 'minor')
    print(f'  Severity: {crit} critical, {maj} major, {minor} minor')
    if crit > 0:
        print(f'  ⛔ Fix {crit} CRITICAL issue(s) first — these indicate missing/broken sections')
else:
    print('  ✅ No structural mismatches detected')
" 2>&1

# ── Summary ──
echo ""
echo "═══ Section Compare Complete ═══"
echo "  Screenshots: $DIR/sections/{ref,impl,diff}/"
echo "  Matches:     $DIR/sections/matches.json"
echo "  Diffs:       $DIR/sections/structure-diff.json"

# Persist the impl hash so the next ONLY_IF_CHANGED=1 run can short-circuit.
# Written regardless of pass/fail — the hash records "this code was checked",
# not "this code passed". Re-running on the same broken impl is still wasteful.
if [ "$ONLY_IF_CHANGED" = "1" ] && [ -n "${CURRENT_HASH:-}" ]; then
  echo "$CURRENT_HASH" > "$HASH_FILE"
fi

if [ "$FAIL_COUNT" -gt 0 ]; then
  echo ""
  echo "⛔ ${FAIL_COUNT} section(s) FAILED visual comparison."
  echo "For each FAIL, read the diff image:"
  for REF_IMG in "${REF_IMGS[@]}"; do
    NAME=$(basename "$REF_IMG" .png)
    DIFF_IMG="$DIR/sections/diff/${NAME}.png"
    if [ -f "$DIFF_IMG" ]; then
      echo "  Read $DIFF_IMG"
    fi
  done

  # ── Context injection: Root Cause guidance ──
  SKILL_DIR="$(cd "$(dirname "$0")/../../ui-reverse-engineering" && pwd 2>/dev/null || echo "")"
  DIAGNOSIS="$SKILL_DIR/diagnosis.md"
  SKIP_ZONES="$SKILL_DIR/skip-zones.md"

  echo ""
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  echo "▶ DIAGNOSIS GUIDE — pick the matching root cause:"
  echo ""
  echo "  Layout/structure wrong?    → Root Cause A (DOM Mismatch)"
  echo "  Color/font/weight wrong?   → Root Cause B (CSS Cascade Conflict)"
  echo "  Spacing/shadow wrong?      → Root Cause C (Missing Wrapper)"
  echo "  Element type wrong?        → Root Cause D (Wrong Element Type)"
  echo "  Animation doesn't animate? → Root Cause E (Animation)"
  echo ""
  if [ -f "$SKIP_ZONES" ]; then
    echo "▶ ZONE 5 VERIFICATION RULES (what was skipped):"
    awk '/^## ZONE 5:/,/^---/' "$SKIP_ZONES" | sed -n '1,25p'
    echo ""
  fi
  if [ -f "$DIAGNOSIS" ]; then
    echo "▶ ROOT CAUSE DIAGNOSIS COMMANDS:"
    awk '/^## Root Cause/,/^---/' "$DIAGNOSIS" | sed -n '1,50p'
  fi
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

  exit 1
fi

# ── All sections passed ──
# The Stop hook (section_gate.py) owns WIP marker cleanup — it removes .ui-re-active
# after calling mark_passed("section-compare") and recording "done" in pipeline-state.json.
# section-compare.sh intentionally does NOT remove the marker here, so the Stop hook
# can still fire once more to record the completed state.
TOTAL_ROWS=$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))
# Template-mode escape valve (Common cheat pattern): when asset-substitution.json
# declares wholesale wildcard substitution ("*"), the agent has explicitly
# committed to a design-template clone where copy/imagery are intentionally
# different. The 50% NON_STRUCTURAL_PASS rule has no pass path in that mode
# (every row is STRUCTURAL_ONLY by construction) — so bypass it. The
# substitution declaration itself IS the up-front contract; gaming protection
# only matters when substitution is partial (per-section, not wildcard).
if [ "$SUBSTITUTION_ALL" = "1" ]; then
  # Coverage safeguard (Sonnet-Opus comparison finding): even with template
  # mode (wildcard substitution + paid-features evidence), require minimum
  # section-map coverage. Block the "agent gets a 2-section ref capture by
  # luck, declares wildcard substitution, gate passes" path. Threshold:
  # TOTAL_ROWS must be ≥ ceil(N/2) where N = section-map.json section count.
  SECTION_MAP="$DIR/section-map.json"
  EXPECTED_SECTIONS=$(python3 -c "
import json
try:
    d = json.loads(open('$SECTION_MAP').read())
    if isinstance(d, dict):
        sections = d.get('sections', [])
    elif isinstance(d, list):
        sections = d
    else:
        sections = []
    print(len(sections))
except Exception:
    print(0)
" 2>/dev/null)
  REQUIRED_COVERAGE=$(( (EXPECTED_SECTIONS + 1) / 2 ))
  TOTAL_TEMPLATE=$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))
  if [ "$EXPECTED_SECTIONS" -ge 4 ] && [ "$TOTAL_TEMPLATE" -lt "$REQUIRED_COVERAGE" ]; then
    echo "  ⛔ Template mode REJECTED — only ${TOTAL_TEMPLATE} sections compared but section-map.json has ${EXPECTED_SECTIONS} (need ≥${REQUIRED_COVERAGE})."
    echo "    Wholesale substitution does not excuse low ref-capture coverage. Re-capture ref or fix the matcher."
    exit 1
  fi
  if [ "$FAIL_COUNT" -eq 0 ] && [ "$SKIP_COUNT" -eq 0 ]; then
    echo "  ✓ Section-compare passed (template mode — wildcard substitution, coverage ${TOTAL_TEMPLATE}/${EXPECTED_SECTIONS}) — Stop hook will record completion on next write."
  elif [ "$SKIP_COUNT" -gt 0 ]; then
    echo "  ⚠  $SKIP_COUNT section(s) missing from impl — implement them and re-run section-compare.sh."
  fi
else
  # Require non-structural pixel evidence on at least ceil(TOTAL_ROWS / 2) rows.
  # This prevents an agent from satisfying section-compare with mostly
  # STRUCTURAL_ONLY (substituted) rows when only a couple of sections actually
  # render real pixel-matching content. STRUCTURAL_ONLY is a deferral, not
  # evidence of visual fidelity.
  REQUIRED_NS_PASS=$(( (TOTAL_ROWS + 1) / 2 ))
  if [ "$FAIL_COUNT" -eq 0 ] && [ "$SKIP_COUNT" -eq 0 ] && [ "$NON_STRUCTURAL_PASS_COUNT" -ge "$REQUIRED_NS_PASS" ]; then
    echo "  ✓ Section-compare passed — Stop hook will record completion on next write."
  elif [ "$FAIL_COUNT" -eq 0 ] && [ "$SKIP_COUNT" -eq 0 ]; then
    echo "  ⚠  Section-compare INCOMPLETE: only ${NON_STRUCTURAL_PASS_COUNT}/${TOTAL_ROWS} rows have non-structural pixel passes (need ≥${REQUIRED_NS_PASS})."
    echo "    STRUCTURAL_ONLY rows skip pixel diff — they are not visual evidence."
    echo "    Implement remaining sections with real content (matching ref fonts/assets) or remove the substitution patterns."
    exit 1
  elif [ "$SKIP_COUNT" -gt 0 ]; then
    echo "  ⚠  $SKIP_COUNT section(s) missing from impl — implement them and re-run section-compare.sh."
  fi
fi
