mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
5aa3f3346d
commit
c133b4ac74
5 files changed
+111
-8
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user