diff --git a/DECISIONS.md b/DECISIONS.md index 838983e..d8182c9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1757,3 +1757,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Escape skips**, like Skip. - **"Cross-fade instantly" means no animation**: with reduced motion the card changes at once. The global reduced-motion CSS would stop the slide anyway; the component also drops the class, so the rule doesn't rely on CSS alone. - **The gate hides itself as soon as the player closes it**, before the command returns, so the cards never flash back while the state refreshes. + +## Step 22.3 + +- **The welcome gate remembers a close per company id**: "Start a new company" keeps the save's id but makes a new company id, and the new company must get its own welcome. +- **How to play doesn't leave Settings**: "Close" just closes it; only a new company's welcome goes to Contracts. diff --git a/apps/web/src/components/Welcome.tsx b/apps/web/src/components/Welcome.tsx index c200d0e..e005b9b 100644 --- a/apps/web/src/components/Welcome.tsx +++ b/apps/web/src/components/Welcome.tsx @@ -134,17 +134,18 @@ export function WelcomeGate() { const api = useApi(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const [closed, setClosed] = useState(false); + // Closed for this company: a new company (Settings) gets its own welcome. + const [closedFor, setClosedFor] = useState(null); const seen = useMutation({ mutationFn: () => api.call("markWelcomeSeen", {}), onSuccess: (next) => queryClient.setQueryData(PLAYER_STATE_KEY, next), }); - if (state.welcomeSeen || closed) return null; + if (state.welcomeSeen || closedFor === state.company.id) return null; return ( { - setClosed(true); + setClosedFor(state.company.id); seen.mutate(); void navigate("/contracts"); }} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index f105bfb..c33c95e 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import type { ReactNode } from "react"; +import { WelcomeCards } from "../components/Welcome"; import { SpendingFloor } from "../components/IndependentPilots"; import { resetMapView } from "../map/camera"; import { About } from "./AboutSettings"; @@ -18,6 +20,7 @@ export function isDevBuild(): boolean { export function Settings() { const state = useLoadedPlayer(); const prefs = usePreferences(); + const [howToPlay, setHowToPlay] = useState(false); return ( @@ -87,7 +90,12 @@ export function Settings() { + + {/* The welcome cards again, ending in Close (decision 0.5.132). */} + {howToPlay && setHowToPlay(false)} />}
diff --git a/apps/web/test/welcome-settings.test.tsx b/apps/web/test/welcome-settings.test.tsx new file mode 100644 index 0000000..71b417d --- /dev/null +++ b/apps/web/test/welcome-settings.test.tsx @@ -0,0 +1,68 @@ +import type { PlayerState } from "@deadhead/schema"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render"; + +const NAME = "Deadhead Freight"; + +/** A company whose welcome is `seen`; "Start a new company" makes another with its welcome unseen. */ +function company(seen: boolean) { + let state: PlayerState = samplePlayerState({ welcomeSeen: seen }); + let companies = 1; + const api = fakeApi({ + getPlayerState: () => state, + getTutorialBoard: () => sampleTutorialBoard(), + markWelcomeSeen: () => { + state = { ...state, welcomeSeen: true }; + return state; + }, + newCompany: () => { + companies++; + state = samplePlayerState({ + welcomeSeen: false, + company: { id: `co${companies}`, name: NAME, credits: 0, loanBalance: 0 }, + }); + return state; + }, + }); + return api; +} + +async function startNewCompany() { + await userEvent.click(await screen.findByRole("button", { name: "Start a new company" })); + const confirm = screen.getByRole("dialog", { name: "Start a new company?" }); + await userEvent.type(within(confirm).getByRole("textbox"), NAME); + await userEvent.click(within(confirm).getByRole("button", { name: "Start over" })); +} + +describe("welcome for a new company and How to play (Step 22.3, decisions 0.5.131, 0.5.132)", () => { + it("shows the welcome for a company started from Settings, even after closing one earlier", async () => { + const api = company(false); + renderApp(api, "/settings"); + // This company's welcome first. + const first = await screen.findByRole("dialog", { name: "Welcome to Deadhead" }); + await userEvent.click(within(first).getByRole("button", { name: "Skip" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + await userEvent.click(await screen.findByRole("link", { name: "Settings" })); + await startNewCompany(); + const again = await screen.findByRole("dialog", { name: "Welcome to Deadhead" }); + expect(again).toHaveTextContent("1 of 3"); + }); + + it("opens How to play from Settings any time, ending in Close, without touching the save", async () => { + const api = company(true); + renderApp(api, "/settings"); + await userEvent.click(await screen.findByRole("button", { name: "How to play" })); + const dialog = screen.getByRole("dialog", { name: "Welcome to Deadhead" }); + await userEvent.click(within(dialog).getByRole("button", { name: "Next" })); + await userEvent.click(within(dialog).getByRole("button", { name: "Next" })); + expect(within(dialog).queryByRole("button", { name: "Let's fly" })).not.toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(api.call).not.toHaveBeenCalledWith("markWelcomeSeen", expect.anything()); + // Still on Settings. + expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 7a7fcf0..8121e15 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -496,5 +496,5 @@ Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-0 - **Other changes.** The three screens that wrote "Halvorsen Lunar Freight" now use `content.tutorialCompanyName`. The e2e `startCompany` (and the production-build test) expect the welcome and skip it, waiting for the save to record it before any reload. Screenshots: `docs/screenshots/22-2-welcome-1-phone.png`, `22-2-welcome-3-phone.png`, `22-2-after-welcome-phone.png`, and the same three for desktop. Tests (`apps/web/test/welcome.test.tsx`): through all three cards to "Let's fly", marked seen, on Contracts with the intro card; Skip on each card does the same; no welcome after a reload; the slide class only without reduced motion; focus stays inside. `packages/engine/test/start.test.ts`: `markWelcomeSeen` records it, and it survives a reload. -- [ ] Step 22.3 — Client: new company and "How to play" +- [x] **Step 22.3 — Client: new company and "How to play".** "Start a new company" makes a save whose welcome is unseen, so the gate shows the cards for it. The gate's "closed" flag is now kept per company id, so a welcome closed earlier in the session doesn't hide the new company's. Settings → Help has "How to play", which opens `WelcomeCards` ending in "Close" and leaves the save alone. Tests (`apps/web/test/welcome-settings.test.tsx`): after skipping one company's welcome, a company started from Settings shows card 1 again; How to play opens anywhere, ends in Close (not "Let's fly"), stays on Settings and never calls `markWelcomeSeen`. - [ ] Step 22.4 — Regression