chore(coach): lift the rail-tray chevrons out of the overlay

The mobile history + sources tray triggers used to mount as absolute
chevron buttons over the message thread, so they (a) overlapped the
first message bubble, (b) sat at a 28 px tap target (`h-7`), and (c)
relied on `backdrop-blur` to stay legible — which still failed on
busy threads.

Lift them into a sub-header strip above the thread: a flex row with
two ghost buttons at `min-h-11`. The strip itself carries `xl:hidden`
since both rails are inline at xl+; the history trigger keeps its own
`lg:hidden` because the history rail mounts inline already at lg+.

While the rail surface is in scope, drop the `opacity-0
group-hover:opacity-100` reveal on the history-rail per-row delete
button — touch surfaces have no hover, so the affordance was
invisible on mobile. The button now sits at `size-11` always-visible;
the confirming state still styles via `data-confirming`.

Test re-baselined to assert the new strip wrapper + `min-h-11` floor.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 18:34:53 +02:00
1 parent 2d8c5e906e
commit beb2b40f7d
3 files changed
+68 -42

No files matched your search

@@ -41,18 +41,22 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
});
it("hides the history trigger on >=lg and the sources trigger on >=xl", () => {
// v1.4.27 R3d MB2 — the strip wrapper carries `xl:hidden` so the
// sources trigger inherits the breakpoint; the history trigger
// keeps its own `lg:hidden` because the history rail mounts
// inline already on lg+ while the sources rail still needs the
// chevron until xl.
const html = render(<CoachDrawerBody {...baseProps} />);
const strip = html.match(
/<div[^>]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/,
);
expect(strip?.[0]).toContain("xl:hidden");
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger).not.toBeNull();
expect(historyTrigger?.[0]).toContain("lg:hidden");
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/,
);
expect(sourcesTrigger).not.toBeNull();
expect(sourcesTrigger?.[0]).toContain("xl:hidden");
});
it("renders the desktop history rail (lg+) and sources rail (xl+)", () => {
@@ -95,12 +99,16 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
expect(html).toContain("Worauf ich zugreife");
});
it("the thread region wraps the rail triggers in a relative container", () => {
// The triggers use absolute positioning; the thread region must
// be `relative` for them to anchor inside it.
it("renders the rail triggers in a sub-header strip above the thread", () => {
// v1.4.27 R3d MB2 — the triggers were lifted out of the absolute
// overlay into a sub-header strip so the chevrons sit at a 44 px
// tap target and never overlay the first message bubble. The
// strip lives above the thread within the centre column.
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(
/<main[^>]*data-slot="coach-drawer-thread"[^>]*relative/,
expect(html).toMatch(/data-slot="coach-drawer-rail-tray-strip"/);
const trigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("min-h-11");
});
});
@@ -59,36 +59,48 @@ export function CoachDrawerBody({
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="relative flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col"
>
{/* v1.4.20 phase B4 — mobile rail tray triggers. Hidden on
`>=lg` because the rails are inline there. */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="bg-background/80 border-border/60 absolute top-2 left-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the chevrons
sit at a 44 px tap target and never overlay the first
message bubble. Hidden on `>=xl` because both rails are
inline at that breakpoint. The history trigger remains
visible up to lg-1 (rail is inline at lg+); the sources
trigger remains visible up to xl-1 (rail is inline at
xl+). */}
<div
data-slot="coach-drawer-rail-tray-strip"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2 xl:hidden"
>
<ChevronRight className="size-3" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="bg-background/80 border-border/60 absolute top-2 right-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur xl:hidden"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-3" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="min-h-11 gap-1.5 text-xs lg:hidden"
>
<ChevronRight className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. The empty `<span>` to
the left keeps the right-aligned button placement when
the history trigger hides at lg+. */}
<span aria-hidden="true" className="hidden lg:block" />
<Button
type="button"
variant="ghost"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="min-h-11 gap-1.5 text-xs"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-4" aria-hidden="true" />
</Button>
</div>
<div className="min-h-0 flex-1">{thread}</div>
{/* Composer pinned to the bottom. */}
@@ -138,6 +138,12 @@ export function HistoryRail({
{formatRelativeTime(c.updatedAt, t)}
</span>
</button>
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
reveal: touch surfaces have no hover, so the delete
affordance was invisible on mobile. The button now
sits at 44 px always-visible per the WCAG 2.5.5
floor; confirming state stays styled via the
`data-confirming` attribute. */}
<Button
type="button"
variant="ghost"
@@ -151,12 +157,12 @@ export function HistoryRail({
data-slot="coach-history-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-6 shrink-0 opacity-0 transition-opacity group-hover:opacity-100",
"size-11 shrink-0",
isConfirming &&
"text-dracula-red hover:text-dracula-red opacity-100",
"text-dracula-red hover:text-dracula-red",
)}
>
<Trash2 className="size-3.5" aria-hidden="true" />
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
);