Skip to content

Commit ec78005

Browse files
authored
feat(web): onboarding polish - welcome shortcuts, Hardcore Mode, all-day nudge convert (#2752)
* feat(web): onboarding polish - welcome shortcuts, Hardcore Mode, all-day nudge convert - Welcome dialog: u/i/s shortcuts for Sign up/Log in/Start Now, with keycap hints - Rename keyboard-only mode copy to Hardcore Mode; sidebar advertises only Esc - Render key names in shortcut tip text as keycaps instead of plain words - Shift+ArrowDown on a focused all-day event converts it to a 60-minute timed event - Refresh welcome heading/body copy and demo-events banner copy * fix(e2e): update keyboard-only-mode spec for Hardcore Mode copy rename
1 parent 651a3e2 commit ec78005

20 files changed

Lines changed: 276 additions & 45 deletions

e2e/timed/keyboard-only-mode.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", asy
4141
await tapShift(page);
4242
await tapShift(page);
4343

44-
await expect(keyboardOnlyIndicator(page)).toContainText("Keyboard only");
45-
await expect(keyboardOnlyIndicator(page)).toContainText("Esc or Shift Shift");
44+
await expect(keyboardOnlyIndicator(page)).toContainText("Hardcore Mode");
45+
await expect(keyboardOnlyIndicator(page)).toContainText("Esc");
4646

4747
// Shortcuts overlay stays mounted with role=dialog even when closed, so
4848
// assert the event form did not open rather than dialog count.

packages/web/src/common/utils/event/event-nudge.util.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dayjs from "@core/util/date/dayjs";
22
import {
3+
convertAllDayToTimedDates,
34
getArrowKeyMovement,
45
isTimedEventFullCalendarDay,
56
isTimedEventInsideOneDay,
@@ -42,6 +43,28 @@ describe("getArrowKeyMovement", () => {
4243
});
4344
});
4445

46+
describe("convertAllDayToTimedDates", () => {
47+
it("places the event at the given start minute on its start day with a 60-minute duration", () => {
48+
const result = convertAllDayToTimedDates(
49+
{ startDate: "2026-05-20" },
50+
9 * 60,
51+
);
52+
53+
expect(result.startDate).toStartWith("2026-05-20T09:00:00");
54+
expect(result.endDate).toStartWith("2026-05-20T10:00:00");
55+
});
56+
57+
it("only reads the event's start day, so a multi-day span collapses onto it", () => {
58+
const result = convertAllDayToTimedDates(
59+
{ startDate: "2026-05-20" },
60+
13 * 60 + 30,
61+
);
62+
63+
expect(result.startDate).toStartWith("2026-05-20T13:30:00");
64+
expect(result.endDate).toStartWith("2026-05-20T14:30:00");
65+
});
66+
});
67+
4568
describe("nudgeEventDates", () => {
4669
const timedEvent = {
4770
startDate: "2026-05-20T10:00:00",

packages/web/src/common/utils/event/event-nudge.util.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,31 @@ export const getArrowKeyMovement = (
2929
}
3030
};
3131

32+
// Mirrors CROSS_ROW_TIMED_DURATION_MIN (grid/interaction/math/cross-row.drag.ts),
33+
// the duration invented when a mouse drag converts an all-day event into the
34+
// timed grid. Kept as a local constant to avoid a common-utils -> grid/interaction
35+
// dependency for one shared number.
36+
const CONVERTED_TIMED_DURATION_MIN = 60;
37+
38+
/**
39+
* All-day -> timed via Shift+ArrowDown. Mirrors the drag conversion: start of
40+
* day plus a caller-supplied visible start minute, fixed duration. A
41+
* multi-day span collapses onto its start day - the keyboard has no drop
42+
* column to say otherwise.
43+
*/
44+
export const convertAllDayToTimedDates = (
45+
event: Pick<CompassEvent, "startDate">,
46+
startMinute: number,
47+
): { startDate: string; endDate: string } => {
48+
const start = dayjs(event.startDate)
49+
.startOf("day")
50+
.add(startMinute, "minute");
51+
return {
52+
startDate: start.format(),
53+
endDate: start.add(CONVERTED_TIMED_DURATION_MIN, "minute").format(),
54+
};
55+
};
56+
3257
export const isTimedEventInsideOneDay = (start: Dayjs, end: Dayjs) => {
3358
const midnightAfterStart = start.add(1, "day").startOf("day");
3459

packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("getNavigationCommandItems", () => {
2525
"Go to Week",
2626
"Go to Life",
2727
"Show shortcuts",
28-
"Toggle keyboard-only mode",
28+
"Toggle Hardcore Mode",
2929
]);
3030
});
3131

@@ -52,7 +52,7 @@ describe("getNavigationCommandItems", () => {
5252
"Go to Day",
5353
"Go to Week",
5454
"Go to Life",
55-
"Toggle keyboard-only mode",
55+
"Toggle Hardcore Mode",
5656
]);
5757
});
5858

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

80-
expect(labels).toEqual([
81-
"Go to Day",
82-
"Go to Week",
83-
"Toggle keyboard-only mode",
84-
]);
80+
expect(labels).toEqual(["Go to Day", "Go to Week", "Toggle Hardcore Mode"]);
8581
});
8682

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

140-
expect(item?.label).toBe("Toggle keyboard-only mode");
136+
expect(item?.label).toBe("Toggle Hardcore Mode");
141137
expect(item?.shortcut).toEqual(["Shift", "Shift"]);
142138
});
143139
});

packages/web/src/components/CommandPalette/navigation.cmd.constants.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,17 @@ export const getNavigationCommandItems = ({
124124

125125
calendarItems.push({
126126
id: "enter-keyboard-only",
127-
label: "Toggle keyboard-only mode",
127+
label: "Toggle Hardcore Mode",
128128
icon: KeyboardIcon,
129129
shortcut: ["Shift", "Shift"],
130-
keywords: ["keyboard", "clicks", "pointer", "mouseless", "hotkeys"],
130+
keywords: [
131+
"keyboard",
132+
"hardcore",
133+
"clicks",
134+
"pointer",
135+
"mouseless",
136+
"hotkeys",
137+
],
131138
// Defer so the palette closes before click-blocking installs.
132139
onClick: () =>
133140
queueMicrotask(() => {

packages/web/src/components/DemoEventsBanner/DemoEventsBanner.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ export const DemoEventsBanner: FC<DemoEventsBannerProps> = ({ onDismiss }) => (
2828
role="status"
2929
>
3030
<span>
31-
Sample events to help you explore. Edit yourself or clear all from the cmd
32-
palette.
31+
Sample events to help you explore. Edit or clear from the cmd palette.
3332
</span>
3433
<button
3534
className="c-focus-ring shrink-0 rounded-xs px-2 py-1 text-text hover:bg-surface-overlay"

packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,12 @@ describe("onboarding tour steps", () => {
8686
expect(shortcuts?.shortcutHint).toBe("?");
8787
});
8888

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

9292
expect(done?.body).toMatch(/anything with the keyboard/i);
9393
expect(done?.body).toMatch(/Shift Shift/i);
94+
expect(done?.body).toMatch(/Hardcore Mode/i);
9495
expect(done?.body).toMatch(/clicks/i);
9596
expect(done?.body).toMatch(/command palette/i);
9697
expect(done?.shortcutHint).toEqual(["Shift", "Shift"]);

packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
8989
},
9090
done: {
9191
title: "You are ready",
92-
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.",
92+
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.",
9393
shortcutHint: ["Shift", "Shift"],
9494
},
9595
};

packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,10 @@ export function WelcomeGuideBody() {
2525
<>
2626
<div className="flex flex-col gap-2">
2727
<h2 className="font-bold text-2xl text-text leading-snug">
28-
The best place to manage your schedule at the keyboard.
28+
The keyboard-first calendar
2929
</h2>
3030
<p className="text-text-muted">
31-
Move fast, stay focused, and never reach for the mouse. Even
32-
scheduling itself is quicker from the keyboard than any other
33-
calendar.
31+
Rediscover the joy of shortcuts as you build your perfect schedule.
3432
</p>
3533
</div>
3634

packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,10 @@ describe("WelcomeModal", () => {
6666

6767
expect(
6868
screen.getByRole("heading", {
69-
name: "The best place to manage your schedule at the keyboard.",
69+
name: "The keyboard-first calendar",
7070
}),
7171
).toBeTruthy();
72-
expect(
73-
screen.getByText(/Move fast, stay focused, and never reach for/),
74-
).toBeTruthy();
72+
expect(screen.getByText(/Rediscover the joy of shortcuts/)).toBeTruthy();
7573
expect(screen.getByRole("img", { name: /pixel pirate/i })).toBeTruthy();
7674
expect(screen.getByText("No signup required")).toBeTruthy();
7775
});
@@ -178,6 +176,45 @@ describe("WelcomeModal", () => {
178176
expect(answer).toHaveAttribute("data-state", "closed");
179177
});
180178

179+
it("opens sign up with the U shortcut", async () => {
180+
const user = userEvent.setup();
181+
render(<WelcomeModal />);
182+
183+
await user.keyboard("u");
184+
185+
expect(mockOpenModal).toHaveBeenCalledWith("signUp");
186+
expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBe("true");
187+
});
188+
189+
it("opens log in with the I shortcut", async () => {
190+
const user = userEvent.setup();
191+
render(<WelcomeModal />);
192+
193+
await user.keyboard("i");
194+
195+
expect(mockOpenModal).toHaveBeenCalledWith("login");
196+
expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBe("true");
197+
});
198+
199+
it("dismisses with the S shortcut", async () => {
200+
const user = userEvent.setup();
201+
render(<WelcomeModal />);
202+
203+
await user.keyboard("s");
204+
205+
expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBe("true");
206+
});
207+
208+
it("ignores the shortcut keys when a modifier is held", async () => {
209+
const user = userEvent.setup();
210+
render(<WelcomeModal />);
211+
212+
await user.keyboard("{Meta>}u{/Meta}");
213+
214+
expect(mockOpenModal).not.toHaveBeenCalled();
215+
expect(localStorage.getItem(STORAGE_KEYS.HAS_SEEN_WELCOME)).toBeNull();
216+
});
217+
181218
it("focuses the first control and keeps Tab inside the dialog", async () => {
182219
const user = userEvent.setup();
183220

0 commit comments

Comments
 (0)