diff --git a/@lab/ll-CARDSHED/.stitch/designs/S02-player-setup.html b/@lab/ll-CARDSHED/.stitch/designs/S02-player-setup.html
new file mode 100644
index 0000000..d45e57e
--- /dev/null
+++ b/@lab/ll-CARDSHED/.stitch/designs/S02-player-setup.html
@@ -0,0 +1,240 @@
+
+
+
+
+
+CARD SHED - Player Setup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+WHO'S PLAYING?
+
+
+
+
+
+RNG SEED
+0x4a2c88f1
+
+
+
+
+
+
+
+ Enter at least 3 names to start
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/@lab/ll-CARDSHED/.stitch/designs/S02-player-setup.png b/@lab/ll-CARDSHED/.stitch/designs/S02-player-setup.png
new file mode 100644
index 0000000..299982f
Binary files /dev/null and b/@lab/ll-CARDSHED/.stitch/designs/S02-player-setup.png differ
diff --git a/@lab/ll-CARDSHED/apps/ui/src/App.tsx b/@lab/ll-CARDSHED/apps/ui/src/App.tsx
index 311bf5e..3e36207 100644
--- a/@lab/ll-CARDSHED/apps/ui/src/App.tsx
+++ b/@lab/ll-CARDSHED/apps/ui/src/App.tsx
@@ -1,48 +1,72 @@
/*
* App shell — routes between the MVP screens.
*
- * M3 wires MainMenu (S01). M4 replaces the "player-setup" placeholder with the
- * real PlayerSetup screen + matchStore. Per .claude/rules/ui-design-pipeline.md,
- * every screen past MainMenu has a Stitch origin in docs/SCREENS/.
+ * M3 wired MainMenu (S01). M4 wires PlayerSetup (S02) + matchStore: Start
+ * advances to a labelled "table" placeholder (M5 owns the real Table screen).
+ * Per .claude/rules/ui-design-pipeline.md, every screen past MainMenu has a
+ * Stitch origin in docs/SCREENS/.
*/
import { useState } from "react";
import { MainMenu, type MainMenuAction } from "./features/menu/MainMenu";
+import { PlayerSetup } from "./features/lobby/PlayerSetup";
+import { useMatchStore } from "./state/matchStore";
type Screen =
| "mainmenu"
- | "player-setup-placeholder"
+ | "player-setup"
+ | "table-placeholder"
| "rules-placeholder"
| "settings-placeholder";
export default function App() {
const [screen, setScreen] = useState("mainmenu");
+ const startMatch = useMatchStore((s) => s.startMatch);
+ const resetMatch = useMatchStore((s) => s.reset);
const onMenuAction = (action: MainMenuAction) => {
- if (action === "play") setScreen("player-setup-placeholder");
+ if (action === "play") setScreen("player-setup");
else if (action === "rules") setScreen("rules-placeholder");
else if (action === "settings") setScreen("settings-placeholder");
};
+ const goMainMenu = () => {
+ resetMatch();
+ setScreen("mainmenu");
+ };
+
if (screen === "mainmenu") {
return ;
}
- return setScreen("mainmenu")} />;
+
+ if (screen === "player-setup") {
+ return (
+ {
+ startMatch({ players, seed });
+ setScreen("table-placeholder");
+ }}
+ onBack={goMainMenu}
+ />
+ );
+ }
+
+ return ;
}
function Placeholder({
screen,
onBack,
}: {
- screen: Exclude;
+ screen: Exclude;
onBack: () => void;
}) {
const labels: Record<
- Exclude,
+ Exclude,
{ title: string; milestone: string }
> = {
- "player-setup-placeholder": { title: "Player Setup", milestone: "M4 (S02)" },
- "rules-placeholder": { title: "Rules Help", milestone: "M11 (S09)" },
+ "table-placeholder": { title: "Match table", milestone: "M5 (S03)" },
+ "rules-placeholder": { title: "Rules help", milestone: "M11 (S09)" },
"settings-placeholder": { title: "Settings", milestone: "M11 (S10)" },
};
const { title, milestone } = labels[screen];
diff --git a/@lab/ll-CARDSHED/apps/ui/src/features/lobby/PlayerSetup.tsx b/@lab/ll-CARDSHED/apps/ui/src/features/lobby/PlayerSetup.tsx
new file mode 100644
index 0000000..0ca51a0
--- /dev/null
+++ b/@lab/ll-CARDSHED/apps/ui/src/features/lobby/PlayerSetup.tsx
@@ -0,0 +1,470 @@
+/*
+ * S02 PlayerSetup.
+ * Stitch origin: docs/SCREENS/player-setup.md
+ * (DS-1, Stitch screen 956dbc597742437da0c8c3309f3e0503).
+ * Tokens consumed verbatim from .stitch/DESIGN.md §9 — do not invent.
+ *
+ * Engine coupling: the Start button calls back into the caller with
+ * `(players, seed)`. The store + core call happens in App.tsx so the
+ * presentational component stays testable in isolation.
+ */
+import { useId, useMemo, useState } from "react";
+import { generateMatchSeed } from "../../state/matchStore";
+
+interface PlayerSetupProps {
+ /** Called once Start is clicked with ≥3 non-empty names. */
+ onStart: (input: { players: string[]; seed: number }) => void;
+ /** Caller-supplied seed generator (default uses crypto.getRandomValues). */
+ generateSeed?: () => number;
+ /** Back-to-menu callback. */
+ onBack: () => void;
+ /** Version chip in the footer. */
+ version?: string;
+}
+
+const MIN_PLAYERS = 3;
+const MAX_PLAYERS = 4;
+
+export function PlayerSetup({
+ onStart,
+ generateSeed = generateMatchSeed,
+ onBack,
+ version = "v0.x",
+}: PlayerSetupProps) {
+ const [names, setNames] = useState(["", "", "", ""]);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const [seed, setSeed] = useState(() => generateSeed());
+
+ const filledNames = useMemo(
+ () => names.map((n) => n.trim()).filter((n) => n.length > 0),
+ [names],
+ );
+ const canStart = filledNames.length >= MIN_PLAYERS;
+
+ const validationId = useId();
+
+ const updateName = (index: number, value: string) => {
+ setNames((prev) => {
+ const next = prev.slice();
+ next[index] = value;
+ return next;
+ });
+ };
+
+ const handleRemovePlayer4 = () => updateName(3, "");
+
+ const handleRandomize = () => {
+ setSeed(generateSeed());
+ };
+
+ const handleStart = () => {
+ if (!canStart) return;
+ onStart({ players: filledNames.slice(0, MAX_PLAYERS), seed });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Who's playing?
+
+
+
+ {names.map((value, idx) => (
+
updateName(idx, v)}
+ onRemove={idx === 3 ? handleRemovePlayer4 : undefined}
+ />
+ ))}
+
+
+ setAdvancedOpen((s) => !s)}
+ seed={seed}
+ onRandomize={handleRandomize}
+ />
+
+
+
+ Enter at least 3 names to start
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function BackToMenuLink({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+ );
+}
+
+interface PlayerRowProps {
+ index: number;
+ value: string;
+ onChange: (next: string) => void;
+ onRemove?: () => void;
+}
+
+function PlayerRow({ index, value, onChange, onRemove }: PlayerRowProps) {
+ const inputId = `player-name-${index + 1}`;
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder="Enter name"
+ className="w-full max-w-[480px] h-14 px-4 outline-none"
+ style={{
+ background: "var(--color-surface-low)",
+ border: "1px solid var(--color-outline-variant)",
+ borderRadius: "var(--radius-md)",
+ color: "var(--color-ink)",
+ fontFamily: "var(--font-body)",
+ fontSize: "16px",
+ }}
+ onFocus={(e) => {
+ e.currentTarget.style.borderColor = "var(--color-legal)";
+ e.currentTarget.style.boxShadow = "0 0 0 1px var(--color-legal)";
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.borderColor = "var(--color-outline-variant)";
+ e.currentTarget.style.boxShadow = "none";
+ }}
+ />
+ {onRemove ? (
+
+ ) : null}
+
+ );
+}
+
+interface AdvancedDisclosureProps {
+ open: boolean;
+ onToggle: () => void;
+ seed: number;
+ onRandomize: () => void;
+}
+
+function AdvancedDisclosure({
+ open,
+ onToggle,
+ seed,
+ onRandomize,
+}: AdvancedDisclosureProps) {
+ const seedHex = `0x${seed.toString(16).padStart(8, "0")}`;
+ const panelId = useId();
+ return (
+
+
+ {open ? (
+
+
+ RNG Seed
+
+
+ {seedHex}
+
+
+
+ ) : null}
+
+ );
+}
+
+interface StartMatchButtonProps {
+ disabled: boolean;
+ onClick: () => void;
+ "aria-describedby"?: string;
+}
+
+function StartMatchButton({
+ disabled,
+ onClick,
+ "aria-describedby": ariaDescribedBy,
+}: StartMatchButtonProps) {
+ return (
+
+ );
+}
+
+function FannedCardsMotif() {
+ const cardStyle = {
+ width: 280,
+ height: 400,
+ background: "var(--color-ink-strong)",
+ border: "3px solid var(--color-outline-variant)",
+ borderRadius: "var(--radius-lg)",
+ filter: "drop-shadow(0 10px 20px rgba(0, 0, 0, 0.5))",
+ transformOrigin: "bottom center",
+ } satisfies React.CSSProperties;
+
+ return (
+
+ );
+}
diff --git a/@lab/ll-CARDSHED/apps/ui/src/state/matchStore.test.ts b/@lab/ll-CARDSHED/apps/ui/src/state/matchStore.test.ts
new file mode 100644
index 0000000..95b2673
--- /dev/null
+++ b/@lab/ll-CARDSHED/apps/ui/src/state/matchStore.test.ts
@@ -0,0 +1,94 @@
+/*
+ * matchStore — engine-integration tests.
+ *
+ * Asserts the PRP 3 M4 acceptance gates that agent-browser cannot directly
+ * introspect (zustand state from outside React):
+ * - 3 names → MatchState has 3 players, 5 cards each, 37 in deck
+ * - 4 names → MatchState has 4 players, 5 cards each, 32 in deck
+ * - < 3 or > 4 → startMatch throws
+ * - Seed is exactly preserved on MatchState.rngSeed (replayability invariant)
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+import { useMatchStore, generateMatchSeed } from "./matchStore";
+
+describe("matchStore.startMatch", () => {
+ beforeEach(() => {
+ useMatchStore.getState().reset();
+ });
+
+ it("produces a 3-player MatchState from 3 names", () => {
+ useMatchStore.getState().startMatch({
+ players: ["Ada", "Bo", "Cy"],
+ seed: 0x1234,
+ });
+ const m = useMatchStore.getState().match!;
+ expect(m).not.toBeNull();
+ expect(m.players).toHaveLength(3);
+ expect(m.players.map((p) => p.name)).toEqual(["Ada", "Bo", "Cy"]);
+ // 3 × 5 + 37 = 52 — conservation invariant from core
+ expect(m.deck.length).toBe(52 - 3 * 5);
+ for (const p of m.players) expect(p.hand).toHaveLength(5);
+ expect(m.rngSeed).toBe(0x1234);
+ expect(m.round.phase).toBe("AwaitingAttack");
+ });
+
+ it("produces a 4-player MatchState from 4 names", () => {
+ useMatchStore.getState().startMatch({
+ players: ["Ada", "Bo", "Cy", "Di"],
+ seed: 0xabcd,
+ });
+ const m = useMatchStore.getState().match!;
+ expect(m.players).toHaveLength(4);
+ expect(m.deck.length).toBe(52 - 4 * 5);
+ expect(m.rngSeed).toBe(0xabcd);
+ });
+
+ it("rejects fewer than 3 names", () => {
+ expect(() =>
+ useMatchStore.getState().startMatch({ players: ["Solo"], seed: 1 }),
+ ).toThrow();
+ expect(() =>
+ useMatchStore.getState().startMatch({ players: ["A", "B"], seed: 1 }),
+ ).toThrow();
+ });
+
+ it("rejects more than 4 names", () => {
+ expect(() =>
+ useMatchStore.getState().startMatch({
+ players: ["A", "B", "C", "D", "E"],
+ seed: 1,
+ }),
+ ).toThrow();
+ });
+
+ it("the same seed produces an identical MatchState (replay invariant)", () => {
+ useMatchStore.getState().startMatch({
+ players: ["Ada", "Bo", "Cy"],
+ seed: 0xdeadbeef,
+ });
+ const a = useMatchStore.getState().match!;
+ useMatchStore.getState().reset();
+ useMatchStore.getState().startMatch({
+ players: ["Ada", "Bo", "Cy"],
+ seed: 0xdeadbeef,
+ });
+ const b = useMatchStore.getState().match!;
+ // Order-sensitive deep equality
+ expect(JSON.stringify(a)).toBe(JSON.stringify(b));
+ });
+});
+
+describe("generateMatchSeed", () => {
+ it("returns a 32-bit unsigned integer", () => {
+ const s = generateMatchSeed();
+ expect(Number.isInteger(s)).toBe(true);
+ expect(s).toBeGreaterThanOrEqual(0);
+ expect(s).toBeLessThanOrEqual(0xffffffff);
+ });
+
+ it("returns distinct values across calls (probabilistic)", () => {
+ const seen = new Set();
+ for (let i = 0; i < 16; i++) seen.add(generateMatchSeed());
+ expect(seen.size).toBeGreaterThan(8); // overwhelmingly likely all 16 differ
+ });
+});
diff --git a/@lab/ll-CARDSHED/apps/ui/src/state/matchStore.ts b/@lab/ll-CARDSHED/apps/ui/src/state/matchStore.ts
new file mode 100644
index 0000000..b7eaf60
--- /dev/null
+++ b/@lab/ll-CARDSHED/apps/ui/src/state/matchStore.ts
@@ -0,0 +1,54 @@
+/*
+ * matchStore — top-level match state for the hot-seat UI.
+ *
+ * The store wraps `@cardshed/core.startNewRound(null, seed, setup)` and stores
+ * the returned `MatchState`. The seed is supplied by the caller; per PRP 3 M4
+ * the caller — and not core — generates it with `crypto.getRandomValues` so the
+ * core stays I/O-free.
+ *
+ * Persistence is intentionally NOT wired here. PRP 3 M4 warns that writing
+ * MatchState to localStorage on every action risks the quota; the agreed
+ * cadence is "throttle to round boundaries" and lands at M5+.
+ */
+import { create } from "zustand";
+import { startNewRound, type MatchState, type PlayerSetup } from "@cardshed/core";
+
+export interface StartMatchInput {
+ /** Trimmed display names, in seat order. Must be length 3 or 4. */
+ players: string[];
+ /** 32-bit unsigned seed, supplied by the caller (e.g. crypto.getRandomValues). */
+ seed: number;
+}
+
+interface MatchStoreState {
+ match: MatchState | null;
+ startMatch: (input: StartMatchInput) => void;
+ reset: () => void;
+}
+
+export const useMatchStore = create((set) => ({
+ match: null,
+ startMatch: ({ players, seed }) => {
+ if (players.length !== 3 && players.length !== 4) {
+ throw new Error(`matchStore: playerCount must be 3 or 4 (got ${players.length})`);
+ }
+ const setup: { matchId: string; players: PlayerSetup[] } = {
+ matchId: `m-${seed.toString(16)}`,
+ players: players.map((name, idx) => ({ id: `p${idx + 1}`, name })),
+ };
+ const match = startNewRound(null, seed, setup);
+ set({ match });
+ },
+ reset: () => set({ match: null }),
+}));
+
+/**
+ * Generates a 32-bit unsigned seed ONCE, outside `@cardshed/core`. The core
+ * never touches `crypto`; the only seed source it accepts is a number caller-
+ * supplied here. Per PRP 3 M4 "common bugs" — never use Math.random().
+ */
+export function generateMatchSeed(): number {
+ const buf = new Uint32Array(1);
+ crypto.getRandomValues(buf);
+ return buf[0]!;
+}
diff --git a/@lab/ll-CARDSHED/docs/DECISIONS/2026-05-21-stitch-run-3.md b/@lab/ll-CARDSHED/docs/DECISIONS/2026-05-21-stitch-run-3.md
new file mode 100644
index 0000000..82bee6c
--- /dev/null
+++ b/@lab/ll-CARDSHED/docs/DECISIONS/2026-05-21-stitch-run-3.md
@@ -0,0 +1,67 @@
+# Stitch Run #3 — S02 PlayerSetup
+
+- **UTC:** 2026-05-21
+- **Stitch project:** `projects/4083518914509964664` ("ll-CARDSHED — MVP hot-seat")
+- **Design system used:** `assets/a70557bcdeed4f9ca600d28ffca0d467` (DS-1, "Tournament Card Elite") — passed via `designSystem` parameter so DS-1 propagates verbatim. Theme block in the returned HTML round-trips byte-identical to S01 and S03.
+- **Screen produced:** `projects/4083518914509964664/screens/956dbc597742437da0c8c3309f3e0503` ("Player Setup — CARD SHED")
+- **Local artifacts:**
+ - `.stitch/designs/S02-player-setup.html` (~13 KB)
+ - `.stitch/designs/S02-player-setup.png` (~59 KB, 2560 × 2714 source res; designed for the 1440 × 900 viewport)
+- **Issue:** #149 (second half — M4)
+- **Branch:** `feat/cardshed-player-setup`
+
+## Why this is "Stitch Run #3" and not "DS-2"
+
+DS-1 remains authoritative. This run **consumed** DS-1 via the `designSystem` parameter — it did **not** regenerate it. The Stitch call returned the same color/typography blocks as S01 and S03, confirming DS-1 propagates cleanly across the third independent screen.
+
+DS-2 would be triggered only by the criteria in `.stitch/DESIGN.md` §10 — none of which apply here.
+
+## What was generated
+
+A pre-match lobby matching `docs/STORYBOARD.md` §5 S02 wireframe. Stitch chose:
+
+- **Layout:** top-left `◀ MAIN MENU` back link, centered `CARD SHED` brand caption in the header, centered 640px-max-width form panel with a Playfair Display h1 "WHO'S PLAYING?", four `Player N`-labelled input rows (P4 carrying a `REMOVE` ghost action), an Advanced disclosure showing the seed + Randomize, a fixed-height validation message line, a 280 × 56 px Start match pill.
+- **Inputs:** 56 px tall, `surface-container-low` fill, `outline-variant` 1 px border, `lg`-radius, Inter 16 px text, placeholder `Enter Name`, focus ring switches to the `tertiary` (teal) color — semantically reusing the "legal action" color as a focus signal.
+- **Start match (enabled):** `secondary-container` gold fill with `0 0 20px rgba(238,152,0,0.3)` glow, uppercase Inter 12px 600.
+- **Start match (disabled):** `surface-container-high` fill, `outline-variant` text, no glow, `cursor: not-allowed`.
+- **Decorative fanned cards:** identical lower-left motif to S01, 7% opacity — visual continuity across S01 → S02.
+- **Advanced disclosure:** a `▼ ADVANCED` toggle that reveals a `RNG SEED 0x4a2c88f1 [RANDOMIZE]` row. Collapsed by default.
+- **Validation slot:** reserved `h-5` height with the message "Enter at least 3 names to start" — opacity toggles, the slot itself never shifts so the Start button doesn't move when validity changes.
+
+## What was NOT shipped from Stitch's output
+
+- **`CARD SHED` brand caption in the header.** Stitch added a small Playfair Display caption between the back link and a right-side spacer. Removed — the h1 "WHO'S PLAYING?" already anchors the page, and adding a second occurrence of the brand violates "single h1 per route" + creates redundant chrome the user doesn't need on a pre-match screen.
+- **Material Symbols Outlined font load.** Stitch wired `` tags for the icon font twice and used `arrow_back_ios` + `arrow_drop_down`. Replaced with Unicode glyphs (`◀`, `▼`, `▲`) to match the S01 "no Material Symbols until S09/S10" decision — keeps page weight down and removes one CDN dependency.
+- **`© 2024 CARD SHED v1.0.4-beta. Tactical Precision Engine.` footer line.** Stitch added an ornamental right-aligned footer caption. Removed — same reason as S01's `v1.0.4 Tournament Mode Active` line: it's ornament implying state we don't actually have.
+- **`overflow: hidden` on ``.** Stitch's inline `