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
26 changes: 16 additions & 10 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -365,15 +365,21 @@ html[data-hero-in-view] iframe[title="Webchat Widget"][state="widgetClose"] {
}

/*
* Resting-position calibration (issue #125) + prompt-safety (issue #184).
* Measured on production: the closed launcher iframe is 90x90 at
* right/bottom:49px (vendor 25px + dashboard spacing), while the visible
* blue circle (~57-59px) sits ~4-5px inside the iframe's corner — an
* effective ~53-54px of clearance vs Cookiebot's ~10-11px. Shifting the
* launcher 36px right/down puts the circle ~17-18px from the viewport
* edges. `transform` is safe: the vendor positions via inline
* left/right/bottom and does not use transform, and transforms never
* create document overflow.
* Fixed launcher anchor (issue #125 calibration, reworked) +
* prompt-safety (issue #184).
*
* 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.
*
* The launcher iframe's transparent top/left still intercepts pointer
* events (verified via elementFromPoint on production). `clip-path`
Expand All @@ -391,6 +397,6 @@ html[data-hero-in-view] iframe[title="Webchat Widget"][state="widgetClose"] {
* positioning and hit region untouched.
*/
iframe[title="Webchat Widget"][data-launcher-only] {
transform: translate(36px, 36px);
transform: translate(6px, 6px);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
clip-path: inset(25% 0 0 25%);
}
5 changes: 3 additions & 2 deletions components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +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 (~80px) 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">
{/* 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">
<div className="grid gap-8 sm:grid-cols-2 sm:gap-10 lg:grid-cols-4">

{/* Plan Your Trip */}
Expand Down
Binary file added 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 added 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 added 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.
Binary file added 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 added 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 added 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.
Binary file added 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 added 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 added 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.
42 changes: 25 additions & 17 deletions tests/e2e/chat-launcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Page } from "@playwright/test";
import { test, expect, hydratedGoto } from "./fixtures";

// Homepage chat-launcher suppression (#123), resting-position
// calibration (#125), and prompt-safety (#184).
// Homepage chat-launcher suppression (#123), fixed launcher anchor
// (#125 calibration, reworked), and prompt-safety (#184).
//
// The real Respond.io iframe is third-party and asynchronous, so these
// tests inject deterministic stand-ins matching the vendor's contract: an
Expand All @@ -14,12 +14,15 @@ import { test, expect, hydratedGoto } from "./fixtures";
// card inflates the same widgetClose iframe to ~330x179 at
// right/bottom:43px). What we own is the mechanism —
// IntersectionObserver toggling `data-hero-in-view` on <html>, the CSS
// rule hiding `[state="widgetClose"]` while it is set, and the 36px
// resting-position translate scoped to `data-launcher-only` — and that is
// what these assertions exercise. The visible bubble sits inset inside
// the iframe, so iframe-box clearance (13px) intentionally differs from
// visible-bubble clearance (~17-18px). Real-widget verification was done
// manually against production.
// rule hiding `[state="widgetClose"]` while it is set, and the 6px
// anchor-alignment translate scoped to `data-launcher-only` — and that is
// what these assertions exercise. The translate lands the closed
// launcher's edge on the same 43px inset the prompt and open states use,
// so the launcher circle keeps one fixed screen position in every state.
// The visible bubble sits inset inside the iframe, so iframe-box
// clearance (43px) intentionally differs from visible-bubble clearance
// (~47-48px). Real-widget verification was done manually against
// production.

async function injectLauncher(page: Page, state = "widgetClose") {
await page.evaluate((s) => {
Expand Down Expand Up @@ -71,11 +74,12 @@ test("homepage: closed launcher hides on hero, appears past it, hides again", as
await page.evaluate((y) => window.scrollTo(0, y + 10), heroBottom);
await expect.poll(() => launcherState(page)).toMatchObject({
visibility: "visible",
// Vendor 49px offset minus the 36px calibration translate = 13px of
// iframe-box clearance (the visible bubble insets ~5px more).
bottom: 13,
right: 13,
transform: "matrix(1, 0, 0, 1, 36, 36)",
// Vendor 49px offset minus the 6px anchor translate = 43px of
// iframe-box clearance — the same inset the prompt state uses, so the
// launcher circle stays put when the teaser appears.
bottom: 43,
right: 43,
transform: "matrix(1, 0, 0, 1, 6, 6)",
});

// Back to the hero — closed launcher hides again.
Expand All @@ -89,7 +93,7 @@ test("an open conversation is never forcibly hidden on the hero", async ({ page
await injectLauncher(page, "widgetOpen");
const s = await launcherState(page);
expect(s?.visibility).toBe("visible");
// The calibration translate is scoped to data-launcher-only — the open
// The anchor translate is scoped to data-launcher-only — the open
// conversation is never repositioned.
expect(s?.transform).toBe("none");
});
Expand All @@ -113,16 +117,20 @@ test("prompt card hides on the hero but is never clipped or repositioned", async
const s = await launcherState(page);
expect(s?.transform).toBe("none");
expect(s?.clipPath).toBe("none");
// The prompt's iframe edge pins the shared 43px anchor — the same
// effective inset the marked launcher-only state lands on.
expect(s?.bottom).toBe(43);
expect(s?.right).toBe(43);
});

test("interior pages show the launcher immediately", async ({ page }) => {
await hydratedGoto(page, "/diving");
await injectLauncher(page);
const s = await launcherState(page);
expect(s?.visibility).toBe("visible");
expect(s?.bottom).toBe(13);
expect(s?.right).toBe(13);
expect(s?.transform).toBe("matrix(1, 0, 0, 1, 36, 36)");
expect(s?.bottom).toBe(43);
expect(s?.right).toBe(43);
expect(s?.transform).toBe("matrix(1, 0, 0, 1, 6, 6)");
// Transforms never create document overflow — pin that here.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
Expand Down
11 changes: 6 additions & 5 deletions tests/e2e/footer-widgets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
// Real production geometry (measured on www.seasaba.com):
// Respond.io closed iframe, launcher-only: 90x90, right/bottom:49px —
// the component marks it `data-launcher-only` (issue #184), which is
// what applies the translate(36px, 36px) calibration + hit-region
// clip -> box x:[vw-103, vw-13], y:[vh-103, vh-13]; clipped zone
// x:[vw-80, vw-13], y:[vh-80, vh-13].
// what applies the translate(6px, 6px) anchor nudge + hit-region
// clip -> box x:[vw-133, vw-43], y:[vh-133, vh-43]; clipped zone
// x:[vw-110.5, vw-43], y:[vh-110.5, vh-43] — the same 43px inset the
// prompt and open states rest at, so the circle never moves.
// Respond.io closed iframe, prompt card visible: up to ~330x179 at
// right/bottom:43px, still state="widgetClose" but NOT marked —
// vendor positioning and hit region stay intact so the prompt is
Expand Down Expand Up @@ -106,8 +107,8 @@ test("footer bottom bar clears both floating launchers at every width", async ({
expect(m.blocked, `${width}px links intercepted by iframe`).toEqual([]);
// Positive breathing room between the last content row and the
// clipped launcher zone — but bounded so padding can't silently grow
// back into an empty slab (zone top ≈ vh-80, so >35px would mean
// ~115px+ of dead space).
// back into an empty slab (zone top ≈ vh-110.5, so >35px would mean
// ~145px+ of dead space).
expect(m.gapAboveZone, `${width}px gap above launcher zone`).toBeGreaterThan(4);
expect(m.gapAboveZone, `${width}px gap above launcher zone`).toBeLessThan(35);
}
Expand Down
8 changes: 5 additions & 3 deletions tests/unit/respond-io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,19 @@ it("hides only the closed widget launcher while the homepage hero is in view", (
expect(globalsCss).not.toContain("translateY(calc(-60px");
});

// Launcher resting-position calibration (issue #125) + prompt-safety
// Fixed launcher anchor (#125 calibration, reworked) + prompt-safety
// (issue #184): the vendor reuses the closed-state iframe for the
// promotional prompt, growing it far beyond the 90x90 launcher — so the
// translate/clip contract must key off the loader-maintained
// `data-launcher-only` geometry marker, never the bare widgetClose state.
it("shifts only the marked launcher-only iframe to its calibrated resting position", () => {
it("nudges only the marked launcher-only iframe onto the shared 43px anchor", () => {
const bodies = [...globalsCss.matchAll(
/iframe\[title="Webchat Widget"\]\[data-launcher-only\]\s*{([^}]+)}/g
)].map((m) => m[1]);
const combined = bodies.join("\n");
expect(combined).toContain("transform: translate(36px, 36px)");
// Vendor closed inset is 49px; the prompt/open inset is 43px. The 6px
// nudge lands the launcher circle on the same anchor in every state.
expect(combined).toContain("transform: translate(6px, 6px)");
// clip-path shrinks the transparent hit area to the circle's quarter.
expect(combined).toContain("clip-path: inset(25% 0 0 25%)");
expect(combined).not.toContain("!important");
Expand Down
83 changes: 83 additions & 0 deletions verify-chat-anchor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";

// Chat launcher anchor verification: the launcher circle must keep one
// fixed viewport position across closed / teaser / open-panel states.
// Injects stand-in iframes at the vendor's measured geometries (closed
// 90x90 @ right/bottom:49px — the watcher marks it data-launcher-only;
// prompt ~330x179 @ right/bottom:43px; open panel placeholder) plus a
// blue marker at the iframe's bottom-right corner where the real circle
// sits, then screenshots each state at several viewports and reports the
// marker's distance from the viewport corner.
// Shots -> review-shots/chat-anchor/.
const BASE = process.env.BASE_URL ?? "http://127.0.0.1:3100";
const OUT = process.env.OUT ?? "review-shots/chat-anchor/";
mkdirSync(OUT, { recursive: true });

const browser = await chromium.launch();

const injectState = async (page, kind) => {
await page.evaluate((k) => {
document
.querySelectorAll('iframe[title="Webchat Widget"], #anchor-marker')
.forEach((el) => el.remove());
const f = document.createElement("iframe");
f.title = "Webchat Widget";
const geo = {
closed: { state: "widgetClose", marked: true, w: 90, h: 90, inset: 49 },
teaser: { state: "widgetClose", marked: false, w: 330, h: 179, inset: 43 },
open: { state: "widgetOpen", marked: false, w: 380, h: 580, inset: 43 },
}[k];
f.setAttribute("state", geo.state);
if (geo.marked) f.setAttribute("data-launcher-only", "");
f.style.cssText = `position:fixed;bottom:${geo.inset}px;right:${geo.inset}px;width:${geo.w}px;height:${geo.h}px;border:0;z-index:9999;outline:1px dashed rgba(0,0,0,.25)`;
document.body.appendChild(f);
// Marker stands in for the launcher circle: ~58px diameter sitting
// ~5px inside the iframe's bottom-right corner.
const m = document.createElement("div");
m.id = "anchor-marker";
const r = f.getBoundingClientRect();
m.style.cssText = `position:fixed;left:${r.right - 63}px;top:${r.bottom - 63}px;width:58px;height:58px;border-radius:50%;background:#0b72de;z-index:10000;pointer-events:none`;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
document.body.appendChild(m);
}, kind);
};

const markerAnchor = (page) =>
page.evaluate(() => {
const m = document.getElementById("anchor-marker");
const f = document.querySelector('iframe[title="Webchat Widget"]');
if (!m || !f) return null;
const r = m.getBoundingClientRect();
const fr = f.getBoundingClientRect();
return {
markerBottom: +(innerHeight - r.bottom).toFixed(1),
markerRight: +(innerWidth - r.right).toFixed(1),
markerCenterX: +(r.left + r.width / 2).toFixed(1),
markerCenterY: +(r.top + r.height / 2).toFixed(1),
iframeBottom: +(innerHeight - fr.bottom).toFixed(1),
iframeRight: +(innerWidth - fr.right).toFixed(1),
};
});

const viewports = [
{ name: "mobile-390", width: 390, height: 844 },
{ name: "tablet-768", width: 768, height: 1024 },
{ name: "desktop-1440", width: 1440, height: 900 },
];

for (const vp of viewports) {
const page = await browser.newPage({ viewport: vp });
await page.goto(BASE + "/diving", { waitUntil: "load" });
await page.waitForTimeout(600);
// Scroll past nothing — interior pages show the launcher immediately.
for (const kind of ["closed", "teaser", "open"]) {
await injectState(page, kind);
await page.waitForTimeout(250);
const a = await markerAnchor(page);
await page.screenshot({ path: `${OUT}${vp.name}-${kind}.png` });
console.log(vp.name, kind, JSON.stringify(a));
}
await page.close();
}

await browser.close();
Loading