From cd47cbb65442cf9407234f4d287ee419d74b9576 Mon Sep 17 00:00:00 2001
From: Tyler Dane
Date: Mon, 10 Aug 2026 13:19:15 -0600
Subject: [PATCH 1/7] feat(web): extend onboarding tour into a fuller shortcut
lesson set
Adds four new lessons (move focus, edit-sequence jump, Shift-tap
targeting, Shift+Arrow nudge, undo) behind a basics/advanced fork so
new users get the required-feeling basics and can opt into extra
credit without ever being held hostage. Also fixes a bug where any
welcome-modal exit into signup permanently burned the tour-seen flag;
it's now offered once right after signup completes. Widens the
PostHog ProductEvent union and instruments the tour end to end.
Keyboard-only mouse-disable and ephemeral synthetic sandbox events
are deferred to a fast-follow (see keyboard-education/01 brief) since
they touch shared query/interaction code that deserves its own pass.
---
packages/web/src/auth/posthog/track.ts | 17 ++++-
.../src/common/constants/storage.constants.ts | 6 ++
.../AuthModal/hooks/useAuthFormHandlers.ts | 2 +
.../navigation.cmd.constants.ts | 14 +++-
.../OnboardingTour/OnboardingTour.tsx | 66 ++++++++++++-------
.../onboarding.tour.steps.test.ts | 15 ++++-
.../OnboardingTour/onboarding.tour.steps.ts | 56 +++++++++++++++-
.../OnboardingTour/onboarding.tour.storage.ts | 16 +++++
.../onboarding.tour.store.test.ts | 36 ++++++++++
.../OnboardingTour/onboarding.tour.store.ts | 42 ++++++++++--
.../useOnboardingTourProgress.test.ts | 4 +-
.../useOnboardingTourProgress.ts | 48 ++++++++++++++
.../components/WelcomeModal/WelcomeModal.tsx | 4 +-
.../GoogleAuthCallback/GoogleAuthCallback.tsx | 2 +
14 files changed, 290 insertions(+), 38 deletions(-)
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..fe625056d 100644
--- a/packages/web/src/common/constants/storage.constants.ts
+++ b/packages/web/src/common/constants/storage.constants.ts
@@ -5,6 +5,10 @@ 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"
| "compass.sidebar.width"
| "compass.theme"
| "compass.life.preferences"
@@ -29,6 +33,7 @@ export const STORAGE_KEYS: Record<
| "HAS_SEEN_ANONYMOUS_SAVE_TOAST"
| "HAS_DISMISSED_DEMO_EVENTS_BANNER"
| "HAS_DISMISSED_TASKS_REMOVAL_NOTICE"
+ | "HAS_PENDING_TOUR_OFFER"
| "LIFE_PREFERENCES"
| "SIDEBAR_WIDTH"
| "SIDEBAR_OPEN"
@@ -48,6 +53,7 @@ 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",
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.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..53a9c5311 100644
--- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
+++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
@@ -1,12 +1,35 @@
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];
export const ONBOARDING_TOUR_STEP_IDS: OnboardingTourStepId[] = [...STEP_IDS];
+/** Steps at or before "fork" count as the required-feeling basics segment. */
+export const ONBOARDING_BASICS_STEP_IDS: OnboardingTourStepId[] =
+ ONBOARDING_TOUR_STEP_IDS.slice(
+ 0,
+ ONBOARDING_TOUR_STEP_IDS.indexOf("fork") + 1,
+ );
+
export type OnboardingTourStep = {
id: OnboardingTourStepId;
title: string;
@@ -31,6 +54,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 +74,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/WelcomeModal/WelcomeModal.tsx b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
index c92fcb4e8..1ce8dbd42 100644
--- a/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
+++ b/packages/web/src/components/WelcomeModal/WelcomeModal.tsx
@@ -69,7 +69,9 @@ export function WelcomeModal() {
const handOffToAuth = (cta: "log_in" | "sign_up") => {
skipFocusRestoreRef.current = true;
markWelcomeSeen();
- onboardingTourActions.markSkippedWithoutStarting();
+ onboardingTourActions.markSkippedWithoutStarting({
+ pendingSignup: cta === "sign_up",
+ });
track("welcome_modal_dismissed", { cta });
if (cta === "sign_up") {
track("signup_started", { source: "welcome_modal" });
diff --git a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx
index a3924be3d..18279d6e6 100644
--- a/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx
+++ b/packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx
@@ -5,6 +5,7 @@ import { useCompleteAuthentication } from "@web/auth/compass/hooks/useCompleteAu
import { completeGoogleAuthorization } from "@web/auth/google/authorization/complete-google-authorization";
import { track } from "@web/auth/posthog/track";
import { showErrorToast } from "@web/common/utils/toast/error-toast.util";
+import { onboardingTourActions } from "@web/components/OnboardingTour/onboarding.tour.store";
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
import { releaseNotesPromptActions } from "@web/components/ReleaseNotesPrompt/release-notes-prompt.store";
@@ -32,6 +33,7 @@ export async function completeGoogleAuthCallback({
} else if (result.isNewUser) {
track("signup_completed", { method: "google" });
releaseNotesPromptActions.scheduleOpen();
+ onboardingTourActions.offerAfterSignupIfPending();
} else {
track("login_completed", { method: "google" });
}
From a80a4fae1cb32083b336be342d778392cfbb0a38 Mon Sep 17 00:00:00 2001
From: Tyler Dane
Date: Mon, 10 Aug 2026 13:24:33 -0600
Subject: [PATCH 2/7] feat(web): add connect-Google and trial CTAs after
onboarding
Once the onboarding tour ends (finish or skip), anonymous users now
see a skippable "Connect Google Calendar" CTA (import runs in the
background) followed by a "ready to try it for real?" trial CTA.
Skipping connect goes straight to the trial step. Stage is persisted
across the Google OAuth full-page redirect. Billing does not exist
yet, so accepting the trial CTA opens signup for anonymous users and
is a no-op stub for authenticated ones until the checkout brief lands.
---
.../src/common/constants/storage.constants.ts | 6 ++
.../PostOnboardingFlow/ConnectGoogleCTA.tsx | 72 +++++++++++++++++++
.../PostOnboardingFlow/PostOnboardingFlow.tsx | 21 ++++++
.../PostOnboardingFlow/TrialCTA.tsx | 66 +++++++++++++++++
.../post-onboarding-flow.storage.ts | 17 +++++
.../post-onboarding-flow.store.test.ts | 48 +++++++++++++
.../post-onboarding-flow.store.ts | 50 +++++++++++++
.../usePostOnboardingFlowTrigger.ts | 25 +++++++
.../src/components/RootShell/RootShell.tsx | 2 +
9 files changed, 307 insertions(+)
create mode 100644 packages/web/src/components/PostOnboardingFlow/ConnectGoogleCTA.tsx
create mode 100644 packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx
create mode 100644 packages/web/src/components/PostOnboardingFlow/TrialCTA.tsx
create mode 100644 packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.storage.ts
create mode 100644 packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts
create mode 100644 packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts
create mode 100644 packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts
diff --git a/packages/web/src/common/constants/storage.constants.ts b/packages/web/src/common/constants/storage.constants.ts
index fe625056d..6a7118fdb 100644
--- a/packages/web/src/common/constants/storage.constants.ts
+++ b/packages/web/src/common/constants/storage.constants.ts
@@ -9,6 +9,10 @@ type StorageKey =
// 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.sidebar.width"
| "compass.theme"
| "compass.life.preferences"
@@ -34,6 +38,7 @@ export const STORAGE_KEYS: Record<
| "HAS_DISMISSED_DEMO_EVENTS_BANNER"
| "HAS_DISMISSED_TASKS_REMOVAL_NOTICE"
| "HAS_PENDING_TOUR_OFFER"
+ | "POST_TOUR_STAGE"
| "LIFE_PREFERENCES"
| "SIDEBAR_WIDTH"
| "SIDEBAR_OPEN"
@@ -54,6 +59,7 @@ export const STORAGE_KEYS: Record<
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",
LIFE_PREFERENCES: "compass.life.preferences",
SIDEBAR_WIDTH: "compass.sidebar.width",
SIDEBAR_OPEN: "compass.view.sidebar-open",
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.tsx b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx
new file mode 100644
index 000000000..37bca65c0
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/PostOnboardingFlow.tsx
@@ -0,0 +1,21 @@
+import { type FC } from "react";
+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.
+ */
+export const PostOnboardingFlow: FC = () => {
+ usePostOnboardingFlowTrigger();
+ const stage = usePostOnboardingFlowStore(selectPostOnboardingStage);
+
+ 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..b03765ea8
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.test.ts
@@ -0,0 +1,48 @@
+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",
+ );
+ });
+});
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..f4a25337b
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/post-onboarding-flow.store.ts
@@ -0,0 +1,50 @@
+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");
+ },
+};
+
+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..96bf4f5b0
--- /dev/null
+++ b/packages/web/src/components/PostOnboardingFlow/usePostOnboardingFlowTrigger.ts
@@ -0,0 +1,25 @@
+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]);
+}
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 && }
From 78b321f8698505bbf190d1393c1ee2e4d1e4d07d Mon Sep 17 00:00:00 2001
From: Tyler Dane
Date: Mon, 10 Aug 2026 13:26:12 -0600
Subject: [PATCH 3/7] fix(web): drop unused ONBOARDING_BASICS_STEP_IDS export
(knip)
---
.../src/components/OnboardingTour/onboarding.tour.steps.ts | 7 -------
1 file changed, 7 deletions(-)
diff --git a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
index 53a9c5311..b60954ac9 100644
--- a/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
+++ b/packages/web/src/components/OnboardingTour/onboarding.tour.steps.ts
@@ -23,13 +23,6 @@ export type OnboardingTourStepId = (typeof STEP_IDS)[number];
export const ONBOARDING_TOUR_STEP_IDS: OnboardingTourStepId[] = [...STEP_IDS];
-/** Steps at or before "fork" count as the required-feeling basics segment. */
-export const ONBOARDING_BASICS_STEP_IDS: OnboardingTourStepId[] =
- ONBOARDING_TOUR_STEP_IDS.slice(
- 0,
- ONBOARDING_TOUR_STEP_IDS.indexOf("fork") + 1,
- );
-
export type OnboardingTourStep = {
id: OnboardingTourStepId;
title: string;
From f44871e8cfdbe0476cd27e1d1e7338545d118cd8 Mon Sep 17 00:00:00 2001
From: Tyler Dane
Date: Mon, 10 Aug 2026 13:32:19 -0600
Subject: [PATCH 4/7] feat(web): add quiet shortcut tips to the sidebar status
bar
Rotates a single-line tip (edit-sequence, nudge, targeting, edge
cycle) into the sidebar's status strip when an event is focused with
no form open, at lowest priority behind keyboard-only/event-jump/
edge-focus/sync-status. Capped to one rotation per 45s regardless of
focus churn, biased toward the edit-sequence tip after 3 consecutive
mouse-driven edits, muted permanently on click, and tracked via the
tour's shortcut_tip_shown/acted_on events.
---
.../src/common/constants/storage.constants.ts | 3 +
.../components/Sidebar/SidebarStatusBar.tsx | 12 ++
.../shortcuts/tips/ShortcutTipIndicator.tsx | 47 +++++++
.../src/shortcuts/tips/shortcut-tips.data.ts | 20 +++
.../shortcuts/tips/shortcut-tips.storage.ts | 12 ++
.../tips/shortcut-tips.store.test.ts | 89 ++++++++++++++
.../src/shortcuts/tips/shortcut-tips.store.ts | 115 ++++++++++++++++++
.../tips/useIsAnyCalendarEventFocused.ts | 40 ++++++
.../shortcuts/tips/useShortcutTipTrigger.ts | 69 +++++++++++
9 files changed, 407 insertions(+)
create mode 100644 packages/web/src/shortcuts/tips/ShortcutTipIndicator.tsx
create mode 100644 packages/web/src/shortcuts/tips/shortcut-tips.data.ts
create mode 100644 packages/web/src/shortcuts/tips/shortcut-tips.storage.ts
create mode 100644 packages/web/src/shortcuts/tips/shortcut-tips.store.test.ts
create mode 100644 packages/web/src/shortcuts/tips/shortcut-tips.store.ts
create mode 100644 packages/web/src/shortcuts/tips/useIsAnyCalendarEventFocused.ts
create mode 100644 packages/web/src/shortcuts/tips/useShortcutTipTrigger.ts
diff --git a/packages/web/src/common/constants/storage.constants.ts b/packages/web/src/common/constants/storage.constants.ts
index 6a7118fdb..8a16f0e97 100644
--- a/packages/web/src/common/constants/storage.constants.ts
+++ b/packages/web/src/common/constants/storage.constants.ts
@@ -13,6 +13,7 @@ type StorageKey =
// 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"
@@ -39,6 +40,7 @@ export const STORAGE_KEYS: Record<
| "HAS_DISMISSED_TASKS_REMOVAL_NOTICE"
| "HAS_PENDING_TOUR_OFFER"
| "POST_TOUR_STAGE"
+ | "SHORTCUT_TIPS_MUTED"
| "LIFE_PREFERENCES"
| "SIDEBAR_WIDTH"
| "SIDEBAR_OPEN"
@@ -60,6 +62,7 @@ export const STORAGE_KEYS: Record<
"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/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 ? (
+
+
+
) : (