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

+41 -1
View File
@@ -14,7 +14,9 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
*
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
* and the reading column (thread and composer) is exactly as wide with
* the panel open as with it closed. The choice survives a reload.
* the panel open as with it closed. The choice survives a reload. The
* panel is flush with the viewport's right edge (no scrollbar gutter
* beside it) and runs from the top bar to the bottom edge.
* 2. Escape inside the docked panel closes it and returns focus to the
* toggle.
* 3. A row's menu renames by keyboard (Escape cancels without closing the
@@ -230,6 +232,43 @@ async function expectNoSidewaysScroll(page: Page) {
expect(overflow.main).toBeLessThanOrEqual(0);
}
/**
* The docked panel is flush with the viewport: its right edge is the
* viewport's (no scrollbar gutter beside it), it runs from the top bar's
* bottom edge to the bottom of the viewport, and its own border is the left
* one only.
*/
async function expectPanelFlush(page: Page, width: number, height: number) {
const geometry = await page.evaluate(() => {
const aside = document.querySelector(
'[data-slot="coach-conversations-panel"]',
)!;
const inner = aside.firstElementChild as HTMLElement;
const bar = document.querySelector('[data-slot="top-bar"]')!;
const main = document.getElementById("main-content")!;
const style = getComputedStyle(inner);
const box = aside.getBoundingClientRect();
return {
right: box.right,
top: box.top,
bottom: box.bottom,
barBottom: bar.getBoundingClientRect().bottom,
gutter: main.offsetWidth - main.clientWidth,
borders: [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
],
};
});
expect(geometry.right).toBe(width);
expect(geometry.top).toBe(geometry.barBottom);
expect(geometry.bottom).toBe(height);
expect(geometry.gutter).toBe(0);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
@@ -318,6 +357,7 @@ test.describe("Coach page frame", () => {
// The panel sits right of the column, never over it.
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
await expectPanelFlush(page, width, 900);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
+6 -16
View File
@@ -263,22 +263,12 @@ export default function CoachPageClient() {
<div
data-slot="coach-page"
data-tour-id="coach-hero"
// v1.18.10 (W4) — full-bleed conversation surface. Cancel the
// AuthShell container's padding (`-mt-6` top, `-mb-20` bottom,
// `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the
// full viewport height minus the top bar (`4rem`). No inner card:
// `<CoachConversation>` paints directly onto the page so it reads as
// one continuous conversation, not a window inside a window. The
// separate bottom-right FAB/overlay drawer stays the compact surface.
//
// v1.18.10 (W10) — height is mobile-aware. The fixed `<BottomNav>`
// (64px + iOS home-indicator inset) is mobile-only; the desktop
// full-bleed layout has no bottom nav. On mobile the page must subtract
// the TopBar (`4rem`) AND the BottomNav band (`4rem` +
// `env(safe-area-inset-bottom)`) so the docked composer / Stop control
// always clears the nav instead of sitting under it. On `md+` the nav
// is hidden, so the page reclaims the full height below the TopBar.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
// Full-bleed conversation surface. The shell renders `/coach` without
// its centred container, padding or reserved scrollbar gutter, and its
// `<main>` already clears the top bar and, on phones, the bottom nav,
// so the page simply fills what is left. No inner card: the
// conversation paints directly onto the page.
className="bg-background flex min-h-[32rem] flex-1 flex-col overflow-hidden"
>
{/* `useSearchParams` requires a Suspense boundary so the client-search
bailout never opts the whole route out of static optimisation. */}
@@ -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),