diff --git a/docs/design.md b/docs/design.md
index 31706f4..550a993 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -140,7 +140,9 @@ is installed without a click.
credential. A failed card has one compact warning icon beside its first quota
title, with no permanent failure sentence or stale badge. Hover, keyboard
focus, or click opens the safe reason and last successful update; Escape,
- focus leaving, or an outside click closes it. Old percentages say "Last" and
+ focus leaving, or an outside click closes it. Details open above the icon
+ when needed and stay within the window as it scrolls or resizes. Old
+ percentages say "Last" and
use muted meters without reset countdowns or replacement explanation lines.
An unavailable result stays unknown. Stale data without a failure says
"Quota awaiting update" in its details rather than claiming a failed refresh.
diff --git a/docs/testing.md b/docs/testing.md
index c958453..e0be1d7 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -191,7 +191,8 @@ as applicable:
- cached and uncached refresh failures showing at most one quota warning per
card, historical percentages and muted meters without a stale badge or
countdown, safe failure details and the last successful time, hover/click/
- keyboard access and Escape/blur/outside dismissal, stale data without a
+ keyboard access and Escape/blur/outside dismissal, details remaining inside
+ the window at its edges and after scroll/resize, stale data without a
failure described as awaiting an update, authentication recovery, and a
successful retry clearing the warning and historical labels;
- reopening with a successful reading older than five minutes keeping its
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 4711fa5..041a816 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1271,6 +1271,48 @@ describe("GSwitch account workspace", () => {
expect(within(card).queryByRole("button", { name: /Quota update failed|Quota awaiting update/ })).not.toBeInTheDocument();
});
+ it("keeps quota details inside the window when the warning is near an edge or the page scrolls", async () => {
+ vi.stubGlobal("innerWidth", 700);
+ vi.stubGlobal("innerHeight", 520);
+ let anchor = new DOMRect(665, 480, 22, 22);
+ const originalRect = HTMLElement.prototype.getBoundingClientRect;
+ const measure = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
+ if (this.classList.contains("quota-alert")) return anchor;
+ if (this.classList.contains("quota-alert-tooltip")) {
+ return new DOMRect(anchor.x + parseFloat(this.style.left || "-4"), anchor.y + parseFloat(this.style.top || "26"), 250, 130);
+ }
+ return originalRect.call(this);
+ });
+ try {
+ mocks.listAccounts.mockResolvedValue([chatAccount]);
+ mocks.accountQuota.mockResolvedValue(staleQuota);
+ mocks.refreshAccountQuota.mockRejectedValue({ code: "network" });
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "Quota update failed for person@example.com" }));
+ const details = screen.getByRole("tooltip");
+ let bounds = details.getBoundingClientRect();
+ expect(bounds.bottom).toBeLessThan(anchor.top);
+ expect(bounds.right).toBeLessThanOrEqual(window.innerWidth - 8);
+ expect(bounds.left).toBeGreaterThanOrEqual(8);
+
+ anchor = new DOMRect(16, 16, 22, 22);
+ fireEvent.scroll(window);
+ bounds = details.getBoundingClientRect();
+ expect(bounds.top).toBeGreaterThan(anchor.bottom);
+ expect(bounds.bottom).toBeLessThanOrEqual(window.innerHeight - 8);
+ expect(bounds.left).toBeGreaterThanOrEqual(8);
+
+ anchor = new DOMRect(665, 480, 22, 22);
+ fireEvent.resize(window);
+ bounds = details.getBoundingClientRect();
+ expect(bounds.bottom).toBeLessThanOrEqual(window.innerHeight - 8);
+ expect(bounds.right).toBeLessThanOrEqual(window.innerWidth - 8);
+ } finally {
+ measure.mockRestore();
+ vi.unstubAllGlobals();
+ }
+ });
+
it("describes stale data without a failure as awaiting an update and omits an invalid last-success time", async () => {
mocks.listAccounts.mockResolvedValue([chatAccount]);
mocks.accountQuota.mockResolvedValue({
diff --git a/src/App.tsx b/src/App.tsx
index 7242b98..cf84d25 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -32,6 +32,7 @@ import {
useCallback,
useEffect,
useId,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -685,6 +686,31 @@ function QuotaAlert({ accountName, failure, id, lastSuccess, formatLocale, t }:
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
+ const detailsRef = useRef(null);
+ const [position, setPosition] = useState({ top: 26, left: -4 });
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ const place = () => {
+ if (!rootRef.current || !detailsRef.current) return;
+ const anchor = rootRef.current.getBoundingClientRect();
+ const details = detailsRef.current.getBoundingClientRect();
+ const below = anchor.bottom + 4;
+ const above = anchor.top - details.height - 4;
+ const top = below + details.height <= window.innerHeight - 8 ? below
+ : above >= 8 ? above : Math.max(8, window.innerHeight - details.height - 8);
+ const left = Math.max(8, Math.min(anchor.left - 4, window.innerWidth - details.width - 8));
+ const next = { top: top - anchor.top, left: left - anchor.left };
+ setPosition((current) => current.top === next.top && current.left === next.left ? current : next);
+ };
+ place();
+ window.addEventListener("resize", place);
+ window.addEventListener("scroll", place, true);
+ return () => {
+ window.removeEventListener("resize", place);
+ window.removeEventListener("scroll", place, true);
+ };
+ }, [open, failure, lastSuccess, formatLocale, t]);
useEffect(() => {
if (!open) return;
@@ -730,7 +756,7 @@ function QuotaAlert({ accountName, failure, id, lastSuccess, formatLocale, t }:
onClick={() => setOpen(true)}
type="button"
>
-
+
{t(failure ? "quota.refreshErrorLabel" : "quota.pending")}
{failure ? {quotaFailureMessage(t, failure)} : null}
{lastSuccess && lastSuccess > 0 && lastSuccess <= Date.now() ? (