diff --git a/packages/web/src/auth/posthog/track.ts b/packages/web/src/auth/posthog/track.ts
index 523bd5c4c..c79524a56 100644
--- a/packages/web/src/auth/posthog/track.ts
+++ b/packages/web/src/auth/posthog/track.ts
@@ -13,7 +13,7 @@ export type ProductEvent =
| "shortcut_showcase_step_redone"
| "shortcut_showcase_skipped"
| "shortcut_showcase_finished"
- | "shortcut_showcase_assist_shown"
+ | "shortcut_showcase_assist_used"
| "checklist_shown"
| "checklist_item_completed"
| "checklist_dismissed"
diff --git a/packages/web/src/components/OnboardingChecklist/OnboardingChecklist.tsx b/packages/web/src/components/OnboardingChecklist/OnboardingChecklist.tsx
index 4729479d3..2689324f0 100644
--- a/packages/web/src/components/OnboardingChecklist/OnboardingChecklist.tsx
+++ b/packages/web/src/components/OnboardingChecklist/OnboardingChecklist.tsx
@@ -62,7 +62,7 @@ const ChecklistCard: FC = () => {
<>
- Practice on real events
+ Practice on sample events
{doneCount}/{CHECKLIST_ITEMS.length}
@@ -71,9 +71,29 @@ const ChecklistCard: FC = () => {
{CHECKLIST_ITEMS.map((item) => {
const isComplete = Boolean(completed[item.id]);
+
+ // The exit of the flow, so it reads as a real CTA rather
+ // than one more thing to check off.
+ if (item.id === "signUp" && !isComplete) {
+ return (
+
+ {
+ track("signup_started", { source: "checklist" });
+ openModal("signUp");
+ }}
+ >
+ {item.label}
+
+
+ );
+ }
+
const keycaps = "keycaps" in item ? item.keycaps : undefined;
- const row = (
- <>
+ return (
+
{isComplete ? (
{
{keycaps && !isComplete && (
)}
- >
- );
- return (
-
- {item.id === "signUp" && !isComplete ? (
- {
- track("signup_started", { source: "checklist" });
- openModal("signUp");
- }}
- >
- {row}
-
- ) : (
- row
- )}
);
})}
diff --git a/packages/web/src/components/OnboardingChecklist/checklist.items.ts b/packages/web/src/components/OnboardingChecklist/checklist.items.ts
index 32173f2cc..98d64a24a 100644
--- a/packages/web/src/components/OnboardingChecklist/checklist.items.ts
+++ b/packages/web/src/components/OnboardingChecklist/checklist.items.ts
@@ -24,7 +24,7 @@ export const CHECKLIST_ITEMS = [
},
{
id: "placeDraft",
- label: "Drop a new event on the grid",
+ label: "Place a new event on the grid",
keycaps: KEYMAP.moveEvent.keycaps,
},
{ id: "undo", label: "Undo a change", keycaps: KEYMAP.undo.keycaps },
diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx
index e13d82dbc..f55e5f838 100644
--- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx
+++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx
@@ -3,7 +3,10 @@ import userEvent from "@testing-library/user-event";
import { STORAGE_KEYS } from "@web/common/constants/storage.constants";
import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store";
import { ShortcutShowcase } from "@web/components/ShortcutShowcase/ShortcutShowcase";
-import { SHOWCASE_STEP_IDS } from "@web/components/ShortcutShowcase/showcase.steps";
+import {
+ SHOWCASE_STEP_IDS,
+ type ShowcaseStepId,
+} from "@web/components/ShortcutShowcase/showcase.steps";
import {
initialShortcutShowcaseState,
shortcutShowcaseActions,
@@ -23,6 +26,16 @@ const pressKey = (key: string, init: KeyboardEventInit = {}) => {
const currentStepId = () =>
SHOWCASE_STEP_IDS[useShortcutShowcaseStore.getState().stepIndex];
+/** Jumps straight to a lesson; there is no store action for an arbitrary step. */
+const showStep = (id: ShowcaseStepId) => {
+ act(() =>
+ useShortcutShowcaseStore.setState({
+ isActive: true,
+ stepIndex: SHOWCASE_STEP_IDS.indexOf(id),
+ }),
+ );
+};
+
describe("ShortcutShowcase", () => {
beforeEach(() => {
useShortcutShowcaseStore.setState(initialShortcutShowcaseState);
@@ -116,6 +129,47 @@ describe("ShortcutShowcase", () => {
).toBe("true");
});
+ it("offers 'Do it for me' from the first step, and swaps it out at graduation", async () => {
+ const user = userEvent.setup();
+ render( );
+ act(() => shortcutShowcaseActions.start());
+
+ // No idle wait or failed attempt required: the way out is always offered.
+ await user.click(screen.getByRole("button", { name: "Do it for me" }));
+ expect(currentStepId()).toBe("save");
+
+ showStep("graduation");
+ expect(screen.queryByRole("button", { name: "Do it for me" })).toBeNull();
+ expect(screen.getByRole("button", { name: "Enter Compass" })).toBeTruthy();
+ });
+
+ it("shows the stretch hint one phase at a time: Tab, then Shift+Arrow", async () => {
+ const user = userEvent.setup();
+ render( );
+ showStep("resizeEdge");
+
+ // Phase one: only the key that moves focus onto the end time.
+ expect(screen.getByTestId("tab-icon")).toBeTruthy();
+ expect(screen.queryByTestId("shift-icon")).toBeNull();
+ expect(screen.queryByTestId("arrowdown-icon")).toBeNull();
+
+ pressKey("Tab");
+
+ // Phase two: the end edge has focus, so the chord replaces Tab.
+ expect(screen.queryByTestId("tab-icon")).toBeNull();
+ expect(screen.getByTestId("shift-icon")).toBeTruthy();
+ expect(screen.getByTestId("arrowdown-icon")).toBeTruthy();
+
+ // Leaving and returning re-seeds the start edge, so the lesson restarts
+ // at phase one rather than stranding the user on the chord.
+ pressKey("ArrowDown", { shiftKey: true });
+ expect(currentStepId()).toBe("placeDraft");
+ await user.click(screen.getByRole("button", { name: "Previous" }));
+ expect(currentStepId()).toBe("resizeEdge");
+ expect(screen.getByTestId("tab-icon")).toBeTruthy();
+ expect(screen.queryByTestId("shift-icon")).toBeNull();
+ });
+
it("Escape confirms once, lesson keys fall through, second Escape skips", () => {
render( );
act(() => shortcutShowcaseActions.start());
diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
index a542de6cd..0186796a0 100644
--- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
+++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
@@ -8,7 +8,6 @@ import {
} from "react";
import { track } from "@web/auth/posthog/track";
import { Z_INDEX_MODAL } from "@web/common/constants/web.constants";
-import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util";
import { PracticeCalendar } from "@web/components/ShortcutShowcase/PracticeCalendar";
import {
clearFocus,
@@ -34,7 +33,7 @@ import {
import {
getShowcaseStep,
SHOWCASE_STEP_IDS,
- type ShowcaseStepId,
+ STRETCH_KEYCAPS,
} from "@web/components/ShortcutShowcase/showcase.steps";
import {
selectShowcaseActive,
@@ -55,9 +54,6 @@ const TEXT_BUTTON_CLASS =
const PRIMARY_BUTTON_CLASS =
"c-button c-button-primary rounded-full px-4 py-1.5 text-xs";
-const ASSIST_IDLE_MS = 15_000;
-const ASSIST_ATTEMPT_THRESHOLD = 2;
-
const ARROW_DIRECTIONS: Record = {
ArrowUp: "up",
ArrowDown: "down",
@@ -65,43 +61,6 @@ const ARROW_DIRECTIONS: Record = {
ArrowRight: "right",
};
-/** "Show me" fallback, ported from the retired tour's assist hook. */
-function useShowcaseAssist(stepId: ShowcaseStepId): boolean {
- const [isVisible, setIsVisible] = useState(false);
- const attemptsRef = useRef(0);
- const revealedRef = useRef(false);
-
- useEffect(() => {
- setIsVisible(false);
- attemptsRef.current = 0;
- revealedRef.current = false;
- if (stepId === "graduation") return;
-
- const reveal = () => {
- if (revealedRef.current) return;
- revealedRef.current = true;
- setIsVisible(true);
- track("shortcut_showcase_assist_shown", { step: stepId });
- };
-
- const idleTimer = window.setTimeout(reveal, ASSIST_IDLE_MS);
- const onKeyDown = (event: KeyboardEvent) => {
- // Typing a title is progress, not a failed attempt at the shortcut.
- if (isEditableKeyboardTarget(event)) return;
- attemptsRef.current += 1;
- if (attemptsRef.current >= ASSIST_ATTEMPT_THRESHOLD) reveal();
- };
-
- document.addEventListener("keydown", onKeyDown);
- return () => {
- window.clearTimeout(idleTimer);
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [stepId]);
-
- return isVisible;
-}
-
/**
* Full-screen practice arena shown before a new user ever sees the real
* calendar. Bindings come from KEYMAP (shared with the real handlers);
@@ -115,7 +74,6 @@ const ShowcaseTakeover: FC = () => {
);
const stepId = stepIdAt(stepIndex);
const step = getShowcaseStep(stepId);
- const isAssistVisible = useShowcaseAssist(stepId);
// The takeover owns the keyboard: silence every real app handler
// (useAppShortcut, the e-sequence, bare-letter s/h) while it is up.
@@ -390,7 +348,9 @@ const ShowcaseTakeover: FC = () => {
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [apply]);
- const showMe = () => {
+ // Performs the lesson's action on the practice board, then moves on.
+ const doItForMe = () => {
+ track("shortcut_showcase_assist_used", { step: stepId });
switch (stepId) {
case "create":
apply(createDraft);
@@ -434,14 +394,15 @@ const ShowcaseTakeover: FC = () => {
case "hardcore":
apply((state) => (state.hardcoreOn ? state : toggleHardcore(state)));
break;
- case "graduation":
- shortcutShowcaseActions.finish();
- return;
}
advance();
};
const progressPercent = ((stepIndex + 1) / SHOWCASE_STEP_IDS.length) * 100;
+ // The stretch lesson teaches Tab first, then the chord, so it hints one
+ // press at a time rather than showing all three keys at once.
+ const isStretchPhase = stepId === "resizeEdge" && practice.edge === "end";
+ const keycaps = isStretchPhase ? STRETCH_KEYCAPS : step.keycaps;
return (
{step.title}
{step.body}
- {step.keycaps && }
+ {keycaps && }
{stepId === "graduation" ? (
{
Enter Compass
) : (
- isAssistVisible && (
-
- Show me
-
- )
+
+ Do it for me
+
)}
{stepIndex > 0 && stepId !== "graduation" && (
> = {
create: {
@@ -68,18 +79,20 @@ const STEP_CONTENT: Record> = {
keycaps: KEYMAP.moveEvent.keycaps,
},
resizeEdge: {
+ // Two phases: the hint swaps to Shift+Arrow once the end edge has focus,
+ // so the keycaps here only cover the first press.
title: "Stretch the end time",
- body: "Press Tab to focus the event's end time, then hold Shift and press an arrow to stretch it. The start stays put.",
- keycaps: [...KEYMAP.edgeFocus.keycaps, "Shift", "ArrowDown"],
+ body: "Press Tab to focus the event's end time, then hold Shift and press the up or down arrow to stretch it. The start stays put.",
+ keycaps: KEYMAP.edgeFocus.keycaps,
},
placeDraft: {
title: "Place a block anywhere",
- body: "With nothing focused, hold Shift and press an arrow key to drop a new block right on the grid.",
+ body: "With nothing focused, hold Shift and press an arrow key to place a new block right on the grid.",
keycaps: KEYMAP.moveEvent.keycaps,
},
undoRedo: {
title: "Never stress a mistake",
- body: "Press Mod+Z to undo your last change, then Mod+Shift+Z to bring it back.",
+ body: `Press ${MOD_KEY}+Z to undo your last change, then ${MOD_KEY}+Shift+Z to bring it back.`,
keycaps: KEYMAP.undo.keycaps,
},
hardcore: {
diff --git a/packages/web/src/shortcuts/keymap.test.ts b/packages/web/src/shortcuts/keymap.test.ts
index 5f3f13847..8a3ea989a 100644
--- a/packages/web/src/shortcuts/keymap.test.ts
+++ b/packages/web/src/shortcuts/keymap.test.ts
@@ -77,6 +77,9 @@ describe("keymap ↔ showcase hint parity", () => {
expect(getShowcaseStep("editTitle").keycaps).toBe(KEYMAP.editTitle.keycaps);
expect(getShowcaseStep("eventJump").keycaps).toBe(KEYMAP.eventJump.keycaps);
expect(getShowcaseStep("moveEvent").keycaps).toBe(KEYMAP.moveEvent.keycaps);
+ expect(getShowcaseStep("resizeEdge").keycaps).toBe(
+ KEYMAP.edgeFocus.keycaps,
+ );
expect(getShowcaseStep("placeDraft").keycaps).toBe(
KEYMAP.moveEvent.keycaps,
);