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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 17:50:55 +02:00
1 parent 5aa3f3346d
commit c133b4ac74
5 files changed
+111 -8

No files matched your search

+49
View File
@@ -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,
}) => {
@@ -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");
});
});
@@ -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,
)}
>
@@ -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}
>
<Link
href="/coach/plans"
@@ -170,7 +179,7 @@ export function ConversationsPanel({
aria-label={t("coach.plans.title")}
title={t("coach.plans.title")}
>
<Target className="size-5 md:size-4" aria-hidden="true" />
<Target className={PANEL_HEADER_ICON} aria-hidden="true" />
</Link>
</Button>
<CoachSettingsOverlay
@@ -180,7 +189,7 @@ export function ConversationsPanel({
if (!next) setSettingsOnData(false);
}}
focusData={settingsOnData}
className="md:size-7 [&_svg]:size-5 md:[&_svg]:size-4"
className="pointer-fine:size-7 [&_svg]:size-5 pointer-fine:[&_svg]:size-4"
/>
{inSheet ? (
<SheetClose asChild>
@@ -191,9 +200,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 md:size-7"
className={PANEL_HEADER_BUTTON}
>
<X className="size-5 md:size-4" aria-hidden="true" />
<X className={PANEL_HEADER_ICON} 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-md py-2 pr-11 pl-2 text-left text-sm transition-colors sm:min-h-9",
"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 pointer-fine: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.
@@ -334,7 +334,7 @@ function HistoryRow({
})}
data-slot="coach-history-row-menu"
className={cn(
"text-muted-foreground hover:text-foreground absolute top-1/2 right-0 size-11 -translate-y-1/2 sm:right-1 sm:size-9",
"text-muted-foreground hover:text-foreground absolute top-1/2 right-0 size-11 -translate-y-1/2 pointer-fine:right-1 pointer-fine:size-9",
// Pointer devices reveal it on hover or focus; touch shows it
// always, since there is no hover to reveal it with.
"[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/row:opacity-100 [@media(hover:hover)]:group-hover/row:opacity-100 [@media(hover:hover)]:data-[state=open]:opacity-100",