// tests/docs-overflow/docs-viewport-overflow.spec.ts
// INFRA-F67 — sweeps every docs page at a few common viewport widths and
// classifies content inside `.docs-main` that is wider than its own box.
//
// Three-way classification (the middle one is the 2026-07-31 addition, F67 (c-1)):
//   1. intentional-scroll  — the first clipping ancestor scrolls horizontally AND
//      scrolling it to the end really does bring the last ink into view (e.g.
//      Button's `.loading-matrix-wrap`). By design, informational only.
//      ⚠️ 2026-08-12 (INFRA-F112): 「祖先有 overflow-x:auto」**不足以**进这个桶 —— 那样
//      写会漏掉「裁剪发生在 auto 容器内部」的整整一类（见下方可达性段的实证）。进桶要
//      验滚到底之后的墨迹位置，不是验有没有 auto。
//   2. paints-outside-but-visible — nothing actually clips it AND nothing paints
//      over the spill, so every pixel is on screen; it merely renders outside its
//      own box. Report-only: not a defect by itself.
//   3. content-lost — pixels the user genuinely cannot read. Two independent ways
//      that happens, both asserted:
//        (a) painted content extends past the client right edge of the first
//            ancestor whose `overflow-x !== visible` → clipped away;
//        (b) painted content reaches into a FOLLOWING sibling's box; a later
//            sibling paints its own (opaque) background on top → covered.
//      BLOCKING.
//
// Why (b) exists at all: the 2026-07-29 triage of this very bucket found that the
// mechanism which actually eats content here is almost never classic clipping —
// 16/16 findings had `overflow-x: visible` all the way up to `<html>`, i.e. their
// content WAS fully painted, and the two real defects were both "spill lands under
// the next card's opaque background" (icon page's repo path read `…/index.jso`;
// form-item's `FormItem + Select` note vanished under the neighbouring card).
// Report: `docs/internal/_reports/2026-07-29-f67-clipped-unreachable-triage.md`.
//
// ⚠️ Two things NOT to do here, both learned the hard way in that triage:
//   - Do NOT threshold on Δ (`scrollWidth - clientWidth`). Δ 5–75px correlated
//     ZERO with "is content actually lost": Δ=75 (tooltip) was fully readable,
//     Δ=26 (icon) was missing a character.
//   - Do NOT add a jitter/noise tolerance. All 16 findings measured `jitter=0`
//     across two independent page loads — font-metric noise was a hypothesis the
//     data refuted.
//   - Do NOT use `elementFromPoint` for (b): `pointer-events:none` overlays are
//     invisible to it, and an ANCESTOR coming back as topmost only means "empty
//     space here", not "content hidden". Geometry + paint order is unambiguous.
import { orderedPages, getPagePath } from '../../playground/docs/navigation'

if (process.env.VITEST) {
  const { test } = await import('vitest')
  test.skip('Playwright-only sweep — run via pnpm audit:docs-overflow', () => {})
} else {
  const { test, expect } = await import('@playwright/test')

  // ── 扫描面 ──────────────────────────────────────────────────────────────────
  // 390（手机档）于 2026-08-17 纳入（[[INFRA-F118]]）。纳入当天的存量缺陷**没有先修**，
  // 而是具名 + 带日期进了下面两张 shrink-only 债务表。选这条路的理由是**覆盖窗口**：
  // 「先修完再加档」会让 390 档在整个修复期继续零自动覆盖，而新缺陷恰恰是从零覆盖里
  // 进来的；具名入表则让「新缺陷无声进来」当天就闭合，存量照旧显式欠着、且只许还不许借。
  //
  // 纳入当天的实测（`pnpm audit:docs-overflow`，35 页 × 3 档）：
  //   · BLOCKING #1 页级溢出 **6** 条，全部 @390（正文列 clientWidth = 350）
  //   · BLOCKING #2 内容丢失 **8** 条，全部 @390
  //   · 1280 / 1440 两档各 0 条 ⇒ 加 390 不动既有绿，风险面只在新档
  //
  // ⛔ 判断某条「看起来只差一点、大概进不了违例集」时，比的是 `.docs-main` 的 scrollWidth
  //    vs **它自己的 clientWidth（350）**，不是 vs 视口宽（390）。立项阶段曾按后者把 tabs
  //    读成 Δ=1（<+2 容差）并据此怀疑真名单只有 5 条；实跑得 391/350 = **Δ41**，稳在集内。
  //    ⇒ 这类「差多少」的判断一律实跑重取，别在两个不同分母之间心算。
  //
  // ⚠️ 本闸**不在** `.husky/pre-commit`（判据：`grep -n 'overflow' .husky/pre-commit` → 无命中），
  //    只在 GitHub `ci.yml` 的 docs-overflow job。⇒ 它变红拦的是 **CI job / PR 合并**，
  //    本地 `git commit` 不受影响。
  //
  // ⚠️ 改本文件的 `VIEWPORTS` 与判据，落在 owner 2026-08-12 docs 快车道裁定的谓词内
  //    （`46234b9f` 把适用面限定为 `tests/**` 里的断言）。⛔ 但**别去改仓库根的
  //    `playwright.docs-overflow.config.ts`** —— 碰它就跌出那条「严格 AND」谓词；
  //    要加超时请改用本 spec 内的 `test.setTimeout()`。
  const VIEWPORTS = [
    { label: '390', width: 390, height: 844 },
    { label: '1280', width: 1280, height: 900 },
    { label: '1440', width: 1440, height: 900 },
  ]

  // ── BLOCKING #1 的已知债务表（2026-08-17 新建, [[INFRA-F118]]）────────────────
  // 这个桶原先**没有**豁免机制，闸内一句设计声明写着它必须恒空。那句话是闸作者当时的
  // 实现判断，成立的前提是「扫描面里的档位，存量已经清干净了」—— 纳入 390 档时该前提
  // 不再成立，于是按 `KNOWN_CONTENT_LOSS` 的同一范式给它配一张**具名 + 带日期 + 带修法
  // 方向**的表，而不是放宽判据。两者的差别是：放宽判据 = 以后同类缺陷都不响；具名入表
  // = 只有**这几条**不响，且新缺陷照样红、修好了还会被下面的 shrink-only 守卫催着删行。
  //
  // ⛔ **键里不放 px**（只有 pageId + viewport）。溢出量随文案 / 字体 / 组件改动天天变，
  //    把它写进键 = 每次内容微调都要来改表，改到最后没人再核那一行是不是还成立。
  //
  // 加一条之前先自问：这是「真溢出但修它要动 `src/**` 或撞 owner 视觉门」，还是「判据误报」？
  // 后者不该进表、该改判据。
  // ✅ **2026-08-20 清空**（[[INFRA-F118]] 存量 6 页：修法与取证 08-17，**owner 08-20 视觉签核后落地**）。
  // 修法 = `docs.css` 的 ≤640 档给 `.docs-demo` 加 `min-width: 0; overflow-x: auto`
  // （范式沿用 [[INFRA-F113]] 那条 `.docs-demo-grid > .docs-demo-card { min-width: 0 }`）。
  // 六页是**同一族**根因：某个东西的 min-content 下限 > 390 档正文列的 350，而它们的父链
  // 全部穿过 `.docs-demo` ⇒ 一条规则六页齐消（闸自己点名「✅ no longer fires」后才删的这些行）。
  // ⛔ 别照被推翻的旧说法「补 min(…,100%) 夹子」——那个 352px 是 used 值不是声明；
  //    也别照「行内 <code> 不换行」——那批 <code> 全在 `pre.code-block`(overflow-x:auto) 内，是假线索。
  // 表空着是**正常终态**，不是待办。
  const KNOWN_PAGE_OVERFLOW: { pageId: string; viewport: string; since: string; why: string }[] = []
  const isKnownPageOverflow = (r: { pageId: string; viewport: string }) =>
    KNOWN_PAGE_OVERFLOW.some((k) => k.pageId === r.pageId && k.viewport === r.viewport)

  // ── 已知债务表（只许缩不许长，范式同 tests/visual-baseline-coverage.test.ts 的
  //    KNOWN_MISSING_BASELINES）─────────────────────────────────────────────────
  // 为什么需要它：这条判据 2026-07-31 首次实跑就抓到一个**真回归**，而它的修法落在
  // docs 页 CSS 上 → 撞 `.husky/pre-commit` 的视觉门（`VISUAL_COMMIT_APPROVED=1`
  // 只有 owner 看过截图才能设，AI 不得自设）。若直接让桶阻塞，等于在 owner 过目之前
  // 封死所有人的提交；若把判据放宽到「让它绿」，就又变回一个不响的闸。折中 = 具名 +
  // 带日期 + 带根因 + 带修法方向的单条豁免，**新**缺陷照样红。
  //
  // 加一条进这个表之前先自问：这是「真丢内容但修它需要 owner 视觉门」，还是「判据误报」？
  // 后者不该进表，该改判据（首版就有两条 `label.form-item__label` 误报，是改判据解决的，
  // 不是塞进这里）。
  const KNOWN_CONTENT_LOSS: { pageId: string; viewport: string; selector: string; since: string; why: string }[] = [
    // ✅ 2026-07-31 已清空。落地当天这里只有一条（tabs 页 demo 卡把 "Monitor" 压到右邻卡
    // 背景下），owner 当天拍「以 Figma 真源为依据修改」→ 读 Figma
    // (`figma-data/raw/components/tab_list__4452_7148.json`，`Type=Line` node 4605:7196)
    // 得 root w=325 / gap=24 / HORIZONTAL / layoutSizingHorizontal=FILL / **无 layoutWrap**，
    // 三个 Tab/Item 93+92+92 + 2×24 = 325 逐字对上 → 设计里既无换行也无横滚，正解只能是
    // 「给卡片足够宽度」。修法 = 该 section 换用 `.tabs-composition-grid`
    // (`repeat(auto-fit, minmax(min(360px,100%), 1fr))`，360 = 325 + 卡片 padding/border)，
    // 1280 退单列、1440 双列每卡 362（内容 328 ≥ 325），两视口都已截图眼验 Monitor 完整。
    //
    // 表空着是**正常终态**，不是待办。
    //
    // ── 2026-08-17 纳入 390 档带进来的存量（[[INFRA-F118]]）─────────────────────
    // 全部 @390，1280/1440 两档一条都没有。08-17 入表 8 条，**08-20 落地时删掉 6 条**（那 6 条
    // `div.docs-page` 与 KNOWN_PAGE_OVERFLOW 是同一批根因的两个观测面 —— 页级溢出 →
    // 页容器的墨迹被裁，`.docs-demo` 那条 ≤640 规则一改，闸当场点名「✅ no longer fires」，
    // 照它点的名删的行）。剩下的 2 条（form / tooltip）只在 #2 侧出现 —— 仍欠到 2026-09-09。
    //
    // ✅ **2026-09-09 再次清空**。那 2 行的 `why` 写的是「栅格轨道下限撑破 258」，playwright 390 档现取后
    // 只对 form 成立（`div.tvu-form` min-content 258 > 卡片内容区 224，与 F118 六页同族）；tooltip 的轨道
    // 258 = 容器 258 **没撑破**，溢出的是 open 气泡 `div.tooltip-box` 261 vs 卡片 258 = **3px**，字体度量一变
    // 就归零 ⇒ 这一行在 mac 上 STILL FIRING、在 GitHub Linux runner 上 no longer fires，下面的 shrink-only
    // 守卫在两台机器上给出**相反**结论 —— GitHub `docs-overflow` job 09-04 起恒红的第二个原因（第一个是
    // 单测 300s 预算没跟上 105 次导航，同日已改 900s）。
    // 修法 = `docs.css` ≤640 档 `.docs-demo-grid > .docs-demo-card--wide { overflow-x: auto }`（沿用 owner
    // 08-20 拍的「让 demo 卡自己横滚」往下推一层）；两页卡片级截图眼验：tooltip 气泡文字完整、垂直未裁；
    // form 的 Port「+」改前就被切、改后可横滚够到。本地重扫两行均「✅ no longer fires」后照它点的名删。
    // ⛔ 别把这两行加回来当豁免 —— 若它们再红，是 demo 又长了，去改 demo 或 CSS，不是改表。
  ]
  const isKnown = (r: { pageId: string; viewport: string; selector: string }) =>
    KNOWN_CONTENT_LOSS.some((k) => k.pageId === r.pageId && k.viewport === r.viewport && k.selector === r.selector)

  type Lost = {
    pageId: string
    viewport: string
    selector: string
    scrollWidth: number
    clientWidth: number
    /** why it counts as lost — 'clipped' = (a), 'covered' = (b) */
    reason: 'clipped' | 'covered'
    /** px past the first clipping ancestor's client right edge (reason 'clipped') */
    visuallyCutPx: number
    /** the following sibling whose background covers the spill (reason 'covered') */
    coveredBy: string | null
    overlapPx: number
    text: string
  }

  // Give each page load its own browser context (closed immediately after) so
  // Chromium resources don't accumulate across the ~60 navigations. On a
  // resource-constrained GH runner, reusing one long-lived page over the whole
  // sweep exhausts the browser's network resources partway through and
  // `page.goto` starts throwing `net::ERR_INSUFFICIENT_RESOURCES` (first CI run
  // 29402370250 died at /component/slider, ~navigation #28 — a plumbing failure,
  // not a real overflow). A fresh context per iteration bounds peak usage.
  test('docs viewport overflow sweep', async ({ browser }) => {
    // 2026-09-09 单测预算 300s→900s。根 config 的 `timeout: 300_000` 是按「~60 次导航（31 页 × 2 档）」
    // 定的；现取 orderedPages 35 × VIEWPORTS 3 = **105** 次，预算没跟着长 ⇒ GitHub runner 上 09-04 起
    // 20/20 次超时（本地 253s PASS）。按上方头注释的纪律**改在这里、不改根 config**（碰根 config 会跌出
    // owner 08-12 docs 快车道的「严格 AND」谓词；同日一度改了根 config 已回退）。这是时间预算不是判据。
    test.setTimeout(900_000)
    const pageLevelOverflow: { pageId: string; viewport: string; scrollWidth: number; clientWidth: number }[] = []
    const contentLost: Lost[] = []
    const paintsOutsideVisible: { pageId: string; viewport: string; selector: string; scrollWidth: number; clientWidth: number; clipper: string }[] = []
    const intentionalScroll: { pageId: string; viewport: string; selector: string; scrollWidth: number; clientWidth: number }[] = []

    for (const item of orderedPages) {
      for (const vp of VIEWPORTS) {
        const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height } })
        const page = await context.newPage()
        try {
          await page.goto(getPagePath(item.id))
          await page.waitForLoadState('networkidle')

          const result = await page.evaluate(() => {
          function describe(el: Element): string {
            const cls = typeof el.className === 'string' && el.className ? `.${el.className.trim().split(/\s+/).join('.')}` : ''
            return `${el.tagName.toLowerCase()}${cls}`
          }
          function hasOwnHorizontalScroll(el: Element): boolean {
            const cs = getComputedStyle(el)
            return cs.overflowX === 'auto' || cs.overflowX === 'scroll'
          }
          function isEllipsisTruncated(el: Element): boolean {
            const cs = getComputedStyle(el)
            return cs.textOverflow === 'ellipsis' && cs.overflowX === 'hidden'
          }
          function cssAlpha(color: string): number {
            const m = /rgba?\(([^)]+)\)/.exec(color)
            if (!m) return color === 'transparent' ? 0 : 1
            const parts = m[1].split(',').map((s) => parseFloat(s))
            return parts.length >= 4 ? parts[3] : 1
          }
          /** Does this element itself put pixels on screen (vs. being a transparent box)? */
          function paints(el: Element, cs: CSSStyleDeclaration): boolean {
            if (/^(img|svg|canvas|video|input|textarea|select|iframe)$/.test(el.tagName.toLowerCase())) return true
            if (cssAlpha(cs.backgroundColor) > 0.01) return true
            if (cs.backgroundImage !== 'none') return true
            for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
              const w = parseFloat(cs.getPropertyValue(`border-${side.toLowerCase()}-width`))
              const style = cs.getPropertyValue(`border-${side.toLowerCase()}-style`)
              const color = cs.getPropertyValue(`border-${side.toLowerCase()}-color`)
              if (w > 0 && style !== 'none' && cssAlpha(color) > 0.01) return true
            }
            return false
          }
          // Right edge of the actual INK inside `el` — Range-measured text rects plus
          // descendants that themselves paint something. Deliberately NOT the boxes of
          // transparent wrappers: measuring boxes over-reports (a wide empty flex child
          // inflates the number with no pixel to lose) and that produced the first
          // version's two false positives on `label.form-item__label`, whose 3.2px
          // spill lands in a `background-color: rgba(0,0,0,0)` sibling — measured, see
          // the commit message. `el`'s own rect is excluded on purpose: that IS the box.
          function inkRight(el: Element, depth = 0): number {
            let max = -Infinity
            if (depth > 12) return max
            const range = document.createRange()
            for (const node of el.childNodes) {
              if (node.nodeType === 3) {
                if (!node.nodeValue || !node.nodeValue.trim()) continue
                range.selectNodeContents(node)
                for (const r of range.getClientRects()) max = Math.max(max, r.right)
              } else if (node.nodeType === 1) {
                const child = node as Element
                const cs = getComputedStyle(child)
                if (cs.display === 'none' || cs.visibility === 'hidden') continue
                if (paints(child, cs)) max = Math.max(max, child.getBoundingClientRect().right)
                max = Math.max(max, inkRight(child, depth + 1))
              }
            }
            return max
          }
          // (a) First ancestor (element itself included) that does NOT let overflow
          // paint out. `<html>` is the terminal fallback: content past the viewport
          // with no scroll container really is unreachable.
          function firstClipper(el: Element): { node: Element; overflowX: string } {
            let node: Element | null = el
            while (node && node !== document.documentElement) {
              const ox = getComputedStyle(node).overflowX
              if (ox !== 'visible') return { node, overflowX: ox }
              node = node.parentElement
            }
            return { node: document.documentElement, overflowX: getComputedStyle(document.documentElement).overflowX }
          }
          // (b) Does the ink reach into a FOLLOWING sibling that PAINTS? Three
          // conditions, all required — dropping any one produces false positives that
          // were actually measured on this repo (see the commit message):
          //   · the sibling starts at/after our right padding edge and overlaps us
          //     vertically (otherwise it is not in the spill's way at all);
          //   · it comes LATER in document order → it paints on top of us. An earlier
          //     sibling paints underneath, so our text stays readable over it;
          //   · it actually paints (opaque-ish background / image / border / replaced
          //     element). A `background-color: rgba(0,0,0,0)` sibling hides nothing —
          //     that is the `label.form-item__label` case the 2026-07-29 triage had
          //     already eye-classified as "字形完整可读, 不改".
          function spillCover(el: Element, rightEdge: number, ink: number) {
            const parent = el.parentElement
            if (!parent || ink <= rightEdge + 1) return null
            const r = el.getBoundingClientRect()
            const cands = Array.from(parent.children)
              .filter((n) => n !== el)
              .map((n) => ({ n, r2: n.getBoundingClientRect(), cs: getComputedStyle(n) }))
              .filter(({ r2 }) => r2.bottom > r.top + 2 && r2.top < r.bottom - 2 && r2.left >= rightEdge - 1)
              .sort((a, b) => a.r2.left - b.r2.left)
            if (!cands.length) return null
            const { n, r2, cs } = cands[0]
            const paintsLater = !!(el.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING)
            if (!paintsLater) return null
            if (!paints(n, cs)) return null
            const overlapPx = ink - r2.left
            if (overlapPx <= 1) return null
            return { sibling: describe(n), overlapPx: Math.round(overlapPx * 10) / 10 }
          }

          const main = document.querySelector('.docs-main')
          if (!main) return { found: false }

          const mainOverflow = main.scrollWidth > main.clientWidth + 2
            ? { scrollWidth: main.scrollWidth, clientWidth: main.clientWidth }
            : null

          const lost: {
            selector: string
            scrollWidth: number
            clientWidth: number
            reason: 'clipped' | 'covered'
            visuallyCutPx: number
            coveredBy: string | null
            overlapPx: number
            text: string
          }[] = []
          const visible: { selector: string; scrollWidth: number; clientWidth: number; clipper: string }[] = []
          const scrollable: { selector: string; scrollWidth: number; clientWidth: number }[] = []
          const seenClippedSubtrees = new Set<Element>()

          const all = main.querySelectorAll('*')
          for (const el of all) {
            if (el.scrollWidth <= el.clientWidth + 2) continue
            if (isEllipsisTruncated(el)) continue // deliberate text truncation, not a layout bug

            // ── 可达性 ────────────────────────────────────────────────────────────
            // ⚠️ INFRA-F112 (2026-08-12) 重写：首版判据是「祖先链上存在 overflow-x:auto
            // ⇒ 读者够得着」，那是**错的**，本条 bug 就是从这个口子逃掉的 —— 闸在 1280 档
            // 一直看得见 `.loading-matrix`（sw/cw=1260/883、最右一列藏 377px 完全不可达），
            // 却因为祖先 `.loading-matrix-wrap` 写着 `overflow-x: auto` 就把它归进 report-only
            // 的 intentional-scroll 桶，从 2026-07-31 上闸到 2026-08-12 一次没报过。
            // 病灶：裁剪发生在**这个 auto 容器的内部**（网格自己 `overflow: hidden` 切圆角），
            // 而网格盒子恒等于容器宽度、从不超出容器 ⇒ 容器判定无溢出、那条滚动条根本不存在。
            //
            // 正确的问法只有一个：**把能滚的都滚到底之后，墨迹还有没有落在看不见的地方。**
            // 于是把「可达」并进下面 (a) 的同一个减法里：
            //     reachableRight = 第一个非 visible 祖先的 client 右边界 + 它剩余可滚量
            // 能滚就抵扣、不能滚就抵扣 0（`overflow: hidden` / `clip` 天然落进后者）。
            //
            // ⛔ 别用 Δ = `scrollWidth - clientWidth` 去比「需要滚多少 vs 能滚多少」——
            // 我 2026-08-12 第一版就是这么写的，当天被自己的取证推翻：Δ 是**盒子**量，
            // 会把 padding / 取整算进去，而丢没丢只由**墨迹**决定。实测 topbar 页那两个实例：
            // Δ 说「差 17px 不可达、丢 191.5px」，真把卡片滚到最右后只剩 **0.5px** ——
            // 良性。这与本文件开头「Do NOT threshold on Δ」是同一条教训的第二次发生。
            const rect = el.getBoundingClientRect()
            const rightEdge = rect.left + el.clientLeft + el.clientWidth
            const ink = inkRight(el)
            const clip = firstClipper(el)
            const clipRect = clip.node.getBoundingClientRect()
            const clipRight = clipRect.left + clip.node.clientLeft + clip.node.clientWidth
            const clipperScrolls = hasOwnHorizontalScroll(clip.node)
            // 还能往右滚多少（探针不改变页面状态，所以用当前 scrollLeft 算余量）
            const remainingScroll = clipperScrolls
              ? Math.max(0, clip.node.scrollWidth - clip.node.clientWidth - clip.node.scrollLeft)
              : 0
            const visuallyCutPx = Math.round((ink - clipRight - remainingScroll) * 10) / 10
            const reachable = clipperScrolls && visuallyCutPx <= 1

            if (reachable) {
              scrollable.push({ selector: describe(el), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth })
            } else {
              // De-dupe: if an ancestor of `el` is already flagged clipped, this
              // descendant is the same overflow, not a separate finding.
              let ancestor = el.parentElement
              let alreadyCounted = false
              while (ancestor && ancestor !== main) {
                if (seenClippedSubtrees.has(ancestor)) { alreadyCounted = true; break }
                ancestor = ancestor.parentElement
              }
              if (!alreadyCounted) {
                seenClippedSubtrees.add(el)

                // ── F67 (c-1) 的两个谓词：区分「真丢内容」与「只是画到框外但看得见」──
                // rect / ink / clip / visuallyCutPx 已在上面「可达性」段算好（F112 起两处
                // 合流：可达与否本来就是同一个减法的两侧，算两遍必然分叉）。
                const cover = spillCover(el, rightEdge, ink)
                const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 70)

                if (visuallyCutPx > 1) {
                  lost.push({
                    selector: describe(el), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth,
                    reason: 'clipped', visuallyCutPx, coveredBy: null, overlapPx: 0, text,
                  })
                } else if (cover) {
                  lost.push({
                    selector: describe(el), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth,
                    reason: 'covered', visuallyCutPx, coveredBy: cover.sibling, overlapPx: cover.overlapPx, text,
                  })
                } else {
                  visible.push({
                    selector: describe(el), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth,
                    clipper: `${describe(clip.node)}[overflow-x:${clip.overflowX}]`,
                  })
                }
              }
            }
          }

          return { found: true, mainOverflow, lost, visible, scrollable }
        })

          if (!result.found) continue // page has no `.docs-main` (e.g. a redirect/hidden page) — skip
          if (result.mainOverflow) {
            pageLevelOverflow.push({ pageId: item.id, viewport: vp.label, ...result.mainOverflow })
          }
          for (const c of result.lost) {
            contentLost.push({ pageId: item.id, viewport: vp.label, ...c })
          }
          for (const v of result.visible) {
            paintsOutsideVisible.push({ pageId: item.id, viewport: vp.label, ...v })
          }
          for (const s of result.scrollable) {
            intentionalScroll.push({ pageId: item.id, viewport: vp.label, ...s })
          }
        } finally {
          await context.close()
        }
      }
    }

    // ---- report ----
    console.log('\n# INFRA-F67 docs viewport overflow sweep\n')

    const pageOverflowNew = pageLevelOverflow.filter((r) => !isKnownPageOverflow(r))
    const pageOverflowKnown = pageLevelOverflow.filter((r) => isKnownPageOverflow(r))

    console.log(`## Page-level overflow — NEW (\`.docs-main\` itself needs horizontal scroll; BLOCKING)`)
    if (pageOverflowNew.length === 0) {
      console.log('(none)')
    } else {
      for (const r of pageOverflowNew) {
        console.log(`  ${r.pageId} @${r.viewport}px: scrollWidth=${r.scrollWidth} clientWidth=${r.clientWidth}`)
      }
    }

    console.log(`\n## Page-level overflow — known debt (owner 视觉门 pending; 表只许缩不许长)`)
    if (KNOWN_PAGE_OVERFLOW.length === 0) {
      console.log('(none — 表已清空, 可以把 KNOWN_PAGE_OVERFLOW 连同这段一起删掉)')
    } else {
      for (const k of KNOWN_PAGE_OVERFLOW) {
        const hit = pageOverflowKnown.find((r) => r.pageId === k.pageId && r.viewport === k.viewport)
        console.log(`  ${k.pageId} @${k.viewport}px: ${hit ? `STILL FIRING (sw/cw=${hit.scrollWidth}/${hit.clientWidth})` : '✅ no longer fires → 从表里删掉这条'}\n      since=${k.since}\n      ${k.why}`)
      }
    }

    const lostNew = contentLost.filter((r) => !isKnown(r))
    const lostKnown = contentLost.filter((r) => isKnown(r))

    console.log(`\n## Content LOST — NEW (pixels the reader cannot get to; BLOCKING)`)
    if (lostNew.length === 0) {
      console.log('(none)')
    } else {
      for (const r of lostNew) {
        const why = r.reason === 'clipped'
          ? `clipped away ${r.visuallyCutPx}px past its first clipping ancestor`
          : `covered by later sibling ${r.coveredBy} (overlap ${r.overlapPx}px)`
        console.log(`  ${r.pageId} @${r.viewport}px: ${r.selector} sw/cw=${r.scrollWidth}/${r.clientWidth} — ${why}\n      text="${r.text}"`)
      }
    }

    console.log(`\n## Content LOST — known debt (owner 视觉门 pending; 表只许缩不许长)`)
    if (KNOWN_CONTENT_LOSS.length === 0) {
      console.log('(none — 表已清空, 可以把 KNOWN_CONTENT_LOSS 连同这段一起删掉)')
    } else {
      for (const k of KNOWN_CONTENT_LOSS) {
        const stillFiring = lostKnown.some((r) => r.pageId === k.pageId && r.viewport === k.viewport && r.selector === k.selector)
        console.log(`  ${k.pageId} @${k.viewport}px: ${k.selector} — ${stillFiring ? 'STILL FIRING' : '✅ no longer fires → 从表里删掉这条'}\n      since=${k.since}\n      ${k.why}`)
      }
    }

    console.log(`\n## Paints outside its box but fully VISIBLE (no clipper, nothing painted over it — report-only)`)
    if (paintsOutsideVisible.length === 0) {
      console.log('(none)')
    } else {
      for (const r of paintsOutsideVisible) {
        console.log(`  ${r.pageId} @${r.viewport}px: ${r.selector} sw/cw=${r.scrollWidth}/${r.clientWidth} first-non-visible-ancestor=${r.clipper}`)
      }
    }

    console.log(`\n## Intentional scroll boxes in use (滚到底后墨迹确实进视野 —— 已验可达, informational only)`)
    if (intentionalScroll.length === 0) {
      console.log('(none)')
    } else {
      for (const r of intentionalScroll) {
        console.log(`  ${r.pageId} @${r.viewport}px: ${r.selector} scrollWidth=${r.scrollWidth} clientWidth=${r.clientWidth}`)
      }
    }

    console.log(`\npages=${orderedPages.length} viewports=${VIEWPORTS.map((v) => v.label).join('/')} page-overflow-new=${pageOverflowNew.length} page-overflow-known=${pageOverflowKnown.length}/${KNOWN_PAGE_OVERFLOW.length} content-lost-new=${lostNew.length} content-lost-known=${lostKnown.length}/${KNOWN_CONTENT_LOSS.length} paints-outside-visible=${paintsOutsideVisible.length} intentional-scroll=${intentionalScroll.length}`)
    console.log('mode=PARTIAL — page-level-overflow + content-lost(NEW) are BLOCKING (INFRA-F67 / (c-1)); paints-outside-visible + intentional-scroll are report-only\n')

    // BLOCKING #1 (INFRA-F67, 2026-07-15): `.docs-main` scrolling horizontally is
    // always a real layout defect. The five original findings were fixed with targeted
    // CSS containment (方案 A).
    //
    // 2026-08-17 ([[INFRA-F118]])：判据从「恒空」改为「**新**的必须为空」。改的是**豁免
    // 粒度，不是灵敏度** —— 每一条不响的都得在 KNOWN_PAGE_OVERFLOW 里具名列着，任何不在
    // 表里的页级溢出照旧当场红。做这个改动是因为纳入 390 档时存量非空，而「先修完再加档」
    // 会让新档在整个修复期零覆盖（理由全文见文件上方 §扫描面）。
    expect(pageOverflowNew).toEqual([])

    // BLOCKING #2 (INFRA-F67 (c-1), 2026-07-31): the old `clipped-unreachable`
    // bucket was report-only because it mixed 2 real defects with 14 findings whose
    // content was in fact fully painted — an unusable signal. The two predicates
    // added above split them, the 14 leave the bucket, and what remains is only
    // "pixels the reader cannot get to". That is worth blocking on: the two defects
    // it would have caught (icon page's truncated repo path, form-item's hidden
    // note) both shipped unnoticed until a manual 3× zoom triage found them.
    //
    // If this fires on a NEW finding, do not reach for a tolerance — read the
    // reason: 'clipped' means something upstream has overflow-x != visible;
    // 'covered' means the spill lands under a later sibling's background. The
    // repo's established fix for the latter is containment at the right layer
    // (`overflow-wrap: anywhere` / `min-width: 0` / widen the grid track), never
    // silencing the probe.
    expect(lostNew).toEqual([])

    // The debt table may only SHRINK. If an entry stops firing, this fails and tells
    // you to delete that row — otherwise a fixed defect leaves a permanent hole that
    // silently absorbs the next regression on the same element.
    const staleExemptions = KNOWN_CONTENT_LOSS.filter(
      (k) => !lostKnown.some((r) => r.pageId === k.pageId && r.viewport === k.viewport && r.selector === k.selector),
    )
    expect(staleExemptions).toEqual([])

    // 同一条 shrink-only 纪律作用在 BLOCKING #1 的表上（2026-08-17, [[INFRA-F118]]）。
    // 它也是这张新表的接线证明：把一个**已经修好**的页 + 档（如 button @390，F113 修完后
    // 该档零溢出）塞进表里，这条断言必须当场红 —— 一次证明三件事：390 档真被访问了、
    // 页级判据在 390 档真求值了、守卫真的读的是这张新表。绿了就说明该档是空过的。
    const stalePageOverflowExemptions = KNOWN_PAGE_OVERFLOW.filter(
      (k) => !pageOverflowKnown.some((r) => r.pageId === k.pageId && r.viewport === k.viewport),
    )
    expect(stalePageOverflowExemptions).toEqual([])
  })
}
