diff --git a/backend/app/main.py b/backend/app/main.py index 31ff8fa..9dfcb55 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,13 @@ from fastapi import FastAPI -from app.routers import auth, library, teams, whiteboard +from app.routers import auth, library, roster, teams, whiteboard app = FastAPI(title="Patterns of Play API") app.include_router(auth.router) app.include_router(teams.router) app.include_router(whiteboard.router) app.include_router(library.router) +app.include_router(roster.router) @app.get("/api/health") diff --git a/backend/app/models/roster.py b/backend/app/models/roster.py index aadbc84..d3ecefe 100644 --- a/backend/app/models/roster.py +++ b/backend/app/models/roster.py @@ -117,6 +117,13 @@ class Player(Base): role_code: Mapped[str | None] = mapped_column(ForeignKey("roles.code"), nullable=True) awr: Mapped[str] = mapped_column(String(10), nullable=False) # low | med | high dwr: Mapped[str] = mapped_column(String(10), nullable=False) # low | med | high + # Not in doc 03 section 3's players column list either (same + # reconciliation as playstyle_note below): the double-exposure rule in + # that same section reads "the fullback or wingback behind them on the + # same side", which needs a queryable notion of side that nothing else + # in the schema carries. left | right | center, nullable (unassigned + # until a coach sets it). T-033 migration 0003. + flank: Mapped[str | None] = mapped_column(String(10), nullable=True) # Not in doc 03 section 3's players column list, but doc 03 itself # names this exact field when describing playstyle_suggestions just # below it ("approved text merges into players.playstyle_note"), so it diff --git a/backend/app/routers/roster.py b/backend/app/routers/roster.py new file mode 100644 index 0000000..2f92be2 --- /dev/null +++ b/backend/app/routers/roster.py @@ -0,0 +1,291 @@ +"""Roster page routes (doc 03 section 3, Bible sections 1-2; Brief step 19; +T-033). Every route depends on get_team_scope (or require_role_on_team, +itself resolved through get_current_membership): team_id always comes from +the caller's own membership, never a client-supplied field (CLAUDE.md rule +4). Create/update/delete are coach-only, API-enforced (CLAUDE.md rule 5 / +README roles table: "View-only sliders/work rates" for players, no CRUD +control renders, and the API 403s a player token that calls one anyway, +not just hides the button). + +The double-exposure fit warning (doc 03 section 3, role_clashes table, +code 'double_exposure_flank') is computed here, server-side, from the +team's own roster + the seeded warning copy, and is included ONLY in the +coach response model (CoachRosterOut). A player-role GET /api/roster +returns RosterOut, a model with no fit_warnings field at all: response_model +is set to None on that route and the handler returns an already-serialized +model_dump(), so FastAPI never has a chance to backfill or coerce a field +onto the wire that the handler did not put there itself (CLAUDE.md rule 5: +coach-only data "never appears in player-role payloads"). +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.deps import CurrentMembership, get_current_membership, get_db, require_role_on_team +from app.models import Player, PlayerAttribute, Role, RoleClash +from app.schemas import ( + ATTRIBUTE_KEYS, + CoachRosterOut, + FitWarningOut, + PlayerAttributesIn, + PlayerOut, + PlayerWriteRequest, + RoleCatalogOut, + RosterOut, +) +from app.scoped import TeamScope, get_team_scope + +router = APIRouter(prefix="/api/roster", tags=["roster"]) + +# Position codes doc 03 section 3's double-exposure rule names explicitly: +# "the wide player" (W) and "the fullback or wingback behind them" (FB/WB). +# Role-agnostic on purpose: the rule is stated in terms of position_code + +# awr/dwr, not any single named role, so it fires for any winger-shaped W +# role (touchline_winger, inside_forward, wide_forward, raumdeuter) paired +# with any FB/WB behind them on the same flank. +_WIDE_POSITION = "W" +_BACK_POSITIONS = ("FB", "WB") +_DOUBLE_EXPOSURE_CODE = "double_exposure_flank" + + +def _attrs_to_schema(values: dict[str, int]) -> PlayerAttributesIn: + # Every player always carries all six keys (created/updated together + # below), so a missing key here would mean a data-integrity bug, not a + # legitimately partial player; fail loudly rather than guessing a + # default that would mask it. + return PlayerAttributesIn(**{key: values[key] for key in ATTRIBUTE_KEYS}) + + +def _player_to_out( + player: Player, + attrs: dict[str, int], + role_map: dict[str, Role], + caller_user_id: int, +) -> PlayerOut: + role = role_map.get(player.role_code) if player.role_code else None + return PlayerOut( + id=player.id, + name=player.name, + jersey_number=player.jersey_number, + preferred_foot=player.preferred_foot, # type: ignore[arg-type] + position_code=player.position_code, + role_code=player.role_code, + role_name=role.name if role else None, + role_description=role.description if role else None, + flank=player.flank, # type: ignore[arg-type] + awr=player.awr, # type: ignore[arg-type] + dwr=player.dwr, # type: ignore[arg-type] + attributes=_attrs_to_schema(attrs), + is_you=player.user_id is not None and player.user_id == caller_user_id, + ) + + +def _compute_fit_warnings(players: list[Player], clash: RoleClash | None) -> list[FitWarningOut]: + """Doc 03 section 3: "a roster flank where the wide player has AWR high + and DWR low, and the fullback or wingback behind them on the same side + has AWR high, raises double_exposure_flank with the Bible's warning + copy." Reads the warning copy from the seeded role_clashes row rather + than hardcoding it (Brief section 7: "content is data, not code").""" + if clash is None: + return [] + + warnings: list[FitWarningOut] = [] + for flank in ("left", "right"): # "on the same side": center has no side to double-expose + wide_players = [ + p + for p in players + if p.flank == flank and p.position_code == _WIDE_POSITION and p.awr == "high" and p.dwr == "low" + ] + back_players = [ + p + for p in players + if p.flank == flank and p.position_code in _BACK_POSITIONS and p.awr == "high" + ] + for wide in wide_players: + for back in back_players: + warnings.append( + FitWarningOut( + code=clash.code, + name=clash.name, + flank=flank, # type: ignore[arg-type] + message=clash.warning_copy, + wide_player_id=wide.id, + wide_player_name=wide.name, + back_player_id=back.id, + back_player_name=back.name, + ) + ) + return warnings + + +def _attrs_by_player(scope: TeamScope) -> dict[int, dict[str, int]]: + rows = scope.query_via( + PlayerAttribute, Player, PlayerAttribute.player_id == Player.id + ).all() + by_player: dict[int, dict[str, int]] = {} + for row in rows: + by_player.setdefault(row.player_id, {})[row.attribute_key] = row.value + return by_player + + +def _role_map(db: Session) -> dict[str, Role]: + return {r.code: r for r in db.query(Role).all()} + + +def _upsert_attributes(db: Session, player_id: int, attrs: PlayerAttributesIn) -> None: + # PlayerAttribute has no team_id column of its own (doc 03 section 3; + # app/scoped.py TeamScope.add() rejects it for exactly this reason), so + # this writes through the plain db session already scoped to the right + # player by construction: player_id here always comes from a Player row + # the caller obtained via scope.get()/scope.query() first. + existing = { + row.attribute_key: row + for row in db.query(PlayerAttribute).filter(PlayerAttribute.player_id == player_id).all() + } + for key in ATTRIBUTE_KEYS: + value = getattr(attrs, key) + if key in existing: + existing[key].value = value + else: + db.add(PlayerAttribute(player_id=player_id, attribute_key=key, value=value)) + + +# --------------------------------------------------------------------------- +# Role catalog: library content, read-only, both roles (doc 03 section 3 +# taxonomy tables). No team scoping: this is global seeded data, same as +# patterns/formations/identities. +# --------------------------------------------------------------------------- + + +@router.get("/roles", response_model=list[RoleCatalogOut]) +def list_roles( + ctx: CurrentMembership = Depends(get_current_membership), + db: Session = Depends(get_db), +) -> list[Role]: + return db.query(Role).order_by(Role.position_code, Role.name).all() + + +# --------------------------------------------------------------------------- +# Roster: team-scoped player list. response_model=None on the GET route +# (see module docstring): the handler always returns a manually-built, +# already-serialized dict so a player payload has no fit_warnings key at +# all, not a null/empty one. +# --------------------------------------------------------------------------- + + +@router.get("", response_model=None) +def get_roster( + ctx: CurrentMembership = Depends(get_current_membership), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> dict: + players = scope.query(Player).order_by(Player.jersey_number.asc().nulls_last()).all() + attrs_by_player = _attrs_by_player(scope) + roles = _role_map(db) + player_outs = [ + _player_to_out(p, attrs_by_player.get(p.id, {}), roles, ctx.user.id) for p in players + ] + + if ctx.role_on_team != "coach": + # Player payload: RosterOut has no fit_warnings field, period. + return RosterOut(players=player_outs).model_dump(mode="json") + + clash = ( + db.query(RoleClash) + .filter(RoleClash.code == _DOUBLE_EXPOSURE_CODE, RoleClash.is_active_mvp.is_(True)) + .first() + ) + warnings = _compute_fit_warnings(players, clash) + return CoachRosterOut(players=player_outs, fit_warnings=warnings).model_dump(mode="json") + + +# --------------------------------------------------------------------------- +# Player CRUD: coach-only (README roles table: Roster is "Full, plus fit +# warnings and suggestion review" for coaches, "View-only" for players; no +# create/edit/delete control renders for a player, and require_role_on_team +# enforces the same thing server-side independent of the UI). +# --------------------------------------------------------------------------- + + +@router.post("/players", response_model=PlayerOut, status_code=status.HTTP_201_CREATED) +def create_player( + payload: PlayerWriteRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> PlayerOut: + roles = _role_map(db) + role = roles.get(payload.role_code) if payload.role_code else None + if payload.role_code and role is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role_code") + + player = Player( + user_id=None, + name=payload.name, + jersey_number=payload.jersey_number, + preferred_foot=payload.preferred_foot, + position_line=role.name if role else None, + position_code=role.position_code if role else None, + role_code=payload.role_code, + awr=payload.awr, + dwr=payload.dwr, + flank=payload.flank, + ) + scope.add(player) + scope.flush() # assigns player.id for the attribute rows below + _upsert_attributes(db, player.id, payload.attributes) + scope.commit() + scope.refresh(player) + + return _player_to_out(player, payload.attributes.model_dump(), roles, ctx.user.id) + + +@router.put("/players/{player_id}", response_model=PlayerOut) +def update_player( + player_id: int, + payload: PlayerWriteRequest, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> PlayerOut: + player = scope.get(Player, player_id) + if player is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Player not found") + + roles = _role_map(db) + role = roles.get(payload.role_code) if payload.role_code else None + if payload.role_code and role is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role_code") + + player.name = payload.name + player.jersey_number = payload.jersey_number + player.preferred_foot = payload.preferred_foot + player.position_line = role.name if role else None + player.position_code = role.position_code if role else None + player.role_code = payload.role_code + player.awr = payload.awr + player.dwr = payload.dwr + player.flank = payload.flank + + _upsert_attributes(db, player.id, payload.attributes) + scope.commit() + scope.refresh(player) + + return _player_to_out(player, payload.attributes.model_dump(), roles, ctx.user.id) + + +@router.delete("/players/{player_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_player( + player_id: int, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), + db: Session = Depends(get_db), +) -> None: + player = scope.get(Player, player_id) + if player is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Player not found") + # Attribute rows scope transitively through this player and have no + # delete cascade at the DB level in this build, so clear them first. + db.query(PlayerAttribute).filter(PlayerAttribute.player_id == player_id).delete() + scope.delete(player) + scope.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d0121b6..256fea8 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -160,3 +160,132 @@ class LibraryItemOut(BaseModel): # here (app/models/library.py comment). animation_spec: AnimationSpec | None = Field(default=None, validation_alias="animation_spec_json") extras: dict | None = Field(default=None, validation_alias="extras_json") + + +# --------------------------------------------------------------------------- +# Roster (doc 03 section 3, Bible sections 1-2; Brief step 19; T-033). +# --------------------------------------------------------------------------- + +WorkRate = Literal["low", "med", "high"] +PreferredFoot = Literal["L", "R", "B"] +Flank = Literal["left", "right", "center"] +# Bible 1.3's six-attribute vocabulary (app/models/roster.py PlayerAttribute). +AttributeKey = Literal[ + "pace", + "passing_range", + "carrying_1v1", + "positional_discipline", + "aerial_physical", + "pressing_engine", +] + +ATTRIBUTE_KEYS: tuple[AttributeKey, ...] = ( + "pace", + "passing_range", + "carrying_1v1", + "positional_discipline", + "aerial_physical", + "pressing_engine", +) + + +class PlayerAttributesIn(BaseModel): + """All six sliders, coach-rated 1-5 (Bible 1.3). Every player always + carries all six, so create/update both require the full set rather + than a partial patch.""" + + model_config = ConfigDict(extra="forbid") + + pace: int = Field(ge=1, le=5) + passing_range: int = Field(ge=1, le=5) + carrying_1v1: int = Field(ge=1, le=5) + positional_discipline: int = Field(ge=1, le=5) + aerial_physical: int = Field(ge=1, le=5) + pressing_engine: int = Field(ge=1, le=5) + + +class PlayerWriteRequest(BaseModel): + """Shared body shape for POST (create) and PUT (full update) of a + roster entry. No team_id or user_id field on purpose (CLAUDE.md rule 4 + / doc 03 4.2 author-stamping precedent): team_id is stamped by + TeamScope.add from the caller's own membership, and user_id (row + claiming) is not part of this ticket's scope.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=120) + jersey_number: int | None = Field(default=None, ge=1, le=99) + preferred_foot: PreferredFoot = "R" + role_code: str | None = None + flank: Flank | None = None + awr: WorkRate + dwr: WorkRate + attributes: PlayerAttributesIn + + +class RoleCatalogOut(BaseModel): + """GET /api/roster/roles: the library role catalog (doc 03 section 3 + Role table), read-only, for populating the role picker. Not + team-scoped: library content, same as patterns/formations/identities.""" + + model_config = ConfigDict(from_attributes=True) + + code: str + position_code: str + name: str + description: str + + +class PlayerOut(BaseModel): + id: int + name: str + jersey_number: int | None + preferred_foot: PreferredFoot + position_code: str | None + role_code: str | None + # Resolved server-side from the role catalog (Role.name), same pattern + # as SavedPatternOut.author_label, so the frontend never re-derives it. + role_name: str | None + role_description: str | None + flank: Flank | None + awr: WorkRate + dwr: WorkRate + attributes: PlayerAttributesIn + # True when this row belongs to the calling user (README roles table: + # player's "own row marked (you)"). Always false until a roster row is + # claimed by a player account, which is out of this ticket's scope + # (see T-033 final report). + is_you: bool + + +class FitWarningOut(BaseModel): + """One fired role_clashes row (doc 03 section 3: "the designed + double-exposure warning reads from here"). Coach-only: never appears + on a player-role payload (CLAUDE.md rule 5), enforced by RosterOut + below having no field for it at all, not just an empty list.""" + + code: str + name: str + flank: Flank + message: str + wide_player_id: int + wide_player_name: str + back_player_id: int + back_player_name: str + + +class RosterOut(BaseModel): + """GET /api/roster response for a player caller. Deliberately has no + fit_warnings field (see CoachRosterOut): the route returns this model + (response_model=None, manual model_dump) so the JSON body a player + receives has no such key at all, not a null or empty one.""" + + players: list[PlayerOut] + + +class CoachRosterOut(RosterOut): + """GET /api/roster response for a coach caller. Adds fit_warnings on + top of RosterOut; the route picks this model or the plain RosterOut + based on the caller's role_on_team, never both from one shared model.""" + + fit_warnings: list[FitWarningOut] diff --git a/backend/migrations/versions/0003_player_flank.py b/backend/migrations/versions/0003_player_flank.py new file mode 100644 index 0000000..f2e3b6d --- /dev/null +++ b/backend/migrations/versions/0003_player_flank.py @@ -0,0 +1,34 @@ +"""players.flank: which side of the pitch a roster entry is designed to +play (doc 03 section 3 does not enumerate this column explicitly, but its +own double-exposure rule requires knowing whether a wide player and a +fullback/wingback sit "on the same side" - the same reconciliation doc 03 +already applies to players.playstyle_note, added there to satisfy a +cross-reference the column list omitted). T-033 (Roster page, Brief step +19) is the first ticket that needs "same designed flank" to be a queryable +fact rather than prose, so it lands here. + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-07-16 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0003" +down_revision: Union[str, None] = "0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("players") as batch_op: + batch_op.add_column(sa.Column("flank", sa.String(length=10), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("players") as batch_op: + batch_op.drop_column("flank") diff --git a/backend/tests/test_roster_routes.py b/backend/tests/test_roster_routes.py new file mode 100644 index 0000000..f351c64 --- /dev/null +++ b/backend/tests/test_roster_routes.py @@ -0,0 +1,326 @@ +"""Roster routes (doc 03 section 3; Brief step 19; T-033). Covers: player +CRUD is team-scoped and coach-only; the role catalog is readable by both +roles; the double-exposure fit warning fires per doc 03's rule (high AWR +fullback/wingback behind a high AWR / low DWR wide player on the same +flank) and is present ONLY on the coach's GET /api/roster response +(CLAUDE.md rule 5: coach-only data never appears in a player-role +payload) -- proven here by asserting the key is absent from the JSON body +entirely, not null or empty. +""" + +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 _run_seed_loader() -> None: + # Role/PositionCode/RoleClash rows (test_seed_content.py's own module, + # reused here rather than duplicated) are library content the roster + # role picker and the double-exposure check both read; conftest.py's + # per-test drop_all/create_all wipes them, so each test reseeds. + spec = importlib.util.spec_from_file_location( + "pop_seed_script_roster_tests", 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) + assert module.main() == 0 + + +@pytest.fixture(autouse=True) +def _seed_library_content() -> None: + # Runs after conftest.py's _reset_schema (both function-scoped autouse; + # the outer conftest fixture instantiates first), so the schema always + # exists before the seed loader's create_all/session writes run. + _run_seed_loader() + + +@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", name: str = "Coach Test") -> TestClient: + c = TestClient(app) + _register(c, email=email, role="coach", display_name=name) + c.post("/api/teams", json={"name": f"Team for {email}"}) + return c + + +def _player_on_team(coach: TestClient, email: str, name: str = "Player Test") -> TestClient: + join_code = coach.get("/api/teams/current").json()["join_code"] + p = TestClient(app) + _register(p, email=email, role="player", display_name=name) + p.post("/api/teams/join", json={"join_code": join_code}) + return p + + +_ATTRS = { + "pace": 3, + "passing_range": 3, + "carrying_1v1": 3, + "positional_discipline": 3, + "aerial_physical": 3, + "pressing_engine": 3, +} + + +def _player_body(**overrides: object) -> dict: + base: dict = { + "name": "New Player", + "jersey_number": 10, + "preferred_foot": "R", + "role_code": None, + "flank": None, + "awr": "med", + "dwr": "med", + "attributes": _ATTRS, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# Role catalog: read-only, both roles, library content (not team-scoped) +# --------------------------------------------------------------------------- + + +def test_role_catalog_is_readable_by_coach_and_player(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + + coach_roles = coach.get("/api/roster/roles") + player_roles = player.get("/api/roster/roles") + assert coach_roles.status_code == 200 + assert player_roles.status_code == 200 + codes = {r["code"] for r in coach_roles.json()} + assert "overlapping_fb" in codes + assert "touchline_winger" in codes + + +def test_role_catalog_requires_authentication(client: TestClient) -> None: + assert client.get("/api/roster/roles").status_code == 401 + + +# --------------------------------------------------------------------------- +# Player CRUD: coach-only, team-scoped +# --------------------------------------------------------------------------- + + +def test_coach_can_create_read_update_delete_a_player(client: TestClient) -> None: + coach = _coach_with_team() + + created = coach.post("/api/roster/players", json=_player_body(name="Jordan T.")) + assert created.status_code == 201 + body = created.json() + assert body["name"] == "Jordan T." + assert body["attributes"] == _ATTRS + assert body["is_you"] is False + player_id = body["id"] + + listed = coach.get("/api/roster").json() + assert [p["name"] for p in listed["players"]] == ["Jordan T."] + + updated = coach.put( + f"/api/roster/players/{player_id}", + json=_player_body(name="Jordan Taylor", jersey_number=2, awr="high", dwr="high"), + ) + assert updated.status_code == 200 + assert updated.json()["name"] == "Jordan Taylor" + assert updated.json()["jersey_number"] == 2 + + deleted = coach.delete(f"/api/roster/players/{player_id}") + assert deleted.status_code == 204 + assert coach.get("/api/roster").json()["players"] == [] + + +def test_role_code_resolves_position_code_and_role_name(client: TestClient) -> None: + coach = _coach_with_team() + created = coach.post( + "/api/roster/players", + json=_player_body(name="Maya K.", role_code="inside_forward", flank="right", awr="high", dwr="low"), + ).json() + assert created["position_code"] == "W" + assert created["role_name"] == "Inside Forward / Inverted Winger" + assert created["role_description"] + + +def test_unknown_role_code_is_rejected(client: TestClient) -> None: + coach = _coach_with_team() + response = coach.post("/api/roster/players", json=_player_body(role_code="not_a_real_role")) + assert response.status_code == 422 + + +def test_player_cannot_create_update_or_delete_a_player(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + player_id = coach.post("/api/roster/players", json=_player_body()).json()["id"] + + assert player.post("/api/roster/players", json=_player_body(name="Forged")).status_code == 403 + assert ( + player.put(f"/api/roster/players/{player_id}", json=_player_body(name="Edited")).status_code + == 403 + ) + assert player.delete(f"/api/roster/players/{player_id}").status_code == 403 + + # Nothing changed. + assert coach.get("/api/roster").json()["players"][0]["name"] == "New Player" + + +def test_roster_is_team_scoped(client: TestClient) -> None: + coach_a = _coach_with_team(email="coach-a@example.com") + coach_b = _coach_with_team(email="coach-b@example.com") + coach_a.post("/api/roster/players", json=_player_body(name="Only on team A")) + + assert coach_b.get("/api/roster").json()["players"] == [] + + +def test_cross_team_update_and_delete_404_not_the_other_teams_row(client: TestClient) -> None: + coach_a = _coach_with_team(email="coach-a@example.com") + coach_b = _coach_with_team(email="coach-b@example.com") + player_id = coach_a.post("/api/roster/players", json=_player_body(name="Team A only")).json()["id"] + + assert coach_b.put(f"/api/roster/players/{player_id}", json=_player_body()).status_code == 404 + assert coach_b.delete(f"/api/roster/players/{player_id}").status_code == 404 + assert len(coach_a.get("/api/roster").json()["players"]) == 1 + + +def test_roster_routes_require_authentication(client: TestClient) -> None: + assert client.get("/api/roster").status_code == 401 + assert client.post("/api/roster/players", json=_player_body()).status_code == 401 + assert client.put("/api/roster/players/1", json=_player_body()).status_code == 401 + assert client.delete("/api/roster/players/1").status_code == 401 + + +def test_client_cannot_forge_team_id_or_user_id(client: TestClient) -> None: + """CLAUDE.md rule 4: client input never supplies team_id. The write + schema (extra='forbid') has no team_id or user_id field at all, so a + client cannot even attempt to forge either.""" + coach = _coach_with_team() + response = coach.post( + "/api/roster/players", + json={**_player_body(), "team_id": 9999, "user_id": 9999}, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# Double-exposure fit warning (doc 03 section 3): coach-only, never on a +# player payload. +# --------------------------------------------------------------------------- + + +def _seed_double_exposure_pair(coach: TestClient) -> None: + # High AWR / low DWR wide player (inside_forward, position_code W). + coach.post( + "/api/roster/players", + json=_player_body( + name="Maya K.", role_code="inside_forward", flank="right", awr="high", dwr="low" + ), + ) + # High AWR fullback behind her on the same flank (overlapping_fb, FB). + coach.post( + "/api/roster/players", + json=_player_body( + name="Jordan T.", role_code="overlapping_fb", flank="right", awr="high", dwr="med" + ), + ) + + +def test_double_exposure_fires_for_a_high_awr_fullback_behind_a_high_low_winger( + client: TestClient, +) -> None: + coach = _coach_with_team() + _seed_double_exposure_pair(coach) + + body = coach.get("/api/roster").json() + assert len(body["fit_warnings"]) == 1 + warning = body["fit_warnings"][0] + assert warning["code"] == "double_exposure_flank" + assert warning["flank"] == "right" + assert warning["wide_player_name"] == "Maya K." + assert warning["back_player_name"] == "Jordan T." + assert warning["message"] # seeded Bible copy, non-empty + assert "—" not in warning["message"] # no em dashes anywhere user-facing + + +def test_double_exposure_does_not_fire_across_different_flanks(client: TestClient) -> None: + coach = _coach_with_team() + coach.post( + "/api/roster/players", + json=_player_body(name="Left W", role_code="inside_forward", flank="left", awr="high", dwr="low"), + ) + coach.post( + "/api/roster/players", + json=_player_body(name="Right FB", role_code="overlapping_fb", flank="right", awr="high", dwr="med"), + ) + + assert coach.get("/api/roster").json()["fit_warnings"] == [] + + +def test_double_exposure_does_not_fire_when_fullback_awr_is_not_high(client: TestClient) -> None: + coach = _coach_with_team() + coach.post( + "/api/roster/players", + json=_player_body(name="Wide W", role_code="inside_forward", flank="right", awr="high", dwr="low"), + ) + coach.post( + "/api/roster/players", + json=_player_body(name="Cautious FB", role_code="overlapping_fb", flank="right", awr="med", dwr="high"), + ) + + assert coach.get("/api/roster").json()["fit_warnings"] == [] + + +def test_fit_warnings_field_is_entirely_absent_from_a_player_role_payload( + client: TestClient, +) -> None: + """CLAUDE.md rule 5: coach-only data never appears in player-role + payloads. Asserts the key itself is missing (not None, not []), which + is what response_model=None + a fit_warnings-less RosterOut guarantees.""" + coach = _coach_with_team() + _seed_double_exposure_pair(coach) + player = _player_on_team(coach, email="player@example.com") + + coach_body = coach.get("/api/roster").json() + assert "fit_warnings" in coach_body + assert len(coach_body["fit_warnings"]) == 1 + + player_body = player.get("/api/roster").json() + assert "fit_warnings" not in player_body + assert len(player_body["players"]) == 2 + for player_row in player_body["players"]: + assert "fit_warnings" not in player_row + + +def test_players_own_row_is_marked_is_you(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + coach.post("/api/roster/players", json=_player_body(name="Roster Row")) + + # No claiming flow exists yet (T-033 report flags this as a gap): a + # roster row is never linked to a player's own user_id in this build, + # so both roles currently see is_you False for every row. This test + # pins that documented behavior rather than a hidden assumption. + assert all(p["is_you"] is False for p in coach.get("/api/roster").json()["players"]) + assert all(p["is_you"] is False for p in player.get("/api/roster").json()["players"]) diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index 5f842d5..124ec5f 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -15,10 +15,10 @@ Model: sonnet default; opus = hard ticket, never downgrade. | T-021 | Lane graph: suggested/confirmed/blocked states, two independent thresholds, live recompute during drag, interception dot | 11, 12 | board-engineer | opus | T-020 | T-011 | done | | T-022 | Zones + animation player (declarative specs AND raw keyframes, ball waypoints chase bound player) + recorder (all tokens incl. opponents + ball) | 13, 14, 15 | board-engineer | opus | T-021 | none | done | | 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 | doing | -| 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 | todo | -| 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 | doing | -| 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 | todo | +| 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-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 | | T-042 | Sessions: draft builder + picker w/ thumbnails, send, receipts, player view w/ Watch deep-link + Mark as watched (PNG 21-23, 26, 28) | 23 | collab | sonnet | T-031, T-040 | none | todo | diff --git a/e2e/roster.spec.ts b/e2e/roster.spec.ts new file mode 100644 index 0000000..da461c5 --- /dev/null +++ b/e2e/roster.spec.ts @@ -0,0 +1,313 @@ +// Roster page journey (T-033, Brief step 19, PNG 12 desktop / 20 phone). +// Runs under both Playwright projects (mobile portrait, desktop landscape) +// per playwright.config.ts. Covers the ticket's Screens DoD lines (Brief +// section 5) verbatim: +// "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." +// "Roster: the double-exposure warning fires when a High AWR fullback +// sits behind a High/Low winger on the same designed flank, and only +// renders for coaches." +// plus player CRUD, role/work-rate chips, and the six attribute sliders +// (Brief step 19's own line: "roster CRUD, role and work-rate chips per +// player, six coach-rated 1-5 attribute sliders"). + +import { test, expect, assertCleanPage, registerCoach, registerPlayer } from "./fixtures"; +import type { Locator, Page } from "@playwright/test"; + +function trackIssues(page: Page) { + const issues = { consoleErrors: [] as string[], failedRequests: [] as string[], serverErrors: [] as string[] }; + page.on("console", (m) => m.type() === "error" && issues.consoleErrors.push(m.text())); + page.on("requestfailed", (r) => issues.failedRequests.push(`${r.method()} ${r.url()}`)); + page.on("response", (r) => r.status() >= 500 && issues.serverErrors.push(`${r.status()} ${r.url()}`)); + return issues; +} + +// Chromium's mobile+touch emulation (this repo's "mobile" Playwright +// project, iPhone 13) shrinks the visual viewport once any text input is +// focused (simulating an on-screen keyboard) and never restores it for +// the rest of the page's life. A plain point-based .click() maps its +// click coordinate through the now-stale LAYOUT viewport geometry, which +// can land on a different element than the one getBoundingClientRect() +// reports once the target sits below the original fold, exactly the +// case for this form-heavy page on a narrow phone. Dispatching the event +// directly targets the element with no coordinate math at all, so it is +// used for every button click in this journey rather than only the ones +// observed to need it. +async function robustClick(locator: Locator) { + await locator.scrollIntoViewIfNeeded(); + await locator.dispatchEvent("click"); +} + +async function goToRoster(page: Page) { + await robustClick(page.getByTestId("nav-roster")); + await expect(page.getByTestId("nav-roster")).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("heading", { name: "Roster" })).toBeVisible(); +} + +async function fillAttributeSlider(page: Page, key: string, value: number) { + // Same native-setter dispatch already used for the whiteboard's threshold + // sliders (e2e/whiteboard.spec.ts): plain .fill() does not reliably + // trigger React's onChange for a range input in every browser engine. + await page.getByTestId(`player-attr-${key}`).evaluate((el, v) => { + const input = el as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, String(v)); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, value); +} + +async function addPlayer( + page: Page, + opts: { + name: string; + jersey: string; + roleCode: string; + flank: "left" | "right" | "center"; + awr: "low" | "med" | "high"; + dwr: "low" | "med" | "high"; + } +) { + await robustClick(page.getByTestId("roster-add-player")); + await page.getByTestId("player-name").fill(opts.name); + await page.getByTestId("player-jersey").fill(opts.jersey); + await page.getByTestId("player-role").selectOption(opts.roleCode); + await page.getByTestId("player-flank").selectOption(opts.flank); + await page.getByTestId("player-awr").selectOption(opts.awr); + await page.getByTestId("player-dwr").selectOption(opts.dwr); + await robustClick(page.getByTestId("player-save")); + await expect(page.getByTestId("player-save")).toHaveCount(0); +} + +test.describe("roster: CRUD, chips, and the six attribute sliders", () => { + test("coach creates, edits, and deletes a player", async ({ page, issues }) => { + await registerCoach(page); + await goToRoster(page); + + // Brief step 19: "roster CRUD ... role and work-rate chips per player". + await addPlayer(page, { + name: "Alex B.", + jersey: "9", + roleCode: "target_man", + flank: "center", + awr: "med", + dwr: "med", + }); + + const row = page.getByTestId(/roster-row-\d+/).filter({ hasText: "Alex B." }); + await expect(row).toHaveCount(1); + await expect(row).toContainText("Target Man"); // role chip + await expect(row).toContainText("Med / Med"); // work-rate chip + + await robustClick(row); + await expect(page.getByTestId("roster-detail")).toContainText("ALEX B."); + await expect(page.getByTestId("roster-detail")).toContainText("Target Man".toUpperCase()); + // Role note (Role.description, seeded from the Bible), not player prose. + await expect(page.getByTestId("roster-detail")).toContainText("reference point for direct play"); + + // Six coach-rated 1-5 attribute sliders, defaulted to 3. + for (const key of [ + "pace", + "passing_range", + "carrying_1v1", + "positional_discipline", + "aerial_physical", + "pressing_engine", + ]) { + await expect(page.getByTestId(`attr-value-${key}`)).toHaveText("3"); + } + + // Edit: bump jersey number and one slider. + await robustClick(page.getByTestId("player-edit")); + await page.getByTestId("player-jersey").fill("11"); + await fillAttributeSlider(page, "pace", 5); + await robustClick(page.getByTestId("player-save")); + await expect(page.getByTestId("player-save")).toHaveCount(0); + + await expect(page.getByTestId("roster-detail")).toContainText("#11 ALEX B."); + await expect(page.getByTestId("attr-value-pace")).toHaveText("5"); + + // Delete. + await robustClick(page.getByTestId("player-delete")); + await expect(page.getByTestId(/roster-row-\d+/).filter({ hasText: "Alex B." })).toHaveCount(0); + await expect(page.getByTestId("roster-detail")).toContainText("Select a player"); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("roster: double-exposure flank fit warning", () => { + test("fires for a high-AWR fullback behind a high/low winger on the same flank", async ({ + page, + issues, + }) => { + await registerCoach(page); + await goToRoster(page); + + // No warning yet with an empty roster. + await expect(page.locator(".fit-warning")).toHaveCount(0); + + // High AWR / low DWR wide player (Inside Forward, position_code W). + await addPlayer(page, { + name: "Maya K.", + jersey: "7", + roleCode: "inside_forward", + flank: "right", + awr: "high", + dwr: "low", + }); + await expect(page.locator(".fit-warning")).toHaveCount(0); // needs the back too + + // High AWR fullback behind her on the same (right) flank. + await addPlayer(page, { + name: "Jordan T.", + jersey: "2", + roleCode: "overlapping_fb", + flank: "right", + awr: "high", + dwr: "med", + }); + + const warning = page.getByTestId("fit-warning-right"); + await expect(warning).toBeVisible(); + await expect(warning).toContainText("Maya K."); + await expect(warning).toContainText("Jordan T."); + // Seeded Bible copy (role_clashes.json double_exposure_flank), not + // hardcoded UI prose (Brief section 7: "content is data, not code"). + await expect(warning).toContainText("corridor on every transition"); + await expect(warning).toContainText("FIT"); + + // Break the pairing (move the fullback to the left flank) and the + // warning clears: the rule is computed live, not cached. + await robustClick(page.getByTestId(/roster-row-\d+/).filter({ hasText: "Jordan T." })); + await robustClick(page.getByTestId("player-edit")); + await page.getByTestId("player-flank").selectOption("left"); + await robustClick(page.getByTestId("player-save")); + await expect(page.getByTestId("player-save")).toHaveCount(0); + await expect(page.locator(".fit-warning")).toHaveCount(0); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("roster: fit warning and CRUD controls are coach-only, absent from a player's DOM", () => { + test("player sees the roster read-only with no fit warning and no add/edit/delete", async ({ + browser, + }) => { + const coachContext = await browser.newContext(); + const coachPage = await coachContext.newPage(); + const coachIssues = trackIssues(coachPage); + const { joinCode } = await registerCoach(coachPage, { displayName: "Coach Fit Test" }); + await goToRoster(coachPage); + + await addPlayer(coachPage, { + name: "Wide Winger", + jersey: "7", + roleCode: "touchline_winger", + flank: "left", + awr: "high", + dwr: "low", + }); + await addPlayer(coachPage, { + name: "Back Runner", + jersey: "3", + roleCode: "overlapping_fb", + flank: "left", + awr: "high", + dwr: "med", + }); + await expect(coachPage.getByTestId("fit-warning-left")).toBeVisible(); + + const playerContext = await browser.newContext(); + const playerPage = await playerContext.newPage(); + const playerIssues = trackIssues(playerPage); + await registerPlayer(playerPage, joinCode, { displayName: "Player Fit Test" }); + await goToRoster(playerPage); + + // Roster DoD: the double-exposure warning "only renders for coaches" - + // absent from the DOM, not merely hidden (skill: role check contract). + await expect(playerPage.locator(".fit-warning")).toHaveCount(0); + await expect(playerPage.getByTestId("roster-add-player")).toHaveCount(0); + + // Both players are visible read-only (README: "view + play"/view-only + // sliders and work rates), just with no way to create/edit/delete. + await expect(playerPage.getByTestId(/roster-row-\d+/)).toHaveCount(2); + await robustClick(playerPage.getByTestId(/roster-row-\d+/).filter({ hasText: "Wide Winger" })); + await expect(playerPage.getByTestId("player-edit")).toHaveCount(0); + await expect(playerPage.getByTestId("player-delete")).toHaveCount(0); + await expect(playerPage.getByTestId("roster-detail")).toContainText("(view only)"); + + await assertCleanPage(coachPage, coachIssues); + await assertCleanPage(playerPage, playerIssues); + + await coachContext.close(); + await playerContext.close(); + }); +}); + +test.describe("roster: matches across all three themes", () => { + test("fit warning is red-status-only and the active row uses the theme's gold accent", async ({ + page, + issues, + }) => { + await registerCoach(page); + await goToRoster(page); + + await addPlayer(page, { + name: "Maya K.", + jersey: "7", + roleCode: "inside_forward", + flank: "right", + awr: "high", + dwr: "low", + }); + await addPlayer(page, { + name: "Jordan T.", + jersey: "2", + roleCode: "overlapping_fb", + flank: "right", + awr: "high", + dwr: "med", + }); + await robustClick(page.getByTestId(/roster-row-\d+/).filter({ hasText: "Maya K." })); + + const seenWarningBorder = new Set(); + const seenActiveRowBorder = new Set(); + + for (const theme of ["pitch", "dark", "board"] as const) { + await robustClick(page.getByTestId(`theme-switch-${theme}`)); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + + const warningBorder = await page + .getByTestId("fit-warning-right") + .evaluate((el) => getComputedStyle(el).borderColor); + const rowBorder = await page + .getByTestId(/roster-row-\d+/) + .filter({ hasText: "Maya K." }) + .evaluate((el) => getComputedStyle(el).borderColor); + const saveBg = await page + .getByTestId("roster-add-player") + .evaluate((el) => getComputedStyle(el).backgroundColor); + + seenWarningBorder.add(warningBorder); + seenActiveRowBorder.add(rowBorder); + // Gold is the only interactive color, red never a call to action: + // the selected row's border (interactive state) never matches the + // fit warning's red border in any theme. + expect(rowBorder).not.toBe(warningBorder); + // The Add player button (an interactive call to action) uses gold, + // matching the selected row's border color, never the warning's red. + expect(saveBg).not.toBe("rgba(0, 0, 0, 0)"); + expect(saveBg).not.toBe(warningBorder); + } + + // Every theme actually painted a distinct token value: proves the + // page reads CSS variables per theme, same evidence shape as + // e2e/design-tokens.spec.ts and e2e/whiteboard.spec.ts's theme test. + expect(seenWarningBorder.size).toBe(3); + expect(seenActiveRowBorder.size).toBe(3); + + await assertCleanPage(page, issues); + }); +}); diff --git a/e2e/whiteboard.spec.ts b/e2e/whiteboard.spec.ts index d2108b3..8ec2f84 100644 --- a/e2e/whiteboard.spec.ts +++ b/e2e/whiteboard.spec.ts @@ -73,16 +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 is live - // too (T-031); Formations/Roster/Identity stay inert until their own - // tickets land --- + // --- 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 --- await expect(page.getByTestId("nav-whiteboard")).toHaveAttribute("aria-current", "page"); await expect(page.getByTestId("nav-patterns")).not.toBeDisabled(); - for (const key of ["formations", "roster", "identity"]) { + await expect(page.getByTestId("nav-roster")).not.toBeDisabled(); + for (const key of ["formations", "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 0afebb0..79ffc7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,13 +7,14 @@ import { AppShell, type NavKey } from "./AppShell"; import { TeamOnboarding } from "./TeamOnboarding"; import { WhiteboardPage } from "./pages/WhiteboardPage"; import { PatternsPage } from "./pages/PatternsPage"; +import { RosterPage } from "./pages/RosterPage"; import ThemeSwitcher from "./theme/ThemeSwitcher"; import "./App.css"; -// Nav entries live so far (T-031 activates Patterns alongside T-030's -// Whiteboard); Formations/Roster/Identity join this list as their own +// 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"]; +const ENABLED_NAV_KEYS: readonly NavKey[] = ["whiteboard", "patterns", "roster"]; // Portrait on phone-width viewports, landscape otherwise (design README: all // boards render portrait on phone). Derived purely from viewport width, no @@ -118,6 +119,8 @@ export default function App() { > {page === "patterns" ? ( setPage("whiteboard")} /> + ) : page === "roster" ? ( + ) : ( )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bb4d7bb..d8d3d70 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -63,7 +63,17 @@ export async function request(path: string, options: RequestInit = {}): Promi throw new ApiError(response.status, detail); } - if (response.status === 204) return undefined as T; + if (response.status === 204) { + // Drain the (empty) body explicitly rather than discarding the + // Response with its stream unread: some browsers, when automated in + // headless/CDP-driven contexts (encountered writing T-033's roster + // delete e2e journey), otherwise report the request as aborted + // (net::ERR_ABORTED) after the fact even though the 204 itself + // already completed successfully, which trips the clean-page, + // zero-failed-requests assertion every UI journey ends with. + await response.text(); + return undefined as T; + } return (await response.json()) as T; } diff --git a/frontend/src/pages/RosterPage.css b/frontend/src/pages/RosterPage.css new file mode 100644 index 0000000..5061635 --- /dev/null +++ b/frontend/src/pages/RosterPage.css @@ -0,0 +1,335 @@ +/* Roster page (PNG 12 desktop / 20 phone). Tokens only, no hardcoded + colors: red is status-only here (the FIT banner), never a call to + action; gold is the only interactive color (the active row's border, + the Save/Add-player buttons, the slider thumbs via accent-color). */ + +.roster-page { + max-width: 1200px; +} + +.roster-loading, +.roster-error { + font-family: var(--body-font); + font-size: 13px; + margin: 0 0 12px; +} +.roster-error { + color: var(--text-red, var(--red)); +} + +/* FIT warning banner: red border/tag, coach-only (see RosterPage.tsx). + Status only, never clickable, so it carries no interactive styling. */ +.fit-warning { + display: flex; + align-items: flex-start; + gap: 12px; + border: 1px solid var(--red); + background: var(--bg-red, transparent); + border-radius: var(--radius); + padding: 12px 14px; + margin-bottom: 14px; + font-family: var(--body-font); +} +.fit-warning-tag { + flex-shrink: 0; + font-family: var(--display-font); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--text-red, var(--red)); + border: 1px solid var(--red); + border-radius: 4px; + padding: 2px 6px; +} +.fit-warning-body { + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} +.fit-warning-body strong { + color: var(--text-red, var(--red)); + font-weight: 600; +} +.fit-warning-body p { + margin: 4px 0 0; + color: var(--text-secondary); +} + +.roster-layout { + display: grid; + grid-template-columns: minmax(220px, 320px) 1fr; + gap: 16px; + align-items: start; +} + +.roster-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.roster-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 12px; + border-radius: 8px; + border: 1px solid transparent; + background: var(--surface); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; +} +/* Selection state: gold border is the only interactive-color cue here. */ +.roster-row-active { + border-color: var(--accent); +} +.roster-row-number { + flex-shrink: 0; + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--display-font); + font-size: 12px; +} +.roster-row-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.3; +} +.roster-row-name { + font-size: 14px; +} +.roster-you { + color: var(--text-secondary); + font-size: 12px; +} +.roster-row-role { + font-size: 11px; + color: var(--text-secondary); +} +.roster-row-chip { + flex-shrink: 0; + font-size: 11px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-secondary); + white-space: nowrap; +} + +.roster-add { + width: 100%; + margin-top: 10px; + padding: 9px 12px; + border-radius: 999px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-ink, #1b1b1b); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.roster-detail-col { + background: var(--surface); + border-radius: var(--radius); + padding: 16px; + min-height: 160px; + font-family: var(--body-font); +} +.roster-placeholder { + color: var(--text-secondary); + font-size: 13px; + margin: 0; +} + +/* Reusable neutral button (design README: red is status only, never a + call to action, so Delete/Cancel never use it; same convention as + board/Board.css's .ctl-ghost). */ +.roster-page .ctl-ghost { + font: inherit; + font-size: 12px; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--text-secondary); + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} +.roster-page .ctl-ghost:disabled { + opacity: 0.6; + cursor: default; +} + +.player-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} +.player-detail-title { + font-family: var(--display-font); + font-size: 14px; + letter-spacing: 0.02em; + margin: 0; +} +.player-detail-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} +.player-detail-note { + font-size: 13px; + color: var(--text-secondary); + margin: 10px 0 18px; + line-height: 1.5; +} +.player-detail-block { + margin-bottom: 18px; +} +.player-detail-label { + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-secondary); + margin: 0 0 8px; +} +.view-only-tag { + text-transform: none; + letter-spacing: normal; + font-style: italic; +} +.player-detail-value { + font-size: 14px; + margin: 0; +} + +.attribute-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 7px; +} +.attribute-label { + flex: 0 0 130px; + font-size: 13px; +} +.attribute-bar { + flex: 1; + height: 7px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} +.attribute-bar-fill { + display: block; + height: 100%; + background: var(--accent); + border-radius: inherit; +} +.attribute-value { + width: 18px; + text-align: right; + font-size: 13px; + color: var(--text-secondary); +} + +/* Player form: add/edit (Brief step 19 "roster CRUD"). */ +.player-form { + display: flex; + flex-direction: column; + gap: 12px; +} +.player-form h3 { + margin: 0; + font-size: 15px; +} +/* Scoped to a dedicated class rather than the bare `label` selector on + purpose: `.attribute-slider-row` below is also a