diff --git a/backend/app/main.py b/backend/app/main.py index 9dfcb55..a5d4c0e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI -from app.routers import auth, library, roster, teams, whiteboard +from app.routers import auth, formations, library, roster, teams, whiteboard app = FastAPI(title="Patterns of Play API") app.include_router(auth.router) @@ -8,6 +8,7 @@ app.include_router(whiteboard.router) app.include_router(library.router) app.include_router(roster.router) +app.include_router(formations.router) @app.get("/api/health") diff --git a/backend/app/routers/formations.py b/backend/app/routers/formations.py new file mode 100644 index 0000000..dfbc26b --- /dev/null +++ b/backend/app/routers/formations.py @@ -0,0 +1,93 @@ +"""Formations, keystones, and the rondo map (doc 03 section 5, Bible 4, +3G.2; Brief step 18; the Formations page's board-first render). Library +world content, same reasoning as app/routers/library.py: no team_id +anywhere (Formation/FormationKeystone/RondoZone carry none), so this only +requires an authenticated user, not the team-scoped query layer. +""" + +from collections import defaultdict + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.deps import get_current_user, get_db +from app.models import Formation, FormationKeystone, RondoZone, User +from app.schemas import FormationKeystoneOut, FormationOut, FormationPositionOut, RondoZoneOut + +router = APIRouter(prefix="/api", tags=["formations"]) + +# doc 03 section 5's six MVP presets, in the Bible's own 4.1-4.6 order +# (matches the seed file and every design PNG's preset list order: 4-3-3, +# 4-2-3-1, 4-4-2, 3-5-2, 3-4-3, 5-4-1). Formation.code has no sequence +# column of its own, so this is the one place that order is asserted. +FORMATION_ORDER = ("433", "4231", "442", "352", "343", "541") + +# Bible 3G.2's rondo map order (first-line build-up through to the +# counterpress moment); only 433 carries seeded zones today (seeds/ +# rondo_zones.json), but the ordering applies to any formation that gains +# a rondo map later. +ZONE_ORDER = ("first_line", "midfield_box", "flank_corridor", "last_line", "counterpress") + + +def _order_index(value: str, order: tuple[str, ...]) -> int: + try: + return order.index(value) + except ValueError: + return len(order) + + +@router.get("/formations", response_model=list[FormationOut]) +def list_formations( + _current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[FormationOut]: + formations = sorted(db.query(Formation).all(), key=lambda f: _order_index(f.code, FORMATION_ORDER)) + + keystones_by_code: dict[str, list[FormationKeystone]] = defaultdict(list) + for keystone in db.query(FormationKeystone).all(): + keystones_by_code[keystone.formation_code].append(keystone) + + zones_by_code: dict[str, list[RondoZone]] = defaultdict(list) + for zone in db.query(RondoZone).all(): + zones_by_code[zone.formation_code].append(zone) + + result: list[FormationOut] = [] + for formation in formations: + zones = sorted( + zones_by_code.get(formation.code, []), + key=lambda z: _order_index(z.zone_key, ZONE_ORDER), + ) + result.append( + FormationOut( + code=formation.code, + name=formation.name, + shape_blurb=formation.shape_blurb, + strengths=formation.strengths_json, + vulnerabilities=formation.vulnerabilities_json, + natural_identities=formation.natural_identities, + positions=[ + FormationPositionOut( + slot=p["slot"], + position_code=p["position_code"], + x=p["x"], + y=p["y"], + ) + for p in formation.positions_json + ], + keystones=[ + FormationKeystoneOut(slot=k.slot, title=k.title, blurb=k.blurb) + for k in keystones_by_code.get(formation.code, []) + ], + rondo_zones=[ + RondoZoneOut( + zone_key=z.zone_key, + rondo_name=z.rondo_name, + teaches=z.teaches, + polygon=z.polygon_json, + trains_pattern_codes=z.trains_pattern_codes, + ) + for z in zones + ], + ) + ) + return result diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 256fea8..02c28a8 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -7,7 +7,15 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field -from app.specs import AnimationSpec, BoardSnapshot, BoardToken, ConfirmedLane, Keyframe, ZonesVisible +from app.specs import ( + AnimationSpec, + BoardSnapshot, + BoardToken, + ConfirmedLane, + Keyframe, + ModelPoint, + ZonesVisible, +) RoleOnTeam = Literal["coach", "player"] @@ -289,3 +297,59 @@ class CoachRosterOut(RosterOut): based on the caller's role_on_team, never both from one shared model.""" fit_warnings: list[FitWarningOut] + + +# --------------------------------------------------------------------------- +# Formations, keystones, rondo map (doc 03 section 5, Bible 4/3G.2; Brief +# step 18; T-032). Library-world content like LibraryItemOut above: no +# team_id, visible to both roles, read-only to every team member. +# --------------------------------------------------------------------------- + + +class FormationPositionOut(BaseModel): + """One slot from Formation.positions_json (doc 03 section 5): a + landscape model coordinate plus the position_code the keystone lookup + and the board's on-token labels both key off of.""" + + slot: str + position_code: str + x: float + y: float + + +class FormationKeystoneOut(BaseModel): + """One formation_keystones row (Bible Section 4 keystone copy): drives + both the on-board pulsing keycard (tap the token at this slot) and the + Details panel's "every keystone blurb" list (Brief step 18 DoD).""" + + slot: str + title: str + blurb: str + + +class RondoZoneOut(BaseModel): + """One rondo_zones row (Bible 3G.2): a tappable zone on the Rondo Map, + naming which rondo lives there and which library patterns it trains.""" + + zone_key: str + rondo_name: str + teaches: str + polygon: list[ModelPoint] + trains_pattern_codes: list[str] + + +class FormationOut(BaseModel): + """GET /api/formations. Keystones and rondo zones are embedded per + formation (not separate endpoints): the Formations page's browse sheet + needs every preset's full detail up front, the same one-round-trip + shape the Patterns page's listLibraryItems already follows.""" + + code: str + name: str + shape_blurb: str + strengths: list[str] + vulnerabilities: list[str] + natural_identities: list[str] + positions: list[FormationPositionOut] + keystones: list[FormationKeystoneOut] + rondo_zones: list[RondoZoneOut] diff --git a/backend/tests/test_formations_routes.py b/backend/tests/test_formations_routes.py new file mode 100644 index 0000000..7938989 --- /dev/null +++ b/backend/tests/test_formations_routes.py @@ -0,0 +1,130 @@ +"""Formations route (doc 03 section 5; Brief step 18): GET /api/formations +serves the six seeded presets with their keystones and rondo zones +embedded. Not team-scoped (Formation/FormationKeystone/RondoZone carry no +team_id), but still requires authentication like every other route. Seeds +via the real scripts/seed.py loader (same in-process import convention as +test_seed_content.py's idempotency test) rather than hand-built fixtures, +so this exercises the actual seeded content: 6 formations, 13 keystones, +5 rondo zones (all on 433, per seeds/rondo_zones.json's own note). +""" + +import importlib.util +import pathlib +import sys + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _import_seed_module(): + spec = importlib.util.spec_from_file_location("pop_seed_script_formations", REPO_ROOT / "scripts" / "seed.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def client() -> TestClient: + seed = _import_seed_module() + assert seed.main() == 0 + return TestClient(app) + + +def _register(client: TestClient, *, email: str, role: str, display_name: str = "Test User"): + return client.post( + "/api/auth/register", + json={ + "email": email, + "password": "correct-horse-battery", + "display_name": display_name, + "role": role, + }, + ) + + +def _coach_with_team(email: str = "coach@example.com") -> TestClient: + c = TestClient(app) + _register(c, email=email, role="coach", display_name="Coach Test") + c.post("/api/teams", json={"name": f"Team for {email}"}) + return c + + +def _player_on_team(coach: TestClient, email: str) -> TestClient: + join_code = coach.get("/api/teams/current").json()["join_code"] + p = TestClient(app) + _register(p, email=email, role="player", display_name="Player Test") + p.post("/api/teams/join", json={"join_code": join_code}) + return p + + +def test_list_formations_requires_authentication(client: TestClient) -> None: + assert client.get("/api/formations").status_code == 401 + + +def test_list_formations_returns_all_six_in_bible_order(client: TestClient) -> None: + coach = _coach_with_team() + response = coach.get("/api/formations") + assert response.status_code == 200 + codes = [f["code"] for f in response.json()] + assert codes == ["433", "4231", "442", "352", "343", "541"] + + +def test_players_can_browse_formations_too(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + assert len(player.get("/api/formations").json()) == 6 + + +def test_formation_shape_includes_positions_strengths_and_vulnerabilities(client: TestClient) -> None: + coach = _coach_with_team() + formations = {f["code"]: f for f in coach.get("/api/formations").json()} + f433 = formations["433"] + assert f433["name"] == "4-3-3" + assert len(f433["positions"]) == 11 + assert any(p["slot"] == "six" and p["position_code"] == "DM" for p in f433["positions"]) + assert len(f433["strengths"]) >= 1 + assert len(f433["vulnerabilities"]) >= 1 + + +def test_every_keystone_tap_target_has_a_slot_title_and_blurb(client: TestClient) -> None: + coach = _coach_with_team() + formations = {f["code"]: f for f in coach.get("/api/formations").json()} + f433 = formations["433"] + keystone_slots = {k["slot"] for k in f433["keystones"]} + assert keystone_slots == {"six", "st", "eight_l"} + six = next(k for k in f433["keystones"] if k["slot"] == "six") + assert six["title"] == "The 6 (single pivot)" + assert "elite positional discipline" in six["blurb"] + + f4231 = formations["4231"] + assert {k["slot"] for k in f4231["keystones"]} == {"am", "dm_l"} + + +def test_rondo_zones_show_their_rondo_and_linked_patterns(client: TestClient) -> None: + coach = _coach_with_team() + formations = {f["code"]: f for f in coach.get("/api/formations").json()} + f433 = formations["433"] + zones = {z["zone_key"]: z for z in f433["rondo_zones"]} + assert set(zones) == {"first_line", "midfield_box", "flank_corridor", "last_line", "counterpress"} + + midfield = zones["midfield_box"] + assert midfield["rondo_name"] == "5v3 (the midfield box)" + assert midfield["trains_pattern_codes"] == ["B8", "A5"] + assert len(midfield["polygon"]) == 4 + assert all({"x", "y"} <= set(pt) for pt in midfield["polygon"]) + + # Only 433 carries a seeded rondo map today (seeds/rondo_zones.json note). + f442 = formations["442"] + assert f442["rondo_zones"] == [] + + +def test_em_dash_never_appears_in_a_formations_response(client: TestClient) -> None: + coach = _coach_with_team() + body = coach.get("/api/formations").text + assert "โ€”" not in body diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index 124ec5f..64cd2a5 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -17,7 +17,7 @@ Model: sonnet default; opus = hard ticket, never downgrade. | T-030 | Whiteboard page (PNG 01-05, 14, 34): toolbar, view menu, record/save into My Patterns | 16 | screens | sonnet | T-022, T-004 | T-031 | done | | T-031 | Patterns page (PNG 05-10, 29-31, 15-18, 35): sheet w/ 3 libraries, chips, search, meta bar, details panels | 17 | screens | sonnet | T-022, T-011 | T-030 | done | | T-032 | Formations page (PNG 11, 19, 37-39, 43) + keystone pulse/keycards + Rondo Map (PNG 32, 36) | 18 | screens | sonnet | T-031 | T-033 | doing | -| T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | pr | +| T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | done | | T-034 | Identity page (PNG 13, 33, 40-42, 44, 45): 4 scripted animations, 2 static shapes, detail slots, pass-risk, cult corner | 20 | screens | sonnet | T-031 | T-033 | doing | | T-040 | Role gating UI + API 403 enforcement, permission test suite both roles (Brief ยง3 table, every row) | 21 | collab | sonnet | T-030..T-034 | T-041 | todo | | T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | todo | diff --git a/e2e/formations.spec.ts b/e2e/formations.spec.ts new file mode 100644 index 0000000..edfe61b --- /dev/null +++ b/e2e/formations.spec.ts @@ -0,0 +1,173 @@ +// Formations page journey (T-032, Brief step 18, PNG 11, 19, 37-39, 43, +// plus the Rondo Map PNG 32/36). Runs under both Playwright projects +// (desktop landscape, iPhone 13 portrait). Covers the ticket's Screens +// DoD lines (Brief section 5): +// "Each page matches its PNGs across the three themes on desktop and +// phone frames; gold is the only interactive color; red never appears +// as a call to action." +// "Formations: every keystone tap shows its keycard; Rondo Map zones +// each show their rondo and linked patterns." + +import { test, expect, assertCleanPage, registerCoach } from "./fixtures"; +import type { Page } from "@playwright/test"; + +async function openSheet(page: Page) { + const handle = page.getByTestId("formations-sheet-handle"); + if ((await handle.getAttribute("aria-expanded")) !== "true") { + await handle.click(); + } + await expect(page.getByTestId("formations-sheet-body")).toBeVisible(); +} + +async function toRgb(page: Page, cssVar: string): Promise { + return page.evaluate((v) => { + const el = document.createElement("div"); + el.style.color = `var(${v})`; + document.body.appendChild(el); + const rgb = getComputedStyle(el).color; + el.remove(); + return rgb; + }, cssVar); +} + +test.describe("formations: board-first shape, keystone keycards, details, rondo map", () => { + test("full coach journey", async ({ page, issues }) => { + await registerCoach(page); + + // --- Formations is a live nav entry (T-032 activates it) --- + await page.getByTestId("nav-formations").click(); + await expect(page.getByTestId("nav-formations")).toHaveAttribute("aria-current", "page"); + + // --- Board-first default: 4-3-3 renders immediately, no empty board + // (design README: "the shape renders full-size on the board") --- + await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3"); + await expect(page.locator('[data-token-id]')).toHaveCount(11); + + // --- Every keystone tap shows its keycard (Brief step 18 DoD). 4-3-3 + // seeds three keystones: six, st, eight_l (seeds/formation_keystones.json) --- + const keystones: [string, string][] = [ + ["six", "The 6 (single pivot)"], + ["st", "The 9"], + ["eight_l", "The 8s"], + ]; + await expect(page.locator('[data-keystone="true"]')).toHaveCount(3); + for (const [slot, title] of keystones) { + await page.locator(`[data-token-id="${slot}"]`).click(); + await expect(page.getByTestId("formations-keycard")).toBeVisible(); + await expect(page.getByTestId("formations-keycard-title")).toHaveText(title); + await page.getByTestId("formations-keycard-close").click(); + await expect(page.getByTestId("formations-keycard")).toHaveCount(0); + } + + // A non-keystone token never opens a keycard. eight_r sits centrally + // (not near the bottom-sheet handle's overlap strip in portrait). + await page.locator('[data-token-id="eight_r"]').click(); + await expect(page.getByTestId("formations-keycard")).toHaveCount(0); + + // --- Details: strengths, danger areas conceded, every keystone blurb --- + await page.getByTestId("formations-details-toggle").click(); + await expect(page.getByTestId("formations-details-panel")).toBeVisible(); + await expect(page.getByTestId("formations-details-panel")).toContainText("Strengths"); + await expect(page.getByTestId("formations-details-panel")).toContainText("Danger areas conceded"); + await expect(page.getByTestId("formations-details-keystone")).toHaveCount(3); + await page.getByTestId("formations-details-close").click(); + await expect(page.getByTestId("formations-details-panel")).toHaveCount(0); + + // --- Rondo Map: toggle, five tappable zones, each shows its rondo and + // linked patterns (Brief step 18 DoD; seeds/rondo_zones.json, 433 only) --- + await page.getByTestId("formations-rondo-toggle").click(); + await expect(page.getByTestId("formations-rondo-active-toggle")).toBeVisible(); + await expect(page.getByTestId("rondo-zone")).toHaveCount(5); + + await page.locator('[data-zone-key="midfield_box"]').click(); + await expect(page.getByTestId("formations-zone-card")).toBeVisible(); + await expect(page.getByTestId("formations-zone-title")).toHaveText("5v3 (the midfield box)"); + await expect(page.getByTestId("formations-zone-teaches")).toContainText("split-pass and pause logic"); + const linkedPatterns = page.getByTestId("formations-linked-pattern"); + await expect(linkedPatterns).toHaveCount(2); + await expect(linkedPatterns.filter({ hasText: "B8" })).toContainText("La Pausa"); + await expect(linkedPatterns.filter({ hasText: "A5" })).toContainText("Third-Man Run"); + + // Switching zones swaps the card, not stacks it. + await page.locator('[data-zone-key="last_line"]').click(); + await expect(page.getByTestId("formations-zone-card")).toHaveCount(1); + await expect(page.getByTestId("formations-zone-title")).toHaveText("2v2 (+1 keeper) (the last line)"); + + // Exiting rondo mode restores the normal meta bar (Details/Rondo map). + await page.getByTestId("formations-rondo-active-toggle").click(); + await expect(page.getByTestId("formations-rondo-toggle")).toBeVisible(); + await expect(page.getByTestId("rondo-zone")).toHaveCount(0); + + // --- Browse sheet: searchable shape thumbnails (six presets) --- + await openSheet(page); + await expect(page.getByTestId("formations-tile")).toHaveCount(6); + await page.getByTestId("formations-search").fill("3-4-3"); + await expect(page.getByTestId("formations-tile")).toHaveCount(1); + await page.getByTestId("formations-tile").click(); + await expect(page.getByTestId("formations-sheet-body")).toHaveCount(0); + await expect(page.getByTestId("formations-meta-bar")).toContainText("3-4-3"); + + // 3-4-3 has no seeded rondo map: the toggle stays present but disabled + // (do not invent a rondo map beyond what seeds/rondo_zones.json carries). + await expect(page.getByTestId("formations-rondo-toggle")).toBeDisabled(); + + // Its own keystones still tap to their own keycards. + await page.locator('[data-token-id="cm_l"]').click(); + await expect(page.getByTestId("formations-keycard-title")).toHaveText("The double pivot"); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("formations: matches across all three themes, gold-only interactive, red never a CTA", () => { + test("keystone pulse, details, and rondo controls are theme-driven, never red", async ({ page, issues }) => { + await registerCoach(page); + await page.getByTestId("nav-formations").click(); + + const seenDetailsBtn = new Set(); + const seenKeystoneGlow = new Set(); + const seenRondoPill = new Set(); + + for (const theme of ["pitch", "dark", "board"] as const) { + await page.getByTestId(`theme-switch-${theme}`).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + + const redRgb = await toRgb(page, "--red"); + + const detailsBg = await page + .getByTestId("formations-details-toggle") + .evaluate((el) => getComputedStyle(el).backgroundColor); + seenDetailsBtn.add(detailsBg); + expect(detailsBg).not.toBe(redRgb); + + const keystoneGlow = await page + .locator('[data-token-id="six"] .token-face') + .evaluate((el) => getComputedStyle(el).filter); + seenKeystoneGlow.add(keystoneGlow); + expect(keystoneGlow).not.toContain("none"); + + await page.getByTestId("formations-rondo-toggle").click(); + const pillBg = await page + .getByTestId("formations-rondo-active-toggle") + .evaluate((el) => getComputedStyle(el).backgroundColor); + seenRondoPill.add(pillBg); + expect(pillBg).not.toBe(redRgb); + + const zoneStroke = await page + .locator('[data-zone-key="first_line"]') + .evaluate((el) => getComputedStyle(el).stroke); + // Rondo zones read the gold --accent token: never the red one. + expect(zoneStroke).not.toBe(redRgb); + + await page.getByTestId("formations-rondo-active-toggle").click(); + } + + // Every theme actually painted a distinct value: proves gold-only + // elements read CSS variables per theme rather than a baked-in color. + expect(seenDetailsBtn.size).toBe(3); + expect(seenKeystoneGlow.size).toBe(3); + expect(seenRondoPill.size).toBe(3); + + await assertCleanPage(page, issues); + }); +}); diff --git a/e2e/whiteboard.spec.ts b/e2e/whiteboard.spec.ts index 8ec2f84..e298b8e 100644 --- a/e2e/whiteboard.spec.ts +++ b/e2e/whiteboard.spec.ts @@ -73,18 +73,18 @@ test.describe("whiteboard: record, save into My Patterns, replay, and reload res test("full coach journey", async ({ page, issues }) => { await registerCoach(page); - // --- App shell: Whiteboard is the active nav entry. Patterns (T-031) - // and Roster (T-033) are live too; Formations/Identity stay inert - // until their own tickets land --- + // --- App shell: Whiteboard is the active nav entry. Patterns (T-031), + // Roster (T-033), and Formations (T-032) are live too; Identity stays + // inert until its own ticket lands --- await expect(page.getByTestId("nav-whiteboard")).toHaveAttribute("aria-current", "page"); await expect(page.getByTestId("nav-patterns")).not.toBeDisabled(); await expect(page.getByTestId("nav-roster")).not.toBeDisabled(); - for (const key of ["formations", "identity"]) { + await expect(page.getByTestId("nav-formations")).not.toBeDisabled(); + for (const key of ["identity"]) { const item = page.getByTestId(`nav-${key}`); await expect(item).toHaveAttribute("aria-disabled", "true"); await expect(item).toBeDisabled(); } - await expect(page.getByTestId("nav-roster")).not.toBeDisabled(); // --- Lay a confirmed lane, toggle a zone, set both thresholds --- await dragTokenTo(page, "home-2", { x: 30, y: 8 }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79ffc7b..a90c7af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,13 +8,14 @@ import { TeamOnboarding } from "./TeamOnboarding"; import { WhiteboardPage } from "./pages/WhiteboardPage"; import { PatternsPage } from "./pages/PatternsPage"; import { RosterPage } from "./pages/RosterPage"; +import { FormationsPage } from "./pages/FormationsPage"; import ThemeSwitcher from "./theme/ThemeSwitcher"; import "./App.css"; -// Nav entries live so far (T-031 Patterns, T-033 Roster alongside T-030's -// Whiteboard); Formations/Identity join this list as their own -// tickets land, without AppShell.tsx itself needing another edit. -const ENABLED_NAV_KEYS: readonly NavKey[] = ["whiteboard", "patterns", "roster"]; +// Nav entries live so far (T-031 Patterns, T-033 Roster, T-032 Formations +// alongside T-030's Whiteboard); Identity joins this list as its own +// ticket lands, without AppShell.tsx itself needing another edit. +const ENABLED_NAV_KEYS: readonly NavKey[] = ["whiteboard", "patterns", "roster", "formations"]; // Portrait on phone-width viewports, landscape otherwise (design README: all // boards render portrait on phone). Derived purely from viewport width, no @@ -121,6 +122,8 @@ export default function App() { setPage("whiteboard")} /> ) : page === "roster" ? ( + ) : page === "formations" ? ( + ) : ( )} diff --git a/frontend/src/board/PatternPreviewBoard.css b/frontend/src/board/PatternPreviewBoard.css index b142202..79204f4 100644 --- a/frontend/src/board/PatternPreviewBoard.css +++ b/frontend/src/board/PatternPreviewBoard.css @@ -27,3 +27,47 @@ .pattern-empty-card strong { color: var(--text-primary); } + +/* Formations keystones (Brief step 18, design README: "Pulsing gold dot + (formations): keystone position, tap for its blurb"). Gold is the only + interactive color, so the pulse and the tap affordance both read off + --glow/--accent, never a new hardcoded color. */ +.token-keystone { + cursor: pointer; +} +.token-keystone .token-face { + animation: keystone-pulse 1.8s ease-in-out infinite; +} +@keyframes keystone-pulse { + 0%, + 100% { + filter: drop-shadow(0 0 4px var(--glow)); + opacity: 1; + } + 50% { + filter: drop-shadow(0 0 10px var(--glow)); + opacity: 0.75; + } +} + +/* Rondo Map overlay (Brief step 18, PNG 32/36): dashed gold zones, tappable. + Sits behind tokens (paint order in PatternPreviewBoard.tsx); red never + appears here, this is a navigation/teaching aid, not a status. */ +.rondo-zone-poly { + fill: var(--accent); + fill-opacity: 0.08; + stroke: var(--accent); + stroke-width: 2; + stroke-dasharray: 6 5; + cursor: pointer; +} +.rondo-zone-active .rondo-zone-poly { + fill-opacity: 0.22; + stroke-width: 3; +} +.rondo-zone-label { + font-family: var(--display-font); + font-size: 13px; + fill: var(--accent); + pointer-events: none; +} diff --git a/frontend/src/board/PatternPreviewBoard.tsx b/frontend/src/board/PatternPreviewBoard.tsx index 8362d8a..5db4554 100644 --- a/frontend/src/board/PatternPreviewBoard.tsx +++ b/frontend/src/board/PatternPreviewBoard.tsx @@ -29,11 +29,39 @@ function tokenRadius(side: TokenSide, vb: Size): number { return side === "ball" ? base * 0.7 : base; } +// Model-space bounding-box area of a zone's corners. Rondo Map zones can +// nest (seeds/rondo_zones.json: the counterpress zone encloses the smaller +// midfield_box zone), so zones paint largest-first, smallest-last: the +// smaller, fully-enclosed zone ends up on top of the DOM and stays +// independently clickable instead of the larger zone swallowing every tap. +function zoneArea(zone: PreviewZone): number { + const xs = zone.corners.map((c) => c.x); + const ys = zone.corners.map((c) => c.y); + return (Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys)); +} + export interface PreviewToken { id: string; side: TokenSide; label: string; pos: ModelPoint; + /** Formations page keystones (Brief step 18, design README: "Pulsing gold + * dot (formations): keystone position, tap for its blurb"). Adds the gold + * pulse animation and, when `onTokenClick` is provided, makes the token a + * tap target; plain tokens (Patterns page, non-keystone formation slots) + * are never clickable regardless of `onTokenClick`. */ + pulsing?: boolean; +} + +/** A tappable Rondo Map zone (Brief step 18, PNG 32/36): an arbitrary + * model-space polygon: rondo_zones.polygon_json is a rectangle today, but + * nothing here assumes four points or axis alignment. Sits behind tokens, + * same paint-order rule as the whiteboard's own ZoneOverlay. */ +export interface PreviewZone { + key: string; + label: string; + corners: ModelPoint[]; + active?: boolean; } interface Props { @@ -48,6 +76,12 @@ interface Props { /** Fired whenever playback starts/stops, so the page's meta bar can show * the "Playing" pill (PNG 05, 09) without duplicating player state. */ onPlayingChange?: (playing: boolean) => void; + /** Fired when a `pulsing` token is tapped (Formations keystones). Tokens + * without `pulsing: true` never receive a click handler. */ + onTokenClick?: (tokenId: string) => void; + /** Rondo Map overlay (Formations page only; absent/empty elsewhere). */ + zones?: PreviewZone[]; + onZoneClick?: (zoneKey: string) => void; } export default function PatternPreviewBoard({ @@ -56,6 +90,9 @@ export default function PatternPreviewBoard({ playback, emptyMessage, onPlayingChange, + onTokenClick, + zones, + onZoneClick, }: Props) { const vb = VIEWBOX[orientation]; const svgRef = useRef(null); @@ -162,10 +199,47 @@ export default function PatternPreviewBoard({ aria-label="Pattern preview" > + {/* Rondo Map zones (Brief step 18, PNG 32/36): behind everything + else, same paint-order rule as the whiteboard's ZoneOverlay. */} + {zones && zones.length > 0 && ( + + {[...zones] + .sort((a, b) => zoneArea(b) - zoneArea(a)) + .map((zone) => { + const pts = zone.corners + .map((c) => modelToPixel(c, orientation, vb)) + .map((p) => `${p.px},${p.py}`) + .join(" "); + const anchor = modelToPixel(zone.corners[0], orientation, vb); + return ( + + onZoneClick?.(zone.key)} + /> + + {zone.label} + + + ); + })} + + )}