refactor(coach): rework the coach card layout for balance and breathing room

Rebuild the coach conversation surface so it uses the full width without
feeling cramped:

- Widen the inline history rail (260px -> 300px on lg, 340px on xl) and
  bump rail typography + padding off the cramped 10/11px scale so the
  conversation list and source toggles read at a comfortable size.
- Align the dividers across columns: the header, the history-rail
  heading, and the centre rail-tray strip now share one fixed h-14 band
  with identical borders, so the lines land on the same row instead of
  the restless two-height split.
- Redesign the header leading cluster: the surface-toggle sits in its
  own group behind a hairline divider rather than crowding the avatar,
  which steps up to size-9.
- Give the full-page coach card more vertical room and a soft shadow so
  it fills the standalone route instead of leaving a wasted band below.
- Even the coach FAB corner inset so the right gap matches the bottom
  gap at each breakpoint.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:54:08 +02:00
1 parent a7b2b61b99
commit e51fb49b89
7 files changed
+77 -33

No files matched your search

+8 -1
View File
@@ -61,7 +61,14 @@ export default function CoachPage() {
return (
<div
data-slot="coach-page"
className="flex h-[calc(100dvh-13rem)] min-h-[28rem] flex-col overflow-hidden rounded-2xl border"
// v1.18.1 (W-COACH-UI C1/C3) — the card was capped at
// `100dvh-13rem`, leaving a band of wasted space below it on a
// standalone route that no longer carries the Insights tab strip.
// Claim the page height down to a comfortable bottom gutter so the
// thread + rail fill the viewport, and lift the card with a soft
// shadow so it reads as one contained surface rather than a thin
// outline.
className="bg-card flex h-[calc(100dvh-8rem)] min-h-[32rem] flex-col overflow-hidden rounded-2xl border shadow-sm"
>
<CoachConversation
surface="page"
@@ -58,13 +58,16 @@ describe("<LayoutCoachFab>", () => {
// Fixed bottom-right, above the mobile bottom-nav band. The plain
// desktop offset kicks in at `md:` — the same breakpoint where the
// bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air
// in the 768-1023px band. The right inset mirrors the desktop
// bottom inset so the corner gap is even.
// in the 768-1023px band. v1.18.1 (C5) — the corner inset is
// symmetric: right equals bottom at each breakpoint (`right-6`
// mobile-band / `md:right-8` desktop mirrors `md:bottom-8`).
expect(html).toContain("fixed right-6");
expect(html).toContain("md:right-8");
expect(html).toContain(
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
);
expect(html).toContain("md:bottom-6");
expect(html).toContain("md:bottom-8");
expect(html).not.toContain("md:bottom-6");
expect(html).not.toContain("lg:bottom-6");
});
@@ -291,12 +291,23 @@ export function CoachConversation({
>
<header
data-slot="coach-conversation-header"
className="border-border/70 flex flex-row items-center gap-2 border-b p-3 sm:gap-3 sm:p-4"
// v1.18.1 (W-COACH-UI C2/C4) — the header used to crowd a bare
// leading ghost-icon flush against the avatar (the "minimal"
// element the maintainer disliked). The surface-toggle now sits
// in its own leading cluster separated by a hairline divider, the
// avatar steps up to `size-9`, and the band locks to `h-14` so
// its bottom border shares the horizontal line with the body's
// rail-tray strip + history-rail heading one row below.
className="border-border/70 flex h-14 shrink-0 flex-row items-center gap-2.5 border-b px-3 sm:px-4"
>
{leadingHeaderActions}
{leadingHeaderActions ? (
<div className="border-border/60 -ml-1 flex items-center gap-1 pr-1 sm:border-r sm:pr-2.5">
{leadingHeaderActions}
</div>
) : null}
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-dracula-purple to-dracula-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br shadow-sm"
>
<Sparkles className="text-background size-4" />
</div>
@@ -53,9 +53,13 @@ export function CoachDrawerBody({
return (
<div
data-slot="coach-drawer-body"
// v1.18.1 (W-COACH-UI C1/C3) — the inline history rail was a
// tight 260 px gutter; widen it to 300 px (lg) / 340 px (xl) so
// the conversation list breathes and the centre thread keeps the
// remaining width. Without a rail the body is a single column.
className={
historyRail
? "grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr]"
? "grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]"
: "grid min-h-0 flex-1 grid-cols-1"
}
>
@@ -65,7 +69,13 @@ export function CoachDrawerBody({
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
<h2 className="border-border/70 text-muted-foreground border-b p-3 text-[11px] font-medium tracking-wide uppercase">
{/* v1.18.1 (W-COACH-UI C4) — the rail heading and the centre
column's rail-tray strip share one fixed `h-14` header band
with identical `border-b`, so the two dividers land on the
same horizontal line across the columns (the asymmetry the
maintainer flagged: a `p-3` text heading vs a `py-2` button
strip put the lines at different heights). */}
<h2 className="border-border/70 text-muted-foreground flex h-14 shrink-0 items-center border-b px-4 text-xs font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</h2>
<div className="min-h-0 flex-1 overflow-y-auto">{historyRail}</div>
@@ -80,10 +90,14 @@ export function CoachDrawerBody({
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the buttons
sit at a 44 px tap target and never overlay the first
message bubble. */}
message bubble.
v1.18.1 (W-COACH-UI C4) — fixed `h-14` so the strip's
bottom border aligns with the history rail heading divider
in the adjacent column (one continuous line, no restless
two-height split). */}
<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"
className="border-border/70 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-3"
>
<Button
type="button"
@@ -67,7 +67,9 @@ export function HistoryRail({
return (
<div
data-slot="coach-history-rail"
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
// v1.18.1 (W-COACH-UI C1/C3) — a touch more horizontal padding so
// the list rows clear the rail edge and the search field breathes.
className={cn("flex h-full min-h-0 flex-col gap-2.5 p-3.5", className)}
>
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the drawer carries a semantic outline on desktop
@@ -78,7 +80,7 @@ export function HistoryRail({
(the rail still has to stand alone on desktop). */}
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
@@ -97,7 +99,7 @@ export function HistoryRail({
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-history-search"
className="h-8 pl-7 text-xs"
className="h-9 pl-7 text-sm"
/>
</div>
<div
@@ -107,14 +109,14 @@ export function HistoryRail({
{isLoading && conversations.length === 0 ? (
<p
data-slot="coach-history-loading"
className="text-muted-foreground px-2 py-3 text-xs"
className="text-muted-foreground px-2 py-3 text-sm"
>
{t("common.loading")}
</p>
) : filtered.length === 0 ? (
<p
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-xs leading-relaxed"
className="text-muted-foreground px-2 py-3 text-sm leading-relaxed"
>
{t("insights.coach.historyEmpty")}
</p>
@@ -128,8 +130,8 @@ export function HistoryRail({
data-slot="coach-history-item"
data-active={isActive ? "true" : undefined}
className={cn(
"group flex items-center gap-1.5 rounded-md px-2 py-1.5",
"text-xs transition-colors",
"group flex items-center gap-1.5 rounded-lg px-2.5 py-2",
"text-sm transition-colors",
isActive
? "bg-dracula-purple/15 text-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
@@ -142,7 +144,7 @@ export function HistoryRail({
data-slot="coach-history-select"
>
<span className="block truncate font-medium">{c.title}</span>
<span className="text-muted-foreground block text-[10px]">
<span className="text-muted-foreground block text-[11px]">
{formatRelativeTime(c.updatedAt, t)}
</span>
</button>
@@ -105,7 +105,7 @@ export function SourcesRail({ className }: SourcesRailProps) {
// column width: the switch rows used to push past the 280/320 px
// container and paint a horizontal scrollbar under the rail.
className={cn(
"flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-x-hidden p-3",
"flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-x-hidden p-3.5",
className,
)}
>
@@ -113,8 +113,10 @@ export function SourcesRail({ className }: SourcesRailProps) {
data-slot="coach-sources-rail-heading"
// No horizontal inset — the heading sits flush with the row
// boxes below it (headings and their content share one left
// edge across the Coach surfaces).
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
// edge across the Coach surfaces). v1.18.1 (W-COACH-UI C1/C4) —
// typography matched to the history-rail heading so the two rails
// read as one family.
className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wide uppercase"
>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
{t("insights.coach.sourcesTitle")}
@@ -126,7 +128,7 @@ export function SourcesRail({ className }: SourcesRailProps) {
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
</label>
@@ -139,13 +141,13 @@ export function SourcesRail({ className }: SourcesRailProps) {
id="coach-sources-window-select"
data-slot="coach-sources-window-trigger"
size="default"
className="h-9 text-xs"
className="h-9 text-sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w} className="text-xs">
<SelectItem key={w} value={w} className="text-sm">
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
@@ -172,19 +174,19 @@ export function SourcesRail({ className }: SourcesRailProps) {
data-active={checked ? "true" : "false"}
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2.5",
"rounded-md border px-2.5",
"min-h-11 py-1.5",
"rounded-lg border px-3",
"min-h-11 py-2",
!checked && "opacity-60",
)}
>
<div className="flex min-w-0 flex-1 flex-col">
<label
htmlFor={switchId}
className="text-foreground cursor-pointer text-xs font-medium"
className="text-foreground cursor-pointer text-sm font-medium"
>
{t(`insights.coach.cluster.${cluster}.label`)}
</label>
<span className="text-muted-foreground text-[10px] leading-snug">
<span className="text-muted-foreground text-[11px] leading-snug">
{t("insights.coach.sourcesMemberCount", {
count: memberCount,
})}
+9 -4
View File
@@ -157,10 +157,15 @@ export function LayoutCoachFab() {
// safe-area inset on mobile; plain bottom offset once the
// bottom-nav hides (`md:hidden` on the nav, so `md:` here —
// not `lg:` — keeps the FAB from floating mid-air 768-1023px).
// The right inset mirrors the desktop bottom inset so the
// corner gap is even.
"fixed right-6 z-40 size-14 rounded-full shadow-lg",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-6",
// v1.18.1 (W-COACH-UI C5) — the corner inset is now symmetric:
// the right inset equals the bottom inset at each breakpoint
// (`right-6`/`bottom-6` mobile-band, `md:right-8`/`md:bottom-8`
// desktop). The FAB was hugging the right edge (`right-6`)
// while the desktop bottom sat at the same `6`, but it read as
// off-centre against the larger mobile bottom band; pulling it
// a touch further left + matching both offsets evens the gap.
"fixed right-6 z-40 size-14 rounded-full shadow-lg md:right-8",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-8",
// Dark glyph on the purple/pink gradient — white sat at
// ≈2.3:1 against the gradient midpoint; the background token
// reads ≈6.5:1.