fix(coach): dock the conversations panel flush against the right edge

The Coach page cancelled the shell's container padding with negative
margins, so <main> kept its reserved scrollbar gutter and painted a dead
strip to the right of the panel on classic-scrollbar platforms. The shell
now renders /coach full bleed: no centred container, no padding and no
reserved gutter, and the page fills what <main> leaves below the top bar.
A refusal card (shared record, module off) keeps the padded frame.

The panel header takes one 12 px inset with a gap of 8 between title and
icons, a text-lg title, and 28 px icon buttons beside a pointer (44 px on
touch). Conversation rows take the control radius and a 36 px pointer
floor.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 17:24:49 +02:00
1 parent 854cd31e18
commit c49a04bac6
6 files changed
+97 -30

No files matched your search

@@ -146,14 +146,14 @@ export function ConversationsPanel({
const header = (inSheet: boolean) => (
<div
data-slot="coach-conversations-panel-header"
className="border-border flex h-14 shrink-0 items-center gap-1 border-b pr-2 pl-3"
className="border-border flex shrink-0 items-center gap-2 border-b p-3"
>
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-sm font-semibold">
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
{t("insights.coach.historyTitle")}
</SheetTitle>
) : (
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold">
<h2 className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
{t("insights.coach.historyTitle")}
</h2>
)}
@@ -161,7 +161,7 @@ export function ConversationsPanel({
asChild
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 md:size-7"
>
<Link
href="/coach/plans"
@@ -169,7 +169,7 @@ export function ConversationsPanel({
aria-label={t("coach.plans.title")}
title={t("coach.plans.title")}
>
<Target className="size-4" aria-hidden="true" />
<Target className="size-5 md:size-4" aria-hidden="true" />
</Link>
</Button>
<CoachSettingsOverlay
@@ -179,6 +179,7 @@ export function ConversationsPanel({
if (!next) setSettingsOnData(false);
}}
focusData={settingsOnData}
className="md:size-7 [&_svg]:size-5 md:[&_svg]:size-4"
/>
{inSheet ? (
<SheetClose asChild>
@@ -189,9 +190,9 @@ export function ConversationsPanel({
data-slot="coach-panel-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 md:size-7"
>
<X className="size-4" aria-hidden="true" />
<X className="size-5 md:size-4" aria-hidden="true" />
</Button>
</SheetClose>
) : null}
@@ -294,7 +294,7 @@ function HistoryRow({
title={c.title}
data-slot="coach-history-select"
className={cn(
"flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-lg py-2 pr-11 pl-2 text-left text-sm transition-colors",
"flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-md py-2 pr-11 pl-2 text-left text-sm transition-colors sm:min-h-9",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
// The open conversation reads like the open page in the left
// navigation: the same primary wash, so the two rails agree.
+29 -3
View File
@@ -27,12 +27,19 @@ import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
import { ModulePageGate } from "@/components/layout/module-page-gate";
import {
isModulePageOff,
ModulePageGate,
} from "@/components/layout/module-page-gate";
import { cn } from "@/lib/utils";
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
/** The route that renders edge to edge inside the shell. */
const COACH_FULL_BLEED_PATH = "/coach";
// v1.4.27 MB6 — `/about` joins the public-path list so the GeoLite2
// CC BY-SA 4.0 attribution stays reachable for unauthenticated
// visitors. The `/about` route is already registered in `proxy.ts`
@@ -141,6 +148,17 @@ export function AuthShell({
inSharedRecord &&
!isDestinationInSharedRecord(pathname, sections) &&
!isRecordSettingsPath;
// The Coach page is the one full-bleed route: its conversations panel sits
// flush against the right edge of the viewport and the page owns every
// scroll area inside it. The content box drops the centred container and
// `<main>` drops its reserved scrollbar gutter, which would otherwise paint
// a dead strip to the right of the panel on classic-scrollbar platforms.
// A refusal (shared record, module off) renders a card, so it keeps the
// ordinary padded frame.
const fullBleed =
pathname === COACH_FULL_BLEED_PATH &&
!outsideSharedRecord &&
!isModulePageOff(pathname, user?.modules);
// The whole setup flow, not only its front door: every screen lives under
// `/onboarding/<step>` (v1.39 C2), and an exact match here put the
// questions inside the full app chrome, sidebar and all.
@@ -461,7 +479,10 @@ export function AuthShell({
// window. Anchoring the scroll container closes the escape route
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
className={cn(
"relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0",
fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
)}
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
@@ -479,7 +500,12 @@ export function AuthShell({
*/}
<div
data-slot="main-content-wrapper"
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
data-full-bleed={fullBleed ? "true" : undefined}
className={
fullBleed
? "flex min-h-0 flex-1 flex-col"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{outsideSharedRecord ? (
<SharedRecordUnavailable />
+12 -2
View File
@@ -74,14 +74,24 @@ export function moduleOwningPath(pathname: string): ModuleKey | undefined {
* only after the account payload resolved, so the map is never read on a
* hydration render.
*/
/** True when the module that owns `pathname` is switched off. */
export function isModulePageOff(
pathname: string,
modules: Partial<Record<ModuleKey, boolean>> | null | undefined,
): boolean {
const moduleKey = moduleOwningPath(pathname);
return moduleKey !== undefined && modules?.[moduleKey] === false;
}
export function ModulePageGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user } = useAuth();
const { t } = useTranslations();
const moduleKey = moduleOwningPath(pathname);
const off = moduleKey !== undefined && user?.modules?.[moduleKey] === false;
if (!off) return <>{children}</>;
if (moduleKey === undefined || !isModulePageOff(pathname, user?.modules)) {
return <>{children}</>;
}
const destination = NAV_DESTINATIONS.find((d) =>
isNavDestinationActive(d.href, pathname),