Merge pull request #1199 from MBombeck/feat/strip-full-height

feat(shell): full-height docked strips, magnifier search, g p for the day
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-10 20:42:22 +02:00
commit 9aadb29876
31 files changed
+633 -242

No files matched your search

+59 -18
View File
@@ -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<CoachMock> {
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");
+23 -16
View File
@@ -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();
+11 -3
View File
@@ -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 () => {
+72 -5
View File
@@ -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,
}) => {
+95 -1
View File
@@ -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);
});
});
+72 -9
View File
@@ -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);
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -12559,8 +12559,6 @@
}
},
"palette": {
"open": "검색…",
"openLabel": "페이지, 설정, 작업 검색",
"title": "검색",
"description": "페이지, 설정, 모듈 또는 작업을 찾습니다.",
"placeholder": "페이지, 설정, 모듈 검색…",
@@ -12597,7 +12595,8 @@
"close": "대화 상자나 패널 닫기",
"previousDay": "이전 날",
"nextDay": "다음 날",
"dayHint": "하루가 열려 있을 때.",
"toggleDay": "하루 열기 또는 닫기",
"dayHint": "이전 날과 다음 날은 하루가 열려 있을 때 작동합니다.",
"groups": {
"navigation": "탐색",
"actions": "작업",
+2 -3
View File
@@ -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",
@@ -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 (
<div className="ml-auto flex shrink-0 items-center">
<button
type="button"
data-slot="command-palette-trigger"
data-variant="field"
aria-label={label}
aria-keyshortcuts={mounted ? shortcut : undefined}
aria-haspopup="dialog"
onClick={openCommandPalette}
onPointerEnter={preloadCommandPalette}
onFocus={preloadCommandPalette}
className="border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 hidden h-9 w-56 items-center gap-2 rounded-md border px-3 text-sm transition-colors outline-none focus-visible:ring-[3px] @2xl/topbar:flex"
>
<Search className="size-4 shrink-0" aria-hidden="true" />
<span className="flex-1 truncate text-left">{t("palette.open")}</span>
{mounted ? (
<kbd
aria-hidden="true"
className="border-border bg-muted text-foreground rounded border px-1.5 font-mono text-xs"
>
{keyHint}
</kbd>
) : null}
</button>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -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"
>
<Search className="size-5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
<TooltipContent side="bottom" data-slot="command-palette-tooltip">
{mounted ? `${label} (${keyHint})` : label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -243,16 +243,19 @@ describe("<DayView>", () => {
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(/<button[^>]*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(/<button[^>]*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");
}
});
@@ -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. */
+33 -13
View File
@@ -50,6 +50,7 @@ import {
openDay,
peekDayTriggerAt,
publishOpenDay,
registerDayToggle,
stepDay,
takeDayTrigger,
takeDayYield,
@@ -255,8 +256,8 @@ function DayLayer() {
}, []);
const panelRef = useRef<HTMLElement>(null);
const stripRef = useRef<HTMLButtonElement>(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}
+25 -37
View File
@@ -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 (
<div
@@ -170,41 +166,19 @@ export function DayView({
data-slot="day-header"
className={cn(
"flex shrink-0 items-center gap-1",
compact ? "pt-0.5 pr-2 pb-2 pl-2" : "border-border border-b px-3",
// The date starts on the body's reading edge (`px-4` / `px-6`).
compact
? "pt-0.5 pr-2 pb-2 pl-4"
: "border-border border-b pr-3 pl-6",
headerClassName,
)}
>
{/* Top left, where the Coach panel keeps its toggle: the way out.
Docked, it collapses the day to the edge; a sheet closes. */}
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={closeLabel}
aria-expanded={shell === "docked" ? true : undefined}
title={closeLabel}
onClick={onClose}
className={PANEL_TOGGLE}
>
{compact ? (
<PanelBottomClose
className={PANEL_TOGGLE_ICON}
aria-hidden="true"
/>
) : (
<PanelRightClose
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
aria-hidden="true"
/>
)}
</Button>
{/* One line, centred in the band: the date and nothing under it. */}
<Title
id={titleId}
ref={titleRef}
tabIndex={-1}
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
className="min-w-0 flex-1 truncate pr-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{/* 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
@@ -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>
)}
@@ -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", () => {
+10 -3
View File
@@ -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);
@@ -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: ["]"] },
];
+9 -11
View File
@@ -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"
+3 -5
View File
@@ -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`
@@ -551,6 +551,43 @@ describe("value line colours", () => {
expect(text(html)).toBe("129/82 mmHg, 82,6 kg");
expect(html).toContain('class="sr-only">, </span>');
});
it("keeps each dot and its value in one unit that never breaks", () => {
const html = render(
<MeanPartsLine
lead="Ø"
parts={[
{ key: "BLOOD_PRESSURE_SYS", text: "129/82 mmHg" },
{ key: "WEIGHT", text: "82,6 kg" },
{ key: "STRAIN", text: "Daily strain with a very long name 12" },
]}
seriesColor={seriesColor}
/>,
);
// Each unit opens with its own tag; everything up to the next unit's tag
// (the lead, the dot, the value) is inside it.
const units = html.split(/(?=<span[^>]*data-slot="timeline-mean-part")/);
expect(units).toHaveLength(3);
for (const unit of units) {
const open = unit.slice(0, unit.indexOf(">"));
expect(open).toContain("inline-flex");
expect(open).toContain("items-center");
expect(open).toContain("whitespace-nowrap");
const dot = unit.match(
/<span[^>]*data-slot="timeline-series-dot"[^>]*>/,
)?.[0];
expect(dot).toContain("shrink-0");
expect(dot).not.toContain("align-middle");
// The value follows the dot inside the same unit.
expect(text(unit.slice(unit.indexOf(dot!)))).toMatch(/\S/);
}
// The lead rides in the first unit, so "Ø" never ends a line alone.
expect(text(units[0]).startsWith("Ø")).toBe(true);
// A long name is cut short inside its unit.
expect(units[2]).toContain(
'<span class="min-w-0 truncate">Daily strain with a very long name 12</span>',
);
});
});
describe("life events in the capture picker", () => {
+4 -4
View File
@@ -39,7 +39,7 @@ import {
} from "./timeline-dates";
import { useItemWords } from "./item-words";
import { itemsInPeriod } from "./timeline-geometry";
import { MeanPartsLine, laneDotStyle } from "./timeline-chart";
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
import {
bucketValues,
labelledMeanParts,
@@ -201,12 +201,12 @@ export function SelectionBar({
})}
{means.values.length > 0 && (
<span
className="text-muted-foreground text-xs tabular-nums"
className={MEAN_PARTS_ROW}
data-slot="timeline-selection-means"
data-bucket={means.start}
>
{meanBefore}
<MeanPartsLine
lead={meanBefore || undefined}
parts={labelledMeanParts(means.values, seriesFormat, {
label: seriesLabel,
bloodPressure: t("timeline.values.bloodPressure"),
@@ -215,7 +215,7 @@ export function SelectionBar({
})}
seriesColor={seriesColor}
/>
{meanAfter}
{meanAfter ? <span>{meanAfter}</span> : null}
</span>
)}
</div>
+17 -3
View File
@@ -1117,13 +1117,22 @@ function SeriesTable({
* ● 82,6 kg". The values stand apart by space, not by a separator glyph;
* a screen reader hears a comma between them. The dot is decoration; the
* text reads the same without it.
*
* Each dot and its value are one unit that never breaks: a line wraps only
* between units (the caller's container is a wrapping flex row), so a dot
* cannot stay behind at the end of a line while its value moves to the
* next. The dot sits on the middle of the text line, and a name too long for
* the row is cut short inside its unit. `lead` (the "Ø" in front of the
* selection bar's means) rides in the first unit for the same reason.
*/
export function MeanPartsLine({
parts,
seriesColor,
lead,
}: {
parts: readonly MeanPart[];
seriesColor: (key: string) => string;
lead?: React.ReactNode;
}) {
return parts.map((part, i) => (
<span
@@ -1133,16 +1142,17 @@ export function MeanPartsLine({
// The readings behind a mean: a hover title, and the same words for a
// screen reader, but not on the line itself.
title={part.detail}
className={i > 0 ? "ml-3" : undefined}
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
>
{i === 0 && lead ? <span className="shrink-0">{lead}</span> : null}
{i > 0 ? <span className="sr-only">, </span> : null}
<span
data-slot="timeline-series-dot"
className="mr-1 inline-block size-2 rounded-full align-middle"
className="size-2 shrink-0 rounded-full"
style={{ background: seriesColor(part.key) }}
aria-hidden="true"
/>
{part.text}
<span className="min-w-0 truncate">{part.text}</span>
{part.detail ? (
<span className="sr-only" data-slot="timeline-mean-detail">
{`, ${part.detail}`}
@@ -1152,6 +1162,10 @@ export function MeanPartsLine({
));
}
/** The row the units of a `MeanPartsLine` wrap in: breaks only between them. */
export const MEAN_PARTS_ROW =
"text-muted-foreground inline-flex max-w-full flex-wrap items-center gap-x-3 gap-y-1 text-xs tabular-nums";
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
return { background: LANE_COLOR[lane] };
@@ -50,7 +50,12 @@ import {
formatMonthYear,
} from "./timeline-dates";
import { LANE_COLOR } from "./timeline-geometry";
import { MeanPartsLine, bucketText, laneDotStyle } from "./timeline-chart";
import {
MEAN_PARTS_ROW,
MeanPartsLine,
bucketText,
laneDotStyle,
} from "./timeline-chart";
import {
chronicleMeans,
meanParts,
@@ -247,7 +252,7 @@ export function TimelineChronicle({
className="bg-muted text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
>
<span
className="size-1.5 rounded-full"
className="size-1.5 shrink-0 rounded-full"
style={laneDotStyle(chip.lane)}
aria-hidden="true"
/>
@@ -295,7 +300,7 @@ export function TimelineChronicle({
</h3>
{bucketMeans && parts.length > 0 && (
<span
className="text-muted-foreground text-xs tabular-nums"
className={MEAN_PARTS_ROW}
data-slot="timeline-chronicle-means"
data-bucket={bucketMeans.start}
>
+6 -3
View File
@@ -603,14 +603,14 @@ export function TimelineLegend({
>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground h-1.5 w-4 rounded-full"
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendSpan")}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground size-2 rounded-full"
className="bg-muted-foreground size-2 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendEvent")}
@@ -622,7 +622,10 @@ export function TimelineLegend({
data-slot="timeline-legend-mean"
data-bucket={bucket}
>
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
<span
className="bg-foreground h-0.5 w-4 shrink-0"
aria-hidden="true"
/>
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
</span>
<span
@@ -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", () => {
+27 -7
View File
@@ -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") {