From c133b4ac7401d005314af09784577203e38d56c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 17:50:55 +0200 Subject: [PATCH] fix(coach): size the panel's controls by pointer, not viewport width The panel header buttons shrank to 28 px from md and the conversation rows to 36 px from sm, so a touch tablet wide enough to dock the panel got pointer-sized targets. They now shrink only under pointer-fine: 28 px header buttons, a 36 px gear in the drawer, 36 px rows and row menus beside a mouse or trackpad, the 44 px floor with touch at any width. A unit test refuses a width breakpoint on a control size in the three panel files; an e2e case measures the docked panel at 1280 px with touch and with a fine pointer. --- e2e/coach-layout.spec.ts | 49 +++++++++++++++++++ .../__tests__/panel-touch-targets.test.ts | 45 +++++++++++++++++ .../coach-panel/coach-settings-overlay.tsx | 2 +- .../coach-panel/conversations-panel.tsx | 19 +++++-- .../insights/coach-panel/history-rail.tsx | 4 +- 5 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/panel-touch-targets.test.ts diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index af710b8a4..bf65e6ed7 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -269,6 +269,22 @@ async function expectPanelFlush(page: Page, width: number, height: number) { expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]); } +/** Rendered heights of the docked panel's header buttons and first row. */ +async function panelControlHeights(page: Page) { + const height = async (slot: string) => + Math.round( + (await panel(page) + .locator(`[data-slot="${slot}"]`) + .first() + .boundingBox())!.height, + ); + return { + plans: await height("coach-panel-plans"), + gear: await height("coach-settings"), + row: await height("coach-history-select"), + }; +} + async function shot(page: Page, testInfo: TestInfo, name: string) { const path = testInfo.outputPath(`${name}.png`); await page.screenshot({ path }); @@ -402,6 +418,39 @@ test.describe("Coach page frame", () => { }); } + test("1280 fine pointer: compact header buttons and rows", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockCoach(page); + await openCoach(page); + expect(await panelControlHeights(page)).toEqual({ + plans: 28, + gear: 28, + row: 36, + }); + }); + + test.describe("touch", () => { + // A touch tablet in landscape docks the panel too; the controls keep the + // 44 px floor because the size follows the input, not the width. + test.use({ hasTouch: true }); + + test("1280 touch: header buttons and rows keep the 44 px floor", async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockCoach(page); + await openCoach(page); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + expect(await panelControlHeights(page)).toEqual({ + plans: 44, + gear: 44, + row: 44, + }); + }); + }); + test("1440: picking a row opens it and New chat clears it, both in the URL", async ({ page, }) => { diff --git a/src/components/insights/coach-panel/__tests__/panel-touch-targets.test.ts b/src/components/insights/coach-panel/__tests__/panel-touch-targets.test.ts new file mode 100644 index 000000000..393546a05 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/panel-touch-targets.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { PANEL_HEADER_BUTTON } from "../conversations-panel"; + +/** + * The conversations panel's controls shrink below the 44 px touch floor only + * beside a fine pointer. A width breakpoint (`sm:` / `md:`) cannot tell a + * tablet from a laptop: a touch tablet at 1280 px docks the panel and got + * 28 px header buttons and 36 px rows from one. + */ +const FILES = [ + "conversations-panel.tsx", + "history-rail.tsx", + "coach-settings-overlay.tsx", +].map((name) => ({ + name, + source: readFileSync( + join(process.cwd(), "src/components/insights/coach-panel", name), + "utf8", + ), +})); + +/** A control size set by a width breakpoint: `md:size-7`, `sm:min-h-9`. */ +const WIDTH_SIZED = /\b(?:sm|md|lg|xl):(?:size|min-h|h)-(?:\d|\[)/g; + +describe("conversations panel touch targets", () => { + it.each(FILES)("$name sizes no control by viewport width", ({ source }) => { + expect(source.match(WIDTH_SIZED) ?? []).toEqual([]); + }); + + it.each(FILES)( + "$name shrinks its controls only beside a fine pointer", + ({ source }) => { + expect(source).toMatch(/pointer-fine:(?:size|min-h)-\d/); + }, + ); + + it("keeps the header buttons at 44 px and 28 px only with a fine pointer", () => { + const classes = PANEL_HEADER_BUTTON.split(/\s+/); + expect(classes).toContain("size-11"); + expect(classes).toContain("pointer-fine:size-7"); + }); +}); diff --git a/src/components/insights/coach-panel/coach-settings-overlay.tsx b/src/components/insights/coach-panel/coach-settings-overlay.tsx index 1a0d0a12a..8f694a47b 100644 --- a/src/components/insights/coach-panel/coach-settings-overlay.tsx +++ b/src/components/insights/coach-panel/coach-settings-overlay.tsx @@ -78,7 +78,7 @@ export function CoachSettingsOverlay({ aria-expanded={open} onClick={isPhone ? () => onOpenChange(true) : undefined} className={cn( - "text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9", + "text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9", className, )} > diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index c2c323764..d43b951fa 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -49,6 +49,15 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach"; */ export const COACH_PANEL_ID = "coach-conversations-panel"; +/** + * The panel header's icon buttons: 28 px beside a fine pointer, the 44 px + * touch floor otherwise. Keyed on the input, not the viewport width, so a + * tablet in landscape (touch, docked panel) keeps the touch size. + */ +export const PANEL_HEADER_BUTTON = + "text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7"; +const PANEL_HEADER_ICON = "size-5 pointer-fine:size-4"; + const noSubscription = () => () => {}; /** @@ -162,7 +171,7 @@ export function ConversationsPanel({ asChild variant="ghost" size="icon" - className="text-muted-foreground hover:text-foreground size-11 shrink-0 md:size-7" + className={PANEL_HEADER_BUTTON} > -