diff --git a/DECISIONS.md b/DECISIONS.md index a7b5ab4..838983e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1750,3 +1750,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"Halvorsen Lunar Freight" is now content** (`tutorialCompanyName`): the CR says the welcome takes it "from the existing content name", but it was hard-coded in three screens. Card 3 says `{company}`, and Step 22.2 points the screens at the content name too. - **`welcomeSeen` lives on the save's `player`**, alongside the tutorial state, since it belongs to the company, not the device. That's why it isn't a one-time tip in device preferences like the tutorial intro card. + +## Step 22.2 + +- **The welcome is its own dialog**, not the shared `Sheet`: it needs a focus trap and swipe handling, which the sheet doesn't have. +- **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. diff --git a/apps/web/e2e/14-11-production.e2e.ts b/apps/web/e2e/14-11-production.e2e.ts index 4627b31..2d5ca03 100644 --- a/apps/web/e2e/14-11-production.e2e.ts +++ b/apps/web/e2e/14-11-production.e2e.ts @@ -12,6 +12,11 @@ test.describe("the production build (CR-014 Step 14.11)", () => { // Start a company and fly a tutorial run, then reload: everything a first session does. await page.goto(SITE); await page.getByRole("button", { name: "Start your company" }).click(); + // The welcome cards (CR-022) come first. + await page + .getByRole("dialog", { name: "Welcome to Deadhead" }) + .getByRole("button", { name: "Skip" }) + .click(); await page.getByRole("button", { name: "Fly it" }).first().click(); await expect(page.getByText(/in flight/i).first()).toBeVisible(); await page.reload(); diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts index b8e74de..1664c39 100644 --- a/apps/web/e2e/support.ts +++ b/apps/web/e2e/support.ts @@ -71,6 +71,13 @@ export async function dismissLogbook(page: Page) { export async function startCompany(page: Page) { await page.goto("/"); await page.getByRole("button", { name: "Start your company" }).click(); + // Every new company opens with the welcome cards (CR-022): skip them. + const welcome = page.getByRole("dialog", { name: "Welcome to Deadhead" }); + await expect(welcome).toBeVisible(); + await welcome.getByRole("button", { name: "Skip" }).click(); + await expect(welcome).toBeHidden(); + // Closing records it in the save; wait for that before any reload. + await expect.poll(async () => (await state(page)).welcomeSeen).toBe(true); await expect(page.getByRole("button", { name: "Fly it" }).first()).toBeVisible(); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9310bc1..bff24df 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { useApi } from "./api/context"; import { startClockTicker, syncClock } from "./clock/clock"; import { AwaySummary } from "./components/AwaySummary"; import { DealModal } from "./components/DealModal"; +import { WelcomeGate } from "./components/Welcome"; import { SkillPointPrompt } from "./components/SkillPointPrompt"; import { formatCredits } from "./format"; import { tabAttention } from "./layout/attention"; @@ -116,6 +117,7 @@ export function App() { + ); diff --git a/apps/web/src/components/Welcome.tsx b/apps/web/src/components/Welcome.tsx new file mode 100644 index 0000000..c200d0e --- /dev/null +++ b/apps/web/src/components/Welcome.tsx @@ -0,0 +1,153 @@ +import { content } from "@deadhead/content"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; +import { useNavigate } from "react-router"; +import { useApi } from "../api/context"; +import { useLoadedPlayer } from "../state/actions"; +import { PLAYER_STATE_KEY } from "../state/player"; +import { reducedMotionOn, usePreferences } from "../state/preferences"; +import { Button } from "../ui"; + +const FOCUSABLE = 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; + +/** A card's body with the tutorial employer's name filled in. */ +export function welcomeBody(body: string): string { + return body.split("{company}").join(content.tutorialCompanyName); +} + +/** + * The three welcome cards (decisions 0.5.131, 0.5.132): Next through them, + * Skip on every card, and a last button that starts the game ("Let's fly") or, + * reopened from Settings → Help, just closes ("Close"). Cards slide in, or + * change at once with reduced motion; focus stays inside until it closes. + */ +export function WelcomeCards({ + final, + onClose, +}: { + final: "start" | "close"; + onClose: () => void; +}) { + const { cards, next, skip, start, close } = content.welcome; + const [index, setIndex] = useState(0); + const reduced = usePreferences(reducedMotionOn); + const dialog = useRef(null); + const touchX = useRef(null); + const card = cards[index]!; + const last = index === cards.length - 1; + + // Focus moves into the pop-up, onto the main button of each card. + useEffect(() => { + dialog.current?.querySelector("[data-primary]")?.focus(); + }, [index]); + + const advance = () => (last ? onClose() : setIndex(index + 1)); + + // Tab and Shift+Tab stay inside; Escape is Skip. + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key !== "Tab" || !dialog.current) return; + const focusable = [...dialog.current.querySelectorAll(FOCUSABLE)]; + if (focusable.length === 0) return; + const first = focusable[0]!; + const end = focusable.at(-1)!; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + end.focus(); + } else if (!e.shiftKey && document.activeElement === end) { + e.preventDefault(); + first.focus(); + } + }; + + return createPortal( +
+
{ + touchX.current = e.touches[0]?.clientX ?? null; + }} + onTouchEnd={(e) => { + const from = touchX.current; + const to = e.changedTouches[0]?.clientX; + touchX.current = null; + if (from === null || to === undefined) return; + // Swipe left for the next card, right for the previous one. + if (from - to > 50) advance(); + else if (to - from > 50 && index > 0) setIndex(index - 1); + }} + className="w-full max-w-md overflow-hidden rounded-t-2xl border border-amber-400/50 bg-space-900/95 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl" + > +

+ {index + 1} of {cards.length} +

+
+

+ {card.title} +

+

{welcomeBody(card.body)}

+
+ +
+ + +
+
+
, + document.body, + ); +} + +/** + * A new company's welcome (decision 0.5.131): shown until this company has + * seen it; closing it records that and goes to Contracts, where the tutorial + * intro card follows. + */ +export function WelcomeGate() { + const state = useLoadedPlayer(); + const api = useApi(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [closed, setClosed] = useState(false); + const seen = useMutation({ + mutationFn: () => api.call("markWelcomeSeen", {}), + onSuccess: (next) => queryClient.setQueryData(PLAYER_STATE_KEY, next), + }); + if (state.welcomeSeen || closed) return null; + return ( + { + setClosed(true); + seen.mutate(); + void navigate("/contracts"); + }} + /> + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b1d4e79..58e6e62 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -35,6 +35,7 @@ --animate-rise: rise 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both; --animate-glow: glow 2.4s ease-in-out infinite; --animate-sheet: sheet 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both; + --animate-slide: slide 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both; @keyframes rise { from { @@ -55,6 +56,16 @@ opacity: 0.55; } } + @keyframes slide { + from { + opacity: 0; + transform: translateX(32px); + } + to { + opacity: 1; + transform: none; + } + } @keyframes sheet { from { opacity: 0; diff --git a/apps/web/src/screens/Contracts.tsx b/apps/web/src/screens/Contracts.tsx index 922b0e9..dfcfa13 100644 --- a/apps/web/src/screens/Contracts.tsx +++ b/apps/web/src/screens/Contracts.tsx @@ -1,3 +1,4 @@ +import { content } from "@deadhead/content"; import type { Contract } from "@deadhead/schema"; import { useState } from "react"; import { useLoadedPlayer } from "../state/actions"; @@ -14,7 +15,7 @@ export function Contracts() { {state.tutorial ? : } diff --git a/apps/web/src/screens/Dashboard.tsx b/apps/web/src/screens/Dashboard.tsx index a66107b..ebe0320 100644 --- a/apps/web/src/screens/Dashboard.tsx +++ b/apps/web/src/screens/Dashboard.tsx @@ -109,7 +109,7 @@ export function Dashboard() { Contract pilot

- Flying for Halvorsen Lunar Freight + Flying for {content.tutorialCompanyName}

Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "} diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index 6c4f071..4620a30 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -50,7 +50,7 @@ export function TutorialBoard() {

- Halvorsen Lunar Freight needs a pilot. + {content.tutorialCompanyName} needs a pilot.

Their ship, your hands. Get the crates to the Moon.

diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index bc121c5..ee03cbf 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -93,6 +93,7 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS summaries: [], goals: [], dispatchOffice: { owned: false, spendingFloor: 500 }, + welcomeSeen: true, ...overrides, }; } diff --git a/apps/web/test/welcome.test.tsx b/apps/web/test/welcome.test.tsx new file mode 100644 index 0000000..415cb98 --- /dev/null +++ b/apps/web/test/welcome.test.tsx @@ -0,0 +1,111 @@ +import type { PlayerState } from "@deadhead/schema"; +import { cleanup, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { usePreferences } from "../src/state/preferences"; +import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, unauthorized } from "./render"; + +/** A fresh guest: "Start your company", then the new company with its welcome unseen. */ +function newCompany() { + let started = false; + let state: PlayerState = samplePlayerState({ welcomeSeen: false }); + const api = fakeApi({ + getPlayerState: () => (started ? state : unauthorized()), + createGuest: () => { + started = true; + return { playerId: "p1", state }; + }, + getTutorialBoard: () => sampleTutorialBoard(), + markWelcomeSeen: () => { + state = { ...state, welcomeSeen: true }; + return state; + }, + }); + return { api, state: () => state }; +} + +async function start(api: ReturnType["api"]) { + renderApp(api, "/"); + await userEvent.click(await screen.findByRole("button", { name: "Start your company" })); + return screen.findByRole("dialog", { name: "Welcome to Deadhead" }); +} + +describe("welcome cards (Step 22.2, decision 0.5.131)", () => { + it("walks through three cards and lands on Contracts with the intro card", async () => { + const { api, state } = newCompany(); + const dialog = await start(api); + expect(dialog).toHaveTextContent("1 of 3"); + expect(dialog).toHaveTextContent("never fly home empty"); + + await userEvent.click(within(dialog).getByRole("button", { name: "Next" })); + expect(dialog).toHaveTextContent("2 of 3"); + expect(within(dialog).getByRole("heading")).toHaveTextContent("Your ships keep flying"); + + await userEvent.click(within(dialog).getByRole("button", { name: "Next" })); + expect(dialog).toHaveTextContent("3 of 3"); + expect(dialog).toHaveTextContent( + "You're flying for Halvorsen Lunar Freight on the Earth–Moon run.", + ); + expect(within(dialog).queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); + + await userEvent.click(within(dialog).getByRole("button", { name: "Let's fly" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(api.call).toHaveBeenCalledWith("markWelcomeSeen", {}); + expect(state().welcomeSeen).toBe(true); + expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument(); + expect(await screen.findByLabelText("Intro")).toHaveTextContent("needs a pilot"); + }); + + it("closes from Skip on any card, marking it seen and landing on Contracts", async () => { + for (const nexts of [0, 1, 2]) { + const { api, state } = newCompany(); + const dialog = await start(api); + for (let i = 0; i < nexts; i++) { + await userEvent.click(within(dialog).getByRole("button", { name: "Next" })); + } + await userEvent.click(within(dialog).getByRole("button", { name: "Skip" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(state().welcomeSeen).toBe(true); + expect(await screen.findByLabelText("Intro")).toBeInTheDocument(); + cleanup(); + } + }); + + it("doesn't come back after a reload once seen", async () => { + const { api } = newCompany(); + const dialog = await start(api); + await userEvent.click(within(dialog).getByRole("button", { name: "Skip" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + cleanup(); + + renderApp(api, "/contracts"); + expect(await screen.findByLabelText("Intro")).toBeInTheDocument(); + expect(screen.queryByRole("dialog", { name: "Welcome to Deadhead" })).not.toBeInTheDocument(); + }); + + it("slides cards in, but not with reduced motion on", async () => { + const { api } = newCompany(); + await start(api); + expect(screen.getByTestId("welcome-card")).toHaveClass("animate-slide"); + cleanup(); + + usePreferences.getState().set({ reducedMotion: true }); + const again = newCompany(); + await start(again.api); + expect(screen.getByTestId("welcome-card")).not.toHaveClass("animate-slide"); + usePreferences.getState().set({ reducedMotion: null }); + }); + + it("keeps focus inside the pop-up until it closes", async () => { + const { api } = newCompany(); + const dialog = await start(api); + // Focus starts on the main button. + expect(within(dialog).getByRole("button", { name: "Next" })).toHaveFocus(); + for (let i = 0; i < 5; i++) { + await userEvent.tab(); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + } + await userEvent.tab({ shift: true }); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + }); +}); diff --git a/docs/screenshots/22-2-after-welcome-desktop.png b/docs/screenshots/22-2-after-welcome-desktop.png new file mode 100644 index 0000000..919bfd5 Binary files /dev/null and b/docs/screenshots/22-2-after-welcome-desktop.png differ diff --git a/docs/screenshots/22-2-after-welcome-phone.png b/docs/screenshots/22-2-after-welcome-phone.png new file mode 100644 index 0000000..dcf5d8b Binary files /dev/null and b/docs/screenshots/22-2-after-welcome-phone.png differ diff --git a/docs/screenshots/22-2-welcome-1-desktop.png b/docs/screenshots/22-2-welcome-1-desktop.png new file mode 100644 index 0000000..c7225ef Binary files /dev/null and b/docs/screenshots/22-2-welcome-1-desktop.png differ diff --git a/docs/screenshots/22-2-welcome-1-phone.png b/docs/screenshots/22-2-welcome-1-phone.png new file mode 100644 index 0000000..66fb855 Binary files /dev/null and b/docs/screenshots/22-2-welcome-1-phone.png differ diff --git a/docs/screenshots/22-2-welcome-3-desktop.png b/docs/screenshots/22-2-welcome-3-desktop.png new file mode 100644 index 0000000..cbfa04c Binary files /dev/null and b/docs/screenshots/22-2-welcome-3-desktop.png differ diff --git a/docs/screenshots/22-2-welcome-3-phone.png b/docs/screenshots/22-2-welcome-3-phone.png new file mode 100644 index 0000000..89e05fb Binary files /dev/null and b/docs/screenshots/22-2-welcome-3-phone.png differ diff --git a/memory-bank/progress.md b/memory-bank/progress.md index dacddcf..7a7fcf0 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -490,6 +490,11 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-020 … Phase 20"). New decisions recorded as plan §0.5.131–0.5.132. - [x] **Step 22.1 — Content and save field.** Content: `welcome` (three cards' titles and bodies, and the Next, Skip, "Let's fly" and Close labels; `welcomeSchema`), with `{company}` in the last card filled from the new `tutorialCompanyName` ("Halvorsen Lunar Freight", until now written into three screens). Save version 7: `player.welcomeSeen`, false for a new company; the 6 → 7 migration sets it true for existing ones. Tests: `packages/content/test/onboarding.test.ts` (the cards validate, in order, with `{company}` and the labels; empty text is rejected); `packages/engine/test/start.test.ts` (a new company's welcome is unseen); `packages/schema/test/save.test.ts` (6 → 7 marks it seen and changes nothing else). -- [ ] Step 22.2 — Client: welcome pop-up +- [x] **Step 22.2 — Client: welcome pop-up.** + - **The cards.** `WelcomeCards` (`apps/web/src/components/Welcome.tsx`) shows the three cards with "1 of 3" announced politely, Next, Skip on every card, and "Let's fly" (or "Close") on the last. Swipe left and right moves between cards. Focus starts on the main button, Tab and Shift+Tab stay inside, and Escape skips. Cards slide in (`animate-slide`) unless reduced motion is on, when they change at once. + - **The gate.** `WelcomeGate` (in `App`) shows the cards while `PlayerState.welcomeSeen` is false. Closing them calls the new `markWelcomeSeen` command and goes to Contracts, where the tutorial intro card follows. + - **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" - [ ] Step 22.4 — Regression diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index f274996..042c349 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -67,6 +67,10 @@ export function commandTable(): CommandTable { acknowledgeSummaries: ({ save, now }, input) => ({ acknowledged: acknowledge(save, input, now), }), + markWelcomeSeen: ({ save, now }) => { + save.player.welcomeSeen = true; + return playerState(save, now); + }, acceptDeal: ({ save, random, now }) => { acceptDeal(save, random, now); return playerState(save, now); diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 950fb00..9eb108a 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -281,6 +281,7 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState { resolvedAt: d.resolvedAt ?? d.arrivalAt, })), goals: goalStates(save), + welcomeSeen: save.player.welcomeSeen, dispatchOffice: { owned: save.company.dispatchOffice, spendingFloor: save.company.npcSpendingFloor, diff --git a/packages/engine/test/start.test.ts b/packages/engine/test/start.test.ts index cffe4f6..fd6b6b9 100644 --- a/packages/engine/test/start.test.ts +++ b/packages/engine/test/start.test.ts @@ -72,6 +72,7 @@ describe("company creation on the device (Step 14.8)", () => { activeDispatches: [], summaries: [], goals: [], + welcomeSeen: false, dispatchOffice: { owned: false, spendingFloor: content.config.independentPilots.defaultSpendingFloor, @@ -105,4 +106,13 @@ describe("welcome cards in the save (Step 22.1, decision 0.5.131)", () => { if (save.status !== "loaded") throw new Error(save.status); expect(save.save.player.welcomeSeen).toBe(false); }); + + it("records the welcome as seen with markWelcomeSeen (Step 22.2)", async () => { + const backend = new MemoryBackend(); + const engine = engineOn(backend); + expect((await engine.call("createGuest", {})).state.welcomeSeen).toBe(false); + expect((await engine.call("markWelcomeSeen", {})).welcomeSeen).toBe(true); + // A reload keeps it. + expect((await engineOn(backend).call("getPlayerState", {})).welcomeSeen).toBe(true); + }); }); diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 128bd16..61d2d92 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -257,5 +257,7 @@ export const playerStateSchema = z.object({ /** Onboarding goals in content order; empty during the tutorial. */ goals: z.array(goalStateSchema), dispatchOffice: dispatchOfficeSchema, + /** This company's welcome cards have been seen (decision 0.5.131). */ + welcomeSeen: z.boolean(), }); export type PlayerState = z.infer; diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index e326c1e..05b5b26 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -211,6 +211,14 @@ export const routes = { response: acknowledgeSummariesResponseSchema, auth: true, }, + /** CR-022: the welcome cards are closed for this company (decision 0.5.131). */ + markWelcomeSeen: { + method: "POST", + path: "/api/welcome/seen", + request: emptyRequestSchema, + response: playerStateSchema, + auth: true, + }, acceptDeal: { method: "POST", path: "/api/deal/accept", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 7030d4a..08c976b 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -31,6 +31,7 @@ describe("route catalog", () => { "setSpendingFloor", "acknowledgeSummaries", "acceptDeal", + "markWelcomeSeen", "exportSave", "importSave", "devAdvanceClock", diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index a770b7c..3eb0f72 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -178,6 +178,7 @@ const playerState = { summaries: [summary], goals: [goal], dispatchOffice: { owned: true, spendingFloor: 500 }, + welcomeSeen: true, }; const cases: [string, z.ZodType, unknown, unknown][] = [