Skip to content
Closed
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
7 changes: 5 additions & 2 deletions docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,12 @@ is visible) and never forces an upstream refresh.
provider-named window or prepaid credits.
- A chip turns amber at 70% used and red at 90% used.
- Hover or keyboard-focus a chip to see every reported window with its reset time and the time the
reading was taken. On a touch screen, the first tap shows those details.
reading was taken. The details stay open as the pointer moves from the chip into the popover,
including across the small visual gap. On a touch screen, the first tap shows those details.
- Click a chip (or tap it a second time) to open that provider's Accounts tab in Providers, where
its accounts or API keys are managed.
its accounts or API keys are managed. The popover's **Open account management** link opens the
same tab; keyboard users can Tab from the chip to this link and press Enter. Escape closes the
popover and returns focus to the chip when focus was inside the popover.
- The bar always stays on one line. When the chips do not fit, scroll it sideways or use the « and
» buttons at either end.
- Providers that report no quota window are left out. The bar is hidden when no provider reports one.
Expand Down
12 changes: 12 additions & 0 deletions gui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ bun run setup:hooks # pre-push runs doctor when gui/ changed

Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage.

## Quota popover hover browser regression

```bash
cd gui
bun run build
bun run test:quota-hover
```

This opt-in check is not part of CI. It moves a real pointer from a quota chip into its popover in an
installed Chrome/Chromium (`CHROME_BIN` when not on PATH), which happy-dom unit tests cannot hit-test.
Rebuild after changing CSS: it uses the production CSS in `dist`.

## Sidebar version browser regression

```bash
Expand Down
3 changes: 2 additions & 1 deletion gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"doctor": "npx --yes react-doctor@0.9.11 --verbose --scope changed --base origin/main --no-telemetry",
"doctor:full": "npx --yes react-doctor@0.9.11 --verbose --scope full --no-telemetry",
"preview": "vite preview",
"test:sidebar-version": "bun tests/sidebar-version-browser.ts"
"test:sidebar-version": "bun tests/sidebar-version-browser.ts",
"test:quota-hover": "bun tests/quota-summary-hover-browser.ts"
},
"dependencies": {
"@tanstack/react-virtual": "^3.14.9",
Expand Down
40 changes: 30 additions & 10 deletions gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface QuotaSummaryData {

const POLL_MS = 60_000;
/** Gap between the chip and its fixed-position popover, and the popover's viewport margin. */
const POPOVER_GAP = 4;
const POPOVER_GAP = 7;
const VIEWPORT_MARGIN = 8;
/** A « / » press pages the strip by this share of its visible width. */
const PAGE_FRACTION = 0.8;
Expand Down Expand Up @@ -83,6 +83,8 @@ function usePopoverPlacement(open: boolean, anchorRef: RefObject<HTMLElement | n
// Unitless: the stylesheet multiplies by 1px.
popover.style.setProperty("--qs-pop-top", String(Math.round(rect.bottom + POPOVER_GAP)));
popover.style.setProperty("--qs-pop-left", String(Math.round(left)));
// Share the measured gap with the hover bridge, including fractional-pixel rounding.
popover.style.setProperty("--qs-pop-gap", String(Math.round(rect.bottom + POPOVER_GAP) - rect.bottom));
};
place();
window.addEventListener("scroll", place, { capture: true, passive: true });
Expand Down Expand Up @@ -124,7 +126,14 @@ function QuotaSummaryItem({ row, t, locale }: { row: QuotaSummaryRow; t: TFn; lo
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setTapped(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
if (event.key === "Escape") {
const returnFocus = popoverRef.current?.contains(document.activeElement);
close();
if (returnFocus) {
chipRef.current?.focus();
setSuppressed(true);
}
}
};
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
Expand Down Expand Up @@ -169,19 +178,23 @@ function QuotaSummaryItem({ row, t, locale }: { row: QuotaSummaryRow; t: TFn; lo
className={`quota-summary-item quota-summary-item--${row.severity}`}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onFocus={event => {
if (!isKeyboardFocus(event.target)) return;
setSuppressed(false);
setFocused(true);
}}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
}}
>
<a
ref={chipRef}
href={`#${providerAccountsHash(row.provider)}`}
className={`quota-summary-chip${open ? " quota-summary-chip--open" : ""}`}
aria-expanded={open}
aria-controls={open ? popoverId : undefined}
aria-describedby={open ? popoverId : undefined}
onPointerDown={event => { pointerTypeRef.current = event.pointerType; }}
onFocus={event => {
if (!isKeyboardFocus(event.currentTarget)) return;
setSuppressed(false);
setFocused(true);
}}
onBlur={() => setFocused(false)}
onClick={onClick}
>
<span className="quota-summary-name">{row.label}</span>
Expand All @@ -191,7 +204,7 @@ function QuotaSummaryItem({ row, t, locale }: { row: QuotaSummaryRow; t: TFn; lo
<span className="sr-only">{t("quotaSummary.openAccounts")}</span>
</a>
{open && (
<div ref={popoverRef} id={popoverId} className="quota-summary-popover" role="tooltip">
<div ref={popoverRef} id={popoverId} className="quota-summary-popover" role="group" aria-label={row.label}>
<div className="quota-summary-popover-head">
<strong>{row.label}</strong>
{warning && <span className={`quota-summary-badge quota-summary-badge--${row.severity}`}>{warning}</span>}
Expand All @@ -213,7 +226,14 @@ function QuotaSummaryItem({ row, t, locale }: { row: QuotaSummaryRow; t: TFn; lo
{row.updatedAt !== undefined && (
<span>{t(row.observed ? "quotaSummary.observedAt" : "quotaSummary.dataAt", { time: formatClock(row.updatedAt, locale) })}</span>
)}
<span className="quota-summary-popover-hint">{t("quotaSummary.openAccounts")}</span>
<a
href={`#${providerAccountsHash(row.provider)}`}
className="quota-summary-popover-link"
onPointerDown={event => { pointerTypeRef.current = event.pointerType; }}
onClick={onClick}
>
{t("quotaSummary.openAccounts")}
</a>
</div>
</div>
)}
Expand Down
22 changes: 22 additions & 0 deletions gui/src/components/quota-summary-bar/quota-summary-bar.css
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
}

/* Keep the pointer inside the item while crossing the visual gap to its fixed popover. */
.quota-summary-popover::before {
content: "";
position: absolute;
left: -1px;
right: -1px;
bottom: 100%;
/* The containing block starts inside the border; cover it as well as the measured gap. */
height: calc((var(--qs-pop-gap, 4) + 1) * 1px);
}

.quota-summary-popover-head {
display: flex;
align-items: center;
Expand Down Expand Up @@ -257,6 +268,17 @@
white-space: nowrap;
}

.quota-summary-popover-link {
color: var(--text);
text-decoration: underline;
text-underline-offset: 2px;
}

.quota-summary-popover-link:focus-visible {
outline: 2px solid var(--text);
outline-offset: 1px;
}

.quota-summary-updated--stale {
color: var(--amber);
}
Expand Down
95 changes: 92 additions & 3 deletions gui/tests/quota-summary-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,30 @@ test("every chip is a link to its provider's Accounts tab, and a click follows i
expect(view.chip("xAI Grok").getAttribute("href")).toBe("#providers?provider=xai&tab=accounts");
await click(view.chip("xAI Grok"));
expect(testWindow.location.hash).toBe("#providers?provider=xai&tab=accounts");
expect(view.container.querySelector("[role=tooltip]")).toBeNull();
expect(view.container.querySelector(".quota-summary-popover")).toBeNull();
await view.unmount();
});

test("popover placement shares the rounded gap with its hover bridge and updates on scroll", async () => {
const view = await mount();
const chip = view.chip("xAI Grok");
let bottom = 28.25;
Object.defineProperty(chip, "getBoundingClientRect", {
configurable: true,
value: () => new testWindow.DOMRect(10, bottom - 27, 150, 27),
});
await act(async () => {
chip.dispatchEvent(new testWindow.PointerEvent("pointerover", {
bubbles: true, pointerType: "mouse", relatedTarget: document.body,
} as never) as unknown as Event);
});
const popover = view.container.querySelector<HTMLElement>(".quota-summary-popover")!;
expect(popover.style.getPropertyValue("--qs-pop-top")).toBe("35");
expect(popover.style.getPropertyValue("--qs-pop-gap")).toBe("6.75");
bottom = 28.75;
await act(async () => { window.dispatchEvent(new testWindow.Event("scroll") as unknown as Event); });
expect(popover.style.getPropertyValue("--qs-pop-top")).toBe("36");
expect(popover.style.getPropertyValue("--qs-pop-gap")).toBe("7.25");
await view.unmount();
});

Expand All @@ -92,6 +115,71 @@ test("following the link that is already current re-announces it", async () => {
await view.unmount();
});

test("the popover account link navigates and preserves modified clicks", async () => {
const view = await mount();
await act(async () => {
view.chip("Kimi").dispatchEvent(new testWindow.PointerEvent("pointerover", {
bubbles: true, pointerType: "mouse", relatedTarget: document.body,
} as never) as unknown as Event);
});
const popover = view.container.querySelector(".quota-summary-popover")!;
expect(popover.getAttribute("role")).toBe("group");
expect(popover.getAttribute("aria-label")).toBe("Kimi");
const link = popover.querySelector("a")!;
expect(link.getAttribute("href")).toBe("#providers?provider=kimi&tab=accounts");
await act(async () => {
const modified = new testWindow.MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true });
link.dispatchEvent(modified as unknown as Event);
expect(modified.defaultPrevented).toBe(false);
});
expect(view.container.querySelector(".quota-summary-popover")).not.toBeNull();
await click(link);
expect(testWindow.location.hash).toBe("#providers?provider=kimi&tab=accounts");
expect(view.container.querySelector(".quota-summary-popover")).toBeNull();
await view.unmount();
});

test("keyboard focus reaches the account link, Escape restores the chip, and outside blur dismisses", async () => {
const view = await mount();
const chip = view.chip("Kimi");
// happy-dom has no input modality: emulate :focus-visible for keyboard focus only.
Object.defineProperty(chip, "matches", { configurable: true, value: () => true });
await act(async () => { chip.focus(); });
const link = view.container.querySelector<HTMLAnchorElement>(".quota-summary-popover-link")!;
Object.defineProperty(link, "matches", { configurable: true, value: () => true });
await act(async () => { link.focus(); });
expect(document.activeElement).toBe(link);
expect(view.container.querySelector(".quota-summary-popover")).not.toBeNull();
await act(async () => {
document.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: "Escape", bubbles: true }) as unknown as Event);
});
expect(document.activeElement).toBe(chip);
expect(chip.getAttribute("aria-expanded")).toBe("false");
expect(view.container.querySelector(".quota-summary-popover")).toBeNull();
await act(async () => { chip.blur(); chip.focus(); });
expect(view.container.querySelector(".quota-summary-popover")).not.toBeNull();
await act(async () => { chip.blur(); });
expect(view.container.querySelector(".quota-summary-popover")).toBeNull();
await view.unmount();
});

test("a touch user can follow the popover account link with one tap", async () => {
const view = await mount();
const chip = view.chip("Kimi");
await act(async () => {
chip.dispatchEvent(new testWindow.PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" } as never) as unknown as Event);
});
await click(chip);
const link = view.container.querySelector(".quota-summary-popover-link")!;
await act(async () => {
link.dispatchEvent(new testWindow.PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" } as never) as unknown as Event);
});
await click(link);
expect(testWindow.location.hash).toBe("#providers?provider=kimi&tab=accounts");
expect(view.container.querySelector(".quota-summary-popover")).toBeNull();
await view.unmount();
});

test("the scroll buttons appear only when the chips overflow, and page the one row", async () => {
const view = await mount();
await scrollMetrics(view.list, { scrollWidth: 300, clientWidth: 300, scrollLeft: 0 });
Expand Down Expand Up @@ -135,7 +223,7 @@ test("on touch the first tap shows the detail, the second follows the link, and
});
await click(chip);
};
const tooltip = () => view.container.querySelector("[role=tooltip]");
const tooltip = () => view.container.querySelector(".quota-summary-popover");

await tap("Kimi");
expect(tooltip()?.textContent).toContain("Kimi");
Expand Down Expand Up @@ -173,10 +261,11 @@ test("a clicked chip keeps its detail closed under the resting pointer until the
item.dispatchEvent(new testWindow.PointerEvent(type, { bubbles: true, pointerType: "mouse", relatedTarget } as never) as unknown as Event);
});
};
const tooltip = () => view.container.querySelector("[role=tooltip]");
const tooltip = () => view.container.querySelector(".quota-summary-popover");

await pointer("pointerover", document.body);
expect(tooltip()?.textContent).toContain("xAI Grok");
expect(view.chip("xAI Grok").getAttribute("aria-controls")).toBe(tooltip()?.id ?? "missing");
expect(view.chip("xAI Grok").getAttribute("aria-describedby")).toBe(tooltip()?.id ?? "missing");

await click(view.chip("xAI Grok"));
Expand Down
Loading
Loading