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
4 changes: 2 additions & 2 deletions e2e/timed/keyboard-only-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", asy
await tapShift(page);
await tapShift(page);

await expect(keyboardOnlyIndicator(page)).toContainText("Keyboard only");
await expect(keyboardOnlyIndicator(page)).toContainText("Esc or Shift Shift");
await expect(keyboardOnlyIndicator(page)).toContainText("Hardcore Mode");
await expect(keyboardOnlyIndicator(page)).toContainText("Esc");

// Shortcuts overlay stays mounted with role=dialog even when closed, so
// assert the event form did not open rather than dialog count.
Expand Down
23 changes: 23 additions & 0 deletions packages/web/src/common/utils/event/event-nudge.util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dayjs from "@core/util/date/dayjs";
import {
convertAllDayToTimedDates,
getArrowKeyMovement,
isTimedEventFullCalendarDay,
isTimedEventInsideOneDay,
Expand Down Expand Up @@ -42,6 +43,28 @@ describe("getArrowKeyMovement", () => {
});
});

describe("convertAllDayToTimedDates", () => {
it("places the event at the given start minute on its start day with a 60-minute duration", () => {
const result = convertAllDayToTimedDates(
{ startDate: "2026-05-20" },
9 * 60,
);

expect(result.startDate).toStartWith("2026-05-20T09:00:00");
expect(result.endDate).toStartWith("2026-05-20T10:00:00");
});

it("only reads the event's start day, so a multi-day span collapses onto it", () => {
const result = convertAllDayToTimedDates(
{ startDate: "2026-05-20" },
13 * 60 + 30,
);

expect(result.startDate).toStartWith("2026-05-20T13:30:00");
expect(result.endDate).toStartWith("2026-05-20T14:30:00");
});
});

describe("nudgeEventDates", () => {
const timedEvent = {
startDate: "2026-05-20T10:00:00",
Expand Down
25 changes: 25 additions & 0 deletions packages/web/src/common/utils/event/event-nudge.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ export const getArrowKeyMovement = (
}
};

// Mirrors CROSS_ROW_TIMED_DURATION_MIN (grid/interaction/math/cross-row.drag.ts),
// the duration invented when a mouse drag converts an all-day event into the
// timed grid. Kept as a local constant to avoid a common-utils -> grid/interaction
// dependency for one shared number.
const CONVERTED_TIMED_DURATION_MIN = 60;

/**
* All-day -> timed via Shift+ArrowDown. Mirrors the drag conversion: start of
* day plus a caller-supplied visible start minute, fixed duration. A
* multi-day span collapses onto its start day - the keyboard has no drop
* column to say otherwise.
*/
export const convertAllDayToTimedDates = (
event: Pick<CompassEvent, "startDate">,
startMinute: number,
): { startDate: string; endDate: string } => {
const start = dayjs(event.startDate)
.startOf("day")
.add(startMinute, "minute");
return {
startDate: start.format(),
endDate: start.add(CONVERTED_TIMED_DURATION_MIN, "minute").format(),
};
};

export const isTimedEventInsideOneDay = (start: Dayjs, end: Dayjs) => {
const midnightAfterStart = start.add(1, "day").startOf("day");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("getNavigationCommandItems", () => {
"Go to Week",
"Go to Life",
"Show shortcuts",
"Toggle keyboard-only mode",
"Toggle Hardcore Mode",
]);
});

Expand All @@ -52,7 +52,7 @@ describe("getNavigationCommandItems", () => {
"Go to Day",
"Go to Week",
"Go to Life",
"Toggle keyboard-only mode",
"Toggle Hardcore Mode",
]);
});

Expand All @@ -77,11 +77,7 @@ describe("getNavigationCommandItems", () => {
onNavigateToView: () => {},
}).map((item) => item.label);

expect(labels).toEqual([
"Go to Day",
"Go to Week",
"Toggle keyboard-only mode",
]);
expect(labels).toEqual(["Go to Day", "Go to Week", "Toggle Hardcore Mode"]);
});

it("runs the matching navigation callbacks", () => {
Expand Down Expand Up @@ -137,7 +133,7 @@ describe("getNavigationCommandItems", () => {
onNavigateToView: () => {},
}).find((entry) => entry.id === "enter-keyboard-only");

expect(item?.label).toBe("Toggle keyboard-only mode");
expect(item?.label).toBe("Toggle Hardcore Mode");
expect(item?.shortcut).toEqual(["Shift", "Shift"]);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,17 @@ export const getNavigationCommandItems = ({

calendarItems.push({
id: "enter-keyboard-only",
label: "Toggle keyboard-only mode",
label: "Toggle Hardcore Mode",
icon: KeyboardIcon,
shortcut: ["Shift", "Shift"],
keywords: ["keyboard", "clicks", "pointer", "mouseless", "hotkeys"],
keywords: [
"keyboard",
"hardcore",
"clicks",
"pointer",
"mouseless",
"hotkeys",
],
// Defer so the palette closes before click-blocking installs.
onClick: () =>
queueMicrotask(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ export const DemoEventsBanner: FC<DemoEventsBannerProps> = ({ onDismiss }) => (
role="status"
>
<span>
Sample events to help you explore. Edit yourself or clear all from the cmd
palette.
Sample events to help you explore. Edit or clear from the cmd palette.
</span>
<button
className="c-focus-ring shrink-0 rounded-xs px-2 py-1 text-text hover:bg-surface-overlay"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@ describe("onboarding tour steps", () => {
expect(shortcuts?.shortcutHint).toBe("?");
});

it("points the finale at keyboard-only practice", () => {
it("points the finale at Hardcore Mode practice", () => {
const done = getOnboardingTourSteps().find((step) => step.id === "done");

expect(done?.body).toMatch(/anything with the keyboard/i);
expect(done?.body).toMatch(/Shift Shift/i);
expect(done?.body).toMatch(/Hardcore Mode/i);
expect(done?.body).toMatch(/clicks/i);
expect(done?.body).toMatch(/command palette/i);
expect(done?.shortcutHint).toEqual(["Shift", "Shift"]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
},
done: {
title: "You are ready",
body: "You can do anything with the keyboard. Try Shift Shift to practice; clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.",
body: "You can do anything with the keyboard. Try Shift Shift to enter Hardcore Mode; clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.",
shortcutHint: ["Shift", "Shift"],
},
};
Expand Down
6 changes: 2 additions & 4 deletions packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@ export function WelcomeGuideBody() {
<>
<div className="flex flex-col gap-2">
<h2 className="font-bold text-2xl text-text leading-snug">
The best place to manage your schedule at the keyboard.
The keyboard-first calendar
</h2>
<p className="text-text-muted">
Move fast, stay focused, and never reach for the mouse. Even
scheduling itself is quicker from the keyboard than any other
calendar.
Rediscover the joy of shortcuts as you build your perfect schedule.
</p>
</div>

Expand Down
45 changes: 41 additions & 4 deletions packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,10 @@ describe("WelcomeModal", () => {

expect(
screen.getByRole("heading", {
name: "The best place to manage your schedule at the keyboard.",
name: "The keyboard-first calendar",
}),
).toBeTruthy();
expect(
screen.getByText(/Move fast, stay focused, and never reach for/),
).toBeTruthy();
expect(screen.getByText(/Rediscover the joy of shortcuts/)).toBeTruthy();
expect(screen.getByRole("img", { name: /pixel pirate/i })).toBeTruthy();
expect(screen.getByText("No signup required")).toBeTruthy();
});
Expand Down Expand Up @@ -178,6 +176,45 @@ describe("WelcomeModal", () => {
expect(answer).toHaveAttribute("data-state", "closed");
});

it("opens sign up with the U shortcut", async () => {
const user = userEvent.setup();
render(<WelcomeModal />);

await user.keyboard("u");

expect(mockOpenModal).toHaveBeenCalledWith("signUp");
expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBe("true");
});

it("opens log in with the I shortcut", async () => {
const user = userEvent.setup();
render(<WelcomeModal />);

await user.keyboard("i");

expect(mockOpenModal).toHaveBeenCalledWith("login");
expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBe("true");
});

it("dismisses with the S shortcut", async () => {
const user = userEvent.setup();
render(<WelcomeModal />);

await user.keyboard("s");

expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBe("true");
});

it("ignores the shortcut keys when a modifier is held", async () => {
const user = userEvent.setup();
render(<WelcomeModal />);

await user.keyboard("{Meta>}u{/Meta}");

expect(mockOpenModal).not.toHaveBeenCalled();
expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBeNull();
});

it("focuses the first control and keeps Tab inside the dialog", async () => {
const user = userEvent.setup();

Expand Down
28 changes: 24 additions & 4 deletions packages/web/src/components/WelcomeModal/WelcomeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useDismissTransition } from "@web/common/hooks/useDismissTransition";
import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal";
import { onboardingTourActions } from "@web/components/OnboardingTour/onboarding.tour.store";
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { PixelPirate } from "./PixelPirate";
import { WelcomeGuideBody } from "./WelcomeGuideBody";
import { hasSeenWelcome, markWelcomeSeen } from "./welcome.modal.util";
Expand Down Expand Up @@ -66,6 +67,21 @@ export function WelcomeModal() {
beginDismiss(() => setIsOpen(false));
};

const handleShortcutKey = (e: React.KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key.toLowerCase();
if (key === "u") {
e.preventDefault();
handOffToAuth("sign_up");
} else if (key === "i") {
e.preventDefault();
handOffToAuth("log_in");
} else if (key === "s") {
e.preventDefault();
dismiss("start_now");
}
};

const handOffToAuth = (cta: "log_in" | "sign_up") => {
skipFocusRestoreRef.current = true;
markWelcomeSeen();
Expand All @@ -89,7 +105,8 @@ export function WelcomeModal() {
skipFocusRestoreRef={skipFocusRestoreRef}
widthClassName="w-120"
>
<div className="flex w-full flex-col gap-6">
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown here is a modal-scoped shortcut layer, not an interactive element in its own right */}
<div className="flex w-full flex-col gap-6" onKeyDown={handleShortcutKey}>
{/* Top row: pirate top-left, auth pills top-right */}
<div className="flex items-center justify-between">
<div className="group relative flex items-center">
Expand All @@ -108,16 +125,18 @@ export function WelcomeModal() {
<button
type="button"
onClick={() => handOffToAuth("sign_up")}
className="rounded-3xl bg-accent px-4 py-1.5 text-on-accent text-xs transition-all hover:brightness-110"
className="inline-flex items-center rounded-3xl bg-accent px-4 py-1.5 text-on-accent text-xs transition-all hover:brightness-110"
>
Sign up
<ShortcutHint className="ml-2">U</ShortcutHint>
</button>
<button
type="button"
onClick={() => handOffToAuth("log_in")}
className="rounded-3xl bg-[#c2c6cc] px-4 py-1.5 text-[#1f1f1f] text-xs transition-all hover:bg-[#d1d5da]"
className="inline-flex items-center rounded-3xl bg-[#c2c6cc] px-4 py-1.5 text-[#1f1f1f] text-xs transition-all hover:bg-[#d1d5da]"
>
Log in
<ShortcutHint className="ml-2">I</ShortcutHint>
</button>
</div>
</div>
Expand All @@ -129,9 +148,10 @@ export function WelcomeModal() {
<button
type="button"
onClick={() => dismiss("start_now")}
className="c-button c-button-primary c-button-elevated rounded-full px-10"
className="c-button c-button-primary c-button-elevated inline-flex items-center rounded-full px-10"
>
Start Now
<ShortcutHint className="ml-2">S</ShortcutHint>
</button>
</div>

Expand Down
19 changes: 19 additions & 0 deletions packages/web/src/grid/shortcuts/useGridEventEditShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
} from "@web/calendars/useCalendarLookup";
import { ID_SIDEBAR } from "@web/common/constants/web.constants";
import { type GridEvent } from "@web/common/types/web.event.types";
import { getVisibleGridStartMinute } from "@web/common/utils/draft/draft.util";
import { refocusEventElement } from "@web/common/utils/event/event.util";
import {
convertAllDayToTimedDates,
type EventEdge,
getArrowKeyMovement,
} from "@web/common/utils/event/event-nudge.util";
Expand Down Expand Up @@ -47,6 +50,9 @@ import {
import { useAppShortcut } from "@web/shortcuts/useAppShortcut";
import { deleteEventAndDiscardDraft } from "@web/views/Forms/hooks/useDeleteEvent";

// Fallback when the grid can't be measured, matching EventForm's all-day->timed toggle.
const DEFAULT_TIMED_START_MINUTE = 9 * 60;

const DRAFT_MOVEMENT_HOTKEY_OPTIONS = {
ignoreInputs: false,
preventDefault: false,
Expand Down Expand Up @@ -250,6 +256,19 @@ export function useGridEventEditShortcuts({
return;
}

if (event.isAllDay && keyboardEvent.key === "ArrowDown") {
if (!event._id) return;
keyboardEvent.preventDefault();
const startMinute =
getVisibleGridStartMinute() ?? DEFAULT_TIMED_START_MINUTE;
const dates = convertAllDayToTimedDates(event, startMinute);
updateEvent({ event: { ...event, ...dates, isAllDay: false } }, true, {
onOptimisticApplied: () => draftActions.discard(),
});
refocusEventElement(event._id);
return;
}

const movement = getArrowKeyMovement(
keyboardEvent.key,
Boolean(event.isAllDay),
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/shortcuts/data/shortcuts.data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ describe("shortcuts.data", () => {
});
expect(stripMetadata(other?.shortcuts ?? [])).toContainEqual({
keys: ["Shift", "Shift"],
label: "Toggle keyboard-only mode",
label: "Toggle Hardcore Mode",
});
expect(stripMetadata(other?.shortcuts ?? [])).toContainEqual({
keys: ["Mod", "Shift", "Z"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const KeyboardOnlyIndicator: FC = () => {
data-keyboard-only-indicator=""
role="status"
>
Keyboard only · Esc or Shift Shift
Hardcore Mode · Esc
</span>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const EventJumpIndicator: FC = () => {
? announcement
: announcement && announcement !== "Event jump on"
? `Jump · ${announcement} · Esc`
: "Event jump · Esc or Shift";
: "Event jump · Esc";

return (
<span
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/shortcuts/shortcuts.registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export const SHORTCUTS_REGISTRY: Shortcut[] = [
{
id: "other-keyboard-only",
keys: ["Shift", "Shift"],
label: "Toggle keyboard-only mode",
label: "Toggle Hardcore Mode",
section: "other",
},
];
Expand Down
Loading
Loading