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" && ( +
+ + Enter +
+ )} {stepId === "graduation" ? (
+
-
- -
+ +
); 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} + )} + + ); + })} +
+
+ {LEGAL_LINKS.map(({ digit, label, href }, index) => ( + + {label} + {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. -

- - )} - - -
- - {/* 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} - )} - + ); })}
{LEGAL_LINKS.map(({ digit, label, href }, index) => ( - {label} - {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(); });