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
21 changes: 16 additions & 5 deletions e2e/onboarding/shortcut-showcase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ const leaveWelcome = async (page: Page) => {
await expect(welcomeDialog).toBeHidden();
};

const startLevelOne = async (page: Page) => {
const showcase = page.getByRole("region", { name: "Shortcut practice" });
await expect(showcase).toContainText("Compass is keyboard-only");
await page.keyboard.press("Enter");
await expect(showcase).toContainText("Drop an event on the board");
await expect(showcase).toContainText("Level 1/6");
};

const holdModAndPress = async (page: Page, key: string) => {
// Linux CI resolves Mod to Control; macOS to Meta. Hold both so the
// platform-specific hold tracker and the chord check both fire.
Expand Down Expand Up @@ -63,18 +71,20 @@ test("exploring without an account starts the practice", async ({ page }) => {
await expect(
page.getByRole("region", { name: "Shortcut practice" }),
).toBeVisible();
await expect(
page.getByRole("region", { name: "Shortcut practice" }),
).toContainText("Compass is keyboard-only");
});

test("the welcome-started practice runs the taught keys through graduation", async ({
page,
}) => {
await page.goto("/week", { waitUntil: "domcontentloaded" });
await leaveWelcome(page);
await startLevelOne(page);

const showcase = page.getByRole("region", { name: "Shortcut practice" });
await expect(showcase).toContainText("Drop an event on the board");
await expect(showcase).toContainText("Press C to start a new event.");
await expect(showcase).toContainText("Level 1/6");
await expect(showcase.getByRole("button", { name: /^Skip$/ })).toBeVisible();

await playThroughLevels(page);
Expand Down Expand Up @@ -102,6 +112,7 @@ test("taking the notifications offer opts in and moves on", async ({
await context.grantPermissions(["notifications"]);
await page.goto("/week", { waitUntil: "domcontentloaded" });
await leaveWelcome(page);
await startLevelOne(page);

const showcase = page.getByRole("region", { name: "Shortcut practice" });
await playThroughLevels(page);
Expand All @@ -119,14 +130,14 @@ test("taking the notifications offer opts in and moves on", async ({
.toBe("true");
});

test("Skip to sign up leaves the practice for the signup form on step 1", async ({
test("Skip to sign up leaves the practice for the signup form on the intro", async ({
page,
}) => {
await page.goto("/week", { waitUntil: "domcontentloaded" });
await leaveWelcome(page);

const showcase = page.getByRole("region", { name: "Shortcut practice" });
await expect(showcase).toContainText("Drop an event on the board");
await expect(showcase).toContainText("Compass is keyboard-only");

await expect(
showcase.getByRole("button", { name: /Skip to sign up/ }),
Expand All @@ -143,7 +154,7 @@ test("Escape leaves the practice and it never auto-replays", async ({
await leaveWelcome(page);

const showcase = page.getByRole("region", { name: "Shortcut practice" });
await expect(showcase).toContainText("Drop an event on the board");
await expect(showcase).toContainText("Compass is keyboard-only");

await page.keyboard.press("Escape");
await expect(showcase).toHaveCount(0);
Expand Down
33 changes: 33 additions & 0 deletions packages/web/src/components/PointerHint/PointerHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
shortcutShowcaseActions,
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import { welcomeGuideActions } from "@web/components/WelcomeModal/welcome.guide.store";
import { POINTER_ACTIONS } from "@web/shortcuts/keyboard-only/pointer-action";
import {
initialPointerBlockState,
Expand All @@ -25,13 +26,17 @@ describe("PointerHint", () => {
usePointerBlockStore.setState(initialPointerBlockState, true);
useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true);
useEventJumpStore.setState(initialEventJumpState, true);
welcomeGuideActions.setFirstVisitOpen(false);
welcomeGuideActions.close();
sessionStorage.removeItem(HINT_COUNT_KEY);
});

afterEach(() => {
usePointerBlockStore.setState(initialPointerBlockState, true);
useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true);
useEventJumpStore.setState(initialEventJumpState, true);
welcomeGuideActions.setFirstVisitOpen(false);
welcomeGuideActions.close();
sessionStorage.removeItem(HINT_COUNT_KEY);
});

Expand All @@ -48,6 +53,34 @@ describe("PointerHint", () => {
);
});

it("omits the legend hint while the welcome modal is open", () => {
welcomeGuideActions.setFirstVisitOpen(true);
render(<PointerHint />);

act(() => {
pointerBlockActions.pulseBlockedClick();
});

expect(screen.getByRole("status")).toHaveTextContent(
"Compass is keyboard only.",
);
expect(screen.getByRole("status")).not.toHaveTextContent("Press");
});

it("omits the legend hint while the welcome guide is open", () => {
welcomeGuideActions.open();
render(<PointerHint />);

act(() => {
pointerBlockActions.pulseBlockedClick();
});

expect(screen.getByRole("status")).toHaveTextContent(
"Compass is keyboard only.",
);
expect(screen.getByRole("status")).not.toHaveTextContent("Press");
});

it("points at the on-screen keys while the showcase is active", () => {
shortcutShowcaseActions.replay();
render(<PointerHint />);
Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/components/PointerHint/PointerHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
selectShowcaseActive,
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import {
selectWelcomeSurfaceOpen,
useWelcomeGuideStore,
} from "@web/components/WelcomeModal/welcome.guide.store";
import {
type BlockedPointerAttempt,
POINTER_ACTIONS,
Expand Down Expand Up @@ -53,11 +57,13 @@ const pointerHintMessage = ({
eventJumpKey,
isBrief,
showcaseActive,
welcomeOpen,
}: {
attempt: BlockedPointerAttempt | null;
eventJumpKey: string | null;
isBrief: boolean;
showcaseActive: boolean;
welcomeOpen: boolean;
}): ReactNode => {
if (showcaseActive) return "Keyboard only. Follow the keys on screen.";

Expand Down Expand Up @@ -92,6 +98,8 @@ const pointerHintMessage = ({

if (isBrief) return "Keyboard only.";

if (welcomeOpen) return "Compass is keyboard only.";

return (
<>
Compass is keyboard only. Press <Key>?</Key> for shortcuts.
Expand All @@ -111,6 +119,7 @@ export const PointerHint: FC = () => {
const attempt = usePointerBlockStore(selectLatestPointerAttempt);
const eventJumpKey = useEventJumpStore(selectEventJumpPointerHintKey);
const showcaseActive = useShortcutShowcaseStore(selectShowcaseActive);
const welcomeOpen = useWelcomeGuideStore(selectWelcomeSurfaceOpen);
const [isVisible, setIsVisible] = useState(false);
const [isBrief, setIsBrief] = useState(false);

Expand Down Expand Up @@ -146,6 +155,7 @@ export const PointerHint: FC = () => {
eventJumpKey,
isBrief,
showcaseActive,
welcomeOpen,
})}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ describe("ShortcutShowcase", () => {
expect(screen.getByLabelText("Shortcut practice")).toBeTruthy();
});

it("starts from welcome on the intro, and Enter opens level 1", () => {
render(<ShortcutShowcase />);
act(() => shortcutShowcaseActions.startFromWelcome());

expect(currentStepId()).toBe("intro");
expect(
screen.getByRole("heading", { name: "Compass is keyboard-only" }),
).toBeTruthy();
expect(
screen.getByText(/That takes a little practice to get the muscle memory/),
).toBeTruthy();
expect(screen.queryByText("Level 1/6")).toBeNull();
expect(
screen.getByRole("button", { name: "Start practicing" }),
).toBeTruthy();

pressKey("c");
expect(currentStepId()).toBe("intro");
expect(screen.queryByLabelText("Event title")).toBeNull();

pressKey("Enter");
expect(currentStepId()).toBe("create");
expect(screen.getByText("Level 1/6")).toBeTruthy();
expect(screen.getByText("Press C to start a new event.")).toBeTruthy();
});

it("teaches create as one motion, then continues to the next level", async () => {
const user = userEvent.setup();
render(<ShortcutShowcase />);
Expand Down
32 changes: 29 additions & 3 deletions packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ const arrowDirection = (key: string): PracticeNudgeDirection | null => {
* behavior is a deliberately simplified reimplementation against ephemeral
* practice state, so nothing here touches storage or the real grid stores.
*
* Levels teach create, hold-Mod jumps, event jump, nudge, the E-then-T
* edit sequence, and Cmd+K; graduation hands off to a prompt on the real
* calendar. Skip is always offered.
* An unnumbered intro gates first-time entry, then levels teach create,
* hold-Mod jumps, event jump, nudge, the E-then-T edit sequence, and Cmd+K.
* Graduation hands off to a prompt on the real calendar. Skip is always
* offered.
*/
const ShowcaseTakeover: FC = () => {
const stepIndex = useShortcutShowcaseStore(selectShowcaseStepIndex);
Expand Down Expand Up @@ -235,6 +236,19 @@ const ShowcaseTakeover: FC = () => {
return;
}

if (stepId === "intro") {
if (event.key === "Enter" && !event.repeat) {
const focusedButton =
event.target instanceof HTMLElement && event.target.closest("button");
if (!focusedButton) {
claim(event);
advance();
}
return;
}
if (!isBareLetterKey(event.nativeEvent, "u")) return;
}

if (stepId === "graduation" && event.key === "Enter") {
claim(event);
graduate();
Expand Down Expand Up @@ -392,6 +406,18 @@ const ShowcaseTakeover: FC = () => {
</p>
{step.keycaps && <ShortcutKeys keys={[...step.keycaps]} />}
<div className="flex flex-wrap items-center gap-2 pt-2">
{stepId === "intro" && (
<div className="flex items-center gap-2">
<button
type="button"
className={PRIMARY_BUTTON_CLASS}
onClick={advance}
>
Start practicing
</button>
<ShortcutHint className="shrink-0">Enter</ShortcutHint>
</div>
)}
{stepId === "graduation" ? (
<div className="flex items-center gap-2">
<button
Expand Down
12 changes: 9 additions & 3 deletions packages/web/src/components/ShortcutShowcase/showcase.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { type ShortcutTipPart } from "@web/shortcuts/tips/shortcut-tips.data";
* graduation hands off to a prompt on the real calendar. Skip is always
* offered: the practice is a game, not a gate.
*
* "notifications" rides in this list to get the step plumbing, but it is not
* a level: it asks for a browser permission rather than teaching a key, so
* it stays out of SHOWCASE_LEVEL_IDS and renders no "Level N/M" chip.
* "intro" and "notifications" ride in this list to get the step plumbing, but
* they are not levels: intro is the intentional start gate, and notifications
* asks for a browser permission rather than teaching a key. Both stay out of
* SHOWCASE_LEVEL_IDS and render no "Level N/M" chip.
*/
export type ShowcaseStep = {
id: string;
Expand All @@ -26,6 +27,11 @@ export type ShowcaseStep = {
};

export const SHOWCASE_STEPS = [
{
id: "intro",
title: "Compass is keyboard-only",
body: "No clicks — just shortcuts. That takes a little practice to get the muscle memory down. This sandbox is that practice, and nothing here is saved.",
},
{
id: "create",
title: "Drop an event on the board",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SHOWCASE_STEP_IDS } from "@web/components/ShortcutShowcase/showcase.ste
import {
initialShortcutShowcaseState,
shortcutShowcaseActions,
stepIdAt,
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
Expand All @@ -25,9 +26,10 @@ describe("shortcutShowcaseActions", () => {
});

it("starts and advances through every step, finishing at the end", () => {
shortcutShowcaseActions.replay();
shortcutShowcaseActions.startFromWelcome();
expect(useShortcutShowcaseStore.getState().isActive).toBe(true);
expect(useShortcutShowcaseStore.getState().stepIndex).toBe(0);
expect(stepIdAt(0)).toBe("intro");

for (let i = 1; i < SHOWCASE_STEP_IDS.length; i += 1) {
shortcutShowcaseActions.advance();
Expand All @@ -42,6 +44,14 @@ describe("shortcutShowcaseActions", () => {
).toBe("true");
});

it("replay skips the intro and opens the first lesson", () => {
shortcutShowcaseActions.replay();
expect(useShortcutShowcaseStore.getState().isActive).toBe(true);
expect(stepIdAt(useShortcutShowcaseStore.getState().stepIndex)).toBe(
"create",
);
});

it("never offers itself twice after signup, but replay always works", () => {
shortcutShowcaseActions.deferUntilSignup();
shortcutShowcaseActions.replay();
Expand All @@ -54,7 +64,9 @@ describe("shortcutShowcaseActions", () => {

shortcutShowcaseActions.replay();
expect(useShortcutShowcaseStore.getState().isActive).toBe(true);
expect(useShortcutShowcaseStore.getState().stepIndex).toBe(0);
expect(stepIdAt(useShortcutShowcaseStore.getState().stepIndex)).toBe(
"create",
);
});

it("treats legacy tour finishers as having seen the showcase", () => {
Expand Down Expand Up @@ -92,9 +104,12 @@ describe("shortcutShowcaseActions", () => {
expect(useShortcutShowcaseStore.getState().isActive).toBe(false);
});

it("starts from welcome without marking the practice seen", () => {
it("starts from welcome on the intro without marking the practice seen", () => {
shortcutShowcaseActions.startFromWelcome();
expect(useShortcutShowcaseStore.getState().isActive).toBe(true);
expect(stepIdAt(useShortcutShowcaseStore.getState().stepIndex)).toBe(
"intro",
);
expect(
persistentBrowserStore.get(STORAGE_KEYS.HAS_SEEN_SHORTCUT_SHOWCASE),
).not.toBe("true");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ const endShowcase = () => {
/** How the practice arena was opened, so the activation funnel can tell them apart. */
export type ShowcaseEntry = "welcome" | "post_signup" | "palette";

/** Palette replay skips the intro pep talk and opens the first lesson. */
const PALETTE_START_INDEX = SHOWCASE_STEP_IDS.indexOf("create");

const activate = (entry: ShowcaseEntry) => {
useShortcutShowcaseStore.setState({ isActive: true, stepIndex: 0 });
const stepIndex = entry === "palette" ? PALETTE_START_INDEX : 0;
useShortcutShowcaseStore.setState({ isActive: true, stepIndex });
track("shortcut_showcase_started", { entry });
};

Expand All @@ -60,7 +64,7 @@ export const shortcutShowcaseActions = {
startFromWelcome: () => {
activate("welcome");
},
/** Palette re-entry: always allowed, always from the first step. */
/** Palette re-entry: always allowed, always from the first lesson. */
replay: () => {
activate("palette");
},
Expand Down
Loading
Loading