From 783ef64d441b411df30077b095d1e71fd54819cc Mon Sep 17 00:00:00 2001 From: xuanru Date: Thu, 23 Jul 2026 09:32:02 +0000 Subject: [PATCH 1/6] feat(lint): dense motion re-sampling for content_overlap Transient text-on-text collisions during continuous motion (e.g. an orbiting label card crossing the center card) overlap for a fraction of a second that the sparse 9-point layout grid seeks straight past. The content_overlap detector is correct; it just never gets a sample at the crossing moment. Rerun ONLY content_overlap on an 8fps grid (text-only, cheap) when the composition animates; findings feed the existing persistence tiering unchanged. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/check.test.ts | 30 +++++++++++ .../cli/src/commands/layout-audit.browser.js | 14 ++++++ packages/cli/src/utils/checkBrowser.ts | 14 ++++++ packages/cli/src/utils/checkPipeline.ts | 50 +++++++++++++++++++ packages/cli/src/utils/checkTypes.ts | 4 ++ 5 files changed, 112 insertions(+) diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index cdac2483ae..e757249071 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -148,6 +148,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), seek: vi.fn(async (_time: number) => undefined), collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectOverlap: vi.fn(async (_time: number) => []), collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), collectRotationSample: vi.fn(async (_time: number) => []), collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })), @@ -1343,3 +1344,32 @@ describe("contrast candidate round-trip", () => { expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/); }); }); + +describe("dense motion-overlap re-sampling", () => { + it("surfaces a held content_overlap that only the dense grid observes", async () => { + // collectLayout (sparse base grid) sees nothing; collectOverlap (dense + // grid) reports the collision — as it would for a mid-motion crossing the + // base samples seek past. Held across the dense grid, it promotes to error. + const driver = fakeDriver({ + collectLayout: vi.fn(async (_time: number) => []), + collectOverlap: vi.fn(async (time: number) => [ + layoutIssue("warning", { time, code: "content_overlap" }), + ]), + }); + const { report } = await runScenario(driver); + expect(driver.collectOverlap).toHaveBeenCalled(); + expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true); + expect(report.layout.errorCount).toBeGreaterThan(0); + }); + + it("skips the dense overlap pass when the composition never animates", async () => { + // A constant geometry fingerprint means the timeline never advanced — no + // transient crossing is possible, so the dense pass must not run. + const driver = fakeDriver({ + collectLayoutGeometry: vi.fn(async () => "static"), + collectOverlap: vi.fn(async (_time: number) => []), + }); + await runScenario(driver); + expect(driver.collectOverlap).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 1ee9085dd4..ee674ee494 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1425,6 +1425,20 @@ return issues; }; + // content_overlap only, for the dense motion re-sampling grid (checkPipeline + // detectMotionTextOverlap). Two free-positioned text blocks crossing mid-orbit + // collide for a fraction of a second the sparse layout grid seeks straight + // past; this reruns just the overlap detector — same collectSolidTextBlocks / + // overlapIssue 0.2-area threshold, no new detection surface — on a fine grid. + window.__hyperframesOverlapAudit = function auditOverlap(options) { + const time = options && typeof options.time === "number" ? options.time : 0; + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + return contentOverlapIssues(root, time); + }; + // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample // fingerprint of every visible element's box + opacity, in DOM order. Node // calls this once per seeked grid point and compares the strings across the diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index d392e82677..4ded58177d 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -349,6 +349,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud await seekCompositionTimeline(page, time, AUDIT_SEEK_OPTIONS); }, collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectOverlap: (time) => collectOverlap(page, time), collectLayoutGeometry: () => collectLayoutGeometry(page), collectRotationSample: (time) => collectRotationSample(page, time), collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time), @@ -464,6 +465,19 @@ async function collectLayout( return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); } +async function collectOverlap(page: Page, time: number): Promise { + const raw = await page.evaluate( + (options: { time: number }) => { + const audit = Reflect.get(window, "__hyperframesOverlapAudit"); + if (typeof audit !== "function") return []; + const result = Reflect.apply(audit, window, [options]); + return Array.isArray(result) ? result : []; + }, + { time }, + ); + return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); +} + async function collectLayoutGeometry(page: Page): Promise { return page.evaluate(() => { const geometry = Reflect.get(window, "__hyperframesLayoutGeometry"); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 6f5145c8ff..1c7d462f6e 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -417,9 +417,59 @@ async function collectGridSamples( collected.screenshots.push({ time, pngBase64: capture.pngBase64 }); } } + await collectMotionOverlapSamples(driver, grid, collected); return collected; } +// content_overlap sampling density for the dense motion re-pass. The sparse +// layout grid (default 9 points over multiple seconds) seeks straight past a +// mid-orbit text-on-text crossing that only overlaps for a fraction of a +// second: an in-corpus orbit (samples/fuzz016) collides at 28% area for ~0.4s, +// entirely between two adjacent base samples. 8fps (~0.125s spacing) lands +// >= 2 samples inside a window that narrow, which is what persistence tiering +// needs to promote the finding to error. Overlap collection is text-only +// (collectSolidTextBlocks), far cheaper than a full layout audit, so a fine +// grid here is affordable where densifying every detector would not be. +const OVERLAP_SAMPLE_FPS = 8; +const OVERLAP_MAX_SAMPLES = 120; + +function buildOverlapSampleTimes(duration: number): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + const count = Math.min( + OVERLAP_MAX_SAMPLES, + Math.max(2, Math.ceil(duration * OVERLAP_SAMPLE_FPS) + 1), + ); + const step = duration / (count - 1); + return mergeSampleTimes( + Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000), + ); +} + +/** + * Dense motion-overlap re-sampling. Reruns ONLY content_overlap on a fine time + * grid so transient text collisions during continuous motion are observed at + * all — the detector itself is unchanged (same 0.2-area threshold), only the + * sampling density is. Gated on the composition actually animating (the + * frozen-sweep geometry fingerprints differ across the base grid): a static + * card produces no new work and no new findings. Findings feed the existing + * collapse/persistence tiering, so a one-sample graze stays info while a held + * collision (samples/fuzz016) re-promotes to error. Skips times already in the + * base grid to avoid double-collecting the overlaps collectLayout already found. + */ +async function collectMotionOverlapSamples( + driver: CheckAuditDriver, + grid: SampleGrid, + collected: GridSamples, +): Promise { + if (new Set(collected.geometrySignatures).size <= 1) return; + const baseTimes = new Set(grid.layoutSamples); + for (const time of buildOverlapSampleTimes(grid.duration)) { + if (baseTimes.has(time)) continue; + await driver.seek(time); + collected.layoutIssues.push(...(await driver.collectOverlap(time))); + } +} + // Frozen-sweep guard (#U10): compositions this short can legitimately hold a // single static frame the whole time (a title card) — never flag those. const SWEEP_STATIC_MIN_DURATION_SEC = 3; diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 569051724a..8ecd781184 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -174,6 +174,10 @@ export interface CheckAuditDriver { findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; collectLayout(time: number, tolerance: number): Promise; + /** content_overlap only, for the dense motion re-sampling grid — catches + * transient text-on-text collisions the sparse layout grid seeks past. See + * checkPipeline detectMotionTextOverlap. */ + collectOverlap(time: number): Promise; /** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity * fingerprint of the current seeked state, for detecting a timeline that * never advances under seek. See layout-audit.browser.js. */ From 96bdf455f10e87b7ee80d9166e949469eca34d5f Mon Sep 17 00:00:00 2001 From: xuanru Date: Fri, 24 Jul 2026 10:15:00 +0000 Subject: [PATCH 2/6] fix(lint): unconditional dense content_overlap pass + honor 500ms floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 blocker: the dense motion-overlap re-pass was gated on sparse-grid geometry fingerprints changing, so an animation aliased to the sparse grid (identical fingerprints, yet colliding between samples) bypassed the pass — exactly the transient false-negative it was built to catch. Remove the gate: the dense pass now runs unconditionally (bounded, text-only), driven by the composition timeline rather than a fingerprint heuristic. Round-2 follow-ups: - Persistence-tier drift: at 8fps, occurrences>=2 spans only ~125ms, not the ~500ms the design intends, and it short-circuited before the ms floor. content_overlap promotion now requires BOTH occurrences>=2 AND a literal firstSeen..lastSeen span >= 500ms, so the wall-clock floor is honored at any sampling density. Comment block updated to match. - Sample cap scales to hold a true 8fps grid up to ~75s (raised 120 -> 600) with an explicit note that longer comps degrade below 8fps to stay bounded. Tests: - Replaced the trivial "warning at every sample" test with a real between-grid regression: a collision living only inside (3.5,4.5) — a gap the sparse grid seeks past — is detected and, held ~750ms, promoted to error. - Replaced the now-invalid "skips when static" test with one asserting the dense pass runs even when sparse fingerprints are identical (aliased motion). - Added a tiering regression: two dense occurrences spanning ~125ms stay a warning (not error). Both new guards verified red before the fix. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/check.test.ts | 40 +++++++++++++++------- packages/cli/src/utils/checkPipeline.ts | 32 ++++++++++------- packages/cli/src/utils/layoutAudit.test.ts | 15 ++++++++ packages/cli/src/utils/layoutAudit.ts | 35 ++++++++++--------- 4 files changed, 80 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index e757249071..e265b7f70d 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -1346,30 +1346,44 @@ describe("contrast candidate round-trip", () => { }); describe("dense motion-overlap re-sampling", () => { - it("surfaces a held content_overlap that only the dense grid observes", async () => { - // collectLayout (sparse base grid) sees nothing; collectOverlap (dense - // grid) reports the collision — as it would for a mid-motion crossing the - // base samples seek past. Held across the dense grid, it promotes to error. + // The default grid is 9 base samples at index+0.5 (0.5,1.5,...,8.5) over a 9s + // composition; the collision below lives entirely inside (3.5, 4.5), a gap + // the sparse grid seeks straight past. Only the 8fps dense pass observes it. + const inBetweenGridWindow = (time: number): boolean => time >= 3.6 && time <= 4.4; + + it("detects a content_overlap that occurs ONLY between two sparse grid samples", async () => { const driver = fakeDriver({ + // Sparse base grid sees nothing at any base sample time. collectLayout: vi.fn(async (_time: number) => []), - collectOverlap: vi.fn(async (time: number) => [ - layoutIssue("warning", { time, code: "content_overlap" }), - ]), + // The transient exists only strictly between base samples 3.5 and 4.5. + collectOverlap: vi.fn(async (time: number) => + inBetweenGridWindow(time) + ? [layoutIssue("warning", { time, code: "content_overlap" })] + : [], + ), }); const { report } = await runScenario(driver); expect(driver.collectOverlap).toHaveBeenCalled(); expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true); + // Held ~750ms across the dense grid (>= the 500ms floor) -> promoted. expect(report.layout.errorCount).toBeGreaterThan(0); }); - it("skips the dense overlap pass when the composition never animates", async () => { - // A constant geometry fingerprint means the timeline never advanced — no - // transient crossing is possible, so the dense pass must not run. + it("runs the dense pass even when sparse fingerprints are identical (aliased motion)", async () => { + // A constant geometry fingerprint no longer suppresses the pass: an + // animation aliased to the sparse grid has identical fingerprints yet still + // collides between samples — the false-negative the removed gate caused. const driver = fakeDriver({ collectLayoutGeometry: vi.fn(async () => "static"), - collectOverlap: vi.fn(async (_time: number) => []), + collectLayout: vi.fn(async (_time: number) => []), + collectOverlap: vi.fn(async (time: number) => + inBetweenGridWindow(time) + ? [layoutIssue("warning", { time, code: "content_overlap" })] + : [], + ), }); - await runScenario(driver); - expect(driver.collectOverlap).not.toHaveBeenCalled(); + const { report } = await runScenario(driver); + expect(driver.collectOverlap).toHaveBeenCalled(); + expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true); }); }); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 1c7d462f6e..63925b0134 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -426,12 +426,15 @@ async function collectGridSamples( // mid-orbit text-on-text crossing that only overlaps for a fraction of a // second: an in-corpus orbit (samples/fuzz016) collides at 28% area for ~0.4s, // entirely between two adjacent base samples. 8fps (~0.125s spacing) lands -// >= 2 samples inside a window that narrow, which is what persistence tiering -// needs to promote the finding to error. Overlap collection is text-only -// (collectSolidTextBlocks), far cheaper than a full layout audit, so a fine -// grid here is affordable where densifying every detector would not be. +// enough samples inside a window that narrow to observe it. Overlap collection +// is text-only (collectSolidTextBlocks), far cheaper than a full layout audit, +// so a fine grid here is affordable where densifying every detector would not. const OVERLAP_SAMPLE_FPS = 8; -const OVERLAP_MAX_SAMPLES = 120; +// Absolute ceiling on dense seeks so the pass stays bounded. This holds a true +// 8fps grid for compositions up to OVERLAP_MAX_SAMPLES / OVERLAP_SAMPLE_FPS +// (~75s); longer compositions degrade below 8fps rather than growing the seek +// budget without limit. (Corpus compositions run 7-8s, well inside 8fps.) +const OVERLAP_MAX_SAMPLES = 600; function buildOverlapSampleTimes(duration: number): number[] { if (!Number.isFinite(duration) || duration <= 0) return []; @@ -449,19 +452,24 @@ function buildOverlapSampleTimes(duration: number): number[] { * Dense motion-overlap re-sampling. Reruns ONLY content_overlap on a fine time * grid so transient text collisions during continuous motion are observed at * all — the detector itself is unchanged (same 0.2-area threshold), only the - * sampling density is. Gated on the composition actually animating (the - * frozen-sweep geometry fingerprints differ across the base grid): a static - * card produces no new work and no new findings. Findings feed the existing - * collapse/persistence tiering, so a one-sample graze stays info while a held - * collision (samples/fuzz016) re-promotes to error. Skips times already in the - * base grid to avoid double-collecting the overlaps collectLayout already found. + * sampling density is. + * + * This runs UNCONDITIONALLY (bounded + text-only), NOT gated on sparse-grid + * geometry fingerprints changing. That gate was the motivating false-negative: + * an animation aliased to the sparse grid (the same pose sampled at every base + * point) has identical fingerprints yet still collides *between* those samples, + * so gating on fingerprint change skipped the exact transient this pass exists + * to catch. A static composition simply yields no overlaps at the extra times, + * so the only cost of running always is a bounded set of cheap text-only seeks. + * Findings feed the existing collapse/persistence tiering (a graze stays info, + * a held collision re-promotes to error). Skips times already in the base grid + * to avoid double-collecting overlaps collectLayout already found. */ async function collectMotionOverlapSamples( driver: CheckAuditDriver, grid: SampleGrid, collected: GridSamples, ): Promise { - if (new Set(collected.geometrySignatures).size <= 1) return; const baseTimes = new Set(grid.layoutSamples); for (const time of buildOverlapSampleTimes(grid.duration)) { if (baseTimes.has(time)) continue; diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index bccb025ae7..d879b4fedf 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -227,6 +227,21 @@ describe("persistence-tiered severity (#U10)", () => { expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 }); }); + it("keeps a content_overlap that spans under the 500ms floor as a warning, even with 2 occurrences", () => { + // Two occurrences from the dense 8fps re-pass span only ~125ms — under the + // held-duration floor, so occurrences>=2 alone must NOT promote to error. + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 4.0 }, + { ...issue("content_overlap", "warning"), time: 4.125 }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 }); + }); + it("promotes a held, canvas-scale canvas_overflow breach from info to warning", () => { const breach = { ...issue("canvas_overflow", "info"), diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 678e1210c6..bad1b8a084 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -183,19 +183,17 @@ export function dedupeLayoutIssues(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. +// under ~250ms, re-promote content_overlap at >= ~500ms — measured against the +// SAME firstSeen/lastSeen span this collapse step already tracks. `occurrences` +// is a NECESSARY guard (one sample can't span any duration), but it is NOT a +// sufficient proxy for the 500ms floor: the dense content_overlap re-pass +// (checkPipeline collectMotionOverlapSamples) samples at 8fps, so two adjacent +// occurrences there span only ~125ms — the old "occurrences >= 2 => held >= +// 500ms" shortcut held only for the coarse ~1s-spaced base grid and breaks +// under dense sampling. content_overlap promotion therefore requires BOTH +// occurrences >= 2 AND a literal firstSeen..lastSeen span >= 500ms, so the +// wall-clock floor is honored regardless of sampling density. The ~250ms +// ignore floor needs no separate constant — see the occurrences <= 1 branch. const CONTENT_OVERLAP_HELD_ERROR_MS = 500; const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2; @@ -317,11 +315,14 @@ function isCanvasBreachHeldLarge(issue: LayoutIssue, occurrences: number): boole return overlapX > 0 && overlapY > 0; } -// 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. +// Split out of applyPersistenceTier so the compound "held long enough" test +// (>= 2 samples AND wall-clock span >= the ms floor) reads as one boolean +// question instead of adding a compound branch to the tiering ladder above. function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean { - if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true; + // Need at least two samples to measure a span at all, AND that span must + // clear the wall-clock floor — dense 8fps re-sampling makes occurrences>=2 + // alone (potentially ~125ms) too weak to imply a genuinely held collision. + if (occurrences < HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return false; const firstSeen = issue.firstSeen ?? issue.time; const lastSeen = issue.lastSeen ?? issue.time; const heldMs = (lastSeen - firstSeen) * 1000; From 7b2538d36127a98acc0505626b832267c8c55ba7 Mon Sep 17 00:00:00 2001 From: xuanru Date: Fri, 24 Jul 2026 22:20:37 +0000 Subject: [PATCH 3/6] perf(lint): settle-free geometry seek for dense content_overlap pass The dense overlap re-pass did up to OVERLAP_MAX_SAMPLES full-settle seeks (120ms paint settle each, ~72s of pure sleep at the ceiling) even though collectOverlap only reads getBoundingClientRect geometry, valid synchronously after the timeline setTime. Add a settle-free DENSE_GEOMETRY_SEEK_OPTIONS + driver.seekGeometry used only by the dense loop; the base grid keeps full-settle driver.seek. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/capture/captureCompositionFrame.ts | 12 ++++++++++++ packages/cli/src/commands/check.test.ts | 1 + packages/cli/src/utils/checkBrowser.ts | 5 +++++ packages/cli/src/utils/checkPipeline.ts | 8 +++++++- packages/cli/src/utils/checkTypes.ts | 5 +++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index fff9bf4b2e..48e8e8db3f 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -18,6 +18,18 @@ export const AUDIT_SEEK_OPTIONS = { settleMs: 120, } as const; +// Settle-free variant for geometry-only audit passes (the dense content_overlap +// re-sampling grid). It keeps the timeline setTime + preferred-target seek +// cascade, but drops the 120ms paint-settle sleep: getBoundingClientRect +// geometry is valid synchronously once GSAP has written the inline transforms +// at setTime, so a geometry-only reader never needs to wait for paint. At up to +// OVERLAP_MAX_SAMPLES seeks that 120ms sleep is ~72s of pure sleep with no DOM +// work, which this variant eliminates. +export const DENSE_GEOMETRY_SEEK_OPTIONS = { + ...AUDIT_SEEK_OPTIONS, + settleMs: 0, +} as const; + export interface SeekCompositionTimelineOptions { fallbackToBridgeAndTimelines?: boolean; waitForPreferredSeekTargetMs?: number; diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index e265b7f70d..0294359c18 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -147,6 +147,7 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })), findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), seek: vi.fn(async (_time: number) => undefined), + seekGeometry: vi.fn(async (_time: number) => undefined), collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), collectOverlap: vi.fn(async (_time: number) => []), collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 4ded58177d..c47cced9ca 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import type { Page } from "puppeteer-core"; import { AUDIT_SEEK_OPTIONS, + DENSE_GEOMETRY_SEEK_OPTIONS, DEFAULT_ZOOM_PADDING_PX, DEFAULT_ZOOM_SCALE, captureRegionCrop, @@ -348,6 +349,10 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud setTime(time); await seekCompositionTimeline(page, time, AUDIT_SEEK_OPTIONS); }, + seekGeometry: async (time) => { + setTime(time); + await seekCompositionTimeline(page, time, DENSE_GEOMETRY_SEEK_OPTIONS); + }, collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), collectOverlap: (time) => collectOverlap(page, time), collectLayoutGeometry: () => collectLayoutGeometry(page), diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 63925b0134..7d45065d4d 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -473,7 +473,13 @@ async function collectMotionOverlapSamples( const baseTimes = new Set(grid.layoutSamples); for (const time of buildOverlapSampleTimes(grid.duration)) { if (baseTimes.has(time)) continue; - await driver.seek(time); + // Settle-free seek: collectOverlap reads getBoundingClientRect geometry + // only, which is valid synchronously after the timeline setTime (GSAP + // writes inline transforms synchronously). The dense pass makes up to + // OVERLAP_MAX_SAMPLES seeks, so skipping the 120ms per-seek paint settle + // here (vs. the full-settle driver.seek the base grid uses to feed + // contrast/rotation/frame checks) removes ~72s of pure sleep at the ceiling. + await driver.seekGeometry(time); collected.layoutIssues.push(...(await driver.collectOverlap(time))); } } diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 8ecd781184..2007ea5b19 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -173,6 +173,11 @@ export interface CheckAuditDriver { getCanvas(): Promise; findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; + /** Cheap settle-free seek for the geometry-only dense content_overlap pass: + * setTime + timeline seek with no paint-settle sleep. Only collectOverlap + * (getBoundingClientRect geometry) consumes the result, which is valid + * synchronously after setTime — see checkPipeline collectMotionOverlapSamples. */ + seekGeometry(time: number): Promise; collectLayout(time: number, tolerance: number): Promise; /** content_overlap only, for the dense motion re-sampling grid — catches * transient text-on-text collisions the sparse layout grid seeks past. See From 3da63c320df82e7ae6c4b2e5d8840d94cb078156 Mon Sep 17 00:00:00 2001 From: xuanru Date: Fri, 24 Jul 2026 22:20:47 +0000 Subject: [PATCH 4/6] test(lint): document + cover content_overlap 500ms boundary for sparse callers The occurrences>=2 AND heldMs>=500 promotion rule is a semantics change for sparse callers (--samples 20, --at, short comps) whose two samples can land <500ms apart. Document the change in the tiering comment and add boundary tests: 499ms span stays warning, 500ms span promotes to error. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/utils/layoutAudit.test.ts | 29 ++++++++++++++++++++++ packages/cli/src/utils/layoutAudit.ts | 6 +++++ 2 files changed, 35 insertions(+) diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index d879b4fedf..fd99028217 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -242,6 +242,35 @@ describe("persistence-tiered severity (#U10)", () => { expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 }); }); + it("does NOT promote content_overlap whose two occurrences span exactly 499ms (under the floor)", () => { + // Boundary: a two-occurrence span one millisecond short of the 500ms floor + // stays a warning. Guards the AND-tighten for sparse callers (--samples 20, + // --at, short comps) whose two samples can land <500ms apart. + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 4.0 }, + { ...issue("content_overlap", "warning"), time: 4.499 }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 }); + }); + + it("promotes content_overlap whose two occurrences span exactly 500ms (at the floor)", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 4.0 }, + { ...issue("content_overlap", "warning"), time: 4.5 }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 }); + }); + it("promotes a held, canvas-scale canvas_overflow breach from info to warning", () => { const breach = { ...issue("canvas_overflow", "info"), diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index bad1b8a084..d9b3531609 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -194,6 +194,12 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { // occurrences >= 2 AND a literal firstSeen..lastSeen span >= 500ms, so the // wall-clock floor is honored regardless of sampling density. The ~250ms // ignore floor needs no separate constant — see the occurrences <= 1 branch. +// +// SEMANTICS CHANGE (intentional): this is stricter than the prior +// occurrences >= 2 => error rule for SPARSE callers too, not only the dense +// pass. Under `--samples 20`, `--at`, or a short composition, two adjacent +// samples can land < 500ms apart; such a pair now stays a warning instead of +// auto-promoting to error. Only a genuinely held (>= 500ms) collision promotes. const CONTENT_OVERLAP_HELD_ERROR_MS = 500; const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2; From 5f111609e881806075162907b8d726bfcc4b50de Mon Sep 17 00:00:00 2001 From: xuanru Date: Fri, 24 Jul 2026 23:21:10 +0000 Subject: [PATCH 5/6] fix(lint): make dense content_overlap seek genuinely geometry-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: DENSE_GEOMETRY_SEEK_OPTIONS only overrode settleMs, still inheriting animationFrameSettle:double + waitForFontsMs:500 → ~3 frame waits + font wait per seek → ~30s at the 600-sample cap. Geometry (getBoundingClientRect) is valid synchronously post-setTime, so drop all post-seek waits (animationFrameSettle:none, waitForFontsMs:0, settleMs:0). Add options-level regression locking the geometry-only contract. Also fix a stale comment name (detectMotionTextOverlap → collectMotionOverlapSamples). Co-Authored-By: Claude Opus 4.8 --- .../capture/captureCompositionFrame.test.ts | 17 +++++++++++++++++ .../cli/src/capture/captureCompositionFrame.ts | 18 +++++++++++------- .../cli/src/commands/layout-audit.browser.js | 2 +- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 46840097a4..9b45465c2c 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + AUDIT_SEEK_OPTIONS, + DENSE_GEOMETRY_SEEK_OPTIONS, captureRegionCrop, clampCropRegion, installPageFunctionGuard, @@ -374,3 +376,18 @@ describe("installPageFunctionGuard", () => { expect(shim(marker)).toBe(marker); }); }); + +describe("DENSE_GEOMETRY_SEEK_OPTIONS", () => { + it("is genuinely geometry-only — no post-seek settle waits at the 600-sample cap", () => { + // Regression for the dense content_overlap grid: geometry (getBoundingClientRect) + // is valid synchronously after setTime, so the dense pass must NOT inherit AUDIT's + // rAF settle / font wait / paint sleep — those multiply by sample count into tens of + // seconds. Any future edit that reintroduces a wait here must fail this test. + expect(DENSE_GEOMETRY_SEEK_OPTIONS.animationFrameSettle).toBe("none"); + expect(DENSE_GEOMETRY_SEEK_OPTIONS.waitForFontsMs).toBe(0); + expect(DENSE_GEOMETRY_SEEK_OPTIONS.settleMs).toBe(0); + // AUDIT (the full-settle path used by the base grid) must still carry the waits. + expect(AUDIT_SEEK_OPTIONS.animationFrameSettle).toBe("double"); + expect(AUDIT_SEEK_OPTIONS.settleMs).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 48e8e8db3f..115832b006 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -18,15 +18,19 @@ export const AUDIT_SEEK_OPTIONS = { settleMs: 120, } as const; -// Settle-free variant for geometry-only audit passes (the dense content_overlap -// re-sampling grid). It keeps the timeline setTime + preferred-target seek -// cascade, but drops the 120ms paint-settle sleep: getBoundingClientRect -// geometry is valid synchronously once GSAP has written the inline transforms -// at setTime, so a geometry-only reader never needs to wait for paint. At up to -// OVERLAP_MAX_SAMPLES seeks that 120ms sleep is ~72s of pure sleep with no DOM -// work, which this variant eliminates. +// Genuinely geometry-only variant for the dense content_overlap re-sampling +// grid. It keeps only the timeline setTime + preferred-target seek cascade and +// drops EVERY post-seek wait — no rAF settle, no font wait, no paint-settle +// sleep — because getBoundingClientRect geometry is valid synchronously once +// GSAP has written the inline transforms at setTime; a geometry-only reader +// never paints, so it needs none of the visual-stability waits. At up to +// OVERLAP_MAX_SAMPLES seeks the full AUDIT settle (double rAF + up to 500ms +// font wait + 120ms sleep) is ~30s of frame waits + ~72s of sleep with no DOM +// work; this variant eliminates all of it. export const DENSE_GEOMETRY_SEEK_OPTIONS = { ...AUDIT_SEEK_OPTIONS, + animationFrameSettle: "none", + waitForFontsMs: 0, settleMs: 0, } as const; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index ee674ee494..6786d78da2 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1426,7 +1426,7 @@ }; // content_overlap only, for the dense motion re-sampling grid (checkPipeline - // detectMotionTextOverlap). Two free-positioned text blocks crossing mid-orbit + // collectMotionOverlapSamples). Two free-positioned text blocks crossing mid-orbit // collide for a fraction of a second the sparse layout grid seeks straight // past; this reruns just the overlap detector — same collectSolidTextBlocks / // overlapIssue 0.2-area threshold, no new detection surface — on a fine grid. From 1ba459a0dadabaee0402db677c04616a5d7d1264 Mon Sep 17 00:00:00 2001 From: xuanru Date: Sat, 25 Jul 2026 08:15:03 +0000 Subject: [PATCH 6/6] style: collapse multi-line comments to single lines Co-Authored-By: Claude Opus 4.8 --- .../capture/captureCompositionFrame.test.ts | 5 +-- .../src/capture/captureCompositionFrame.ts | 10 +---- packages/cli/src/commands/check.test.ts | 8 +--- .../cli/src/commands/layout-audit.browser.js | 6 +-- packages/cli/src/utils/checkPipeline.ts | 39 ++----------------- packages/cli/src/utils/checkTypes.ts | 9 +---- packages/cli/src/utils/layoutAudit.test.ts | 7 +--- packages/cli/src/utils/layoutAudit.ts | 28 ++----------- 8 files changed, 16 insertions(+), 96 deletions(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 9b45465c2c..a1cfff9823 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -379,10 +379,7 @@ describe("installPageFunctionGuard", () => { describe("DENSE_GEOMETRY_SEEK_OPTIONS", () => { it("is genuinely geometry-only — no post-seek settle waits at the 600-sample cap", () => { - // Regression for the dense content_overlap grid: geometry (getBoundingClientRect) - // is valid synchronously after setTime, so the dense pass must NOT inherit AUDIT's - // rAF settle / font wait / paint sleep — those multiply by sample count into tens of - // seconds. Any future edit that reintroduces a wait here must fail this test. + // Dense pass must not inherit AUDIT's post-seek waits — geometry is valid synchronously after setTime, and waits multiply by sample count. expect(DENSE_GEOMETRY_SEEK_OPTIONS.animationFrameSettle).toBe("none"); expect(DENSE_GEOMETRY_SEEK_OPTIONS.waitForFontsMs).toBe(0); expect(DENSE_GEOMETRY_SEEK_OPTIONS.settleMs).toBe(0); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 115832b006..167ebd892e 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -18,15 +18,7 @@ export const AUDIT_SEEK_OPTIONS = { settleMs: 120, } as const; -// Genuinely geometry-only variant for the dense content_overlap re-sampling -// grid. It keeps only the timeline setTime + preferred-target seek cascade and -// drops EVERY post-seek wait — no rAF settle, no font wait, no paint-settle -// sleep — because getBoundingClientRect geometry is valid synchronously once -// GSAP has written the inline transforms at setTime; a geometry-only reader -// never paints, so it needs none of the visual-stability waits. At up to -// OVERLAP_MAX_SAMPLES seeks the full AUDIT settle (double rAF + up to 500ms -// font wait + 120ms sleep) is ~30s of frame waits + ~72s of sleep with no DOM -// work; this variant eliminates all of it. +// Geometry-only seek for the dense content_overlap grid: getBoundingClientRect is valid synchronously after setTime, so drop all post-seek waits (rAF/font/sleep) that would multiply across the dense grid. export const DENSE_GEOMETRY_SEEK_OPTIONS = { ...AUDIT_SEEK_OPTIONS, animationFrameSettle: "none", diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 0294359c18..8d9a07f97e 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -1347,9 +1347,7 @@ describe("contrast candidate round-trip", () => { }); describe("dense motion-overlap re-sampling", () => { - // The default grid is 9 base samples at index+0.5 (0.5,1.5,...,8.5) over a 9s - // composition; the collision below lives entirely inside (3.5, 4.5), a gap - // the sparse grid seeks straight past. Only the 8fps dense pass observes it. + // Collision lives inside (3.5, 4.5), a gap the sparse base grid seeks past; only the 8fps dense pass observes it. const inBetweenGridWindow = (time: number): boolean => time >= 3.6 && time <= 4.4; it("detects a content_overlap that occurs ONLY between two sparse grid samples", async () => { @@ -1371,9 +1369,7 @@ describe("dense motion-overlap re-sampling", () => { }); it("runs the dense pass even when sparse fingerprints are identical (aliased motion)", async () => { - // A constant geometry fingerprint no longer suppresses the pass: an - // animation aliased to the sparse grid has identical fingerprints yet still - // collides between samples — the false-negative the removed gate caused. + // Aliased motion has identical fingerprints yet still collides between samples — the false-negative the removed gate caused. const driver = fakeDriver({ collectLayoutGeometry: vi.fn(async () => "static"), collectLayout: vi.fn(async (_time: number) => []), diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 6786d78da2..382184309a 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1425,11 +1425,7 @@ return issues; }; - // content_overlap only, for the dense motion re-sampling grid (checkPipeline - // collectMotionOverlapSamples). Two free-positioned text blocks crossing mid-orbit - // collide for a fraction of a second the sparse layout grid seeks straight - // past; this reruns just the overlap detector — same collectSolidTextBlocks / - // overlapIssue 0.2-area threshold, no new detection surface — on a fine grid. + // Reruns only the overlap detector (same threshold, no new surface) on a fine grid for the dense motion re-sampling pass. window.__hyperframesOverlapAudit = function auditOverlap(options) { const time = options && typeof options.time === "number" ? options.time : 0; const root = diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 7d45065d4d..a02fb2fa5b 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -421,19 +421,9 @@ async function collectGridSamples( return collected; } -// content_overlap sampling density for the dense motion re-pass. The sparse -// layout grid (default 9 points over multiple seconds) seeks straight past a -// mid-orbit text-on-text crossing that only overlaps for a fraction of a -// second: an in-corpus orbit (samples/fuzz016) collides at 28% area for ~0.4s, -// entirely between two adjacent base samples. 8fps (~0.125s spacing) lands -// enough samples inside a window that narrow to observe it. Overlap collection -// is text-only (collectSolidTextBlocks), far cheaper than a full layout audit, -// so a fine grid here is affordable where densifying every detector would not. +// Dense grid catches mid-motion text collisions the sparse layout grid seeks past; text-only overlap collection is cheap enough to afford it. const OVERLAP_SAMPLE_FPS = 8; -// Absolute ceiling on dense seeks so the pass stays bounded. This holds a true -// 8fps grid for compositions up to OVERLAP_MAX_SAMPLES / OVERLAP_SAMPLE_FPS -// (~75s); longer compositions degrade below 8fps rather than growing the seek -// budget without limit. (Corpus compositions run 7-8s, well inside 8fps.) +// Ceiling that bounds dense seeks; past ~75s the grid degrades below 8fps rather than growing the seek budget without limit. const OVERLAP_MAX_SAMPLES = 600; function buildOverlapSampleTimes(duration: number): number[] { @@ -448,23 +438,7 @@ function buildOverlapSampleTimes(duration: number): number[] { ); } -/** - * Dense motion-overlap re-sampling. Reruns ONLY content_overlap on a fine time - * grid so transient text collisions during continuous motion are observed at - * all — the detector itself is unchanged (same 0.2-area threshold), only the - * sampling density is. - * - * This runs UNCONDITIONALLY (bounded + text-only), NOT gated on sparse-grid - * geometry fingerprints changing. That gate was the motivating false-negative: - * an animation aliased to the sparse grid (the same pose sampled at every base - * point) has identical fingerprints yet still collides *between* those samples, - * so gating on fingerprint change skipped the exact transient this pass exists - * to catch. A static composition simply yields no overlaps at the extra times, - * so the only cost of running always is a bounded set of cheap text-only seeks. - * Findings feed the existing collapse/persistence tiering (a graze stays info, - * a held collision re-promotes to error). Skips times already in the base grid - * to avoid double-collecting overlaps collectLayout already found. - */ +/** Reruns content_overlap on a fine grid, unconditionally rather than gated on fingerprint change, since an animation aliased to the sparse grid collides between identical-fingerprint samples. */ async function collectMotionOverlapSamples( driver: CheckAuditDriver, grid: SampleGrid, @@ -473,12 +447,7 @@ async function collectMotionOverlapSamples( const baseTimes = new Set(grid.layoutSamples); for (const time of buildOverlapSampleTimes(grid.duration)) { if (baseTimes.has(time)) continue; - // Settle-free seek: collectOverlap reads getBoundingClientRect geometry - // only, which is valid synchronously after the timeline setTime (GSAP - // writes inline transforms synchronously). The dense pass makes up to - // OVERLAP_MAX_SAMPLES seeks, so skipping the 120ms per-seek paint settle - // here (vs. the full-settle driver.seek the base grid uses to feed - // contrast/rotation/frame checks) removes ~72s of pure sleep at the ceiling. + // Settle-free seek: collectOverlap reads getBoundingClientRect geometry, valid synchronously after setTime, so the dense pass skips the per-seek paint settle. await driver.seekGeometry(time); collected.layoutIssues.push(...(await driver.collectOverlap(time))); } diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 2007ea5b19..c334787e37 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -173,15 +173,10 @@ export interface CheckAuditDriver { getCanvas(): Promise; findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; - /** Cheap settle-free seek for the geometry-only dense content_overlap pass: - * setTime + timeline seek with no paint-settle sleep. Only collectOverlap - * (getBoundingClientRect geometry) consumes the result, which is valid - * synchronously after setTime — see checkPipeline collectMotionOverlapSamples. */ + /** Settle-free seek for the geometry-only dense content_overlap pass; only collectOverlap consumes it, and getBoundingClientRect is valid synchronously after setTime. */ seekGeometry(time: number): Promise; collectLayout(time: number, tolerance: number): Promise; - /** content_overlap only, for the dense motion re-sampling grid — catches - * transient text-on-text collisions the sparse layout grid seeks past. See - * checkPipeline detectMotionTextOverlap. */ + /** content_overlap only, for the dense re-sampling grid — catches transient text collisions the sparse grid seeks past. */ collectOverlap(time: number): Promise; /** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity * fingerprint of the current seeked state, for detecting a timeline that diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index fd99028217..13ea482e32 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -228,8 +228,7 @@ describe("persistence-tiered severity (#U10)", () => { }); it("keeps a content_overlap that spans under the 500ms floor as a warning, even with 2 occurrences", () => { - // Two occurrences from the dense 8fps re-pass span only ~125ms — under the - // held-duration floor, so occurrences>=2 alone must NOT promote to error. + // Two dense-pass occurrences ~125ms apart are under the held-duration floor, so occurrences>=2 alone must not promote to error. const collapsed = collapseStaticLayoutIssues( [ { ...issue("content_overlap", "warning"), time: 4.0 }, @@ -243,9 +242,7 @@ describe("persistence-tiered severity (#U10)", () => { }); it("does NOT promote content_overlap whose two occurrences span exactly 499ms (under the floor)", () => { - // Boundary: a two-occurrence span one millisecond short of the 500ms floor - // stays a warning. Guards the AND-tighten for sparse callers (--samples 20, - // --at, short comps) whose two samples can land <500ms apart. + // Boundary: a span one millisecond short of the 500ms floor stays a warning — guards the AND-tighten for sparse callers. const collapsed = collapseStaticLayoutIssues( [ { ...issue("content_overlap", "warning"), time: 4.0 }, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index d9b3531609..5f68cb7007 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -181,25 +181,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { return result; } -// 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. `occurrences` -// is a NECESSARY guard (one sample can't span any duration), but it is NOT a -// sufficient proxy for the 500ms floor: the dense content_overlap re-pass -// (checkPipeline collectMotionOverlapSamples) samples at 8fps, so two adjacent -// occurrences there span only ~125ms — the old "occurrences >= 2 => held >= -// 500ms" shortcut held only for the coarse ~1s-spaced base grid and breaks -// under dense sampling. content_overlap promotion therefore requires BOTH -// occurrences >= 2 AND a literal firstSeen..lastSeen span >= 500ms, so the -// wall-clock floor is honored regardless of sampling density. The ~250ms -// ignore floor needs no separate constant — see the occurrences <= 1 branch. -// -// SEMANTICS CHANGE (intentional): this is stricter than the prior -// occurrences >= 2 => error rule for SPARSE callers too, not only the dense -// pass. Under `--samples 20`, `--at`, or a short composition, two adjacent -// samples can land < 500ms apart; such a pair now stays a warning instead of -// auto-promoting to error. Only a genuinely held (>= 500ms) collision promotes. +// Persistence-tier thresholds (#U10): occurrences>=2 alone can't imply the 500ms floor under dense 8fps sampling, so content_overlap promotion requires a literal firstSeen..lastSeen span >= 500ms — stricter for sparse callers too. const CONTENT_OVERLAP_HELD_ERROR_MS = 500; const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2; @@ -321,13 +303,9 @@ function isCanvasBreachHeldLarge(issue: LayoutIssue, occurrences: number): boole return overlapX > 0 && overlapY > 0; } -// Split out of applyPersistenceTier so the compound "held long enough" test -// (>= 2 samples AND wall-clock span >= the ms floor) reads as one boolean -// question instead of adding a compound branch to the tiering ladder above. +// Split out of applyPersistenceTier so the compound "held long enough" test reads as one boolean question. function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean { - // Need at least two samples to measure a span at all, AND that span must - // clear the wall-clock floor — dense 8fps re-sampling makes occurrences>=2 - // alone (potentially ~125ms) too weak to imply a genuinely held collision. + // Two samples measure a span, but under dense 8fps sampling that span must still clear the wall-clock floor. if (occurrences < HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return false; const firstSeen = issue.firstSeen ?? issue.time; const lastSeen = issue.lastSeen ?? issue.time;