From 51d91103077cdd8039e2688eadca1dbf4009eb4d Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Wed, 7 Oct 2026 20:38:43 -0400 Subject: [PATCH] Anchor the Respond.io launcher at ~22px/~10px clearance, not 43px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #235 stopped the state-dependent jumping but anchored to the wrong target: the 43px value came from the vendor's teaser iframe inset, not from where the launcher circle should rest. Re-measured on production, the vendor anchors the same iframe at right/bottom:43px in every steady state (launcher 90x90, prompt ~330x178, open panel ~400x600), briefly mounts at 25px before remote config lands, and goes full-bleed 0px for the open panel on small portrait viewports. The ~58px circle sits ~4px inside the iframe's bottom-right corner, so 43px of edge clearance read as ~47px of visible inset — detached from the corner. Replace the static CSS nudge with an adaptive anchor: the geometry watcher now measures the vendor's own right/bottom inset on every sync and applies an inline translate that lands the iframe edges at RESPOND_IO_ANCHOR_RIGHT_PX (18px -> ~22px visible circle clearance) and RESPOND_IO_ANCHOR_BOTTOM_PX (6px -> ~10px, matching the rolled-back production resting point). The deltas are computed from the measured vendor inset rather than hardcoded per state, so the 25px mount transient, the steady 43px, and future vendor spacing changes all land on the same anchor; a ~0px inset (full-bleed mobile open panel) is left untouched. Re-syncs are idempotent: the vendor inset is recovered from the transformed rect, a wiped transform is detected and rewritten, and replaced iframes get fresh observers while outgoing ones are stripped. Cleanup clears marker+transform. The watcher is decoupled from the cId env var so any "Webchat Widget" iframe in the DOM is governed. globals.css keeps only the launcher-only hit-region clip; footer bottom padding returns to 5.5rem since the clipped zone now tops out ~74px above the viewport bottom. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/globals.css | 44 ++-- components/footer.tsx | 6 +- components/respond-io-widget.tsx | 13 +- lib/respond-io.ts | 118 +++++++-- .../chat-anchor/desktop-1440-closed.png | Bin 1211496 -> 1211489 bytes .../chat-anchor/desktop-1440-open.png | Bin 1215120 -> 1215861 bytes .../chat-anchor/desktop-1440-teaser.png | Bin 1213705 -> 1213621 bytes .../chat-anchor/desktop-1440-transient.png | Bin 0 -> 1211489 bytes .../chat-anchor/mobile-390-closed.png | Bin 245011 -> 245694 bytes review-shots/chat-anchor/mobile-390-open.png | Bin 247909 -> 245552 bytes .../chat-anchor/mobile-390-teaser.png | Bin 248029 -> 248616 bytes .../chat-anchor/mobile-390-transient.png | Bin 0 -> 245694 bytes .../chat-anchor/mobile-land-844-closed.png | Bin 0 -> 262473 bytes .../chat-anchor/mobile-land-844-open.png | Bin 0 -> 265373 bytes .../chat-anchor/mobile-land-844-teaser.png | Bin 0 -> 264788 bytes .../chat-anchor/mobile-land-844-transient.png | Bin 0 -> 262473 bytes .../chat-anchor/tablet-768-closed.png | Bin 648398 -> 648352 bytes review-shots/chat-anchor/tablet-768-open.png | Bin 652038 -> 652820 bytes .../chat-anchor/tablet-768-teaser.png | Bin 651131 -> 651116 bytes .../chat-anchor/tablet-768-transient.png | Bin 0 -> 648352 bytes tests/e2e/chat-launcher.spec.ts | 182 +++++++++----- tests/e2e/footer-widgets.spec.ts | 83 ++++--- tests/integration/respond-io-widget.test.tsx | 232 ++++++++++++++---- tests/unit/respond-io.test.ts | 48 +++- verify-chat-anchor-measure.mjs | 90 +++++++ verify-chat-anchor.mjs | 70 ++++-- verify-chat-circle-scan.mjs | 97 ++++++++ 27 files changed, 780 insertions(+), 203 deletions(-) create mode 100644 review-shots/chat-anchor/desktop-1440-transient.png create mode 100644 review-shots/chat-anchor/mobile-390-transient.png create mode 100644 review-shots/chat-anchor/mobile-land-844-closed.png create mode 100644 review-shots/chat-anchor/mobile-land-844-open.png create mode 100644 review-shots/chat-anchor/mobile-land-844-teaser.png create mode 100644 review-shots/chat-anchor/mobile-land-844-transient.png create mode 100644 review-shots/chat-anchor/tablet-768-transient.png create mode 100644 verify-chat-anchor-measure.mjs create mode 100644 verify-chat-circle-scan.mjs diff --git a/app/globals.css b/app/globals.css index 81bf73d..5eb37a9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -368,35 +368,43 @@ html[data-hero-in-view] iframe[title="Webchat Widget"][state="widgetClose"] { * Fixed launcher anchor (issue #125 calibration, reworked) + * prompt-safety (issue #184). * - * The launcher circle must sit at one screen position in every state: + * The launcher circle must sit at one screen position in every state. * Measured on production, the vendor anchors the same iframe at - * right/bottom:49px while launcher-only (90x90) and at right/bottom:43px - * while the promotional prompt card is showing (~330x179) — the circle - * stays bottom-right-anchored inside the iframe in both, so its center - * sits ~6px farther in from the corner during the prompt state. The - * prompt/open inset is the comfortable resting inset the launcher should - * keep, so the marked launcher-only iframe is nudged 6px toward the - * corner: its edge lands on the same 43px anchor as every other state and - * the circle never moves when the teaser or panel appears. `transform` is - * safe: the vendor positions via inline left/right/bottom and does not - * use transform, and transforms never create document overflow. + * right/bottom:43px in every steady state — launcher-only (90x90), + * the promotional prompt card (~330x178), and the desktop open panel + * (~400x600) — with a brief right/bottom:25px transient before remote + * config lands, and a full-bleed right/bottom:0 open panel on small + * viewports. The ~58px circle stays bottom-right-aligned inside the + * iframe with ~4px of internal padding in every state. * - * The launcher iframe's transparent top/left still intercepts pointer - * events (verified via elementFromPoint on production). `clip-path` + * The fixed anchor lives in JS, not here: the watcher (lib/respond-io.ts) + * measures the vendor's own right/bottom inset on every sync and applies + * an inline translate that lands the iframe edge at + * RESPOND_IO_ANCHOR_RIGHT_PX / RESPOND_IO_ANCHOR_BOTTOM_PX (18px / 6px), + * so the visible circle keeps ~22px right / ~10px bottom clearance — + * closed, teaser, open, and closing — and the mount transient plus + * future vendor spacing changes land on the same anchor. + * A full-bleed mobile open panel (vendor inset ~0) keeps vendor + * geometry. `transform` is safe: the vendor positions via inline + * right/bottom and does not use transform, and transforms never create + * document overflow. + * + * What remains in this rule is only the launcher-only hit-region clip: + * the launcher iframe's transparent top/left still intercepts pointer + * events (verified via elementFromPoint on production), so `clip-path` * shrinks the hit area to the bottom-right quarter that actually contains - * the circle (~58px circle inset ~5px, so 25% leaves ~5px of slack), + * the circle (~58px circle inset ~4px, so 25% leaves ~4px of slack), * letting clicks pass through to page content beneath. * * Why `data-launcher-only` instead of `state="widgetClose"`: the vendor * reuses the same closed-state iframe for its promotional prompt card, - * growing it to ~179px tall and up to ~330px wide — the prompt renders in + * growing it to ~178px tall and up to ~330px wide — the prompt renders in * the region a blanket clip would cut off. CSS cannot read element size, * so respond-io-widget.tsx marks the iframe `data-launcher-only` only * while it measures launcher-sized (see lib/respond-io.ts). The prompt - * and the open conversation panel (`widgetOpen`) keep the vendor's own - * positioning and hit region untouched. + * and the open conversation panel (`widgetOpen`) keep the vendor's full + * hit region untouched. */ iframe[title="Webchat Widget"][data-launcher-only] { - transform: translate(6px, 6px); clip-path: inset(25% 0 0 25%); } \ No newline at end of file diff --git a/components/footer.tsx b/components/footer.tsx index 2bd4333..e4d2dbc 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -55,9 +55,9 @@ const headingCls = "text-xs font-semibold uppercase tracking-widest text-foregro export function Footer() { return (