Exit intent — spec
Table of Contents
1. Purpose
The exit-intent overlay is the "before you leave" surface: a blended-grid unit shown when the reader appears to be leaving. Modelled on Fortune's implementation of the pattern (Bounce Exchange (Wunderkind), n.d.), with three deliberate divergences — per-card provenance, holdout suppression, and no back-button traps — that are captured as R28..R33 in this spec.
The overlay fires on desktop when the cursor moves within 20 px of the viewport top, and on mobile when upward scroll velocity crosses 1.5 px/ms. Both proxies are approximations; the true "exit" event is unobservable in the browser.
ORACLE: src/wal_sh/adtech/exit_intent/browser.cljs (browser only —
the module is DOM-native and has no separate core.cljc).
2. Requirements
- R28
- Never trap the back button. The overlay is dismissible; back-button navigation is never intercepted. R28 is permanent, not pending. Rationale: the pattern of back-button hijacking is the single most-blocked exit-intent behaviour by browser policy and ad blockers.
- R29
- Eligibility gate — engagedTime ≥ 15 s AND scrollDepth ≥
50 %. A bounce never sees the overlay. Reads
window._srIntent.eligible(see intent-signals). - R30
- Fires at most once per session; dismissal suppresses for
7 days via
localStorage._sr_ei_dismissed. The overlay is not a nag surface. - R31
- Suppressed in the
holdoutarm. Readswindow._srAttribution.Audit.arm. This is what makes the overlay's effect measurable rather than asserted — the incrementality delta betweenexposedandholdoutis a real lift or a real drop. - R32
- Every card carries its own provenance label (
organicorsponsored). The source pattern hides disclosure behind a container-level ad-choices icon; ours prints on the card. - R33
- Dismissible via Escape, backdrop click, close button; focus returns to the previously-focused element on close.
3. Contract signature
Browser (browser.cljs):
window._srExitIntent = {
install: (fn [] -> nil) ; wire listeners
fire: (fn [] -> nil) ; force-fire (test/debug)
dismiss: (fn [] -> nil) ; test hook
eligible?: (fn [] -> bool) ; composite gate result
}
Config (private, not surfaced):
{:top-threshold-px 20
:dismiss-days 7
:card-count 6
:organic-ratio 0.5 ; 3 organic + 3 sponsored per grid
:scroll-vel-thresh 1.5 ; px/ms upward — mobile proxy
:dismiss-storage-key "_sr_ei_dismissed"}
Gate composition:
gate? = armed?
∧ ¬shown?
∧ ¬dismissed? ;; localStorage window
∧ ¬holdout? ;; R31
∧ intent-eligible? ;; R29
Grid card sources:
- Organic:
document.querySelectorAll("a[href^'research']")=, takecard-count · organic-ratio, wrap with:kind "organic" :source "wal.sh". - Sponsored: fetches from
window._sponsoredResearch(Format. sample-mixed); each card carries:kind "sponsored" :sourcethe advertiser name.
On fire, appends an exit_intent touchpoint to the ledger
(channel "exit_intent", campaign "before-you-leave",
claimant "wal.sh", provenance "observed").
4. Triggers
Desktop: mousemove listener; fires when event.clientY < 20 and
we haven't already shown. Debounced to a single fire per session.
Mobile: scroll listener; tracks (y, t) at each event, computes
upward velocity (last-y - y) / (t - last-t) in px/ms; fires when
vel > 1.5 and y > last-y. Approximation only — no browser event
matches "user about to leave" on mobile.
Both are pure-approximation triggers. The actual exit event is unobservable, so we substitute proxies known to correlate.
5. Related literature
- Bounce Exchange (Wunderkind) is the commercial-scale antecedent (Bounce Exchange (Wunderkind), n.d.); the "before you leave" pattern is their signature surface.
- Zeng, Kohno & Roesner on deceptive ads (Zeng, Kohno, and Roesner 2020) documents the failure mode this spec's divergences (R28, R32) are designed to avoid — deceptive disclosure and back-button hijacking.
- Ali et al. on delivery discrimination (Ali et al. 2019) motivates R32: per-card provenance is the only way an outside audit can distinguish an owned recommendation from a paid one.
- ANA 2023 MFA study (Association of National Advertisers 2023) on the transparency crisis in ad delivery is the context for the holdout suppression (R31) — the only reliable way to detect lift is a suppression arm.
- Intent-signal gating: Chartbeat's engagedTime model (Chartbeat, n.d.) is the antecedent for R29.
- IntersectionObserver + focus-restoration: MDN (MDN Web Docs, n.d.).
6. Cross-references
- intent-signals/spec.org — R29 gate; reads
eligible?. - attribution-audit/spec.org — R31 arm suppression + ledger
emit for
exit_intenttouchpoint. - sponsored-research/spec.org — sponsored cards fetched via
window._sponsoredResearch.sample-mixed. - ORACLE:
src/wal_sh/adtech/exit_intent/browser.cljs - [BROKEN LINK: No match for fuzzy expression: *2013–2018: Mobile + programmatic era] on the rise of exit overlays.
- web-history — privacy pushback era on browser policy against back-button hijacking.
7. Open questions
- Frequency capping across visits. Current window is 7 days from dismissal. A returning-visitor exit-intent (i.e. dismissed on visit 1, re-shown on visit 5) is not supported and probably shouldn't be — nag pressure is what makes these surfaces universally blocked. But a per-visitor cadence is worth measuring.
- Mobile trigger. The upward-scroll-velocity proxy fires false-
positive on any fast scroll-up-to-reread. A better proxy would
fold in
page.dwelland only fire once dwell exceeds a threshold; captured but not implemented.