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
35 changes: 35 additions & 0 deletions .agents/handoffs/2879.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
schema_version: 1
task_id: "2879"
from: Reviewer
to: Manager
owner: Manager
status: verifying
artifact:
- path: packages/web/src/components/WelcomeModal/WelcomeGuideBody.tsx
- path: packages/web/src/components/AuthModal/AuthModal.tsx
- path: packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx
- path: e2e/onboarding/shortcut-showcase.spec.ts
- url: https://github.com/KeepSoftwareSimple/compass-calendar/pull/2879
evidence:
- command: bun run verify
result: "Selected packages: web; Checks run: test:web, type-check, lint, knip, test:a11y, test:e2e; Checks skipped: (none); All checks passed. web 2346 pass / 0 fail; e2e 35 passed including shortcut-showcase."
- command: bun run knip
result: pass (PRACTICE_JUMP_TARGETS export removed)
assumptions:
- "task is welcome/onboarding polish on packages/web plus e2e/docs; no core contract change"
- "production deploy is user-authorized after staging health is green"
open_risks:
- "signed-in staging QA needs compasscaltest3@gmail.com; anonymous welcome/practice can be checked without auth"
next_deadline: 2026-08-26T06:00:00Z
retry: 0
approval: none
waiting_on: null
escalation: null
---

Verifier: PASS. Simplify: no behavior-preserving commit (practice per-mission
branches and auth letter listener are already the smallest surfaces).
Independent review: no confirmed findings.

Suggested skills: `/qa-test-staging` after squash-merge and staging health.
1 change: 1 addition & 0 deletions .agents/ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ Status: `queued` | `running` | `waiting` | `verifying` | `done` | `escalated`

| task_id | priority | owner | status | artifact | evidence | next_deadline | retry | approval |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2879 | high | Manager | verifying | https://github.com/KeepSoftwareSimple/compass-calendar/pull/2879 | bun run verify PASS; review: no confirmed findings | 2026-08-26T06:00:00Z | 0 | none |
13 changes: 7 additions & 6 deletions docs/frontend/frontend-runtime-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ Welcome → signup → first-event contract:
- the Shortcut Showcase therefore has three entries: welcome dismiss,
post-signup, and the command palette's "Practice shortcuts".
`showcase.steps.ts` holds the order; taught keycaps come from
`packages/web/src/shortcuts/keymap.ts`. One lesson gates the exit — create,
then title-and-save, taught as a single continuous motion rather than two
steps — and is the only shortcut the arena implements
- every showcase step offers **Skip to calendar**, and **Skip to sign up** for
anyone not already signed in; Escape does the former. There is no confirm in
the way
`packages/web/src/shortcuts/keymap.ts`. Six skippable missions teach create
(C then title then Enter), hold-Mod page jumps, `S` event jump, Shift+arrow
nudge, `E` then `T` to target a title, and Cmd+K on a practice-only
palette, then graduation hands off to the real calendar
- every showcase step offers **Skip to calendar** (`X`), and **Skip to sign
up** (`U`) for anyone not already signed in; Escape does the former. There
is no confirm in the way
- graduating the showcase hands off directly to `FirstEventPrompt`, a
non-blocking card on the real calendar (not an app-lock modal) that asks the
user to press `C` on a real event. It is shown once the showcase has been
Expand Down
32 changes: 21 additions & 11 deletions e2e/onboarding/shortcut-showcase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,42 @@ test("exploring without an account starts the practice", async ({ page }) => {
).toBeVisible();
});

test("the welcome-started practice runs the one-lesson happy path", async ({
test("the welcome-started practice runs create then D through graduation", async ({
page,
}) => {
await page.goto("/week", { waitUntil: "domcontentloaded" });
await leaveWelcome(page);

const showcase = page.getByRole("region", { name: "Shortcut practice" });
await expect(showcase).toContainText("Create an event");
await expect(showcase).toContainText("Drop an event on the board");
await expect(showcase).toContainText("Press C to start a new event.");
await expect(showcase).toContainText("Mission 1 of 6");

await page.keyboard.press("c");
await expect(showcase).toContainText("Type a title, then press Enter");

// The practice title editor autofocuses; Enter commits it.
await page.keyboard.type("Practice Event");
await page.keyboard.press("Enter");
await expect(showcase).toContainText("young cap'n");
await expect(showcase).toContainText("Practice Event");
await expect(showcase).toContainText("Mission 2 of 6");

// Compass blocks trusted pointer clicks; D is the assist binding.
const remainingMissions = [
"Hold fast — reveal the jump keys",
"Pick a target",
"Nudge it into place",
"Grab the title",
"When you forget, ask the palette",
];
for (const title of remainingMissions) {
await expect(showcase).toContainText(title);
await page.keyboard.press("d");
}

await expect(showcase).toContainText("young cap'n");
await page.keyboard.press("Enter");
await expect(showcase).toHaveCount(0);

// Graduation lands on the real calendar: seeded events + the handoff to
// create a real one.
await expect(
page.getByRole("complementary", { name: "Create your first event" }),
).toBeVisible();
Expand All @@ -61,14 +73,12 @@ test("Skip to sign up leaves the practice for the signup form on step 1", async
await leaveWelcome(page);

const showcase = page.getByRole("region", { name: "Shortcut practice" });
await expect(showcase).toContainText("Create an event");
await expect(showcase).toContainText("Drop an event on the board");

// One click, from the first step, with no lesson finished and no confirm.
// The advertised S letter, not a click: the side actions are key-bound.
await expect(
showcase.getByRole("button", { name: /Skip to sign up/ }),
).toBeVisible();
await page.keyboard.press("s");
await page.keyboard.press("u");
await expect(showcase).toHaveCount(0);
await expect(page).toHaveURL(/auth=signup/);
});
Expand All @@ -80,7 +90,7 @@ test("Escape leaves the practice and it never auto-replays", async ({
await leaveWelcome(page);

const showcase = page.getByRole("region", { name: "Shortcut practice" });
await expect(showcase).toContainText("Create an event");
await expect(showcase).toContainText("Drop an event on the board");

await page.keyboard.press("Escape");
await expect(showcase).toHaveCount(0);
Expand Down
83 changes: 82 additions & 1 deletion packages/web/src/components/AuthModal/AuthModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "@tanstack/react-router";
import { act, type ReactElement } from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createTestEmailPasswordPort } from "@web/__tests__/helpers/web-test-seams";
import { createTestRouter } from "@web/__tests__/utils/providers/createTestRouter";
Expand Down Expand Up @@ -1083,6 +1083,87 @@ describe("URL Parameter Support", () => {
});
});

describe("Shortcut hints", () => {
beforeEach(() => {
installAuthModalTestSeams();
});

it("shows U, G, and Enter chips on login and switches to signup with U", async () => {
const user = userEvent.setup();
await renderWithProviders(<ModalTrigger />);

await user.click(screen.getByRole("button", { name: /open modal/i }));
await waitForAuthModal();

expect(
within(screen.getByRole("button", { name: /^sign up$/i })).getByText("U"),
).toBeTruthy();
expect(
within(
screen.getByRole("button", { name: "Continue with Google" }),
).getByText("G"),
).toBeTruthy();
expect(
within(screen.getByRole("button", { name: /^log in$/i })).getByText(
"Enter",
),
).toBeTruthy();

await user.keyboard("u");

await waitFor(() => {
expect(
screen.getByRole("heading", { name: /nice to meet you/i }),
).toBeInTheDocument();
});
});

it("types letters into email instead of switching views", async () => {
const user = userEvent.setup();
await renderWithProviders(<ModalTrigger />);
await user.click(screen.getByRole("button", { name: /open modal/i }));
await waitForAuthModal();

const email = screen.getByLabelText(/email/i);
await user.click(email);
await user.keyboard("user@example.com");

expect(email).toHaveValue("user@example.com");
expect(
screen.getByRole("heading", { name: /hey, welcome back/i }),
).toBeInTheDocument();
});

it("shows i on signup and switches back to login with i", async () => {
const user = userEvent.setup();
await renderWithProviders(<div />, "/?auth=signup");
await waitForAuthModal(/nice to meet you/i);

expect(
within(screen.getByRole("button", { name: /^log in$/i })).getByText("i"),
).toBeTruthy();

await user.keyboard("i");

await waitFor(() => {
expect(
screen.getByRole("heading", { name: /hey, welcome back/i }),
).toBeInTheDocument();
});
});

it("starts Google auth with G when focus is not in a field", async () => {
const user = userEvent.setup();
await renderWithProviders(<ModalTrigger />);
await user.click(screen.getByRole("button", { name: /open modal/i }));
await waitForAuthModal();

await user.keyboard("g");

expect(mockGoogleLogin).toHaveBeenCalled();
});
});

afterAll(() => {
isSessionMocked = false;
});
47 changes: 45 additions & 2 deletions packages/web/src/components/AuthModal/AuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { consumeGoogleAuthNeedsConsentRetry } from "@web/auth/google/authorization/google-authorization.storage";
import { useStartGoogleAuthorization } from "@web/auth/google/authorization/useStartGoogleAuthorization";
import { useIsGoogleAvailable } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable";
import { isEditableKeyboardTarget } from "@web/common/utils/form/form.util";
import {
dismissErrorToast,
SESSION_EXPIRED_TOAST_ID,
} from "@web/common/utils/toast/error-toast.util";
import { GoogleButton } from "@web/components/AuthModal/components/GoogleButton";
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { useAppLockReason } from "@web/shortcuts/app-lock";
import { keyboardKey } from "@web/shortcuts/is-bare-letter-key";
import { ForgotPasswordForm } from "./forms/ForgotPasswordForm";
import { LogInForm } from "./forms/LogInForm";
import { ResetPasswordForm } from "./forms/ResetPasswordForm";
Expand Down Expand Up @@ -109,11 +112,47 @@ export const AuthModal: FC = () => {
setView("forgotPassword");
}, [setView]);

const showAuthSwitch = isLoginView || currentView === "signUp";

useEffect(() => {
if (!isOpen || !showAuthSwitch) return;

const onKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditableKeyboardTarget(event)) return;
const key = keyboardKey(event).toLowerCase();
if (key === "g" && isGoogleAvailable) {
event.preventDefault();
handleGoogleSignIn();
return;
}
if (key === "u" && isLoginView) {
event.preventDefault();
setView("signUp");
return;
}
if (key === "i" && currentView === "signUp") {
event.preventDefault();
setView("login");
}
};

document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [
currentView,
handleGoogleSignIn,
isGoogleAvailable,
isLoginView,
isOpen,
setView,
showAuthSwitch,
]);

if (!isOpen) {
return null;
}

const showAuthSwitch = isLoginView || currentView === "signUp";
const showGoogleAuth = currentView !== "resetPassword" && isGoogleAvailable;
const showSubmitError =
submitError !== null && (isLoginView || currentView === "signUp");
Expand All @@ -137,9 +176,12 @@ export const AuthModal: FC = () => {
<button
type="button"
onClick={handleSwitchAuth}
className="shrink-0 rounded-3xl bg-[#c2c6cc] px-4 py-1.5 text-[#1f1f1f] text-xs transition-all hover:bg-[#d1d5da]"
className="inline-flex shrink-0 items-center gap-2 rounded-3xl bg-[#c2c6cc] px-4 py-1.5 text-[#1f1f1f] text-xs transition-all hover:bg-[#d1d5da]"
>
{isLoginView ? "Sign up" : "Log in"}
<ShortcutHint className="shrink-0">
{isLoginView ? "U" : "i"}
</ShortcutHint>
</button>
) : null
}
Expand Down Expand Up @@ -200,6 +242,7 @@ export const AuthModal: FC = () => {
onClick={handleGoogleSignIn}
disabled={isGoogleAuthLoading}
label="Continue with Google"
shortcutKey="G"
style={{ width: "100%" }}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classNames from "classnames";
import type React from "react";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";

/**
* Monochrome Google "G" logo SVG
Expand Down Expand Up @@ -37,11 +38,13 @@ export const GoogleButton = ({
onClick,
disabled,
label = "Sign in with Google",
shortcutKey,
style,
}: {
onClick: () => void;
disabled?: boolean;
label?: string;
shortcutKey?: string;
style?: React.CSSProperties;
}) => {
return (
Expand All @@ -63,6 +66,9 @@ export const GoogleButton = ({
>
<GoogleGLogo size={18} />
<span>{label}</span>
{shortcutKey ? (
<ShortcutHint className="shrink-0">{shortcutKey}</ShortcutHint>
) : null}
</button>
);
};
3 changes: 3 additions & 0 deletions packages/web/src/components/AuthModal/forms/LogInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type LogInFormData,
LogInSchema,
} from "@web/auth/compass/schemas/auth.schemas";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { AuthButton } from "../components/AuthButton";
import { AuthInput } from "../components/AuthInput";
import { useZodForm } from "../hooks/useZodForm";
Expand Down Expand Up @@ -75,10 +76,12 @@ export const LogInForm: FC<SignInFormProps> = ({

<AuthButton
type="submit"
className="inline-flex items-center justify-center gap-2"
disabled={!form.isValid}
isLoading={isSubmitting}
>
Log in
<ShortcutHint className="shrink-0">Enter</ShortcutHint>
</AuthButton>
</form>
);
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/components/AuthModal/forms/SignUpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type SignUpFormData,
SignUpSchema,
} from "@web/auth/compass/schemas/auth.schemas";
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
import { AuthButton } from "../components/AuthButton";
import { AuthInput } from "../components/AuthInput";
import { useZodForm } from "../hooks/useZodForm";
Expand Down Expand Up @@ -80,10 +81,12 @@ export const SignUpForm: FC<SignUpFormProps> = ({

<AuthButton
type="submit"
className="inline-flex items-center justify-center gap-2"
disabled={!form.isValid}
isLoading={isSubmitting}
>
Sign up
<ShortcutHint className="shrink-0">Enter</ShortcutHint>
</AuthButton>
</form>
);
Expand Down
Loading
Loading