diff --git a/e2e/onboarding/interactive-tour.spec.ts b/e2e/onboarding/interactive-tour.spec.ts
index 3d6340b67..76c54c49a 100644
--- a/e2e/onboarding/interactive-tour.spec.ts
+++ b/e2e/onboarding/interactive-tour.spec.ts
@@ -26,8 +26,29 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => {
const title = createEventTitle("Tour Event");
await fillTitleAndSaveEventForm(page, title);
+ await expect(card).toContainText("Move between events");
+
+ await page.keyboard.press("ArrowRight");
+ await expect(card).toContainText("Jump straight to a field");
+
+ // E then T is the edit sequence; it acts on whichever event has DOM
+ // focus. There is only one event on the calendar, so the ArrowRight
+ // above (there being no adjacent event to move to) may not have kept
+ // focus on it -- refocus it explicitly, mirroring
+ // e2e/timed/edit-sequence-title.spec.ts.
+ const eventButton = page
+ .locator("#mainGrid")
+ .getByRole("button", { name: title });
+ await eventButton.focus();
+
+ await page.keyboard.press("e");
+ await page.keyboard.press("t");
await expect(card).toContainText("Open the command palette");
+ // Close the form the edit sequence opened before testing the palette
+ // shortcut, so Escape here closes the form rather than the palette.
+ await page.keyboard.press("Escape");
+
// Linux CI uses Ctrl; macOS local runs use Meta. Press both modifiers' chord
// via ControlOrMeta through Playwright's platform-aware ControlOrMeta token.
await page.keyboard.press("ControlOrMeta+k");
@@ -38,9 +59,9 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => {
// Shift+/ opens the legend (same as ? on US keyboards) once the calendar has focus.
await page.keyboard.press("Shift+/");
- await expect(card).toContainText("You are ready");
+ await expect(card).toContainText("That's the basics");
- await card.getByRole("button", { name: "Finish" }).click();
+ await card.getByRole("button", { name: "I'm done" }).click();
await expect(card).toHaveCount(0);
await page.reload({ waitUntil: "domcontentloaded" });
diff --git a/packages/web/src/auth/posthog/track.ts b/packages/web/src/auth/posthog/track.ts
index 9087e6389..61dfd2e30 100644
--- a/packages/web/src/auth/posthog/track.ts
+++ b/packages/web/src/auth/posthog/track.ts
@@ -7,7 +7,22 @@ export type ProductEvent =
| "signup_completed"
| "login_completed"
| "event_created"
- | "calendar_connected";
+ | "calendar_connected"
+ | "onboarding_game_started"
+ | "onboarding_task_completed"
+ | "onboarding_segment_reached"
+ | "onboarding_game_skipped"
+ | "onboarding_game_finished"
+ | "onboarding_game_replayed"
+ | "connect_cta_shown"
+ | "connect_cta_accepted"
+ | "connect_cta_skipped"
+ | "trial_cta_shown"
+ | "trial_started"
+ | "trial_converted"
+ | "trial_expired"
+ | "shortcut_tip_shown"
+ | "shortcut_tip_acted_on";
/**
* Fire-and-forget capture for the small set of product-activation events.
diff --git a/packages/web/src/common/constants/storage.constants.ts b/packages/web/src/common/constants/storage.constants.ts
index 0702d6133..8a16f0e97 100644
--- a/packages/web/src/common/constants/storage.constants.ts
+++ b/packages/web/src/common/constants/storage.constants.ts
@@ -5,6 +5,15 @@ type StorageKey =
| "compass.onboarding.has-seen-anonymous-save-toast"
| "compass.onboarding.has-dismissed-demo-events-banner"
| "compass.onboarding.has-dismissed-tasks-removal-notice"
+ // Set when a new user hands off to signup/login from the welcome modal
+ // before starting the tour; consumed once, right after signup completes,
+ // to offer the tour instead of silently burning it forever.
+ | "compass.onboarding.has-pending-tour-offer"
+ // Persists which stage of the post-tour connect/trial flow a user is on,
+ // so a full-page OAuth redirect can resume on the right stage. "done"
+ // means completed or dismissed; absent means never triggered.
+ | "compass.onboarding.post-tour-stage"
+ | "compass.shortcuts.tips-muted"
| "compass.sidebar.width"
| "compass.theme"
| "compass.life.preferences"
@@ -29,6 +38,9 @@ export const STORAGE_KEYS: Record<
| "HAS_SEEN_ANONYMOUS_SAVE_TOAST"
| "HAS_DISMISSED_DEMO_EVENTS_BANNER"
| "HAS_DISMISSED_TASKS_REMOVAL_NOTICE"
+ | "HAS_PENDING_TOUR_OFFER"
+ | "POST_TOUR_STAGE"
+ | "SHORTCUT_TIPS_MUTED"
| "LIFE_PREFERENCES"
| "SIDEBAR_WIDTH"
| "SIDEBAR_OPEN"
@@ -48,6 +60,9 @@ export const STORAGE_KEYS: Record<
"compass.onboarding.has-dismissed-demo-events-banner",
HAS_DISMISSED_TASKS_REMOVAL_NOTICE:
"compass.onboarding.has-dismissed-tasks-removal-notice",
+ HAS_PENDING_TOUR_OFFER: "compass.onboarding.has-pending-tour-offer",
+ POST_TOUR_STAGE: "compass.onboarding.post-tour-stage",
+ SHORTCUT_TIPS_MUTED: "compass.shortcuts.tips-muted",
LIFE_PREFERENCES: "compass.life.preferences",
SIDEBAR_WIDTH: "compass.sidebar.width",
SIDEBAR_OPEN: "compass.view.sidebar-open",
diff --git a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts
index e6ce4f291..44efadcca 100644
--- a/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts
+++ b/packages/web/src/components/AuthModal/hooks/useAuthFormHandlers.ts
@@ -9,6 +9,7 @@ import {
type SignUpFormData,
} from "@web/auth/compass/schemas/auth.schemas";
import { track } from "@web/auth/posthog/track";
+import { onboardingTourActions } from "@web/components/OnboardingTour/onboarding.tour.store";
import { releaseNotesPromptActions } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store";
import { getAuthSubmitErrorMessage } from "./useAuthFormHandlers.util";
import { type AuthView } from "./useAuthModal";
@@ -77,6 +78,7 @@ export function useAuthFormHandlers({
track("signup_completed", { method: "email" });
closeModal();
releaseNotesPromptActions.scheduleOpen();
+ onboardingTourActions.offerAfterSignupIfPending();
return;
case "FIELD_ERROR":
setSubmitError(response.formFields[0]?.error ?? "Sign up failed");
diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts
index 0f6cb9c29..c0663ea79 100644
--- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts
+++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts
@@ -36,9 +36,9 @@ describe("getNavigationCommandItems", () => {
onShowWelcomeGuide: () => {},
}).map((item) => item.label);
- expect(labels).toContain("Restart onboarding tour");
+ expect(labels).toContain("Practice shortcuts");
expect(labels).toContain("Show welcome guide");
- expect(labels.indexOf("Restart onboarding tour")).toBeLessThan(
+ expect(labels.indexOf("Practice shortcuts")).toBeLessThan(
labels.indexOf("Show welcome guide"),
);
});
diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts
index 1a32fe7b6..cee2c3c84 100644
--- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts
+++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts
@@ -142,9 +142,19 @@ export const getNavigationCommandItems = ({
if (onShowOnboardingTour) {
calendarItems.push({
id: "show-onboarding-tour",
- label: "Restart onboarding tour",
+ label: "Practice shortcuts",
icon: CompassIcon,
- keywords: ["onboarding", "tour", "intro", "help", "tutorial", "coach"],
+ keywords: [
+ "onboarding",
+ "tour",
+ "intro",
+ "help",
+ "tutorial",
+ "coach",
+ "sandbox",
+ "practice",
+ "shortcuts",
+ ],
onClick: onShowOnboardingTour,
});
}
diff --git a/packages/web/src/components/OnboardingTour/OnboardingTour.tsx b/packages/web/src/components/OnboardingTour/OnboardingTour.tsx
index 91397218e..3b624b46a 100644
--- a/packages/web/src/components/OnboardingTour/OnboardingTour.tsx
+++ b/packages/web/src/components/OnboardingTour/OnboardingTour.tsx
@@ -29,6 +29,7 @@ export const OnboardingTour: FC = () => {
const step = steps.find((entry) => entry.id === stepId) ?? steps[0];
const stepIndex = ONBOARDING_TOUR_STEP_IDS.indexOf(stepId);
const isDone = stepId === "done";
+ const isFork = stepId === "fork";
return (
) : null}
-
- {isDone ? (
-
+ {isFork ? (
+ <>
+
+
+ >
) : (
-
+ <>
+
+ {isDone ? (
+
+ ) : (
+
+ )}
+ >
)}
diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts
index 1ef343589..912ead1e8 100644
--- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts
+++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.test.ts
@@ -15,11 +15,20 @@ describe("onboarding tour steps", () => {
}
});
- it("orders steps create → save → palette → shortcuts → done", () => {
+ it("orders basics create → save → moveFocus → editSequence → palette → shortcuts → fork", () => {
expect(getNextOnboardingStepId("create")).toBe("save");
- expect(getNextOnboardingStepId("save")).toBe("palette");
+ expect(getNextOnboardingStepId("save")).toBe("moveFocus");
+ expect(getNextOnboardingStepId("moveFocus")).toBe("editSequence");
+ expect(getNextOnboardingStepId("editSequence")).toBe("palette");
expect(getNextOnboardingStepId("palette")).toBe("shortcuts");
- expect(getNextOnboardingStepId("shortcuts")).toBe("done");
+ expect(getNextOnboardingStepId("shortcuts")).toBe("fork");
+ });
+
+ it("orders advanced fork → targetEvent → nudge → undo → done", () => {
+ expect(getNextOnboardingStepId("fork")).toBe("targetEvent");
+ expect(getNextOnboardingStepId("targetEvent")).toBe("nudge");
+ expect(getNextOnboardingStepId("nudge")).toBe("undo");
+ expect(getNextOnboardingStepId("undo")).toBe("done");
expect(getNextOnboardingStepId("done")).toBeNull();
});
diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
index 9dd208f12..b60954ac9 100644
--- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
+++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
@@ -1,7 +1,23 @@
import { getModifierKeyLabel } from "@web/shortcuts/shortcut.util";
-/** Single source of truth for step order; the type and id list below derive from it. */
-const STEP_IDS = ["create", "save", "palette", "shortcuts", "done"] as const;
+/**
+ * Single source of truth for step order. "fork" is not a lesson: it's the
+ * exit ramp between the basics (required-feeling) and advanced (extra
+ * credit) segments — see OnboardingTour.tsx for its two-button UI.
+ */
+const STEP_IDS = [
+ "create",
+ "save",
+ "moveFocus",
+ "editSequence",
+ "palette",
+ "shortcuts",
+ "fork",
+ "targetEvent",
+ "nudge",
+ "undo",
+ "done",
+] as const;
export type OnboardingTourStepId = (typeof STEP_IDS)[number];
@@ -31,6 +47,16 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
body: "Type a title, then press Enter to save. Changes show up instantly.",
shortcutHint: "Enter",
},
+ moveFocus: {
+ title: "Move between events",
+ body: "Press an arrow key to move focus from event to event without touching the mouse.",
+ shortcutHint: "Arrow keys",
+ },
+ editSequence: {
+ title: "Jump straight to a field",
+ body: "Press E, then T, to jump straight into an event's title. Every field has its own letter.",
+ shortcutHint: "E then T",
+ },
palette: {
title: "Open the command palette",
body: `Press ${mod}+K for commands. Browse or search, then close with Escape.`,
@@ -41,6 +67,25 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
body: "Press ? from the calendar to open the shortcut legend. Search it anytime you forget a key.",
shortcutHint: "?",
},
+ fork: {
+ title: "That's the basics",
+ body: "You know enough to fly. Want a few extra-credit moves for rescheduling fast, or are you good for now?",
+ },
+ targetEvent: {
+ title: "Jump to any event",
+ body: "Tap Shift once to flash a key over every visible event, then press it to jump straight there. Great when there are a few on the same day.",
+ shortcutHint: "Shift",
+ },
+ nudge: {
+ title: "Nudge into the perfect slot",
+ body: "With an event focused, hold Shift and press an arrow key to slide it a few minutes at a time.",
+ shortcutHint: "Shift + Arrow",
+ },
+ undo: {
+ title: "Never stress about a mistake",
+ body: `Made a change you didn't mean? Press ${mod}+Z to undo it, ${mod}+Shift+Z to redo.`,
+ shortcutHint: `${mod}+Z`,
+ },
done: {
title: "You are ready",
body: "You can do anything with the keyboard. Try Shift Shift to practice; clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.",
diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts
index af06ace0d..2792e5ba2 100644
--- a/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts
+++ b/packages/web/src/components/OnboardingTour/onboarding.tour.storage.ts
@@ -11,3 +11,19 @@ export function hasSeenOnboardingTour(): boolean {
export function markOnboardingTourSeen(): void {
persistentBrowserStore.set(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR, "true");
}
+
+/** Set when a welcome-modal exit hands off to signup instead of starting the tour. */
+export function markTourOfferPending(): void {
+ persistentBrowserStore.set(STORAGE_KEYS.HAS_PENDING_TOUR_OFFER, "true");
+}
+
+/** Consumed once, right after signup completes, to decide whether to offer the tour. */
+export function consumePendingTourOffer(): boolean {
+ if (!persistentBrowserStore.isAvailable()) return false;
+ const pending =
+ persistentBrowserStore.get(STORAGE_KEYS.HAS_PENDING_TOUR_OFFER) === "true";
+ if (pending) {
+ persistentBrowserStore.remove(STORAGE_KEYS.HAS_PENDING_TOUR_OFFER);
+ }
+ return pending;
+}
diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts
index 12ed3c5c9..3cfb0bb22 100644
--- a/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts
+++ b/packages/web/src/components/OnboardingTour/onboarding.tour.store.test.ts
@@ -21,13 +21,49 @@ describe("onboardingTourActions", () => {
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("save");
onboardingTourActions.advance();
+ expect(useOnboardingTourStore.getState().stepId).toBe("moveFocus");
+ onboardingTourActions.advance();
+ expect(useOnboardingTourStore.getState().stepId).toBe("editSequence");
+ onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("palette");
onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("shortcuts");
onboardingTourActions.advance();
+ expect(useOnboardingTourStore.getState().stepId).toBe("fork");
+ onboardingTourActions.advance();
+ expect(useOnboardingTourStore.getState().stepId).toBe("targetEvent");
+ onboardingTourActions.advance();
+ expect(useOnboardingTourStore.getState().stepId).toBe("nudge");
+ onboardingTourActions.advance();
+ expect(useOnboardingTourStore.getState().stepId).toBe("undo");
+ onboardingTourActions.advance();
expect(useOnboardingTourStore.getState().stepId).toBe("done");
});
+ it("skip at the fork ends the tour without entering the advanced segment", () => {
+ onboardingTourActions.start();
+ useOnboardingTourStore.setState({ stepId: "fork" });
+ onboardingTourActions.skip();
+ expect(useOnboardingTourStore.getState().isActive).toBe(false);
+ expect(useOnboardingTourStore.getState().stepId).toBe("create");
+ });
+
+ it("defers the seen flag when heading into signup, then redeems it once", () => {
+ onboardingTourActions.markSkippedWithoutStarting({ pendingSignup: true });
+ expect(
+ persistentBrowserStore.get(STORAGE_KEYS.HAS_SEEN_ONBOARDING_TOUR),
+ ).not.toBe("true");
+
+ onboardingTourActions.offerAfterSignupIfPending();
+ expect(useOnboardingTourStore.getState().isActive).toBe(true);
+ expect(useOnboardingTourStore.getState().stepId).toBe("create");
+
+ // Second redemption attempt is a no-op: the pending flag was consumed.
+ useOnboardingTourStore.setState(initialOnboardingTourState);
+ onboardingTourActions.offerAfterSignupIfPending();
+ expect(useOnboardingTourStore.getState().isActive).toBe(false);
+ });
+
it("finish and skip persist the seen flag and clear active state", () => {
onboardingTourActions.start();
onboardingTourActions.finish();
diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts
index 16f71d48a..b54304cd7 100644
--- a/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts
+++ b/packages/web/src/components/OnboardingTour/onboarding.tour.store.ts
@@ -1,11 +1,14 @@
import { create } from "zustand";
+import { track } from "@web/auth/posthog/track";
import {
getNextOnboardingStepId,
type OnboardingTourStepId,
} from "@web/components/OnboardingTour/onboarding.tour.steps";
import {
+ consumePendingTourOffer,
hasSeenOnboardingTour,
markOnboardingTourSeen,
+ markTourOfferPending,
} from "@web/components/OnboardingTour/onboarding.tour.storage";
export type OnboardingTourState = {
@@ -33,29 +36,58 @@ export const onboardingTourActions = {
start: () => {
if (hasSeenOnboardingTour()) return;
useOnboardingTourStore.setState({ isActive: true, stepId: "create" });
+ track("onboarding_game_started");
},
/** Palette re-entry: always restart from the first step. */
restart: () => {
useOnboardingTourStore.setState({ isActive: true, stepId: "create" });
+ track("onboarding_game_replayed", { source: "palette" });
},
advance: () => {
const { isActive, stepId } = useOnboardingTourStore.getState();
if (!isActive) return;
+ track("onboarding_task_completed", { task: stepId });
const next = getNextOnboardingStepId(stepId);
if (!next) {
onboardingTourActions.finish();
return;
}
+ if (stepId === "fork") {
+ track("onboarding_segment_reached", { segment: "advanced" });
+ }
useOnboardingTourStore.setState({ stepId: next });
},
/** Reached the last step. */
- finish: endTour,
- /** User dismissed the tour early (Skip button or Escape). */
- skip: endTour,
- /** Welcome backdrop/auth dismiss: never trap; mark seen without starting. */
- markSkippedWithoutStarting: () => {
+ finish: () => {
+ track("onboarding_game_finished");
+ endTour();
+ },
+ /** User dismissed the tour early (Skip button, the fork's "I'm done", or Escape). */
+ skip: () => {
+ const { stepId } = useOnboardingTourStore.getState();
+ track("onboarding_game_skipped", { step: stepId });
+ endTour();
+ },
+ /**
+ * Welcome backdrop/auth dismiss: never trap. If the user is heading into
+ * signup, defer the seen-flag so the tour can be offered once, right after
+ * signup completes, instead of being burned forever (log-in and plain
+ * dismiss mark it seen immediately — only signup gets the deferred offer).
+ */
+ markSkippedWithoutStarting: (options?: { pendingSignup?: boolean }) => {
+ if (options?.pendingSignup) {
+ markTourOfferPending();
+ return;
+ }
markOnboardingTourSeen();
},
+ /** Called once, right after signup completes, to redeem a pending offer. */
+ offerAfterSignupIfPending: () => {
+ if (!consumePendingTourOffer()) return;
+ if (hasSeenOnboardingTour()) return;
+ useOnboardingTourStore.setState({ isActive: true, stepId: "create" });
+ track("onboarding_game_started");
+ },
};
export const selectOnboardingTourActive = (state: OnboardingTourState) =>
diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts
index 7783a52a8..9a2fd0464 100644
--- a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts
+++ b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.test.ts
@@ -127,8 +127,8 @@ describe("useOnboardingTourProgress palette step", () => {
});
await waitFor(() => {
- // Palette advance + shortcuts already-open cascade lands on done.
- expect(useOnboardingTourStore.getState().stepId).toBe("done");
+ // Palette advance + shortcuts already-open cascade lands on fork.
+ expect(useOnboardingTourStore.getState().stepId).toBe("fork");
});
});
diff --git a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts
index ae2f7687b..656e36a23 100644
--- a/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts
+++ b/packages/web/src/components/OnboardingTour/useOnboardingTourProgress.ts
@@ -85,11 +85,59 @@ export function useOnboardingTourProgress() {
return;
}
+ // The E-then-T sequence reopens the form; enough to count as the lesson.
+ if (stepId === "editSequence" && isFormOpen) {
+ onboardingTourActions.advance();
+ return;
+ }
+
if (stepId === "shortcuts" && isShortcutsOpen) {
onboardingTourActions.advance();
}
}, [isActive, stepId, isFormOpen, isSaving, isShortcutsOpen]);
+ // Lessons taught by a single keypress: encouragement-based, like the rest
+ // of this hook — pressing the key is enough to count as the lesson, we do
+ // not verify the resulting focus/nudge/undo actually landed.
+ useEffect(() => {
+ if (!isActive) return;
+ if (
+ stepId !== "moveFocus" &&
+ stepId !== "targetEvent" &&
+ stepId !== "nudge" &&
+ stepId !== "undo"
+ ) {
+ return;
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ const mod = event.metaKey || event.ctrlKey;
+ if (
+ stepId === "moveFocus" &&
+ !event.shiftKey &&
+ !mod &&
+ event.key.startsWith("Arrow")
+ ) {
+ onboardingTourActions.advance();
+ } else if (stepId === "targetEvent" && event.key === "Shift") {
+ onboardingTourActions.advance();
+ } else if (
+ stepId === "nudge" &&
+ event.shiftKey &&
+ event.key.startsWith("Arrow")
+ ) {
+ onboardingTourActions.advance();
+ } else if (stepId === "undo" && mod && event.key.toLowerCase() === "z") {
+ onboardingTourActions.advance();
+ }
+ };
+
+ document.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ };
+ }, [isActive, stepId]);
+
// ESC skips the tour when nothing higher owns Escape. Capture + stand down
// for app lock / form / floating layers so we never trap the user.
useEffect(() => {
diff --git a/packages/web/src/components/PostOnboardingFlow/ConnectGoogleCTA.tsx b/packages/web/src/components/PostOnboardingFlow/ConnectGoogleCTA.tsx
new file mode 100644
index 000000000..0fc25d474
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/ConnectGoogleCTA.tsx
@@ -0,0 +1,72 @@
+import { type FC, useEffect, useRef } from "react";
+import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle";
+import { track } from "@web/auth/posthog/track";
+import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants";
+import { postOnboardingFlowActions } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store";
+
+/**
+ * Shown once, right after the onboarding tour ends. Connecting starts a
+ * background import (see useGoogleUiState / isFirstImportInProgress) so the
+ * user is never blocked from continuing to explore.
+ */
+export const ConnectGoogleCTA: FC = () => {
+ const { connect, isAvailable } = useConnectGoogle();
+ const shownRef = useRef(false);
+
+ useEffect(() => {
+ if (!isAvailable) {
+ // isAvailable starts false while the /config fetch that determines it
+ // is still in flight, so an instant skip here would race that fetch
+ // and strand real deployments. Give it a moment; if Google genuinely
+ // isn't configured, don't leave the user stuck on a step with nothing
+ // to click.
+ const timer = window.setTimeout(() => {
+ if (!isAvailable) postOnboardingFlowActions.skipConnect();
+ }, 4000);
+ return () => window.clearTimeout(timer);
+ }
+ if (!shownRef.current) {
+ shownRef.current = true;
+ track("connect_cta_shown");
+ }
+ }, [isAvailable]);
+
+ if (!isAvailable) return null;
+
+ const onConnect = () => {
+ postOnboardingFlowActions.acceptConnect();
+ connect();
+ };
+
+ return (
+
+
+
Bring in your calendar
+
+ Connect Google Calendar and we will import your events in the
+ background while you keep exploring. You can always do this later.
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx
new file mode 100644
index 000000000..531aa2b61
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.test.tsx
@@ -0,0 +1,61 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import { type ReactNode } from "react";
+import { SessionContext } from "@web/auth/compass/session/session.context";
+import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
+import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";
+import { PostOnboardingFlow } from "@web/components/PostOnboardingFlow/PostOnboardingFlow";
+import { usePostOnboardingFlowStore } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store";
+import { beforeEach, describe, expect, it } from "bun:test";
+
+function renderFlow(ui: ReactNode, authenticated: boolean) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+ {} }}
+ >
+ {ui}
+
+ ,
+ );
+}
+
+describe("PostOnboardingFlow", () => {
+ beforeEach(() => {
+ persistentBrowserStore.set(STORAGE_KEYS.POST_TOUR_STAGE, "");
+ });
+
+ it("renders the trial CTA for an anonymous user with a pending trial stage", () => {
+ usePostOnboardingFlowStore.setState({ stage: "trial" });
+ renderFlow(, false);
+
+ expect(
+ screen.getByRole("region", { name: "Start your trial" }),
+ ).toBeInTheDocument();
+ });
+
+ it("never renders for an authenticated user, even with a stale connect/trial stage", () => {
+ // Simulates a user who reached "connect"/"trial" anonymously, then
+ // authenticated by a path other than the Connect Google CTA, leaving a
+ // stale stage in localStorage/the store from a prior session.
+ usePostOnboardingFlowStore.setState({ stage: "trial" });
+ renderFlow(, true);
+
+ expect(
+ screen.queryByRole("region", { name: "Start your trial" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("region", { name: "Connect Google Calendar" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("resolves a stale connect/trial stage to done once authenticated becomes true", () => {
+ usePostOnboardingFlowStore.setState({ stage: "connect" });
+ renderFlow(, true);
+
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("done");
+ });
+});
diff --git a/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx
new file mode 100644
index 000000000..e617ce9fe
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx
@@ -0,0 +1,30 @@
+import { type FC, useContext } from "react";
+import { SessionContext } from "@web/auth/compass/session/session.context";
+import { ConnectGoogleCTA } from "@web/components/PostOnboardingFlow/ConnectGoogleCTA";
+import {
+ selectPostOnboardingStage,
+ usePostOnboardingFlowStore,
+} from "@web/components/PostOnboardingFlow/post-onboarding-flow.store";
+import { TrialCTA } from "@web/components/PostOnboardingFlow/TrialCTA";
+import { usePostOnboardingFlowTrigger } from "@web/components/PostOnboardingFlow/usePostOnboardingFlowTrigger";
+
+/**
+ * Renders the connect-Google and trial CTAs after the onboarding tour ends.
+ * Both steps are skippable; skipping connect goes straight to the trial CTA.
+ *
+ * Gated on `authenticated` here too (not just at the trigger that sets
+ * `stage`): a user can become authenticated by a path other than accepting
+ * Connect Google, which would otherwise leave a stale "connect"/"trial"
+ * stage in localStorage that renders on their next load even though
+ * usePostOnboardingFlowTrigger's own effect resolves it moments later.
+ */
+export const PostOnboardingFlow: FC = () => {
+ usePostOnboardingFlowTrigger();
+ const { authenticated } = useContext(SessionContext);
+ const stage = usePostOnboardingFlowStore(selectPostOnboardingStage);
+
+ if (authenticated) return null;
+ if (stage === "connect") return ;
+ if (stage === "trial") return ;
+ return null;
+};
diff --git a/packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx b/packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx
new file mode 100644
index 000000000..f5fb07f76
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx
@@ -0,0 +1,66 @@
+import { type FC, useContext, useEffect, useRef } from "react";
+import { SessionContext } from "@web/auth/compass/session/session.context";
+import { track } from "@web/auth/posthog/track";
+import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants";
+import { useAuthModal } from "@web/components/AuthModal/hooks/useAuthModal";
+import { postOnboardingFlowActions } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store";
+
+/**
+ * "Ready to try it for real?" — the last step of the connect/trial flow.
+ * Billing does not exist yet (see keyboard-education/03), so accepting for
+ * an anonymous user opens signup; an already-authenticated user (arrived via
+ * Connect Google, which signs up on its own) gets a coming-soon toast in
+ * place of real checkout until 03 ships.
+ */
+export const TrialCTA: FC = () => {
+ const { authenticated } = useContext(SessionContext);
+ const { openModal } = useAuthModal();
+ const shownRef = useRef(false);
+
+ useEffect(() => {
+ if (!shownRef.current) {
+ shownRef.current = true;
+ track("trial_cta_shown");
+ }
+ }, []);
+
+ const onStartTrial = () => {
+ track("trial_started");
+ if (!authenticated) {
+ openModal("signUp");
+ return;
+ }
+ postOnboardingFlowActions.dismissTrial();
+ };
+
+ return (
+
+
+
Ready to try it for real?
+
+ Start a free trial and keep everything you just set up.
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts
new file mode 100644
index 000000000..304bd1524
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts
@@ -0,0 +1,17 @@
+import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
+import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";
+
+export type PostOnboardingStage = "connect" | "trial" | "done";
+
+/** Null means the flow has never been triggered for this browser. */
+export function getPostOnboardingStage(): PostOnboardingStage | null {
+ const value = persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE);
+ if (value === "connect" || value === "trial" || value === "done") {
+ return value;
+ }
+ return null;
+}
+
+export function setPostOnboardingStage(stage: PostOnboardingStage): void {
+ persistentBrowserStore.set(STORAGE_KEYS.POST_TOUR_STAGE, stage);
+}
diff --git a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts
new file mode 100644
index 000000000..cc50e9930
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts
@@ -0,0 +1,71 @@
+import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
+import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";
+import {
+ postOnboardingFlowActions,
+ usePostOnboardingFlowStore,
+} from "@web/components/PostOnboardingFlow/post-onboarding-flow.store";
+import { beforeEach, describe, expect, it } from "bun:test";
+
+describe("postOnboardingFlowActions", () => {
+ beforeEach(() => {
+ persistentBrowserStore.set(STORAGE_KEYS.POST_TOUR_STAGE, "");
+ usePostOnboardingFlowStore.setState({ stage: null });
+ });
+
+ it("starts at connect and persists the stage", () => {
+ postOnboardingFlowActions.startAfterTour();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("connect");
+ expect(persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE)).toBe(
+ "connect",
+ );
+ });
+
+ it("does not retrigger once already started", () => {
+ postOnboardingFlowActions.startAfterTour();
+ postOnboardingFlowActions.skipConnect();
+ postOnboardingFlowActions.startAfterTour();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("trial");
+ });
+
+ it("skip and accept both move connect to trial", () => {
+ postOnboardingFlowActions.startAfterTour();
+ postOnboardingFlowActions.skipConnect();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("trial");
+
+ usePostOnboardingFlowStore.setState({ stage: "connect" });
+ postOnboardingFlowActions.acceptConnect();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("trial");
+ });
+
+ it("dismissing the trial CTA ends the flow for good", () => {
+ usePostOnboardingFlowStore.setState({ stage: "trial" });
+ postOnboardingFlowActions.dismissTrial();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("done");
+ expect(persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE)).toBe(
+ "done",
+ );
+ });
+
+ it("resolveOnAuth clears a stale connect/trial stage left by a non-Google login", () => {
+ usePostOnboardingFlowStore.setState({ stage: "connect" });
+ postOnboardingFlowActions.resolveOnAuth();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("done");
+ expect(persistentBrowserStore.get(STORAGE_KEYS.POST_TOUR_STAGE)).toBe(
+ "done",
+ );
+
+ usePostOnboardingFlowStore.setState({ stage: "trial" });
+ postOnboardingFlowActions.resolveOnAuth();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("done");
+ });
+
+ it("resolveOnAuth is a no-op when there is nothing to resolve", () => {
+ usePostOnboardingFlowStore.setState({ stage: null });
+ postOnboardingFlowActions.resolveOnAuth();
+ expect(usePostOnboardingFlowStore.getState().stage).toBeNull();
+
+ usePostOnboardingFlowStore.setState({ stage: "done" });
+ postOnboardingFlowActions.resolveOnAuth();
+ expect(usePostOnboardingFlowStore.getState().stage).toBe("done");
+ });
+});
diff --git a/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts
new file mode 100644
index 000000000..79d5bffbf
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts
@@ -0,0 +1,61 @@
+import { create } from "zustand";
+import { track } from "@web/auth/posthog/track";
+import {
+ getPostOnboardingStage,
+ type PostOnboardingStage,
+ setPostOnboardingStage,
+} from "@web/components/PostOnboardingFlow/post-onboarding-flow.storage";
+
+export type PostOnboardingFlowState = {
+ /** null: never triggered, so nothing renders. */
+ stage: PostOnboardingStage | null;
+};
+
+export const usePostOnboardingFlowStore = create()(
+ () => ({
+ stage: getPostOnboardingStage(),
+ }),
+);
+
+const setStage = (stage: PostOnboardingStage) => {
+ setPostOnboardingStage(stage);
+ usePostOnboardingFlowStore.setState({ stage });
+};
+
+export const postOnboardingFlowActions = {
+ /**
+ * Called once the onboarding tour ends (finish or skip). A no-op if this
+ * browser already went through the flow, so replaying the tour from the
+ * command palette never re-shows connect/trial CTAs to a returning user.
+ */
+ startAfterTour: () => {
+ if (usePostOnboardingFlowStore.getState().stage !== null) return;
+ setStage("connect");
+ },
+ /** Google OAuth is a full navigation; persist the next stage first. */
+ acceptConnect: () => {
+ track("connect_cta_accepted");
+ setStage("trial");
+ },
+ skipConnect: () => {
+ track("connect_cta_skipped");
+ setStage("trial");
+ },
+ dismissTrial: () => {
+ setStage("done");
+ },
+ /**
+ * Resolves a stale "connect"/"trial" stage once the user is authenticated
+ * by any path (not just accepting the Connect Google CTA), so a returning
+ * established user is never shown these CTAs again.
+ */
+ resolveOnAuth: () => {
+ const { stage } = usePostOnboardingFlowStore.getState();
+ if (stage === "connect" || stage === "trial") {
+ setStage("done");
+ }
+ },
+};
+
+export const selectPostOnboardingStage = (state: PostOnboardingFlowState) =>
+ state.stage;
diff --git a/packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts b/packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts
new file mode 100644
index 000000000..adf638569
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts
@@ -0,0 +1,37 @@
+import { useContext, useEffect, useRef } from "react";
+import { SessionContext } from "@web/auth/compass/session/session.context";
+import {
+ selectOnboardingTourActive,
+ useOnboardingTourStore,
+} from "@web/components/OnboardingTour/onboarding.tour.store";
+import { postOnboardingFlowActions } from "@web/components/PostOnboardingFlow/post-onboarding-flow.store";
+
+/**
+ * Starts the connect/trial flow the moment the onboarding tour ends
+ * (finish or skip), for anonymous users only. Authenticated users already
+ * connected or paying have nothing to gain from these CTAs.
+ */
+export function usePostOnboardingFlowTrigger() {
+ const { authenticated } = useContext(SessionContext);
+ const isTourActive = useOnboardingTourStore(selectOnboardingTourActive);
+ const wasTourActive = useRef(false);
+
+ useEffect(() => {
+ if (wasTourActive.current && !isTourActive && !authenticated) {
+ postOnboardingFlowActions.startAfterTour();
+ }
+ wasTourActive.current = isTourActive;
+ }, [isTourActive, authenticated]);
+
+ // A user can become authenticated by a path other than accepting the
+ // Connect Google CTA (e.g. email/password login, or Google auth from
+ // elsewhere in the app), leaving a stale "connect"/"trial" stage in
+ // localStorage. Resolve it so an established, possibly already-paying
+ // user is never shown these CTAs on a later load. PostOnboardingFlow also
+ // gates its own render on `authenticated` as defense in depth.
+ useEffect(() => {
+ if (authenticated) {
+ postOnboardingFlowActions.resolveOnAuth();
+ }
+ }, [authenticated]);
+}
diff --git a/packages/web/src/components/RootShell/RootShell.tsx b/packages/web/src/components/RootShell/RootShell.tsx
index 7c16c2097..93ecaacf8 100644
--- a/packages/web/src/components/RootShell/RootShell.tsx
+++ b/packages/web/src/components/RootShell/RootShell.tsx
@@ -2,6 +2,7 @@ import { Outlet } from "@tanstack/react-router";
import { AuthModal } from "@web/components/AuthModal/AuthModal";
import { AuthModalProvider } from "@web/components/AuthModal/AuthModalProvider";
import { OnboardingTour } from "@web/components/OnboardingTour/OnboardingTour";
+import { PostOnboardingFlow } from "@web/components/PostOnboardingFlow/PostOnboardingFlow";
import { ReleaseNotesPrompt } from "@web/components/ReleaseNotesPrompt/ReleaseNotesPrompt";
import {
selectReleaseNotesPromptOpen,
@@ -40,6 +41,7 @@ export function RootShell() {
+
{isWelcomeGuideOpen && }
{isReleaseNotesPromptOpen && }
diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx
index f5f0b37af..cd7d89fd8 100644
--- a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx
+++ b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx
@@ -25,6 +25,12 @@ import {
selectEventJumpAnnouncement,
useEventJumpStore,
} from "@web/shortcuts/shift-hint/event-jump.store";
+import { ShortcutTipIndicator } from "@web/shortcuts/tips/ShortcutTipIndicator";
+import {
+ selectActiveShortcutTipId,
+ useShortcutTipsStore,
+} from "@web/shortcuts/tips/shortcut-tips.store";
+import { useShortcutTipTrigger } from "@web/shortcuts/tips/useShortcutTipTrigger";
import { useSseDegraded } from "@web/sse/hooks/useSseDegraded";
/**
@@ -39,6 +45,8 @@ import { useSseDegraded } from "@web/sse/hooks/useSseDegraded";
* the meaning; `title` is the safety net if anything still overflows.
*/
export const SidebarStatusBar: FC = () => {
+ useShortcutTipTrigger();
+ const activeTipId = useShortcutTipsStore(selectActiveShortcutTipId);
const isKeyboardOnly = useKeyboardOnlyStore(selectKeyboardOnlyActive);
const isEventJump = useEventJumpStore(selectEventJumpActive);
const eventJumpAnnouncement = useEventJumpStore(selectEventJumpAnnouncement);
@@ -86,6 +94,10 @@ export const SidebarStatusBar: FC = () => {
+ ) : !status && activeTipId ? (
+
+
+
) : (