From cec879c96da36a9b312aa9c6e33377c6eb3049dc Mon Sep 17 00:00:00 2001 From: BrandanBurgess Date: Thu, 16 Jul 2026 02:39:54 -0400 Subject: [PATCH 01/18] chore(backlog): T-011 done Co-Authored-By: Claude Fable 5 --- docs/agent/BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md index 35c635c..b0e4f36 100644 --- a/docs/agent/BACKLOG.md +++ b/docs/agent/BACKLOG.md @@ -10,7 +10,7 @@ Model: sonnet default; opus = hard ticket, never downgrade. | T-003 | Auth + teams: register, roles, team create, join code, join flow, minimal token-styled screens (Brief §8: invent nothing) | 3 | platform | sonnet | T-001 | T-002 | done | | T-004 | Scoped query layer + full schema from doc 03 + Alembic chain from zero + cross-team read test returns nothing | 4, 5 | platform | sonnet | T-001 | T-002 | done | | T-010 | Seed files: transcribe Bible per doc 03 §4-6 (12 patterns, 8 deliveries, 3 rotations, 6 formations+keystones, rondo 5 zones, 6 archetypes+pass-risk, 4 animated + 2 static ref teams, detail-only slots, cult corner, roles, synergies) | 6 | content-seeder | sonnet | T-004 | T-020 | done | -| T-011 | Em-dash transform pass + CI copy scan + seed validator (required fields, blurb ≤25 words, banned identity phrases, slot refs resolve) | 7, 8 | content-seeder | sonnet | T-010 | T-020 | pr | +| T-011 | Em-dash transform pass + CI copy scan + seed validator (required fields, blurb ≤25 words, banned identity phrases, slot refs resolve) | 7, 8 | content-seeder | sonnet | T-010 | T-020 | done | | T-020 | Board core: pitch canvas, landscape model coords, token drag 60fps @23 tokens, portrait mapping (left=y, top=100-x) with lossless round-trip unit test FIRST | 9, 10 | board-engineer | opus | T-001 | T-010 | done | | 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 | From d0a1e4f4baa9ea1179f4239e5d21ff2a8f9c0cd1 Mon Sep 17 00:00:00 2001 From: BrandanBurgess Date: Thu, 16 Jul 2026 10:08:48 -0400 Subject: [PATCH 02/18] docs(agent): orchestrator state snapshot for session handoff Co-Authored-By: Claude Fable 5 --- docs/agent/STATE.md | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/agent/STATE.md diff --git a/docs/agent/STATE.md b/docs/agent/STATE.md new file mode 100644 index 0000000..4ddc227 --- /dev/null +++ b/docs/agent/STATE.md @@ -0,0 +1,47 @@ +# Orchestrator state snapshot + +Written 2026-07-16. Read this WITH CLAUDE.md and BACKLOG.md when resuming orchestration in a fresh session. BACKLOG.md remains the ticket status source of truth; this file carries session context that the backlog does not. + +## Done and merged to main (all via CI-green PRs) + +| PR | Tickets | Notes | +|---|---|---| +| #1 | T-001, T-002, T-003, T-020 | Batched only because GitHub auth arrived late; per-ticket PRs from #2 on | +| #2 | T-021 | Lane graph + marking rings | +| #3 | T-004 | Scoped query layer + full doc 03 schema (20 tables) | +| #4 | T-022 | Zones + animation player + recorder. Board engine complete | +| #5 | T-010 | All Bible seed content, idempotent loader | +| #6 | T-011 | Hardened seed validator; T-010 content had zero violations | + +main == integration at PR #6 plus backlog chores. Every ticket was independently re-verified by the orchestrator (make verify) before its integration merge and again after. + +## Working agreements established this session + +- Orchestrator creates AND merges PRs itself once gh pr checks is green (user instruction 2026-07-16). Permission rules for gh pr create/merge/view/checks live in .claude/settings.local.json. +- Flow per ticket: agent completes in worktree -> orchestrator reviews diff + reruns make verify -> merge to integration -> verify again -> push -> PR to main -> watch checks -> merge -> BACKLOG status -> remove worktree -> dispatch next ready tickets. +- One ticket per PR (push integration up to a cutoff commit if two tickets are locally merged). +- Worktrees at ../pop-T### with branch feat/T###-slug, based on integration. +- Every agent gets dedicated ports to avoid e2e collisions: POP_API_PORT / POP_WEB_PORT (scheme used so far: T-002 8102/5273, T-003 8103/5373, T-004 8104/5374, T-010 8110/5310, T-011 8111/5311, T-020 8120/5520, T-021 8121/5521, T-022 8122/5522, T-030 8130/5530). +- Agents must run Playwright with explicit --timeout=30000 --global-timeout=200000 and never run make dev in the foreground, never headed/--ui/page.pause (a silent 600s command kills the agent via watchdog). +- Environment: gh CLI is authenticated (BrandanBurgess); no MCP servers configured (github/render/turso absent, gh CLI substitutes for github MCP); git push works via keychain. + +## IN FLIGHT: T-030 whiteboard page (needs adoption by the resuming session) + +- Worktree /Users/brandanburgess/Documents/pop-T030, branch feat/T030-whiteboard-page (base 475a21b). ~29 files changed, NOTHING COMMITTED yet. +- Built so far: backend/app/routers/whiteboard.py + backend/tests/test_whiteboard_routes.py (saved patterns + boards routes via scoped layer), whiteboard page UI, nav shell, e2e/whiteboard.spec.ts; TeamDashboard.tsx renamed to TeamMeta.tsx; e2e/fixtures.ts was modified (REVIEW THIS: the clean-page contract assertions must remain intact; the agent never delivered its justification). +- Current test status (orchestrator-run): e2e/whiteboard.spec.ts = 5 passed, 1 failed. Failing: "full coach journey" on desktop at the reload-restores-state step; zone-toggle-thirds unchecked after reload (boards row round trip loses zones_visible on write or rehydrate). The agent's last hypothesis under investigation: the open view menu interferes with the drag step. +- The original agent hit 3 watchdog stalls + 1 connection drop in this session and its background task is dead. Resuming session should dispatch a FRESH screens agent to adopt the worktree: fix the failing journey in product code (not by weakening the test), full verify, commit, then the standard merge/PR loop. +- T-030 dispatch requirements (from the original brief): PNGs 01-05, 14, 34 + design README lines 39/50 + Brief step 16 + doc 03 4.2/4.3; role rules (author-stamp server-side, coach-only delete = API 403 for players); board state persists to boards row, reload restores; no localStorage; keep all existing board/lane/zone/player/recorder e2e assertions intact. + +## Queue after T-030 merges + +1. Dispatch T-031 (patterns page) and T-033 (roster page) in parallel; T-030's nav ships inert placeholder entries, each page activates its own (trivial expected conflict, orchestrator resolves at merge). +2. Then T-032 + T-034 (deps T-031), then T-040 -> T-041 -> T-042 (collab), T-050 (phone), T-051 (hardening), T-060 (deploy; STOP for the founder's deploy credentials decision per the bootstrap prompt). + +## Open founder questions (defaults shipped, not blocking) + +1. T-003: a coach account joining another team's code becomes coach on that team; should join-by-code force player instead? +2. T-003: join codes are returned by the API to any team member (UI shows them to coaches only); Brief section 3 does not list them coach-only. Confirm before T-040 locks the pattern. +3. T-010: doc 03 section 5 identities schema has no age_hint column but Bible 8.2.4 wants an age hint per card; doc 03 won per CLAUDE.md. Amend doc 03 and add a migration? +4. T-010: founder spot-check sign-off on transformed blurbs is still open (Brief section 5 content DoD line 4; orchestrator spot-check passed). +5. T-022: mid-playback orientation flips are unsupported by design (rotation between runs works, satisfying the DoD); fine, or wanted for T-030+? From 613cad2ce16bcb5978b3e0557a798d3db6cacc76 Mon Sep 17 00:00:00 2001 From: BrandanBurgess Date: Thu, 16 Jul 2026 10:54:15 -0400 Subject: [PATCH 03/18] feat(screens): whiteboard page with toolbar, view menu, record/save (T-030) Whiteboard page (Brief step 16, PNG 01-05/14/34): toolbar, view menu (zones + thresholds), record/replay, and save into My Patterns, all persisted through the scoped query layer (backend/app/routers/whiteboard.py) into the boards and saved_patterns rows (doc 03 4.2/4.3). Delete is coach-only, enforced server-side (403 for players), and every saved pattern is author-stamped from the caller's own membership, never a client-supplied field. Fixes a real reload bug: the view menu's settings popover floated above the toolbar and, left open, could visually sit on top of a token (e.g. the ball's default spot, or any token near the toolbar on a phone-width board). A drag or click landing there hit the popover instead of the token, and for a checkbox's label that silently flipped a zone toggle the user never touched, which then persisted to the boards row on reload. Fixed by closing the view menu when recording starts (a deliberate mode switch) and, for phone-width boards where the popover has no gap in the pitch it can occupy without covering some token, pinning it to the viewport's top edge instead of floating over the pitch. Co-Authored-By: Claude Fable 5 --- backend/app/main.py | 3 +- backend/app/routers/whiteboard.py | 153 +++++++ backend/app/schemas.py | 65 ++- backend/app/scoped.py | 7 + backend/app/specs.py | 9 +- backend/tests/test_whiteboard_routes.py | 270 ++++++++++++ e2e/board.spec.ts | 19 +- e2e/fixtures.ts | 76 ++++ e2e/lanes.spec.ts | 19 +- e2e/player.spec.ts | 13 +- e2e/recorder.spec.ts | 34 +- e2e/whiteboard.spec.ts | 249 ++++++++++++ e2e/zones.spec.ts | 8 +- frontend/src/App.css | 57 +-- frontend/src/App.tsx | 107 +++-- frontend/src/AppShell.css | 156 +++++++ frontend/src/AppShell.tsx | 125 ++++++ frontend/src/TeamDashboard.tsx | 35 -- frontend/src/TeamMeta.tsx | 41 ++ frontend/src/api.ts | 2 +- frontend/src/board/Board.css | 200 +++++++-- frontend/src/board/Board.tsx | 520 +++++++++++++++++------- frontend/src/board/time.test.ts | 81 ++++ frontend/src/board/time.ts | 10 +- frontend/src/board/wire.test.ts | 58 +++ frontend/src/board/wire.ts | 63 +++ frontend/src/pages/WhiteboardPage.css | 18 + frontend/src/pages/WhiteboardPage.tsx | 138 +++++++ frontend/src/styles/global.css | 38 ++ frontend/src/whiteboardApi.ts | 99 +++++ 30 files changed, 2306 insertions(+), 367 deletions(-) create mode 100644 backend/app/routers/whiteboard.py create mode 100644 backend/tests/test_whiteboard_routes.py create mode 100644 e2e/whiteboard.spec.ts create mode 100644 frontend/src/AppShell.css create mode 100644 frontend/src/AppShell.tsx delete mode 100644 frontend/src/TeamDashboard.tsx create mode 100644 frontend/src/TeamMeta.tsx create mode 100644 frontend/src/board/time.test.ts create mode 100644 frontend/src/board/wire.test.ts create mode 100644 frontend/src/board/wire.ts create mode 100644 frontend/src/pages/WhiteboardPage.css create mode 100644 frontend/src/pages/WhiteboardPage.tsx create mode 100644 frontend/src/whiteboardApi.ts diff --git a/backend/app/main.py b/backend/app/main.py index 05bd92b..fb89081 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,11 @@ from fastapi import FastAPI -from app.routers import auth, teams +from app.routers import auth, 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.get("/api/health") diff --git a/backend/app/routers/whiteboard.py b/backend/app/routers/whiteboard.py new file mode 100644 index 0000000..c092782 --- /dev/null +++ b/backend/app/routers/whiteboard.py @@ -0,0 +1,153 @@ +"""Whiteboard state and saved patterns (doc 03 sections 4.2, 4.3; Brief step +16; T-030). Every route depends on get_team_scope (or require_role_on_team, +which itself resolves through get_current_membership): team_id always comes +from the caller's own membership, never a client-supplied field or path +parameter (CLAUDE.md rule 4). +""" + +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 Board, SavedPattern, User +from app.schemas import ( + BoardOut, + BoardStateOut, + SavedPatternCreateRequest, + SavedPatternOut, +) +from app.scoped import TeamScope, get_team_scope +from app.specs import BoardSnapshot, BoardToken, ConfirmedLane, Keyframe, ZonesVisible + +router = APIRouter(prefix="/api", tags=["whiteboard"]) + + +def _board_to_out(board: Board) -> BoardOut: + return BoardOut( + id=board.id, + tokens=[BoardToken.model_validate(t) for t in board.tokens_json], + confirmed_lanes=[ + ConfirmedLane.model_validate(lane) for lane in board.confirmed_lanes_json + ], + blocking_threshold=board.blocking_threshold, + marking_threshold=board.marking_threshold, + zones_visible=ZonesVisible.model_validate(board.zones_visible_json), + updated_at=board.updated_at, + ) + + +def _author_label(role: str, user: User | None) -> str: + # Author stamping (doc 03 4.2): tiles render COACH when author_role is + # coach, else the player's display name. + if role == "coach": + return "COACH" + return user.display_name if user is not None else "Player" + + +def _pattern_to_out(pattern: SavedPattern, author: User | None) -> SavedPatternOut: + return SavedPatternOut( + id=pattern.id, + name=pattern.name, + author_role=pattern.author_role, # type: ignore[arg-type] + author_label=_author_label(pattern.author_role, author), + board_snapshot=BoardSnapshot.model_validate(pattern.board_snapshot_json), + keyframes=[Keyframe.model_validate(k) for k in pattern.keyframes_json], + created_at=pattern.created_at, + ) + + +# --------------------------------------------------------------------------- +# Whiteboard state: one live board per team (doc 03 4.3). GET auto-vivifies +# nothing; PUT upserts the team's single row. +# --------------------------------------------------------------------------- + + +@router.get("/boards/current", response_model=BoardStateOut) +def get_current_board(scope: TeamScope = Depends(get_team_scope)) -> BoardStateOut: + board = scope.query(Board).order_by(Board.updated_at.desc()).first() + return BoardStateOut(board=_board_to_out(board) if board is not None else None) + + +@router.put("/boards/current", response_model=BoardOut) +def upsert_current_board( + payload: BoardSnapshot, scope: TeamScope = Depends(get_team_scope) +) -> BoardOut: + tokens_json = [t.model_dump() for t in payload.tokens] + confirmed_lanes_json = [lane.model_dump() for lane in payload.confirmed_lanes] + zones_visible_json = payload.zones_visible.model_dump() + + board = scope.query(Board).order_by(Board.updated_at.desc()).first() + if board is None: + board = Board( + name="Whiteboard", + tokens_json=tokens_json, + confirmed_lanes_json=confirmed_lanes_json, + blocking_threshold=payload.blocking_threshold, + marking_threshold=payload.marking_threshold, + zones_visible_json=zones_visible_json, + ) + scope.add(board) + else: + board.tokens_json = tokens_json + board.confirmed_lanes_json = confirmed_lanes_json + board.blocking_threshold = payload.blocking_threshold + board.marking_threshold = payload.marking_threshold + board.zones_visible_json = zones_visible_json + + scope.commit() + scope.refresh(board) + return _board_to_out(board) + + +# --------------------------------------------------------------------------- +# Saved patterns: My Patterns (doc 03 4.2). Both roles create; delete is +# coach-only, enforced here (not just hidden client-side), per the Brief +# section 3 permission table and README roles table. +# --------------------------------------------------------------------------- + + +@router.get("/patterns", response_model=list[SavedPatternOut]) +def list_patterns( + scope: TeamScope = Depends(get_team_scope), db: Session = Depends(get_db) +) -> list[SavedPatternOut]: + rows = scope.query(SavedPattern).order_by(SavedPattern.created_at.desc()).all() + author_ids = {r.author_user_id for r in rows} + authors = {u.id: u for u in db.query(User).filter(User.id.in_(author_ids)).all()} + return [_pattern_to_out(r, authors.get(r.author_user_id)) for r in rows] + + +@router.post("/patterns", response_model=SavedPatternOut, status_code=status.HTTP_201_CREATED) +def create_pattern( + payload: SavedPatternCreateRequest, + ctx: CurrentMembership = Depends(get_current_membership), + scope: TeamScope = Depends(get_team_scope), +) -> SavedPatternOut: + # Both coach and player may record and save (Brief section 3 table). + # author_user_id/author_role come from the session, never the body. + row = SavedPattern( + author_user_id=ctx.user.id, + author_role=ctx.role_on_team, + name=payload.name, + board_snapshot_json=payload.board_snapshot.model_dump(), + keyframes_json=[k.model_dump() for k in payload.keyframes], + ) + scope.add(row) + scope.commit() + scope.refresh(row) + return _pattern_to_out(row, ctx.user) + + +@router.delete("/patterns/{pattern_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_pattern( + pattern_id: int, + ctx: CurrentMembership = Depends(require_role_on_team("coach")), + scope: TeamScope = Depends(get_team_scope), +) -> None: + # require_role_on_team("coach") 403s a player before this body runs + # (README: "the delete control never renders" for players, and the API + # enforces it independently of the UI, CLAUDE.md rule 5). + row = scope.get(SavedPattern, pattern_id) + if row is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pattern not found") + scope.delete(row) + scope.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 444e17e..4a4f879 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,11 +1,14 @@ -"""Pydantic v2 request/response models for auth and teams (doc 04 section 1: -validate every payload boundary with Pydantic).""" +"""Pydantic v2 request/response models for auth, teams, and whiteboard +routes (doc 04 section 1: validate every payload boundary with +Pydantic).""" from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, EmailStr, Field +from app.specs import BoardSnapshot, BoardToken, ConfirmedLane, Keyframe, ZonesVisible + RoleOnTeam = Literal["coach", "player"] @@ -69,3 +72,61 @@ class MeOut(BaseModel): user: UserOut | None memberships: list[MembershipOut] + + +# --------------------------------------------------------------------------- +# Whiteboard state (doc 03 section 4.3, `boards`: one live board per team) +# and recorded patterns (doc 03 section 4.2, `saved_patterns`). Request +# bodies reuse the JSON-shape validators from app/specs.py verbatim +# (BoardSnapshot's fields line up 1:1 with the `boards` table's own +# columns) so the wire contract and the doc 03 shape never drift apart. +# --------------------------------------------------------------------------- + + +class BoardOut(BaseModel): + """PUT/GET /api/boards/current. Field names match BoardSnapshot, not + the `boards` table's *_json column names, since this is the API + boundary, not the row (see app/routers/whiteboard.py for the mapping).""" + + id: int + tokens: list[BoardToken] + confirmed_lanes: list[ConfirmedLane] + blocking_threshold: float + marking_threshold: float + zones_visible: ZonesVisible + updated_at: datetime + + +class BoardStateOut(BaseModel): + """GET /api/boards/current always returns 200 (MeOut's pattern): + `board` is null the first time a team opens the whiteboard, before + anything has ever been saved, rather than a 404 a signed-in client + would have to treat as an error case on every fresh team.""" + + board: BoardOut | None + + +class SavedPatternCreateRequest(BaseModel): + """POST /api/patterns body. No author_* or team_id field exists here + on purpose (CLAUDE.md rule 4 / doc 03 section 4.2 author stamping): + the server stamps both from the authenticated caller's own + membership, so nothing in this payload can forge who recorded it.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=120) + board_snapshot: BoardSnapshot + keyframes: list[Keyframe] = Field(min_length=1) + + +class SavedPatternOut(BaseModel): + id: int + name: str + author_role: RoleOnTeam + # "COACH" when author_role is coach, else the author's display name + # (design README roles table: "tile shows COACH or player name"). + # Resolved server-side so the frontend never re-derives it. + author_label: str + board_snapshot: BoardSnapshot + keyframes: list[Keyframe] + created_at: datetime diff --git a/backend/app/scoped.py b/backend/app/scoped.py index 8322c1d..460d894 100644 --- a/backend/app/scoped.py +++ b/backend/app/scoped.py @@ -95,6 +95,13 @@ def add(self, obj: ModelT) -> ModelT: self._db.add(obj) return obj + def delete(self, obj: Any) -> None: + """Stages a delete on this scope's session. Callers must obtain + `obj` via this same scope (get()/query()) first, never via a raw + db.get/db.query, so a row from another team can never reach here + (T-030: coach-only delete of a saved pattern).""" + self._db.delete(obj) + def commit(self) -> None: self._db.commit() diff --git a/backend/app/specs.py b/backend/app/specs.py index 8ba6b4b..633f77d 100644 --- a/backend/app/specs.py +++ b/backend/app/specs.py @@ -126,11 +126,16 @@ def _slot_references_resolve(self) -> "AnimationSpec": class Keyframe(BaseModel): """Doc 03 section 4.2: array of {t_ms, token_id, x, y} covering every - dragged token including opponents and the ball, exactly as recorded.""" + dragged token including opponents and the ball, exactly as recorded. + + t_ms is a float, not an int: the frontend recorder timestamps against + performance.now() (frontend/src/board/time.ts / recorder.ts), which is + sub-millisecond precision, and doc 03 says to "keep raw... exactly as + recorded" rather than round it on the way in.""" model_config = ConfigDict(extra="forbid") - t_ms: int = Field(ge=0) + t_ms: float = Field(ge=0) token_id: str = Field(min_length=1) x: float = Field(ge=0, le=100) y: float = Field(ge=0, le=100) diff --git a/backend/tests/test_whiteboard_routes.py b/backend/tests/test_whiteboard_routes.py new file mode 100644 index 0000000..07636ca --- /dev/null +++ b/backend/tests/test_whiteboard_routes.py @@ -0,0 +1,270 @@ +"""Whiteboard state and saved pattern routes (doc 03 sections 4.2, 4.3; +Brief step 16; T-030). Covers: board persistence round-trips, saved +patterns are author-stamped from the session (never client-supplied), +delete is coach-only and API-enforced (a player token gets 403, not just +a hidden button), and both tables are team-scoped end to end through the +HTTP layer (not just the query-layer unit tests in +test_scoped_query_layer.py). +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@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 + + +_TOKENS = [ + {"id": "home-9", "side": "home", "label": "9", "pos": {"x": 60, "y": 30}}, + {"id": "away-3", "side": "away", "label": "3", "pos": {"x": 40, "y": 70}}, + {"id": "ball", "side": "ball", "label": "", "pos": {"x": 50, "y": 50}}, +] + + +def _board_snapshot(**overrides: object) -> dict: + base = { + "tokens": _TOKENS, + "confirmed_lanes": [], + "blocking_threshold": 7.0, + "marking_threshold": 10.0, + "zones_visible": { + "thirds": False, + "half_spaces": False, + "zone_14": False, + "cutback": False, + }, + } + base.update(overrides) + return base + + +_KEYFRAMES = [{"t_ms": 0, "token_id": "home-9", "x": 60.0, "y": 30.0}] + + +# --------------------------------------------------------------------------- +# Boards: doc 03 4.3 whiteboard state persistence +# --------------------------------------------------------------------------- + + +def test_board_state_is_null_before_anything_is_saved(client: TestClient) -> None: + coach = _coach_with_team() + response = coach.get("/api/boards/current") + assert response.status_code == 200 + assert response.json() == {"board": None} + + +def test_board_upsert_round_trips_thresholds_lanes_and_zones(client: TestClient) -> None: + coach = _coach_with_team() + snapshot = _board_snapshot( + confirmed_lanes=[{"a": "home-9", "b": "away-3"}], + blocking_threshold=4.0, + marking_threshold=12.0, + zones_visible={ + "thirds": True, + "half_spaces": False, + "zone_14": True, + "cutback": False, + }, + ) + # confirmed lane must reference two tokens on the SAME side per the + # BoardSnapshot validator's sibling rule set (existing tests only + # check token-id existence, not side matching, so home/away is legal + # at the schema level even if the frontend never emits it). + put = coach.put("/api/boards/current", json=snapshot) + assert put.status_code == 200 + body = put.json() + assert body["blocking_threshold"] == 4.0 + assert body["marking_threshold"] == 12.0 + assert body["zones_visible"]["zone_14"] is True + assert body["confirmed_lanes"] == [{"a": "home-9", "b": "away-3"}] + + # Reloading (a fresh GET, simulating a page reload) restores it. + got = coach.get("/api/boards/current") + assert got.status_code == 200 + restored = got.json()["board"] + assert restored["blocking_threshold"] == 4.0 + assert restored["zones_visible"]["thirds"] is True + assert {t["id"] for t in restored["tokens"]} == {"home-9", "away-3", "ball"} + + +def test_board_upsert_replaces_the_single_row_not_append(client: TestClient) -> None: + coach = _coach_with_team() + coach.put("/api/boards/current", json=_board_snapshot(blocking_threshold=5.0)) + coach.put("/api/boards/current", json=_board_snapshot(blocking_threshold=9.0)) + + got = coach.get("/api/boards/current").json()["board"] + assert got["blocking_threshold"] == 9.0 + + +def test_board_state_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.put("/api/boards/current", json=_board_snapshot(blocking_threshold=3.0)) + + assert coach_b.get("/api/boards/current").json() == {"board": None} + + +def test_board_route_requires_authentication(client: TestClient) -> None: + assert client.get("/api/boards/current").status_code == 401 + assert client.put("/api/boards/current", json=_board_snapshot()).status_code == 401 + + +# --------------------------------------------------------------------------- +# Saved patterns: doc 03 4.2, author stamping, coach-only delete +# --------------------------------------------------------------------------- + + +def test_create_pattern_is_author_stamped_from_the_session_coach(client: TestClient) -> None: + coach = _coach_with_team() + response = coach.post( + "/api/patterns", + json={ + "name": "Build out right", + "board_snapshot": _board_snapshot(), + "keyframes": _KEYFRAMES, + }, + ) + assert response.status_code == 201 + body = response.json() + assert body["author_role"] == "coach" + assert body["author_label"] == "COACH" + assert body["name"] == "Build out right" + + +def test_create_pattern_ignores_any_client_supplied_author_fields(client: TestClient) -> None: + """CLAUDE.md rule 4's "client input never supplies team_id" extends to + author stamping here: the request schema has no author_* field at all + (extra="forbid"), so a client cannot even attempt to forge one.""" + coach = _coach_with_team() + response = coach.post( + "/api/patterns", + json={ + "name": "Forged", + "author_role": "player", + "author_user_id": 9999, + "board_snapshot": _board_snapshot(), + "keyframes": _KEYFRAMES, + }, + ) + assert response.status_code == 422 # extra="forbid" rejects the unknown fields + + +def test_player_can_create_a_pattern_stamped_with_their_display_name(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com", name="Sam Player") + + response = player.post( + "/api/patterns", + json={ + "name": "Press trigger", + "board_snapshot": _board_snapshot(), + "keyframes": _KEYFRAMES, + }, + ) + assert response.status_code == 201 + body = response.json() + assert body["author_role"] == "player" + assert body["author_label"] == "Sam Player" + + +def test_list_patterns_is_team_scoped_newest_first(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/patterns", + json={"name": "A1", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ) + coach_a.post( + "/api/patterns", + json={"name": "A2", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ) + coach_b.post( + "/api/patterns", + json={"name": "B1", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ) + + names_a = [p["name"] for p in coach_a.get("/api/patterns").json()] + names_b = [p["name"] for p in coach_b.get("/api/patterns").json()] + assert names_a == ["A2", "A1"] # newest first + assert names_b == ["B1"] + + +def test_coach_can_delete_a_saved_pattern(client: TestClient) -> None: + coach = _coach_with_team() + pattern_id = coach.post( + "/api/patterns", + json={"name": "To delete", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).json()["id"] + + response = coach.delete(f"/api/patterns/{pattern_id}") + assert response.status_code == 204 + assert coach.get("/api/patterns").json() == [] + + +def test_player_delete_attempt_is_403_and_the_pattern_survives(client: TestClient) -> None: + coach = _coach_with_team() + player = _player_on_team(coach, email="player@example.com") + pattern_id = coach.post( + "/api/patterns", + json={"name": "Protected", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).json()["id"] + + response = player.delete(f"/api/patterns/{pattern_id}") + assert response.status_code == 403 + assert len(coach.get("/api/patterns").json()) == 1 + + +def test_delete_across_teams_404s_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") + pattern_id = coach_a.post( + "/api/patterns", + json={"name": "Team A only", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).json()["id"] + + response = coach_b.delete(f"/api/patterns/{pattern_id}") + assert response.status_code == 404 + assert len(coach_a.get("/api/patterns").json()) == 1 + + +def test_pattern_routes_require_authentication(client: TestClient) -> None: + assert client.get("/api/patterns").status_code == 401 + assert ( + client.post( + "/api/patterns", + json={"name": "x", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES}, + ).status_code + == 401 + ) + assert client.delete("/api/patterns/1").status_code == 401 diff --git a/e2e/board.spec.ts b/e2e/board.spec.ts index d468b62..7bd1036 100644 --- a/e2e/board.spec.ts +++ b/e2e/board.spec.ts @@ -1,8 +1,13 @@ // Board engine journey (T-020). Runs under both Playwright projects: desktop // (landscape default) and mobile iPhone 13 (portrait default). Covers the two // board-engine DoD lines from Brief section 5. +// +// T-030: the whiteboard is now an authenticated page, so every test signs in +// a fresh coach first (registerCoach lands on the whiteboard once the team is +// created). Orientation flips use a real viewport resize across the phone +// breakpoint instead of the old dev-only "Rotate board" toggle. -import { test, expect, assertCleanPage } from "./fixtures"; +import { test, expect, assertCleanPage, registerCoach, flipOrientationViewport } from "./fixtures"; // Must match Board.tsx VIEWBOX exactly. const VB = { @@ -38,8 +43,7 @@ async function currentOrientation(page: import("@playwright/test").Page): Promis } test("board renders 23 tokens in the viewport's orientation", async ({ page, issues }) => { - await page.goto("/"); - await expect(page.getByTestId("board")).toBeVisible(); + await registerCoach(page); await expect(page.locator("[data-token-id]")).toHaveCount(23); // 11 + 11 + ball await expect(page.locator('[data-token-side="ball"]')).toHaveCount(1); @@ -55,7 +59,7 @@ test("board renders 23 tokens in the viewport's orientation", async ({ page, iss // stored landscape model coordinate; that coordinate renders correctly in both // orientations, proving orientation is render-only. test("stored model coords replay correctly in both orientations", async ({ page, issues }) => { - await page.goto("/"); + await registerCoach(page); const token = page.locator('[data-token-id="home-9"]'); await expect(token).toBeVisible(); @@ -78,8 +82,9 @@ test("stored model coords replay correctly in both orientations", async ({ page, expect(px.px).toBeCloseTo(exp.px, 1); expect(px.py).toBeCloseTo(exp.py, 1); - // Flip orientation. Same stored model coord, render-only change. - await page.getByRole("button", { name: "Rotate board" }).click(); + // Flip orientation (resize across the phone breakpoint). Same stored model + // coord, render-only change. + await flipOrientationViewport(page); const after = await currentOrientation(page); expect(after).not.toBe(before); @@ -99,7 +104,7 @@ test("stored model coords replay correctly in both orientations", async ({ page, // budget during a scripted drag: sample rAF deltas while dragging and assert // the median frame stays within budget. Actual numbers are logged as evidence. test("drag holds frame budget with 23 tokens", async ({ page, issues }) => { - await page.goto("/"); + await registerCoach(page); const token = page.locator('[data-token-id="home-9"]'); const box = (await token.boundingBox())!; const cx = box.x + box.width / 2; diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index bfa4951..6fc62fe 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -25,3 +25,79 @@ export async function assertCleanPage(page: Page, issues: Issues) { expect(issues.failedRequests, "failed requests").toEqual([]); expect(issues.serverErrors, "5xx responses").toEqual([]); } + +// --------------------------------------------------------------------------- +// Auth helpers (T-030): the whiteboard is now an authenticated page, so +// every board/lane/zone/player/recorder journey needs a signed-in account +// before it can reach the board. Centralized here so each spec file states +// its intent (registerCoach / registerPlayer) instead of re-deriving the +// register-then-create-team flow. +// --------------------------------------------------------------------------- + +const PASSWORD = "correct-horse-battery"; + +export function uniqueEmail(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; +} + +/** Registers a fresh coach, creates a team, and waits for the whiteboard + * board to render. Returns the team's join code (coach-only, per the + * README roles table) for tests that also need a player on the same team. */ +export async function registerCoach( + page: Page, + opts: { displayName?: string; teamName?: string } = {} +): Promise<{ email: string; joinCode: string }> { + const email = uniqueEmail("coach"); + await page.goto("/"); + await page.getByLabel("Name").fill(opts.displayName ?? "Coach Test"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(PASSWORD); + await page.getByRole("radio", { name: "Coach" }).check(); + await page.getByRole("button", { name: "Create account" }).click(); + await page.getByLabel("Team name").fill(opts.teamName ?? `Team ${Date.now()}`); + await page.getByRole("button", { name: "Create team" }).click(); + await expect(page.getByTestId("board")).toBeVisible(); + const joinCode = (await page.locator(".join-code strong").textContent())?.trim() ?? ""; + return { email, joinCode }; +} + +/** Registers a fresh player and joins the given team's join code, waiting + * for the whiteboard board to render. */ +export async function registerPlayer( + page: Page, + joinCode: string, + opts: { displayName?: string } = {} +): Promise<{ email: string }> { + const email = uniqueEmail("player"); + await page.goto("/"); + await page.getByLabel("Name").fill(opts.displayName ?? "Player Test"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(PASSWORD); + await page.getByRole("radio", { name: "Player" }).check(); + await page.getByRole("button", { name: "Create account" }).click(); + await page.getByLabel("Join code").fill(joinCode); + await page.getByRole("button", { name: "Join team" }).click(); + await expect(page.getByTestId("board")).toBeVisible(); + return { email }; +} + +/** Flips the board's rendered orientation by resizing the viewport across + * the phone/desktop breakpoint (design README: portrait on phone, + * landscape otherwise). Replaces the old dev-only "Rotate board" toggle + * (throwaway scaffolding removed in T-030) with the real mechanism. + * + * App.tsx derives orientation from a matchMedia "change" listener, which + * fires asynchronously after the resize (unlike the old toggle's + * synchronous state update), so this waits for the DOM to actually reflect + * the flip before returning: otherwise a caller that reads orientation + * immediately after resizing can observe the pre-flip value. */ +export async function flipOrientationViewport(page: Page): Promise { + const size = page.viewportSize()!; + const wasPortrait = size.width <= 700; + const target = wasPortrait ? { width: 1440, height: 900 } : { width: 390, height: 844 }; + await page.setViewportSize(target); + const expected = wasPortrait ? "landscape" : "portrait"; + await expect(page.locator(".board-wrap")).toHaveAttribute("data-orientation", expected, { + timeout: 4000, + }); +} diff --git a/e2e/lanes.spec.ts b/e2e/lanes.spec.ts index baef63b..f86a935 100644 --- a/e2e/lanes.spec.ts +++ b/e2e/lanes.spec.ts @@ -5,8 +5,12 @@ // Covers the board-engine DoD line verbatim: "Lanes recompute live during drags; // confirmed lanes persist; blocked-lane dot sits on the interception point; the // two thresholds adjust independently." +// +// T-030: the whiteboard is now an authenticated page (registerCoach signs in +// and lands on it), and the threshold sliders moved into the toolbar's view +// menu (folded in alongside the zone toggles). -import { test, expect, assertCleanPage } from "./fixtures"; +import { test, expect, assertCleanPage, registerCoach } from "./fixtures"; import type { Locator, Page } from "@playwright/test"; const VB = { @@ -83,11 +87,14 @@ test("confirm a lane, block it, and adjust both thresholds independently", async page, issues, }) => { - await page.goto("/"); - await expect(page.getByTestId("board")).toBeVisible(); + await registerCoach(page); // Lay a clean horizontal lane near the top touchline (y = 8), a region clear of - // opponents, so the confirmed lane starts unblocked. + // opponents, so the confirmed lane starts unblocked. The view menu (thresholds + // live there, T-030) stays closed for these drags/clicks: on a phone-width + // board its popover has no gap in the pitch it can sit in without covering + // some token (Board.css), so it only opens for the threshold adjustments + // below and closes again before the board is touched. await dragTokenTo(page, "home-2", { x: 30, y: 8 }); await dragTokenTo(page, "home-9", { x: 70, y: 8 }); @@ -110,6 +117,7 @@ test("confirm a lane, block it, and adjust both thresholds independently", async expect(Number(await dot(page).getAttribute("cy"))).toBeCloseTo(expectDot.py, 0); // --- Blocking threshold responds only to the blocking slider --- + await page.getByTestId("view-menu").click(); await setThreshold(page, "blocking-threshold", 4); // 6 > 4: no longer blocked await expect(lane(page)).toHaveAttribute("data-blocked", "false"); await expect(dot(page)).toHaveCount(0); @@ -119,6 +127,8 @@ test("confirm a lane, block it, and adjust both thresholds independently", async // No marking yet on home-9. await expect(ring(page, "home-9")).toHaveCount(0); + // Done with the sliders for now: close the menu before the next drag. + await page.getByTestId("view-menu").click(); // Mark home-9: a defender 6 model units away (loose ring, tight band is 5). await dragTokenTo(page, "away-9", { x: 76, y: 8 }); @@ -126,6 +136,7 @@ test("confirm a lane, block it, and adjust both thresholds independently", async await expect(ring(page, "home-9")).toHaveAttribute("data-mark-level", "loose"); // --- Independence, both directions --- + await page.getByTestId("view-menu").click(); // Changing the BLOCKING threshold must not disturb the marking ring. await setThreshold(page, "blocking-threshold", 20); await expect(ring(page, "home-9")).toHaveCount(1); diff --git a/e2e/player.spec.ts b/e2e/player.spec.ts index 5fadf5c..e4cca91 100644 --- a/e2e/player.spec.ts +++ b/e2e/player.spec.ts @@ -5,8 +5,14 @@ // The raw-recording half is covered in recorder.spec.ts; here we prove the // DECLARATIVE demo spec runs and its bound ball waypoint lands on the moving // runner (home-2) at that runner's final position. +// +// T-030: the whiteboard is now an authenticated page; registerCoach signs in +// and lands on it. The toolbar's Play button runs this same demo spec (see +// Board.tsx's doc comment on that interpretation); Restart is a small +// secondary control shown once a playback exists, not part of the 5-icon +// PNG toolbar. -import { test, expect, assertCleanPage } from "./fixtures"; +import { test, expect, assertCleanPage, registerCoach } from "./fixtures"; import type { Page, Locator } from "@playwright/test"; // The demo's overlapping fullback (home-2) finishes here (demoSpec.ts). @@ -29,8 +35,7 @@ test("plays the declarative demo spec and connects the pass to the moving runner page, issues, }) => { - await page.goto("/"); - await expect(page.getByTestId("board")).toBeVisible(); + await registerCoach(page); await page.getByTestId("play-demo").click(); @@ -55,7 +60,7 @@ test("plays the declarative demo spec and connects the pass to the moving runner }); test("restart replays the spec from the top", async ({ page, issues }) => { - await page.goto("/"); + await registerCoach(page); await page.getByTestId("play-demo").click(); await waitPlaybackDone(page); diff --git a/e2e/recorder.spec.ts b/e2e/recorder.spec.ts index 34819e1..7316654 100644 --- a/e2e/recorder.spec.ts +++ b/e2e/recorder.spec.ts @@ -4,8 +4,13 @@ // and the Brief cross-orientation DoD: record in one orientation, replay in the // other, and the mapped positions still match (everything is stored in landscape // model coordinates; orientation is render only). +// +// T-030: saving now round-trips through the API (My Patterns is server data, +// author-stamped and id-keyed, so replay buttons are found by role within a +// saved-pattern tile rather than a hardcoded "replay-0" index), and the +// whiteboard is an authenticated page. -import { test, expect, assertCleanPage } from "./fixtures"; +import { test, expect, assertCleanPage, registerCoach, flipOrientationViewport } from "./fixtures"; import type { Page, Locator } from "@playwright/test"; const VB = { @@ -66,12 +71,18 @@ async function waitPlaybackDone(page: Page) { await expect(root).toHaveAttribute("data-playing", "false", { timeout: 15000 }); } +// The saved-pattern tile's Replay button, found by role within the tile +// rather than a hardcoded index/id: My Patterns is server data (T-030), so +// database-assigned ids are not predictable literals. +function replayButton(page: Page): Locator { + return page.getByTestId("saved-pattern").getByRole("button", { name: "Replay" }); +} + test("records a teammate, an opponent, and the ball, then replays them", async ({ page, issues, }) => { - await page.goto("/"); - await expect(page.getByTestId("board")).toBeVisible(); + await registerCoach(page); await page.getByTestId("record").click(); await expect(page.getByTestId("record-banner")).toBeVisible(); @@ -93,15 +104,17 @@ test("records a teammate, an opponent, and the ball, then replays them", async ( await page.getByTestId("record-name").fill("Defensive shift"); await page.getByTestId("save-pattern").click(); - // The saved pattern is author-stamped and lands in My patterns (doc 03 4.2). + // The saved pattern is author-stamped (COACH) and lands in My patterns + // (doc 03 4.2), round-tripped through the API. await expect(page.getByTestId("saved-pattern")).toHaveCount(1); await expect(page.getByTestId("saved-pattern")).toContainText("Defensive shift"); + await expect(page.getByTestId("saved-pattern-author")).toHaveText("COACH"); // Nudge everything away so replay has to move the tokens back. await dragTokenTo(page, "home-9", { x: 20, y: 20 }); await dragTokenTo(page, "away-3", { x: 80, y: 20 }); - await page.getByTestId("replay-0").click(); + await replayButton(page).click(); await waitPlaybackDone(page); for (const [id, m] of Object.entries(want)) { @@ -115,8 +128,7 @@ test("records a teammate, an opponent, and the ball, then replays them", async ( }); test("record in one orientation, replay correctly in the other", async ({ page, issues }) => { - await page.goto("/"); - await expect(page.getByTestId("board")).toBeVisible(); + await registerCoach(page); const recordedIn = await orientationOf(page); @@ -129,13 +141,15 @@ test("record in one orientation, replay correctly in the other", async ({ page, }; await page.getByTestId("stop-record").click(); await page.getByTestId("save-pattern").click(); + await expect(page.getByTestId("saved-pattern")).toHaveCount(1); - // Flip the board. Same stored model coordinates, render-only change. - await page.getByRole("button", { name: "Rotate board" }).click(); + // Flip the board (viewport resize across the phone breakpoint). Same + // stored model coordinates, render-only change. + await flipOrientationViewport(page); const replayIn = await orientationOf(page); expect(replayIn).not.toBe(recordedIn); - await page.getByTestId("replay-0").click(); + await replayButton(page).click(); await waitPlaybackDone(page); // Model coordinates are orientation-independent: they replay to the same values. diff --git a/e2e/whiteboard.spec.ts b/e2e/whiteboard.spec.ts new file mode 100644 index 0000000..538c0b9 --- /dev/null +++ b/e2e/whiteboard.spec.ts @@ -0,0 +1,249 @@ +// Whiteboard page journey (T-030, Brief step 16, PNG 01-05, 14, 34). 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." +// plus the doc 03 4.2/4.3 persistence lines this ticket adds: saved +// patterns are author-stamped and team-scoped, and whiteboard state +// (thresholds, confirmed lanes, zones visible) persists and survives a +// reload. + +import { + test, + expect, + assertCleanPage, + registerCoach, + registerPlayer, +} from "./fixtures"; +import type { Page } from "@playwright/test"; + +const VB = { + landscape: { width: 1050, height: 680 }, + portrait: { width: 700, height: 1000 }, +} as const; +type Orientation = keyof typeof VB; +interface Model { + x: number; + y: number; +} + +function expectedPixel(m: Model, o: Orientation) { + const vb = VB[o]; + return o === "portrait" + ? { px: (m.y / 100) * vb.width, py: ((100 - m.x) / 100) * vb.height } + : { px: (m.x / 100) * vb.width, py: (m.y / 100) * vb.height }; +} + +async function orientationOf(page: Page): Promise { + return (await page.locator(".board-wrap").getAttribute("data-orientation")) as Orientation; +} + +async function modelToClient(page: Page, m: Model) { + const o = await orientationOf(page); + const box = (await page.getByTestId("board").boundingBox())!; + const vb = VB[o]; + const p = expectedPixel(m, o); + return { x: box.x + (p.px / vb.width) * box.width, y: box.y + (p.py / vb.height) * box.height }; +} + +async function dragTokenTo(page: Page, id: string, m: Model) { + const b = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!; + const start = { x: b.x + b.width / 2, y: b.y + b.height / 2 }; + const target = await modelToClient(page, m); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(target.x, target.y, { steps: 10 }); + await page.mouse.up(); +} + +async function waitPlaybackDone(page: Page) { + const root = page.locator(".board-root"); + await expect(root).toHaveAttribute("data-playing", "true", { timeout: 4000 }); + await expect(root).toHaveAttribute("data-playing", "false", { timeout: 15000 }); +} + +async function waitSaved(page: Page) { + await expect(page.getByTestId("board-save-status")).toHaveText("All changes saved", { + timeout: 4000, + }); +} + +test.describe("whiteboard: record, save into My Patterns, replay, and reload restores state", () => { + test("full coach journey", async ({ page, issues }) => { + await registerCoach(page); + + // --- App shell: Whiteboard is the active nav entry, the rest are + // present but inert until their own tickets land --- + await expect(page.getByTestId("nav-whiteboard")).toHaveAttribute("aria-current", "page"); + for (const key of ["patterns", "formations", "roster", "identity"]) { + const item = page.getByTestId(`nav-${key}`); + await expect(item).toHaveAttribute("aria-disabled", "true"); + await expect(item).toBeDisabled(); + } + + // --- Lay a confirmed lane, toggle a zone, set both thresholds --- + await dragTokenTo(page, "home-2", { x: 30, y: 8 }); + await dragTokenTo(page, "home-9", { x: 70, y: 8 }); + await page.locator('[data-token-id="home-2"]').click(); + await page.locator('[data-token-id="home-9"]').click(); + await expect(page.locator('[data-lane-key="home-2|home-9"]')).toHaveAttribute( + "data-lane-status", + "confirmed" + ); + + await page.getByTestId("view-menu").click(); + await page.getByTestId("zone-toggle-thirds").check(); + await page.getByTestId("blocking-threshold").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 })); + }, 15); + await page.getByTestId("marking-threshold").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 })); + }, 12); + + // Whiteboard state (doc 03 4.3) autosaves; wait for it to land before + // moving on, so the reload assertion later is not racing the PUT. + await waitSaved(page); + + // --- Record a MULTI-token movement (teammate + opponent + ball) --- + await page.getByTestId("record").click(); + await expect(page.getByTestId("record-banner")).toBeVisible(); + await dragTokenTo(page, "home-9", { x: 55, y: 35 }); + await dragTokenTo(page, "away-3", { x: 45, y: 65 }); + await dragTokenTo(page, "ball", { x: 50, y: 50 }); + await page.getByTestId("stop-record").click(); + await expect(page.getByTestId("save-bar")).toBeVisible(); + + // --- Save with a name --- + await page.getByTestId("record-name").fill("Press trigger release"); + await page.getByTestId("save-pattern").click(); + + // --- See it in My Patterns, author-stamped COACH --- + const tile = page.getByTestId("saved-pattern").filter({ hasText: "Press trigger release" }); + await expect(tile).toHaveCount(1); + await expect(tile.getByTestId("saved-pattern-author")).toHaveText("COACH"); + // Delete is coach-only and the API-enforced control (README roles table). + await expect(tile.getByRole("button", { name: "Delete" })).toBeVisible(); + + // --- Replay it --- + await tile.getByRole("button", { name: "Replay" }).click(); + await waitPlaybackDone(page); + + await waitSaved(page); + + // --- Reload: board state (thresholds, confirmed lanes, zones) and My + // Patterns both come back from the server, not localStorage --- + await page.reload(); + await expect(page.getByTestId("board")).toBeVisible(); + + // Present at all (not just conditionally, the way a merely-suggested + // lane would be) proves it survived as a CONFIRMED pair, not just that + // some lane happens to be near the ball after reload. + await expect(page.locator('[data-lane-key="home-2|home-9"]')).toHaveCount(1); + + await page.getByTestId("view-menu").click(); + await expect(page.getByTestId("zone-toggle-thirds")).toBeChecked(); + await expect(page.getByTestId("blocking-threshold-value")).toHaveText("15"); + await expect(page.getByTestId("marking-threshold-value")).toHaveText("12"); + + await expect( + page.getByTestId("saved-pattern").filter({ hasText: "Press trigger release" }) + ).toHaveCount(1); + + await assertCleanPage(page, issues); + }); +}); + +test.describe("whiteboard: delete is coach-only, API-enforced, absent from the DOM for players", () => { + test("player sees the coach's pattern but never a delete control", async ({ browser }) => { + const coachContext = await browser.newContext(); + const coachPage = await coachContext.newPage(); + const { joinCode } = await registerCoach(coachPage, { displayName: "Coach Role Test" }); + + await coachPage.getByTestId("record").click(); + await dragTokenTo(coachPage, "home-4", { x: 60, y: 40 }); + await coachPage.getByTestId("stop-record").click(); + await coachPage.getByTestId("record-name").fill("Role check pattern"); + await coachPage.getByTestId("save-pattern").click(); + await expect(coachPage.getByTestId("saved-pattern")).toHaveCount(1); + await expect(coachPage.getByRole("button", { name: "Delete" })).toBeVisible(); + + const playerContext = await browser.newContext(); + const playerPage = await playerContext.newPage(); + await registerPlayer(playerPage, joinCode, { displayName: "Player Role Test" }); + + const tile = playerPage.getByTestId("saved-pattern").filter({ hasText: "Role check pattern" }); + await expect(tile).toHaveCount(1); + await expect(tile.getByTestId("saved-pattern-author")).toHaveText("COACH"); + // Absent from the DOM, not merely hidden (skill: role check contract). + await expect(playerPage.getByRole("button", { name: "Delete" })).toHaveCount(0); + await expect(playerPage.locator('[data-testid^="delete-pattern-"]')).toHaveCount(0); + + // A player CAN record and save their own pattern (Brief section 3 + // table: "Yes; saved patterns author-stamped"), and it is author-stamped + // with their display name, not COACH. + await playerPage.getByTestId("record").click(); + await dragTokenTo(playerPage, "home-6", { x: 44, y: 44 }); + await playerPage.getByTestId("stop-record").click(); + await playerPage.getByTestId("record-name").fill("Player recorded shift"); + await playerPage.getByTestId("save-pattern").click(); + const playerTile = playerPage + .getByTestId("saved-pattern") + .filter({ hasText: "Player recorded shift" }); + await expect(playerTile).toHaveCount(1); + await expect(playerTile.getByTestId("saved-pattern-author")).toHaveText("Player Role Test"); + await expect(playerTile.getByRole("button", { name: "Delete" })).toHaveCount(0); + + await coachContext.close(); + await playerContext.close(); + }); +}); + +test.describe("whiteboard: matches across all three themes", () => { + test("toolbar's interactive color is theme-driven, never hardcoded", async ({ + page, + issues, + }) => { + await registerCoach(page); + + const accentBg = () => + page.getByTestId("select-tool").evaluate((el) => getComputedStyle(el).backgroundColor); + const surfaceBg = () => + page.locator(".board-toolbar-float").evaluate((el) => getComputedStyle(el).backgroundColor); + + const seenAccent = new Set(); + const seenSurface = 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); + await expect(page.getByTestId("board")).toBeVisible(); + // The always-active Select tool is gold in every theme's own accent + // (design README: gold is the only interactive color). + seenAccent.add(await accentBg()); + seenSurface.add(await surfaceBg()); + // Record, before it is ever pressed, must NOT render in the red status + // color (red is never a call to action). + const recordBg = await page + .getByTestId("record") + .evaluate((el) => getComputedStyle(el).backgroundColor); + expect(recordBg).not.toBe(await accentBg()); + } + + // Every theme actually painted a distinct value: proves the toolbar + // reads CSS variables per theme rather than a color baked into the + // component (same evidence shape as e2e/design-tokens.spec.ts). + expect(seenAccent.size).toBe(3); + expect(seenSurface.size).toBe(3); + + await assertCleanPage(page, issues); + }); +}); diff --git a/e2e/zones.spec.ts b/e2e/zones.spec.ts index 1896ad4..8fb6291 100644 --- a/e2e/zones.spec.ts +++ b/e2e/zones.spec.ts @@ -2,8 +2,11 @@ // projects (desktop landscape, iPhone 13 portrait): zones are model-space // geometry mapped through coords.ts, so the same toggles must work in either // orientation. Covers the view menu, per-group toggling, and independence. +// +// T-030: the whiteboard is now an authenticated page; registerCoach signs in +// and lands on it. -import { test, expect, assertCleanPage } from "./fixtures"; +import { test, expect, assertCleanPage, registerCoach } from "./fixtures"; async function openViewMenu(page: import("@playwright/test").Page) { const btn = page.getByTestId("view-menu"); @@ -11,8 +14,7 @@ async function openViewMenu(page: import("@playwright/test").Page) { } test("view menu toggles each zone group independently", async ({ page, issues }) => { - await page.goto("/"); - await expect(page.getByTestId("board")).toBeVisible(); + await registerCoach(page); // Nothing is on by default (empty board, per the design README default). await expect(page.locator("[data-zone-group]")).toHaveCount(0); diff --git a/frontend/src/App.css b/frontend/src/App.css index f1acf26..4d99f12 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,60 +1,9 @@ -.app-shell { - min-height: 100%; - background: var(--bg); - color: var(--text-primary); - display: flex; - flex-direction: column; -} - -.app-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 12px 16px; - background: var(--sidebar-bg); - border-bottom: 1px solid var(--border); -} - -.app-brand { - font-family: var(--display-font); - font-weight: 600; - font-size: 15px; - margin: 0; -} - -.app-main { - padding: 16px; -} +/* App.tsx-specific extras. Shared shell/topbar/brand/main chrome lives in + styles/global.css so the pre-auth branches and the full AppShell (T-030) + never drift out of sync. */ .app-status { font-family: var(--body-font); color: var(--text-secondary); font-size: 13px; } - -/* Temporary board frame until the whiteboard page proper lands in T-030. */ -.whiteboard-dev { - margin-top: 16px; -} - -.board-toolbar { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 12px; -} - -.board-toolbar button { - font: inherit; - padding: 6px 14px; - border-radius: 999px; - border: 1px solid var(--accent); - background: var(--accent); - color: var(--accent-ink); - cursor: pointer; -} - -.board-toolbar span { - color: var(--text-secondary); -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1fa4ea..8d72812 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,21 +1,23 @@ import { useCallback, useEffect, useState } from "react"; -import ThemeSwitcher from "./theme/ThemeSwitcher"; -import Board from "./board/Board"; +import type { ReactNode } from "react"; import type { Orientation } from "./board/coords"; import { MeOut, fetchMe, logout as apiLogout } from "./api"; import { AuthForms } from "./AuthForms"; -import { TeamDashboard } from "./TeamDashboard"; +import { AppShell } from "./AppShell"; import { TeamOnboarding } from "./TeamOnboarding"; +import { WhiteboardPage } from "./pages/WhiteboardPage"; +import ThemeSwitcher from "./theme/ThemeSwitcher"; import "./App.css"; // Portrait on phone-width viewports, landscape otherwise (design README: all -// boards render portrait on phone). A manual toggle lets either orientation be -// exercised on any device, which the round-trip verification relies on. +// boards render portrait on phone). Derived purely from viewport width, no +// manual override: this is how a real device actually decides it. +const PORTRAIT_QUERY = "(max-width: 700px)"; + function usePreferredOrientation(): Orientation { - const query = "(max-width: 700px)"; const initial: Orientation = typeof window !== "undefined" && typeof window.matchMedia === "function" - ? window.matchMedia(query).matches + ? window.matchMedia(PORTRAIT_QUERY).matches ? "portrait" : "landscape" : "landscape"; @@ -23,7 +25,7 @@ function usePreferredOrientation(): Orientation { useEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; - const mq = window.matchMedia(query); + const mq = window.matchMedia(PORTRAIT_QUERY); const onChange = () => setOrientation(mq.matches ? "portrait" : "landscape"); onChange(); mq.addEventListener("change", onChange); @@ -33,11 +35,28 @@ function usePreferredOrientation(): Orientation { return orientation; } +// The pre-team-ready chrome (loading / signed-out / onboarding): same +// topbar shape as the full AppShell (T-002's theme switcher must work +// before an account exists), just without the sidebar there is nothing yet +// to navigate. +function MinimalShell({ children }: { children: ReactNode }) { + return ( +
+
+

+

+ +
+
{children}
+
+ ); +} + export default function App() { const [me, setMe] = useState(null); - const preferred = usePreferredOrientation(); - const [override, setOverride] = useState(null); - const orientation = override ?? preferred; + const orientation = usePreferredOrientation(); const refreshMe = useCallback(async () => { setMe(await fetchMe()); @@ -52,40 +71,38 @@ export default function App() { await refreshMe(); }, [refreshMe]); + if (me === null) { + return ( + +

Loading...

+
+ ); + } + + if (me.user === null) { + return ( + + + + ); + } + + if (me.memberships.length === 0) { + return ( + + + + ); + } + + const membership = me.memberships[0]; + + // The whiteboard is now an authenticated page (sign in to save; the PNGs + // assume a signed-in coach). Player recordings are still allowed + // (Brief section 3 table), just author-stamped with their own name. return ( -
-
-

Patterns of Play

- -
-
- {/* Temporary board mount until the whiteboard page proper lands in - T-030. Kept outside the auth gate so the board journeys stay - exercisable without an account, and above the auth flow so its - content swaps never shift the board's layout mid-gesture. */} -
-
- - Orientation: {orientation} -
- -
- {me === null &&

Loading...

} - {me !== null && me.user === null && } - {me !== null && me.user !== null && me.memberships.length === 0 && ( - - )} - {me !== null && me.user !== null && me.memberships.length > 0 && ( - - )} -
-
+ + + ); } diff --git a/frontend/src/AppShell.css b/frontend/src/AppShell.css new file mode 100644 index 0000000..21f6516 --- /dev/null +++ b/frontend/src/AppShell.css @@ -0,0 +1,156 @@ +/* Sidebar + nav + team meta (T-030). Base shell/topbar/brand/main chrome + lives in styles/global.css (shared with App.tsx's pre-auth branches); + this file only adds what the full shell needs on top of that. Colors are + theme tokens only. */ + +/* The lone gold dot beside the wordmark (PNG 01-05 topbar). */ +.app-brand-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px var(--glow); +} + +.app-topbar-end { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.app-body { + display: flex; + flex: 1; + min-height: 0; +} + +/* Desktop: full labelled sidebar (PNG 01-05). */ +.app-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 190px; + flex-shrink: 0; + padding: 14px 10px; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); +} + +.app-nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 14px; + cursor: pointer; + text-align: left; +} + +.app-nav-item:disabled { + cursor: default; + opacity: 0.55; +} + +/* Gold is the only interactive color: the active nav entry is the one place + in the rail that carries it. */ +.app-nav-item-active { + background: var(--accent); + color: var(--accent-ink, #1b1b1b); + opacity: 1; +} + +.app-page-heading { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 14px; + font-size: 20px; +} + +.app-page-info { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--surface); + color: var(--text-secondary); + font-size: 11px; +} + +/* Team identity cluster (no designed screen exists for it, Brief section 8; + kept compact so it never forces the topbar to overflow on phone). */ +.team-meta { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--body-font); +} +.team-meta-text { + display: flex; + flex-direction: column; + line-height: 1.3; +} +.team-meta-name { + margin: 0; + font-size: 13px; + font-family: var(--display-font); + font-weight: 500; +} +.team-meta-role, +.join-code { + margin: 0; + font-size: 11px; + color: var(--text-secondary); +} +.join-code strong { + color: var(--text-primary); + letter-spacing: 0.06em; +} +.team-meta-logout { + font: inherit; + font-size: 12px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--text-secondary); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; +} + +/* Phone: sidebar collapses to a 52px vertical icon rail (design README). */ +@media (max-width: 700px) { + .app-body { + flex-direction: row; + } + .app-sidebar { + width: 52px; + padding: 10px 4px; + align-items: center; + } + .app-nav-item { + justify-content: center; + padding: 9px; + width: 40px; + } + .app-nav-label { + display: none; + } + .app-main { + padding: 10px; + } + /* Team meta (name, role, join code, log out) stays visible on phone: the + T-003 platform DoD journey asserts on it in both viewports. The topbar + already wraps (flex-wrap), so this never forces horizontal overflow. */ + .app-topbar { + padding: 10px 12px; + } +} diff --git a/frontend/src/AppShell.tsx b/frontend/src/AppShell.tsx new file mode 100644 index 0000000..40d5be6 --- /dev/null +++ b/frontend/src/AppShell.tsx @@ -0,0 +1,125 @@ +// The app shell: topbar + sidebar nav (desktop PNGs) / 52px icon rail (phone, +// design README "Screens & interaction conventions"). T-030 is the first real +// page, so this is the minimal navigation the handoff shows: five entries, +// only Whiteboard live. The rest render present but inert (no onClick, no +// href) until their own tickets (T-031..T-034) land, per this ticket's brief: +// "do not invent surfaces beyond what the PNGs show." + +import type { ReactNode } from "react"; +import ThemeSwitcher from "./theme/ThemeSwitcher"; +import { TeamMeta } from "./TeamMeta"; +import type { MembershipOut, UserOut } from "./api"; +import "./AppShell.css"; + +function IconWhiteboard() { + return ( + + ); +} +function IconPatterns() { + return ( + + ); +} +function IconFormations() { + return ( + + ); +} +function IconRoster() { + return ( + + ); +} +function IconIdentity() { + return ( + + ); +} + +const NAV_ITEMS = [ + { key: "whiteboard", label: "Whiteboard", icon: }, + { key: "patterns", label: "Patterns", icon: }, + { key: "formations", label: "Formations", icon: }, + { key: "roster", label: "Roster", icon: }, + { key: "identity", label: "Identity", icon: }, +] as const; + +export type NavKey = (typeof NAV_ITEMS)[number]["key"]; + +export function AppShell({ + user, + membership, + active, + onLogout, + children, +}: { + user: UserOut; + membership: MembershipOut; + active: NavKey; + onLogout: () => void; + children: ReactNode; +}) { + return ( +
+
+

+

+
+ + +
+
+
+ +
{children}
+
+
+ ); +} diff --git a/frontend/src/TeamDashboard.tsx b/frontend/src/TeamDashboard.tsx deleted file mode 100644 index 7bd31df..0000000 --- a/frontend/src/TeamDashboard.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// Minimal token-styled landing screen after auth + team setup are done. -// Shows the join code only to the coach, per the demo path (Brief section -// 6: "coach... sees a join code") and the acceptance criteria in section 5. - -import { MembershipOut, UserOut } from "./api"; - -export function TeamDashboard({ - user, - memberships, - onLogout, -}: { - user: UserOut; - memberships: MembershipOut[]; - onLogout: () => void; -}) { - const membership = memberships[0]; - const isCoach = membership.role_on_team === "coach"; - - return ( -
-

{membership.team.name}

-

- Signed in as {user.display_name} ({membership.role_on_team}) -

- {isCoach && ( -

- Join code: {membership.team.join_code} -

- )} - -
- ); -} diff --git a/frontend/src/TeamMeta.tsx b/frontend/src/TeamMeta.tsx new file mode 100644 index 0000000..2303b6e --- /dev/null +++ b/frontend/src/TeamMeta.tsx @@ -0,0 +1,41 @@ +// Compact team identity cluster for the app shell topbar (T-030). Team +// creation/join has no designed screen (Brief section 8 gap); this remains +// the smallest functional surface for it (join code, role, logout), just +// relocated from its own landing page into the topbar now that the +// Whiteboard is the landing page. Testids/text kept stable on purpose: the +// T-003 platform DoD journey (e2e/auth-teams.spec.ts) asserts on this exact +// shape (a team-name heading, "(role)" text, ".join-code strong", a "Log +// out" button, and the join-code block ABSENT from the DOM for players). + +import { MembershipOut, UserOut } from "./api"; + +export function TeamMeta({ + user, + membership, + onLogout, +}: { + user: UserOut; + membership: MembershipOut; + onLogout: () => void; +}) { + const isCoach = membership.role_on_team === "coach"; + + return ( +
+
+

{membership.team.name}

+

+ {user.display_name} ({membership.role_on_team}) +

+ {isCoach && ( +

+ Join code: {membership.team.join_code} +

+ )} +
+ +
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 93dc24f..bb4d7bb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -46,7 +46,7 @@ export class ApiError extends Error { } } -async function request(path: string, options: RequestInit = {}): Promise { +export async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(`/api${path}`, { headers: { "Content-Type": "application/json" }, ...options, diff --git a/frontend/src/board/Board.css b/frontend/src/board/Board.css index 5dfa679..59fe2de 100644 --- a/frontend/src/board/Board.css +++ b/frontend/src/board/Board.css @@ -115,31 +115,26 @@ filter: drop-shadow(0 0 5px var(--red)); } -/* Threshold controls. Two independent sliders, stored per board (doc 03 4.3). */ .board-root { width: 100%; max-width: 1100px; margin: 0 auto; } -.lane-controls { +/* Threshold controls (T-030: folded into the view menu, doc 03 4.3's two + independent stored thresholds). */ +.lane-control { display: flex; - flex-wrap: wrap; - align-items: center; - gap: 16px; - margin-top: 12px; + flex-direction: column; + align-items: flex-start; + gap: 4px; font-family: var(--body-font); color: var(--text-secondary); - font-size: 13px; -} - -.lane-control { - display: inline-flex; - align-items: center; - gap: 8px; + font-size: 12px; } .lane-control input[type="range"] { + width: 100%; accent-color: var(--accent); } @@ -150,8 +145,11 @@ } .lane-hint { - margin: 0; - flex-basis: 100%; + margin: 4px 0 0; + max-width: 190px; + font-family: var(--body-font); + font-size: 11px; + color: var(--text-secondary); } /* ------------------------------------------------------------------------- @@ -274,48 +272,67 @@ } /* ------------------------------------------------------------------------- - Control clusters (dev board surface; the real toolbar is T-030). + Toolbar: a floating pill pinned to the bottom-center of the pitch (PNG + 01-04). Select is the permanent default tool (gold, always active); record + is status, never a call to action (surface fill, red only once active); + play/reset/view-menu are neutral until acted on. Gold is the only + interactive color (design README). ------------------------------------------------------------------------- */ -.board-view { +.board-toolbar-float { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); display: flex; - flex-wrap: wrap; align-items: center; - gap: 10px; - max-width: 1100px; - margin: 0 auto 12px; -} -.board-view button { - font: inherit; - font-size: 13px; - padding: 6px 14px; + gap: 6px; + padding: 6px; + background: var(--surface); border-radius: 999px; - border: 1px solid var(--accent); - background: var(--accent); - color: var(--accent-ink, #1b1b1b); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); +} +.tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border-radius: 50%; + border: none; + background: transparent; + color: var(--text-secondary); cursor: pointer; } -.board-view button:disabled { - opacity: 0.5; +.tool-btn:disabled { + opacity: 0.4; cursor: default; } -/* Record is status, not a call to action: red outline, surface fill. */ -.board-view .ctl-record { - background: var(--surface); - border-color: var(--red); - color: var(--red); +.tool-btn.tool-active { + background: var(--accent); + color: var(--accent-ink, #1b1b1b); } -.board-view .ctl-record-active { +/* Record is status, not a call to action: neutral until recording, then a + solid red dot (red is never interactive, only status, design README). */ +.tool-btn.tool-record-active { background: var(--red); color: var(--surface); } .view-menu { position: relative; + display: flex; } +/* Positioned against .board-wrap, not the toolbar's own small .view-menu + button (T-030 follow-up: it renders as board-wrap's child now, see + Board.tsx), so its footprint is independent of exactly where the toolbar + sits. Centered above the toolbar on desktop, where the pitch is wide + enough that this compact popover has no default token underneath it. */ .view-menu-panel { position: absolute; z-index: 5; - top: calc(100% + 6px); - left: 0; + bottom: 58px; + left: 50%; + transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; @@ -323,7 +340,54 @@ background: var(--surface); border: 1px solid var(--accent); border-radius: 10px; - min-width: 160px; + /* Fixed, bounded footprint: a range input's flex-item width:100% has no + intrinsic cap, so without an explicit width here the panel grows to fit + the sliders and can blanket most of the pitch on a phone-width board, + blocking drags/clicks on tokens underneath it (T-030). A small popover + that scrolls internally if it ever runs out of vertical room stays + usable at every viewport instead. */ + width: 220px; + max-width: calc(100vw - 32px); + max-height: min(340px, 60vh); + overflow-y: auto; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); +} +/* On phone-width boards the pitch is too narrow for a popover this size to + float above the toolbar without covering a large share of it (a phone + board is barely wider than the popover itself, and the default formation + plus every drag target this app's own journeys use fills the pitch top to + bottom), which would leave tokens, and even the toolbar's own buttons, + undraggable/unclickable under it. There is no sliver of the pitch or its + toolbar that is reliably clear on every phone: the toolbar sits at the + board's bottom edge, and the board itself commonly runs taller than the + viewport, so anchoring the popover to any edge of the board or the + viewport's own bottom collides with one or the other. Docking it to the + viewport's TOP instead (fixed, independent of the board's box entirely) + puts it above the pitch, in the app header's own area, which nothing in + this menu's own journeys touches while it is open. */ +@media (max-width: 700px) { + .view-menu-panel { + position: fixed; + top: 8px; + left: 12px; + right: 12px; + bottom: auto; + transform: none; + width: auto; + max-width: none; + max-height: 170px; + } +} +.view-menu-heading { + margin: 4px 0 0; + font-family: var(--display-font, sans-serif); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); +} +.view-menu-heading:first-child { + margin-top: 0; } .view-menu-item { display: flex; @@ -338,10 +402,39 @@ accent-color: var(--accent); } +/* Restart: a small secondary control shown only once a playback exists, not + part of the persistent 5-icon PNG toolbar. */ +.restart-btn { + position: absolute; + bottom: 14px; + right: 14px; + font: inherit; + font-size: 12px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--accent); + background: var(--surface); + color: var(--accent); + cursor: pointer; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Save flow (PNG 04): full-width banner above the pitch. */ .save-bar { display: flex; flex-wrap: wrap; - align-items: flex-end; + align-items: center; gap: 12px; max-width: 1100px; margin: 0 auto 12px; @@ -350,7 +443,15 @@ border: 1px solid var(--accent); border-radius: 10px; } +.save-bar-label { + font-family: var(--body-font); + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; +} .save-name { + flex: 1; + min-width: 160px; display: flex; flex-direction: column; gap: 4px; @@ -366,6 +467,7 @@ border: 1px solid var(--accent); background: var(--bg); color: var(--text-primary); + width: 100%; } .save-bar button { font: inherit; @@ -377,11 +479,25 @@ color: var(--accent-ink, #1b1b1b); cursor: pointer; } -.save-bar .ctl-ghost { +.save-bar button:disabled { + opacity: 0.6; + cursor: default; +} +/* Reusable neutral button: never red (red is status only, never a call to + action, design README), used for Discard and for Delete on saved patterns. */ +.save-bar .ctl-ghost, +.saved-pattern .ctl-ghost { background: transparent; color: var(--text-secondary); border-color: var(--text-secondary); } +.save-error { + flex-basis: 100%; + margin: 0; + font-family: var(--body-font); + font-size: 12px; + color: var(--text-red, var(--red)); +} .saved-patterns { max-width: 1100px; diff --git a/frontend/src/board/Board.tsx b/frontend/src/board/Board.tsx index 8494716..68cac13 100644 --- a/frontend/src/board/Board.tsx +++ b/frontend/src/board/Board.tsx @@ -1,5 +1,7 @@ // Pitch canvas + token rendering + drag pipeline (T-020) + lane graph and -// marking rings (T-021) + zone overlays, animation player, and recorder (T-022). +// marking rings (T-021) + zone overlays, animation player, and recorder (T-022) +// + the whiteboard page chrome: toolbar, view menu, record/save flow into My +// Patterns (T-030, PNG 01-05, 14, 34). // // Rendering choice: SVG behind a component boundary. The pitch is a static // backdrop; each token is a transformed in viewBox user units. Orientation @@ -20,9 +22,13 @@ // FrameLoop, so lanes and rings stay live during playback (T-021 handoff 1). // It abstracts over declarative specs AND raw recordings via one Playback. // - The recorder (recorder.ts) captures every drag on every token as -// timestamped model-space keyframes; saved recordings live in state (no -// backend, no localStorage) shaped as doc 03 4.2 and replay through the -// player immediately. +// timestamped model-space keyframes. +// +// T-030 owns persistence: this component takes an initial snapshot and reports +// every change upward (onSnapshotChange) so the page can PUT it to the boards +// row (doc 03 4.3); My Patterns is now server data (savedPatterns prop), +// author-stamped by the API, with Delete rendered coach-only (README roles +// table) and enforced again server-side regardless of what the UI hides. import { useCallback, useLayoutEffect, useMemo, useRef, useState, useEffect } from "react"; import { @@ -52,7 +58,10 @@ import { PlayerController } from "./player"; import { Recorder } from "./recorder"; import { buildDeclarativePlayback, buildKeyframePlayback, type Playback } from "./playback"; import { DEMO_BINDING, DEMO_SPEC } from "./demoSpec"; -import type { BoardSnapshot, Keyframe, RecordedPattern } from "./animationTypes"; +import { fromWireSnapshot } from "./wire"; +import type { BoardSnapshot, Keyframe } from "./animationTypes"; +import type { Role } from "../api"; +import type { SavedPatternOutWire } from "../whiteboardApi"; import "./Board.css"; // Fixed viewBox per orientation. Aspect matches PITCH_ASPECT so the SVG scales @@ -88,24 +97,106 @@ function snapshotToTokens(snap: BoardSnapshot): Token[] { })); } +// ---- Minimal inline toolbar icons (currentColor only; no hardcoded hex). ---- +function IconSelect() { + return ( + + ); +} +function IconRecord() { + return ( + + ); +} +function IconPlay() { + return ( + + ); +} +function IconReset() { + return ( + + ); +} +function IconMenu() { + return ( + + ); +} + interface BoardProps { orientation: Orientation; - initialTokens?: Token[]; + role: Role; + /** Hydrates the board from the boards row (doc 03 4.3). Omit for a fresh, + * never-saved board, which starts from the default 4-3-3 shape. */ + initialSnapshot?: BoardSnapshot; + /** Fired after every committed change (drag end, lane confirm, threshold + * or zone toggle) with the full current snapshot. NOT called for the + * initial hydration render. The page debounces this into a PUT. */ + onSnapshotChange?: (snapshot: BoardSnapshot) => void; + /** My Patterns, server-owned (author-stamped, team-scoped). */ + savedPatterns: SavedPatternOutWire[]; + onSavePattern: (name: string, snapshot: BoardSnapshot, keyframes: Keyframe[]) => Promise; + /** Absent (not just disabled) for players: the delete control never + * renders for a non-coach role (README roles table), and the API 403s a + * player's attempt independently of this. */ + onDeletePattern?: (id: number) => Promise; } -export default function Board({ orientation, initialTokens }: BoardProps) { - const [tokens, setTokens] = useState(() => initialTokens ?? defaultBoardTokens()); +export default function Board({ + orientation, + role, + initialSnapshot, + onSnapshotChange, + savedPatterns, + onSavePattern, + onDeletePattern, +}: BoardProps) { + const [tokens, setTokens] = useState(() => + initialSnapshot ? snapshotToTokens(initialSnapshot) : defaultBoardTokens() + ); const [activeId, setActiveId] = useState(null); - // Whiteboard lane state, shaped to doc 03 4.3 (boards row). No backend yet - // (T-004 / T-030) and no localStorage (Brief section 7): it lives here until - // persistence lands, then maps straight onto the row. - const [laneState, setLaneState] = useState(defaultLaneState); + // Whiteboard lane state, shaped to doc 03 4.3 (boards row): hydrated from + // the boards row when one exists, otherwise the engine defaults. + const [laneState, setLaneState] = useState(() => + initialSnapshot + ? { + confirmedLanes: initialSnapshot.confirmed_lanes, + blockingThreshold: initialSnapshot.blocking_threshold, + markingThreshold: initialSnapshot.marking_threshold, + } + : defaultLaneState() + ); // First token of an in-progress "click two players" confirm gesture. const [pairingId, setPairingId] = useState(null); // Zone visibility (doc 03 4.3 zones_visible_json). Independent of lanes. - const [zonesVisible, setZonesVisible] = useState>(() => new Set()); + const [zonesVisible, setZonesVisible] = useState>( + () => new Set((initialSnapshot?.zones_visible ?? []) as ZoneGroup[]) + ); const [viewMenuOpen, setViewMenuOpen] = useState(false); // Player + recorder UI state. None of these mutate per frame (that would @@ -115,7 +206,9 @@ export default function Board({ orientation, initialTokens }: BoardProps) { const [recording, setRecording] = useState(false); const [pendingKeyframes, setPendingKeyframes] = useState(null); const [recordName, setRecordName] = useState(""); - const [savedPatterns, setSavedPatterns] = useState([]); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [deletingId, setDeletingId] = useState(null); // Bumped to (re)start a playback after tokens have committed to their start. const [playToken, setPlayToken] = useState(0); @@ -222,6 +315,41 @@ export default function Board({ orientation, initialTokens }: BoardProps) { } }, []); + // ---- Persistence: report the committed snapshot upward (T-030) ----------- + + const buildSnapshot = useCallback( + (): BoardSnapshot => ({ + tokens: tokens.map((t) => ({ + id: t.id, + side: t.side, + label: t.label, + x: t.pos.x, + y: t.pos.y, + })), + confirmed_lanes: laneState.confirmedLanes, + blocking_threshold: laneState.blockingThreshold, + marking_threshold: laneState.markingThreshold, + zones_visible: [...zonesVisible], + }), + [tokens, laneState, zonesVisible] + ); + + // Skip the very first run: that is the hydration render itself, already in + // sync with the server (or the untouched default), not a change to persist. + const hydratedRef = useRef(false); + useEffect(() => { + if (!hydratedRef.current) { + hydratedRef.current = true; + return; + } + onSnapshotChange?.(buildSnapshot()); + // buildSnapshot/onSnapshotChange intentionally excluded: buildSnapshot is + // a fresh function identity on every one of these same state changes (it + // closes over them), and onSnapshotChange is a stable useCallback from + // the page. Depending on tokens/laneState/zonesVisible alone is correct + // and avoids re-running this effect for reasons other than a real change. + }, [tokens, laneState, zonesVisible]); + // ---- Animation player wiring ---------------------------------------------- // Called every frame by the player with a sampled frame in model coords. Reads @@ -311,38 +439,35 @@ export default function Board({ orientation, initialTokens }: BoardProps) { }, [beginPlayback]); const replaySaved = useCallback( - (pattern: RecordedPattern) => { + (pattern: SavedPatternOutWire) => { if (playing || recording) return; - const pb = buildKeyframePlayback(pattern.keyframes, pattern.board_snapshot); - beginPlayback(pb, snapshotToTokens(pattern.board_snapshot)); + const snapshot = fromWireSnapshot(pattern.board_snapshot); + const pb = buildKeyframePlayback(pattern.keyframes, snapshot); + beginPlayback(pb, snapshotToTokens(snapshot)); }, [beginPlayback, playing, recording] ); // ---- Recorder wiring ------------------------------------------------------ - const buildSnapshot = useCallback( - (): BoardSnapshot => ({ - tokens: tokens.map((t) => ({ - id: t.id, - side: t.side, - label: t.label, - x: t.pos.x, - y: t.pos.y, - })), - confirmed_lanes: laneState.confirmedLanes, - blocking_threshold: laneState.blockingThreshold, - marking_threshold: laneState.markingThreshold, - zones_visible: [...zonesVisible], - }), - [tokens, laneState, zonesVisible] - ); - const startRecording = useCallback(() => { if (playing) return; + // Close the view menu before capturing anything: its panel floats above + // the toolbar at bottom-center and can overlap tokens near the middle of + // the board (e.g. the ball's default spot). Left open during a + // recording drag, a drag that starts or ends under the panel lands on a + // menu control instead of a token, and the browser fires a genuine + // click on that control (a checkbox's