diff --git a/docs/acceptance/shortcuts.md b/docs/acceptance/shortcuts.md index f3ed8381e..e6c4ba93b 100644 --- a/docs/acceptance/shortcuts.md +++ b/docs/acceptance/shortcuts.md @@ -54,7 +54,8 @@ Helpful notes: | `T` | Day view | Go to today | | `I` | Day view | Focus sidebar | | `U` | Day view | Focus first calendar event | -| `Shift` | Day view | Toggle event jump keys | +| `S` | Day view | Toggle event jump keys | +| `H` | Day view | Toggle Hardcore Mode | | `C` | Day view | Create timed event | | `A` | Day view | Create all-day event | | `Delete` | Day view | Delete focused event | @@ -78,7 +79,8 @@ Helpful notes: | `A` | Week view | Create all-day event | | `I` | Week view | Focus sidebar | | `U` | Week view | Focus first calendar event | -| `Shift` | Week view | Toggle event jump keys | +| `S` | Week view | Toggle event jump keys | +| `H` | Week view | Toggle Hardcore Mode | | `Delete` | Week view | Delete focused event | | `ArrowUp` / `ArrowDown` | Week view | Focus previous/next event | | `Arrow keys` | Week view | Move open draft event | @@ -327,27 +329,29 @@ After deleting or moving an event, pressing Cmd+Z (Mac) or Ctrl+Z (Windows/Linux --- -## Scenario 12: Tap Shift To Jump Focus To An Event By Day Prefix +## Scenario 12: Tap S To Jump Focus To An Event By Day Prefix ### UX -Pressing `Shift` shows event-jump chips immediately; releasing a quick tap keeps the mode on. Week view chips use day prefixes (`SU`/`M`/`T`/`W`/`R`/`F`/`SA`) plus a per-day index (`W4`, `SU1`). Day view uses numeric chips (`1`, `2`, …). Pressing a day letter highlights that column and focuses its first event; a following digit focuses that index. `Esc` or another Shift tap exits. Long holds and quick chords such as Shift+J or Shift+Arrow cancel the press so jump mode does not stay on. A following Shift-Shift still enters keyboard-only without leaving jump chips up. +Pressing `S` shows event-jump chips. Week view chips use day prefixes (`SU`/`M`/`T`/`W`/`R`/`F`/`SA`) plus a per-day index (`W4`, `SU1`). Day view uses numeric chips (`1`, `2`, …). Pressing a day letter highlights that column and focuses its first event; a following digit focuses that index. `Esc` exits (in day view a second `S` also toggles off). Bare Shift and Shift+Tab do not show jump chips. Press `H` to toggle Hardcore Mode independently. ### Steps 1. Navigate to `/week` with timed events on at least two different days. -2. Press `Shift` once (do not hold for a chord); chips should appear on key down. +2. Press `S` once; chips should appear. 3. Press the day letter on a chip (for example `W` for Wednesday), then optionally a digit (`2`) or use arrow keys. -4. Press `Esc` (or tap `Shift` again) to exit. -5. Repeat with a fast Shift+J chord and confirm jump mode does not activate. +4. Press `Esc` to exit. +5. Press Shift alone or Shift+Tab and confirm jump mode does not activate. +6. Press `H` and confirm Hardcore Mode enters. ### Expected Results -- Chips appear on events as soon as Shift is pressed and stay after a quick release until Esc / another Shift tap. +- Chips appear on events when `S` is pressed and stay until Esc. - A day letter highlights that column and focuses the first event; digits refine to `Wn`. - Arrow keys keep jump mode on so letter-then-arrows works. -- Fast Shift+J / Shift+Arrow do not toggle jump mode. -- While a modal holds the app lock, or focus is in an editable field, Shift does not toggle jump mode. +- Shift alone / Shift+Tab / Shift+J do not toggle jump mode. +- While a modal holds the app lock, or focus is in an editable field, `S` does not toggle jump mode. +- `H` toggles Hardcore Mode; Esc exits it. --- @@ -391,4 +395,4 @@ If time is limited, run these checks before shipping shortcut-related changes: 12. With a focused event and no draft open, ArrowUp/ArrowDown move focus to the previous/next event chronologically. 13. Cmd+D / Ctrl+D duplicates a focused event in Day and Week view. 14. With a focused event, `E` then `T` opens the form with the title focused; bare `E` alone does nothing. -15. Pressing Shift shows event jump chips immediately; a day letter + digit focuses that event; fast Shift+J does not leave the mode on. +15. Pressing `S` shows event jump chips; a day letter + digit focuses that event; Shift+Tab does not show chips. `H` toggles Hardcore Mode. diff --git a/e2e/onboarding/interactive-tour.spec.ts b/e2e/onboarding/interactive-tour.spec.ts index ebe75ef15..652a487af 100644 --- a/e2e/onboarding/interactive-tour.spec.ts +++ b/e2e/onboarding/interactive-tour.spec.ts @@ -42,7 +42,7 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => { await expect(card).toContainText("Jump to Dentist"); // Act 2: targetEvent, move, resizeEdge, placeDraft, undo. The exact - // Shift-hold jump key is covered by e2e/timed/shift-hold-event-hints.spec.ts; + // event-jump key is covered by e2e/timed/shift-hold-event-hints.spec.ts; // here we drive the mission's actual completion signal (Dentist focused), // same as a jump would leave it. const dentistButton = page @@ -69,17 +69,11 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => { await expect(card).toContainText("Graduate to Hardcore Mode"); // Act 3: hardcore graduation, the tour's finale. - await page.keyboard.down("Shift"); - await page.keyboard.up("Shift"); - await page.keyboard.down("Shift"); - await page.keyboard.up("Shift"); + await page.keyboard.press("h"); await expect(card).toHaveCount(0); // Leave Hardcore Mode so it doesn't affect other assertions/reload below. - await page.keyboard.down("Shift"); - await page.keyboard.up("Shift"); - await page.keyboard.down("Shift"); - await page.keyboard.up("Shift"); + await page.keyboard.press("h"); await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.locator("[data-onboarding-tour]")).toHaveCount(0); diff --git a/e2e/timed/keyboard-only-mode.spec.ts b/e2e/timed/keyboard-only-mode.spec.ts index 57fd49049..50c3cc2cc 100644 --- a/e2e/timed/keyboard-only-mode.spec.ts +++ b/e2e/timed/keyboard-only-mode.spec.ts @@ -12,11 +12,6 @@ type CalendarPage = Parameters[0]; const keyboardOnlyIndicator = (page: CalendarPage) => page.locator("[data-keyboard-only-indicator]"); -const tapShift = async (page: CalendarPage) => { - await page.keyboard.down("Shift"); - await page.keyboard.up("Shift"); -}; - const createTimedEventOnGrid = async (page: CalendarPage, label: string) => { const title = createEventTitle(label); const { x, y } = await getMainGridPoint(page, { @@ -29,7 +24,7 @@ const createTimedEventOnGrid = async (page: CalendarPage, label: string) => { return page.locator("#mainGrid").getByRole("button", { name: title }); }; -test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", async ({ +test("h enters keyboard-only mode; clicks are inert until Escape", async ({ page, }) => { await prepareCalendarPage(page); @@ -38,8 +33,7 @@ test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", asy "Keyboard Only Target", ); - await tapShift(page); - await tapShift(page); + await page.keyboard.press("h"); await expect(keyboardOnlyIndicator(page)).toContainText("Hardcore Mode"); await expect(keyboardOnlyIndicator(page)).toContainText("Esc"); @@ -58,28 +52,24 @@ test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", asy await expect(page.getByLabel("Title")).toBeVisible(); }); -test("SHIFT-SHIFT exits keyboard-only mode and restores clicks", async ({ - page, -}) => { +test("h exits keyboard-only mode and restores clicks", async ({ page }) => { await prepareCalendarPage(page); const eventButton = await createTimedEventOnGrid( page, "Keyboard Only Toggle Exit", ); - await tapShift(page); - await tapShift(page); + await page.keyboard.press("h"); await expect(keyboardOnlyIndicator(page)).toBeVisible(); - await tapShift(page); - await tapShift(page); + await page.keyboard.press("h"); await expect(keyboardOnlyIndicator(page)).toHaveCount(0); await eventButton.click(); await expect(page.getByLabel("Title")).toBeVisible(); }); -test("hold Shift does not enter keyboard-only or event jump", async ({ +test("Shift alone does not enter keyboard-only or event jump", async ({ page, }) => { await prepareCalendarPage(page); diff --git a/e2e/timed/shift-hold-event-hints.spec.ts b/e2e/timed/shift-hold-event-hints.spec.ts index 6d41f91ce..974c39f14 100644 --- a/e2e/timed/shift-hold-event-hints.spec.ts +++ b/e2e/timed/shift-hold-event-hints.spec.ts @@ -33,12 +33,7 @@ const createTimedEventAt = async ( const shiftHintOverlay = (page: CalendarPage) => page.locator("[data-shift-event-hints]"); -const tapShift = async (page: CalendarPage) => { - await page.keyboard.down("Shift"); - await page.keyboard.up("Shift"); -}; - -test("tap Shift shows day-prefix jump keys and focuses the assigned event", async ({ +test("tap s shows day-prefix jump keys and focuses the assigned event", async ({ page, }) => { await prepareCalendarPage(page); @@ -61,7 +56,7 @@ test("tap Shift shows day-prefix jump keys and focuses the assigned event", asyn document.activeElement.blur(); } }); - await tapShift(page); + await page.keyboard.press("s"); const overlay = shiftHintOverlay(page); await expect(overlay.locator(":scope > span")).toHaveCount(3); @@ -93,12 +88,25 @@ test("tap Shift shows day-prefix jump keys and focuses the assigned event", asyn await expect(shiftHintOverlay(page)).toHaveCount(0); }); -test("fast Shift+J does not toggle event jump keys", async ({ page }) => { +test("Shift+Tab does not toggle event jump keys", async ({ page }) => { await prepareCalendarPage(page); const title = createEventTitle("Chord Quiet"); await createTimedEventAt(page, title, { xRatio: 0.42, yRatio: 0.35 }); + await page.keyboard.down("Shift"); + await page.keyboard.press("Tab"); + await page.keyboard.up("Shift"); + + await expect(shiftHintOverlay(page)).toHaveCount(0); +}); + +test("fast Shift+J does not toggle event jump keys", async ({ page }) => { + await prepareCalendarPage(page); + + const title = createEventTitle("Chord Quiet J"); + await createTimedEventAt(page, title, { xRatio: 0.42, yRatio: 0.35 }); + await page.keyboard.down("Shift"); await page.keyboard.down("j"); await page.waitForTimeout(250); diff --git a/packages/web/src/common/utils/datetime/web.date.util.test.ts b/packages/web/src/common/utils/datetime/web.date.util.test.ts index 86b9bd772..7c27f80cb 100644 --- a/packages/web/src/common/utils/datetime/web.date.util.test.ts +++ b/packages/web/src/common/utils/datetime/web.date.util.test.ts @@ -7,11 +7,14 @@ import { getCalendarHeadingLabel, getColorsByHour, getHourLabels, + getTimeOptionByValue, getTimesLabel, getWeekRangeLabel, + mapToBackend, parseUserTime, toUTCOffset, } from "@web/common/utils/datetime/web.date.util"; +import { getFormDates } from "@web/views/Forms/EventForm/DateControlsSection/DateTimeSection/form.datetime.util"; import { afterAll, beforeAll, @@ -514,3 +517,69 @@ describe("parseUserTime", () => { expect(result?.label).toBe("10 AM"); }); }); + +describe("mapToBackend timed overnight", () => { + it("keeps an overnight end on the next calendar day", () => { + const start = dayjs("2026-08-11T23:30:00"); + const end = start.add(1, "hour"); + const form = getFormDates(start.format(), end.format()); + + const schedule = mapToBackend({ + startDate: form.startDate, + endDate: form.endDate, + startTime: form.startTime, + endTime: form.endTime, + isAllDay: false, + }); + + expect(schedule.kind).toBe("timed"); + if (schedule.kind !== "timed") return; + + expect(dayjs(schedule.end).isAfter(dayjs(schedule.start))).toBe(true); + expect(dayjs(schedule.start).format("YYYY-MM-DD HH:mm")).toBe( + "2026-08-11 23:30", + ); + expect(dayjs(schedule.end).format("YYYY-MM-DD HH:mm")).toBe( + "2026-08-12 00:30", + ); + }); + + it("still stamps same-day timed ends onto the start calendar day", () => { + const start = dayjs("2026-08-11T14:00:00"); + const end = start.add(1, "hour"); + const form = getFormDates(start.format(), end.format()); + + const schedule = mapToBackend({ + startDate: form.startDate, + endDate: form.endDate, + startTime: form.startTime, + endTime: form.endTime, + isAllDay: false, + }); + + expect(schedule.kind).toBe("timed"); + if (schedule.kind !== "timed") return; + + expect(dayjs(schedule.start).format("YYYY-MM-DD HH:mm")).toBe( + "2026-08-11 14:00", + ); + expect(dayjs(schedule.end).format("YYYY-MM-DD HH:mm")).toBe( + "2026-08-11 15:00", + ); + }); + + it("accepts explicit same-day start/end dates with same-day clock times", () => { + const day = dayjs("2026-08-11T00:00:00"); + const schedule = mapToBackend({ + startDate: day.toDate(), + endDate: day.toDate(), + startTime: getTimeOptionByValue(day.hour(9).minute(0)), + endTime: getTimeOptionByValue(day.hour(10).minute(0)), + isAllDay: false, + }); + + expect(schedule.kind).toBe("timed"); + if (schedule.kind !== "timed") return; + expect(dayjs(schedule.end).isAfter(dayjs(schedule.start))).toBe(true); + }); +}); diff --git a/packages/web/src/common/utils/datetime/web.date.util.ts b/packages/web/src/common/utils/datetime/web.date.util.ts index bc1af2457..044f7f322 100644 --- a/packages/web/src/common/utils/datetime/web.date.util.ts +++ b/packages/web/src/common/utils/datetime/web.date.util.ts @@ -257,8 +257,11 @@ const _addTimesToDates = (dt: SelectedDates, timeZone: string) => { .format(); const end = getDayjsByTimeValue(dt.endTime.value); + // Use endDate (not startDate) so overnight / multi-day timed drafts keep + // their real end calendar day. Applying the end clock time onto startDate + // made 11:30 PM → 12:30 AM parse as inverted and blocked Save. const endDate = dayjs - .tz(dt.startDate, timeZone) + .tz(dt.endDate, timeZone) .hour(end.hour()) .minute(end.minute()) .second(0) diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts index 6bb666649..e6803890c 100644 --- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts +++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts @@ -128,13 +128,13 @@ describe("getNavigationCommandItems", () => { expect(useKeyboardOnlyStore.getState().isActive).toBe(false); }); - it("advertises Shift Shift as the shortcut", () => { + it("advertises h as the shortcut", () => { const item = getNavigationCommandItems({ onNavigateToView: () => {}, }).find((entry) => entry.id === "enter-keyboard-only"); expect(item?.label).toBe("Toggle Hardcore Mode"); - expect(item?.shortcut).toEqual(["Shift", "Shift"]); + expect(item?.shortcut).toEqual(["h"]); }); }); }); diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts index a2ace2d56..c04b2f9af 100644 --- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts +++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts @@ -126,7 +126,7 @@ export const getNavigationCommandItems = ({ id: "enter-keyboard-only", label: "Toggle Hardcore Mode", icon: KeyboardIcon, - shortcut: ["Shift", "Shift"], + shortcut: ["h"], keywords: [ "keyboard", "hardcore", diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts index 0a80ea4b0..f2c91990f 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts @@ -109,10 +109,10 @@ describe("onboarding tour steps", () => { ); expect(hardcore?.title).toMatch(/Graduate/i); - expect(hardcore?.body).toMatch(/Shift twice/i); + expect(hardcore?.body).toMatch(/Press H/i); expect(hardcore?.body).toMatch(/keyboard-only/i); expect(hardcore?.body).toMatch(/clicks/i); expect(hardcore?.body).toMatch(/command palette/i); - expect(hardcore?.shortcutHint).toEqual(["Shift", "Shift"]); + expect(hardcore?.shortcutHint).toBe("H"); }); }); diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts index ca6bc6a3c..fa64ec6e3 100644 --- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts +++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts @@ -65,8 +65,8 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] { }, targetEvent: { title: "Jump to Dentist", - body: "Tap Shift once to flash a key over every visible event, then press Dentist's key to jump straight to it. Great when there are a few on the same day.", - shortcutHint: "Shift", + body: "Tap S to flash a key over every visible event, then press Dentist's key to jump straight to it. Great when there are a few on the same day.", + shortcutHint: "S", }, move: { title: "Move Dentist out of the overlap", @@ -90,8 +90,8 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] { }, hardcore: { title: "Graduate to Hardcore Mode", - body: "Press Shift twice to go keyboard-only, clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.", - shortcutHint: ["Shift", "Shift"], + body: "Press H to go keyboard-only, clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.", + shortcutHint: "H", }, }; diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourKeyboardOnly.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourKeyboardOnly.ts index ef6a420d9..8ebf67bae 100644 --- a/packages/web/src/components/OnboardingTour/useOnboardingTourKeyboardOnly.ts +++ b/packages/web/src/components/OnboardingTour/useOnboardingTourKeyboardOnly.ts @@ -9,7 +9,7 @@ import { keyboardOnlyActions } from "@web/shortcuts/keyboard-only/keyboard-only. /** * Mission steps that target a real grid event: mouse clicks disable for - * these on top of the existing double-Shift gesture in useKeyboardOnlyMode.ts, + * these on top of the existing `h` Hardcore toggle in useKeyboardOnlyMode.ts, * so the keyboard is the only way through. `undo`/`hardcore` don't touch a * grid event directly and stay mouse-permissive; `create`/`save` are the * very first lesson and stay permissive too. diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts index 2574d9f7f..da5909eec 100644 --- a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts +++ b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts @@ -36,7 +36,6 @@ import { selectKeyboardOnlyActive, useKeyboardOnlyStore, } from "@web/shortcuts/keyboard-only/keyboard-only.store"; -import { resetSharedShiftTapGesture } from "@web/shortcuts/shift-tap-gesture"; const TITLE_FIELD_SELECTOR = `form[name="${ID_EVENT_FORM}"] input[name="Event Title"]`; @@ -255,15 +254,10 @@ export function useOnboardingTourProgress() { }, [isActive, stepId, draftActivity, gridDraft]); // undo: two phases against real event state, not keydowns - wait for - // Dentist's schedule to change (undo), then to change again (redo). Also - // resets the shared Shift-tap gesture: Act 2's several prior Shift+Arrow - // missions can leave it mid-cycle, and an in-flight arm treats the very - // next non-arrow keydown as a (failed) day-jump letter and swallows it - - // which is exactly what Mod+Shift+Z's "Z" looks like to that listener. + // Dentist's schedule to change (undo), then to change again (redo). // biome-ignore lint/correctness/useExhaustiveDependencies: capture the schedule once, on step entry - dentistEvent updating mid-step must not reset the snapshot we diff against. useEffect(() => { if (!isActive || stepId !== "undo") return; - resetSharedShiftTapGesture(); enterDentistMission(dentistEvent, scheduleAtEntryRef); undoPhaseRef.current = "pending-undo"; }, [isActive, stepId]); diff --git a/packages/web/src/shortcuts/data/shortcuts.data.test.ts b/packages/web/src/shortcuts/data/shortcuts.data.test.ts index 0b01709f1..0dc13fbf9 100644 --- a/packages/web/src/shortcuts/data/shortcuts.data.test.ts +++ b/packages/web/src/shortcuts/data/shortcuts.data.test.ts @@ -149,22 +149,22 @@ describe("shortcuts.data", () => { expect(stripMetadata(findFocus("day")?.shortcuts ?? [])).toEqual([ { keys: ["i"], label: "Focus sidebar" }, { keys: ["u"], label: "Focus calendar event" }, - { keys: ["Shift"], label: "Toggle event jump keys" }, + { keys: ["s"], label: "Toggle event jump keys" }, ]); expect(stripMetadata(findFocus("week")?.shortcuts ?? [])).toEqual([ { keys: ["i"], label: "Focus sidebar" }, { keys: ["u"], label: "Focus calendar event" }, - { keys: ["Shift"], label: "Toggle event jump keys" }, + { keys: ["s"], label: "Toggle event jump keys" }, ]); expect(stripMetadata(findFocus("week", true)?.shortcuts ?? [])).toEqual([ { keys: ["i"], label: "Focus sidebar" }, { keys: ["u"], label: "Focus calendar event" }, - { keys: ["Shift"], label: "Toggle event jump keys" }, + { keys: ["s"], label: "Toggle event jump keys" }, ]); expect(stripMetadata(findFocus("day", true)?.shortcuts ?? [])).toEqual([ { keys: ["i"], label: "Focus sidebar" }, { keys: ["u"], label: "Focus calendar event" }, - { keys: ["Shift"], label: "Toggle event jump keys" }, + { keys: ["s"], label: "Toggle event jump keys" }, ]); }); @@ -277,7 +277,7 @@ describe("shortcuts.data", () => { label: "Undo last change", }); expect(stripMetadata(other?.shortcuts ?? [])).toContainEqual({ - keys: ["Shift", "Shift"], + keys: ["h"], label: "Toggle Hardcore Mode", }); expect(stripMetadata(other?.shortcuts ?? [])).toContainEqual({ diff --git a/packages/web/src/shortcuts/is-bare-letter-key.ts b/packages/web/src/shortcuts/is-bare-letter-key.ts new file mode 100644 index 000000000..c8fcddbfb --- /dev/null +++ b/packages/web/src/shortcuts/is-bare-letter-key.ts @@ -0,0 +1,8 @@ +/** True for an unmodified single-letter key matching `letter` (case-insensitive). */ +export const isBareLetterKey = (event: KeyboardEvent, letter: string) => + event.key.length === 1 && + event.key.toLowerCase() === letter && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey; diff --git a/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.test.tsx b/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.test.tsx index 94db29ba5..abd202981 100644 --- a/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.test.tsx +++ b/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.test.tsx @@ -5,8 +5,15 @@ import { useKeyboardOnlyStore, } from "@web/shortcuts/keyboard-only/keyboard-only.store"; import { useKeyboardOnlyMode } from "@web/shortcuts/keyboard-only/useKeyboardOnlyMode"; -import { resetSharedShiftTapGesture } from "@web/shortcuts/shift-tap-gesture"; -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + eventJumpActions, + useEventJumpStore, +} from "@web/shortcuts/shift-hint/event-jump.store"; +import { + resetEditSequenceArm, + useEditSequenceShortcut, +} from "@web/shortcuts/useEditSequenceShortcut"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; const dispatchKey = ( type: "keydown" | "keyup", @@ -15,7 +22,6 @@ const dispatchKey = ( ) => { const event = new KeyboardEvent(type, { key, - code: key === "Shift" ? "ShiftLeft" : key, bubbles: true, cancelable: true, ...init, @@ -24,32 +30,33 @@ const dispatchKey = ( return event; }; -const tapShift = () => { - dispatchKey("keydown", "Shift"); - dispatchKey("keyup", "Shift", { shiftKey: false }); +const pressH = () => { + dispatchKey("keydown", "h"); + dispatchKey("keyup", "h"); }; describe("useKeyboardOnlyMode", () => { beforeEach(() => { useKeyboardOnlyStore.setState(initialKeyboardOnlyState); + eventJumpActions.reset(); + resetEditSequenceArm(); clearAppLockReasons(); - resetSharedShiftTapGesture(); document.body.innerHTML = `
`; }); afterEach(() => { useKeyboardOnlyStore.setState(initialKeyboardOnlyState); + eventJumpActions.reset(); + resetEditSequenceArm(); clearAppLockReasons(); - resetSharedShiftTapGesture(); document.body.innerHTML = ""; }); - it("enters on SHIFT-SHIFT and suppresses clicks until Escape", () => { + it("enters on h and suppresses clicks until Escape", () => { renderHook(() => useKeyboardOnlyMode()); act(() => { - tapShift(); - tapShift(); + pressH(); }); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); @@ -95,8 +102,7 @@ describe("useKeyboardOnlyMode", () => { renderHook(() => useKeyboardOnlyMode()); act(() => { - tapShift(); - tapShift(); + pressH(); }); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); @@ -125,18 +131,16 @@ describe("useKeyboardOnlyMode", () => { expect(useKeyboardOnlyStore.getState().blockedClickPulse).toBe(0); }); - it("exits on a second SHIFT-SHIFT", () => { + it("exits on a second h", () => { renderHook(() => useKeyboardOnlyMode()); act(() => { - tapShift(); - tapShift(); + pressH(); }); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); act(() => { - tapShift(); - tapShift(); + pressH(); }); expect(useKeyboardOnlyStore.getState().isActive).toBe(false); }); @@ -146,10 +150,52 @@ describe("useKeyboardOnlyMode", () => { renderHook(() => useKeyboardOnlyMode()); act(() => { - tapShift(); - tapShift(); + pressH(); + }); + + expect(useKeyboardOnlyStore.getState().isActive).toBe(false); + }); + + it("does not enter on Shift-Shift", () => { + renderHook(() => useKeyboardOnlyMode()); + + act(() => { + dispatchKey("keydown", "Shift"); + dispatchKey("keyup", "Shift"); + dispatchKey("keydown", "Shift"); + dispatchKey("keyup", "Shift"); + }); + + expect(useKeyboardOnlyStore.getState().isActive).toBe(false); + }); + + it("does not steal h while the e edit sequence is armed", () => { + const onSequence = mock(() => {}); + // Match production mount order: Hardcore (RootShell) before edit sequences. + renderHook(() => useKeyboardOnlyMode()); + renderHook(() => useEditSequenceShortcut({ onSequence })); + + act(() => { + dispatchKey("keydown", "e"); + dispatchKey("keydown", "h"); }); + // While armed, Hardcore yields so the edit sequence can disarm on the + // unknown follow key without entering keyboard-only. expect(useKeyboardOnlyStore.getState().isActive).toBe(false); + expect(onSequence).not.toHaveBeenCalled(); + }); + + it("clears event jump when entering hardcore", () => { + renderHook(() => useKeyboardOnlyMode()); + eventJumpActions.setActive(true); + expect(useEventJumpStore.getState().isActive).toBe(true); + + act(() => { + pressH(); + }); + + expect(useKeyboardOnlyStore.getState().isActive).toBe(true); + expect(useEventJumpStore.getState().isActive).toBe(false); }); }); diff --git a/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.ts b/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.ts index 2bbcb53a1..769d7dbf4 100644 --- a/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.ts +++ b/packages/web/src/shortcuts/keyboard-only/useKeyboardOnlyMode.ts @@ -1,17 +1,18 @@ import { useEffect } from "react"; +import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util"; +import { isAppLocked } from "@web/shortcuts/app-lock"; import { isHigherEscapeOwner } from "@web/shortcuts/escape-ownership"; +import { isBareLetterKey } from "@web/shortcuts/is-bare-letter-key"; import { keyboardOnlyActions, useKeyboardOnlyStore, } from "@web/shortcuts/keyboard-only/keyboard-only.store"; -import { - resetSharedShiftTapGesture, - subscribeToShiftTapGesture, -} from "@web/shortcuts/shift-tap-gesture"; +import { eventJumpActions } from "@web/shortcuts/shift-hint/event-jump.store"; +import { isEditSequenceArmed } from "@web/shortcuts/useEditSequenceShortcut"; /** - * SHIFT-SHIFT (two quick taps) toggles keyboard-only mode. Clicks are inert; - * ESC, SHIFT-SHIFT again, or refresh exits. Mode is not persisted. + * Bare `h` toggles keyboard-only (Hardcore) mode. Clicks are inert; ESC, `h` + * again, or refresh exits. Mode is not persisted. * * ESC stands down while app lock, floating layers, or the event form own Escape * so those dismiss first; a later ESC exits this mode. @@ -20,14 +21,39 @@ export function useKeyboardOnlyMode() { const isActive = useKeyboardOnlyStore((state) => state.isActive); useEffect(() => { - return subscribeToShiftTapGesture((event) => { - if (event.type !== "doubleTap") return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + + if (event.key === "Escape") { + if (!useKeyboardOnlyStore.getState().isActive) return; + if (isHigherEscapeOwner()) return; + + event.preventDefault(); + event.stopPropagation(); + keyboardOnlyActions.exit(); + return; + } + + if (!isBareLetterKey(event, "h")) return; + if (isAppLocked() || isEditableKeyboardTarget(event)) return; + // Yield to an armed `e`… edit sequence (same as event-jump `s`). + if (isEditSequenceArmed()) return; + + event.preventDefault(); + event.stopPropagation(); if (useKeyboardOnlyStore.getState().isActive) { keyboardOnlyActions.exit(); } else { + // Clear jump chips so Hardcore does not leave a second Esc owner. + eventJumpActions.reset(); keyboardOnlyActions.enter(); } - }); + }; + + document.addEventListener("keydown", onKeyDown, true); + return () => { + document.removeEventListener("keydown", onKeyDown, true); + }; }, []); // Click suppression while active. Window capture runs before React's @@ -64,25 +90,4 @@ export function useKeyboardOnlyMode() { window.removeEventListener("auxclick", blockPointer, true); }; }, [isActive]); - - // ESC exits when nothing higher owns Escape. - useEffect(() => { - if (!isActive) return; - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (event.defaultPrevented) return; - if (isHigherEscapeOwner()) return; - - event.preventDefault(); - event.stopPropagation(); - keyboardOnlyActions.exit(); - resetSharedShiftTapGesture(); - }; - - document.addEventListener("keydown", onKeyDown, true); - return () => { - document.removeEventListener("keydown", onKeyDown, true); - }; - }, [isActive]); } diff --git a/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx b/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx index 1e378c681..02d2872b8 100644 --- a/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx +++ b/packages/web/src/shortcuts/shift-hint-keyboard-only.integration.test.tsx @@ -1,8 +1,6 @@ /** - * Shift-hint and keyboard-only mode share one Shift-tap gesture listener - * (see shift-tap-gesture.ts). This covers the interaction between the two - * real hooks mounted together - the scenario the old per-hook detectors - * coordinated only by timing, and could race. + * Event-jump (`s`) and Hardcore (`h`) are independent letter shortcuts. + * Mounted together they must not steal each other's keys. */ import { act, renderHook } from "@testing-library/react"; @@ -18,19 +16,9 @@ import { eventJumpActions, useEventJumpStore, } from "@web/shortcuts/shift-hint/event-jump.store"; -import { SHIFT_DOUBLE_TAP_MAX_GAP_MS } from "@web/shortcuts/shift-hint/shift-hold-detector"; import { useShiftHoldEventHints } from "@web/shortcuts/shift-hint/useShiftHoldEventHints"; -import { resetSharedShiftTapGesture } from "@web/shortcuts/shift-tap-gesture"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -const waitPastDoubleTapWindow = async () => { - await act(async () => { - await new Promise((resolve) => - setTimeout(resolve, SHIFT_DOUBLE_TAP_MAX_GAP_MS + 10), - ); - }); -}; - const dispatch = ( type: "keydown" | "keyup", key: string, @@ -47,9 +35,9 @@ const dispatch = ( ); }; -const tapShift = () => { - dispatch("keydown", "Shift"); - dispatch("keyup", "Shift"); +const press = (key: string) => { + dispatch("keydown", key); + dispatch("keyup", key); }; const EVENT_ID = EventIdSchema.parse("aaaaaaaaaaaaaaaaaaaaaaaa"); @@ -70,72 +58,88 @@ const mountBoth = () => { timedFixture(EVENT_ID, "2026-08-05T09:00:00.000Z"), ]; - renderHook(() => useKeyboardOnlyMode()); - return renderHook(() => + // Match production effect order: view (child) listeners register before + // RootShell Hardcore, so jump is first on the capture path. + const jump = renderHook(() => useShiftHoldEventHints({ focus: () => {}, listVisible: () => [{ eventId: EVENT_ID, eventType: "timed", element }], timedEvents, }), ); + renderHook(() => useKeyboardOnlyMode()); + return jump; }; -describe("shift-hint + keyboard-only integration", () => { +describe("event-jump + keyboard-only integration", () => { beforeEach(() => { clearAppLockReasons(); eventJumpActions.reset(); useKeyboardOnlyStore.setState(initialKeyboardOnlyState); - resetSharedShiftTapGesture(); }); afterEach(() => { clearAppLockReasons(); eventJumpActions.reset(); useKeyboardOnlyStore.setState(initialKeyboardOnlyState); - resetSharedShiftTapGesture(); document.body.innerHTML = ""; }); - it("a single tap activates shift-hint only", () => { + it("s activates event jump only", () => { mountBoth(); act(() => { - tapShift(); + press("s"); }); expect(useEventJumpStore.getState().isActive).toBe(true); expect(useKeyboardOnlyStore.getState().isActive).toBe(false); }); - it("Shift-Shift cancels the hints it just activated and enters keyboard-only instead", () => { + it("h activates hardcore only", () => { mountBoth(); act(() => { - tapShift(); - tapShift(); + press("h"); }); expect(useEventJumpStore.getState().isActive).toBe(false); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); }); - it("Shift press still activates jump while keyboard-only is already on", async () => { + it("s still activates jump while hardcore is already on", () => { const { result } = mountBoth(); act(() => { - tapShift(); - tapShift(); + press("h"); }); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); expect(useEventJumpStore.getState().isActive).toBe(false); - await waitPastDoubleTapWindow(); act(() => { - tapShift(); + press("s"); }); expect(useKeyboardOnlyStore.getState().isActive).toBe(true); expect(useEventJumpStore.getState().isActive).toBe(true); expect(result.current.hints.length).toBeGreaterThan(0); }); + + it("h clears active jump chips when entering hardcore", () => { + const { result } = mountBoth(); + + act(() => { + press("s"); + }); + expect(useEventJumpStore.getState().isActive).toBe(true); + expect(result.current.hints.length).toBeGreaterThan(0); + + act(() => { + press("h"); + }); + + expect(useKeyboardOnlyStore.getState().isActive).toBe(true); + expect(useEventJumpStore.getState().isActive).toBe(false); + expect(result.current.hints).toEqual([]); + }); }); diff --git a/packages/web/src/shortcuts/shift-hint/event-jump.store.ts b/packages/web/src/shortcuts/shift-hint/event-jump.store.ts index 8f83fdd72..7bc8c1d48 100644 --- a/packages/web/src/shortcuts/shift-hint/event-jump.store.ts +++ b/packages/web/src/shortcuts/shift-hint/event-jump.store.ts @@ -36,13 +36,6 @@ export const eventJumpActions = { false, { type: "setActive" }, ), - /** Exit without announcing (e.g. Shift-Shift handing off to keyboard-only). */ - silenceOff: () => - useEventJumpStore.setState( - { isActive: false, activeDayKeys: [], announcement: "" }, - false, - { type: "silenceOff" }, - ), /** Clear a lingering exit announcement after the live region has spoken. */ clearAnnouncement: () => useEventJumpStore.setState({ announcement: "" }, false, { diff --git a/packages/web/src/shortcuts/shift-hint/shift-hold-detector.test.ts b/packages/web/src/shortcuts/shift-hint/shift-hold-detector.test.ts deleted file mode 100644 index 8b754760d..000000000 --- a/packages/web/src/shortcuts/shift-hint/shift-hold-detector.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - createShiftJumpGestureState, - isShiftDoubleTapCandidate, - reduceShiftJumpGesture, - SHIFT_DOUBLE_TAP_MAX_GAP_MS, - SHIFT_TAP_MAX_HOLD_MS, -} from "@web/shortcuts/shift-hint/shift-hold-detector"; -import { describe, expect, it } from "bun:test"; - -describe("reduceShiftJumpGesture", () => { - it("emits press on Shift down and toggle on a quick release", () => { - const state = createShiftJumpGestureState(); - let result = reduceShiftJumpGesture(state, { - type: "shiftDown", - now: 1000, - blocked: false, - }); - expect(result.state.phase).toBe("armed"); - expect(result.press).toBe(true); - expect(result.toggle).toBe(false); - - result = reduceShiftJumpGesture(result.state, { - type: "shiftUp", - now: 1100, - }); - expect(result.toggle).toBe(true); - expect(result.forceOff).toBe(false); - expect(result.cancel).toBe(false); - expect(result.state.phase).toBe("idle"); - expect(result.state.lastShiftReleaseAt).toBe(1100); - }); - - it("cancels on a long press release", () => { - let state = createShiftJumpGestureState(); - state = reduceShiftJumpGesture(state, { - type: "shiftDown", - now: 1000, - blocked: false, - }).state; - const result = reduceShiftJumpGesture(state, { - type: "shiftUp", - now: 1000 + SHIFT_TAP_MAX_HOLD_MS, - }); - expect(result.toggle).toBe(false); - expect(result.forceOff).toBe(false); - expect(result.cancel).toBe(true); - expect(result.state.lastShiftReleaseAt).toBeNull(); - }); - - it("cancels armed on chord key so Shift+J never toggles", () => { - const state = createShiftJumpGestureState(); - let result = reduceShiftJumpGesture(state, { - type: "shiftDown", - now: 1000, - blocked: false, - }); - expect(result.press).toBe(true); - result = reduceShiftJumpGesture(result.state, { type: "chordKeyDown" }); - expect(result.state.phase).toBe("idle"); - expect(result.cancel).toBe(true); - - result = reduceShiftJumpGesture(result.state, { - type: "shiftUp", - now: 1100, - }); - expect(result.toggle).toBe(false); - expect(result.cancel).toBe(false); - }); - - it("cancels via holdExpired while still armed", () => { - let state = createShiftJumpGestureState(); - state = reduceShiftJumpGesture(state, { - type: "shiftDown", - now: 1000, - blocked: false, - }).state; - const result = reduceShiftJumpGesture(state, { type: "holdExpired" }); - expect(result.cancel).toBe(true); - expect(result.state.phase).toBe("idle"); - expect(result.state.lastShiftReleaseAt).toBeNull(); - }); - - it("does not arm when blocked (editable / app lock)", () => { - const result = reduceShiftJumpGesture(createShiftJumpGestureState(), { - type: "shiftDown", - now: 1000, - blocked: true, - }); - expect(result.state.phase).toBe("idle"); - expect(result.press).toBe(false); - expect(result.toggle).toBe(false); - }); - - it("forceOff on the second quick tap (Shift-Shift) without a second press", () => { - let state = createShiftJumpGestureState(); - state = reduceShiftJumpGesture(state, { - type: "shiftDown", - now: 1000, - blocked: false, - }).state; - state = reduceShiftJumpGesture(state, { - type: "shiftUp", - now: 1050, - }).state; - expect(state.lastShiftReleaseAt).toBe(1050); - - let result = reduceShiftJumpGesture(state, { - type: "shiftDown", - now: 1100, - blocked: false, - }); - expect(result.press).toBe(false); - result = reduceShiftJumpGesture(result.state, { - type: "shiftUp", - now: 1150, - }); - expect(result.toggle).toBe(false); - expect(result.forceOff).toBe(true); - expect(result.state.lastShiftReleaseAt).toBeNull(); - }); -}); - -describe("hold vs double-tap coordination", () => { - it("records release times so a future SHIFT-SHIFT detector can use them", () => { - let state = createShiftJumpGestureState(); - state = reduceShiftJumpGesture(state, { - type: "shiftDown", - now: 1000, - blocked: false, - }).state; - state = reduceShiftJumpGesture(state, { - type: "shiftUp", - now: 1050, - }).state; - expect( - isShiftDoubleTapCandidate({ - lastShiftReleaseAt: state.lastShiftReleaseAt, - now: 1050 + 100, - maxGapMs: SHIFT_DOUBLE_TAP_MAX_GAP_MS, - }), - ).toBe(true); - }); - - it("does not treat a long hold release as a double-tap candidate", () => { - expect(SHIFT_TAP_MAX_HOLD_MS).toBeGreaterThan(100); - expect( - isShiftDoubleTapCandidate({ - lastShiftReleaseAt: 1000, - now: 1000 + 1000, - maxGapMs: SHIFT_DOUBLE_TAP_MAX_GAP_MS, - }), - ).toBe(false); - }); -}); diff --git a/packages/web/src/shortcuts/shift-hint/shift-hold-detector.ts b/packages/web/src/shortcuts/shift-hint/shift-hold-detector.ts deleted file mode 100644 index e21ec1f5f..000000000 --- a/packages/web/src/shortcuts/shift-hint/shift-hold-detector.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Pure Shift gesture state for event-jump mode. - * - * Shift down → optional optimistic `press` (hints can show immediately). - * Quick Shift up → `toggle` (hook confirms or toggles off). Chord while - * armed, or hold past {@link SHIFT_TAP_MAX_HOLD_MS}, cancels so Shift+J / - * long holds never leave jump mode on. A second quick tap within - * {@link SHIFT_DOUBLE_TAP_MAX_GAP_MS} is a Shift-Shift candidate: force jump - * mode off so keyboard-only can win. - */ - -export const SHIFT_TAP_MAX_HOLD_MS = 200; - -/** Max gap between two quick Shift taps to enter keyboard-only mode. */ -export const SHIFT_DOUBLE_TAP_MAX_GAP_MS = 400; - -export const isShiftKey = (event: Pick) => - event.key === "Shift" || - event.code === "ShiftLeft" || - event.code === "ShiftRight"; - -export type ShiftJumpGesturePhase = "idle" | "armed"; - -export type ShiftJumpGestureState = { - phase: ShiftJumpGesturePhase; - armedAt: number | null; - /** - * Timestamp of the last completed quick Shift tap. Used to detect the - * second tap of Shift-Shift (force jump off; keyboard-only owns that). - */ - lastShiftReleaseAt: number | null; -}; - -export const createShiftJumpGestureState = (): ShiftJumpGestureState => ({ - phase: "idle", - armedAt: null, - lastShiftReleaseAt: null, -}); - -export type ShiftJumpGestureEvent = - | { type: "shiftDown"; now: number; blocked: boolean } - | { type: "shiftUp"; now: number } - | { type: "chordKeyDown" } - | { type: "holdExpired" } - | { type: "reset" }; - -export type ShiftJumpGestureResult = { - state: ShiftJumpGestureState; - /** Shift down that may show jump hints before release. */ - press: boolean; - /** Quick tap up: confirm optimistic press, or toggle off if already on. */ - toggle: boolean; - /** Second quick tap: force jump mode off for keyboard-only coexistence. */ - forceOff: boolean; - /** Chord or hold timeout: undo an optimistic press. */ - cancel: boolean; -}; - -const idleResult = ( - state: ShiftJumpGestureState, - overrides: Partial = {}, -): ShiftJumpGestureResult => ({ - state, - press: false, - toggle: false, - forceOff: false, - cancel: false, - ...overrides, -}); - -export function reduceShiftJumpGesture( - state: ShiftJumpGestureState, - event: ShiftJumpGestureEvent, -): ShiftJumpGestureResult { - switch (event.type) { - case "shiftDown": { - if (event.blocked) { - return idleResult({ - phase: "idle", - armedAt: null, - lastShiftReleaseAt: state.lastShiftReleaseAt, - }); - } - if (state.phase === "armed") { - return idleResult(state); - } - const isSecondTap = isShiftDoubleTapCandidate({ - lastShiftReleaseAt: state.lastShiftReleaseAt, - now: event.now, - maxGapMs: SHIFT_DOUBLE_TAP_MAX_GAP_MS, - }); - return idleResult( - { - ...state, - phase: "armed", - armedAt: event.now, - }, - // Second tap of Shift-Shift must not flash hints before forceOff. - { press: !isSecondTap }, - ); - } - case "shiftUp": { - if (state.phase !== "armed" || state.armedAt === null) { - return idleResult(state); - } - - const heldMs = event.now - state.armedAt; - // Long press: not a tap (also keeps Shift-Shift keyboard-only clean). - if (heldMs >= SHIFT_TAP_MAX_HOLD_MS) { - return idleResult( - { - phase: "idle", - armedAt: null, - lastShiftReleaseAt: null, - }, - { cancel: true }, - ); - } - - const isDoubleTap = isShiftDoubleTapCandidate({ - lastShiftReleaseAt: state.lastShiftReleaseAt, - now: event.now, - maxGapMs: SHIFT_DOUBLE_TAP_MAX_GAP_MS, - }); - - if (isDoubleTap) { - return idleResult( - { - phase: "idle", - armedAt: null, - lastShiftReleaseAt: null, - }, - { forceOff: true }, - ); - } - - return idleResult( - { - phase: "idle", - armedAt: null, - lastShiftReleaseAt: event.now, - }, - { toggle: true }, - ); - } - case "chordKeyDown": { - if (state.phase !== "armed") { - return idleResult(state); - } - return idleResult( - { - phase: "idle", - armedAt: null, - // Chord cancels the tap; do not seed a double-tap gap. - lastShiftReleaseAt: null, - }, - { cancel: true }, - ); - } - case "holdExpired": { - if (state.phase !== "armed") { - return idleResult(state); - } - return idleResult( - { - phase: "idle", - armedAt: null, - lastShiftReleaseAt: null, - }, - { cancel: true }, - ); - } - case "reset": { - return idleResult(createShiftJumpGestureState()); - } - } -} - -/** True when a second Shift tap would count as double-tap, not a hold. */ -export function isShiftDoubleTapCandidate({ - lastShiftReleaseAt, - now, - maxGapMs, -}: { - lastShiftReleaseAt: number | null; - now: number; - maxGapMs: number; -}): boolean { - if (lastShiftReleaseAt === null) return false; - const gap = now - lastShiftReleaseAt; - return gap >= 0 && gap <= maxGapMs; -} diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx index 0172a110e..a70cdd96e 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx @@ -10,12 +10,11 @@ import { eventJumpActions, useEventJumpStore, } from "@web/shortcuts/shift-hint/event-jump.store"; -import { - SHIFT_DOUBLE_TAP_MAX_GAP_MS, - SHIFT_TAP_MAX_HOLD_MS, -} from "@web/shortcuts/shift-hint/shift-hold-detector"; import { useShiftHoldEventHints } from "@web/shortcuts/shift-hint/useShiftHoldEventHints"; -import { resetSharedShiftTapGesture } from "@web/shortcuts/shift-tap-gesture"; +import { + resetEditSequenceArm, + useEditSequenceShortcut, +} from "@web/shortcuts/useEditSequenceShortcut"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; const EVENT_A = EventIdSchema.parse("aaaaaaaaaaaaaaaaaaaaaaaa"); @@ -38,16 +37,9 @@ const dispatch = ( ); }; -const tapShift = () => { - dispatch("keydown", "Shift"); - dispatch("keyup", "Shift"); -}; - -/** Past the double-tap window, so the next tap is treated as fresh. */ -const waitPastDoubleTapWindow = async () => { - await act(async () => { - await Bun.sleep(SHIFT_DOUBLE_TAP_MAX_GAP_MS + 5); - }); +const pressS = () => { + dispatch("keydown", "s"); + dispatch("keyup", "s"); }; const timedFixture = (id: string, startDate: string): GridEvent => @@ -64,7 +56,7 @@ describe("useShiftHoldEventHints", () => { clearAppLockReasons(); eventJumpActions.reset(); keyboardOnlyActions.exit(); - resetSharedShiftTapGesture(); + resetEditSequenceArm(); }); afterEach(() => { @@ -72,7 +64,7 @@ describe("useShiftHoldEventHints", () => { clearAppLockReasons(); eventJumpActions.reset(); keyboardOnlyActions.exit(); - resetSharedShiftTapGesture(); + resetEditSequenceArm(); document.body.innerHTML = ""; }); @@ -120,11 +112,11 @@ describe("useShiftHoldEventHints", () => { return { focus, result, elements }; }; - it("toggles hints on a quick Shift tap and focuses via day prefix", () => { + it("toggles hints on s and focuses via day prefix", () => { const { focus, result, elements } = mountHints(); act(() => { - tapShift(); + pressS(); }); expect(useEventJumpStore.getState().isActive).toBe(true); @@ -155,45 +147,21 @@ describe("useShiftHoldEventHints", () => { expect(useEventJumpStore.getState().isActive).toBe(true); }); - it("shows hints on Shift keydown before release", () => { + it("does not activate on bare Shift or Shift+Tab", () => { const { result } = mountHints(); act(() => { dispatch("keydown", "Shift"); - }); - - expect(useEventJumpStore.getState().isActive).toBe(true); - expect(result.current.hints).toHaveLength(3); - - act(() => { dispatch("keyup", "Shift"); + dispatch("keydown", "Tab", { shiftKey: true }); + dispatch("keyup", "Tab", { shiftKey: true }); }); - expect(useEventJumpStore.getState().isActive).toBe(true); - expect(result.current.hints).toHaveLength(3); - }); - - it("cancels optimistic hints when Shift is held past the tap threshold", async () => { - const { result } = mountHints(); - - act(() => { - dispatch("keydown", "Shift"); - }); - expect(result.current.hints).toHaveLength(3); - - await act(async () => { - await Bun.sleep(SHIFT_TAP_MAX_HOLD_MS + 20); - }); expect(useEventJumpStore.getState().isActive).toBe(false); expect(result.current.hints).toEqual([]); - - act(() => { - dispatch("keyup", "Shift"); - }); - expect(useEventJumpStore.getState().isActive).toBe(false); }); - it("does not toggle on a quick Shift chord (Shift+J)", () => { + it("does not activate on Shift+J", () => { const { result } = mountHints(); act(() => { @@ -207,33 +175,12 @@ describe("useShiftHoldEventHints", () => { expect(result.current.hints).toEqual([]); }); - it("deactivates an already-on jump mode when Shift+Arrow chords", async () => { - const { result } = mountHints(); - - act(() => { - tapShift(); - }); - expect(useEventJumpStore.getState().isActive).toBe(true); - expect(result.current.hints).toHaveLength(3); - - await waitPastDoubleTapWindow(); - act(() => { - dispatch("keydown", "Shift"); - dispatch("keydown", "ArrowRight", { shiftKey: true }); - dispatch("keyup", "ArrowRight", { shiftKey: true }); - dispatch("keyup", "Shift"); - }); - - expect(useEventJumpStore.getState().isActive).toBe(false); - expect(result.current.hints).toEqual([]); - }); - it("stays inert while app-locked", () => { setAppLockReason("test-modal", true); const { result } = mountHints(); act(() => { - tapShift(); + pressS(); }); expect(useEventJumpStore.getState().isActive).toBe(false); @@ -245,7 +192,7 @@ describe("useShiftHoldEventHints", () => { const { result } = mountHints(); act(() => { - tapShift(); + pressS(); }); expect(useEventJumpStore.getState().isActive).toBe(true); @@ -253,32 +200,32 @@ describe("useShiftHoldEventHints", () => { expect(useKeyboardOnlyStore.getState().isActive).toBe(true); }); - it("clears hints when Shift is tapped again or Escape is pressed", async () => { + it("clears hints when Escape is pressed", () => { const { result } = mountHints(); act(() => { - tapShift(); + pressS(); }); expect(result.current.hints).toHaveLength(3); - // Wait past the double-tap window so the next Shift is a toggle-off, - // not a double-tap that hands off to keyboard-only instead. - await waitPastDoubleTapWindow(); act(() => { - tapShift(); + dispatch("keydown", "Escape"); }); expect(useEventJumpStore.getState().isActive).toBe(false); expect(result.current.hints).toEqual([]); + }); + + it("toggles off with a second s in day view", () => { + const { result } = mountHints("day"); - // Wait so re-entry is a fresh tap, not Shift-Shift double-tap. - await waitPastDoubleTapWindow(); act(() => { - tapShift(); + pressS(); }); expect(useEventJumpStore.getState().isActive).toBe(true); + expect(result.current.hints).toHaveLength(3); act(() => { - dispatch("keydown", "Escape"); + pressS(); }); expect(useEventJumpStore.getState().isActive).toBe(false); expect(result.current.hints).toEqual([]); @@ -288,7 +235,7 @@ describe("useShiftHoldEventHints", () => { const { focus, result } = mountHints(); act(() => { - tapShift(); + pressS(); dispatch("keydown", "w"); }); expect(focus).toHaveBeenCalled(); @@ -298,17 +245,21 @@ describe("useShiftHoldEventHints", () => { dispatch("keydown", "ArrowDown"); }); expect(useEventJumpStore.getState().isActive).toBe(true); + expect(result.current.hints.length).toBeGreaterThan(0); }); - it("cancels the just-activated hints on a following Shift-Shift", () => { - mountHints(); + it("does not steal s while the e edit sequence is armed", () => { + const onSequence = mock(() => {}); + renderHook(() => useEditSequenceShortcut({ onSequence })); + const { result } = mountHints(); act(() => { - tapShift(); - tapShift(); + dispatch("keydown", "e"); + dispatch("keydown", "s"); }); + expect(onSequence).toHaveBeenCalledWith("start"); expect(useEventJumpStore.getState().isActive).toBe(false); - expect(useEventJumpStore.getState().announcement).toBe(""); + expect(result.current.hints).toEqual([]); }); }); diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts index 357d72caa..def0797aa 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts @@ -5,6 +5,7 @@ import { type GridEvent } from "@web/common/types/web.event.types"; import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util"; import { isAppLocked } from "@web/shortcuts/app-lock"; import { isHigherEscapeOwner } from "@web/shortcuts/escape-ownership"; +import { isBareLetterKey } from "@web/shortcuts/is-bare-letter-key"; import { assignDayJumpKeys, type DayJumpAssignment, @@ -17,7 +18,7 @@ import { eventJumpActions, useEventJumpStore, } from "@web/shortcuts/shift-hint/event-jump.store"; -import { subscribeToShiftTapGesture } from "@web/shortcuts/shift-tap-gesture"; +import { isEditSequenceArmed } from "@web/shortcuts/useEditSequenceShortcut"; export type ShiftHintFocusTarget = { eventId: string; @@ -108,10 +109,9 @@ const buildDayJumpAssignments = ( }; /** - * Press Shift to show day-prefix jump labels immediately; release confirms. - * Esc or another Shift tap exits. Day letters (and digits after a day) win - * over global shortcuts while active. Shift+chords and long holds cancel the - * optimistic press; Shift-Shift forces off so keyboard-only mode can enter. + * Press `s` to show day-prefix jump labels. Esc exits. Day letters (and digits + * after a day) win over global shortcuts while active. In day view, a second + * `s` toggles off; in week view `s` keeps Sunday/Saturday prefix meaning. */ export function useShiftHoldEventHints({ allDayEvents = [], @@ -134,8 +134,6 @@ export function useShiftHoldEventHints({ const assignmentsRef = useRef([]); const visibleByIdRef = useRef>(new Map()); const suppressKeyUpRef = useRef(new Set()); - /** True when the current Shift press opened jump mode before keyup. */ - const openedByPressRef = useRef(false); const ambiguousCommitTimerRef = useRef | null>( null, ); @@ -207,15 +205,11 @@ export function useShiftHoldEventHints({ setHints(toActiveHints(assignments, visibleByIdRef.current)); }; - const deactivate = (announceOff = true) => { + const deactivate = () => { isActiveRef.current = false; bufferRef.current = ""; clearHints(); - if (announceOff) { - eventJumpActions.setActive(false); - } else { - eventJumpActions.silenceOff(); - } + eventJumpActions.setActive(false); }; const focusEvent = (eventId: string) => { @@ -256,7 +250,21 @@ export function useShiftHoldEventHints({ const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (!isActiveRef.current) return; + + if (!isActiveRef.current) { + if (!isBareLetterKey(event, "s")) return; + if (isAppLocked() || isEditableKeyboardTarget(event)) return; + if (isEditSequenceArmed()) return; + + event.preventDefault(); + event.stopPropagation(); + activate(); + if (isActiveRef.current) { + suppressKeyUpRef.current.add("s"); + } + return; + } + if (isAppLocked() || isEditableKeyboardTarget(event)) { deactivate(); return; @@ -285,6 +293,8 @@ export function useShiftHoldEventHints({ if (key.length !== 1) return; // Swallow j/k and other unmatched printable shortcuts while jump is on. + // Leave bare `h` alone so Hardcore can toggle (child listeners register + // before RootShell, so jump would otherwise mark the event prevented). const match = matchDayJumpKeystroke({ assignments: assignmentsRef.current, key, @@ -292,16 +302,24 @@ export function useShiftHoldEventHints({ mode: modeRef.current, }); - event.preventDefault(); - event.stopPropagation(); - suppressKeyUpRef.current.add(key); - if (!match) { clearAmbiguousCommitTimer(); stripDigitBuffer(); + if (key === "h") return; + event.preventDefault(); + event.stopPropagation(); + suppressKeyUpRef.current.add(key); + // Day view has no letter prefixes; a second `s` toggles off. + if (modeRef.current === "day" && key === "s") { + deactivate(); + } return; } + event.preventDefault(); + event.stopPropagation(); + suppressKeyUpRef.current.add(key); + if (match.kind === "prefix") { bufferRef.current = match.buffer; eventJumpActions.setActiveDayKeys( @@ -348,51 +366,9 @@ export function useShiftHoldEventHints({ }; const onBlur = () => { - openedByPressRef.current = false; if (isActiveRef.current) deactivate(); }; - // Press shows hints before release; quick release confirms (or toggles - // off when mode was already on). Chord / hold-timeout cancel an - // optimistic press. Double tap cancels and hands off to keyboard-only. - const unsubscribeShiftGesture = subscribeToShiftTapGesture((event) => { - if (event.type === "press") { - if (isActiveRef.current) { - openedByPressRef.current = false; - return; - } - activate(); - openedByPressRef.current = isActiveRef.current; - return; - } - if (event.type === "cancel") { - // Always clear jump on chord/hold cancel so an already-on jump mode - // cannot swallow follow-up keys (e.g. recurrence toast 1/2 after - // Shift+Arrow). openedByPress only mattered for optimistic press. - openedByPressRef.current = false; - if (isActiveRef.current) { - deactivate(false); - } - return; - } - if (event.type === "singleTap") { - if (openedByPressRef.current) { - openedByPressRef.current = false; - return; - } - if (isActiveRef.current) { - deactivate(); - } else { - activate(); - } - return; - } - if (event.type === "doubleTap") { - openedByPressRef.current = false; - if (isActiveRef.current) deactivate(false); - } - }); - document.addEventListener("keydown", onKeyDown, true); document.addEventListener("keyup", onKeyUp, true); window.addEventListener("blur", onBlur); @@ -400,8 +376,6 @@ export function useShiftHoldEventHints({ return () => { clearAmbiguousCommitTimer(); suppressKeyUpRef.current.clear(); - openedByPressRef.current = false; - unsubscribeShiftGesture(); document.removeEventListener("keydown", onKeyDown, true); document.removeEventListener("keyup", onKeyUp, true); window.removeEventListener("blur", onBlur); @@ -418,8 +392,15 @@ export function useShiftHoldEventHints({ .join(","); useEffect(() => { - if (!isActive) return; - // Read eventIdsKey so the effect re-runs when the visible event id set changes. + if (!isActive) { + // External reset (e.g. Hardcore `h`) clears the store without going + // through deactivate(); drop local chips/buffer to match. + bufferRef.current = ""; + assignmentsRef.current = []; + visibleByIdRef.current = new Map(); + setHints([]); + return; + } void eventIdsKey; const { assignments, visibleById } = buildDayJumpAssignments( diff --git a/packages/web/src/shortcuts/shift-tap-gesture.test.ts b/packages/web/src/shortcuts/shift-tap-gesture.test.ts deleted file mode 100644 index 56b4bcede..000000000 --- a/packages/web/src/shortcuts/shift-tap-gesture.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { clearAppLockReasons } from "@web/shortcuts/app-lock"; -import { SHIFT_TAP_MAX_HOLD_MS } from "@web/shortcuts/shift-hint/shift-hold-detector"; -import { - resetSharedShiftTapGesture, - subscribeToShiftTapGesture, -} from "@web/shortcuts/shift-tap-gesture"; -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; - -const dispatch = ( - type: "keydown" | "keyup", - key: string, - init: KeyboardEventInit = {}, -) => { - document.dispatchEvent( - new KeyboardEvent(type, { - bubbles: true, - cancelable: true, - composed: true, - key, - ...init, - }), - ); -}; - -const tapShift = () => { - dispatch("keydown", "Shift"); - dispatch("keyup", "Shift"); -}; - -describe("shift-tap-gesture", () => { - beforeEach(() => { - clearAppLockReasons(); - resetSharedShiftTapGesture(); - }); - - afterEach(() => { - clearAppLockReasons(); - resetSharedShiftTapGesture(); - }); - - it("notifies every subscriber of press then single tap", () => { - const events: string[] = []; - const unsubscribeA = subscribeToShiftTapGesture((event) => - events.push(`a:${event.type}`), - ); - const unsubscribeB = subscribeToShiftTapGesture((event) => - events.push(`b:${event.type}`), - ); - - tapShift(); - - expect(events).toEqual([ - "a:press", - "b:press", - "a:singleTap", - "b:singleTap", - ]); - - unsubscribeA(); - unsubscribeB(); - }); - - it("notifies every subscriber of a double tap instead of two single taps", () => { - const events: string[] = []; - const unsubscribe = subscribeToShiftTapGesture((event) => - events.push(event.type), - ); - - tapShift(); - tapShift(); - - expect(events).toEqual(["press", "singleTap", "doubleTap"]); - - unsubscribe(); - }); - - it("cancels an optimistic press on a Shift chord", () => { - const events: string[] = []; - const unsubscribe = subscribeToShiftTapGesture((event) => - events.push(event.type), - ); - - dispatch("keydown", "Shift"); - dispatch("keydown", "j", { shiftKey: true }); - dispatch("keyup", "j", { shiftKey: true }); - dispatch("keyup", "Shift"); - - expect(events).toEqual(["press", "cancel"]); - - unsubscribe(); - }); - - it("cancels an optimistic press once the hold threshold elapses", async () => { - const events: string[] = []; - const unsubscribe = subscribeToShiftTapGesture((event) => - events.push(event.type), - ); - - dispatch("keydown", "Shift"); - await Bun.sleep(SHIFT_TAP_MAX_HOLD_MS + 20); - dispatch("keyup", "Shift"); - - expect(events).toEqual(["press", "cancel"]); - - unsubscribe(); - }); - - it("stops listening once the last subscriber unsubscribes", () => { - const events: string[] = []; - const unsubscribe = subscribeToShiftTapGesture((event) => - events.push(event.type), - ); - - unsubscribe(); - tapShift(); - - expect(events).toEqual([]); - }); -}); diff --git a/packages/web/src/shortcuts/shift-tap-gesture.ts b/packages/web/src/shortcuts/shift-tap-gesture.ts deleted file mode 100644 index d59978b84..000000000 --- a/packages/web/src/shortcuts/shift-tap-gesture.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * One shared document-level listener for the Shift-tap gesture, driving both - * shift-hint (press shows day-jump hints; quick release confirms) and - * keyboard-only mode (double tap toggles it). Previously each feature ran - * its own independent listener + reducer instance and coordinated only by - * timing. One listener means the two can no longer race: Shift down can - * `press` immediately so hints appear before release; a following second - * tap within the window fires `doubleTap` instead — which cancels whatever - * the first tap just started (shift-hint listens for this and turns hints - * back off). Chord or hold-past-threshold fires `cancel` so Shift+J and - * long holds do not leave jump mode on. - */ - -import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util"; -import { isAppLocked } from "@web/shortcuts/app-lock"; -import { - createShiftJumpGestureState, - isShiftKey, - reduceShiftJumpGesture, - SHIFT_TAP_MAX_HOLD_MS, - type ShiftJumpGestureState, -} from "@web/shortcuts/shift-hint/shift-hold-detector"; - -export type ShiftTapGestureEvent = { - type: "press" | "singleTap" | "doubleTap" | "cancel"; -}; -export type ShiftTapGestureListener = (event: ShiftTapGestureEvent) => void; - -let gestureState: ShiftJumpGestureState = createShiftJumpGestureState(); -const listeners = new Set(); -let holdTimer: ReturnType | null = null; - -const notify = (type: ShiftTapGestureEvent["type"]) => { - for (const listener of listeners) listener({ type }); -}; - -const clearHoldTimer = () => { - if (holdTimer === null) return; - clearTimeout(holdTimer); - holdTimer = null; -}; - -const armHoldExpiry = () => { - clearHoldTimer(); - holdTimer = setTimeout(() => { - holdTimer = null; - const result = reduceShiftJumpGesture(gestureState, { - type: "holdExpired", - }); - gestureState = result.state; - if (result.cancel) notify("cancel"); - }, SHIFT_TAP_MAX_HOLD_MS); -}; - -const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - - if (isShiftKey(event)) { - if (event.repeat) return; - const blocked = isAppLocked() || isEditableKeyboardTarget(event); - const result = reduceShiftJumpGesture(gestureState, { - type: "shiftDown", - now: Date.now(), - blocked, - }); - gestureState = result.state; - if (result.press) { - notify("press"); - armHoldExpiry(); - } - return; - } - - // Chord while Shift is down (Shift+J, Shift+Arrow, …): never toggle. - if (gestureState.phase === "armed") { - clearHoldTimer(); - const result = reduceShiftJumpGesture(gestureState, { - type: "chordKeyDown", - }); - gestureState = result.state; - if (result.cancel) notify("cancel"); - } -}; - -const onKeyUp = (event: KeyboardEvent) => { - if (!isShiftKey(event)) return; - // Keep counting a hold if the other Shift key is still down. - if (event.shiftKey) return; - - clearHoldTimer(); - const result = reduceShiftJumpGesture(gestureState, { - type: "shiftUp", - now: Date.now(), - }); - gestureState = result.state; - - if (result.forceOff) { - notify("doubleTap"); - } else if (result.cancel) { - notify("cancel"); - } else if (result.toggle) { - if (isAppLocked() || isEditableKeyboardTarget(event)) return; - notify("singleTap"); - } -}; - -const onBlur = () => { - clearHoldTimer(); - gestureState = reduceShiftJumpGesture(gestureState, { type: "reset" }).state; -}; - -let attached = false; - -const attach = () => { - if (attached) return; - attached = true; - document.addEventListener("keydown", onKeyDown, true); - document.addEventListener("keyup", onKeyUp, true); - window.addEventListener("blur", onBlur); -}; - -const detach = () => { - attached = false; - clearHoldTimer(); - document.removeEventListener("keydown", onKeyDown, true); - document.removeEventListener("keyup", onKeyUp, true); - window.removeEventListener("blur", onBlur); -}; - -export function subscribeToShiftTapGesture( - listener: ShiftTapGestureListener, -): () => void { - listeners.add(listener); - attach(); - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - detach(); - gestureState = createShiftJumpGestureState(); - } - }; -} - -/** - * Clears in-flight gesture state (armed/tap-gap tracking). Used by both a - * consumer that just exited via a means other than the gesture itself (ESC) - * and by tests resetting between cases. - */ -export function resetSharedShiftTapGesture(): void { - clearHoldTimer(); - gestureState = createShiftJumpGestureState(); -} diff --git a/packages/web/src/shortcuts/shortcuts.registry.test.ts b/packages/web/src/shortcuts/shortcuts.registry.test.ts index 5e7c2468f..913bbbe38 100644 --- a/packages/web/src/shortcuts/shortcuts.registry.test.ts +++ b/packages/web/src/shortcuts/shortcuts.registry.test.ts @@ -62,7 +62,7 @@ describe("shortcuts.registry", () => { expect(ids).toContain("edit-save"); }); - it("lists Shift event jump toggle in day and week focus sections", () => { + it("lists s event jump toggle in day and week focus sections", () => { for (const view of ["day", "week"] as const) { const shortcuts = filterShortcutsByContext({ view, @@ -80,7 +80,7 @@ describe("shortcuts.registry", () => { expect(life).not.toContain("focus-shift-hold"); }); - it("lists SHIFT-SHIFT keyboard-only mode in every view's other section", () => { + it("lists h keyboard-only mode in every view's other section", () => { for (const view of ["day", "week", "life"] as const) { const ids = filterShortcutsByContext({ view, diff --git a/packages/web/src/shortcuts/shortcuts.registry.ts b/packages/web/src/shortcuts/shortcuts.registry.ts index 2fe0f24da..34c1aec97 100644 --- a/packages/web/src/shortcuts/shortcuts.registry.ts +++ b/packages/web/src/shortcuts/shortcuts.registry.ts @@ -130,7 +130,7 @@ export const SHORTCUTS_REGISTRY: Shortcut[] = [ }, { id: "focus-shift-hold", - keys: ["Shift"], + keys: ["s"], label: "Toggle event jump keys", section: "focus", }, @@ -365,7 +365,7 @@ export const SHORTCUTS_REGISTRY: Shortcut[] = [ }, { id: "other-keyboard-only", - keys: ["Shift", "Shift"], + keys: ["h"], label: "Toggle Hardcore Mode", section: "other", }, diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts b/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts index 52d64c681..70f132021 100644 --- a/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts +++ b/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts @@ -17,7 +17,7 @@ describe("getTipPlainText", () => { "Hold Shift and press an arrow to move this event", ); expect(plainTextById["target-event"]).toBe( - "Tap Shift to jump to any visible event", + "Tap S to jump to any visible event", ); expect(plainTextById["edge-cycle"]).toBe( "Press Tab to move between start and end", diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.data.ts b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts index 4869a120b..17ef19bbf 100644 --- a/packages/web/src/shortcuts/tips/shortcut-tips.data.ts +++ b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts @@ -39,7 +39,7 @@ export function getShortcutTips(): ShortcutTip[] { }, { id: "target-event", - parts: ["Tap ", { key: "Shift" }, " to jump to any visible event"], + parts: ["Tap ", { key: "S" }, " to jump to any visible event"], }, { id: "edge-cycle", diff --git a/packages/web/src/shortcuts/useEditSequenceShortcut.ts b/packages/web/src/shortcuts/useEditSequenceShortcut.ts index a52a9137a..b8f54112a 100644 --- a/packages/web/src/shortcuts/useEditSequenceShortcut.ts +++ b/packages/web/src/shortcuts/useEditSequenceShortcut.ts @@ -21,6 +21,16 @@ export type EditSequenceSecondKey = keyof typeof EDIT_SEQUENCE_FIELDS; const ARM_WINDOW_MS = 600; const LEADER_KEY = "e"; +/** Shared so other letter shortcuts (e.g. event-jump `s`) can yield to `e`… sequences. */ +let editSequenceArmedUntil = 0; + +export const isEditSequenceArmed = () => editSequenceArmedUntil > Date.now(); + +/** Test helper: clear the shared arm window. */ +export const resetEditSequenceArm = () => { + editSequenceArmedUntil = 0; +}; + const hasModifier = (event: KeyboardEvent) => event.metaKey || event.ctrlKey || event.altKey || event.shiftKey; @@ -38,12 +48,11 @@ export function useEditSequenceShortcut({ onSequenceRef.current = onSequence; useEffect(() => { - let armedUntil = 0; let armTimeoutId: ReturnType | null = null; const suppressKeyUp = new Set(); const disarm = () => { - armedUntil = 0; + editSequenceArmedUntil = 0; if (armTimeoutId !== null) { clearTimeout(armTimeoutId); armTimeoutId = null; @@ -52,15 +61,13 @@ export function useEditSequenceShortcut({ const arm = () => { disarm(); - armedUntil = Date.now() + ARM_WINDOW_MS; + editSequenceArmedUntil = Date.now() + ARM_WINDOW_MS; armTimeoutId = setTimeout(() => { - armedUntil = 0; + editSequenceArmedUntil = 0; armTimeoutId = null; }, ARM_WINDOW_MS); }; - const isArmed = () => armedUntil > Date.now(); - const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if (isAppLocked()) { @@ -78,7 +85,7 @@ export function useEditSequenceShortcut({ const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; - if (isArmed()) { + if (isEditSequenceArmed()) { const field = key in EDIT_SEQUENCE_FIELDS ? EDIT_SEQUENCE_FIELDS[key as EditSequenceSecondKey] diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx index 428d61b0a..3937011bf 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx @@ -313,6 +313,9 @@ const setDraftEvent = (event: CompassEvent) => { beforeEach(() => { seededEvents = []; + // Pin midday so keyboardPlace drafts seeded from the current hour stay + // inside a single calendar day (overnight drafts hit save/map edge cases). + setSystemTime(new Date("2026-05-20T12:00:00.000Z")); }); afterEach(() => { diff --git a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx index 055cd02c2..ad4785714 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx @@ -33,7 +33,15 @@ import { } from "@web/grid/shortcuts/edge-focus.store"; import { dayEventRegistry } from "@web/views/Day/interaction/registry/day-event.registry"; import { useDayEventNudgeShortcuts } from "./useDayEventNudgeShortcuts"; -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + setSystemTime, +} from "bun:test"; const TIMED_EVENT_ID = "aaaaaaaaaaaaaaaaaaaaaaaa"; const LATER_TIMED_EVENT_ID = "cccccccccccccccccccccccc"; @@ -203,6 +211,9 @@ const getCreateMutation = ( .find((mutation) => mutation.options.mutationKey?.[2] === "create"); beforeEach(() => { + // Pin midday so keyboardPlace drafts (seeded from dayjs().hour()) do not + // land near midnight and reject the next 15-minute Shift+Arrow move. + setSystemTime(new Date("2026-05-20T12:00:00.000Z")); HotkeyManager.resetInstance(); draftActions.discard(); useEdgeFocusStore.setState(initialEdgeFocusState, true); @@ -214,6 +225,7 @@ afterEach(() => { draftActions.discard(); useEdgeFocusStore.setState(initialEdgeFocusState, true); document.body.innerHTML = ""; + setSystemTime(); }); describe("useDayEventNudgeShortcuts", () => {