Step 8.1: add Vite + React client shell

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:50:26 -04:00
parent e58e99c156
commit acf550c901
18 changed files with 1311 additions and 46 deletions

View File

@ -360,3 +360,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.
## Step 8.1
- **Client stack:** Vite 8 + React 19, Tailwind CSS 4 (via `@tailwindcss/vite`, no config file), React Router 8 in declarative mode (`<Routes>`; `BrowserRouter` in `main.tsx`, `MemoryRouter` in tests). Component tests use Vitest with jsdom and Testing Library (`apps/web/vitest.config.ts`).
- **The dev server proxies `/api` to `http://127.0.0.1:3000`**, which keeps the client and API on one origin in development (decision 0.5.20) and sidesteps the server listening only on IPv4 (Step 7.1).
- **Screens are listed once in `src/routes.ts`**, shared by the router and the bottom navigation. The Locations tab is labelled "Places" to fit six tabs at 375px.
- **Header credits and clock are placeholders** until Steps 8.3 and 8.5 supply live values.
- **ESLint gives `apps/web` browser globals.**

12
apps/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Deadhead: Space Freight Tycoon</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -3,9 +3,33 @@
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@deadhead/content": "workspace:*",
"@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*",
"@tanstack/react-query": "^5.104.1",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1",
"tailwindcss": "^4.3.3",
"vite": "^8.3.2"
}
}

22
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Route, Routes } from "react-router";
import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes";
import { Placeholder } from "./screens/Placeholder";
/** Routes for every screen. The router itself is provided by the caller. */
export function App() {
return (
<Routes>
<Route element={<Layout />}>
{SCREENS.map((screen) => (
<Route
key={screen.path}
index={screen.path === "/"}
path={screen.path === "/" ? undefined : screen.path}
element={<Placeholder title={screen.title} />}
/>
))}
</Route>
</Routes>
);
}

1
apps/web/src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -1 +0,0 @@
export {};

View File

@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
import { SCREENS } from "../routes";
export interface LayoutProps {
/** Shown at the top right; live values arrive with the player state and clock steps. */
credits?: ReactNode;
clock?: ReactNode;
}
/** Header with credits and game clock, the current screen, and a bottom tab bar. */
export function Layout({ credits = "—", clock = "—" }: LayoutProps) {
return (
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">
<span className="truncate text-sm font-bold tracking-wide">DEADHEAD</span>
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums">
<span aria-label="Credits">{credits}</span>
<span aria-label="Game clock" className="text-slate-400">
{clock}
</span>
</div>
</header>
<main className="flex-1 overflow-x-hidden pb-20">
<Outlet />
</main>
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 grid grid-cols-6 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
>
{SCREENS.map((screen) => (
<NavLink
key={screen.path}
to={screen.path}
end={screen.path === "/"}
className={({ isActive }) =>
`truncate px-1 py-3 text-center text-xs ${isActive ? "font-semibold text-amber-400" : "text-slate-400"}`
}
>
{screen.label}
</NavLink>
))}
</nav>
</div>
);
}

13
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

9
apps/web/src/routes.ts Normal file
View File

@ -0,0 +1,9 @@
/** The app's top-level screens, in bottom-navigation order. */
export const SCREENS = [
{ path: "/", title: "HQ", label: "HQ" },
{ path: "/contracts", title: "Contracts", label: "Contracts" },
{ path: "/fleet", title: "Fleet", label: "Fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots" },
{ path: "/market", title: "Market", label: "Market" },
{ path: "/locations", title: "Locations", label: "Places" },
] as const;

View File

@ -0,0 +1,9 @@
/** A screen that has not been built yet (Phase 9 fills these in). */
export function Placeholder({ title }: { title: string }) {
return (
<section className="p-4">
<h1 className="text-xl font-semibold">{title}</h1>
<p className="mt-2 text-sm text-slate-400">Coming soon.</p>
</section>
);
}

View File

@ -0,0 +1,41 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { App } from "../src/App";
import { SCREENS } from "../src/routes";
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
);
}
describe("app layout", () => {
it("renders the header with credits and game clock, and the bottom navigation", () => {
renderAt("/");
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByLabelText("Credits")).toBeInTheDocument();
expect(screen.getByLabelText("Game clock")).toBeInTheDocument();
const nav = screen.getByRole("navigation", { name: "Main" });
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
});
it.each(SCREENS)("shows the $title placeholder at $path", ({ path, title }) => {
renderAt(path);
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
});
it("navigates between screens with the bottom navigation", async () => {
renderAt("/");
const nav = screen.getByRole("navigation", { name: "Main" });
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
expect(within(nav).getByRole("link", { name: "Fleet" })).toHaveAttribute(
"aria-current",
"page",
);
});
});

5
apps/web/test/setup.ts Normal file
View File

@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => cleanup());

View File

@ -1,4 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"]
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "test", "vite.config.ts", "vitest.config.ts"]
}

12
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
// Same-domain API in development too (decision 0.5.20). The server listens on
// IPv4 only, so target 127.0.0.1 rather than localhost.
proxy: { "/api": "http://127.0.0.1:3000" },
},
});

10
apps/web/vitest.config.ts Normal file
View File

@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react";
import { defineProject } from "vitest/config";
export default defineProject({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
},
});

View File

@ -16,6 +16,12 @@ export default tseslint.config(
globals: { ...globals.node },
},
},
{
files: ["apps/web/**/*.{ts,tsx}"],
languageOptions: {
globals: { ...globals.browser },
},
},
{
files: ["packages/sim/**/*.ts", "packages/content/**/*.ts"],
rules: {

View File

@ -74,3 +74,11 @@
- [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
- [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it.
- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.
## Phase 8 — Client Foundation
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user.
- [ ] Step 8.2 — Typed API client
- [ ] Step 8.3 — Server-synced clock
- [ ] Step 8.4 — Guest onboarding
- [ ] Step 8.5 — Player state store

File diff suppressed because it is too large Load Diff