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") {