diff --git a/backend/app/main.py b/backend/app/main.py index a5d4c0e..d737397 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI -from app.routers import auth, formations, library, roster, teams, whiteboard +from app.routers import auth, formations, identity, library, roster, teams, whiteboard app = FastAPI(title="Patterns of Play API") app.include_router(auth.router) @@ -9,6 +9,7 @@ app.include_router(library.router) app.include_router(roster.router) app.include_router(formations.router) +app.include_router(identity.router) @app.get("/api/health") diff --git a/backend/app/routers/identity.py b/backend/app/routers/identity.py new file mode 100644 index 0000000..aa0ddfd --- /dev/null +++ b/backend/app/routers/identity.py @@ -0,0 +1,34 @@ +"""Identities: reference teams, style archetypes, cult corner (doc 03 +section 5; Brief step 20; the Identity page's three browsable segments). +Read-only to every team member (coach and player alike, README roles +table: "Pattern library, formations, identity... Full (view + play)"), so +this only requires an authenticated user, not a team scope: Identity +carries no team_id (app/models/formations.py), and CLAUDE.md rule 4 only +requires the scoped query layer for TEAM data. Mirrors +app/routers/library.py's shape exactly. +""" + +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.deps import get_current_user, get_db +from app.models import Identity, User +from app.schemas import IdentityOut + +router = APIRouter(prefix="/api", tags=["identities"]) + +IdentityKind = Literal["reference_team", "style_archetype", "cult_card"] + + +@router.get("/identities", response_model=list[IdentityOut]) +def list_identities( + kind: IdentityKind | None = Query(default=None), + _current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> list[Identity]: + query = db.query(Identity) + if kind is not None: + query = query.filter(Identity.kind == kind) + return query.order_by(Identity.kind, Identity.code).all() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 02c28a8..ea1e08c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -170,6 +170,43 @@ class LibraryItemOut(BaseModel): extras: dict | None = Field(default=None, validation_alias="extras_json") +# --------------------------------------------------------------------------- +# Identities: reference teams, style archetypes, cult corner (doc 03 +# section 5, Bible 5, 5.7, 6; Brief step 20; T-034). Library world, same +# no-team-scope reasoning as LibraryItemOut above. +# --------------------------------------------------------------------------- + + +class IdentityOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + kind: Literal["style_archetype", "reference_team", "cult_card"] + code: str + name: str + tag_line: str + formation_code: str | None + core_idea: str + signature_pattern_codes: list[str] + # keystone_roles_json shape depends on kind: reference teams carry + # {"role", "note"} objects (doc 03 5 example), style archetypes and cult + # cards carry a plain role-code list or null, so this stays a free list + # rather than a fixed model (mirrors LibraryItemOut.extras above). + keystone_roles: list | None = Field(default=None, validation_alias="keystone_roles_json") + youth_takeaway: str + block: Literal["high", "mid", "low"] | None + # style archetypes only (Bible 5.7): encouraged/tolerated/discouraged/tempo_rule. + pass_risk: dict | None = Field(default=None, validation_alias="pass_risk_json") + shape_render: Literal["animated", "static", "details_only"] + signature_animation_spec: AnimationSpec | None = Field( + default=None, validation_alias="signature_animation_spec_json" + ) + # {"positions": [{"slot","role_hint","x","y"}, ...], "note": str} + # (doc 03 5 Atletico/Man City examples); a free dict for the same reason + # as extras above. + static_shape: dict | None = Field(default=None, validation_alias="static_shape_json") + + # --------------------------------------------------------------------------- # Roster (doc 03 section 3, Bible sections 1-2; Brief step 19; T-033). # --------------------------------------------------------------------------- diff --git a/backend/tests/test_identity_routes.py b/backend/tests/test_identity_routes.py new file mode 100644 index 0000000..8535e3a --- /dev/null +++ b/backend/tests/test_identity_routes.py @@ -0,0 +1,205 @@ +"""Identity content routes (doc 03 section 5; Brief step 20): GET +/api/identities serves the three browsable segments (reference teams, +style archetypes, cult corner) to any authenticated team member, +filterable by kind. Not team-scoped (identities carries no team_id), but +still requires authentication like every other route. Mirrors +test_library_routes.py's shape. +""" + +import pytest +from fastapi.testclient import TestClient + +from app.db import SessionLocal +from app.main import app +from app.models import Identity + + +@pytest.fixture +def client() -> TestClient: + 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 + + +_ANIM_SPEC = { + "slots": [{"slot": "a", "role_hint": "W", "start": {"x": 50, "y": 50}}], + "ball": {"holder_slot": "a"}, + "steps": [{"n": 1, "caption": "Step one.", "moves": []}], + "loop": False, +} + +_STATIC_SHAPE = { + "positions": [{"slot": "gk", "role_hint": "GK", "x": 6, "y": 50}], + "note": "A static blueprint shape.", +} + + +def _seed_identities() -> None: + db = SessionLocal() + try: + db.add( + Identity( + kind="reference_team", + code="ref_1", + name="Reference One", + tag_line="A reference team tag line.", + formation_code=None, + core_idea="Formation: 4-3-3. The core idea text follows the formation sentence.", + signature_pattern_codes=["A1"], + keystone_roles_json=[{"role": "single_pivot", "note": "The pivot."}], + youth_takeaway="A youth takeaway line.", + block="high", + pass_risk_json=None, + shape_render="animated", + signature_animation_spec_json=_ANIM_SPEC, + static_shape_json=None, + source_ref="bible:6.1", + content_version="1.0.0", + ) + ) + db.add( + Identity( + kind="reference_team", + code="ref_2", + name="Reference Two", + tag_line="A static reference team.", + formation_code=None, + core_idea="Formation: 4-4-2. Static shape only, no animation.", + signature_pattern_codes=[], + keystone_roles_json=[{"role": "stopper_cb", "note": "The stopper."}], + youth_takeaway="Another youth takeaway.", + block="mid", + pass_risk_json=None, + shape_render="static", + signature_animation_spec_json=None, + static_shape_json=_STATIC_SHAPE, + source_ref="bible:6.2", + content_version="1.0.0", + ) + ) + db.add( + Identity( + kind="style_archetype", + code="style_1", + name="Style One", + tag_line="A style archetype tag line.", + formation_code=None, + core_idea="Keep the ball to control the game.", + signature_pattern_codes=["B5"], + keystone_roles_json=["single_pivot", "false_9"], + youth_takeaway="A style youth takeaway.", + block="high", + pass_risk_json={ + "encouraged": ["Short circulation"], + "tolerated": [], + "discouraged": ["Hopeful long balls"], + "tempo_rule": "Slow-slow-fast.", + }, + shape_render="details_only", + signature_animation_spec_json=None, + static_shape_json=None, + source_ref="bible:5.1", + content_version="1.0.0", + ) + ) + db.add( + Identity( + kind="cult_card", + code="cult_1", + name="Cult One", + tag_line="A one-line cult corner card.", + formation_code=None, + core_idea="A one-line cult corner idea.", + signature_pattern_codes=[], + keystone_roles_json=None, + youth_takeaway="A cult corner youth takeaway.", + block=None, + pass_risk_json=None, + shape_render="details_only", + signature_animation_spec_json=None, + static_shape_json=None, + source_ref="bible:6.19", + content_version="1.0.0", + ) + ) + db.commit() + finally: + db.close() + + +def test_list_all_identities(client: TestClient) -> None: + _seed_identities() + coach = _coach_with_team() + response = coach.get("/api/identities") + assert response.status_code == 200 + codes = {item["code"] for item in response.json()} + assert codes == {"ref_1", "ref_2", "style_1", "cult_1"} + + +def test_filter_by_kind(client: TestClient) -> None: + _seed_identities() + coach = _coach_with_team() + + ref_teams = coach.get("/api/identities", params={"kind": "reference_team"}).json() + assert {t["code"] for t in ref_teams} == {"ref_1", "ref_2"} + + styles = coach.get("/api/identities", params={"kind": "style_archetype"}).json() + assert [s["code"] for s in styles] == ["style_1"] + assert styles[0]["pass_risk"]["tempo_rule"] == "Slow-slow-fast." + + cult = coach.get("/api/identities", params={"kind": "cult_card"}).json() + assert [c["code"] for c in cult] == ["cult_1"] + + +def test_identity_shape_matches_the_content_model(client: TestClient) -> None: + _seed_identities() + coach = _coach_with_team() + animated = coach.get("/api/identities", params={"kind": "reference_team"}).json() + animated_ref = next(t for t in animated if t["code"] == "ref_1") + static_ref = next(t for t in animated if t["code"] == "ref_2") + + assert animated_ref["shape_render"] == "animated" + assert animated_ref["signature_animation_spec"]["slots"][0]["slot"] == "a" + assert animated_ref["keystone_roles"] == [{"role": "single_pivot", "note": "The pivot."}] + assert animated_ref["static_shape"] is None + + assert static_ref["shape_render"] == "static" + assert static_ref["static_shape"]["positions"][0]["slot"] == "gk" + assert static_ref["signature_animation_spec"] is None + + +def test_players_can_browse_identities_too(client: TestClient) -> None: + _seed_identities() + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + assert len(player.get("/api/identities").json()) == 4 + + +def test_identity_route_requires_authentication(client: TestClient) -> None: + _seed_identities() + assert client.get("/api/identities").status_code == 401 diff --git a/e2e/identity.spec.ts b/e2e/identity.spec.ts new file mode 100644 index 0000000..059c283 --- /dev/null +++ b/e2e/identity.spec.ts @@ -0,0 +1,334 @@ +// Identity page journey (T-034, Brief step 20, PNG 13, 33, 40-42, 44, 45). +// 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." +// "Identity: the four scripted animations play; static teams render +// their shape; every reference team card follows the five-part +// Section 6 template; 'curate, never lock' copy tone verified." +// +// Split into several focused tests (rather than one long journey) so the +// four real-time animation waits do not stack inside a single test's +// budget; each test registers its own fresh coach, same convention as +// roster.spec.ts / player.spec.ts. + +import { test, expect, assertCleanPage, registerCoach } from "./fixtures"; +import type { Page } from "@playwright/test"; + +async function openSheet(page: Page) { + const handle = page.getByTestId("identity-sheet-handle"); + if ((await handle.getAttribute("aria-expanded")) !== "true") { + await handle.click(); + } + await expect(page.getByTestId("identity-sheet-body")).toBeVisible(); +} + +async function selectByName(page: Page, name: string) { + await openSheet(page); + await page.getByTestId("identity-search").fill(name); + await expect(page.getByTestId("identity-tile")).toHaveCount(1); + await page.getByTestId("identity-tile").click(); + await expect(page.getByTestId("identity-sheet-body")).toHaveCount(0); + await expect(page.getByTestId("identity-meta-bar")).toContainText(name); +} + +/** Waits for the preview board's autoplay to finish (PatternPreviewBoard + * sets data-playing on its own .pattern-preview root, same convention as + * the whiteboard's .board-root). */ +async function waitPreviewDone(page: Page) { + const root = page.locator(".pattern-preview"); + await expect(root).toHaveAttribute("data-playing", "true", { timeout: 4000 }); + await expect(root).toHaveAttribute("data-playing", "false", { timeout: 15000 }); +} + +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("identity: default view, segments, search, curate-never-lock copy", () => { + test("browse sheet: three segments, counts, and scoped search", async ({ page, issues }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await expect(page.getByTestId("nav-identity")).toHaveAttribute("aria-current", "page"); + + // --- Default view: empty board, no meta bar (design README: + // "empty board by default") --- + await expect(page.getByTestId("pattern-empty")).toBeVisible(); + await expect(page.getByTestId("identity-meta-bar")).toHaveCount(0); + + // --- "curate, never lock" copy tone (doc 03 section 7 rule 6) --- + await expect(page.getByTestId("identity-info")).toHaveAttribute("title", /curate, never lock/); + + // --- Browse sheet: Reference teams is the default segment, three + // segments total (design README: "three segments") --- + await openSheet(page); + await expect(page.getByTestId("identity-seg-reference_team")).toHaveAttribute( + "aria-selected", + "true" + ); + await expect(page.getByTestId("identity-tile")).toHaveCount(15); + + await page.getByTestId("identity-seg-style_archetype").click(); + await expect(page.getByTestId("identity-tile")).toHaveCount(6); + await page.getByTestId("identity-seg-cult_card").click(); + await expect(page.getByTestId("identity-tile")).toHaveCount(6); + await page.getByTestId("identity-seg-reference_team").click(); + await expect(page.getByTestId("identity-tile")).toHaveCount(15); + + // --- Search scopes to the active segment --- + await page.getByTestId("identity-search").fill("Barcelona"); + await expect(page.getByTestId("identity-tile")).toHaveCount(1); + await expect(page.getByTestId("identity-tile")).toContainText("Barcelona 2008-12"); + await page.getByTestId("identity-search").fill("nonsense-team-xyz"); + await expect(page.getByTestId("identity-empty-result")).toBeVisible(); + + await assertCleanPage(page, issues); + }); +}); + +// ========================================================= +// DoD: "the four scripted animations play" (assert token movement, not +// just player visibility: verify-ui contract). One test per team so each +// stays comfortably inside the default test budget. +// ========================================================= +test.describe("identity: the four scripted animations play", () => { + for (const name of [ + "Barcelona 2008-12", + "Liverpool 2018-20", + "Real Madrid 2010-13", + "Leicester City 2015/16", + ]) { + test(`${name}: ball token moves through its signature sequence`, async ({ page, issues }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await selectByName(page, name); + + const ball = page.locator('[data-token-id="ball"]'); + const startBox = (await ball.boundingBox())!; + await waitPreviewDone(page); + const endBox = (await ball.boundingBox())!; + const moved = Math.abs(startBox.x - endBox.x) > 15 || Math.abs(startBox.y - endBox.y) > 15; + expect(moved, `${name}: ball token should move during its signature animation`).toBe(true); + + await assertCleanPage(page, issues); + }); + } +}); + +test.describe("identity: static teams render their shape", () => { + test("Atletico Madrid: all eleven positions, no Playing pill, no animation", async ({ + page, + issues, + }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await selectByName(page, "Atletico Madrid 2013/14"); + + // Static: no playback, so no Playing pill ever appears. + await expect(page.getByTestId("identity-playing-pill")).toHaveCount(0); + // All 11 positions from the static_shape render as tokens (no ball: + // a static shape has no ball holder). + await expect(page.locator("[data-token-id]")).toHaveCount(11); + await expect(page.locator('[data-token-id="ball"]')).toHaveCount(0); + await expect(page.locator('[data-token-id="cm_l"] .token-label')).toHaveText("CM"); + + await assertCleanPage(page, issues); + }); + + test("Man City 2022/23: renders its in-possession shape too", async ({ page, issues }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await selectByName(page, "Manchester City 2022/23"); + + await expect(page.getByTestId("identity-playing-pill")).toHaveCount(0); + await expect(page.locator("[data-token-id]")).toHaveCount(11); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("identity: five-part Section 6 template, pass-risk, cult corner", () => { + test("reference team card: five parts in order, no pass-risk", async ({ page, issues }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await selectByName(page, "Barcelona 2008-12"); + + await page.getByTestId("identity-details-toggle").click(); + const panel = page.getByTestId("identity-details-panel"); + await expect(panel).toBeVisible(); + await expect(panel.getByTestId("identity-detail-formation")).toContainText( + "4-3-3 with a false nine" + ); + await expect(panel.getByTestId("identity-detail-core-idea")).toContainText("Juego de posición"); + await expect(panel.getByTestId("identity-detail-signature-patterns")).toBeVisible(); + await expect(panel.getByTestId("identity-detail-keystone-roles")).toBeVisible(); + await expect(panel.getByTestId("identity-detail-youth-takeaway")).toContainText( + "Positions before players" + ); + // A reference team's own pass_risk_json is null: the block never renders. + await expect(panel.getByTestId("identity-detail-pass-risk")).toHaveCount(0); + // Order in the DOM matches the template's own order. + const rowOrder = await panel + .locator("[data-testid^='identity-detail-']") + .evaluateAll((els) => els.map((e) => e.getAttribute("data-testid"))); + expect(rowOrder).toEqual([ + "identity-detail-formation", + "identity-detail-core-idea", + "identity-detail-signature-patterns", + "identity-detail-keystone-roles", + "identity-detail-youth-takeaway", + ]); + await page.getByTestId("identity-details-close").click(); + await expect(page.getByTestId("identity-details-panel")).toHaveCount(0); + + await assertCleanPage(page, issues); + }); + + test("details-only reference team: no visualization, still the full template", async ({ + page, + issues, + }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await selectByName(page, "France 2018"); + + // CLAUDE.md rule 6: content with no designed surface stays seed data, + // rendered only in Details, board stays empty. + await expect(page.getByTestId("pattern-empty")).toBeVisible(); + await page.getByTestId("identity-details-toggle").click(); + await expect(page.getByTestId("identity-detail-formation")).toContainText("4-2-3-1"); + await expect(page.getByTestId("identity-detail-youth-takeaway")).toBeVisible(); + + await assertCleanPage(page, issues); + }); + + test("style archetype: pass-risk block (Encouraged / Off-menu / Tempo), no fabricated formation row", async ({ + page, + issues, + }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await openSheet(page); + await page.getByTestId("identity-seg-style_archetype").click(); + await selectByName(page, "Positional Possession"); + + await page.getByTestId("identity-details-toggle").click(); + const styleRisk = page.getByTestId("identity-detail-pass-risk"); + await expect(styleRisk).toBeVisible(); + await expect(styleRisk).toContainText("Encouraged:"); + await expect(styleRisk).toContainText("Off-menu:"); + await expect(styleRisk).toContainText("Tempo:"); + // Style archetypes' core_idea has no "Formation:" leading sentence, so + // the template does not fabricate a Formation & shape row for them. + await expect(page.getByTestId("identity-detail-formation")).toHaveCount(0); + + await assertCleanPage(page, issues); + }); + + test("cult corner: lightweight mini-card, no keystone roles, no pass-risk", async ({ + page, + issues, + }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await openSheet(page); + await page.getByTestId("identity-seg-cult_card").click(); + await expect(page.getByTestId("identity-tile")).toHaveCount(6); + await selectByName(page, "Greece 2004"); + + await expect(page.getByTestId("pattern-empty")).toBeVisible(); + await page.getByTestId("identity-details-toggle").click(); + await expect(page.getByTestId("identity-detail-core-idea")).toBeVisible(); + await expect(page.getByTestId("identity-detail-youth-takeaway")).toBeVisible(); + await expect(page.getByTestId("identity-detail-keystone-roles")).toHaveCount(0); + await expect(page.getByTestId("identity-detail-pass-risk")).toHaveCount(0); + await expect(page.getByTestId("identity-detail-signature-patterns")).toHaveCount(0); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("identity: matches across all three themes, gold-only interactive, red never a CTA", () => { + test("segments, Details, and the pass-risk status colors are theme-driven", async ({ + page, + issues, + }) => { + await registerCoach(page); + await page.getByTestId("nav-identity").click(); + await openSheet(page); + await page.getByTestId("identity-seg-style_archetype").click(); + + const seenSegActive = new Set(); + const seenDetailsBtn = new Set(); + const seenEncouraged = 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"); + // The pass-risk "Off-menu" label renders with --text-red (IdentityPage.css: + // the readable-on-surface red variant, same convention as + // RosterPage.css's fit-warning text), not the raw --red swatch. + const textRedRgb = await toRgb(page, "--text-red"); + + const segActiveBg = await page + .getByTestId("identity-seg-style_archetype") + .evaluate((el) => getComputedStyle(el).backgroundColor); + const segInactiveBg = await page + .getByTestId("identity-seg-reference_team") + .evaluate((el) => getComputedStyle(el).backgroundColor); + seenSegActive.add(segActiveBg); + // Gold is the only interactive color: the active segment reads + // differently from an inactive one, and it is never red. + expect(segActiveBg).not.toBe(segInactiveBg); + expect(segActiveBg).not.toBe(redRgb); + + await selectByName(page, "Positional Possession"); + await page.getByTestId("identity-details-toggle").click(); + + const detailsBg = await page + .getByTestId("identity-details-toggle") + .evaluate((el) => getComputedStyle(el).backgroundColor); + const clearBg = await page + .getByTestId("identity-clear") + .evaluate((el) => getComputedStyle(el).backgroundColor); + seenDetailsBtn.add(detailsBg); + expect(detailsBg).not.toBe(redRgb); + expect(clearBg).not.toBe(redRgb); + expect(clearBg).not.toBe(detailsBg); + + // The pass-risk "Off-menu" label uses the status red (never a call + // to action: it is plain text inside a details panel, nothing to + // click), while "Encouraged" always uses the gold accent. + const encouragedColor = await page + .locator(".identity-risk-encouraged") + .evaluate((el) => getComputedStyle(el).color); + const discouragedColor = await page + .locator(".identity-risk-discouraged") + .evaluate((el) => getComputedStyle(el).color); + seenEncouraged.add(encouragedColor); + expect(discouragedColor).toBe(textRedRgb); + expect(encouragedColor).not.toBe(redRgb); + expect(encouragedColor).not.toBe(textRedRgb); + + await page.getByTestId("identity-clear").click(); + await openSheet(page); + await page.getByTestId("identity-seg-style_archetype").click(); + } + + expect(seenSegActive.size).toBe(3); + expect(seenDetailsBtn.size).toBe(3); + expect(seenEncouraged.size).toBe(3); + + await assertCleanPage(page, issues); + }); +}); diff --git a/e2e/whiteboard.spec.ts b/e2e/whiteboard.spec.ts index e298b8e..7aae15c 100644 --- a/e2e/whiteboard.spec.ts +++ b/e2e/whiteboard.spec.ts @@ -73,17 +73,12 @@ 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), - // Roster (T-033), and Formations (T-032) are live too; Identity stays - // inert until its own ticket lands --- + // --- App shell: Whiteboard is the active nav entry. Every page has + // landed now (T-031 Patterns, T-032 Formations, T-033 Roster, T-034 + // Identity), so all five entries are live --- 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(); - 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(); + for (const key of ["patterns", "roster", "formations", "identity"]) { + await expect(page.getByTestId(`nav-${key}`)).not.toBeDisabled(); } // --- Lay a confirmed lane, toggle a zone, set both thresholds --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a90c7af..297fded 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,13 +9,20 @@ import { WhiteboardPage } from "./pages/WhiteboardPage"; import { PatternsPage } from "./pages/PatternsPage"; import { RosterPage } from "./pages/RosterPage"; import { FormationsPage } from "./pages/FormationsPage"; +import { IdentityPage } from "./pages/IdentityPage"; import ThemeSwitcher from "./theme/ThemeSwitcher"; import "./App.css"; -// 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"]; +// Every screens ticket has landed (T-030 Whiteboard, T-031 Patterns, +// T-032 Formations, T-033 Roster, T-034 Identity): all five nav entries +// are live. +const ENABLED_NAV_KEYS: readonly NavKey[] = [ + "whiteboard", + "patterns", + "roster", + "formations", + "identity", +]; // Portrait on phone-width viewports, landscape otherwise (design README: all // boards render portrait on phone). Derived purely from viewport width, no @@ -124,6 +131,8 @@ export default function App() { ) : page === "formations" ? ( + ) : page === "identity" ? ( + ) : ( )} diff --git a/frontend/src/identityApi.ts b/frontend/src/identityApi.ts new file mode 100644 index 0000000..8c40ca8 --- /dev/null +++ b/frontend/src/identityApi.ts @@ -0,0 +1,58 @@ +// Wire types and fetch calls for the identity content routes (doc 03 +// section 5; Brief step 20): reference teams, style archetypes, and cult +// corner, the Identity page's three browsable segments. Mirrors +// backend/app/schemas.py IdentityOut field for field, and reuses the +// animation spec wire shape libraryApi.ts already defines (doc 03 4.1: +// preset content and identities.signature_animation_spec_json are the +// exact same declarative-spec format). + +import { request } from "./api"; +import type { AnimationSpecWire } from "./libraryApi"; + +export type IdentityKind = "reference_team" | "style_archetype" | "cult_card"; + +/** doc 03 5: reference teams carry {role, note} objects; style archetypes + * carry a plain role-code string list; cult cards carry null. */ +export type KeystoneRoleWire = string | { role: string; note: string }; + +export interface PassRiskWire { + encouraged: string[]; + tolerated: string[]; + discouraged: string[]; + tempo_rule: string; +} + +export interface StaticShapePositionWire { + slot: string; + role_hint?: string | null; + x: number; + y: number; +} + +export interface StaticShapeWire { + positions: StaticShapePositionWire[]; + note?: string; +} + +export interface IdentityOutWire { + id: number; + kind: IdentityKind; + code: string; + name: string; + tag_line: string; + formation_code: string | null; + core_idea: string; + signature_pattern_codes: string[]; + keystone_roles: KeystoneRoleWire[] | null; + youth_takeaway: string; + block: "high" | "mid" | "low" | null; + pass_risk: PassRiskWire | null; + shape_render: "animated" | "static" | "details_only"; + signature_animation_spec: AnimationSpecWire | null; + static_shape: StaticShapeWire | null; +} + +export function listIdentities(kind?: IdentityKind): Promise { + const qs = kind ? `?kind=${kind}` : ""; + return request(`/identities${qs}`); +} diff --git a/frontend/src/pages/IdentityPage.css b/frontend/src/pages/IdentityPage.css new file mode 100644 index 0000000..7533726 --- /dev/null +++ b/frontend/src/pages/IdentityPage.css @@ -0,0 +1,89 @@ +/* Identity page (Brief step 20, PNG 13, 33, 40-42, 44, 45). Reuses + PatternsPage.css's board-first layout (stage, meta bar, sheet, tiles) + wholesale since both pages share the same board-first + swipe-up-sheet + shape (design README sections 3/5); this file only adds what's specific + to the identity content itself. Colors come only from the theme token + variables; gold is the only interactive color, red never appears here + as a call to action (the pass-risk "Off-menu" label uses --text-red as + a STATUS color, same convention as a fit warning or a blocked lane, + never as a clickable control). */ + +/* Reference team / archetype names run much longer than a pattern's short + code+name tile ("Barcelona 2008-12 (Guardiola)" vs "A5: Third-Man Run"), + long enough on a phone-width meta bar to force PatternsPage.css's + .patterns-meta-bar onto a second flex line (its title has no min-width: + 0, so the nowrap+ellipsis on .patterns-meta-title never actually + engages inside a flex row). A wrapped second line pushes Details/Clear + down far enough to sit under the details panel (top: 54px on phone), + which then intercepts their clicks. Force a single row here and let the + title itself truncate (PNG 44/45: the meta bar shows a truncated name, + never a wrapped one), which is what PatternsPage.css already intended. */ +.identity-page .patterns-meta-bar { + flex-wrap: nowrap; +} +.identity-page .patterns-meta-title { + min-width: 0; + flex: 1 1 auto; +} + +/* Neutral Clear button (design README: red is status only, never a call + to action, so Clear never uses it; same convention as + RosterPage.css's .roster-page .ctl-ghost). */ +.identity-page .ctl-ghost { + font: inherit; + font-size: 13px; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--text-secondary); + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.identity-details-body { + display: flex; + flex-direction: column; + gap: 10px; +} +.identity-detail-row p { + margin: 0; +} + +.identity-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.identity-tag { + font-family: var(--display-font); + font-size: 11px; + padding: 3px 9px; + border-radius: 999px; + background: var(--bg); + color: var(--accent); +} + +.identity-keystone-list { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Pass-risk block (Bible 5.7, style archetypes only): Encouraged in gold + (matches the confirmed-lane / interactive convention), Off-menu in the + status red used elsewhere for blocked lanes and fit warnings (never a + clickable control, so this is not "red as a CTA"), Tempo muted italic. */ +.identity-risk-encouraged { + color: var(--accent); + font-weight: 600; +} +.identity-risk-discouraged { + color: var(--text-red, var(--red)); + font-weight: 600; +} +.identity-risk-tempo { + color: var(--text-secondary); + font-style: italic; +} diff --git a/frontend/src/pages/IdentityPage.tsx b/frontend/src/pages/IdentityPage.tsx new file mode 100644 index 0000000..8bc7e0a --- /dev/null +++ b/frontend/src/pages/IdentityPage.tsx @@ -0,0 +1,367 @@ +// Identity page (Brief step 20, PNG 13, 33, 40-42, 44, 45): board-first, +// empty board by default; a page-level swipe-up sheet holds search and +// three segments (Reference teams, Style archetypes, Cult corner); +// selecting a team or style plays its signature idea on the board (the +// four scripted animations) or renders its static in-possession shape +// (Atletico, Man City); the remaining reference teams are data slots that +// render Details only (CLAUDE.md rule 6: no designed surface, no +// invented one). Details follows the Section 6 five-part template +// (Formation & shape, Core idea, Signature patterns, Keystone roles, +// Youth takeaway); style archetypes additionally show the pass-risk +// block (Bible 5.7) between Keystone roles and Youth takeaway. Copy rule +// (doc 03 section 7): identities curate, never lock. + +import { useEffect, useMemo, useState } from "react"; +import type { Orientation } from "../board/coords"; +import PatternPreviewBoard from "../board/PatternPreviewBoard"; +import { listIdentities, type IdentityKind, type IdentityOutWire, type KeystoneRoleWire } from "../identityApi"; +import { identityPreview } from "./identityPreview"; +import { TileThumb } from "./PatternsPage"; +import "./PatternsPage.css"; +import "./IdentityPage.css"; + +const SEGMENTS: { key: IdentityKind; label: string }[] = [ + { key: "reference_team", label: "Reference teams" }, + { key: "style_archetype", label: "Style archetypes" }, + { key: "cult_card", label: "Cult corner" }, +]; + +function humanizeSlug(slug: string): string { + const words = slug.replace(/_/g, " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function matchesSearch(haystack: (string | undefined | null)[], query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return haystack.some((h) => h?.toLowerCase().includes(q)); +} + +/** Every reference team's core_idea begins with a "Formation: ..." leading + * sentence (seeds/identities_reference_teams.json); this pulls it out for + * the template's own "Formation & shape" row and returns the remainder as + * the "Core idea" row. Style archetypes and cult cards never carry this + * prefix, so they fall through with formationShape null and the full text + * as core idea, unchanged. */ +function splitFormationAndCoreIdea(coreIdea: string): { formationShape: string | null; idea: string } { + if (!coreIdea.startsWith("Formation:")) return { formationShape: null, idea: coreIdea }; + const sentenceEnd = coreIdea.indexOf(". "); + const firstSentence = sentenceEnd === -1 ? coreIdea : coreIdea.slice(0, sentenceEnd + 1); + const rest = sentenceEnd === -1 ? "" : coreIdea.slice(sentenceEnd + 2); + const formationShape = firstSentence.replace(/^Formation:\s*/, "").replace(/\.\s*$/, ""); + return { formationShape, idea: rest || firstSentence }; +} + +function keystoneRoleLabel(role: KeystoneRoleWire): string { + return typeof role === "string" ? humanizeSlug(role) : `${humanizeSlug(role.role)}: ${role.note}`; +} + +interface IdentityPageProps { + orientation: Orientation; +} + +export function IdentityPage({ orientation }: IdentityPageProps) { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [itemsByKind, setItemsByKind] = useState>({ + reference_team: [], + style_archetype: [], + cult_card: [], + }); + + const [segment, setSegment] = useState("reference_team"); + const [searchQuery, setSearchQuery] = useState(""); + const [sheetOpen, setSheetOpen] = useState(false); + + const [selection, setSelection] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const [playing, setPlaying] = useState(false); + + useEffect(() => { + let cancelled = false; + Promise.all([ + listIdentities("reference_team"), + listIdentities("style_archetype"), + listIdentities("cult_card"), + ]) + .then(([referenceTeams, styleArchetypes, cultCards]) => { + if (cancelled) return; + setItemsByKind({ + reference_team: referenceTeams, + style_archetype: styleArchetypes, + cult_card: cultCards, + }); + }) + .catch(() => { + if (!cancelled) setLoadError("Could not load identities. Try reloading."); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const tiles = useMemo( + () => + itemsByKind[segment].filter((item) => + // Only the user-visible fields: item.code is an internal slug + // (e.g. "hybrid_transition_control") never shown on the tile, and + // searching it produces surprising substring collisions (that + // slug alone contains "control"). + matchesSearch([item.name, item.tag_line], searchQuery) + ), + [itemsByKind, segment, searchQuery] + ); + + function switchSegment(next: IdentityKind) { + setSegment(next); + setSearchQuery(""); + } + + function selectIdentity(item: IdentityOutWire) { + setSelection(item); + setSheetOpen(false); + setDetailsOpen(false); + } + + function clearSelection() { + setSelection(null); + setDetailsOpen(false); + } + + const preview = useMemo(() => { + if (!selection) return { tokens: [], playback: null }; + return identityPreview(selection); + }, [selection]); + + const templateRows = useMemo(() => { + if (!selection) return null; + const { formationShape, idea } = splitFormationAndCoreIdea(selection.core_idea); + return { formationShape, idea }; + }, [selection]); + + return ( +
+

+ Team identity + +

+ + {loadError && ( +

+ {loadError} +

+ )} + + {loading ? ( +

Loading identities...

+ ) : ( +
+
+ + Pick a team or a style from Browse identities below. +
+ Its signature idea plays here. + + ) : preview.tokens.length === 0 ? ( + <> + No visualization for this entry yet. +
+ Open Details below for the full breakdown. + + ) : undefined + } + /> + + {selection && ( +
+ + {selection.name} + + + +
+ )} + + {playing && ( +
+
+ )} + + {detailsOpen && selection && templateRows && ( +
+
+

{selection.name}

+ +
+
+ {templateRows.formationShape && ( +
+

Formation & shape

+

{templateRows.formationShape}

+
+ )} +
+

Core idea

+

{templateRows.idea}

+
+ {selection.signature_pattern_codes.length > 0 && ( +
+

Signature patterns

+
+ {selection.signature_pattern_codes.map((code) => ( + + {code} + + ))} +
+
+ )} + {selection.keystone_roles && selection.keystone_roles.length > 0 && ( +
+

Keystone roles

+
    + {selection.keystone_roles.map((role, i) => ( +
  • {keystoneRoleLabel(role)}
  • + ))} +
+
+ )} + {selection.pass_risk && ( +
+

Passing menu

+

+ Encouraged:{" "} + {selection.pass_risk.encouraged.join(", ")} +
+ Off-menu:{" "} + {selection.pass_risk.discouraged.join(", ")} +
+ Tempo: {selection.pass_risk.tempo_rule} +

+
+ )} +
+

Youth takeaway

+

{selection.youth_takeaway}

+
+
+
+ )} +
+ +
+ + + {sheetOpen && ( +
+
+ {SEGMENTS.map((seg) => ( + + ))} +
+ + setSearchQuery(e.target.value)} + /> + +
+ {tiles.map((item) => { + const { tokens } = identityPreview(item); + return ( + + ); + })} + {tiles.length === 0 && ( +

+ No matches. Try a different search. +

+ )} +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/PatternsPage.tsx b/frontend/src/pages/PatternsPage.tsx index 3ad2921..297df6f 100644 --- a/frontend/src/pages/PatternsPage.tsx +++ b/frontend/src/pages/PatternsPage.tsx @@ -53,7 +53,9 @@ function matchesSearch(haystack: (string | undefined)[], query: string): boolean return haystack.some((h) => h?.toLowerCase().includes(q)); } -function TileThumb({ tokens }: { tokens: { id: string; side: TokenSide; pos: { x: number; y: number } }[] }) { +// Exported so the Identity page (T-034) can reuse this exact mini-pitch +// thumbnail for its own browse tiles instead of redefining it. +export function TileThumb({ tokens }: { tokens: { id: string; side: TokenSide; pos: { x: number; y: number } }[] }) { const W = 105; const H = 68; return ( diff --git a/frontend/src/pages/identityPreview.ts b/frontend/src/pages/identityPreview.ts new file mode 100644 index 0000000..8d36d26 --- /dev/null +++ b/frontend/src/pages/identityPreview.ts @@ -0,0 +1,56 @@ +// Converts an identity (wire shape: identityApi.ts) into the board +// engine's internal shapes for the Identity page (Brief step 20): a +// starting token scene plus a Playback for PatternPreviewBoard when the +// identity is `animated` (the four scripted signature animations), a +// static token scene with no Playback when it is `static` (Atletico, +// Man City: the two hardcoded shapes), or an empty scene when it is +// `details_only` (every remaining reference team: a data slot with no +// designed visualization, per CLAUDE.md rule 6, "content with no designed +// surface stays seed data"). +// +// Deliberately reuses patternPreview.ts's toDeclarativeSpec rather than +// redefining it: identities.signature_animation_spec_json is the exact +// same doc 03 4.1 declarative-spec wire shape a library preset's +// animation_spec already is. + +import type { IdentityOutWire } from "../identityApi"; +import { toDeclarativeSpec } from "./patternPreview"; +import { buildDeclarativePlayback, type Playback } from "../board/playback"; +import type { ModelPoint } from "../board/coords"; +import type { PreviewToken } from "../board/PatternPreviewBoard"; + +export function identityPreview(identity: IdentityOutWire): { tokens: PreviewToken[]; playback: Playback | null } { + if (identity.shape_render === "animated" && identity.signature_animation_spec) { + const spec = identity.signature_animation_spec; + const tokens: PreviewToken[] = spec.slots.map((s) => ({ + id: s.slot, + side: s.side === "opponent" ? "away" : "home", + label: s.role_hint ?? "", + pos: s.start, + })); + const holder = spec.slots.find((s) => s.slot === spec.ball.holder_slot); + const ballPos: ModelPoint = holder?.start ?? { x: 50, y: 50 }; + tokens.push({ id: "ball", side: "ball", label: "", pos: ballPos }); + + // Identity binding: the preview's own token ids ARE the spec's slot + // names, same as patternPreview.ts's library-item preview. + const binding: Record = {}; + for (const s of spec.slots) binding[s.slot] = s.slot; + + return { tokens, playback: buildDeclarativePlayback(toDeclarativeSpec(spec), binding, "ball") }; + } + + if (identity.shape_render === "static" && identity.static_shape) { + const tokens: PreviewToken[] = identity.static_shape.positions.map((p) => ({ + id: p.slot, + side: "home", + label: p.role_hint ?? "", + pos: { x: p.x, y: p.y }, + })); + return { tokens, playback: null }; + } + + // details_only (Bible 6.1, 6.3, ...): no visualization, the board stays + // empty and Details carries the full five-part template instead. + return { tokens: [], playback: null }; +} diff --git a/frontend/src/pages/patternPreview.ts b/frontend/src/pages/patternPreview.ts index 71221e7..f2520f0 100644 --- a/frontend/src/pages/patternPreview.ts +++ b/frontend/src/pages/patternPreview.ts @@ -21,7 +21,11 @@ import { DEFAULT_BLOCKING_THRESHOLD, DEFAULT_MARKING_THRESHOLD } from "../board/ import type { PreviewToken } from "../board/PatternPreviewBoard"; import type { ModelPoint } from "../board/coords"; -function toDeclarativeSpec(spec: AnimationSpecWire): DeclarativeSpec { +// Exported so identityPreview.ts (T-034) can reuse this conversion for +// identities.signature_animation_spec_json instead of re-deriving it: doc +// 03 4.1 is the exact same declarative-spec wire shape for both a library +// preset and a reference team's signature animation. +export function toDeclarativeSpec(spec: AnimationSpecWire): DeclarativeSpec { return { slots: spec.slots.map((s) => ({ slot: s.slot,