feat(scan): card-ready gating replaces the fixed settle delay
Adds src/lib/cardReadyGate.ts: after a tile click, poll the detail fingerprint until it has both changed from the previous artifact and stabilized across consecutive samples, instead of waiting a hardcoded 280ms and hoping. - Faster on quick machines (proceeds as soon as the card is stable), correct on slow ones (waits up to the budget). - Robust to particle/hover-glow animation: requiring two consecutive equal samples ignores single-frame noise, and if the card never fully stabilizes it still proceeds once the content has changed rather than looping on an animated frame. - ESC/stop abort is honored between polls via checkAbort. autoScanLoop now uses waitForCardReady for both the initial read and the one retry; CLICK_SETTLE_MS removed. 6 new unit tests; 94 total green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface CardReadyDeps {
|
||||
/** Fast capture -> detail fingerprint. */
|
||||
sampleFingerprint: () => Promise<string>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
/** Returns a non-empty reason to abort (ESC held, stop pressed, ...). */
|
||||
checkAbort?: () => Promise<string> | 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<CardReadyResult> {
|
||||
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 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;
|
||||
|
||||
if (changed && stable) {
|
||||
return { ready: true, changed: true, stable: true, fingerprint: latest, abortReason: "", polls };
|
||||
}
|
||||
|
||||
if (deps.now() - start >= 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user