diff --git a/apps/dashboard/e2e/dashboard.spec.ts b/apps/dashboard/e2e/dashboard.spec.ts
index 9a2b133..6956051 100644
--- a/apps/dashboard/e2e/dashboard.spec.ts
+++ b/apps/dashboard/e2e/dashboard.spec.ts
@@ -105,6 +105,55 @@ test.describe("mobile portrait overview", () => {
await expect(page.getByTestId("panel-snapchain")).toContainText("58,429");
await context.setOffline(false);
});
+
+ test("keeps every explanatory tooltip inside the visual viewport", async ({ browser }) => {
+ const viewports = [
+ { width: 320, height: 568, safe: { bottom: 18, left: 28, right: 24, top: 20 } },
+ { width: 360, height: 844, safe: { bottom: 12, left: 12, right: 12, top: 12 } },
+ { width: 390, height: 844, safe: { bottom: 12, left: 12, right: 12, top: 12 } },
+ { width: 430, height: 844, safe: { bottom: 12, left: 12, right: 12, top: 12 } }
+ ];
+ for (const { width, height, safe } of viewports) {
+ const context = await browser.newContext({ viewport: { width, height } });
+ const page = await context.newPage();
+ await page.goto("/?demo=1");
+ const triggers = page.locator(".info-trigger");
+ await expect(triggers).toHaveCount(4);
+
+ for (let index = 0; index < 4; index += 1) {
+ const trigger = triggers.nth(index);
+ await trigger.click();
+ if (index === 0) {
+ await page.evaluate((insets) => {
+ for (const [side, value] of Object.entries(insets)) {
+ document.documentElement.style.setProperty(`--fc-safe-area-inset-${side}`, `${value}px`);
+ }
+ }, safe);
+ }
+ const tooltipId = await trigger.getAttribute("aria-describedby");
+ expect(tooltipId).not.toBeNull();
+ const tooltip = page.locator(`[id="${tooltipId}"]`);
+ await expect(tooltip).toBeVisible();
+ const bounds = await tooltip.boundingBox();
+ const viewport = await page.evaluate(() => ({
+ height: window.visualViewport?.height ?? window.innerHeight,
+ left: window.visualViewport?.offsetLeft ?? 0,
+ top: window.visualViewport?.offsetTop ?? 0,
+ width: window.visualViewport?.width ?? window.innerWidth
+ }));
+ expect(bounds).not.toBeNull();
+ expect(bounds!.x).toBeGreaterThanOrEqual(viewport.left + safe.left - 1);
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.left + viewport.width - safe.right + 1);
+ expect(bounds!.y).toBeGreaterThanOrEqual(viewport.top + safe.top - 1);
+ expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(viewport.top + viewport.height - safe.bottom + 1);
+ expect(await tooltip.evaluate((element) => element.parentElement === document.body)).toBe(true);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
+ await page.keyboard.press("Escape");
+ await expect(trigger).not.toHaveAttribute("aria-describedby", /.+/);
+ }
+ await context.close();
+ }
+ });
});
test("switches to side-by-side protocol comparison on desktop", async ({ page }) => {
diff --git a/apps/dashboard/src/components/InfoTip.test.tsx b/apps/dashboard/src/components/InfoTip.test.tsx
new file mode 100644
index 0000000..81e5561
--- /dev/null
+++ b/apps/dashboard/src/components/InfoTip.test.tsx
@@ -0,0 +1,65 @@
+// @vitest-environment jsdom
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import { InfoTip } from "./InfoTip";
+
+describe("InfoTip", () => {
+ it("keeps visual and described state synchronized for focus and Escape", async () => {
+ const user = userEvent.setup();
+ render(Shared UTC window.);
+ const trigger = screen.getByRole("button", { name: "Explain the metric" });
+
+ await user.tab();
+ const tooltip = screen.getByRole("tooltip");
+ expect(trigger).toHaveFocus();
+ expect(trigger).toHaveAttribute("aria-describedby", tooltip.id);
+
+ await user.hover(trigger);
+ await user.unhover(trigger);
+ expect(trigger).toHaveAttribute("aria-describedby", tooltip.id);
+ expect(screen.getByRole("tooltip")).toBeInTheDocument();
+
+ await user.keyboard("{Escape}");
+ expect(trigger).toHaveFocus();
+ expect(trigger).not.toHaveAttribute("aria-describedby");
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ });
+
+ it("toggles on click, closes outside, and permits only one open tip", async () => {
+ const user = userEvent.setup();
+ render(
+
+ First tooltip.
+ Second tooltip.
+
+
+ );
+ const first = screen.getByRole("button", { name: "First explanation" });
+ const second = screen.getByRole("button", { name: "Second explanation" });
+
+ await user.click(first);
+ expect(screen.getByRole("tooltip")).toHaveTextContent("First tooltip.");
+ await user.click(second);
+ expect(screen.getByRole("tooltip")).toHaveTextContent("Second tooltip.");
+ expect(first).not.toHaveAttribute("aria-describedby");
+
+ await user.click(screen.getByRole("button", { name: "Outside" }));
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ });
+
+ it("remains hoverable before dismissing after the pointer leaves", async () => {
+ const user = userEvent.setup();
+ render(Hoverable tooltip.);
+ const trigger = screen.getByRole("button", { name: "Hover explanation" });
+
+ await user.hover(trigger);
+ const tooltip = screen.getByRole("tooltip");
+ await user.unhover(trigger);
+ await user.hover(tooltip);
+ expect(tooltip).toBeInTheDocument();
+ await user.unhover(tooltip);
+ await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
+ });
+});
diff --git a/apps/dashboard/src/components/InfoTip.tsx b/apps/dashboard/src/components/InfoTip.tsx
index cfdbb2a..b22c960 100644
--- a/apps/dashboard/src/components/InfoTip.tsx
+++ b/apps/dashboard/src/components/InfoTip.tsx
@@ -1,24 +1,192 @@
-import { useId, useState } from "react";
+import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+type OpenReason = "click" | "focus" | "hover";
+type TooltipPosition = Readonly<{ left: number; placement: "above" | "below"; ready: boolean; top: number }>;
+
+const OPEN_EVENT = "snapmeter:info-tip-open";
+const VIEWPORT_GUTTER_PX = 12;
+const TRIGGER_GAP_PX = 8;
+
+function cssPixels(style: CSSStyleDeclaration, property: string): number {
+ const value = Number.parseFloat(style.getPropertyValue(property));
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
+}
export function InfoTip({ label, children }: { label: string; children: React.ReactNode }): React.JSX.Element {
const id = useId();
- const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+ const tooltipRef = useRef(null);
+ const closeTimerRef = useRef(undefined);
+ const [openReason, setOpenReason] = useState(null);
+ const [position, setPosition] = useState({ left: 0, placement: "above", ready: false, top: 0 });
+ const open = openReason !== null;
+
+ const cancelScheduledClose = useCallback((): void => {
+ if (closeTimerRef.current !== undefined) window.clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = undefined;
+ }, []);
+
+ const close = useCallback((): void => {
+ cancelScheduledClose();
+ setOpenReason(null);
+ }, [cancelScheduledClose]);
+
+ const openAs = useCallback((reason: OpenReason): void => {
+ cancelScheduledClose();
+ setOpenReason(reason);
+ document.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: id }));
+ }, [cancelScheduledClose, id]);
+
+ const scheduleHoverClose = useCallback((): void => {
+ cancelScheduledClose();
+ closeTimerRef.current = window.setTimeout(() => {
+ setOpenReason((current) => current === "hover" ? null : current);
+ closeTimerRef.current = undefined;
+ }, 100);
+ }, [cancelScheduledClose]);
+
+ const updatePosition = useCallback((): void => {
+ const trigger = triggerRef.current;
+ const tooltip = tooltipRef.current;
+ if (!trigger || !tooltip) return;
+
+ const viewport = window.visualViewport;
+ const viewportLeft = viewport?.offsetLeft ?? 0;
+ const viewportTop = viewport?.offsetTop ?? 0;
+ const viewportRight = viewportLeft + (viewport?.width ?? window.innerWidth);
+ const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
+ const rootStyle = window.getComputedStyle(document.documentElement);
+ const minLeft = viewportLeft + Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-left"));
+ const maxRight = viewportRight - Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-right"));
+ const minTop = viewportTop + Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-top"));
+ const maxBottom = viewportBottom - Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-bottom"));
+ const usableWidth = Math.max(1, maxRight - minLeft);
+ const usableHeight = Math.max(1, maxBottom - minTop);
+
+ // Constrain the overlay before measuring it. The visual viewport can be
+ // narrower than 100vw in a Mini App, and Farcaster safe-area insets make
+ // the actually usable rectangle smaller again.
+ tooltip.style.setProperty("--tooltip-max-width", `${usableWidth}px`);
+ tooltip.style.setProperty("--tooltip-max-height", `${usableHeight}px`);
+ const triggerRect = trigger.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+ const maxLeft = Math.max(minLeft, maxRight - tooltipRect.width);
+ const left = Math.min(maxLeft, Math.max(minLeft, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2));
+ const aboveTop = triggerRect.top - TRIGGER_GAP_PX - tooltipRect.height;
+ const belowTop = triggerRect.bottom + TRIGGER_GAP_PX;
+ const fitsAbove = aboveTop >= minTop;
+ const fitsBelow = belowTop + tooltipRect.height <= maxBottom;
+ const placement = fitsAbove || (!fitsBelow && triggerRect.top - minTop >= maxBottom - triggerRect.bottom)
+ ? "above"
+ : "below";
+ const desiredTop = placement === "above" ? aboveTop : belowTop;
+ const maxTop = Math.max(minTop, maxBottom - tooltipRect.height);
+ const top = Math.min(maxTop, Math.max(minTop, desiredTop));
+
+ setPosition({ left, placement, ready: true, top });
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ setPosition((current) => ({ ...current, ready: false }));
+ updatePosition();
+ }, [children, open, updatePosition]);
+
+ useEffect(() => {
+ if (!open) return;
+ let frame = 0;
+ const schedulePosition = (): void => {
+ window.cancelAnimationFrame(frame);
+ frame = window.requestAnimationFrame(updatePosition);
+ };
+ const viewport = window.visualViewport;
+ const rootStyleObserver = new MutationObserver(schedulePosition);
+ rootStyleObserver.observe(document.documentElement, { attributeFilter: ["style"], attributes: true });
+ window.addEventListener("resize", schedulePosition);
+ window.addEventListener("scroll", schedulePosition, true);
+ viewport?.addEventListener("resize", schedulePosition);
+ viewport?.addEventListener("scroll", schedulePosition);
+ return () => {
+ window.cancelAnimationFrame(frame);
+ rootStyleObserver.disconnect();
+ window.removeEventListener("resize", schedulePosition);
+ window.removeEventListener("scroll", schedulePosition, true);
+ viewport?.removeEventListener("resize", schedulePosition);
+ viewport?.removeEventListener("scroll", schedulePosition);
+ };
+ }, [open, updatePosition]);
+
+ useEffect(() => {
+ const handleOtherOpen = (event: Event): void => {
+ if ((event as CustomEvent).detail !== id) close();
+ };
+ document.addEventListener(OPEN_EVENT, handleOtherOpen);
+ return () => document.removeEventListener(OPEN_EVENT, handleOtherOpen);
+ }, [close, id]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handlePointerDown = (event: PointerEvent): void => {
+ const target = event.target as Node | null;
+ if (!triggerRef.current?.contains(target) && !tooltipRef.current?.contains(target)) close();
+ };
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") close();
+ };
+ document.addEventListener("pointerdown", handlePointerDown, true);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown, true);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [close, open]);
+
+ useEffect(() => () => cancelScheduledClose(), [cancelScheduledClose]);
+
+ const tooltip = open ? createPortal(
+ {
+ if (event.pointerType === "mouse" && openReason === "hover") cancelScheduledClose();
+ }}
+ onPointerLeave={(event) => {
+ if (event.pointerType === "mouse" && openReason === "hover") scheduleHoverClose();
+ }}
+ >
+ {children}
+ ,
+ document.body
+ ) : null;
+
return (
-
- {children}
-
+ {tooltip}
);
}
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index 595d62f..2afa290 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -669,8 +669,8 @@ canvas:focus-visible {
position: relative;
display: inline-grid;
place-items: center;
- width: 16px;
- height: 16px;
+ width: 24px;
+ height: 24px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 50%;
@@ -683,57 +683,37 @@ canvas:focus-visible {
line-height: 1;
}
-.info-trigger::after {
- content: "";
- position: absolute;
- inset: -10px;
-}
-
.tooltip {
- position: absolute;
- z-index: 90;
- right: -3px;
- bottom: calc(100% + 8px);
- width: min(260px, calc(100vw - 36px));
- padding: 9px 10px;
+ position: fixed;
+ z-index: 110;
+ width: min(260px, var(--tooltip-max-width, calc(100vw - 24px)));
+ max-width: var(--tooltip-max-width, calc(100vw - 24px));
+ max-height: var(--tooltip-max-height, calc(100dvh - 24px));
+ overflow: auto;
+ padding: 10px 11px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 5px;
color: #e7edf2;
background: #131820;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
- font-size: 0.7rem;
+ font-size: 0.8125rem;
font-style: normal;
font-weight: 450;
letter-spacing: 0;
- line-height: 1.4;
+ line-height: 1.45;
text-align: left;
text-transform: none;
white-space: normal;
+ pointer-events: auto;
opacity: 0;
visibility: hidden;
- transform: translateY(4px);
- transition: opacity 120ms ease, transform 120ms ease, visibility 120ms;
+ transition: opacity 120ms ease, visibility 120ms;
}
-.tooltip[data-open],
-.info-trigger:hover + .tooltip,
-.info-trigger:focus-visible + .tooltip {
+.tooltip[data-open][data-ready] {
opacity: 1;
visibility: visible;
- transform: translateY(0);
-}
-
-.metric-label .tooltip {
- right: auto;
- left: 50%;
- transform: translate(-56%, 4px);
-}
-
-.metric-label .tooltip[data-open],
-.metric-label .info-trigger:hover + .tooltip,
-.metric-label .info-trigger:focus-visible + .tooltip {
- transform: translate(-56%, 0);
}
/* Deeper analytics */