From 49237cfcf633bb3d009154b62638ed8f40dcfa7e Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:58:15 +0200 Subject: [PATCH] fix(widget): FAB dodges interactive controls on narrow viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 320px the fixed corner launcher sat directly on the /auth GitHub sign-in button (and pricing CTAs) — max z-index, so it steals the tap on the one control a signup needs. data-fc-bottom only helps hosts that know their own layout, so the widget now hit-tests the stack under the FAB (elementsFromPoint, shadow host excluded) and steps upward 16px at a time until nothing tappable is beneath it, capped at 12 steps. Runs at mount, after hydration settles, on resize, and when scrolling settles; ≤480px only, and the host's data-fc-bottom stays the base. Proven against the live orangecat /auth page at 320×800 by route-intercepting widget.js with this build: FAB lands at bottom:160px, zero overlap with the GitHub button; at 1280px the host's 80px offset is preserved untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018RmMktaYn8MuJdEqjp5gWU --- widget/main.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/widget/main.ts b/widget/main.ts index 32a5dc47..c4149cbc 100644 --- a/widget/main.ts +++ b/widget/main.ts @@ -325,7 +325,53 @@ function h( window.addEventListener("scroll", () => { fab.classList.add("scrolling"); clearTimeout(scrollSettle); - scrollSettle = window.setTimeout(() => fab.classList.remove("scrolling"), 350); + scrollSettle = window.setTimeout(() => { + fab.classList.remove("scrolling"); + dodge(); + }, 350); + }, { passive: true }); + + // Narrow viewports: a fixed corner launcher can land ON an interactive + // control — measured covering the /auth GitHub sign-in button at 320px, + // where the max-z FAB steals the tap. `data-fc-bottom` only helps hosts + // that know their own layout; pages can't predict what scrolls into the + // corner. So hit-test the stacking context under the FAB and step it + // upward until nothing tappable sits beneath (capped, so a pathological + // page can't walk it off-screen). + const baseBottom = Number.isFinite(bottomOffset) ? bottomOffset : 12; + const INTERACTIVE = "a,button,input,select,textarea,summary,[role='button']"; + const dodge = () => { + if (fab.style.display === "none") return; // hidden while panel is open — rect is degenerate + if (window.innerWidth > 480) { + fab.style.bottom = Number.isFinite(bottomOffset) ? `${bottomOffset}px` : ""; + return; + } + let bottom = baseBottom; + fab.style.bottom = `${bottom}px`; + for (let i = 0; i < 12; i++) { + const r = fab.getBoundingClientRect(); + const pts: Array<[number, number]> = [ + [r.left + 3, r.top + 3], [r.right - 3, r.top + 3], + [r.left + 3, r.bottom - 3], [r.right - 3, r.bottom - 3], + [(r.left + r.right) / 2, (r.top + r.bottom) / 2], + ]; + const covered = pts.some(([x, y]) => + document.elementsFromPoint(x, y).some( + (el) => el !== host && !host.contains(el) && el.closest(INTERACTIVE) !== null, + ), + ); + if (!covered) return; + bottom += 16; + fab.style.bottom = `${bottom}px`; + } + }; + dodge(); + // Layout shifts after hydration/fonts move the controls under the corner. + window.setTimeout(dodge, 800); + let resizeSettle = 0; + window.addEventListener("resize", () => { + clearTimeout(resizeSettle); + resizeSettle = window.setTimeout(dodge, 150); }, { passive: true }); // ---- panel (built once, shown on demand) ----