From 39fc4e4da2259bb8fe89a46f7d942d1eae585969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 10 Oct 2026 20:04:19 +0200 Subject: [PATCH] feat(shell): run the docked strips the full height and make them the only fold The docked panels' strips now run from the top edge of the window to the bottom, and the top bar ends left of them. The panel header no longer carries a fold button in docked mode: the strip opens and closes the day and, on /coach, the conversations; opening moves focus to the panel's heading and closing returns it to the strip. The sheets keep a close button, the day's now an X at the end of its header. The command palette's door is a magnifier at every width, named Search, with the shortcut in its tooltip. g p opens or closes the day like a click on its strip, and below the docking width opens the sheet on the last day or closes it; the shortcuts list shows it under Day. --- e2e/coach-layout.spec.ts | 77 +++++++++++---- e2e/command-palette.spec.ts | 39 ++++---- e2e/day-doors.spec.ts | 14 ++- e2e/day-view.spec.ts | 77 ++++++++++++++- e2e/keyboard-shortcuts.spec.ts | 96 ++++++++++++++++++- e2e/timeline.spec.ts | 81 ++++++++++++++-- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- .../command-palette-trigger.tsx | 43 ++------- .../day/__tests__/day-view.test.tsx | 11 ++- src/components/day/day-layer-controller.ts | 21 ++++ src/components/day/day-layer.tsx | 46 ++++++--- src/components/day/day-view.tsx | 62 +++++------- .../coach-panel/conversations-panel.tsx | 53 ++++------ .../layout/__tests__/shell-dock.test.tsx | 12 +-- src/components/layout/global-shortcuts.tsx | 13 ++- .../layout/keyboard-shortcuts-dialog.tsx | 1 + src/components/layout/shell-dock.tsx | 20 ++-- src/components/layout/top-bar.tsx | 8 +- .../__tests__/global-shortcuts.test.ts | 47 ++++++++- src/lib/keyboard/global-shortcuts.ts | 34 +++++-- 26 files changed, 561 insertions(+), 229 deletions(-) diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index fc4b02a72..ae6bfbaec 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -21,9 +21,12 @@ import { mockDay } from "./setup/day-mock"; * panel left of the strips, a second click shuts it; the choice * survives a reload. The panel is flush with the strips and runs from * the top of the window to the bottom; the top bar ends at its left - * edge and the panel's header border is the top bar's. Focus moves - * between the fold control and the strip, and Escape inside the open - * panel folds it. + * edge and the panel's header border is the top bar's. Both strips run + * the full height of the window, from its top edge, and the search + * magnifier ends the top bar in front of them, never covered. The strip + * is the docked panel's only fold control (the header has none); focus + * moves between the strip and the panel's heading, and Escape inside + * the open panel folds it. * 2. New conversation is a round 56 px button in the conversation column, * never over the panel, never over the composer's field, and absent * from the panel's header. @@ -218,6 +221,8 @@ async function mockCoach(page: Page): Promise { const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]'); const collapse = (page: Page) => page.locator('[data-slot="coach-panel-collapse"]'); +const heading = (page: Page) => + page.locator('[data-slot="coach-panel-heading"]'); const expand = (page: Page) => page.locator('[data-slot="coach-panel-strip-toggle"]'); const strip = (page: Page) => page.locator('[data-slot="coach-panel-strip"]'); @@ -257,7 +262,9 @@ async function expectNoSidewaysScroll(page: Page) { * right edge (no scrollbar gutter beside them). The panel runs from the top * of the window to the bottom, the top bar ends at its left edge, an open * panel's header row has the top bar's bottom border, and its own border - * is the left one only. Both strips start below the top bar's band. + * is the left one only. Both strips run the full height of the window, and + * the search magnifier at the end of the top bar sits in front of them, + * whole and on top. */ async function expectPanelFlush(page: Page, width: number, height: number) { const geometry = await page.evaluate(() => { @@ -283,6 +290,18 @@ async function expectPanelFlush(page: Page, width: number, height: number) { list: box('[data-slot="coach-panel-strip"]')!, listButton: box('[data-slot="coach-panel-strip-toggle"]')!, day: box('[data-slot="day-strip"]')!, + dayButton: box('[data-slot="day-strip-toggle"]')!, + search: box('[data-slot="command-palette-trigger"]')!, + searchOnTop: (() => { + const r = document + .querySelector('[data-slot="command-palette-trigger"]')! + .getBoundingClientRect(); + return ( + document + .elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) + ?.closest('[data-slot="command-palette-trigger"]') != null + ); + })(), dayDock: box("#day-docked-panel")!, gutter: main.offsetWidth - main.clientWidth, borders: style @@ -305,8 +324,17 @@ async function expectPanelFlush(page: Page, width: number, height: number) { expect(geometry.panel.top).toBe(0); expect(geometry.panel.bottom).toBe(height); expect(geometry.bar.right).toBe(geometry.panel.left); - // The strips start below the band; the palette's corner stays the bar's. - expect(geometry.listButton.top).toBe(geometry.bar.bottom); + // The strips run the full height, from the top edge of the window. + for (const button of [geometry.listButton, geometry.dayButton]) { + expect(button.top).toBe(0); + expect(button.bottom).toBe(height); + } + // The magnifier ends the top bar, left of the strips, whole and on top. + expect(geometry.search.top).toBeGreaterThanOrEqual(0); + expect(geometry.search.bottom).toBeLessThanOrEqual(geometry.bar.bottom); + expect(geometry.search.right).toBeLessThanOrEqual(geometry.bar.right); + expect(geometry.search.right).toBeLessThanOrEqual(geometry.list.left); + expect(geometry.searchOnTop).toBe(true); if (geometry.header !== null) { expect(geometry.header).toBe(geometry.bar.bottom); expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]); @@ -364,7 +392,6 @@ async function panelControlHeights(page: Page) { .boundingBox())!.height, ); return { - collapse: await height("coach-panel-collapse"), plans: await height("coach-panel-plans"), gear: await height("coach-settings"), row: await height("coach-history-select"), @@ -449,10 +476,11 @@ test.describe("Coach page frame", () => { // First visit: open. await expect(panel(page)).toHaveAttribute("data-state", "open"); await waitForWidth(panel(page), 288); - // Docked, the fold control lives in the panel; the top bar has none. + // Docked, the strip is the one fold control: the top bar and the + // panel's header carry none. await expect(toggle(page)).toHaveCount(0); - await expect(collapse(page)).toHaveAttribute("aria-expanded", "true"); - await expect(collapse(page)).toHaveAttribute( + await expect(collapse(page)).toHaveCount(0); + await expect(expand(page)).toHaveAttribute( "aria-controls", "coach-conversations-panel", ); @@ -480,7 +508,7 @@ test.describe("Coach page frame", () => { // Folded: the panel slides to nothing, the strip takes the focus, and // the top bar ends at the strips. - await collapse(page).click(); + await expand(page).click(); await expect(panel(page)).toHaveAttribute("data-state", "closed"); await waitForWidth(panel(page), 0); expect(await stripBox(strip(page))).toEqual(stripOpen); @@ -522,12 +550,17 @@ test.describe("Coach page frame", () => { await expect(expand(page)).toBeFocused(); expect(await stripBox(strip(page))).toEqual(stripOpen); - // Opened from the keyboard, focus lands on the fold control; Escape + // Opened from the keyboard, focus lands on the panel's heading; Escape // from inside folds it again and focus goes back to the strip. await expand(page).focus(); await page.keyboard.press("Enter"); await expect(panel(page)).toHaveAttribute("data-state", "open"); - await expect(collapse(page)).toBeFocused(); + await expect(heading(page)).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + await expect(expand(page)).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(panel(page)).toHaveAttribute("data-state", "open"); await panel(page) .locator('[data-slot="coach-history-select"]') .first() @@ -572,14 +605,14 @@ test.describe("Coach page frame", () => { await shot(page, testInfo, "coach-frame-1440-day"); // Opening the conversations closes the day; its strip keeps the day, - // focus goes to the conversations' fold control, the day is out of the + // focus goes to the conversations' heading, the day is out of the // URL, and the conversation stays the same. await expand(page).click(); await expect(panel(page)).toHaveAttribute("data-state", "open"); await expect(dayPanel(page)).toHaveCount(0); await expect(dayStrip(page)).toHaveAttribute("data-day", day); await expect(dayStrip(page)).toHaveAttribute("data-state", "closed"); - await expect(collapse(page)).toBeFocused(); + await expect(heading(page)).toBeFocused(); await expect(page).toHaveURL(/\/coach\?c=frame-bp$/); await expectNewChatFab(page); await shot(page, testInfo, "coach-frame-1440-list"); @@ -627,7 +660,7 @@ test.describe("Coach page frame", () => { await shot(page, testInfo, "coach-frame-1920-both"); // Folding one leaves the other alone. - await collapse(page).click(); + await expand(page).click(); await expect(panel(page)).toHaveAttribute("data-state", "closed"); await expect(dayPanel(page)).toBeVisible(); await expand(page).click(); @@ -642,7 +675,6 @@ test.describe("Coach page frame", () => { await mockCoach(page); await openCoach(page); expect(await panelControlHeights(page)).toEqual({ - collapse: 28, plans: 28, gear: 28, row: 36, @@ -662,7 +694,6 @@ test.describe("Coach page frame", () => { await openCoach(page); await expect(panel(page)).toHaveAttribute("data-state", "open"); expect(await panelControlHeights(page)).toEqual({ - collapse: 44, plans: 44, gear: 44, row: 44, @@ -935,6 +966,16 @@ test.describe("Coach page frame", () => { await title.evaluate((el) => el.scrollWidth <= el.clientWidth), "the sheet title is not truncated", ).toBe(true); + // Without a strip, the sheet keeps a clear close button and no fold. + await expect( + sheet.locator('[data-slot="coach-panel-close"]'), + ).toBeVisible(); + await expect( + sheet.locator('[data-slot="coach-panel-close"]'), + ).toHaveAttribute("aria-label", "Close"); + await expect( + sheet.locator('[data-slot="coach-panel-collapse"]'), + ).toHaveCount(0); await shot(page, testInfo, "coach-frame-390-panel"); await page.keyboard.press("Escape"); diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts index 0f37045a2..26d24eac5 100644 --- a/e2e/command-palette.spec.ts +++ b/e2e/command-palette.spec.ts @@ -12,13 +12,14 @@ import { expect, test } from "./setup/test"; * - a module's entry is a switch: Enter sends the module write and the * palette stays open (the write is answered by a route mock, so the * shared account's modules never move); - * - the top bar carries the search field on a wide desktop and a - * magnifier on a phone, which opens a full-height sheet with the field - * focused; + * - the top bar carries a magnifier at every width, named "Search", + * with a tooltip that gives the shortcut; on a phone it opens a + * full-height sheet with the field focused; * - the open palette passes axe. * * Stable hooks only: `command-palette` (+ `data-variant`), - * `command-palette-input`, `command-palette-trigger` (+ `data-variant`), + * `command-palette-input`, `command-palette-trigger`, + * `command-palette-tooltip`, * options by `data-entry`. */ @@ -109,20 +110,27 @@ test.describe("command palette", () => { await expect(input(page)).toHaveValue("timeline"); }); - test("the top bar: a search field on a wide desktop", async ({ page }) => { + test("the top bar: a magnifier on a wide desktop, with the shortcut in its tooltip", async ({ + page, + }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); await shellReady(page); - const field = page.locator( - '[data-slot="command-palette-trigger"][data-variant="field"]', + const magnifier = page.locator('[data-slot="command-palette-trigger"]'); + await expect(magnifier).toHaveCount(1); + await expect(magnifier).toBeVisible(); + await expect(magnifier).toHaveAttribute("aria-label", "Search"); + await expect(magnifier).toHaveAttribute( + "aria-keyshortcuts", + /^(Meta|Control)\+K$/, ); - await expect(field).toBeVisible(); + // An icon, not a field: no visible words in the button. + expect((await magnifier.innerText()).trim()).toBe(""); + await magnifier.hover(); await expect( - page.locator( - '[data-slot="command-palette-trigger"][data-variant="icon"]', - ), - ).toBeHidden(); - await field.click(); + page.locator('[data-slot="command-palette-tooltip"]').first(), + ).toHaveText(/^Search \((⌘K|Ctrl K)\)$/); + await magnifier.click(); await expect(palette(page)).toBeVisible(); await expect(input(page)).toBeFocused(); @@ -142,10 +150,9 @@ test.describe("command palette", () => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); await shellReady(page); - const magnifier = page.locator( - '[data-slot="command-palette-trigger"][data-variant="icon"]', - ); + const magnifier = page.locator('[data-slot="command-palette-trigger"]'); await expect(magnifier).toBeVisible(); + await expect(magnifier).toHaveAttribute("aria-label", "Search"); await magnifier.click(); await expect(palette(page)).toHaveAttribute("data-variant", "sheet"); await expect(input(page)).toBeFocused(); diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts index 367e99e46..6ef0f427b 100644 --- a/e2e/day-doors.spec.ts +++ b/e2e/day-doors.spec.ts @@ -245,8 +245,15 @@ test.describe("the dashboard and the list of all values", () => { .first(); await expect(plot).toBeVisible({ timeout: 20_000 }); await openFromPlot(page, plot, testInfo); - // Closing leaves the dashboard on today. - await page.locator('[data-slot="day-close"]').click(); + // Closing leaves the dashboard on today: a sheet from its close button, + // the docked day from its strip, the only control it has. + await page + .locator( + isPhone(testInfo) + ? '[data-slot="day-close"]' + : '[data-slot="day-strip-toggle"]', + ) + .click(); await expect(panel(page)).toHaveCount(0); await expect(page).toHaveURL(/\/$/); }); @@ -292,7 +299,8 @@ test.describe("the dashboard and the list of all values", () => { .toBeLessThanOrEqual(panelBox.x); // Folded, the day keeps its strip; the launcher stays clear of it. - await page.locator('[data-slot="day-close"]').click(); + await page.locator('[data-slot="day-strip-toggle"]').click(); + await expect(panel(page)).toHaveCount(0); const edge = (await page.locator('[data-slot="day-strip"]').boundingBox())!; await expect .poll(async () => { diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 4337d3f33..3b99bacd5 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -21,8 +21,11 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; * - `?day=` opens the layer on any page, Back and Escape close it, a date in * the future is dropped; * - stepping to the neighbouring days never adds history entries; - * - docked, the day collapses to a narrow edge that brings it back on any - * page; a sheet closes for good; + * - docked, the day folds to its strip, which runs the full height of the + * window right of the top bar, is the panel's only open and close + * control and brings the day back on any page; the search magnifier at + * the end of the top bar is never covered by it; a sheet closes for good + * from its close button; * - the dashboard's today area offers no door; its charts below do. * * Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`, @@ -175,12 +178,15 @@ test.describe("the day view", () => { await page.goto(`/mood?day=${day}`); await expect(panel(page)).toHaveAttribute("data-shell", "docked"); - await page.locator('[data-slot="day-close"]').click(); + // Docked, the header has no fold button: the strip is the toggle. + await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0); + const rail = page.locator('[data-slot="day-strip"]'); + const expand = page.locator('[data-slot="day-strip-toggle"]'); + await expect(expand).toHaveAttribute("aria-expanded", "true"); + await expand.click(); await expect(panel(page)).toHaveCount(0); await expect(page).toHaveURL(/\/mood$/); // The strip stays, holding the day, and focus waits on it. - const rail = page.locator('[data-slot="day-strip"]'); - const expand = page.locator('[data-slot="day-strip-toggle"]'); await expect(rail).toHaveAttribute("data-day", day); await expect(rail).toHaveAttribute("data-state", "closed"); await expect(expand).toBeFocused(); @@ -305,6 +311,67 @@ test.describe("the day view", () => { await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0); }); + test("the strip runs the full height; the search magnifier stays clear of it", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "docked from 1280 px", + ); + const day = isoDaysAgo(3); + const strip = page.locator('[data-slot="day-strip"]'); + const search = page.locator('[data-slot="command-palette-trigger"]'); + const topBar = page.locator('[data-slot="top-bar"]'); + for (const width of [1280, 1440, 1920]) { + await page.setViewportSize({ width, height: 900 }); + for (const url of ["/mood", `/mood?day=${day}`]) { + await page.goto(url); + await expect(strip).toBeVisible(); + if (url.includes("day=")) { + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + // The open panel runs the full height beside its strip. + await expect + .poll(async () => (await panel(page).boundingBox())?.width) + .toBeGreaterThan(400); + const p = (await panel(page).boundingBox())!; + expect(p.y).toBe(0); + expect(Math.round(p.y + p.height)).toBe(900); + } + const s = (await strip.boundingBox())!; + // Top edge at the very top of the window, down to the bottom. + expect(s.y).toBe(0); + expect(Math.round(s.height)).toBe(900); + expect(Math.round(s.x + s.width)).toBe(width); + // The top bar ends left of the strips, so the magnifier sits fully + // inside it, in front of them. + const bar = (await topBar.boundingBox())!; + expect(bar.x + bar.width).toBeLessThanOrEqual(s.x + 0.5); + await expect(search).toBeVisible(); + const m = (await search.boundingBox())!; + expect(m.x).toBeGreaterThanOrEqual(0); + expect(m.y).toBeGreaterThanOrEqual(0); + expect(m.x + m.width).toBeLessThanOrEqual(s.x); + expect(m.y + m.height).toBeLessThanOrEqual(bar.y + bar.height); + // Nothing paints over its centre: the hit test lands on it. + const hit = await page.evaluate( + ([x, y]) => + document + .elementFromPoint(x, y) + ?.closest('[data-slot="command-palette-trigger"]') != null, + [m.x + m.width / 2, m.y + m.height / 2], + ); + expect(hit).toBe(true); + // No horizontal scroll anywhere on the page. + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + } + } + }); + test("a future or malformed ?day= is dropped without a word", async ({ page, }) => { diff --git a/e2e/keyboard-shortcuts.spec.ts b/e2e/keyboard-shortcuts.spec.ts index 0c1e64031..d796998db 100644 --- a/e2e/keyboard-shortcuts.spec.ts +++ b/e2e/keyboard-shortcuts.spec.ts @@ -15,7 +15,10 @@ import { mockTimeline } from "./utils/mock-timeline"; * - `?` opens the list: a titled dialog that keeps focus inside and closes * on Escape; the account menu opens the same list; * - `n` opens the add menu; - * - `[` and `]` step the open day, never past today. + * - `[` and `]` step the open day, never past today; + * - `g p` opens and closes the day like a click on its strip (focus into + * the panel, back to the strip); below the docking width it opens the + * sheet on the last day and closes it again. * * The module map is the account's real `/api/auth/me` with only `timeline` * rewritten, so the switch is never moved on a shared account. Desktop only: @@ -149,6 +152,9 @@ test.describe("keyboard shortcuts", () => { "g", "d", ]); + await expect( + dialog.locator('[data-group="day"] [data-shortcut="day-toggle"] kbd'), + ).toHaveText(["g", "p"]); // Focus trap: Tab and Shift+Tab never leave the dialog. for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) { @@ -213,4 +219,92 @@ test.describe("keyboard shortcuts", () => { await page.keyboard.press("]"); await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`)); }); + + test("g p opens and closes the docked day, focus like its strip", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockDay(page); + await page.goto("/insights"); + await shellReady(page); + const panel = page.locator('[data-slot="day-panel"]'); + const strip = page.locator('[data-slot="day-strip-toggle"]'); + await expect(strip).toHaveAttribute("aria-expanded", "false"); + await expect(panel).toHaveCount(0); + + await page.keyboard.press("g"); + await page.keyboard.press("p"); + await expect(panel).toHaveAttribute("data-shell", "docked"); + await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`)); + await expect(strip).toHaveAttribute("aria-expanded", "true"); + // Into the panel, on its heading, the way the strip opens it. + await expect(panel.locator("h2").first()).toBeFocused(); + + await page.keyboard.press("g"); + await page.keyboard.press("p"); + await expect(panel).toHaveCount(0); + await expect(page).toHaveURL(/\/insights$/); + await expect(strip).toHaveAttribute("aria-expanded", "false"); + // Back on the strip, which brings the day back. + await expect(strip).toBeFocused(); + }); + + test("below the docking width g p opens the sheet on the last day and closes it", async ({ + page, + }) => { + await page.setViewportSize({ width: 1024, height: 800 }); + await mockDay(page); + const day = shiftDay(TODAY, -3); + // Open and close a day once, so it is the one remembered. + await page.goto(`/insights?day=${day}`); + await shellReady(page); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toHaveAttribute("data-shell", "sheet"); + await page.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await page.locator("#main-content").focus(); + + await page.keyboard.press("g"); + await page.keyboard.press("p"); + await expect(panel).toHaveAttribute("data-shell", "sheet"); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day, + ); + // Over the sheet, the same keys close it. + await page.keyboard.press("g"); + await page.keyboard.press("p"); + await expect(panel).toHaveCount(0); + await expect(page).toHaveURL(/\/insights$/); + }); + + test("g p stays out of a field and out of another dialog", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockDay(page); + await page.goto("/insights"); + await shellReady(page); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(page.locator('[data-slot="day-strip"]')).toBeVisible(); + await page.evaluate(() => { + const field = document.createElement("input"); + field.setAttribute("data-testid", "probe-field"); + field.setAttribute("aria-label", "Probe"); + document.getElementById("main-content")?.prepend(field); + }); + await page.getByTestId("probe-field").focus(); + await page.keyboard.type("gp"); + await expect(page.getByTestId("probe-field")).toHaveValue("gp"); + await expect(panel).toHaveCount(0); + + await page.locator("#main-content").focus(); + await page.keyboard.press("Shift+?"); + await expect(helpDialog(page)).toBeVisible(); + await page.keyboard.press("g"); + await page.keyboard.press("p"); + await expect(panel).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(helpDialog(page)).toHaveCount(0); + }); }); diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index e2d09e17b..07cf90bc4 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -306,6 +306,68 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th await expect(last).toBeInViewport(); }); +test("each mean's dot sits on the middle of its text and never wraps apart from it", async ({ + page, +}, testInfo) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + const check = async (slot: string) => { + const rows = page.locator(`[data-slot="${slot}"]`); + await expect(rows.first()).toBeVisible({ timeout: 20_000 }); + const units = await rows.evaluateAll((els) => + els.flatMap((row) => + Array.from( + row.querySelectorAll('[data-slot="timeline-mean-part"]'), + ).map((unit) => { + const dot = unit + .querySelector('[data-slot="timeline-series-dot"]')! + .getBoundingClientRect(); + const text = unit.querySelector(".truncate")!.getBoundingClientRect(); + return { + dotMid: dot.top + dot.height / 2, + textMid: text.top + text.height / 2, + dotRight: dot.right, + textLeft: text.left, + textHeight: text.height, + }; + }), + ), + ); + expect(units.length).toBeGreaterThan(0); + for (const unit of units) { + // Centred on the text line, to the pixel. + expect(Math.abs(unit.dotMid - unit.textMid)).toBeLessThanOrEqual(1); + // On the same line, the value right after its dot: one line high. + expect(unit.textLeft).toBeGreaterThan(unit.dotRight); + expect(unit.textLeft - unit.dotRight).toBeLessThanOrEqual(8); + expect(unit.textHeight).toBeLessThanOrEqual(20); + } + }; + + // A narrow chart: the selection bar's means wrap between units only. + await page.setViewportSize({ width: 800, height: 900 }); + await page.goto("/timeline"); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({ + timeout: 20_000, + }); + await check("timeline-selection-means"); + await testInfo.attach("selection-means-800", { + body: await page + .locator('[data-slot="timeline-selection-bar"]') + .screenshot(), + contentType: "image/png", + }); + + // The phone chronicle's means follow the same rule. + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/timeline"); + await check("timeline-chronicle-means"); + await testInfo.attach("chronicle-means-390", { + body: await page.screenshot(), + contentType: "image/png", + }); +}); + test("one medication is one row: its doses cut the bar, its pause is a gap in it", async ({ page, }) => { @@ -621,8 +683,10 @@ test("an open day stays open from the timeline to another page", async ({ day, ); - // Folded by the person, it stays folded on the next page. - await page.locator('[data-slot="day-close"]').click(); + // Folded by the person (the strip is the docked day's only fold control), + // it stays folded on the next page. + await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0); + await page.locator('[data-slot="day-strip-toggle"]').click(); await expect(panel).toHaveCount(0); await page.locator('a[href="/timeline"]').first().click(); await expect(page).toHaveURL(/\/timeline$/); @@ -799,9 +863,6 @@ test("the day's strip stays at the right edge, opens and closes the day, and nev docSw: doc.scrollWidth - doc.clientWidth, docSh: doc.scrollHeight - doc.clientHeight, mainSw: main.scrollWidth - main.clientWidth, - bar: document - .querySelector('[data-slot="top-bar"]')! - .getBoundingClientRect().bottom, }; }); @@ -812,17 +873,19 @@ test("the day's strip stays at the right edge, opens and closes the day, and nev page.locator('[data-slot="timeline-chart"] > svg'), ).toBeVisible({ timeout: 20_000 }); - // Closed: the strip is there, at the right edge, below the top bar's - // band, holding today. + // Closed: the strip is there, at the right edge, the full height of the + // window from its top edge, holding today. await expect(strip).toHaveAttribute("data-state", "closed"); await expect(toggle).toHaveAttribute("aria-expanded", "false"); const closed = await where(); expect(closed[0] + closed[2], `${width}px right edge`).toBe(width); const before = await measure(); + expect(closed[1], `${width}px from the top edge`).toBe(0); + expect(closed[3], `${width}px full height`).toBe(900); expect( Math.round((await toggle.boundingBox())!.y), - `${width}px below the band`, - ).toBe(Math.round(before.bar)); + `${width}px toggle from the top edge`, + ).toBe(0); expect(before.docSw).toBeLessThanOrEqual(0); expect(before.mainSw).toBeLessThanOrEqual(0); diff --git a/messages/de.json b/messages/de.json index 7e8193c6c..8395f39a5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12559,8 +12559,6 @@ } }, "palette": { - "open": "Suchen…", - "openLabel": "Seiten, Einstellungen und Aktionen suchen", "title": "Suche", "description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.", "placeholder": "Seiten, Einstellungen, Module suchen…", @@ -12597,7 +12595,8 @@ "close": "Dialog oder Bereich schließen", "previousDay": "Vorheriger Tag", "nextDay": "Nächster Tag", - "dayHint": "Während ein Tag geöffnet ist.", + "toggleDay": "Tag öffnen oder schließen", + "dayHint": "Vorheriger und nächster Tag gehen, solange ein Tag offen ist.", "groups": { "navigation": "Navigation", "actions": "Aktionen", diff --git a/messages/en.json b/messages/en.json index f128039c2..164ca09ff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12559,8 +12559,6 @@ } }, "palette": { - "open": "Search…", - "openLabel": "Search pages, settings and actions", "title": "Search", "description": "Find a page, a setting, a module or an action.", "placeholder": "Search pages, settings, modules…", @@ -12597,7 +12595,8 @@ "close": "Close a dialog or panel", "previousDay": "Previous day", "nextDay": "Next day", - "dayHint": "While a day is open.", + "toggleDay": "Open or close the day", + "dayHint": "Previous and next day work while a day is open.", "groups": { "navigation": "Navigation", "actions": "Actions", diff --git a/messages/es.json b/messages/es.json index f28a25543..85feaee82 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12559,8 +12559,6 @@ } }, "palette": { - "open": "Buscar…", - "openLabel": "Buscar páginas, ajustes y acciones", "title": "Buscar", "description": "Encuentra una página, un ajuste, un módulo o una acción.", "placeholder": "Buscar páginas, ajustes, módulos…", @@ -12597,7 +12595,8 @@ "close": "Cerrar un diálogo o panel", "previousDay": "Día anterior", "nextDay": "Día siguiente", - "dayHint": "Mientras hay un día abierto.", + "toggleDay": "Abrir o cerrar el día", + "dayHint": "Día anterior y siguiente funcionan mientras hay un día abierto.", "groups": { "navigation": "Navegación", "actions": "Acciones", diff --git a/messages/fr.json b/messages/fr.json index 6dee0c114..e84f55fb3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12559,8 +12559,6 @@ } }, "palette": { - "open": "Rechercher…", - "openLabel": "Rechercher des pages, réglages et actions", "title": "Recherche", "description": "Trouver une page, un réglage, un module ou une action.", "placeholder": "Rechercher des pages, réglages, modules…", @@ -12597,7 +12595,8 @@ "close": "Fermer une boîte de dialogue ou un panneau", "previousDay": "Jour précédent", "nextDay": "Jour suivant", - "dayHint": "Lorsqu’un jour est ouvert.", + "toggleDay": "Ouvrir ou fermer le jour", + "dayHint": "Jour précédent et suivant fonctionnent quand un jour est ouvert.", "groups": { "navigation": "Navigation", "actions": "Actions", diff --git a/messages/it.json b/messages/it.json index fece67e6c..6937f4746 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12559,8 +12559,6 @@ } }, "palette": { - "open": "Cerca…", - "openLabel": "Cerca pagine, impostazioni e azioni", "title": "Cerca", "description": "Trova una pagina, un’impostazione, un modulo o un’azione.", "placeholder": "Cerca pagine, impostazioni, moduli…", @@ -12597,7 +12595,8 @@ "close": "Chiudi una finestra o un pannello", "previousDay": "Giorno precedente", "nextDay": "Giorno successivo", - "dayHint": "Quando un giorno è aperto.", + "toggleDay": "Apri o chiudi il giorno", + "dayHint": "Giorno precedente e successivo funzionano mentre un giorno è aperto.", "groups": { "navigation": "Navigazione", "actions": "Azioni", diff --git a/messages/ko.json b/messages/ko.json index 649bb7763..ec79c416d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12559,8 +12559,6 @@ } }, "palette": { - "open": "검색…", - "openLabel": "페이지, 설정, 작업 검색", "title": "검색", "description": "페이지, 설정, 모듈 또는 작업을 찾습니다.", "placeholder": "페이지, 설정, 모듈 검색…", @@ -12597,7 +12595,8 @@ "close": "대화 상자나 패널 닫기", "previousDay": "이전 날", "nextDay": "다음 날", - "dayHint": "하루가 열려 있을 때.", + "toggleDay": "하루 열기 또는 닫기", + "dayHint": "이전 날과 다음 날은 하루가 열려 있을 때 작동합니다.", "groups": { "navigation": "탐색", "actions": "작업", diff --git a/messages/pl.json b/messages/pl.json index 5ae6159a1..19bdad46a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12559,8 +12559,6 @@ } }, "palette": { - "open": "Szukaj…", - "openLabel": "Szukaj stron, ustawień i akcji", "title": "Szukaj", "description": "Znajdź stronę, ustawienie, moduł lub akcję.", "placeholder": "Szukaj stron, ustawień, modułów…", @@ -12597,7 +12595,8 @@ "close": "Zamknij okno lub panel", "previousDay": "Poprzedni dzień", "nextDay": "Następny dzień", - "dayHint": "Gdy otwarty jest dzień.", + "toggleDay": "Otwórz lub zamknij dzień", + "dayHint": "Poprzedni i następny dzień działają, gdy dzień jest otwarty.", "groups": { "navigation": "Nawigacja", "actions": "Akcje", diff --git a/src/components/command-palette/command-palette-trigger.tsx b/src/components/command-palette/command-palette-trigger.tsx index dc86cce0e..c0599da46 100644 --- a/src/components/command-palette/command-palette-trigger.tsx +++ b/src/components/command-palette/command-palette-trigger.tsx @@ -17,47 +17,23 @@ import { openCommandPalette, preloadCommandPalette } from "./palette-store"; /** * The way into the command palette, at the trailing end of the top bar on - * every signed-in page. Where the bar has room it looks like a search field - * ("Search…" and the key that opens it); where it has not (a phone, or a - * desktop bar narrowed by the sidebar and a docked panel) it is a magnifier - * with a tooltip. The switch reads the bar's own width (`@container/topbar` - * on the header), not the window's, because a docked panel takes the room - * without the window changing. + * every signed-in page: a magnifier at every width, named "Search", with a + * tooltip that gives the key that opens it as well ("Search (⌘K)", or + * "Search (Ctrl K)" off Apple platforms). 44 px under a coarse pointer, 36 px + * beside a fine one. Docked panels' strips stand right of the top bar, so the + * magnifier always sits just left of them and nothing covers it. */ export function CommandPaletteTrigger() { const { t } = useTranslations(); // The key hint depends on the platform, which the server cannot know. const mounted = useMounted(); const apple = mounted && isApplePlatform(); - const label = t("palette.openLabel"); + const label = t("palette.title"); const keyHint = apple ? "⌘K" : "Ctrl K"; const shortcut = apple ? "Meta+K" : "Control+K"; return (
- @@ -66,19 +42,20 @@ export function CommandPaletteTrigger() { variant="ghost" size="icon" data-slot="command-palette-trigger" - data-variant="icon" aria-label={label} aria-keyshortcuts={mounted ? shortcut : undefined} aria-haspopup="dialog" onClick={openCommandPalette} onPointerEnter={preloadCommandPalette} onFocus={preloadCommandPalette} - className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 @2xl/topbar:hidden" + className="text-muted-foreground hover:text-foreground size-11 pointer-fine:size-9" > - {label} + + {mounted ? `${label} (${keyHint})` : label} +
diff --git a/src/components/day/__tests__/day-view.test.tsx b/src/components/day/__tests__/day-view.test.tsx index 442b35fbd..e4b9685aa 100644 --- a/src/components/day/__tests__/day-view.test.tsx +++ b/src/components/day/__tests__/day-view.test.tsx @@ -243,16 +243,19 @@ describe("", () => { expect(leave).not.toContain("FAMILY"); }); - it("collapses the docked day and closes a sheet, and says which", async () => { + it("has no close button docked, where the strip folds it, and a clear one in a sheet", async () => { const docked = await render(); - const hide = docked.match(/]*data-slot="day-close"[^>]*>/)?.[0]; - expect(hide).toContain('aria-label="Hide day"'); - expect(hide).toContain('aria-expanded="true"'); + expect(docked).not.toContain('data-slot="day-close"'); for (const shell of ["sheet", "bottom"] as const) { const sheet = await render({ shell }); const close = sheet.match(/]*data-slot="day-close"[^>]*>/)?.[0]; expect(close).toContain('aria-label="Close day"'); expect(close).not.toContain("aria-expanded"); + // An X, at the end of the header row, after the next-day arrow. + expect(sheet.indexOf('data-slot="day-close"')).toBeGreaterThan( + sheet.indexOf('data-slot="day-next"'), + ); + expect(sheet).toContain("lucide-x"); } }); diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts index 50041a777..1431ce594 100644 --- a/src/components/day/day-layer-controller.ts +++ b/src/components/day/day-layer-controller.ts @@ -185,6 +185,27 @@ export function jumpDay(next: DateKey): void { * Coach's conversations below 1600 px): focus then stays with the control * that asked for the room instead of moving to the day's edge. */ +let dayToggle: (() => void) | null = null; + +/** + * The mounted layer's open-or-close, the same as a click on the day's strip + * (a sheet with the strip's day below the docking width). The layer + * registers it; `g p` calls it through `toggleDay`. + */ +export function registerDayToggle(toggle: () => void): () => void { + dayToggle = toggle; + return () => { + if (dayToggle === toggle) dayToggle = null; + }; +} + +/** Open or close the day. False when no day layer is mounted. */ +export function toggleDay(): boolean { + if (dayToggle === null) return false; + dayToggle(); + return true; +} + let yielding = false; /** Collapse the docked day to its edge for a neighbouring panel. */ diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index b0dcf3899..d0cfaec05 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -50,6 +50,7 @@ import { openDay, peekDayTriggerAt, publishOpenDay, + registerDayToggle, stepDay, takeDayTrigger, takeDayYield, @@ -255,8 +256,8 @@ function DayLayer() { }, []); const panelRef = useRef(null); const stripRef = useRef(null); - // Set by a fold (the header's control, the strip): focus then lands on the - // strip, which brings the day back from the same place. + // Set by a fold (the strip, its shortcut): focus then lands on the strip, + // which brings the day back from the same place. const collapsing = useRef(false); // The remembered day, while the docked day is shut, if it is still a day @@ -353,6 +354,33 @@ function DayLayer() { [date, today], ); + // What the strip holds: the open day, else the remembered one, else today. + // Below the docking width the same day is the one `g p` opens. + const stripDay = stripDayOf(date, lastDay, today); + const dockedOpen = dockedDay !== null; + // The strip's click, and `g p` at every width: docked, the column opens + // or folds; below, the sheet opens on the strip's day or closes. + const toggle = useCallback(() => { + if (shell === "docked") { + if (dockedOpen) { + collapse(); + return; + } + openDay(stripDay, { trigger: stripRef.current ?? undefined }); + return; + } + if (date !== null) { + closeByPerson(); + return; + } + openDay(stripDay); + }, [shell, dockedOpen, collapse, stripDay, date, closeByPerson]); + const latestToggle = useRef(toggle); + useEffect(() => { + latestToggle.current = toggle; + }); + useEffect(() => registerDayToggle(() => latestToggle.current()), []); + const onPick = useCallback( (next: DateKey) => { if (next > today) return; @@ -418,7 +446,8 @@ function DayLayer() { today={today} focus={focus} shell={shell} - onClose={shell === "docked" ? collapse : closeByPerson} + // Only the sheets show a close button; docked, the strip folds the day. + onClose={closeByPerson} onStep={onStep} onPick={onPick} Title={Title} @@ -429,16 +458,7 @@ function DayLayer() { ); if (shell === "docked") { - // What the strip holds: the open day, else the remembered one, else today. - const stripDay = stripDayOf(date, railDay, today); - const open = dockedDay !== null; - const toggle = () => { - if (open) { - collapse(); - return; - } - openDay(stripDay, { trigger: stripRef.current ?? undefined }); - }; + const open = dockedOpen; return ( <> {live} diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index 6e290bb49..cedddfad3 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -8,9 +8,8 @@ import { ChevronRight, History, MessageCircle, - PanelBottomClose, - PanelRightClose, Plus, + X, } from "lucide-react"; import { @@ -84,15 +83,12 @@ const ICON_BUTTON = "text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9"; /** - * The collapse control is the Coach panel's own toggle, in the same place: - * the panel's top-left corner, the panel icon mirrored so it points the way - * the panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as - * a copy so the day layer does not pull the Coach's panel into the shell). - * The collapsed edge's control in `day-layer.tsx` takes the same pair. + * The sheets' close control, at the trailing end of the header like the + * Coach sheet's own. The docked day has none: its strip at the right edge of + * the window (`shell-dock.tsx`) is the one control that opens and closes it. */ -export const PANEL_TOGGLE = +const CLOSE_BUTTON = "text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7"; -export const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4"; export function DayView({ date, @@ -157,7 +153,7 @@ export function DayView({ ? Object.values(data.sections).some((s) => s?.reason === "not_shared") : false; const compact = shell === "bottom"; - const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close"); + const docked = shell === "docked"; return (
- {/* Top left, where the Coach panel keeps its toggle: the way out. - Docked, it collapses the day to the edge; a sheet closes. */} - {/* One line, centred in the band: the date and nothing under it. */} {/* The date is the way to any other day: a calendar opens from it, so the header needs no icon of its own. */} @@ -262,6 +236,20 @@ export function DayView({ aria-hidden="true" /> </Button> + {docked ? null : ( + <Button + type="button" + variant="ghost" + size="icon" + data-slot="day-close" + aria-label={t("day.close")} + title={t("day.close")} + onClick={onClose} + className={CLOSE_BUTTON} + > + <X className="size-5 pointer-fine:size-4" aria-hidden="true" /> + </Button> + )} </div> <div diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index 6c3c66c04..a3700a4d5 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -58,8 +58,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach"; * the two bottom borders draw one line. Its strip stays at the right edge of * the window, left of the day's (`shell-dock.tsx`), open or not: a click * on the strip opens the panel left of the strips and a second click shuts - * it; the header's first control shuts it too. The panel slides open and - * shut (a 200 ms width transition, none under reduced motion). + * it. The strip is the docked panel's only fold control; the header carries + * none. The panel slides open and shut (a 200 ms width transition, none + * under reduced motion). * The header carries the title, a link to Plans, the settings gear and, in * the sheet, a close button. New conversation is not here: it floats in the * conversation itself (`NewChatFab`). @@ -75,7 +76,7 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach"; * Keyboard: Escape folds the docked panel when focus is inside it and * nothing inside (a row menu, the rename field, the settings popover) has * already claimed the key; focus moves to the strip, and opening from the - * strip lands on the fold control. In the sheet Radix owns Escape, the + * strip lands on the panel's heading. In the sheet Radix owns Escape, the * focus trap and the return. */ export const COACH_PANEL_ID = "coach-conversations-panel"; @@ -286,17 +287,17 @@ export function ConversationsPanel({ } const expanded = docked ? dockedOpen : sheetOpen; - const collapseRef = useRef<HTMLButtonElement>(null); + const headingRef = useRef<HTMLHeadingElement>(null); const expandRef = useRef<HTMLButtonElement>(null); // The list keeps painting while it slides shut. const shownOpen = useLingering(docked && dockedOpen ? true : null) ?? false; // Where focus goes once the docked panel has folded or opened. - const focusAfter = useRef<"collapse" | "expand" | null>(null); + const focusAfter = useRef<"panel" | "strip" | null>(null); useEffect(() => { const target = focusAfter.current; focusAfter.current = null; - if (target === "collapse") collapseRef.current?.focus(); - if (target === "expand") expandRef.current?.focus(); + if (target === "panel") headingRef.current?.focus({ preventScroll: true }); + if (target === "strip") expandRef.current?.focus(); }, [dockedOpen]); function toggleSheet() { @@ -304,7 +305,7 @@ export function ConversationsPanel({ } function expandDocked() { - focusAfter.current = "collapse"; + focusAfter.current = "panel"; setYielded(false); setRevealed(false); setOpen(true); @@ -313,7 +314,7 @@ export function ConversationsPanel({ } function collapseDocked() { - focusAfter.current = "expand"; + focusAfter.current = "strip"; setYielded(false); setRevealed(false); setOpen(false); @@ -344,37 +345,19 @@ export function ConversationsPanel({ : cn("border-sidebar-border px-3", SHELL_HEADER_BAND), )} > - {inSheet ? null : ( - // A title, not a tooltip: focus lands here when the panel opens - // from its edge, and a tooltip opened by that focus would take the - // next Escape for itself. - <Button - ref={collapseRef} - type="button" - variant="ghost" - size="icon" - onClick={collapseDocked} - data-slot="coach-panel-collapse" - aria-controls={COACH_PANEL_ID} - aria-expanded={true} - aria-label={t("insights.coach.frame.hidePanel")} - title={t("insights.coach.frame.hidePanel")} - className={PANEL_HEADER_BUTTON} - > - {/* The day's own fold control and glyph, mirrored so it points - the way the panel goes. */} - <PanelRightClose - className={cn(PANEL_HEADER_ICON, "-scale-x-100")} - aria-hidden="true" - /> - </Button> - )} {inSheet ? ( <SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold"> {t("insights.coach.historyTitle")} </SheetTitle> ) : ( - <h2 className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold"> + // Docked, the strip is the panel's only fold control; focus lands on + // the heading when the panel opens from it. + <h2 + ref={headingRef} + tabIndex={-1} + data-slot="coach-panel-heading" + className="min-w-0 flex-1 truncate pl-1 text-lg leading-tight font-semibold focus-visible:outline-none" + > {t("insights.coach.historyTitle")} </h2> )} diff --git a/src/components/layout/__tests__/shell-dock.test.tsx b/src/components/layout/__tests__/shell-dock.test.tsx index 0cd86a48e..3e00646ee 100644 --- a/src/components/layout/__tests__/shell-dock.test.tsx +++ b/src/components/layout/__tests__/shell-dock.test.tsx @@ -15,7 +15,7 @@ import { DOCK_SLIDE, DockStrip } from "../shell-dock"; * The docked panels' strips (v1.42). Each docked panel keeps its own strip * at the right edge; the strip is a toggle for its panel, never moves, and * below 1600 px only one panel is open. The click paths, focus and the - * geometry run in `e2e/dock-strips.spec.ts`. + * geometry run in `e2e/day-view.spec.ts` and `e2e/coach-layout.spec.ts`. */ function strip(expanded: boolean) { return renderToStaticMarkup( @@ -56,14 +56,14 @@ describe("DockStrip", () => { expect(html).toContain('aria-label="Hide day"'); }); - it("keeps the same shape open and closed, below the top bar's band", () => { + it("keeps the same shape open and closed, the full height of the window", () => { const shape = (html: string) => html.match(/^<div[^>]*class="([^"]*)"/)![1]; expect(shape(strip(true))).toBe(shape(strip(false))); expect(shape(strip(false))).toContain("w-10"); - // The first cell is the band (`SHELL_HEADER_BAND`), empty and hidden. - expect(strip(false)).toMatch( - /^<div[^>]*><div aria-hidden="true" class="h-16 border-b/, - ); + expect(shape(strip(false))).toContain("h-full"); + // The button is the strip's first and only cell: no band above it. + expect(strip(false)).toMatch(/^<div[^>]*><button/); + expect(strip(false)).not.toContain("h-16"); }); it("orders the strips like their panels: conversations, then the day", () => { diff --git a/src/components/layout/global-shortcuts.tsx b/src/components/layout/global-shortcuts.tsx index 4a8cd5130..71eb14229 100644 --- a/src/components/layout/global-shortcuts.tsx +++ b/src/components/layout/global-shortcuts.tsx @@ -9,7 +9,11 @@ import { useSyncExternalStore, } from "react"; -import { stepDay, useOpenDay } from "@/components/day/day-layer-controller"; +import { + stepDay, + toggleDay, + useOpenDay, +} from "@/components/day/day-layer-controller"; import { useTodayKey } from "@/components/day/use-today-key"; import { CAPTURE_KIND_ORDER, @@ -38,8 +42,8 @@ import { * (never on the sign-in pages or in the setup flow, which the shell renders * without it). * - * `g` + a key opens a page, `n` the add menu, `[` / `]` step the open day, - * `?` the list of all of them. Which key does what, and when a key is not + * `g` + a key opens a page, `g p` opens or closes the day, `n` the add menu, + * `[` / `]` step the open day, `?` the list of all of them. Which key does what, and when a key is not * ours, is decided in `src/lib/keyboard/global-shortcuts.ts`; this component * reads the page's state and carries the action out. A destination comes from * the same lists the navigation renders, so `g t` with the timeline module @@ -196,6 +200,9 @@ export function GlobalShortcuts() { stepDay(date, action.delta); return; } + case "day-toggle": + if (toggleDay()) event.preventDefault(); + return; } }; document.addEventListener("keydown", onKeyDown); diff --git a/src/components/layout/keyboard-shortcuts-dialog.tsx b/src/components/layout/keyboard-shortcuts-dialog.tsx index c8e307db4..628447df6 100644 --- a/src/components/layout/keyboard-shortcuts-dialog.tsx +++ b/src/components/layout/keyboard-shortcuts-dialog.tsx @@ -92,6 +92,7 @@ export function KeyboardShortcutsDialog({ ]; const day: Row[] = [ + { id: "day-toggle", label: t("shortcuts.toggleDay"), sequence: ["g", "p"] }, { id: "day-previous", label: t("shortcuts.previousDay"), sequence: ["["] }, { id: "day-next", label: t("shortcuts.nextDay"), sequence: ["]"] }, ]; diff --git a/src/components/layout/shell-dock.tsx b/src/components/layout/shell-dock.tsx index bce15d195..efd690eec 100644 --- a/src/components/layout/shell-dock.tsx +++ b/src/components/layout/shell-dock.tsx @@ -5,7 +5,6 @@ import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { SHELL_HEADER_BAND } from "./shell-metrics"; import { createShellSlot } from "./shell-slot"; /** @@ -17,9 +16,12 @@ import { createShellSlot } from "./shell-slot"; * panels: conversations (`order-1`), then the day (`order-2`). A panel opens * to the left of the strips; the strips never move or change shape. * - * Each strip starts below the top bar's band. The band above them is empty - * and draws the top bar's bottom line on through, so nothing in the strips - * meets the command palette at the end of the top bar. + * Each strip runs the full height of the shell row, from the top of the + * window to the bottom. The strip column sits beside the content column, so + * the top bar (and the command palette's magnifier at its trailing end) ends + * at the strips' left edge and nothing in a strip can cover it. Docked, the + * strip is the panel's only open and close control: the panel's header + * carries no fold button of its own. */ const strips = createShellSlot("shell-strips"); @@ -89,9 +91,9 @@ export interface DockStripProps { } /** - * One strip: an empty cell of the top bar's band, then a button the rest of - * the window's height that opens and closes its panel. The label reads - * top to bottom; the strip is marked while its panel is open. + * One strip: a button the full height of the window that opens and closes + * its panel. The label reads top to bottom; the strip is marked while its + * panel is open. */ export function DockStrip({ ref, @@ -116,10 +118,6 @@ export function DockStrip({ order === 1 ? "order-1" : "order-2", )} > - <div - aria-hidden="true" - className={cn(SHELL_HEADER_BAND, "border-sidebar-border shrink-0")} - /> <button ref={ref} type="button" diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 691ea9bd8..bdaf794b0 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -90,9 +90,7 @@ export function TopBar() { // sidebar logo band reads too, so the two borders draw one // continuous line. Never restate a height here. className={cn( - // `@container/topbar`: the search control sizes to the bar's own - // width, which a docked side panel narrows without the window moving. - "bg-card/80 border-border @container/topbar sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6", + "bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6", SHELL_HEADER_BAND, )} // The status-bar inset of the installed app is the shell's @@ -114,8 +112,8 @@ export function TopBar() { <TopBarContextOutlet className="shell-desktop:flex hidden min-w-0 flex-1 items-center" /> {/* v1.42 — the command palette's door, on every signed-in page: a - search-field button where the bar has room, a magnifier where it - has not (and on a phone). `ml-auto` keeps it at the trailing end. */} + magnifier at every width. `ml-auto` keeps it at the trailing end, + just left of any docked panel's strip. */} <CommandPaletteTrigger /> {/* Page-owned actions (the Coach's conversations toggle). `ml-auto` diff --git a/src/lib/keyboard/__tests__/global-shortcuts.test.ts b/src/lib/keyboard/__tests__/global-shortcuts.test.ts index 9b4fe123d..7ac31df90 100644 --- a/src/lib/keyboard/__tests__/global-shortcuts.test.ts +++ b/src/lib/keyboard/__tests__/global-shortcuts.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { visibleNavDestinations } from "@/components/layout/nav-model"; import { + DAY_TOGGLE_KEY, + GO_TO_SHORTCUTS, SEQUENCE_TIMEOUT_MS, blockedByModifier, createShortcutReader, @@ -181,6 +183,40 @@ describe("sequence timing", () => { }); }); +describe("g p, the day", () => { + it("opens or closes the day from the page", () => { + const reader = createShortcutReader(); + expect(reader.read(key("g"), "page", 0)).toBe("pending"); + expect(reader.read(key("p"), "page", 10)).toEqual({ type: "day-toggle" }); + }); + + it("collides with no destination", () => { + expect(DAY_TOGGLE_KEY).toBe("p"); + const keys = GO_TO_SHORTCUTS.map((s) => s.key as string); + expect(keys).not.toContain(DAY_TOGGLE_KEY); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("needs the g, within the window", () => { + const reader = createShortcutReader(); + expect(reader.read(key("p"), "page", 0)).toBeNull(); + reader.read(key("g"), "page", 0); + expect(reader.read(key("p"), "page", SEQUENCE_TIMEOUT_MS + 1)).toBeNull(); + }); + + it("is not read from a field, under a modifier or over another dialog", () => { + const reader = createShortcutReader(); + reader.read(key("g"), "page", 0); + expect(reader.read(key("p", { target: input }), "page", 10)).toBeNull(); + reader.read(key("g"), "page", 20); + expect(reader.read(key("p", { metaKey: true }), "page", 30)).toBeNull(); + reader.read(key("g"), "page", 40); + expect(reader.read(key("p"), "off", 50)).toBeNull(); + expect(reader.read(key("g"), "off", 60)).toBeNull(); + expect(reader.read(key("p"), "page", 70)).toBeNull(); + }); +}); + describe("scope", () => { it("an open dialog, menu or popover takes every key", () => { const reader = createShortcutReader(); @@ -191,7 +227,7 @@ describe("scope", () => { expect(reader.read(key("d"), "off", 10)).toBeNull(); }); - it("over the day sheet only [ and ] act", () => { + it("over the day sheet only [, ] and g p act", () => { const reader = createShortcutReader(); expect(reader.read(key("["), "day-sheet", 0)).toEqual({ type: "day", @@ -202,7 +238,14 @@ describe("scope", () => { delta: 1, }); expect(reader.read(key("n"), "day-sheet", 0)).toBeNull(); - expect(reader.read(key("g"), "day-sheet", 0)).toBeNull(); + expect(reader.read(key("?"), "day-sheet", 0)).toBeNull(); + // g waits, but only p completes it there: no page leaves under a sheet. + expect(reader.read(key("g"), "day-sheet", 0)).toBe("pending"); + expect(reader.read(key("d"), "day-sheet", 10)).toBeNull(); + reader.read(key("g"), "day-sheet", 20); + expect(reader.read(key("p"), "day-sheet", 30)).toEqual({ + type: "day-toggle", + }); }); it("Escape is never ours", () => { diff --git a/src/lib/keyboard/global-shortcuts.ts b/src/lib/keyboard/global-shortcuts.ts index d4062db1f..8573eb274 100644 --- a/src/lib/keyboard/global-shortcuts.ts +++ b/src/lib/keyboard/global-shortcuts.ts @@ -15,7 +15,8 @@ * Option+5 on a German Mac, AltGr+8 on a German PC) still counts, because * for that person there is no other way to press it; * - while a dialog, sheet, menu or popover is open the keys are its own, - * except over the day sheet, where `[` and `]` step the day it shows. + * except over the day sheet, where `[` and `]` step the day it shows and + * `g p` closes it. * * `g` starts a two-key sequence; the second key has to follow within * `SEQUENCE_TIMEOUT_MS`. Escape is never handled here: every surface closes @@ -43,17 +44,25 @@ export const GO_TO_SHORTCUTS = [ export type GoToKey = (typeof GO_TO_SHORTCUTS)[number]["key"]; +/** + * `g` + `p` opens or closes the day, the way a click on the day's strip does + * (a sheet below the docking width). Not a destination, and no `g` key above. + */ +export const DAY_TOGGLE_KEY = "p"; + export type ShortcutAction = | { type: "go"; key: GoToKey } | { type: "palette" } | { type: "capture" } | { type: "help" } - | { type: "day"; delta: -1 | 1 }; + | { type: "day"; delta: -1 | 1 } + | { type: "day-toggle" }; /** * Where the keyboard is: * - `page`: nothing modal is open, every shortcut applies; - * - `day-sheet`: the day sheet is the only modal surface, `[` / `]` apply; + * - `day-sheet`: the day sheet is the only modal surface, `[` / `]` and + * `g p` apply; * - `off`: another dialog, sheet, menu or popover has the keys. */ export type ShortcutScope = "page" | "day-sheet" | "off"; @@ -205,12 +214,23 @@ export function createShortcutReader(timeoutMs = SEQUENCE_TIMEOUT_MS) { if (key === "[" || key === "]") { return { type: "day", delta: key === "[" ? -1 : 1 }; } - if (scope !== "page") return null; - if (pending !== null && now - pending <= timeoutMs) { - // The second key of a sequence: a destination, or nothing at all, so - // a mistyped `g n` does not open the add menu. + const inSequence = pending !== null && now - pending <= timeoutMs; + if (scope !== "page") { + // Over the day sheet only `g p` (which closes it) is a sequence. + if (inSequence && key === DAY_TOGGLE_KEY) return { type: "day-toggle" }; + if (key === "g") { + pendingSince = now; + return "pending"; + } + return null; + } + + if (inSequence) { + // The second key of a sequence: a destination, the day, or nothing at + // all, so a mistyped `g n` does not open the add menu. if (isGoToKey(key)) return { type: "go", key }; + if (key === DAY_TOGGLE_KEY) return { type: "day-toggle" }; if (key !== "g") return null; } if (key === "g") {