From 0fdfb4863dca5274707bee93784a6c5852b43925 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 27 Aug 2026 16:00:17 +0000
Subject: [PATCH 1/3] feat(web): smooth first-visit onboarding into shortcut
practice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add an unnumbered intro before Level 1 so Explore/Escape is an intentional start, style the welcome FAQ shortcuts, jump 6–0 to social and legal links, and drop the legend hint while welcome is open.
Co-authored-by: Tyler Dane
---
.../src/__tests__/utils/state/reset-stores.ts | 3 +
.../PointerHint/PointerHint.test.tsx | 34 +++++
.../components/PointerHint/PointerHint.tsx | 16 +++
.../ShortcutShowcase.test.tsx | 26 ++++
.../ShortcutShowcase/ShortcutShowcase.tsx | 36 ++++-
.../ShortcutShowcase/showcase.steps.ts | 12 +-
.../ShortcutShowcase/showcase.store.test.ts | 21 ++-
.../ShortcutShowcase/showcase.store.ts | 8 +-
.../WelcomeModal/WelcomeGuideBody.test.tsx | 74 ++++++++--
.../WelcomeModal/WelcomeGuideBody.tsx | 29 ++--
.../WelcomeModal/WelcomeGuideModal.tsx | 23 +--
.../components/WelcomeModal/WelcomeLinks.tsx | 77 ++++++++++
.../WelcomeModal/WelcomeModal.test.tsx | 5 +-
.../components/WelcomeModal/WelcomeModal.tsx | 132 ++++++------------
.../web/src/components/WelcomeModal/faq.ts | 19 ++-
...hortcuts.ts => useWelcomeJumpShortcuts.ts} | 43 ++++--
.../WelcomeModal/welcome.modal.store.ts | 16 +++
17 files changed, 429 insertions(+), 145 deletions(-)
create mode 100644 packages/web/src/components/WelcomeModal/WelcomeLinks.tsx
rename packages/web/src/components/WelcomeModal/{useWelcomeFaqShortcuts.ts => useWelcomeJumpShortcuts.ts} (55%)
create mode 100644 packages/web/src/components/WelcomeModal/welcome.modal.store.ts
diff --git a/packages/web/src/__tests__/utils/state/reset-stores.ts b/packages/web/src/__tests__/utils/state/reset-stores.ts
index c7221185c..2eb150f1f 100644
--- a/packages/web/src/__tests__/utils/state/reset-stores.ts
+++ b/packages/web/src/__tests__/utils/state/reset-stores.ts
@@ -23,6 +23,7 @@ import {
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import { useWelcomeGuideStore } from "@web/components/WelcomeModal/welcome.guide.store";
+import { useWelcomeModalStore } from "@web/components/WelcomeModal/welcome.modal.store";
import { recurrenceScopeOpportunityActions } from "@web/events/recurrence/recurrence-scope-opportunity.store";
import { resetEventRepositorySourceForTests } from "@web/events/repositories/event.repository.source.store";
import {
@@ -79,6 +80,8 @@ const storeResets: StoreReset[] = [
() => useFirstEventPromptStore.setState(initialFirstEventPromptState, true),
() =>
useWelcomeGuideStore.setState(useWelcomeGuideStore.getInitialState(), true),
+ () =>
+ useWelcomeModalStore.setState(useWelcomeModalStore.getInitialState(), true),
() => useThemeStore.setState(useThemeStore.getInitialState(), true),
() => usePointerBlockStore.setState(initialPointerBlockState, true),
() => useEventJumpStore.setState(initialEventJumpState, true),
diff --git a/packages/web/src/components/PointerHint/PointerHint.test.tsx b/packages/web/src/components/PointerHint/PointerHint.test.tsx
index 0832981d7..fdcd57606 100644
--- a/packages/web/src/components/PointerHint/PointerHint.test.tsx
+++ b/packages/web/src/components/PointerHint/PointerHint.test.tsx
@@ -5,6 +5,8 @@ import {
shortcutShowcaseActions,
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
+import { welcomeGuideActions } from "@web/components/WelcomeModal/welcome.guide.store";
+import { welcomeModalActions } from "@web/components/WelcomeModal/welcome.modal.store";
import { POINTER_ACTIONS } from "@web/shortcuts/keyboard-only/pointer-action";
import {
initialPointerBlockState,
@@ -25,6 +27,8 @@ describe("PointerHint", () => {
usePointerBlockStore.setState(initialPointerBlockState, true);
useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true);
useEventJumpStore.setState(initialEventJumpState, true);
+ welcomeModalActions.setOpen(false);
+ welcomeGuideActions.close();
sessionStorage.removeItem(HINT_COUNT_KEY);
});
@@ -32,6 +36,8 @@ describe("PointerHint", () => {
usePointerBlockStore.setState(initialPointerBlockState, true);
useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true);
useEventJumpStore.setState(initialEventJumpState, true);
+ welcomeModalActions.setOpen(false);
+ welcomeGuideActions.close();
sessionStorage.removeItem(HINT_COUNT_KEY);
});
@@ -48,6 +54,34 @@ describe("PointerHint", () => {
);
});
+ it("omits the legend hint while the welcome modal is open", () => {
+ welcomeModalActions.setOpen(true);
+ render( );
+
+ 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( );
+
+ 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( );
diff --git a/packages/web/src/components/PointerHint/PointerHint.tsx b/packages/web/src/components/PointerHint/PointerHint.tsx
index dd828eb34..9d33d88bd 100644
--- a/packages/web/src/components/PointerHint/PointerHint.tsx
+++ b/packages/web/src/components/PointerHint/PointerHint.tsx
@@ -4,6 +4,14 @@ import {
selectShowcaseActive,
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
+import {
+ selectWelcomeGuideOpen,
+ useWelcomeGuideStore,
+} from "@web/components/WelcomeModal/welcome.guide.store";
+import {
+ selectWelcomeModalOpen,
+ useWelcomeModalStore,
+} from "@web/components/WelcomeModal/welcome.modal.store";
import {
type BlockedPointerAttempt,
POINTER_ACTIONS,
@@ -53,11 +61,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.";
@@ -92,6 +102,8 @@ const pointerHintMessage = ({
if (isBrief) return "Keyboard only.";
+ if (welcomeOpen) return "Compass is keyboard only.";
+
return (
<>
Compass is keyboard only. Press ? for shortcuts.
@@ -111,6 +123,9 @@ export const PointerHint: FC = () => {
const attempt = usePointerBlockStore(selectLatestPointerAttempt);
const eventJumpKey = useEventJumpStore(selectEventJumpPointerHintKey);
const showcaseActive = useShortcutShowcaseStore(selectShowcaseActive);
+ const welcomeGuideOpen = useWelcomeGuideStore(selectWelcomeGuideOpen);
+ const welcomeModalOpen = useWelcomeModalStore(selectWelcomeModalOpen);
+ const welcomeOpen = welcomeGuideOpen || welcomeModalOpen;
const [isVisible, setIsVisible] = useState(false);
const [isBrief, setIsBrief] = useState(false);
@@ -146,6 +161,7 @@ export const PointerHint: FC = () => {
eventJumpKey,
isBrief,
showcaseActive,
+ welcomeOpen,
})}
);
diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx
index d3f22164d..6d3c3d6c7 100644
--- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx
+++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx
@@ -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( );
+ 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( );
diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
index f989c8361..dc692b71f 100644
--- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
+++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
@@ -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);
@@ -235,6 +236,23 @@ 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") && !authenticated) {
+ claim(event);
+ skipToSignup();
+ }
+ return;
+ }
+
if (stepId === "graduation" && event.key === "Enter") {
claim(event);
graduate();
@@ -392,6 +410,18 @@ const ShowcaseTakeover: FC = () => {
{step.keycaps && }
+ {stepId === "intro" && (
+
+
+ Start practicing
+
+ Enter
+
+ )}
{stepId === "graduation" ? (
{
});
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();
@@ -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();
@@ -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", () => {
@@ -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");
diff --git a/packages/web/src/components/ShortcutShowcase/showcase.store.ts b/packages/web/src/components/ShortcutShowcase/showcase.store.ts
index 0d06b46d9..8b88dce5b 100644
--- a/packages/web/src/components/ShortcutShowcase/showcase.store.ts
+++ b/packages/web/src/components/ShortcutShowcase/showcase.store.ts
@@ -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 });
};
@@ -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");
},
diff --git a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
index b8fee4447..943ee6e1e 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
@@ -2,7 +2,8 @@ import { resolveModifier } from "@tanstack/react-hotkeys";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WelcomeGuideBody } from "./WelcomeGuideBody";
-import { afterEach, describe, expect, it } from "bun:test";
+import { WelcomeLinks } from "./WelcomeLinks";
+import { afterEach, describe, expect, it, mock } from "bun:test";
const modKey = resolveModifier("Mod") === "Meta" ? "Meta" : "Control";
@@ -22,20 +23,37 @@ const releaseWindowKey = (init: KeyboardEventInit) => {
});
};
+const renderWelcomeGuide = () =>
+ render(
+
+
+ ,
+ );
+
+const captureLinkClick = (name: string) => {
+ const link = screen.getByRole("link", { name });
+ const onClick = mock((event: Event) => {
+ event.preventDefault();
+ });
+ link.addEventListener("click", onClick);
+ return onClick;
+};
+
describe("WelcomeGuideBody", () => {
afterEach(() => {
releaseWindowKey({ key: modKey });
});
it("explains that numbered shortcuts open the FAQ", () => {
- render( );
+ renderWelcomeGuide();
- expect(screen.getByText(/to see keys, then press 1–5/)).toBeTruthy();
- expect(screen.getByText(/The same hold reveals jump keys/)).toBeTruthy();
+ expect(screen.getByText(/Tip:/)).toBeTruthy();
+ expect(screen.getByText(/to see keys, then press a number/)).toBeTruthy();
+ expect(screen.queryByText(/The same hold reveals jump keys/)).toBeNull();
});
it("toggles a FAQ with a bare digit", async () => {
- render( );
+ renderWelcomeGuide();
const question = screen.getByRole("button", {
name: "Who is Compass for?",
@@ -54,7 +72,7 @@ describe("WelcomeGuideBody", () => {
});
it("reveals numbered keycaps while Mod is held", async () => {
- render( );
+ renderWelcomeGuide();
expect(screen.queryByText("1")).toBeNull();
@@ -62,15 +80,18 @@ describe("WelcomeGuideBody", () => {
expect(screen.getByText("1")).toBeTruthy();
expect(screen.getByText("5")).toBeTruthy();
+ expect(screen.getByText("6")).toBeTruthy();
+ expect(screen.getByText("0")).toBeTruthy();
releaseWindowKey({ key: modKey });
expect(screen.queryByText("1")).toBeNull();
+ expect(screen.queryByText("6")).toBeNull();
});
it("still lets a click expand a question", async () => {
const user = userEvent.setup();
- render( );
+ renderWelcomeGuide();
const question = screen.getByRole("button", {
name: "How is Compass different?",
@@ -82,7 +103,7 @@ describe("WelcomeGuideBody", () => {
it("explains why the mouse does not work", async () => {
const user = userEvent.setup();
- render( );
+ renderWelcomeGuide();
await user.click(
screen.getByRole("button", { name: "Why doesn't my mouse work?" }),
@@ -94,4 +115,41 @@ describe("WelcomeGuideBody", () => {
),
).toBeTruthy();
});
+
+ it("styles the legend and palette shortcuts in the lost FAQ", async () => {
+ const user = userEvent.setup();
+ renderWelcomeGuide();
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "I don't know any shortcuts yet. Will I be lost?",
+ }),
+ );
+
+ expect(screen.getByText("?").closest(".c-keycap")).toBeTruthy();
+ expect(screen.getByText("K").closest(".c-keycap")).toBeTruthy();
+ expect(screen.getByText(/and \? opens the full legend/)).toBeTruthy();
+ });
+
+ it("opens footer links with digits 6 through 0", () => {
+ renderWelcomeGuide();
+
+ const x = captureLinkClick("X (Twitter)");
+ const linkedin = captureLinkClick("LinkedIn");
+ const github = captureLinkClick("GitHub");
+ const privacy = captureLinkClick("Privacy");
+ const terms = captureLinkClick("Terms");
+
+ pressWindowKey({ key: "6", code: "Digit6" });
+ pressWindowKey({ key: "7", code: "Digit7" });
+ pressWindowKey({ key: "8", code: "Digit8" });
+ pressWindowKey({ key: "9", code: "Digit9" });
+ pressWindowKey({ key: "0", code: "Digit0" });
+
+ expect(x).toHaveBeenCalledTimes(1);
+ expect(linkedin).toHaveBeenCalledTimes(1);
+ expect(github).toHaveBeenCalledTimes(1);
+ expect(privacy).toHaveBeenCalledTimes(1);
+ expect(terms).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
index 5f0044e13..89152ee8a 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
@@ -1,10 +1,14 @@
-import { useCallback, useId, useState } from "react";
+import { type ReactNode, useCallback, useId, useState } from "react";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
+import { ShortcutTipParts } from "@web/shortcuts/tips/ShortcutTipParts";
import { FAQ_ITEMS } from "./faq";
-import { useWelcomeFaqShortcuts } from "./useWelcomeFaqShortcuts";
+import {
+ useWelcomeJumpShortcuts,
+ WelcomeModHoldContext,
+} from "./useWelcomeJumpShortcuts";
-export function WelcomeGuideBody() {
+export function WelcomeGuideBody({ children }: { children?: ReactNode }) {
const disclosureIdPrefix = useId();
const faqHintId = `${disclosureIdPrefix}-faq-hint`;
const [expandedFaqs, setExpandedFaqs] = useState>(
@@ -33,10 +37,10 @@ export function WelcomeGuideBody() {
[toggleFaq],
);
- const isModHeld = useWelcomeFaqShortcuts(toggleFaqAt);
+ const isModHeld = useWelcomeJumpShortcuts(toggleFaqAt);
return (
- <>
+
The Keyboard Calendar
@@ -80,7 +84,11 @@ export function WelcomeGuideBody() {
>
- {item.answer}
+ {typeof item.answer === "string" ? (
+ item.answer
+ ) : (
+
+ )}
@@ -91,10 +99,11 @@ export function WelcomeGuideBody() {
- Hold to see keys, then press 1–5.
- {" "}
- The same hold reveals jump keys all over Compass.
+ Tip: Hold to see keys, then press a
+ number.
+
- >
+ {children}
+
);
}
diff --git a/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx b/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx
index f8ae3d26a..730adbd62 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx
@@ -3,6 +3,7 @@ import { useDismissTransition } from "@web/common/hooks/useDismissTransition";
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
import { PixelPirate } from "./PixelPirate";
import { WelcomeGuideBody } from "./WelcomeGuideBody";
+import { WelcomeLinks } from "./WelcomeLinks";
import { welcomeGuideActions } from "./welcome.guide.store";
export function WelcomeGuideModal() {
@@ -23,17 +24,19 @@ export function WelcomeGuideModal() {
>
-
+
+
+
+ Close
+
+
-
-
- Close
-
-
+
+
);
diff --git a/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx b/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx
new file mode 100644
index 000000000..9bceaa71d
--- /dev/null
+++ b/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx
@@ -0,0 +1,77 @@
+import {
+ GithubLogoIcon,
+ LinkedinLogoIcon,
+ XLogoIcon,
+} from "@phosphor-icons/react";
+import { SOCIAL_LINKS } from "@web/common/constants/social.constants";
+import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
+import { useWelcomeModHeld } from "./useWelcomeJumpShortcuts";
+
+const SOCIAL_ICONS = {
+ x: XLogoIcon,
+ linkedin: LinkedinLogoIcon,
+ github: GithubLogoIcon,
+} as const;
+
+const SOCIAL_JUMP_DIGITS = ["6", "7", "8"] as const;
+
+const LEGAL_LINKS = [
+ {
+ digit: "9",
+ label: "Privacy",
+ href: "https://compasscalendar.com/privacy",
+ },
+ {
+ digit: "0",
+ label: "Terms",
+ href: "https://compasscalendar.com/terms",
+ },
+] as const;
+
+export function WelcomeLinks() {
+ const isModHeld = useWelcomeModHeld();
+
+ return (
+
+
+ {SOCIAL_LINKS.map(({ id, label, href }, index) => {
+ const SocialIcon = SOCIAL_ICONS[id];
+ const digit = SOCIAL_JUMP_DIGITS[index];
+ return (
+
+
+ {isModHeld && (
+ {digit}
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx b/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx
index 814af901e..054598d3c 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeModal.test.tsx
@@ -76,7 +76,7 @@ const { WelcomeModal } =
const { STORAGE_KEYS } =
require("@web/common/constants/storage.constants") as typeof import("@web/common/constants/storage.constants");
-const { useShortcutShowcaseStore, initialShortcutShowcaseState } =
+const { useShortcutShowcaseStore, initialShortcutShowcaseState, stepIdAt } =
require("@web/components/ShortcutShowcase/showcase.store") as typeof import("@web/components/ShortcutShowcase/showcase.store");
describe("WelcomeModal", () => {
@@ -253,6 +253,9 @@ describe("WelcomeModal", () => {
await waitFor(() => {
expect(useShortcutShowcaseStore.getState().isActive).toBe(true);
});
+ expect(stepIdAt(useShortcutShowcaseStore.getState().stepIndex)).toBe(
+ "intro",
+ );
expect(
localStorage.getItem(STORAGE_KEYS.HAS_SEEN_SHORTCUT_SHOWCASE),
).not.toBe("true");
diff --git a/packages/web/src/components/WelcomeModal/WelcomeModal.tsx b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
index 0f1af13c9..c61661a04 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
@@ -1,15 +1,9 @@
-import {
- GithubLogoIcon,
- LinkedinLogoIcon,
- XLogoIcon,
-} from "@phosphor-icons/react";
import { useContext, useEffect, useRef, useState } from "react";
import { SessionContext } from "@web/auth/compass/session/session.context";
import { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization";
import { useIsGoogleAvailable } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable";
import { track } from "@web/auth/posthog/track";
import { MODAL_DISMISS_MS } from "@web/common/constants/motion.constants";
-import { SOCIAL_LINKS } from "@web/common/constants/social.constants";
import { useDismissTransition } from "@web/common/hooks/useDismissTransition";
import { GoogleButton } from "@web/components/AuthModal/components/GoogleButton";
import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal";
@@ -19,14 +13,10 @@ import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { keyboardKey } from "@web/shortcuts/is-bare-letter-key";
import { PixelPirate } from "./PixelPirate";
import { WelcomeGuideBody } from "./WelcomeGuideBody";
+import { WelcomeLinks } from "./WelcomeLinks";
+import { welcomeModalActions } from "./welcome.modal.store";
import { hasSeenWelcome, markWelcomeSeen } from "./welcome.modal.util";
-const SOCIAL_ICONS = {
- x: XLogoIcon,
- linkedin: LinkedinLogoIcon,
- github: GithubLogoIcon,
-} as const;
-
export function WelcomeModal() {
const { authenticated } = useContext(SessionContext);
const { openModal, isOpen: isAuthModalOpen } = useAuthModal();
@@ -83,6 +73,11 @@ export function WelcomeModal() {
}
}, [visible]);
+ useEffect(() => {
+ welcomeModalActions.setOpen(visible);
+ return () => welcomeModalActions.setOpen(false);
+ }, [visible]);
+
if (!visible) return null;
// Fade the backdrop and gently scale the panel before unmounting, so the
@@ -208,84 +203,47 @@ export function WelcomeModal() {
-
-
- {/* CTA: connecting a calendar is the moment Compass starts being
- useful, so the Google round trip - which signs up and grants
- calendar access at once - leads, and everything else is a fallback
- from it. */}
-
- {isGoogleAvailable && (
- <>
-
-
- Signs you up and connects your Google Calendar.
-
- >
- )}
-
handOffToAuth("sign_up")}
- className="c-button c-button-primary c-button-elevated inline-flex h-10 w-full items-center justify-center rounded-full"
- >
- {isGoogleAvailable ? "Sign up with email" : "Sign up"}
- U
-
-
dismiss("explore")}
- className="c-focus-ring inline-flex items-center rounded-md px-2 py-1 text-text-muted text-xs hover:bg-surface-overlay hover:text-text"
- >
- Explore without an account
- S
-
-
-
- {/* Footer: social + legal */}
-
-
- {SOCIAL_LINKS.map(({ id, label, href }) => {
- const SocialIcon = SOCIAL_ICONS[id];
- return (
-
-
-
- );
- })}
-
-
+
+
+
);
diff --git a/packages/web/src/components/WelcomeModal/faq.ts b/packages/web/src/components/WelcomeModal/faq.ts
index 383a9ece5..ab34f5156 100644
--- a/packages/web/src/components/WelcomeModal/faq.ts
+++ b/packages/web/src/components/WelcomeModal/faq.ts
@@ -1,4 +1,12 @@
-export const FAQ_ITEMS = [
+import { KEYMAP } from "@web/shortcuts/keymap";
+import { type ShortcutTipPart } from "@web/shortcuts/tips/shortcut-tips.data";
+
+export type FaqItem = {
+ question: string;
+ answer: string | readonly ShortcutTipPart[];
+};
+
+export const FAQ_ITEMS: readonly FaqItem[] = [
{
question: "Who is Compass for?",
answer: "Compass is for busy professionals who live at their keyboard.",
@@ -19,7 +27,12 @@ export const FAQ_ITEMS = [
},
{
question: "I don't know any shortcuts yet. Will I be lost?",
- answer:
- "No. The practice arena walks you through the core shortcut patterns, hints appear right when they're useful, and ? opens the full legend. Cmd+K opens a command palette for anything you can't remember.",
+ answer: [
+ "No. The practice arena walks you through the core shortcut patterns, hints appear right when they're useful, and ",
+ { key: "?" },
+ " opens the full legend. ",
+ { keys: KEYMAP.commandPalette.keycaps },
+ " opens a command palette for anything you can't remember.",
+ ],
},
];
diff --git a/packages/web/src/components/WelcomeModal/useWelcomeFaqShortcuts.ts b/packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts
similarity index 55%
rename from packages/web/src/components/WelcomeModal/useWelcomeFaqShortcuts.ts
rename to packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts
index 92c307bcb..11c3b3ed3 100644
--- a/packages/web/src/components/WelcomeModal/useWelcomeFaqShortcuts.ts
+++ b/packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts
@@ -1,27 +1,28 @@
import { resolveModifier } from "@tanstack/react-hotkeys";
-import { useEffect, useState } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util";
+import { physicalDigitIndex } from "@web/shortcuts/digit-pick.util";
import { FAQ_ITEMS } from "./faq";
+export const WELCOME_JUMP_ATTR = "data-welcome-jump";
+
+export const WelcomeModHoldContext = createContext(false);
+
+export const useWelcomeModHeld = () => useContext(WelcomeModHoldContext);
+
const isPlatformModKey = (event: KeyboardEvent) =>
resolveModifier("Mod") === "Meta"
? event.key === "Meta"
: event.key === "Control";
-const faqIndexFromEvent = (event: KeyboardEvent): number | null => {
- const match = /^Digit([1-9])$/.exec(event.code);
- if (!match) return null;
- const index = Number(match[1]) - 1;
- if (index < 0 || index >= FAQ_ITEMS.length) return null;
- return index;
-};
-
/**
- * Hold Mod to reveal numbered FAQ keycaps; press 1–5 (with or without Mod)
- * to toggle that question. Bare digits work because browsers steal
- * Cmd/Ctrl+1–8 for tab switching.
+ * Hold Mod to reveal numbered keycaps; press a number (with or without Mod)
+ * to toggle that FAQ or open a footer link. Bare digits work because
+ * browsers steal Cmd/Ctrl+1–8 for tab switching.
+ *
+ * 1–5 toggle FAQ items. 6–0 activate matching `[data-welcome-jump]` links.
*/
-export function useWelcomeFaqShortcuts(toggleFaqAt: (index: number) => void) {
+export function useWelcomeJumpShortcuts(toggleFaqAt: (index: number) => void) {
const [isModHeld, setIsModHeld] = useState(false);
useEffect(() => {
@@ -32,11 +33,23 @@ export function useWelcomeFaqShortcuts(toggleFaqAt: (index: number) => void) {
if (isEditableKeyboardTarget(event)) return;
- const index = faqIndexFromEvent(event);
+ const index = physicalDigitIndex(event);
if (index === null) return;
+ if (index < FAQ_ITEMS.length) {
+ event.preventDefault();
+ toggleFaqAt(index);
+ return;
+ }
+
+ const footerIndex = index - FAQ_ITEMS.length;
+ const el = document.querySelector(
+ `[${WELCOME_JUMP_ATTR}="${footerIndex}"]`,
+ );
+ if (!el) return;
+
event.preventDefault();
- toggleFaqAt(index);
+ el.click();
};
const onKeyUp = (event: KeyboardEvent) => {
diff --git a/packages/web/src/components/WelcomeModal/welcome.modal.store.ts b/packages/web/src/components/WelcomeModal/welcome.modal.store.ts
new file mode 100644
index 000000000..6792458fe
--- /dev/null
+++ b/packages/web/src/components/WelcomeModal/welcome.modal.store.ts
@@ -0,0 +1,16 @@
+import { create } from "zustand";
+
+export interface WelcomeModalState {
+ isOpen: boolean;
+}
+
+export const useWelcomeModalStore = create()(() => ({
+ isOpen: false,
+}));
+
+export const welcomeModalActions = {
+ setOpen: (isOpen: boolean) => useWelcomeModalStore.setState({ isOpen }),
+};
+
+export const selectWelcomeModalOpen = (state: WelcomeModalState) =>
+ state.isOpen;
From c3cd12bde51974726908957c82d98fca7b6128dd Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 27 Aug 2026 17:11:40 +0000
Subject: [PATCH 2/3] refactor(web): fold welcome surface state and drop jump
context
Publish first-visit visibility on the existing welcome guide store, render footer jumps from the guide body, and reuse the showcase signup shortcut on the intro step.
Co-authored-by: Tyler Dane
---
.../src/__tests__/utils/state/reset-stores.ts | 3 -
.../PointerHint/PointerHint.test.tsx | 7 +-
.../components/PointerHint/PointerHint.tsx | 10 +--
.../ShortcutShowcase/ShortcutShowcase.tsx | 6 +-
.../WelcomeModal/WelcomeGuideBody.test.tsx | 8 +--
.../WelcomeModal/WelcomeGuideBody.tsx | 11 ++-
.../WelcomeModal/WelcomeGuideModal.tsx | 3 -
.../components/WelcomeModal/WelcomeLinks.tsx | 67 ++++++++++++-------
.../components/WelcomeModal/WelcomeModal.tsx | 9 +--
.../WelcomeModal/useWelcomeJumpShortcuts.ts | 6 +-
.../WelcomeModal/welcome.guide.store.ts | 7 ++
.../WelcomeModal/welcome.modal.store.ts | 16 -----
12 files changed, 67 insertions(+), 86 deletions(-)
delete mode 100644 packages/web/src/components/WelcomeModal/welcome.modal.store.ts
diff --git a/packages/web/src/__tests__/utils/state/reset-stores.ts b/packages/web/src/__tests__/utils/state/reset-stores.ts
index 2eb150f1f..c7221185c 100644
--- a/packages/web/src/__tests__/utils/state/reset-stores.ts
+++ b/packages/web/src/__tests__/utils/state/reset-stores.ts
@@ -23,7 +23,6 @@ import {
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import { useWelcomeGuideStore } from "@web/components/WelcomeModal/welcome.guide.store";
-import { useWelcomeModalStore } from "@web/components/WelcomeModal/welcome.modal.store";
import { recurrenceScopeOpportunityActions } from "@web/events/recurrence/recurrence-scope-opportunity.store";
import { resetEventRepositorySourceForTests } from "@web/events/repositories/event.repository.source.store";
import {
@@ -80,8 +79,6 @@ const storeResets: StoreReset[] = [
() => useFirstEventPromptStore.setState(initialFirstEventPromptState, true),
() =>
useWelcomeGuideStore.setState(useWelcomeGuideStore.getInitialState(), true),
- () =>
- useWelcomeModalStore.setState(useWelcomeModalStore.getInitialState(), true),
() => useThemeStore.setState(useThemeStore.getInitialState(), true),
() => usePointerBlockStore.setState(initialPointerBlockState, true),
() => useEventJumpStore.setState(initialEventJumpState, true),
diff --git a/packages/web/src/components/PointerHint/PointerHint.test.tsx b/packages/web/src/components/PointerHint/PointerHint.test.tsx
index fdcd57606..861443ccb 100644
--- a/packages/web/src/components/PointerHint/PointerHint.test.tsx
+++ b/packages/web/src/components/PointerHint/PointerHint.test.tsx
@@ -6,7 +6,6 @@ import {
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import { welcomeGuideActions } from "@web/components/WelcomeModal/welcome.guide.store";
-import { welcomeModalActions } from "@web/components/WelcomeModal/welcome.modal.store";
import { POINTER_ACTIONS } from "@web/shortcuts/keyboard-only/pointer-action";
import {
initialPointerBlockState,
@@ -27,7 +26,7 @@ describe("PointerHint", () => {
usePointerBlockStore.setState(initialPointerBlockState, true);
useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true);
useEventJumpStore.setState(initialEventJumpState, true);
- welcomeModalActions.setOpen(false);
+ welcomeGuideActions.setFirstVisitOpen(false);
welcomeGuideActions.close();
sessionStorage.removeItem(HINT_COUNT_KEY);
});
@@ -36,7 +35,7 @@ describe("PointerHint", () => {
usePointerBlockStore.setState(initialPointerBlockState, true);
useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true);
useEventJumpStore.setState(initialEventJumpState, true);
- welcomeModalActions.setOpen(false);
+ welcomeGuideActions.setFirstVisitOpen(false);
welcomeGuideActions.close();
sessionStorage.removeItem(HINT_COUNT_KEY);
});
@@ -55,7 +54,7 @@ describe("PointerHint", () => {
});
it("omits the legend hint while the welcome modal is open", () => {
- welcomeModalActions.setOpen(true);
+ welcomeGuideActions.setFirstVisitOpen(true);
render( );
act(() => {
diff --git a/packages/web/src/components/PointerHint/PointerHint.tsx b/packages/web/src/components/PointerHint/PointerHint.tsx
index 9d33d88bd..7e3059e48 100644
--- a/packages/web/src/components/PointerHint/PointerHint.tsx
+++ b/packages/web/src/components/PointerHint/PointerHint.tsx
@@ -5,13 +5,9 @@ import {
useShortcutShowcaseStore,
} from "@web/components/ShortcutShowcase/showcase.store";
import {
- selectWelcomeGuideOpen,
+ selectWelcomeSurfaceOpen,
useWelcomeGuideStore,
} from "@web/components/WelcomeModal/welcome.guide.store";
-import {
- selectWelcomeModalOpen,
- useWelcomeModalStore,
-} from "@web/components/WelcomeModal/welcome.modal.store";
import {
type BlockedPointerAttempt,
POINTER_ACTIONS,
@@ -123,9 +119,7 @@ export const PointerHint: FC = () => {
const attempt = usePointerBlockStore(selectLatestPointerAttempt);
const eventJumpKey = useEventJumpStore(selectEventJumpPointerHintKey);
const showcaseActive = useShortcutShowcaseStore(selectShowcaseActive);
- const welcomeGuideOpen = useWelcomeGuideStore(selectWelcomeGuideOpen);
- const welcomeModalOpen = useWelcomeModalStore(selectWelcomeModalOpen);
- const welcomeOpen = welcomeGuideOpen || welcomeModalOpen;
+ const welcomeOpen = useWelcomeGuideStore(selectWelcomeSurfaceOpen);
const [isVisible, setIsVisible] = useState(false);
const [isBrief, setIsBrief] = useState(false);
diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
index dc692b71f..35251ffdb 100644
--- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
+++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
@@ -246,11 +246,7 @@ const ShowcaseTakeover: FC = () => {
}
return;
}
- if (isBareLetterKey(event.nativeEvent, "u") && !authenticated) {
- claim(event);
- skipToSignup();
- }
- return;
+ if (!isBareLetterKey(event.nativeEvent, "u")) return;
}
if (stepId === "graduation" && event.key === "Enter") {
diff --git a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
index 943ee6e1e..851558ac2 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
@@ -2,7 +2,6 @@ import { resolveModifier } from "@tanstack/react-hotkeys";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WelcomeGuideBody } from "./WelcomeGuideBody";
-import { WelcomeLinks } from "./WelcomeLinks";
import { afterEach, describe, expect, it, mock } from "bun:test";
const modKey = resolveModifier("Mod") === "Meta" ? "Meta" : "Control";
@@ -23,12 +22,7 @@ const releaseWindowKey = (init: KeyboardEventInit) => {
});
};
-const renderWelcomeGuide = () =>
- render(
-
-
- ,
- );
+const renderWelcomeGuide = () => render( );
const captureLinkClick = (name: string) => {
const link = screen.getByRole("link", { name });
diff --git a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
index 89152ee8a..9b111a6f8 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
@@ -3,10 +3,8 @@ import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
import { ShortcutTipParts } from "@web/shortcuts/tips/ShortcutTipParts";
import { FAQ_ITEMS } from "./faq";
-import {
- useWelcomeJumpShortcuts,
- WelcomeModHoldContext,
-} from "./useWelcomeJumpShortcuts";
+import { useWelcomeJumpShortcuts } from "./useWelcomeJumpShortcuts";
+import { WelcomeLinks } from "./WelcomeLinks";
export function WelcomeGuideBody({ children }: { children?: ReactNode }) {
const disclosureIdPrefix = useId();
@@ -40,7 +38,7 @@ export function WelcomeGuideBody({ children }: { children?: ReactNode }) {
const isModHeld = useWelcomeJumpShortcuts(toggleFaqAt);
return (
-
+ <>
The Keyboard Calendar
@@ -104,6 +102,7 @@ export function WelcomeGuideBody({ children }: { children?: ReactNode }) {
{children}
-
+
+ >
);
}
diff --git a/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx b/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx
index 730adbd62..5eda31554 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx
@@ -3,7 +3,6 @@ import { useDismissTransition } from "@web/common/hooks/useDismissTransition";
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
import { PixelPirate } from "./PixelPirate";
import { WelcomeGuideBody } from "./WelcomeGuideBody";
-import { WelcomeLinks } from "./WelcomeLinks";
import { welcomeGuideActions } from "./welcome.guide.store";
export function WelcomeGuideModal() {
@@ -34,8 +33,6 @@ export function WelcomeGuideModal() {
Close
-
-
diff --git a/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx b/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx
index 9bceaa71d..0751227e3 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx
@@ -3,9 +3,9 @@ import {
LinkedinLogoIcon,
XLogoIcon,
} from "@phosphor-icons/react";
+import { type ReactNode } from "react";
import { SOCIAL_LINKS } from "@web/common/constants/social.constants";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
-import { useWelcomeModHeld } from "./useWelcomeJumpShortcuts";
const SOCIAL_ICONS = {
x: XLogoIcon,
@@ -13,8 +13,6 @@ const SOCIAL_ICONS = {
github: GithubLogoIcon,
} as const;
-const SOCIAL_JUMP_DIGITS = ["6", "7", "8"] as const;
-
const LEGAL_LINKS = [
{
digit: "9",
@@ -28,48 +26,71 @@ const LEGAL_LINKS = [
},
] as const;
-export function WelcomeLinks() {
- const isModHeld = useWelcomeModHeld();
+function JumpAnchor({
+ jumpIndex,
+ digit,
+ href,
+ label,
+ isModHeld,
+ className,
+ children,
+}: {
+ jumpIndex: number;
+ digit: string;
+ href: string;
+ label?: string;
+ isModHeld: boolean;
+ className: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+ {isModHeld && {digit} }
+
+ );
+}
+export function WelcomeLinks({ isModHeld }: { isModHeld: boolean }) {
return (
{SOCIAL_LINKS.map(({ id, label, href }, index) => {
const SocialIcon = SOCIAL_ICONS[id];
- const digit = SOCIAL_JUMP_DIGITS[index];
return (
-
- {isModHeld && (
- {digit}
- )}
-
+
);
})}
diff --git a/packages/web/src/components/WelcomeModal/WelcomeModal.tsx b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
index c61661a04..789083f8e 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
@@ -13,8 +13,7 @@ import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { keyboardKey } from "@web/shortcuts/is-bare-letter-key";
import { PixelPirate } from "./PixelPirate";
import { WelcomeGuideBody } from "./WelcomeGuideBody";
-import { WelcomeLinks } from "./WelcomeLinks";
-import { welcomeModalActions } from "./welcome.modal.store";
+import { welcomeGuideActions } from "./welcome.guide.store";
import { hasSeenWelcome, markWelcomeSeen } from "./welcome.modal.util";
export function WelcomeModal() {
@@ -74,8 +73,8 @@ export function WelcomeModal() {
}, [visible]);
useEffect(() => {
- welcomeModalActions.setOpen(visible);
- return () => welcomeModalActions.setOpen(false);
+ welcomeGuideActions.setFirstVisitOpen(visible);
+ return () => welcomeGuideActions.setFirstVisitOpen(false);
}, [visible]);
if (!visible) return null;
@@ -241,8 +240,6 @@ export function WelcomeModal() {
S
-
-
diff --git a/packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts b/packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts
index 11c3b3ed3..00f5c28c0 100644
--- a/packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts
+++ b/packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts
@@ -1,15 +1,11 @@
import { resolveModifier } from "@tanstack/react-hotkeys";
-import { createContext, useContext, useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util";
import { physicalDigitIndex } from "@web/shortcuts/digit-pick.util";
import { FAQ_ITEMS } from "./faq";
export const WELCOME_JUMP_ATTR = "data-welcome-jump";
-export const WelcomeModHoldContext = createContext(false);
-
-export const useWelcomeModHeld = () => useContext(WelcomeModHoldContext);
-
const isPlatformModKey = (event: KeyboardEvent) =>
resolveModifier("Mod") === "Meta"
? event.key === "Meta"
diff --git a/packages/web/src/components/WelcomeModal/welcome.guide.store.ts b/packages/web/src/components/WelcomeModal/welcome.guide.store.ts
index b09ec0610..499856cec 100644
--- a/packages/web/src/components/WelcomeModal/welcome.guide.store.ts
+++ b/packages/web/src/components/WelcomeModal/welcome.guide.store.ts
@@ -2,16 +2,23 @@ import { create } from "zustand";
export interface WelcomeGuideState {
isOpen: boolean;
+ isFirstVisitOpen: boolean;
}
export const useWelcomeGuideStore = create()(() => ({
isOpen: false,
+ isFirstVisitOpen: false,
}));
export const welcomeGuideActions = {
open: () => useWelcomeGuideStore.setState({ isOpen: true }),
close: () => useWelcomeGuideStore.setState({ isOpen: false }),
+ setFirstVisitOpen: (isFirstVisitOpen: boolean) =>
+ useWelcomeGuideStore.setState({ isFirstVisitOpen }),
};
export const selectWelcomeGuideOpen = (state: WelcomeGuideState) =>
state.isOpen;
+
+export const selectWelcomeSurfaceOpen = (state: WelcomeGuideState) =>
+ state.isOpen || state.isFirstVisitOpen;
diff --git a/packages/web/src/components/WelcomeModal/welcome.modal.store.ts b/packages/web/src/components/WelcomeModal/welcome.modal.store.ts
deleted file mode 100644
index 6792458fe..000000000
--- a/packages/web/src/components/WelcomeModal/welcome.modal.store.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { create } from "zustand";
-
-export interface WelcomeModalState {
- isOpen: boolean;
-}
-
-export const useWelcomeModalStore = create()(() => ({
- isOpen: false,
-}));
-
-export const welcomeModalActions = {
- setOpen: (isOpen: boolean) => useWelcomeModalStore.setState({ isOpen }),
-};
-
-export const selectWelcomeModalOpen = (state: WelcomeModalState) =>
- state.isOpen;
From 7179d4eb2748fa5b59ac6407eb4e24b0830f9b0b Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 27 Aug 2026 17:42:25 +0000
Subject: [PATCH 3/3] test(web): match onboarding intro and leaked shortcut
hint mock
Welcome-started practice now lands on the intro, and the full web suite
mocks ShortcutHint without c-keycap. Point the assertions at those.
Co-authored-by: Tyler Dane
---
e2e/onboarding/shortcut-showcase.spec.ts | 21 ++++++++++++++-----
.../WelcomeModal/WelcomeGuideBody.test.tsx | 7 +++++--
2 files changed, 21 insertions(+), 7 deletions(-)
diff --git a/e2e/onboarding/shortcut-showcase.spec.ts b/e2e/onboarding/shortcut-showcase.spec.ts
index 8689d59ca..418264e82 100644
--- a/e2e/onboarding/shortcut-showcase.spec.ts
+++ b/e2e/onboarding/shortcut-showcase.spec.ts
@@ -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.
@@ -63,6 +71,9 @@ 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 ({
@@ -70,11 +81,10 @@ test("the welcome-started practice runs the taught keys through graduation", asy
}) => {
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);
@@ -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);
@@ -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/ }),
@@ -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);
diff --git a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
index 851558ac2..dbf1a54b8 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeGuideBody.test.tsx
@@ -120,8 +120,11 @@ describe("WelcomeGuideBody", () => {
}),
);
- expect(screen.getByText("?").closest(".c-keycap")).toBeTruthy();
- expect(screen.getByText("K").closest(".c-keycap")).toBeTruthy();
+ // `[aria-hidden]` (not `.c-keycap`) because SelectView.test.tsx mocks
+ // ShortcutHint process-wide (bun's mock.module leaks across files);
+ // its stub keeps aria-hidden but drops the real class.
+ expect(screen.getByText("?").closest("[aria-hidden]")).toBeTruthy();
+ expect(screen.getByText("K").closest("[aria-hidden]")).toBeTruthy();
expect(screen.getByText(/and \? opens the full legend/)).toBeTruthy();
});