fix(design): a user row answers as a whole card — the hover layer under its badges and buttons, a click anywhere opens the account (E28)

The layer was the name button's own and ended at the row's first badge or button. The card now paints
the B3 layer under its content (isolate + a new z-under level), the name button's hit layer spans the
card, and the actions sit above it.
This commit is contained in:
2026-09-25 23:43:26 +02:00
parent 4ef11901db
commit 113da76c36
5 changed files with 50 additions and 6 deletions
@@ -98,6 +98,24 @@ describe("the last sign-in column (E22 S1)", () => {
});
});
// E28 (owner): the hover ended wherever the row's first badge or button began, because the layer was
// the name button's own. The card is the row: it paints the layer under its content, and the name
// button's hit layer spans the card, under the actions that sit above it.
describe("a user row", () => {
it("answers as a whole card, with its actions above the card's hit layer", async () => {
mount();
const open = await screen.findByTitle("users.account.open");
const card = open.parentElement!;
expect(card.className).toMatch(/(^|\s)isolate(\s|$)/);
expect(card.className).toMatch(/(^|\s)hover:before:bg-\(--hover\)(\s|$)/);
expect(open.className).toMatch(/(^|\s)after:inset-0(\s|$)/);
expect(open.className).not.toMatch(/selectable|hover:/);
const actions = [...card.querySelectorAll("button")].filter((b) => b !== open);
expect(actions.length).toBeGreaterThan(0);
for (const b of actions) expect(b.className).toMatch(/(^|\s)relative(\s|$)/);
});
});
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
+22 -4
View File
@@ -54,6 +54,13 @@ import { SURFACE } from "./ui/surfaces";
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// pre-select the tab without importing this lazy-loaded page — see that file.)
// A user row's B3 layer (DESIGN.md "State layers"), painted by the card under its content, and the name
// button's stretched hit layer over the whole card. Why the row is built so: at the row below.
const ROW_LAYER =
"relative isolate before:absolute before:inset-0 before:z-under before:rounded-[inherit] before:transition-colors before:content-[''] hover:before:bg-(--hover) has-[[data-row-open]:active]:before:bg-(--press)";
const ROW_OPEN =
"focus-visible:outline-none after:absolute after:inset-0 after:rounded-xl after:content-[''] focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-accent";
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
@@ -215,11 +222,20 @@ function UsersRoles({ me }: { me: Me }) {
{rows.map((u) => {
const self = u.id === me.id;
return (
<div key={u.id} className={`${SURFACE.card} flex items-center gap-2.5 p-2.5`}>
// The whole card is the row, so the whole card answers (owner, E28): the name's button
// stretches an invisible layer over the card (`after:inset-0`), so a click on a badge or
// a gap opens the account too, and the B3 hover layer is the card's own `::before`, under
// the badges and buttons (`isolate` + `z-under`). The actions are `relative`, above the
// stretched layer. The hover used to be the name button's own and ended at the first badge.
<div
key={u.id}
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
data-row-open=""
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className="selectable cursor-pointer min-w-0 flex-1 rounded-lg -m-1 p-1"
className={`min-w-0 flex-1 cursor-pointer ${ROW_OPEN}`}
>
<div className="text-sm truncate">
{displayName(u)}
@@ -251,7 +267,7 @@ function UsersRoles({ me }: { me: Me }) {
<Button
variant="glass"
size="xs"
className="shrink-0"
className="relative shrink-0"
onClick={() => onToggle(u)}
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
// focusable so a keyboard user reaches the reason. A pending write is transient.
@@ -281,6 +297,7 @@ function UsersRoles({ me }: { me: Me }) {
}
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
className="relative"
onClick={() => onSuspend(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
@@ -301,6 +318,7 @@ function UsersRoles({ me }: { me: Me }) {
label={t("users.delete.action")}
size="sm"
variant="outline-danger"
className="relative"
onClick={() => onDelete(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
@@ -321,7 +339,7 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.roles.lastLoginNone")
}
>
<span className="hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
</span>
</Tooltip>
+3
View File
@@ -48,6 +48,9 @@
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
/* `under` is not an app layer: a surface's own layer painted under its content, inside that surface's
`isolate` stacking context (a whole-card row's hover layer, DESIGN.md State layers). */
--z-index-under: -1;
--z-index-base: 10;
--z-index-menu: 20;
--z-index-chrome: 30;