Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/web/src/auth/posthog/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const ChecklistCard: FC = () => {
<>
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold text-sm">
Practice on real events
Practice on sample events
</span>
<span className="text-text-muted text-xs">
{doneCount}/{CHECKLIST_ITEMS.length}
Expand All @@ -71,9 +71,29 @@ const ChecklistCard: FC = () => {
<ul className="flex flex-col gap-1.5">
{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 (
<li key={item.id} className="mt-1">
<button
type="button"
className="c-focus-ring inline-flex w-full items-center justify-center rounded-3xl bg-accent px-4 py-1.5 font-medium text-on-accent text-xs transition-all hover:brightness-110"
onClick={() => {
track("signup_started", { source: "checklist" });
openModal("signUp");
}}
>
{item.label}
</button>
</li>
);
}

const keycaps = "keycaps" in item ? item.keycaps : undefined;
const row = (
<>
return (
<li key={item.id} className="flex items-center gap-2">
{isComplete ? (
<CheckCircleIcon
className="shrink-0 text-accent"
Expand All @@ -98,24 +118,6 @@ const ChecklistCard: FC = () => {
{keycaps && !isComplete && (
<ShortcutKeys className="ml-auto" keys={[...keycaps]} />
)}
</>
);
return (
<li key={item.id} className="flex items-center gap-2">
{item.id === "signUp" && !isComplete ? (
<button
type="button"
className="c-focus-ring flex w-full items-center gap-2 rounded-md text-left hover:bg-surface-overlay"
onClick={() => {
track("signup_started", { source: "checklist" });
openModal("signUp");
}}
>
{row}
</button>
) : (
row
)}
</li>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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(<ShortcutShowcase />);
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(<ShortcutShowcase />);
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(<ShortcutShowcase />);
act(() => shortcutShowcaseActions.start());
Expand Down
73 changes: 16 additions & 57 deletions packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +33,7 @@ import {
import {
getShowcaseStep,
SHOWCASE_STEP_IDS,
type ShowcaseStepId,
STRETCH_KEYCAPS,
} from "@web/components/ShortcutShowcase/showcase.steps";
import {
selectShowcaseActive,
Expand All @@ -55,53 +54,13 @@ 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<string, PracticeDirection> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
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);
Expand All @@ -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.
Expand Down Expand 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);
Expand Down Expand Up @@ -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 (
<section
Expand Down Expand Up @@ -493,7 +454,7 @@ const ShowcaseTakeover: FC = () => {
</div>
<h2 className="font-semibold text-lg text-text">{step.title}</h2>
<p className="text-sm text-text-muted">{step.body}</p>
{step.keycaps && <ShortcutKeys keys={[...step.keycaps]} />}
{keycaps && <ShortcutKeys keys={[...keycaps]} />}
<div className="flex items-center gap-2 pt-2">
{stepId === "graduation" ? (
<button
Expand All @@ -504,15 +465,13 @@ const ShowcaseTakeover: FC = () => {
Enter Compass
</button>
) : (
isAssistVisible && (
<button
type="button"
className={PRIMARY_BUTTON_CLASS}
onClick={showMe}
>
Show me
</button>
)
<button
type="button"
className={PRIMARY_BUTTON_CLASS}
onClick={doItForMe}
>
Do it for me
</button>
)}
{stepIndex > 0 && stepId !== "graduation" && (
<button
Expand Down
25 changes: 19 additions & 6 deletions packages/web/src/components/ShortcutShowcase/showcase.steps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { detectPlatform } from "@tanstack/react-hotkeys";
import { KEYMAP } from "@web/shortcuts/keymap";

// Prose has no keycap chips to expand "Mod" into, so spell the real key out.
const MOD_KEY = detectPlatform() === "mac" ? "Cmd" : "Ctrl";

/**
* Single source of truth for showcase step order. Every shortcut concept the
* app teaches lives here; the checklist in the real app only re-practices
Expand Down Expand Up @@ -31,10 +35,17 @@ export type ShowcaseStep = {
keycaps?: readonly string[];
};

/**
* Second half of the resizeEdge lesson, swapped in once the end edge has
* focus so the row never reads as one three-key press. Stretching reuses the
* Shift+Arrow family bound by KEYMAP.moveEvent; the arrow stays literal
* because it demonstrates one direction, and only up/down stretch.
*/
export const STRETCH_KEYCAPS: readonly string[] = ["Shift", "ArrowDown"];

/**
* Keycaps reference KEYMAP so a remap updates the hints automatically;
* keymap.test.ts pins the 1:1 cases. Direction-specific arrows in combined
* hints stay literal because they demonstrate one direction of a family.
* keymap.test.ts pins the 1:1 cases.
*/
const STEP_CONTENT: Record<ShowcaseStepId, Omit<ShowcaseStep, "id">> = {
create: {
Expand Down Expand Up @@ -68,18 +79,20 @@ const STEP_CONTENT: Record<ShowcaseStepId, Omit<ShowcaseStep, "id">> = {
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: {
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/shortcuts/keymap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down