Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
}
6 changes: 3 additions & 3 deletions components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ const headingCls = "text-xs font-semibold uppercase tracking-widest text-foregro
export function Footer() {
return (
<footer className="border-t border-border/40 bg-muted/30">
{/* pb clears the fixed launchers' clipped hit region (~110px at the
shared 43px anchor inset) plus safe-area */}
<div className="mx-auto max-w-6xl px-4 pt-10 pb-[calc(7.5rem+env(safe-area-inset-bottom,0px))] sm:px-6 lg:px-8">
{/* pb clears the fixed launchers' clipped hit region (~74px at the
6px bottom anchor edge) plus safe-area */}
<div className="mx-auto max-w-6xl px-4 pt-10 pb-[calc(5.5rem+env(safe-area-inset-bottom,0px))] sm:px-6 lg:px-8">
<div className="grid gap-8 sm:grid-cols-2 sm:gap-10 lg:grid-cols-4">

{/* Plan Your Trip */}
Expand Down
13 changes: 7 additions & 6 deletions components/respond-io-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ import {
*/
export function RespondIoWidget() {
useEffect(() => {
const cId = process.env.NEXT_PUBLIC_RESPOND_IO_CID;
if (!cId) return;

// Geometry watcher runs even when the script element already exists
// (remount after client-side navigation): the vendor iframe keeps
// living in the DOM, so `data-launcher-only` must be maintained too.
// Geometry watcher is unconditional: the vendor iframe can outlive
// this component (remount after client-side navigation) or arrive by
// paths other than our injection, so `data-launcher-only` and the
// shared-anchor translate must be maintained whenever it exists.
const stopWatching = watchRespondIoIframe();

const cId = process.env.NEXT_PUBLIC_RESPOND_IO_CID;
if (!cId) return stopWatching;

let cancelled = false;

const inject = () => {
Expand Down
118 changes: 103 additions & 15 deletions lib/respond-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,8 @@ export function wireRespondAnalytics(
// the same `state="widgetClose"` iframe to ~179px tall and up to ~330px
// wide. CSS cannot read element geometry, so the loader marks the iframe
// `data-launcher-only` only while it measures launcher-sized; globals.css
// scopes the resting-position translate and hit-region clip to that
// marker. Anything larger — prompt or open panel — keeps the vendor's own
// positioning so no content is ever clipped.
// scopes the hit-region clip to that marker. Anything larger — prompt or
// open panel — is never clipped so no content is ever cut off.
export const RESPOND_IO_LAUNCHER_EDGE_PX = 120;

export function isLauncherOnlyGeometry(
Expand All @@ -81,31 +80,117 @@ export function syncLauncherOnlyAttribute(iframe: HTMLIFrameElement): void {
);
}

// Launcher anchor target. Production measurements (verified against the
// live widget): the vendor anchors the same iframe at right/bottom:43px in
// every steady state — launcher-only, prompt card, and the desktop open
// panel — with a brief 25px transient before remote config lands, and a
// full-bleed right/bottom:0 open panel on small viewports. The ~58px
// circle sits ~4px inside the iframe's bottom-right corner in every state.
//
// The launcher must hold one fixed anchor across closed → teaser → open →
// closing, so the watcher translates the iframe until its edges rest at
// the constants below — circle ≈ edge + 4px. The deltas are computed
// from the measured vendor inset on every sync rather than hardcoded per
// state, so the transient 25px mount, later dashboard spacing changes,
// and any future vendor inset all land on the same anchor. A full-bleed
// mobile open panel (vendor gap ~0) is left at vendor geometry.
//
// The axes target different clearances: 18px on the right puts the
// visible circle ~22px in, matching the comfortable teaser-era inset;
// 6px on the bottom lands the circle ~10px up — visually matching the
// rolled-back production resting point (the old 36px translate on a 43px
// vendor inset put the edge at ~7px) instead of floating higher.
export const RESPOND_IO_ANCHOR_RIGHT_PX = 18;
export const RESPOND_IO_ANCHOR_BOTTOM_PX = 6;

/**
* Translate delta that moves an iframe edge sitting `vendorInsetPx` from
* the viewport edge onto the `anchorPx` anchor. Insets of ~1px or less
* mean a full-bleed state (e.g. the mobile open panel) — leave those
* alone.
*/
export function launcherAnchorDelta(
vendorInsetPx: number,
anchorPx: number
): number {
return vendorInsetPx > 1 ? vendorInsetPx - anchorPx : 0;
}

/**
* Keep `data-launcher-only` in sync with the live widget iframe. The
* iframe is injected asynchronously after widget.js runs, the vendor flips
* its `state` attribute, and the prompt resizes the same element — so we
* observe both DOM insertion and per-iframe size/attribute changes.
* Keep `data-launcher-only` and the anchor transform in sync with the
* live widget iframe. The iframe is injected asynchronously after
* widget.js runs, the vendor flips its `state` attribute and rewrites
* inline `right`/`bottom`, and the prompt resizes the same element — so
* we observe DOM insertion, resizes, and `state`/`style` changes.
* Returns a cleanup that disconnects every observer and clears the
* marker — the vendor iframe can outlive the component, and a stale
* `data-launcher-only` would re-apply the launcher clip to a prompt.
* marker + transform — the vendor iframe can outlive the component, and
* stale overrides must never survive the watcher that maintains them.
*/
export function watchRespondIoIframe(): () => void {
let iframe: HTMLIFrameElement | null = null;
let appliedDx = 0;
let appliedDy = 0;
let lastTransform = "";
let sizeObserver: ResizeObserver | null = null;
let stateObserver: MutationObserver | null = null;

const sync = (el: HTMLIFrameElement) => {
// Stale callbacks from a replaced iframe must not touch shared state.
if (el !== iframe) return;
syncLauncherOnlyAttribute(el);
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
// The rect already includes our transform — recover the vendor's own
// inset (translate(+d) shrinks the measured gap by d) before
// recomputing the delta, so re-syncing after a style mutation is
// idempotent rather than accumulating. If the transform was wiped
// externally (e.g. the vendor rewrote style.cssText), the rect is
// already untransformed — don't add the cached deltas back, and
// rewrite the transform below.
const transformIntact = el.style.transform === lastTransform;
const effDx = transformIntact ? appliedDx : 0;
const effDy = transformIntact ? appliedDy : 0;
const vendorRight = Math.round(window.innerWidth - rect.right + effDx);
const vendorBottom = Math.round(window.innerHeight - rect.bottom + effDy);
const dx = launcherAnchorDelta(vendorRight, RESPOND_IO_ANCHOR_RIGHT_PX);
const dy = launcherAnchorDelta(vendorBottom, RESPOND_IO_ANCHOR_BOTTOM_PX);
if (transformIntact && dx === appliedDx && dy === appliedDy) return;
appliedDx = dx;
appliedDy = dy;
lastTransform = dx || dy ? `translate(${dx}px, ${dy}px)` : "";
el.style.transform = lastTransform;
};

const attach = (el: HTMLIFrameElement) => {
// The vendor can insert a replacement iframe before removing the old
// one — retire the previous observers so their callbacks can't run
// against the outgoing element, and strip its overrides so a detached
// iframe never keeps our marker or translate.
sizeObserver?.disconnect();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
stateObserver?.disconnect();
if (iframe && iframe !== el) {
iframe.removeAttribute("data-launcher-only");
iframe.style.transform = "";
}
iframe = el;
syncLauncherOnlyAttribute(el);
sizeObserver = new ResizeObserver(() => syncLauncherOnlyAttribute(el));
appliedDx = 0;
appliedDy = 0;
lastTransform = "";
sync(el);
sizeObserver = new ResizeObserver(() => sync(el));
sizeObserver.observe(el);
stateObserver = new MutationObserver(() => syncLauncherOnlyAttribute(el));
stateObserver.observe(el, { attributes: true, attributeFilter: ["state"] });
stateObserver = new MutationObserver(() => sync(el));
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
stateObserver.observe(el, {
attributes: true,
attributeFilter: ["state", "style"],
});
};

const scan = () => {
const found = document.querySelector('iframe[title="Webchat Widget"]');
// If the vendor ever inserts a replacement before removing the old
// iframe, prefer the newest (last in DOM order).
const all = document.querySelectorAll('iframe[title="Webchat Widget"]');
const found = all[all.length - 1];
if (found instanceof HTMLIFrameElement && found !== iframe) attach(found);
};

Expand All @@ -117,6 +202,9 @@ export function watchRespondIoIframe(): () => void {
bodyObserver.disconnect();
sizeObserver?.disconnect();
stateObserver?.disconnect();
iframe?.removeAttribute("data-launcher-only");
if (iframe) {
iframe.removeAttribute("data-launcher-only");
iframe.style.transform = "";
}
};
}
Binary file modified review-shots/chat-anchor/desktop-1440-closed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/desktop-1440-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/desktop-1440-teaser.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/mobile-390-closed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/mobile-390-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/mobile-390-teaser.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/tablet-768-closed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/tablet-768-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified review-shots/chat-anchor/tablet-768-teaser.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading