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/PointerHint/PointerHint.test.tsx b/packages/web/src/components/PointerHint/PointerHint.test.tsx index 0832981d7..861443ccb 100644 --- a/packages/web/src/components/PointerHint/PointerHint.test.tsx +++ b/packages/web/src/components/PointerHint/PointerHint.test.tsx @@ -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, @@ -25,6 +26,8 @@ describe("PointerHint", () => { usePointerBlockStore.setState(initialPointerBlockState, true); useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true); useEventJumpStore.setState(initialEventJumpState, true); + welcomeGuideActions.setFirstVisitOpen(false); + welcomeGuideActions.close(); sessionStorage.removeItem(HINT_COUNT_KEY); }); @@ -32,6 +35,8 @@ describe("PointerHint", () => { usePointerBlockStore.setState(initialPointerBlockState, true); useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true); useEventJumpStore.setState(initialEventJumpState, true); + welcomeGuideActions.setFirstVisitOpen(false); + welcomeGuideActions.close(); sessionStorage.removeItem(HINT_COUNT_KEY); }); @@ -48,6 +53,34 @@ describe("PointerHint", () => { ); }); + it("omits the legend hint while the welcome modal is open", () => { + welcomeGuideActions.setFirstVisitOpen(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..7e3059e48 100644 --- a/packages/web/src/components/PointerHint/PointerHint.tsx +++ b/packages/web/src/components/PointerHint/PointerHint.tsx @@ -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, @@ -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."; @@ -92,6 +98,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 +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); @@ -146,6 +155,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..35251ffdb 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,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(); @@ -392,6 +406,18 @@ const ShowcaseTakeover: FC = () => {

{step.keycaps && }
+ {stepId === "intro" && ( +
+ + Enter +
+ )} {stepId === "graduation" ? (
@@ -91,10 +97,12 @@ 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 3e360f0af..b1a927f45 100644 --- a/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx +++ b/packages/web/src/components/WelcomeModal/WelcomeGuideModal.tsx @@ -24,18 +24,18 @@ export function WelcomeGuideModal() { >
- - -
- -
+ +
+ +
+
); diff --git a/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx b/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx new file mode 100644 index 000000000..0751227e3 --- /dev/null +++ b/packages/web/src/components/WelcomeModal/WelcomeLinks.tsx @@ -0,0 +1,98 @@ +import { + GithubLogoIcon, + 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"; + +const SOCIAL_ICONS = { + x: XLogoIcon, + linkedin: LinkedinLogoIcon, + github: GithubLogoIcon, +} 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; + +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]; + return ( + + + + ); + })} +
+
+ {LEGAL_LINKS.map(({ digit, label, href }, index) => ( + + {label} + + ))} +
+
+ ); +} 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..789083f8e 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,9 @@ 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 { welcomeGuideActions } from "./welcome.guide.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 +72,11 @@ export function WelcomeModal() { } }, [visible]); + useEffect(() => { + welcomeGuideActions.setFirstVisitOpen(visible); + return () => welcomeGuideActions.setFirstVisitOpen(false); + }, [visible]); + if (!visible) return null; // Fade the backdrop and gently scale the panel before unmounting, so the @@ -208,84 +202,45 @@ 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 61% rename from packages/web/src/components/WelcomeModal/useWelcomeFaqShortcuts.ts rename to packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts index 92c307bcb..00f5c28c0 100644 --- a/packages/web/src/components/WelcomeModal/useWelcomeFaqShortcuts.ts +++ b/packages/web/src/components/WelcomeModal/useWelcomeJumpShortcuts.ts @@ -1,27 +1,24 @@ import { resolveModifier } from "@tanstack/react-hotkeys"; 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"; + 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 +29,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.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;