// Card-ready gating for the auto-scan loop (ADR replaces the fixed 280ms settle // delay). After clicking a tile, instead of waiting a hardcoded interval and // hoping the detail card has rendered, poll a cheap detail fingerprint until it // has both (a) changed from the previously-read artifact and (b) stabilized // across consecutive samples. This is faster on quick machines and correct on // slow ones, and it is robust to particle/hover-glow animation: if the card // never fully stabilizes within the budget it still proceeds once the content // has changed, rather than looping forever on an animated frame. // // Pure except for the injected async sampler/clock, so it is unit testable. export interface CardReadyOptions { /** Consecutive equal samples required to call the card stable. */ minStableSamples?: number; /** Total time budget before giving up on full stability. */ maxWaitMs?: number; /** Delay between samples. */ pollIntervalMs?: number; /** Proceed with changed-but-animated content after this much elapsed time. */ acceptChangedAfterMs?: number; } export interface CardReadyDeps { /** Fast capture -> detail fingerprint. */ sampleFingerprint: () => Promise; wait: (ms: number) => Promise; now: () => number; /** Returns a non-empty reason to abort (ESC held, stop pressed, ...). */ checkAbort?: () => Promise | string; } export interface CardReadyResult { /** Safe to read the full artifact: content changed (and stabilized or budget spent). */ ready: boolean; /** The detail differs from the previously-read artifact. */ changed: boolean; /** Reached the required number of consecutive equal samples. */ stable: boolean; /** Latest sampled fingerprint. */ fingerprint: string; /** Non-empty when aborted via checkAbort. */ abortReason: string; polls: number; } const DEFAULT_MIN_STABLE = 2; const DEFAULT_MAX_WAIT_MS = 900; const DEFAULT_POLL_MS = 90; export async function waitForCardReady( deps: CardReadyDeps, previousFingerprint: string, options: CardReadyOptions = {}, ): Promise { const minStable = Math.max(1, options.minStableSamples ?? DEFAULT_MIN_STABLE); const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS); const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_MS); const acceptChangedAfterMs = Math.max(0, options.acceptChangedAfterMs ?? maxWaitMs); const start = deps.now(); let previousSample = ""; let stableCount = 0; let latest = ""; let polls = 0; for (;;) { if (deps.checkAbort) { const abortReason = await deps.checkAbort(); if (abortReason) { return { ready: false, changed: false, stable: false, fingerprint: latest, abortReason, polls }; } } latest = await deps.sampleFingerprint(); polls++; stableCount = latest && latest === previousSample ? stableCount + 1 : 1; previousSample = latest; const changed = Boolean(latest) && latest !== previousFingerprint; const stable = stableCount >= minStable; const elapsedMs = deps.now() - start; if (changed && (stable || elapsedMs >= acceptChangedAfterMs)) { return { ready: true, changed: true, stable, fingerprint: latest, abortReason: "", polls }; } if (elapsedMs >= maxWaitMs) { // Budget spent. Proceed if the content has at least changed, even if it is // still animating (never fully stabilizes). return { ready: changed, changed, stable, fingerprint: latest, abortReason: "", polls }; } await deps.wait(pollIntervalMs); } }