mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(coach): put New chat in the conversations panel header
The round button floated over the list's bottom right and covered the last conversation, which the list offset with a pb-20 reserve. It is now the first control of the panel header, left of the title, in the docked panel and the phone sheet alike: 28 px beside a fine pointer, the 44 px floor on touch, with its accessible name and a tooltip. The reserve goes with it, and the header packs its touch-sized buttons tighter so the title keeps its word at 390 px.
This commit is contained in:
1 parent
402c503781
commit
f788e2892d
3 files changed
+85
-70
No files matched your search
+52
-40
@@ -7,8 +7,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
|
||||
/**
|
||||
* The Coach page frame: the conversations panel on the right, its toggle in
|
||||
* the top bar, the settings gear in the panel header, and New chat at the
|
||||
* panel's bottom right.
|
||||
* the top bar, the settings gear in the panel header, and New chat opening
|
||||
* that header row.
|
||||
*
|
||||
* Contracts under test, at 1440, 1280 and 390 px:
|
||||
*
|
||||
@@ -20,7 +20,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* top bar ends at its left edge and the panel's header border is the
|
||||
* top bar's. The toggle is the bar's last item, within 16 px of its
|
||||
* right edge, open and shut; shut, no pixel of the panel shows and the
|
||||
* bar runs to the window's edge. New chat is a round 48 px plus.
|
||||
* bar runs to the window's edge. New chat is the header row's first
|
||||
* control, left of the title, and never overlaps the list.
|
||||
* 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
|
||||
@@ -31,8 +32,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* can see" and drops the param from the URL.
|
||||
* 5. axe finds nothing on the open panel or the open settings, in the
|
||||
* light and the dark theme.
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
|
||||
* right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat first in its
|
||||
* header and the title beside it; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. Nothing
|
||||
* scrolls sideways.
|
||||
*
|
||||
@@ -316,19 +317,38 @@ async function expectToggleAtBarEnd(page: Page) {
|
||||
expect(at.gap).toBeLessThanOrEqual(16);
|
||||
}
|
||||
|
||||
/** New chat: a round 48 px button with the plus glyph. */
|
||||
async function expectFab(page: Page) {
|
||||
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
const box = (await fab.boundingBox())!;
|
||||
expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]);
|
||||
/**
|
||||
* New chat opens the panel header: the row's first control, left of the
|
||||
* title, a plus glyph with an accessible name. It sits above the list, so no
|
||||
* row ever slides under it.
|
||||
*/
|
||||
async function expectNewChatInHeader(scope: Locator) {
|
||||
const header = scope.locator(
|
||||
'[data-slot="coach-conversations-panel-header"]',
|
||||
);
|
||||
const button = header.locator('[data-slot="coach-panel-new-chat"]');
|
||||
await expect(button).toHaveCount(1);
|
||||
await expect(button.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(button).toHaveAttribute("aria-label", "New chat");
|
||||
const order = await header.evaluate((el) => {
|
||||
const first = el.firstElementChild as HTMLElement;
|
||||
const heading = el.querySelector("h2") as HTMLElement;
|
||||
return {
|
||||
first: first.dataset.slot,
|
||||
buttonRight: first.getBoundingClientRect().right,
|
||||
headingLeft: heading.getBoundingClientRect().left,
|
||||
};
|
||||
});
|
||||
expect(order.first).toBe("coach-panel-new-chat");
|
||||
expect(order.buttonRight).toBeLessThanOrEqual(order.headingLeft);
|
||||
const buttonBox = (await button.boundingBox())!;
|
||||
const listBox = (await scope
|
||||
.locator('[data-slot="coach-history-list"]')
|
||||
.boundingBox())!;
|
||||
expect(
|
||||
await fab.evaluate((el) =>
|
||||
parseFloat(getComputedStyle(el).borderTopLeftRadius),
|
||||
),
|
||||
"round",
|
||||
).toBeGreaterThanOrEqual(24);
|
||||
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expect(fab).toHaveAttribute("aria-label", "New chat");
|
||||
buttonBox.y + buttonBox.height,
|
||||
"the button ends above the list",
|
||||
).toBeLessThanOrEqual(listBox.y);
|
||||
}
|
||||
|
||||
/** Rendered heights of the docked panel's header buttons and first row. */
|
||||
@@ -341,6 +361,7 @@ async function panelControlHeights(page: Page) {
|
||||
.boundingBox())!.height,
|
||||
);
|
||||
return {
|
||||
newChat: await height("coach-panel-new-chat"),
|
||||
plans: await height("coach-panel-plans"),
|
||||
gear: await height("coach-settings"),
|
||||
row: await height("coach-history-select"),
|
||||
@@ -439,7 +460,7 @@ test.describe("Coach page frame", () => {
|
||||
await expectPanelFlush(page, width, 900);
|
||||
// Open, the toggle is the top bar's last item, against the panel.
|
||||
await expectToggleAtBarEnd(page);
|
||||
await expectFab(page);
|
||||
await expectNewChatInHeader(panel(page));
|
||||
// The bar says where the reader is: the page, then the conversation.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-top-bar-trail"]'),
|
||||
@@ -505,6 +526,7 @@ test.describe("Coach page frame", () => {
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 28,
|
||||
plans: 28,
|
||||
gear: 28,
|
||||
row: 36,
|
||||
@@ -524,6 +546,7 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await panelControlHeights(page)).toEqual({
|
||||
newChat: 44,
|
||||
plans: 44,
|
||||
gear: 44,
|
||||
row: 44,
|
||||
@@ -552,18 +575,8 @@ test.describe("Coach page frame", () => {
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
|
||||
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
|
||||
|
||||
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
const fabBox = (await fab.boundingBox())!;
|
||||
const asideBox = (await panel(page).boundingBox())!;
|
||||
// 24 px in from the panel's corner from `md`, 16 px on a phone.
|
||||
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
|
||||
24,
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
|
||||
).toBeCloseTo(24, 0);
|
||||
await fab.click();
|
||||
const newChat = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
await newChat.click();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-input-textarea"]'),
|
||||
@@ -788,16 +801,15 @@ test.describe("Coach page frame", () => {
|
||||
const sheet = page.getByRole("dialog", { name: "Conversations" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
|
||||
// Measured once the sheet has slid in.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await fab.boundingBox())!;
|
||||
return Math.round(390 - (box.x + box.width));
|
||||
})
|
||||
.toBe(16);
|
||||
expect((await fab.boundingBox())!.width).toBe(48);
|
||||
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
|
||||
await expectNewChatInHeader(sheet);
|
||||
// The title keeps its whole word beside four header controls.
|
||||
const title = sheet.locator(
|
||||
'[data-slot="coach-conversations-panel-header"] h2',
|
||||
);
|
||||
expect(
|
||||
await title.evaluate((el) => el.scrollWidth <= el.clientWidth),
|
||||
"the sheet title is not truncated",
|
||||
).toBe(true);
|
||||
await shot(page, testInfo, "coach-frame-390-panel");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -46,8 +46,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* and the top bar runs to the window's edge. The toggle is the last item of
|
||||
* the top bar, so it sits against the open panel and at the window's edge
|
||||
* when the panel is shut. The panel header carries the title, a link to
|
||||
* Plans, the settings gear and, in the sheet, a close button. The round New
|
||||
* chat button sits at the panel's bottom right.
|
||||
* Plans, the settings gear and, in the sheet, a close button. New chat
|
||||
* opens the header row, left of the title: a fixed control that never sits
|
||||
* over the list, so the last conversation needs no reserve below it.
|
||||
*
|
||||
* Keyboard: Escape closes the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
@@ -214,13 +215,41 @@ export function ConversationsPanel({
|
||||
// app (`shell-safe-area` in `auth-shell.tsx`).
|
||||
// Docked, the row is the top bar's band (height and bottom border
|
||||
// from `SHELL_HEADER_BAND`), so the two borders draw one line.
|
||||
// Touch: 44 px buttons already carry their own air, so the row packs
|
||||
// them tighter to leave the title room at 390 px.
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
"flex shrink-0 items-center gap-1 pointer-fine:gap-2",
|
||||
inSheet
|
||||
? "border-border border-b p-3"
|
||||
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
// The panel's one action, so the primary wash marks it apart
|
||||
// from the muted utility buttons on the right.
|
||||
className={cn(
|
||||
PANEL_HEADER_BUTTON,
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{inSheet ? (
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
{t("insights.coach.historyTitle")}
|
||||
@@ -287,28 +316,6 @@ export function ConversationsPanel({
|
||||
onUndoDeleteActive={(id) => onSelect(id)}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
{/* New chat: a round filled button floating at the panel's bottom
|
||||
right; the list pads its own scroll area so the last row clears it. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
className="absolute right-4 bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] size-12 rounded-full shadow-lg md:right-6 md:bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))]"
|
||||
>
|
||||
<Plus className="size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -149,11 +149,7 @@ export function HistoryRail({
|
||||
<div
|
||||
ref={setListNode}
|
||||
data-slot="coach-history-list"
|
||||
className={cn(
|
||||
// `pb-20` keeps the last row clear of the panel's floating New
|
||||
// chat button; it pads this list's own scroll area, not `main`.
|
||||
"-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1 pb-20",
|
||||
)}
|
||||
className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1"
|
||||
>
|
||||
{isLoading && visible.length === 0 ? (
|
||||
<p
|
||||
|
||||
Reference in new issue
Block a user