Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/acceptance/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ Compass is the keyboard calendar: pointer clicks, right-clicks, and double-click

- Clicking an event does not open it, but jump chips appear, the event is selected, and the hint identifies that event's exact token plus `Enter`; the shown sequence opens the event without an initial `H`.
- Clicking either sidebar control does not toggle the sidebar; the hint says to press `]` and uses open/close language matching the current state.
- Clicking an empty timed-grid slot does not open a draft; the hint shows the matching HHMM digits (`1200`, `1830`) and typing those digits creates an event at that time.
- Clicking the all-day row teaches `Shift+C`.
- Unannotated controls retain the generic keyboard-only fallback while contextual coverage is expanded.
- Right-click does not open the context menu; `M` (or Shift+F10) on a focused event does.
- Keyboard shortcuts and Enter/Space activation of buttons continue to work.
Expand Down
7 changes: 5 additions & 2 deletions docs/frontend/contextual-pointer-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ of a generic legend. Acceptance coverage lives in
`composedPath()` and stores that attempt on the pointer-block store.
3. `PointerHint` renders copy for the attempt. Sidebar open/close teach `]`.
Event open teaches the current jump token plus Enter, or `S` if no token is
available.
available. An empty timed-grid click teaches the matching HHMM digits
(`1200`, `1830`) so the same create can be typed. An empty all-day-row
click teaches `Shift+C`.
4. A primary event click also dispatches `compass:pointer-event-jump` so the
mounted grid's event-jump owner can assign tokens and activate leaderless
sequences. Right-clicks stay on the generic fallback so `M` still opens
the context menu.
the context menu. An empty-grid click parks that HHMM as a short-lived
teaching target so typing the shown digits creates at the clicked instant.

Unannotated controls keep the generic keyboard-only fallback.

Expand Down
4 changes: 3 additions & 1 deletion docs/frontend/frontend-runtime-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ Pointer suppression (always on, mounted from `RootShell`):
and hover remain
- keyboard-activation clicks (Enter/Space on a native button), keyboard
contextmenu (Shift+F10), and synthetic `.click()` calls pass through
- blocked clicks pulse `PointerHint`, a transient "keyboard only" pill
- blocked clicks pulse `PointerHint`, a transient pill: known targets get
the matching shortcut (including HHMM digits for an empty timed-grid
click), and unannotated controls fall back to "keyboard only"

See [Shortcuts](../acceptance/shortcuts.md) for acceptance coverage and
[Feature File Map](../development/feature-file-map.md#keyboard-shortcuts) for
Expand Down
28 changes: 28 additions & 0 deletions e2e/booking/booking-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ export function buildBookableSlot(durationMinutes = 30): {
return { slotStart: start.toISOString(), slotEnd: end.toISOString() };
}

/** Distinct sibling on the same UTC date. Steps backward when +gap would cross midnight. */
export function buildSameDaySiblingSlot(
slot: { slotStart: string; slotEnd: string },
gapMinutes = 30,
durationMinutes = 30,
): { slotStart: string; slotEnd: string } {
const currentMs = Date.parse(slot.slotStart);
const day = slot.slotStart.slice(0, 10);
const dayStart = Date.parse(`${day}T00:00:00.000Z`);
const lastStart = Date.parse(`${day}T23:59:00.000Z`);
let siblingMs = currentMs + gapMinutes * 60 * 1000;
if (siblingMs > lastStart || siblingMs === currentMs) {
siblingMs = currentMs - gapMinutes * 60 * 1000;
}
if (siblingMs < dayStart) {
siblingMs = dayStart;
}
const siblingStart = new Date(siblingMs);
siblingStart.setUTCSeconds(0, 0);
siblingStart.setUTCMilliseconds(0);
return {
slotStart: siblingStart.toISOString(),
slotEnd: new Date(
siblingStart.getTime() + durationMinutes * 60 * 1000,
).toISOString(),
};
}

export interface PublicBookingStubOptions {
slug?: string;
hostDisplayName?: string;
Expand Down
19 changes: 3 additions & 16 deletions e2e/booking/public-booking.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import {
buildBookableSlot,
buildSameDaySiblingSlot,
formatSlotButtonLabel,
preparePublicBookingConfirmedPage,
preparePublicBookingPage,
Expand Down Expand Up @@ -302,13 +303,7 @@ test.describe("public booking page", () => {
page,
}) => {
const first = buildBookableSlot();
const secondStart = new Date(
Date.parse(first.slotStart) + 30 * 60 * 1000,
).toISOString();
const second = {
slotStart: secondStart,
slotEnd: new Date(Date.parse(secondStart) + 30 * 60 * 1000).toISOString(),
};
const second = buildSameDaySiblingSlot(first);
const captured = await preparePublicBookingPage(page, {
confirmStatus: 409,
slots: [first, second],
Expand Down Expand Up @@ -418,15 +413,7 @@ test.describe("public booking page", () => {
page,
}) => {
const today = buildBookableSlot();
const laterTodayStart = new Date(
Date.parse(today.slotStart) + 30 * 60 * 1000,
).toISOString();
const laterToday = {
slotStart: laterTodayStart,
slotEnd: new Date(
Date.parse(laterTodayStart) + 30 * 60 * 1000,
).toISOString(),
};
const laterToday = buildSameDaySiblingSlot(today);
const otherDayStart = new Date(
Date.parse(today.slotStart) + 25 * 60 * 60 * 1000,
).toISOString();
Expand Down
14 changes: 14 additions & 0 deletions e2e/timed/mouse-inert.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ import {
// Compass is the keyboard calendar: the mouse is permanently inert. These
// tests are the behavioral contract for the always-on pointer suppression.

test("empty timed-grid clicks teach the matching HHMM shortcut", async ({
page,
}) => {
await prepareCalendarPage(page);

const { x, y } = await getMainGridPoint(page, { xRatio: 0.6, yRatio: 0.6 });
await page.mouse.click(x, y);

await expect(page.getByLabel("Title")).toHaveCount(0);
await expect(page.locator("[data-pointer-hint]")).toContainText(
/Type \d{4} to create an event at/,
);
});

test("mouse clicks are inert and show the keyboard-only hint", async ({
page,
}) => {
Expand Down
56 changes: 48 additions & 8 deletions packages/web/src/booking/PublicBookingPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ function slotAround(start: Date) {
};
}

/** Distinct sibling on the same UTC date, stepping backward when +gap would leave the day. */
function slotOnSameUtcDay(
current: { slotStart: string; slotEnd: string },
gapMinutes: number,
) {
const currentMs = Date.parse(current.slotStart);
const day = current.slotStart.slice(0, 10);
const dayStart = Date.parse(`${day}T00:00:00.000Z`);
const lastStart = Date.parse(`${day}T23:59:00.000Z`);
const after = currentMs + gapMinutes * 60 * 1000;
if (after <= lastStart && after !== currentMs) {
return slotAround(new Date(after));
}
const before = currentMs - gapMinutes * 60 * 1000;
if (before >= dayStart && before !== currentMs) {
return slotAround(new Date(before));
}
return slotAround(new Date(dayStart));
}

function utcTodayAt(now: Date, hours: number, minutes: number) {
return new Date(
Date.UTC(
Expand Down Expand Up @@ -153,16 +173,13 @@ async function selectGuestTimeZone(
const currentSlot = bookableSlotInCurrentMonth();
const laterCurrentSlot = (() => {
const later = bookableSlotInCurrentMonth(30);
if (later.slotStart.slice(0, 10) === currentSlot.slotStart.slice(0, 10)) {
if (
later.slotStart !== currentSlot.slotStart &&
later.slotStart.slice(0, 10) === currentSlot.slotStart.slice(0, 10)
) {
return later;
}
const currentMs = Date.parse(currentSlot.slotStart);
const endOfUtcDay = Date.parse(
`${currentSlot.slotStart.slice(0, 10)}T23:59:00.000Z`,
);
return slotAround(
new Date(Math.min(currentMs + 15 * 60 * 1000, endOfUtcDay)),
);
return slotOnSameUtcDay(currentSlot, 15);
})();
const nextMonthSlot = bookableSlotInNextMonth();
const guestTimeZone = "UTC";
Expand Down Expand Up @@ -227,7 +244,30 @@ function reservationGetHandler(
);
}

describe("slotOnSameUtcDay", () => {
it("steps forward when the gap still fits on the UTC day", () => {
const current = slotAround(new Date("2026-08-31T20:00:00.000Z"));
expect(slotOnSameUtcDay(current, 30).slotStart).toBe(
"2026-08-31T20:30:00.000Z",
);
});

it("steps backward instead of collapsing onto 23:59 at month-end midnight", () => {
const current = slotAround(new Date("2026-08-31T23:59:00.000Z"));
const sibling = slotOnSameUtcDay(current, 15);
expect(sibling.slotStart).toBe("2026-08-31T23:44:00.000Z");
expect(sibling.slotStart).not.toBe(current.slotStart);
});
});

describe("PublicBookingPage", () => {
it("uses two distinct same-day slots in the current UTC month", () => {
expect(laterCurrentSlot.slotStart).not.toBe(currentSlot.slotStart);
expect(laterCurrentSlot.slotStart.slice(0, 10)).toBe(
currentSlot.slotStart.slice(0, 10),
);
});

it("shows a generic not-found state for an unknown slug", async () => {
renderBookingRoute("/book/unknown-host");

Expand Down
37 changes: 36 additions & 1 deletion packages/web/src/components/PointerHint/PointerHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,42 @@ describe("PointerHint", () => {
});

expect(screen.getByRole("status")).toHaveTextContent(
"Press 1130 to create at 11:30 AM.",
"Type 1130 to create an event at 11:30 AM.",
);
});

it("names an evening quarter-hour in 24-hour digits", () => {
render(<PointerHint />);

act(() => {
pointerBlockActions.pulseBlockedClick({
actionId: "grid.timed",
gridDate: "2026-08-29",
gridTimeKey: "1830",
gridTimeLabel: "6:30 PM",
});
});

expect(screen.getByRole("status")).toHaveTextContent(
"Type 1830 to create an event at 6:30 PM.",
);
});

it("keeps the timed-grid shortcut sentence after the session reminder threshold", () => {
sessionStorage.setItem(HINT_COUNT_KEY, "3");
render(<PointerHint />);

act(() => {
pointerBlockActions.pulseBlockedClick({
actionId: "grid.timed",
gridDate: "2026-08-29",
gridTimeKey: "1200",
gridTimeLabel: "12:00 PM",
});
});

expect(screen.getByRole("status")).toHaveTextContent(
"Type 1200 to create an event at 12:00 PM.",
);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/PointerHint/PointerHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const pointerHintMessage = ({
if (attempt?.actionId === "grid.timed" && attempt.gridTimeKey) {
return (
<>
Press <Key>{attempt.gridTimeKey}</Key> to create at{" "}
Type <Key>{attempt.gridTimeKey}</Key> to create an event at{" "}
{attempt.gridTimeLabel ?? attempt.gridTimeKey}.
</>
);
Expand Down
Loading
Loading