feat(cli): persistence-tiered findings, frozen-sweep guard, occlusion coverage

Layout findings now distinguish held defects from entrance/exit
transients: a dynamic issue seen at a single grid sample demotes to
info, while content_overlap held across two-plus samples (or 500ms+)
promotes to error, resolving the long-standing re-promotion TODO. Static
compositions keep their severity. check gains a sweep_static error when
a 3s+ composition shows zero geometry change across every sample (a
frozen timeline makes every green verdict unreliable); skipped when the
motion sidecar already reported motion_frozen. text_occluded findings
carry a coveredFraction; atomic labels (short, no whitespace) flag on
any cover while prose needs 15%, since partial cover changes what a
short label reads as.

Deprecation-test scaffolding consolidates into deprecationTestHarness;
tier logic and logger tests restructured under the complexity gate
without suppression markers.

Detection mechanics adapted from Adam Rosler's open-sourced
visual-linter design (github.com/Adam-Rosler/hyperframes-visual-linter-design);
the elementFromPoint paint model, opt-out attributes, and single-audit
architecture are unchanged.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent f4cef54b8b
commit 94f6de8b8d
14 changed files with 712 additions and 238 deletions
+95 -8
View File
@@ -18,6 +18,9 @@ export type LayoutIssueCode =
| "text_occluded"
| "caption_zone_collision"
| "frame_out_of_frame"
// Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample
// geometry observation; never persistence-tiered (see `applyPersistenceTier`).
| "sweep_static"
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
| "motion_appears_late"
| "motion_out_of_order"
@@ -42,6 +45,9 @@ export interface LayoutIssue {
rect: LayoutRect;
containerRect?: LayoutRect;
overflow?: LayoutOverflow;
/** `text_occluded` only: approximate fraction (0-1) of the occlusion probe
* grid that hit an opaque occluder — see layout-audit.browser.js. */
coveredFraction?: number;
fixHint?: string;
}
@@ -164,7 +170,44 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
return result;
}
export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
// Persistence-tier thresholds (#U10, adapted from Adam Rosler's visual-linter
// design). The approach doc frames these as held-duration floors — ignore
// under ~250ms, re-promote content_overlap at >= ~500ms — measured against
// the SAME firstSeen/lastSeen span this collapse step already tracks. At the
// default 9-sample grid over a multi-second composition, a single collapsed
// occurrence is held 0ms (one entrance/exit transient sample) and two
// collapsed occurrences are already >= one sample-to-sample gap, which is
// well past 500ms — so "held under 250ms" reduces to `occurrences <= 1` and
// "held >= 500ms" reduces to `occurrences >= 2`. Tiering below is written in
// those sample-count terms (the mapping the approach doc asks to document),
// with the literal ms span (CONTENT_OVERLAP_HELD_ERROR_MS) kept as a fallback
// for callers whose samples really are spaced close enough together for the
// ms floor to matter on its own (dense `--at`/`--at-transitions` runs). The
// ~250ms ignore floor needs no separate constant — see the occurrences <= 1
// branch below.
const CONTENT_OVERLAP_HELD_ERROR_MS = 500;
const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2;
// Tiering only applies to layout-audit.browser.js's own per-sample seek-grid
// findings — the ones this collapse step's firstSeen/lastSeen span was built
// to describe. `caption_zone_collision`/`frame_out_of_frame` (a different
// script, U3) and the `motion_*`/`sweep_static` codes (evaluated once over
// the whole run, not per grid sample) already carry their own singular
// dedupe/severity semantics; re-interpreting their occurrence count as a
// held-duration signal would misread it.
const PERSISTENCE_TIERED_CODES: ReadonlySet<LayoutIssueCode> = new Set([
"text_box_overflow",
"clipped_text",
"canvas_overflow",
"container_overflow",
"content_overlap",
"text_occluded",
]);
export function collapseStaticLayoutIssues(
issues: LayoutIssue[],
totalSampleCount?: number,
): LayoutIssue[] {
const groups = new Map<
string,
{
@@ -193,13 +236,57 @@ export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[]
existing.occurrences += 1;
}
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({
...issue,
time: firstSeen,
firstSeen,
lastSeen,
occurrences,
}));
// A run that only ever sampled one point in time can't distinguish a
// transient from a persistent finding — skip tiering entirely rather than
// guess (see `applyPersistenceTier`).
const sampleCount = totalSampleCount ?? new Set(issues.map((issue) => issue.time)).size;
const multiSampleRun = sampleCount > 1;
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) =>
applyPersistenceTier(
{ ...issue, time: firstSeen, firstSeen, lastSeen, occurrences },
multiSampleRun,
),
);
}
/**
* Held-duration severity tiering (#U10). A finding observed at only one
* sample among several (held 0ms) is an entrance/exit transient, not a held
* defect — demote to info so it stays in the data (verbose/--json output)
* without gating the run. `content_overlap` specifically re-promotes from
* warning to error once it's held long enough to be a real, sustained
* collision rather than a crossfade/transition blip (resolves the TODO in
* layout-audit.browser.js's `overlapIssue`). A finding held at every sample
* (a genuinely static defect) is well past both thresholds and is left
* untouched either way — persistence, not the code, decides the tier.
*/
function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): LayoutIssue {
if (!multiSampleRun) return issue;
if (!PERSISTENCE_TIERED_CODES.has(issue.code)) return issue;
const occurrences = issue.occurrences ?? 1;
// A single collapsed occurrence is held 0ms by construction (firstSeen ===
// lastSeen) — always under the ignore floor, so occurrences <= 1 is a
// complete (not approximate) test for "held under 250ms".
if (occurrences <= 1) {
return { ...issue, severity: "info" };
}
if (issue.code === "content_overlap" && isContentOverlapHeldLongEnough(issue, occurrences)) {
return { ...issue, severity: "error" };
}
return issue;
}
// Split out of applyPersistenceTier so the two independent "held long enough"
// signals (sample count vs. wall-clock span) read as one boolean question
// instead of adding a third compound branch to the tiering ladder above.
function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean {
if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true;
const firstSeen = issue.firstSeen ?? issue.time;
const lastSeen = issue.lastSeen ?? issue.time;
const heldMs = (lastSeen - firstSeen) * 1000;
return heldMs >= CONTENT_OVERLAP_HELD_ERROR_MS;
}
export function limitLayoutIssues(