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}
>
-
+
{inSheet ? (
@@ -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}
>
-
+
) : null}
diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx
index a3d9da2e5..b19e423fe 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -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",