{toasts.map((t) => (
- {t.kind === "ok" ? : }
- {t.text}
-
diff --git a/web/src/styles.css b/web/src/styles.css
index 79f7029..8e58353 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -288,12 +288,12 @@ a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
-input[type="checkbox"] {
- width: auto;
- height: auto;
-}
input[type="checkbox"],
input[type="radio"] {
+ flex: none;
+ width: auto;
+ height: auto;
+ padding: 0;
accent-color: var(--accent);
}
@@ -498,6 +498,13 @@ label.check {
align-items: center;
gap: 8px;
}
+/* a choice whose text may wrap: the box stays on the first line */
+label.check.top {
+ align-items: flex-start;
+}
+label.check.top > input {
+ margin-top: 4px;
+}
form.stack {
gap: 14px;
}
@@ -1559,6 +1566,13 @@ kbd {
.toast.error {
border-color: var(--danger);
}
+.toast.notice > svg:first-child {
+ flex: none;
+ color: var(--accent);
+}
+.toast-link {
+ color: var(--text);
+}
.toast.error > svg:first-child {
color: var(--danger);
}
@@ -4935,6 +4949,121 @@ a.item.import-row:hover {
display: block;
white-space: normal;
}
+.notif-channels {
+ width: 100%;
+ border-collapse: collapse;
+}
+.notif-channels th,
+.notif-channels td {
+ padding: 8px 0;
+ border-top: 1px solid var(--border);
+ text-align: left;
+ font-weight: 400;
+ vertical-align: top;
+}
+.notif-channels thead th {
+ border-top: 0;
+ padding-top: 0;
+ color: var(--text-muted);
+ font-size: 12.5px;
+}
+.notif-channels th + th,
+.notif-channels td {
+ width: 72px;
+ text-align: center;
+}
+.notif-channels tbody tr:first-child th,
+.notif-channels tbody tr:first-child td {
+ border-top-color: var(--border-strong);
+}
+.notif-channels td input {
+ margin-top: 4px;
+}
+
+/* ---------------------------------------------------------------- inbox (the bell) */
+.inbox-btn {
+ position: relative;
+}
+.inbox-count {
+ position: absolute;
+ top: 1px;
+ right: 0;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 10.5px;
+ font-weight: 600;
+ line-height: 16px;
+ text-align: center;
+}
+.inbox {
+ width: min(380px, calc(100vw - 48px));
+ margin: -12px;
+}
+.inbox-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border);
+}
+.inbox-empty {
+ margin: 0;
+ padding: 20px 12px;
+ text-align: center;
+}
+.inbox-list {
+ max-height: min(440px, 70vh);
+ margin: 0;
+ padding: 4px;
+ overflow-y: auto;
+ list-style: none;
+}
+.inbox-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 8px;
+ border-radius: 6px;
+ color: var(--text);
+ text-decoration: none;
+}
+.inbox-item:hover {
+ background: var(--bg-hover);
+ text-decoration: none;
+}
+.inbox-icon {
+ flex: none;
+ margin-top: 2px;
+ color: var(--text-muted);
+}
+.inbox-icon.bad {
+ color: var(--danger);
+}
+.inbox-icon.ok {
+ color: var(--ok);
+}
+.inbox-text {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ gap: 2px;
+}
+.inbox-item.unread .inbox-title {
+ font-weight: 600;
+}
+.inbox-dot {
+ flex: none;
+ width: 8px;
+ height: 8px;
+ margin-top: 6px;
+ border-radius: 50%;
+ background: var(--accent);
+}
/* ---------------------------------------------------------------- test reports (M7) */
.test-report {
From b6469ab3fad042a7479b6ef986c5277e0e12ce6e Mon Sep 17 00:00:00 2001
From: d3vyce <44915747+d3vyce@users.noreply.github.com>
Date: Sat, 14 Nov 2026 07:44:51 -0500
Subject: [PATCH 2/2] fix: tooltips and hover cards that stayed on screen after
a click
Signed-off-by: d3vyce <44915747+d3vyce@users.noreply.github.com>
---
web/e2e/shell.spec.ts | 24 ++++++++++++++++-
web/src/components/HoverCards.tsx | 44 ++++++++++++++++++++++---------
2 files changed, 55 insertions(+), 13 deletions(-)
diff --git a/web/e2e/shell.spec.ts b/web/e2e/shell.spec.ts
index d04e4bb..acc49c1 100644
--- a/web/e2e/shell.spec.ts
+++ b/web/e2e/shell.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect, P, at, isMobile } from "./fixtures";
+import { test, expect, P, ADMIN, at, isMobile } from "./fixtures";
test("dashboard → project through the sidebar and breadcrumbs", async ({ page }) => {
await page.goto("/dashboard/projects");
@@ -149,3 +149,25 @@ test("search issues and merge requests across projects", async ({ page }) => {
await page.getByRole("button", { name: "Merged" }).click();
await expect(page.getByText("Nothing matches")).toBeVisible();
});
+
+test("tips go with a click, and do not come back when a popover closes", async ({ page }) => {
+ test.skip(isMobile(page), "hover needs a pointer");
+ await page.goto("/");
+ const tip = page.locator(".tip");
+ // the avatar's tip: shown on hover, gone once the menu is open
+ await page.locator(".avatar-btn").hover();
+ await expect(tip).toHaveText(ADMIN);
+ await page.locator(".avatar-btn").click();
+ // (the click focuses the button: a tip asked for by that focus would show after its delay)
+ await page.waitForTimeout(600);
+ await expect(tip).toHaveCount(0);
+ await page.mouse.click(500, 500);
+ // the bell: closing its list hands the focus back to the button, the pointer
+ // being elsewhere: no tip may stay there
+ await page.locator(".inbox-btn").click();
+ await expect(page.locator(".inbox")).toBeVisible();
+ await page.mouse.click(500, 500);
+ await expect(page.locator(".inbox")).toHaveCount(0);
+ await page.waitForTimeout(600);
+ await expect(tip).toHaveCount(0);
+});
diff --git a/web/src/components/HoverCards.tsx b/web/src/components/HoverCards.tsx
index e70d3de..55ee74d 100644
--- a/web/src/components/HoverCards.tsx
+++ b/web/src/components/HoverCards.tsx
@@ -65,6 +65,25 @@ function Tip({ text, rect }: { text: string; rect: DOMRect }) {
);
}
+/**
+ * Focus asks for a tip or a card only when it came from the keyboard: a click
+ * focuses what it hits, and a menu or popover that closes hands the focus back
+ * to its button, with the pointer elsewhere and nothing to end the tip.
+ */
+function byKeyboard(e: Event, el: Element) {
+ return e.type !== "focusin" || el.matches(":focus-visible");
+}
+
+/**
+ * Whether the element still asks for its tip or card when the delay ends: on
+ * the page, and under the pointer or focused from the keyboard. Not one that
+ * went away meanwhile (a click that navigated, a menu that closed): its tip
+ * would show in a corner with nothing to end it.
+ */
+function wanted(el: Element) {
+ return el.isConnected && el.matches(":hover, :focus-visible");
+}
+
/**
* Plain tooltips: an element's `title` shows in a styled tip instead of the
* browser's, after a short delay. The title moves to `data-title` (and to
@@ -79,6 +98,7 @@ function useTips() {
const el = (e.target as Element | null)?.closest?.("[title], [data-title]") as HTMLElement | SVGElement | null;
if (!el || el.closest(".hover-card")) return;
if (el instanceof HTMLAnchorElement && targetOf(el)) return;
+ if (!byKeyboard(e, el)) return;
const title = el.getAttribute("title");
if (title) {
el.setAttribute("data-title", title);
@@ -88,7 +108,7 @@ function useTips() {
const text = el.getAttribute("data-title");
if (!text) return;
clearTimeout(t.current);
- t.current = window.setTimeout(() => setTip({ text, rect: el.getBoundingClientRect() }), TIP_MS);
+ t.current = window.setTimeout(() => wanted(el) && setTip({ text, rect: el.getBoundingClientRect() }), TIP_MS);
};
const leave = (e: Event) => {
const el = (e.target as Element | null)?.closest?.("[data-title]");
@@ -108,9 +128,13 @@ function useTips() {
document.addEventListener("focusin", enter);
document.addEventListener("focusout", leave);
document.addEventListener("mousedown", close);
+ // a click focuses what it hits (a tip is asked for again) and may remove it
+ // (a link that navigates): no `mouseout` or `focusout` follows, so it ends here
+ document.addEventListener("click", close, true);
window.addEventListener("scroll", close, true);
window.addEventListener("popstate", close);
return () => {
+ document.removeEventListener("click", close, true);
document.removeEventListener("mouseover", enter);
document.removeEventListener("mouseout", leave);
document.removeEventListener("focusin", enter);
@@ -131,7 +155,7 @@ function useTips() {
*/
export function HoverCards() {
const tip = useTips();
- const [card, setCard] = useState<{ target: Target; rect: DOMRect; anchor: HTMLAnchorElement } | null>(null);
+ const [card, setCard] = useState<{ target: Target; rect: DOMRect } | null>(null);
const showT = useRef
(undefined);
const hideT = useRef(undefined);
const over = useRef(false);
@@ -139,7 +163,7 @@ export function HoverCards() {
useEffect(() => {
const enter = (e: Event) => {
const a = (e.target as Element | null)?.closest?.(SELECTOR) as HTMLAnchorElement | null;
- if (!a) return;
+ if (!a || !byKeyboard(e, a)) return;
const target = targetOf(a);
if (!target) return;
clearTimeout(hideT.current);
@@ -149,7 +173,7 @@ export function HoverCards() {
a.dataset.title = a.title;
a.removeAttribute("title");
}
- showT.current = window.setTimeout(() => setCard({ target, rect: a.getBoundingClientRect(), anchor: a }), SHOW_MS);
+ showT.current = window.setTimeout(() => wanted(a) && setCard({ target, rect: a.getBoundingClientRect() }), SHOW_MS);
};
const leave = (e: Event) => {
const a = (e.target as Element | null)?.closest?.(SELECTOR);
@@ -171,9 +195,13 @@ export function HoverCards() {
document.addEventListener("focusin", enter);
document.addEventListener("focusout", leave);
document.addEventListener("keydown", key);
+ // a click navigates (and focuses the link, which asks for its card again):
+ // the card goes, shown or about to be, wherever the link ends up
+ document.addEventListener("click", close, true);
window.addEventListener("scroll", scrolled, true);
window.addEventListener("popstate", close);
return () => {
+ document.removeEventListener("click", close, true);
document.removeEventListener("mouseover", enter);
document.removeEventListener("mouseout", leave);
document.removeEventListener("focusin", enter);
@@ -184,14 +212,6 @@ export function HoverCards() {
};
}, []);
- // a click navigates: the card goes with it
- useEffect(() => {
- if (!card) return;
- const click = () => setCard(null);
- card.anchor.addEventListener("click", click);
- return () => card.anchor.removeEventListener("click", click);
- }, [card]);
-
if (!card) return tip && ;
const width = 340;
const left = Math.max(8, Math.min(card.rect.left, window.innerWidth - width - 8));