diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0b08040
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,22 @@
+# Keep the build context small and deterministic: everything the image needs
+# is copied explicitly in the Dockerfile.
+.git
+.github
+.venv
+node_modules
+frontend/node_modules
+frontend/dist
+docs
+e2e
+playwright-report
+test-results
+backend/tests
+**/__pycache__
+**/*.pyc
+.pytest_cache
+.mypy_cache
+.ruff_cache
+*.db
+*.db-wal
+*.db-shm
+.DS_Store
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0dd3f9e..d52714c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,8 @@ jobs:
run: make bootstrap
- name: Copy checks (em-dash scan + seed validator)
run: make check-copy
+ - name: Permission suite (Brief section 3, every row, no skips)
+ run: make permissions
- name: Lint + typecheck
run: make lint typecheck
- name: Unit + integration
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ac42c4c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,64 @@
+# Deployment image (T-060): one container, one origin, SPA plus API.
+#
+# Stage 1 builds the SPA with the exact lockfile the repo pins. Stage 2 is the
+# runtime: Python, the backend package, the seed data, and the built SPA. Node
+# does not ship in the final image.
+
+# ---------------------------------------------------------------------------
+# Stage 1: build the SPA
+# ---------------------------------------------------------------------------
+FROM node:22-slim AS web
+
+WORKDIR /build
+
+# Workspace manifests first, so a dependency-only change is the only thing
+# that busts the npm layer cache.
+COPY package.json package-lock.json ./
+COPY frontend/package.json ./frontend/
+RUN npm ci --no-audit --no-fund
+
+COPY frontend/ ./frontend/
+# `npm run build` is `tsc --noEmit && vite build`: the deployed bundle is
+# built by the same command that has to typecheck clean, so a type error
+# fails the image rather than shipping.
+RUN npm --workspace frontend run build
+
+# ---------------------------------------------------------------------------
+# Stage 2: runtime
+# ---------------------------------------------------------------------------
+FROM python:3.13-slim AS runtime
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1
+
+WORKDIR /app
+
+# The backend package (no dev extras: no pytest, ruff or mypy in the image).
+COPY backend/pyproject.toml ./backend/pyproject.toml
+COPY backend/app ./backend/app
+RUN pip install --no-cache-dir ./backend
+
+# Everything the boot sequence needs: the migration chain, the seed loaders,
+# and the seed files themselves.
+COPY backend/alembic.ini ./backend/alembic.ini
+COPY backend/migrations ./backend/migrations
+COPY scripts ./scripts
+COPY seeds ./seeds
+
+COPY --from=web /build/frontend/dist ./frontend/dist
+
+# Where the SQLite file lives. Overridden by DATABASE_URL in the service
+# config; the default points at the mount path a persistent disk would use,
+# so attaching one later needs no image change.
+ENV DATABASE_URL=sqlite:////data/pop.db \
+ POP_FRONTEND_DIST=/app/frontend/dist \
+ COOKIE_SECURE=true \
+ PORT=8000
+
+EXPOSE 8000
+
+# alembic.ini's script_location is relative to the process working directory,
+# and app/db.py resolves a relative sqlite path the same way, so the entry
+# point runs from /app exactly as scripts/dev.sh runs from the repo root.
+CMD ["bash", "scripts/start.sh"]
diff --git a/Makefile b/Makefile
index 42fb58d..164099b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: bootstrap dev migrate lint typecheck test e2e verify seed check-copy
+.PHONY: bootstrap dev migrate lint typecheck test e2e verify seed seed-demo demo check-copy permissions screenshots
VENV := .venv
PY := $(VENV)/bin/python
@@ -34,8 +34,48 @@ check-copy:
$(PY) scripts/check_copy.py
$(PY) scripts/validate_seeds.py
+# The Brief section 3 permission table, every row (backend/tests/test_permissions.py).
+# `make test` already runs it, but this target also fails when a row is
+# SKIPPED rather than asserted: two rows sat as @pytest.mark.skip
+# placeholders for most of the build, and a green suite that quietly stops
+# checking a permission row is exactly the failure mode worth pinning.
+permissions:
+ @out=$$($(VENV)/bin/pytest backend/tests/test_permissions.py -q --no-header 2>&1); \
+ status=$$?; \
+ echo "$$out"; \
+ if [ $$status -ne 0 ]; then exit $$status; fi; \
+ if echo "$$out" | grep -q "skipped"; then \
+ echo "permissions: FAILED, a Brief section 3 row is skipped, not enforced"; \
+ exit 1; \
+ fi; \
+ echo "permissions: every Brief section 3 row enforced, none skipped"
+
seed:
$(PY) scripts/seed.py
-verify: check-copy lint typecheck test e2e
+seed-demo:
+ $(PY) scripts/seed_demo.py
+
+# One command before a meeting: throw the dev database away, rebuild it
+# from the migration chain, load the library content, then populate one
+# realistic team (roster with a live fit warning, a recorded pattern, a
+# sent session with a receipt). Prints the demo credentials at the end.
+# The rm is why this is a separate target from `seed`: `make dev` must
+# never destroy data, and this always starts from zero.
+demo:
+ rm -f dev.db dev.db-wal dev.db-shm
+ $(PY) -m alembic -c backend/alembic.ini upgrade head
+ $(PY) scripts/seed.py
+ $(PY) scripts/seed_demo.py
+
+# Marketing shots for docs/screenshots/ and the README. Reseeds the demo
+# database first so the captures always show the same content, then drives
+# the real UI (e2e/screenshots.spec.ts, which skips unless POP_SCREENSHOTS
+# is set, so `make verify` never rewrites the images). Stop any running
+# `make dev` first: this drops and rebuilds the database underneath it.
+screenshots: demo
+ POP_SCREENSHOTS=1 npx playwright test e2e/screenshots.spec.ts \
+ --project=desktop --timeout=60000 --global-timeout=300000
+
+verify: check-copy permissions lint typecheck test e2e
@echo "verify: all green"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9c7365d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,133 @@
+# Patterns of Play
+
+**The tactics board your players actually watch.**
+
+Draw a pattern on a live board that shows you which passes are on and which are
+covered, record it the way you would draw it on a whiteboard, and send it to the
+squad's phones. You see who watched it.
+
+
+
+## Why a coach cares
+
+- **The board tells you what the defence has taken away.** Every pass your player
+ can make is drawn as you move people. Gold means it is on, red means someone is
+ standing in it, and the red dot is exactly where it gets intercepted.
+- **Twelve patterns, six shapes, and the rondos that train them, already in it.**
+ Overlaps, third-man runs, build-out against a press, pressing triggers. Each one
+ plays out on the pitch with the ball, not as a static diagram.
+- **Your session lands on their phone, not in a group chat.** Bundle two patterns
+ and a note, send it, and see "3 of 5 watched" before you get to training.
+- **It tells you when a pairing will cost you.** Put a flying fullback behind a
+ winger who does not track back and it says so, on that flank, by name.
+
+## The walkthrough
+
+**1. Move a player, and the passing picture redraws.** Solid gold is a lane the
+coach has locked in; dashed red with a dot is a lane an opponent has taken away.
+
+
+
+**2. Hit record and coach it the way you would on a whiteboard.** Every player,
+every opponent, and the ball are captured, and the ball leaves a gold trace.
+
+
+
+**3. Pull up the library.** Twelve pattern archetypes, eight delivery types, three
+whole-team rotations, filtered by what you want to work on.
+
+
+
+**4. Play one on the board.** The ball chases the runner it was played to, so the
+pass connects the way it does on grass.
+
+
+
+**5. Load a shape and tap the players it hinges on.** The 4-3-3 with its pivot
+keystone, and what that role has to be able to do.
+
+
+
+**6. Turn on the rondo map.** Each zone tells you which rondo belongs there and
+which pattern it trains.
+
+
+
+**7. Show them who already plays this way.** A reference team's signature idea
+runs on the board, with the five-part card behind it.
+
+
+
+**8. Build the squad, and get told when a flank is exposed.** Roles, work rates,
+six coach-rated sliders, and the fit warning that reads the pairing.
+
+
+
+**9. Send the session and watch the receipts come in.** A pattern from the
+library, your own recording, a note, and per-player read receipts.
+
+
+
+**10. On their phone, the board goes portrait.** Same pattern, same coordinates,
+readable in a hand.
+
+
+
+## Quickstart
+
+```bash
+make bootstrap # Python venv + npm install, once
+make demo # rebuild the database and load a full demo team
+make dev # http://127.0.0.1:5173
+```
+
+`make demo` drops the dev database, runs the migration chain from zero, loads the
+tactical content, then creates one realistic team: 14 players with roles and
+sliders, a live whiteboard, a recorded pattern, and two sessions (one sent with
+receipts, one draft ready to send in front of the room). Rerun it any time to get
+back to a clean starting state.
+
+Sign in at :
+
+| | Email | Password |
+|---|---|---|
+| Coach | `coach@example.com` | `demo-pass-2026` |
+| Player | `player@example.com` | `demo-pass-2026` |
+
+Join codes for the demo team are `TEAM24` (joins as a player) and `STAFF7` (joins
+as a coach). The code decides the role, not the account.
+
+Other targets: `make verify` (copy scan, permission suite, lint, typecheck, unit
+and integration tests, and the Playwright journeys on both viewports),
+`make screenshots` (rebuilds the demo database and recaptures `docs/screenshots/`).
+
+## Stack and architecture
+
+React 19 + Vite + TypeScript on the front, FastAPI + SQLAlchemy 2 + Alembic over
+SQLite (WAL) on the back. No ORM-free corners, no client state store: the server
+is the source of truth and every screen re-reads from it.
+
+- **Board engine** (`frontend/src/board/`). SVG behind a component boundary.
+ Pointer input is coalesced to one update per animation frame and written
+ straight to the DOM, so a drag never re-renders all 23 tokens. The lane graph,
+ marking rings, zone overlays, animation player, and recorder all share one
+ coordinate and timing model.
+- **Coordinates.** Every position is stored in landscape model coordinates (x 0
+ to 100 toward the attacking goal, y 0 to 100 top to bottom). Orientation is a
+ render concern only: portrait maps `left = y, top = 100 - x`, with the inverse
+ applied to drag input, so a pattern recorded on a laptop replays correctly on a
+ phone and the round trip is covered by a test.
+- **Two animation formats, one player.** Library presets are declarative specs
+ (player from-to plus ball waypoints bound to the player who starts or finishes
+ at that spot); recordings are raw keyframes. The player abstracts over both.
+- **Tenancy.** Every team-scoped query goes through one scoped query layer
+ (`backend/app/scoped.py`) built from the caller's own membership. No route
+ handler filters by `team_id`, and no request body can supply one.
+- **Permissions are API-enforced, not UI-hidden.** Coach-only data (fit warnings,
+ read receipts, join codes) is absent from a player's payload rather than nulled,
+ via split response models. `backend/tests/test_permissions.py` asserts every row
+ of the permission table, and `make permissions` fails if any row is skipped
+ rather than checked.
+- **Content is data.** Patterns, deliveries, rotations, formations, keystones, the
+ rondo map, and the identity library live in `seeds/*.json` with a validator, so
+ the tactical content can be revised without an engineer.
diff --git a/backend/app/main.py b/backend/app/main.py
index eea033b..aad402e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,21 @@
-from fastapi import FastAPI
+import os
+import pathlib
-from app.routers import auth, formations, identity, library, roster, suggestions, teams, whiteboard
+from fastapi import FastAPI, HTTPException, status
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+from app.routers import (
+ auth,
+ formations,
+ identity,
+ library,
+ roster,
+ sessions,
+ suggestions,
+ teams,
+ whiteboard,
+)
app = FastAPI(title="Patterns of Play API")
app.include_router(auth.router)
@@ -11,8 +26,65 @@
app.include_router(suggestions.router)
app.include_router(formations.router)
app.include_router(identity.router)
+app.include_router(sessions.router)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
+
+
+# ---------------------------------------------------------------------------
+# Single-origin serving for deployment (T-060).
+#
+# In development, Vite serves the SPA on its own port and proxies /api to this
+# process (frontend/vite.config.ts). In a deployed environment there is one
+# process and one origin: this app serves the built SPA alongside the API.
+#
+# One origin is not just tidiness, it is what makes the session cookie work as
+# written. The cookie is SameSite=Lax and host-only (app/routers/auth.py), and
+# frontend/src/api.ts fetches relative "/api/..." paths with no CORS setup and
+# no credentials mode. Splitting the SPA onto a second origin would mean a
+# cross-site cookie, SameSite=None, an allow-list, and a credentials flag on
+# every fetch. Serving both from here means none of that exists.
+#
+# The whole block is conditional on the build output actually being present,
+# so a dev checkout and the test suite (which never build the SPA) behave
+# exactly as they did before: API only, no catch-all route registered.
+# ---------------------------------------------------------------------------
+
+_DIST_OVERRIDE = os.environ.get("POP_FRONTEND_DIST")
+FRONTEND_DIST = (
+ pathlib.Path(_DIST_OVERRIDE)
+ if _DIST_OVERRIDE
+ else pathlib.Path(__file__).resolve().parents[2] / "frontend" / "dist"
+)
+
+if FRONTEND_DIST.is_dir():
+ _DIST_ROOT = FRONTEND_DIST.resolve()
+ _ASSETS = _DIST_ROOT / "assets"
+ if _ASSETS.is_dir():
+ # Vite emits content-hashed filenames under /assets, so these are the
+ # one set of files safe to serve as immutable static content.
+ app.mount("/assets", StaticFiles(directory=_ASSETS), name="assets")
+
+ @app.get("/{full_path:path}", include_in_schema=False)
+ def serve_spa(full_path: str) -> FileResponse:
+ """SPA fallback: a real file if one matches, index.html otherwise.
+
+ Registered LAST so every API route above wins on the same path, and
+ /api/* is refused explicitly rather than falling through: an unknown
+ API path must stay a JSON 404, not silently return the HTML shell,
+ which would turn a typo'd endpoint into a confusing parse error on
+ the client instead of an obvious 404.
+ """
+ if full_path.startswith("api/"):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
+ if full_path:
+ candidate = (_DIST_ROOT / full_path).resolve()
+ # is_relative_to pins the lookup inside the build output: a
+ # traversal like "../../etc/passwd" resolves outside it and falls
+ # through to index.html instead of being served.
+ if candidate.is_relative_to(_DIST_ROOT) and candidate.is_file():
+ return FileResponse(candidate)
+ return FileResponse(_DIST_ROOT / "index.html")
diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py
new file mode 100644
index 0000000..27014e0
--- /dev/null
+++ b/backend/app/routers/sessions.py
@@ -0,0 +1,530 @@
+"""Sessions: the classroom loop (doc 03 section 6; Brief step 23, PNG 21-23,
+26, 28; T-042).
+
+Design README: "bundle patterns and recorded whiteboard tactics into a
+session, attach a coach note, and send to players. Draft state:
+reorder/remove items, + Add from library opens a picker (presets and My
+patterns, each with its mini-board thumbnail), players listed as Will
+receive. Sent state: gold SENT pill with an x/y viewed counter, and
+per-player read receipts (Viewed / Not yet). Receipts are coach-only,
+consistent with fit warnings, players never see each other's status."
+
+Enforcement (CLAUDE.md rules 4 and 5, Brief section 3):
+ - Every route resolves team_id through get_team_scope, never a client
+ field. session_items and session_receipts carry no team_id of their
+ own (doc 03 section 6) and are read through TeamScope.query_via,
+ joined to their parent session's team_id.
+ - Create, edit, reorder, remove, and send are coach-only
+ (require_role_on_team("coach")); Mark as watched is player-only.
+ - The coach/player payload split is the RosterOut/CoachRosterOut
+ pattern: response_model=None plus a manual model_dump so a player's
+ JSON has NO receipt key at all (not a null or empty one), and a
+ coach's has no `you_watched` key.
+
+Recipient set: receipts are written for every player-role member at SEND
+time (doc 03 section 6: "receipts exist for every recipient at send time
+with viewed_at null"), and a player's own session list is gated on having
+one of those receipt rows. A session is therefore a record of what the
+team was told when it was sent: someone who joins afterwards does not
+silently appear in an already-sent session's denominator, and the coach's
+x/y counter never moves on its own.
+"""
+
+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 (
+ LibraryItem,
+ Player,
+ SavedPattern,
+ SessionItem,
+ SessionReceipt,
+ TeamMember,
+ TrainingSession,
+ User,
+)
+from app.models._util import utcnow
+from app.routers.whiteboard import pattern_to_out
+from app.schemas import (
+ CoachSessionOut,
+ LibraryItemOut,
+ PlayerSessionOut,
+ SessionCreateRequest,
+ SessionItemCreateRequest,
+ SessionItemMoveRequest,
+ SessionItemOut,
+ SessionReceiptOut,
+ SessionUpdateRequest,
+)
+from app.scoped import TeamScope, get_team_scope
+
+router = APIRouter(prefix="/api/sessions", tags=["sessions"])
+
+
+# ---------------------------------------------------------------------------
+# Reading: item hydration and the two payload shapes.
+# ---------------------------------------------------------------------------
+
+
+def _items_for(scope: TeamScope, db: Session, session_id: int) -> list[SessionItemOut]:
+ """Every attached item with its FULL referenced content embedded, so
+ one round trip gives the coach's picker thumbnails and the player's
+ Watch deep-link everything they need to render a board."""
+ rows = (
+ scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id)
+ .filter(SessionItem.session_id == session_id)
+ .order_by(SessionItem.position.asc())
+ .all()
+ )
+ library_ids = [r.library_item_id for r in rows if r.library_item_id is not None]
+ library_by_id = {
+ item.id: LibraryItemOut.model_validate(item)
+ for item in (
+ db.query(LibraryItem).filter(LibraryItem.id.in_(library_ids)).all()
+ if library_ids
+ else []
+ )
+ }
+
+ # Saved patterns are TEAM data, so they resolve through the scope, not
+ # a raw db query: an item pointing at another team's row (impossible to
+ # create through this router, but the FK alone would not stop it)
+ # simply resolves to nothing rather than leaking across teams.
+ pattern_ids = [r.saved_pattern_id for r in rows if r.saved_pattern_id is not None]
+ pattern_rows = (
+ scope.query(SavedPattern).filter(SavedPattern.id.in_(pattern_ids)).all()
+ if pattern_ids
+ else []
+ )
+ authors = {
+ u.id: u
+ for u in db.query(User).filter(User.id.in_([p.author_user_id for p in pattern_rows])).all()
+ }
+ pattern_by_id = {
+ p.id: pattern_to_out(p, authors.get(p.author_user_id)) for p in pattern_rows
+ }
+
+ return [
+ SessionItemOut(
+ id=row.id,
+ position=row.position,
+ item_kind=row.item_kind, # type: ignore[arg-type]
+ library_item=library_by_id.get(row.library_item_id)
+ if row.library_item_id is not None
+ else None,
+ saved_pattern=pattern_by_id.get(row.saved_pattern_id)
+ if row.saved_pattern_id is not None
+ else None,
+ )
+ for row in rows
+ ]
+
+
+def _jersey_by_user(scope: TeamScope) -> dict[int, int | None]:
+ """Claimed roster rows only (app/routers/roster.py's name-match
+ claim): PNG 21 renders a numbered gold badge per recipient, and an
+ unclaimed member simply has no number to show."""
+ return {
+ p.user_id: p.jersey_number
+ for p in scope.query(Player).filter(Player.user_id.isnot(None)).all()
+ if p.user_id is not None
+ }
+
+
+def _player_members(scope: TeamScope, db: Session) -> list[tuple[int, str]]:
+ """(user_id, display_name) for every player-role member, oldest first."""
+ members = (
+ scope.query(TeamMember)
+ .filter(TeamMember.role_on_team == "player")
+ .order_by(TeamMember.joined_at.asc())
+ .all()
+ )
+ users = {
+ u.id: u for u in db.query(User).filter(User.id.in_([m.user_id for m in members])).all()
+ }
+ return [(m.user_id, users[m.user_id].display_name) for m in members if m.user_id in users]
+
+
+def _receipts_for(
+ scope: TeamScope, db: Session, session: TrainingSession
+) -> list[SessionReceiptOut]:
+ """Coach-only. A SENT session reads its real receipt rows; a DRAFT has
+ none yet, so it lists the team's current player members as the design
+ README's "Will receive" preview of who the send would reach."""
+ jerseys = _jersey_by_user(scope)
+
+ if session.status != "sent":
+ return [
+ SessionReceiptOut(
+ player_user_id=user_id,
+ display_name=display_name,
+ jersey_number=jerseys.get(user_id),
+ viewed_at=None,
+ viewed=False,
+ )
+ for user_id, display_name in _player_members(scope, db)
+ ]
+
+ rows = (
+ scope.query_via(
+ SessionReceipt, TrainingSession, SessionReceipt.session_id == TrainingSession.id
+ )
+ .filter(SessionReceipt.session_id == session.id)
+ .all()
+ )
+ users = {
+ u.id: u
+ for u in db.query(User).filter(User.id.in_([r.player_user_id for r in rows])).all()
+ }
+ out = [
+ SessionReceiptOut(
+ player_user_id=r.player_user_id,
+ display_name=users[r.player_user_id].display_name
+ if r.player_user_id in users
+ else "Player",
+ jersey_number=jerseys.get(r.player_user_id),
+ viewed_at=r.viewed_at,
+ viewed=r.viewed_at is not None,
+ )
+ for r in rows
+ ]
+ out.sort(key=lambda r: ((r.jersey_number is None), r.jersey_number or 0, r.display_name))
+ return out
+
+
+def _coach_payload(scope: TeamScope, db: Session, session: TrainingSession) -> dict:
+ receipts = _receipts_for(scope, db, session)
+ return CoachSessionOut(
+ id=session.id,
+ title=session.title,
+ coach_note=session.coach_note,
+ status=session.status, # type: ignore[arg-type]
+ sent_at=session.sent_at,
+ created_at=session.created_at,
+ items=_items_for(scope, db, session.id),
+ receipts=receipts,
+ viewed_count=sum(1 for r in receipts if r.viewed),
+ recipient_count=len(receipts),
+ ).model_dump(mode="json")
+
+
+def _player_payload(
+ scope: TeamScope, db: Session, session: TrainingSession, receipt: SessionReceipt
+) -> dict:
+ # PlayerSessionOut has no receipts/viewed_count/recipient_count field
+ # at all, so those keys are absent from this body rather than nulled.
+ return PlayerSessionOut(
+ id=session.id,
+ title=session.title,
+ coach_note=session.coach_note,
+ status=session.status, # type: ignore[arg-type]
+ sent_at=session.sent_at,
+ created_at=session.created_at,
+ items=_items_for(scope, db, session.id),
+ you_watched=receipt.viewed_at is not None,
+ ).model_dump(mode="json")
+
+
+def _own_receipt(scope: TeamScope, session_id: int, user_id: int) -> SessionReceipt | None:
+ return (
+ scope.query_via(
+ SessionReceipt, TrainingSession, SessionReceipt.session_id == TrainingSession.id
+ )
+ .filter(
+ SessionReceipt.session_id == session_id,
+ SessionReceipt.player_user_id == user_id,
+ )
+ .first()
+ )
+
+
+def _session_or_404(scope: TeamScope, session_id: int) -> TrainingSession:
+ session = scope.get(TrainingSession, session_id)
+ if session is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
+ return session
+
+
+def _draft_or_409(session: TrainingSession) -> TrainingSession:
+ if session.status != "draft":
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="A sent session can no longer be edited",
+ )
+ return session
+
+
+# ---------------------------------------------------------------------------
+# Both roles: list and read. The role decides both WHICH sessions are
+# visible and WHICH payload shape comes back.
+# ---------------------------------------------------------------------------
+
+
+@router.get("", response_model=None)
+def list_sessions(
+ ctx: CurrentMembership = Depends(get_current_membership),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> list[dict]:
+ if ctx.role_on_team == "coach":
+ sessions = (
+ scope.query(TrainingSession)
+ .order_by(TrainingSession.created_at.desc())
+ .all()
+ )
+ return [_coach_payload(scope, db, s) for s in sessions]
+
+ # Player: sent sessions they were an actual recipient of (README:
+ # "Sees sent sessions only"), newest send first.
+ sessions = (
+ scope.query(TrainingSession)
+ .filter(TrainingSession.status == "sent")
+ .order_by(TrainingSession.sent_at.desc())
+ .all()
+ )
+ out: list[dict] = []
+ for session in sessions:
+ receipt = _own_receipt(scope, session.id, ctx.user.id)
+ if receipt is None:
+ continue
+ out.append(_player_payload(scope, db, session, receipt))
+ return out
+
+
+@router.get("/{session_id}", response_model=None)
+def get_session(
+ session_id: int,
+ ctx: CurrentMembership = Depends(get_current_membership),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = _session_or_404(scope, session_id)
+ if ctx.role_on_team == "coach":
+ return _coach_payload(scope, db, session)
+
+ receipt = _own_receipt(scope, session.id, ctx.user.id)
+ if session.status != "sent" or receipt is None:
+ # A draft does not exist as far as a player is concerned, and
+ # neither does a sent session they were not a recipient of: 404,
+ # not 403, so the response says nothing about what else the team
+ # has in flight.
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
+ return _player_payload(scope, db, session, receipt)
+
+
+# ---------------------------------------------------------------------------
+# Coach-only: draft builder, item picker, send.
+# ---------------------------------------------------------------------------
+
+
+@router.post("", response_model=None, status_code=status.HTTP_201_CREATED)
+def create_session(
+ payload: SessionCreateRequest,
+ ctx: CurrentMembership = Depends(require_role_on_team("coach")),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = TrainingSession(
+ created_by=ctx.user.id,
+ title=payload.title,
+ coach_note=payload.coach_note,
+ status="draft",
+ )
+ scope.add(session)
+ scope.commit()
+ scope.refresh(session)
+ return _coach_payload(scope, db, session)
+
+
+@router.patch("/{session_id}", response_model=None)
+def update_session(
+ session_id: int,
+ payload: SessionUpdateRequest,
+ ctx: CurrentMembership = Depends(require_role_on_team("coach")),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = _draft_or_409(_session_or_404(scope, session_id))
+ if payload.title is not None:
+ session.title = payload.title
+ if payload.coach_note is not None:
+ session.coach_note = payload.coach_note
+ scope.commit()
+ scope.refresh(session)
+ return _coach_payload(scope, db, session)
+
+
+@router.post("/{session_id}/items", response_model=None, status_code=status.HTTP_201_CREATED)
+def add_item(
+ session_id: int,
+ payload: SessionItemCreateRequest,
+ ctx: CurrentMembership = Depends(require_role_on_team("coach")),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = _draft_or_409(_session_or_404(scope, session_id))
+
+ if payload.item_kind == "library":
+ if payload.library_item_id is None or payload.saved_pattern_id is not None:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="A library item needs library_item_id and no saved_pattern_id",
+ )
+ if db.get(LibraryItem, payload.library_item_id) is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Library item not found"
+ )
+ else:
+ if payload.saved_pattern_id is None or payload.library_item_id is not None:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="A saved pattern needs saved_pattern_id and no library_item_id",
+ )
+ # Scoped get: another team's recording resolves to None here, so a
+ # coach can only ever attach their own team's patterns.
+ if scope.get(SavedPattern, payload.saved_pattern_id) is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Saved pattern not found"
+ )
+
+ existing = (
+ scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id)
+ .filter(SessionItem.session_id == session.id)
+ .count()
+ )
+ # SessionItem has no team_id of its own (doc 03 section 6), so it is
+ # added through the plain db session after its PARENT session has been
+ # verified through the scope above (app/scoped.py add() docstring).
+ item = SessionItem(
+ session_id=session.id,
+ position=existing,
+ item_kind=payload.item_kind,
+ library_item_id=payload.library_item_id,
+ saved_pattern_id=payload.saved_pattern_id,
+ )
+ db.add(item)
+ scope.commit()
+ return _coach_payload(scope, db, session)
+
+
+@router.patch("/{session_id}/items/{item_id}", response_model=None)
+def move_item(
+ session_id: int,
+ item_id: int,
+ payload: SessionItemMoveRequest,
+ ctx: CurrentMembership = Depends(require_role_on_team("coach")),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = _draft_or_409(_session_or_404(scope, session_id))
+ rows = (
+ scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id)
+ .filter(SessionItem.session_id == session.id)
+ .order_by(SessionItem.position.asc())
+ .all()
+ )
+ moving = next((r for r in rows if r.id == item_id), None)
+ if moving is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session item not found")
+
+ target = min(payload.position, len(rows) - 1)
+ rows.remove(moving)
+ rows.insert(target, moving)
+ # Renormalize to 0..n-1 so positions never drift into gaps or ties.
+ for index, row in enumerate(rows):
+ row.position = index
+ scope.commit()
+ return _coach_payload(scope, db, session)
+
+
+@router.delete("/{session_id}/items/{item_id}", response_model=None)
+def remove_item(
+ session_id: int,
+ item_id: int,
+ ctx: CurrentMembership = Depends(require_role_on_team("coach")),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = _draft_or_409(_session_or_404(scope, session_id))
+ rows = (
+ scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id)
+ .filter(SessionItem.session_id == session.id)
+ .order_by(SessionItem.position.asc())
+ .all()
+ )
+ target = next((r for r in rows if r.id == item_id), None)
+ if target is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session item not found")
+
+ db.delete(target)
+ for index, row in enumerate([r for r in rows if r.id != item_id]):
+ row.position = index
+ scope.commit()
+ return _coach_payload(scope, db, session)
+
+
+@router.post("/{session_id}/send", response_model=None)
+def send_session(
+ session_id: int,
+ ctx: CurrentMembership = Depends(require_role_on_team("coach")),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = _draft_or_409(_session_or_404(scope, session_id))
+
+ items = (
+ scope.query_via(SessionItem, TrainingSession, SessionItem.session_id == TrainingSession.id)
+ .filter(SessionItem.session_id == session.id)
+ .count()
+ )
+ if items == 0:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="Add at least one pattern before sending",
+ )
+
+ # doc 03 section 6: a receipt exists for every recipient at send time,
+ # viewed_at null. Receipts carry no team_id (they scope through the
+ # session), so they are added on the plain db session.
+ for user_id, _display_name in _player_members(scope, db):
+ db.add(SessionReceipt(session_id=session.id, player_user_id=user_id, viewed_at=None))
+
+ session.status = "sent"
+ session.sent_at = utcnow()
+ scope.commit()
+ scope.refresh(session)
+ return _coach_payload(scope, db, session)
+
+
+# ---------------------------------------------------------------------------
+# Player-only: Mark as watched, which feeds the coach's receipt counter
+# (README roles table). A coach calling this gets 403.
+# ---------------------------------------------------------------------------
+
+
+@router.post("/{session_id}/watched", response_model=None)
+def mark_watched(
+ session_id: int,
+ ctx: CurrentMembership = Depends(require_role_on_team("player")),
+ scope: TeamScope = Depends(get_team_scope),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = _session_or_404(scope, session_id)
+ receipt = _own_receipt(scope, session.id, ctx.user.id)
+ if session.status != "sent" or receipt is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
+
+ # Idempotent: re-marking keeps the FIRST watch time rather than
+ # resetting it, so the coach's receipt reads when the player actually
+ # watched it.
+ if receipt.viewed_at is None:
+ receipt.viewed_at = utcnow()
+ scope.commit()
+ scope.refresh(receipt)
+ return _player_payload(scope, db, session, receipt)
diff --git a/backend/app/routers/whiteboard.py b/backend/app/routers/whiteboard.py
index c092782..56b7192 100644
--- a/backend/app/routers/whiteboard.py
+++ b/backend/app/routers/whiteboard.py
@@ -44,7 +44,7 @@ def _author_label(role: str, user: User | None) -> str:
return user.display_name if user is not None else "Player"
-def _pattern_to_out(pattern: SavedPattern, author: User | None) -> SavedPatternOut:
+def pattern_to_out(pattern: SavedPattern, author: User | None) -> SavedPatternOut:
return SavedPatternOut(
id=pattern.id,
name=pattern.name,
@@ -113,7 +113,7 @@ def list_patterns(
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]
+ 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)
@@ -134,7 +134,7 @@ def create_pattern(
scope.add(row)
scope.commit()
scope.refresh(row)
- return _pattern_to_out(row, ctx.user)
+ return pattern_to_out(row, ctx.user)
@router.delete("/patterns/{pattern_id}", status_code=status.HTTP_204_NO_CONTENT)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index d351133..f88dabf 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -504,3 +504,131 @@ class FormationOut(BaseModel):
positions: list[FormationPositionOut]
keystones: list[FormationKeystoneOut]
rondo_zones: list[RondoZoneOut]
+
+
+# ---------------------------------------------------------------------------
+# Sessions: the classroom loop (doc 03 section 6; Brief step 23, PNG 21-23,
+# 26, 28; T-042). Design README: "bundle patterns and recorded whiteboard
+# tactics into a session, attach a coach note, and send to players... Sent
+# state: gold SENT pill with an x/y viewed counter, and per-player read
+# receipts (Viewed / Not yet). Receipts are coach-only, consistent with fit
+# warnings, players never see each other's status."
+#
+# The coach/player split follows the RosterOut/CoachRosterOut precedent
+# exactly: two sibling models over a shared base, picked per caller by a
+# response_model=None route that returns an already-serialized dict, so a
+# player's payload has no receipt KEY at all (not a null or empty one) and
+# a coach's has no `you_watched` key (it is meaningless for a non-recipient).
+# ---------------------------------------------------------------------------
+
+SessionStatus = Literal["draft", "sent"]
+SessionItemKind = Literal["library", "saved_pattern"]
+
+
+class SessionCreateRequest(BaseModel):
+ """POST /api/sessions body. No team_id/created_by/status field on
+ purpose (CLAUDE.md rule 4): team_id is stamped by TeamScope.add from
+ the caller's own membership, created_by from the session, and a fresh
+ session is always a draft."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ title: str = Field(min_length=1, max_length=120)
+ coach_note: str | None = Field(default=None, max_length=2000)
+
+
+class SessionUpdateRequest(BaseModel):
+ """PATCH /api/sessions/{id}. Draft-only (a sent session is a record of
+ what the players were actually told, so it stops being editable), and
+ both fields are optional so the draft builder can save a note without
+ resending the title."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ title: str | None = Field(default=None, min_length=1, max_length=120)
+ coach_note: str | None = Field(default=None, max_length=2000)
+
+
+class SessionItemCreateRequest(BaseModel):
+ """POST /api/sessions/{id}/items. Exactly one of the two id fields must
+ be set, matching `item_kind`; the route validates that pairing and that
+ the referenced row is reachable (library items are library world, saved
+ patterns are team-scoped through this same caller's scope)."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ item_kind: SessionItemKind
+ library_item_id: int | None = None
+ saved_pattern_id: int | None = None
+
+
+class SessionItemMoveRequest(BaseModel):
+ """PATCH /api/sessions/{id}/items/{item_id}: the design README's
+ "Draft state: reorder/remove items". Positions are renormalized to
+ 0..n-1 server-side after every move, so a client never has to compute
+ a gap-free ordering itself."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ position: int = Field(ge=0)
+
+
+class SessionItemOut(BaseModel):
+ """One attached item. Carries the FULL referenced content (not just an
+ id) so both the coach's picker thumbnails and the player's Watch
+ deep-link can render it through the existing preview converters
+ (frontend/src/pages/patternPreview.ts) in one round trip, the same
+ embedded-detail shape FormationOut already uses for keystones."""
+
+ id: int
+ position: int
+ item_kind: SessionItemKind
+ library_item: LibraryItemOut | None = None
+ saved_pattern: SavedPatternOut | None = None
+
+
+class SessionReceiptOut(BaseModel):
+ """One recipient's read state (doc 03 section 6). COACH-ONLY: this
+ model only ever appears inside CoachSessionOut. `jersey_number` is
+ resolved from the recipient's claimed roster row when there is one
+ (PNG 21's numbered badges), null otherwise."""
+
+ player_user_id: int
+ display_name: str
+ jersey_number: int | None
+ viewed_at: datetime | None
+ viewed: bool
+
+
+class SessionOut(BaseModel):
+ """Fields both roles see. Never returned on its own: the routes always
+ build one of the two subclasses below."""
+
+ id: int
+ title: str
+ coach_note: str | None
+ status: SessionStatus
+ sent_at: datetime | None
+ created_at: datetime
+ items: list[SessionItemOut]
+
+
+class PlayerSessionOut(SessionOut):
+ """Player payload. `you_watched` is the caller's OWN receipt state and
+ nothing else: it is what the Mark as watched button reads, and it
+ reveals no other player's status, which is the actual coach-only line
+ the design README draws ("players never see each other's status")."""
+
+ you_watched: bool
+
+
+class CoachSessionOut(SessionOut):
+ """Coach payload: the per-player receipts and the x/y counter (PNG 21's
+ "SENT . 3/4 viewed"). For a DRAFT the receipt rows are the team's
+ current player members with viewed=false, which is the design README's
+ "players listed as Will receive"; real receipt rows are written at send
+ time (doc 03 section 6)."""
+
+ receipts: list[SessionReceiptOut]
+ viewed_count: int
+ recipient_count: int
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index b8814b6..f99a50d 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -14,11 +14,18 @@ dependencies = [
]
[project.optional-dependencies]
+# Upper bounds on purpose. These were open-ended (`ruff>=0.5`, `mypy>=1.10`),
+# which meant CI resolved whatever had shipped that morning while a checkout
+# kept whatever it installed months ago. A ruff minor release then expanded
+# its default rule set and turned a locally-green `make verify` into 140 CI
+# errors in files nobody had touched. Pinning to the minor keeps patch fixes
+# flowing while making a rule-set change a deliberate, reviewable bump
+# instead of a surprise on someone else's pull request.
dev = [
- "pytest>=8",
- "httpx>=0.27",
- "ruff>=0.5",
- "mypy>=1.10",
+ "pytest>=9.1,<10",
+ "httpx>=0.27,<1",
+ "ruff>=0.15.21,<0.16",
+ "mypy>=2.3,<3",
]
[build-system]
diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py
index 7100f9d..7999614 100644
--- a/backend/tests/test_permissions.py
+++ b/backend/tests/test_permissions.py
@@ -23,17 +23,13 @@
re-derived from scratch, so a reviewer can check this file against
Brief section 3 line by line without cross-referencing five other files.
-Two rows -- "Suggest own playstyle" and "Sessions" -- have no API
-surface yet in this codebase state: only their SQLAlchemy models exist
-(app/models/roster.py PlaystyleSuggestion; app/models/sessions.py
-TrainingSession/SessionItem/SessionReceipt), no router is registered for
-either in app/main.py. Per Brief section 4's own build order, role
-gating (step 21, this ticket) lands before the suggestion flow (step 22,
-T-041) and sessions (step 23, T-042). Those two rows are marked skipped
-below with a reason, not silently omitted, so the suite still names
-every row of the table; T-041/T-042 must turn each skip into a real
-assertion when their routes land (T-041's own ticket says as much for
-the suggestion row; the same applies to sessions by the same logic).
+Two rows -- "Suggest own playstyle" and "Sessions" -- were placeholder
+skips when this file was written, because only their SQLAlchemy models
+existed at the time (Brief section 4 lands role gating, step 21, before
+the suggestion flow, step 22, and sessions, step 23). Both routers have
+since landed (app/routers/suggestions.py, T-041; app/routers/sessions.py,
+T-042) and both skips have been replaced by real assertions, so EVERY row
+of the table is now enforced here with nothing skipped.
"""
import pytest
@@ -290,52 +286,141 @@ def test_roster__coach_full_with_fit_warnings_player_view_only_no_fit_warnings(
# ---------------------------------------------------------------------------
# Row: "Suggest own playstyle" -- not applicable (coach) / free text on own
# profile then "pending coach review"; coach sees a gold badge and an
-# Approve / Dismiss card (player). No route exists yet: PlaystyleSuggestion
-# is a model only (app/models/roster.py), no router is registered in
-# app/main.py. T-041 (suggestion flow) is being built in parallel in
-# another worktree and owns turning this into a real assertion.
+# Approve / Dismiss card (player). Implemented by T-041
+# (app/routers/suggestions.py), which replaced this row's placeholder skip
+# with the assertion below. Route-level detail (409 on a second pending
+# suggestion, dismiss leaving the profile unchanged) lives in
+# test_suggestions_routes.py; this states the TABLE ROW itself.
# ---------------------------------------------------------------------------
-@pytest.mark.skip(
- reason=(
- "No suggestion route exists in this worktree yet (Brief step 22 / "
- "T-041, building in parallel). PlaystyleSuggestion is a model only; "
- "app/main.py registers no router for it. T-041 must replace this "
- "skip with a real submit/pending/approve/dismiss assertion."
- )
-)
def test_suggest_own_playstyle__player_submits_pending_coach_approves_or_dismisses(
client: TestClient,
-) -> None: # pragma: no cover - intentionally not runnable yet, see skip reason
- raise AssertionError("T-041 must implement this row's route and this test")
+) -> None:
+ coach = _coach_with_team()
+ # The roster row's name matches the player's display name, which is how
+ # a row becomes "their own profile" (app/routers/roster.py claim).
+ player_id = coach.post("/api/roster/players", json=_player_body(name="Sam Player")).json()["id"]
+ player = _player_on_team(coach, email="player@example.com", name="Sam Player")
+ player.get("/api/roster") # claims the matching row
+
+ # "Not applicable" for a coach: the submit route 403s them outright.
+ assert (
+ coach.post(
+ f"/api/roster/players/{player_id}/suggestions", json={"text": "Coach text"}
+ ).status_code
+ == 403
+ )
+
+ submitted = player.post(
+ f"/api/roster/players/{player_id}/suggestions",
+ json={"text": "I read the game better as a number 8 than out wide."},
+ )
+ assert submitted.status_code == 201
+ assert submitted.json()["status"] == "pending"
+
+ # Never against a teammate's row, even on the same team.
+ teammate_id = coach.post("/api/roster/players", json=_player_body(name="Other Player")).json()[
+ "id"
+ ]
+ assert (
+ player.post(
+ f"/api/roster/players/{teammate_id}/suggestions", json={"text": "Not mine"}
+ ).status_code
+ == 403
+ )
+
+ # The pending queue is the coach's alone.
+ assert player.get("/api/roster/suggestions/pending").status_code == 403
+ pending = coach.get("/api/roster/suggestions/pending").json()
+ assert len(pending) == 1
+ suggestion_id = pending[0]["id"]
+
+ # Approve and dismiss are coach-only too.
+ assert player.post(f"/api/roster/suggestions/{suggestion_id}/approve").status_code == 403
+ assert player.post(f"/api/roster/suggestions/{suggestion_id}/dismiss").status_code == 403
+
+ approved = coach.post(f"/api/roster/suggestions/{suggestion_id}/approve")
+ assert approved.status_code == 200
+ assert approved.json()["status"] == "approved"
+
+ # "Approve merges the note into the profile", visible to both roles.
+ merged = next(
+ row for row in coach.get("/api/roster").json()["players"] if row["id"] == player_id
+ )
+ assert merged["playstyle_note"] == "I read the game better as a number 8 than out wide."
+ assert coach.get("/api/roster/suggestions/pending").json() == []
# ---------------------------------------------------------------------------
# Row: "Sessions" -- create, edit drafts, send, see per-player read
# receipts (coach) / sees sent sessions only, read-only, Watch deep-link,
-# Mark as watched feeding the coach's receipt counter (player). No route
-# exists yet: TrainingSession/SessionItem/SessionReceipt are models only
-# (app/models/sessions.py); app/main.py registers no sessions router.
-# That module's own docstring assigns enforcement to T-042. Same treatment
-# as the suggestion row above: named and skipped, not silently omitted.
+# Mark as watched feeding the coach's receipt counter (player).
+# Implemented by T-042 (app/routers/sessions.py), which replaced this row's
+# placeholder skip with the assertion below. Route-level detail (reorder,
+# send validation, cross-team scoping) lives in test_sessions_routes.py;
+# this states the TABLE ROW itself, including the two things the skip
+# reason called out: receipts created for every recipient at send with
+# viewed_at null, and receipt data ABSENT from player payloads.
# ---------------------------------------------------------------------------
-@pytest.mark.skip(
- reason=(
- "No sessions route exists in this worktree yet (Brief step 23 / "
- "T-042). TrainingSession/SessionItem/SessionReceipt are models "
- "only; app/main.py registers no router for them. T-042 must "
- "replace this skip with a real create/send/receipt assertion, "
- "including: receipts created for every recipient at send with "
- "viewed_at null, and receipt data absent from player payloads."
- )
-)
def test_sessions__coach_creates_sends_sees_receipts_player_reads_and_marks_watched(
client: TestClient,
-) -> None: # pragma: no cover - intentionally not runnable yet, see skip reason
- raise AssertionError("T-042 must implement this row's route and this test")
+) -> None:
+ coach = _coach_with_team()
+ player = _player_on_team(coach, email="player@example.com", name="Sam Player")
+ other = _player_on_team(coach, email="other@example.com", name="Jordan Player")
+
+ # Coach: create and edit a draft.
+ pattern_id = coach.post(
+ "/api/patterns",
+ json={"name": "Build-out", "board_snapshot": _board_snapshot(), "keyframes": _KEYFRAMES},
+ ).json()["id"]
+ session_id = coach.post("/api/sessions", json={"title": "Tuesday"}).json()["id"]
+ coach.patch(f"/api/sessions/{session_id}", json={"coach_note": "Watch this before training."})
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "saved_pattern", "saved_pattern_id": pattern_id},
+ )
+
+ # Player: create/edit/send are all 403 (the whole draft builder is
+ # coach-only), and a draft is not even visible to them.
+ assert player.post("/api/sessions", json={"title": "Mine"}).status_code == 403
+ assert player.patch(f"/api/sessions/{session_id}", json={"title": "Mine"}).status_code == 403
+ assert player.post(f"/api/sessions/{session_id}/send").status_code == 403
+ assert player.get("/api/sessions").json() == []
+ assert player.get(f"/api/sessions/{session_id}").status_code == 404
+
+ sent = coach.post(f"/api/sessions/{session_id}/send").json()
+ assert sent["status"] == "sent"
+ # Receipts exist for every recipient at send time, viewed_at null.
+ assert sent["recipient_count"] == 2
+ assert sent["viewed_count"] == 0
+ assert all(r["viewed_at"] is None for r in sent["receipts"])
+
+ # Player: sees the sent session, read-only, with the coach note and the
+ # content list the Watch buttons deep-link into. Receipt data is ABSENT
+ # from the payload, not null or empty (CLAUDE.md rule 5): players never
+ # see each other's status.
+ player_rows = player.get("/api/sessions").json()
+ assert [r["title"] for r in player_rows] == ["Tuesday"]
+ player_row = player_rows[0]
+ assert player_row["coach_note"] == "Watch this before training."
+ assert player_row["items"][0]["saved_pattern"]["name"] == "Build-out"
+ for coach_only_key in ("receipts", "viewed_count", "recipient_count"):
+ assert coach_only_key not in player_row
+
+ # Mark as watched feeds the coach's counter and flips that row only.
+ assert player.post(f"/api/sessions/{session_id}/watched").json()["you_watched"] is True
+ assert coach.post(f"/api/sessions/{session_id}/watched").status_code == 403 # not a recipient
+
+ after = coach.get(f"/api/sessions/{session_id}").json()
+ assert (after["viewed_count"], after["recipient_count"]) == (1, 2)
+ by_name = {r["display_name"]: r["viewed"] for r in after["receipts"]}
+ assert by_name == {"Sam Player": True, "Jordan Player": False}
+ # The other player still sees no receipt data of any kind.
+ assert "receipts" not in other.get(f"/api/sessions/{session_id}").json()
# ---------------------------------------------------------------------------
diff --git a/backend/tests/test_sessions_routes.py b/backend/tests/test_sessions_routes.py
new file mode 100644
index 0000000..6e6bed8
--- /dev/null
+++ b/backend/tests/test_sessions_routes.py
@@ -0,0 +1,500 @@
+"""Sessions routes (doc 03 section 6; Brief step 23, PNG 21-23, 26, 28;
+T-042). Covers the "Roles and sessions" DoD line from Brief section 5:
+
+ "Session receipts: Mark as watched increments the coach's x/y counter
+ and flips that player's row to Viewed; players never see receipt data
+ in any payload."
+
+plus the draft-builder mechanics the design README specifies (attach
+library presets AND saved recordings, reorder, remove, coach note, send)
+and team scoping end to end through the HTTP layer.
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.db import SessionLocal
+from app.main import app
+from app.models import LibraryItem
+
+
+@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
+
+
+_SPEC = {
+ "slots": [
+ {"slot": "cm", "role_hint": "CM", "start": {"x": 40, "y": 50}},
+ {"slot": "st", "role_hint": "ST", "start": {"x": 70, "y": 50}},
+ ],
+ "ball": {"holder_slot": "cm"},
+ "steps": [
+ {
+ "n": 1,
+ "caption": "Play it in.",
+ "moves": [{"slot": "st", "to": {"x": 80, "y": 45}}],
+ "ball_to": {"bind_slot": "st", "trajectory": "ground"},
+ }
+ ],
+ "loop": False,
+}
+
+
+def _seed_library_item(code: str = "A5", name: str = "Third-Man Run") -> int:
+ """The suite resets the schema per test (conftest.py), so library-world
+ content is empty; sessions need one real row to attach."""
+ db = SessionLocal()
+ try:
+ item = LibraryItem(
+ code=code,
+ item_type="pattern",
+ name=name,
+ category="combination",
+ blurb="The third player arrives to receive.",
+ when_to_use="Against a compact block.",
+ coaching_points_json=["Time the run."],
+ youth_takeaway="Look past the first pass.",
+ age_hint="U13+",
+ roles_involved=["deep_lying_playmaker"],
+ animation_spec_json=_SPEC,
+ extras_json=None,
+ )
+ db.add(item)
+ db.commit()
+ return item.id
+ finally:
+ db.close()
+
+
+_TOKENS = [
+ {"id": "home-9", "side": "home", "label": "9", "pos": {"x": 60, "y": 30}},
+ {"id": "ball", "side": "ball", "label": "", "pos": {"x": 50, "y": 50}},
+]
+_SNAPSHOT = {
+ "tokens": _TOKENS,
+ "confirmed_lanes": [],
+ "blocking_threshold": 7.0,
+ "marking_threshold": 10.0,
+ "zones_visible": {"thirds": False, "half_spaces": False, "zone_14": False, "cutback": False},
+}
+_KEYFRAMES = [
+ {"t_ms": 0, "token_id": "home-9", "x": 60.0, "y": 30.0},
+ {"t_ms": 500, "token_id": "home-9", "x": 72.0, "y": 24.0},
+]
+
+
+def _saved_pattern(coach: TestClient, name: str = "Our build-out vs press") -> int:
+ return coach.post(
+ "/api/patterns",
+ json={"name": name, "board_snapshot": _SNAPSHOT, "keyframes": _KEYFRAMES},
+ ).json()["id"]
+
+
+# ---------------------------------------------------------------------------
+# The full coach loop: draft, attach both item kinds, note, send, receipts.
+# ---------------------------------------------------------------------------
+
+
+def test_coach_builds_a_draft_attaches_both_item_kinds_and_sends(client: TestClient) -> None:
+ coach = _coach_with_team()
+ _player_on_team(coach, email="p1@example.com", name="Jordan T.")
+ library_id = _seed_library_item()
+ pattern_id = _saved_pattern(coach)
+
+ created = coach.post(
+ "/api/sessions",
+ json={"title": "Tuesday, wide overloads", "coach_note": "Watch both before training."},
+ )
+ assert created.status_code == 201
+ session = created.json()
+ assert session["status"] == "draft"
+ assert session["sent_at"] is None
+ assert session["items"] == []
+ # Draft receipts preview the send: the design README's "Will receive".
+ assert session["recipient_count"] == 1
+ assert session["viewed_count"] == 0
+
+ session_id = session["id"]
+ with_library = coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": library_id},
+ )
+ assert with_library.status_code == 201
+ with_pattern = coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "saved_pattern", "saved_pattern_id": pattern_id},
+ )
+ assert with_pattern.status_code == 201
+
+ items = with_pattern.json()["items"]
+ assert [i["position"] for i in items] == [0, 1]
+ # Items embed their full content so a thumbnail renders in one round trip.
+ assert items[0]["library_item"]["code"] == "A5"
+ assert items[0]["library_item"]["animation_spec"]["ball"]["holder_slot"] == "cm"
+ assert items[0]["saved_pattern"] is None
+ assert items[1]["saved_pattern"]["name"] == "Our build-out vs press"
+ assert items[1]["saved_pattern"]["author_label"] == "COACH"
+ assert len(items[1]["saved_pattern"]["keyframes"]) == 2
+
+ sent = coach.post(f"/api/sessions/{session_id}/send")
+ assert sent.status_code == 200
+ body = sent.json()
+ assert body["status"] == "sent"
+ assert body["sent_at"] is not None
+ assert body["recipient_count"] == 1
+ assert body["viewed_count"] == 0
+ assert body["receipts"][0]["display_name"] == "Jordan T."
+ assert body["receipts"][0]["viewed"] is False
+ assert body["receipts"][0]["viewed_at"] is None
+
+
+def test_draft_items_reorder_and_remove(client: TestClient) -> None:
+ coach = _coach_with_team()
+ first = _seed_library_item("A1", "Overlap")
+ second = _seed_library_item("B3", "Switch of play")
+ session_id = coach.post("/api/sessions", json={"title": "Draft"}).json()["id"]
+
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": first},
+ )
+ body = coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": second},
+ ).json()
+ assert [i["library_item"]["code"] for i in body["items"]] == ["A1", "B3"]
+
+ second_item_id = body["items"][1]["id"]
+ moved = coach.patch(
+ f"/api/sessions/{session_id}/items/{second_item_id}", json={"position": 0}
+ ).json()
+ assert [i["library_item"]["code"] for i in moved["items"]] == ["B3", "A1"]
+ assert [i["position"] for i in moved["items"]] == [0, 1]
+
+ removed = coach.delete(f"/api/sessions/{session_id}/items/{second_item_id}").json()
+ assert [i["library_item"]["code"] for i in removed["items"]] == ["A1"]
+ assert [i["position"] for i in removed["items"]] == [0]
+
+
+def test_a_sent_session_can_no_longer_be_edited(client: TestClient) -> None:
+ coach = _coach_with_team()
+ _player_on_team(coach, email="p1@example.com")
+ library_id = _seed_library_item()
+ session_id = coach.post("/api/sessions", json={"title": "Locked"}).json()["id"]
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": library_id},
+ )
+ item_id = coach.get(f"/api/sessions/{session_id}").json()["items"][0]["id"]
+ coach.post(f"/api/sessions/{session_id}/send")
+
+ assert coach.patch(f"/api/sessions/{session_id}", json={"title": "Nope"}).status_code == 409
+ assert (
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": library_id},
+ ).status_code
+ == 409
+ )
+ assert coach.delete(f"/api/sessions/{session_id}/items/{item_id}").status_code == 409
+ assert coach.post(f"/api/sessions/{session_id}/send").status_code == 409
+
+
+def test_an_empty_session_cannot_be_sent(client: TestClient) -> None:
+ coach = _coach_with_team()
+ _player_on_team(coach, email="p1@example.com")
+ session_id = coach.post("/api/sessions", json={"title": "Nothing in it"}).json()["id"]
+ assert coach.post(f"/api/sessions/{session_id}/send").status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# The player side, and the coach-only receipt contract (CLAUDE.md rule 5).
+# ---------------------------------------------------------------------------
+
+
+def _sent_session(coach: TestClient) -> int:
+ library_id = _seed_library_item()
+ session_id = coach.post(
+ "/api/sessions", json={"title": "Tuesday", "coach_note": "Watch it."}
+ ).json()["id"]
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": library_id},
+ )
+ coach.post(f"/api/sessions/{session_id}/send")
+ return session_id
+
+
+def test_player_sees_sent_sessions_only_and_no_receipt_data_in_any_payload(
+ client: TestClient,
+) -> None:
+ coach = _coach_with_team()
+ player = _player_on_team(coach, email="p1@example.com", name="Jordan T.")
+ _player_on_team(coach, email="p2@example.com", name="Sam R.")
+ session_id = _sent_session(coach)
+ # A draft that must stay invisible to the player.
+ coach.post("/api/sessions", json={"title": "Not sent yet"})
+
+ listed = player.get("/api/sessions")
+ assert listed.status_code == 200
+ rows = listed.json()
+ assert [r["title"] for r in rows] == ["Tuesday"]
+
+ row = rows[0]
+ # Coach-only keys are ABSENT, not null or empty (the RosterOut /
+ # CoachRosterOut contract, Brief section 3 principles).
+ assert "receipts" not in row
+ assert "viewed_count" not in row
+ assert "recipient_count" not in row
+ assert row["you_watched"] is False
+ assert row["coach_note"] == "Watch it."
+ assert row["items"][0]["library_item"]["code"] == "A5"
+
+ single = player.get(f"/api/sessions/{session_id}").json()
+ assert "receipts" not in single
+ assert "viewed_count" not in single
+ assert "recipient_count" not in single
+
+ # The draft 404s for a player rather than 403ing (it does not exist as
+ # far as they are concerned).
+ draft_id = next(s["id"] for s in coach.get("/api/sessions").json() if s["status"] == "draft")
+ assert player.get(f"/api/sessions/{draft_id}").status_code == 404
+
+
+def test_mark_as_watched_flips_the_row_and_increments_the_coach_counter(
+ client: TestClient,
+) -> None:
+ coach = _coach_with_team()
+ player = _player_on_team(coach, email="p1@example.com", name="Jordan T.")
+ _player_on_team(coach, email="p2@example.com", name="Sam R.")
+ session_id = _sent_session(coach)
+
+ before = coach.get(f"/api/sessions/{session_id}").json()
+ assert (before["viewed_count"], before["recipient_count"]) == (0, 2)
+
+ watched = player.post(f"/api/sessions/{session_id}/watched")
+ assert watched.status_code == 200
+ assert watched.json()["you_watched"] is True
+ assert "receipts" not in watched.json()
+
+ after = coach.get(f"/api/sessions/{session_id}").json()
+ assert (after["viewed_count"], after["recipient_count"]) == (1, 2)
+ by_name = {r["display_name"]: r for r in after["receipts"]}
+ assert by_name["Jordan T."]["viewed"] is True
+ assert by_name["Jordan T."]["viewed_at"] is not None
+ assert by_name["Sam R."]["viewed"] is False
+ assert by_name["Sam R."]["viewed_at"] is None
+
+
+def test_mark_as_watched_is_idempotent_and_keeps_the_first_watch_time(
+ client: TestClient,
+) -> None:
+ coach = _coach_with_team()
+ player = _player_on_team(coach, email="p1@example.com")
+ session_id = _sent_session(coach)
+
+ player.post(f"/api/sessions/{session_id}/watched")
+ first_seen = next(
+ r["viewed_at"] for r in coach.get(f"/api/sessions/{session_id}").json()["receipts"]
+ )
+ player.post(f"/api/sessions/{session_id}/watched")
+ again = coach.get(f"/api/sessions/{session_id}").json()
+ assert again["viewed_count"] == 1
+ assert again["receipts"][0]["viewed_at"] == first_seen
+
+
+def test_receipts_are_written_for_every_recipient_at_send_time(client: TestClient) -> None:
+ coach = _coach_with_team()
+ _player_on_team(coach, email="p1@example.com", name="Jordan T.")
+ _player_on_team(coach, email="p2@example.com", name="Sam R.")
+ session_id = _sent_session(coach)
+
+ body = coach.get(f"/api/sessions/{session_id}").json()
+ assert body["recipient_count"] == 2
+ assert all(r["viewed_at"] is None for r in body["receipts"])
+
+ # Someone joining afterwards is not retro-added to an already-sent
+ # session: the counter denominator is what the team was when it was
+ # sent, and they see no session they were never sent.
+ latecomer = _player_on_team(coach, email="p3@example.com", name="Late Arrival")
+ assert coach.get(f"/api/sessions/{session_id}").json()["recipient_count"] == 2
+ assert latecomer.get("/api/sessions").json() == []
+ assert latecomer.get(f"/api/sessions/{session_id}").status_code == 404
+ assert latecomer.post(f"/api/sessions/{session_id}/watched").status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# Role enforcement at the API (CLAUDE.md rule 5) and team scoping (rule 4).
+# ---------------------------------------------------------------------------
+
+
+def test_every_coach_only_session_route_403s_a_player(client: TestClient) -> None:
+ coach = _coach_with_team()
+ player = _player_on_team(coach, email="p1@example.com")
+ library_id = _seed_library_item()
+ session_id = coach.post("/api/sessions", json={"title": "Coach only"}).json()["id"]
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": library_id},
+ )
+ item_id = coach.get(f"/api/sessions/{session_id}").json()["items"][0]["id"]
+
+ attempts = {
+ "create": player.post("/api/sessions", json={"title": "Mine"}),
+ "update": player.patch(f"/api/sessions/{session_id}", json={"title": "Mine"}),
+ "add item": player.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": library_id},
+ ),
+ "move item": player.patch(
+ f"/api/sessions/{session_id}/items/{item_id}", json={"position": 0}
+ ),
+ "remove item": player.delete(f"/api/sessions/{session_id}/items/{item_id}"),
+ "send": player.post(f"/api/sessions/{session_id}/send"),
+ }
+ for label, response in attempts.items():
+ assert response.status_code == 403, label
+
+ # And nothing the player attempted changed anything.
+ unchanged = coach.get(f"/api/sessions/{session_id}").json()
+ assert unchanged["title"] == "Coach only"
+ assert unchanged["status"] == "draft"
+ assert len(unchanged["items"]) == 1
+
+
+def test_mark_as_watched_403s_a_coach(client: TestClient) -> None:
+ coach = _coach_with_team()
+ _player_on_team(coach, email="p1@example.com")
+ session_id = _sent_session(coach)
+ assert coach.post(f"/api/sessions/{session_id}/watched").status_code == 403
+
+
+def test_sessions_are_team_scoped_end_to_end(client: TestClient) -> None:
+ coach_a = _coach_with_team(email="a@example.com", name="Coach A")
+ coach_b = _coach_with_team(email="b@example.com", name="Coach B")
+ _player_on_team(coach_a, email="pa@example.com")
+ library_id = _seed_library_item()
+
+ session_id = coach_a.post("/api/sessions", json={"title": "A only"}).json()["id"]
+ coach_a.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": library_id},
+ )
+ item_id = coach_a.get(f"/api/sessions/{session_id}").json()["items"][0]["id"]
+
+ # Team B sees nothing of team A's, and cannot touch it by id.
+ assert coach_b.get("/api/sessions").json() == []
+ assert coach_b.get(f"/api/sessions/{session_id}").status_code == 404
+ assert coach_b.patch(f"/api/sessions/{session_id}", json={"title": "Stolen"}).status_code == 404
+ assert coach_b.delete(f"/api/sessions/{session_id}/items/{item_id}").status_code == 404
+ assert coach_b.post(f"/api/sessions/{session_id}/send").status_code == 404
+ assert coach_a.get(f"/api/sessions/{session_id}").json()["title"] == "A only"
+
+
+def test_a_coach_cannot_attach_another_teams_recording(client: TestClient) -> None:
+ coach_a = _coach_with_team(email="a@example.com", name="Coach A")
+ coach_b = _coach_with_team(email="b@example.com", name="Coach B")
+ pattern_id = _saved_pattern(coach_a, name="A's recording")
+
+ session_id = coach_b.post("/api/sessions", json={"title": "B's draft"}).json()["id"]
+ attached = coach_b.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "saved_pattern", "saved_pattern_id": pattern_id},
+ )
+ assert attached.status_code == 404
+ assert coach_b.get(f"/api/sessions/{session_id}").json()["items"] == []
+
+
+def test_item_kind_and_id_must_agree(client: TestClient) -> None:
+ coach = _coach_with_team()
+ library_id = _seed_library_item()
+ pattern_id = _saved_pattern(coach)
+ session_id = coach.post("/api/sessions", json={"title": "Validation"}).json()["id"]
+
+ assert (
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "saved_pattern_id": pattern_id},
+ ).status_code
+ == 422
+ )
+ assert (
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "saved_pattern", "library_item_id": library_id},
+ ).status_code
+ == 422
+ )
+ assert (
+ coach.post(
+ f"/api/sessions/{session_id}/items",
+ json={"item_kind": "library", "library_item_id": 99999},
+ ).status_code
+ == 404
+ )
+
+
+def test_receipts_carry_the_jersey_number_of_a_claimed_roster_row(client: TestClient) -> None:
+ """PNG 21 renders a numbered gold badge per recipient. The number comes
+ from the roster row the player's own display name claimed
+ (app/routers/roster.py), and is null when no row matches."""
+ coach = _coach_with_team()
+ coach.post(
+ "/api/roster/players",
+ json={
+ "name": "Jordan T.",
+ "jersey_number": 7,
+ "preferred_foot": "R",
+ "awr": "high",
+ "dwr": "med",
+ "attributes": {
+ "pace": 4,
+ "passing_range": 3,
+ "carrying_1v1": 4,
+ "positional_discipline": 3,
+ "aerial_physical": 2,
+ "pressing_engine": 4,
+ },
+ },
+ )
+ player = _player_on_team(coach, email="p1@example.com", name="Jordan T.")
+ player.get("/api/roster") # the claim happens on the player's own roster fetch
+ session_id = _sent_session(coach)
+
+ receipt = coach.get(f"/api/sessions/{session_id}").json()["receipts"][0]
+ assert receipt["display_name"] == "Jordan T."
+ assert receipt["jersey_number"] == 7
+
+
+def test_signed_out_callers_get_401(client: TestClient) -> None:
+ anon = TestClient(app)
+ assert anon.get("/api/sessions").status_code == 401
+ assert anon.post("/api/sessions", json={"title": "Nope"}).status_code == 401
diff --git a/docs/agent/BACKLOG.md b/docs/agent/BACKLOG.md
index bc8a254..ccb5d51 100644
--- a/docs/agent/BACKLOG.md
+++ b/docs/agent/BACKLOG.md
@@ -11,7 +11,7 @@ Model: sonnet default; opus = hard ticket, never downgrade.
| 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 | done |
-| T-012 | Founder decision 2026-07-16: identities age_hint column (amend doc 03, Alembic migration, backfill from Bible 8.2.4, validator + seed update) | founder | content-seeder | sonnet | T-010, T-041 | T-043 | doing |
+| T-012 | Founder decision 2026-07-16: identities age_hint column (amend doc 03, Alembic migration, backfill from Bible 8.2.4, validator + seed update) | founder | content-seeder | sonnet | T-010, T-041 | T-043 | 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 |
@@ -20,12 +20,12 @@ Model: sonnet default; opus = hard ticket, never downgrade.
| 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 | done |
| T-033 | Roster page (PNG 12, 20): CRUD, chips, 6 sliders, double-exposure warning coach-only | 19 | screens | sonnet | T-004, T-011 | T-032 | done |
| T-034 | Identity page (PNG 13, 33, 40-42, 44, 45): 4 scripted animations, 2 static shapes, detail slots, pass-risk, cult corner | 20 | screens | sonnet | T-031 | T-033 | done |
-| 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 | 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 | done |
| T-041 | Playstyle suggestion flow (PNG 24, 25, 27) | 22 | collab | sonnet | T-033 | T-040 | done |
-| 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 |
-| T-043 | Founder decision 2026-07-16: role-scoped join codes (player + coach code, migration), join codes coach-only in API payloads, head coach (creator) removes members + edits member roles, permission tests | founder | platform | sonnet | T-003, T-040 | T-041 | doing |
-| T-050 | Phone pass: icon rail, stacked grids, portrait boards all surfaces, cross-device save/replay test | 24 | screens | sonnet | T-030..T-042 | none | todo |
-| T-051 | Hardening: full em-dash sweep, permission suite in CI, demo-path e2e (Brief §6 narrative as one Playwright journey, both viewports) | 25 | verifier | sonnet | T-050 | none | 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 | done |
+| T-043 | Founder decision 2026-07-16: role-scoped join codes (player + coach code, migration), join codes coach-only in API payloads, head coach (creator) removes members + edits member roles, permission tests | founder | platform | sonnet | T-003, T-040 | T-041 | done |
+| T-050 | Phone pass: icon rail, stacked grids, portrait boards all surfaces, cross-device save/replay test | 24 | screens | sonnet | T-030..T-042 | none | done |
+| T-051 | Hardening: full em-dash sweep, permission suite in CI, demo-path e2e (Brief §6 narrative as one Playwright journey, both viewports) | 25 | verifier | sonnet | T-050 | none | done |
| T-060 | Deploy: Render service (persistent volume), Litestream to object storage, env config, prod Turso decision point, smoke journey vs prod URL | doc 04 §2 | platform | sonnet | T-051 | none | todo |
Sequencing: T-001 solo → (T-002 ∥ T-003 ∥ T-004) → (T-010/011 ∥ T-020/021/022) → screens fan-out → collab → phone → hardening → deploy.
diff --git a/docs/agent/BUILD_TO_DEMO.md b/docs/agent/BUILD_TO_DEMO.md
new file mode 100644
index 0000000..4b9d67b
--- /dev/null
+++ b/docs/agent/BUILD_TO_DEMO.md
@@ -0,0 +1,42 @@
+# BUILD TO DEMO: single-agent finish plan
+
+Supersedes the orchestrator/subagent protocol in CLAUDE.md for the remainder of this
+project. One agent, one branch, build straight through to a demo-ready product.
+
+Goal: a working product to put in front of the UofT varsity soccer coaches, plus a
+README that sells it at a glance.
+
+## What changes from the original plan
+
+DROPPED:
+- Orchestrator/subagent dispatch, worktrees, one-ticket-one-PR ceremony, per-ticket
+ port assignments, CI gating loops. Work directly on one branch.
+- Screen-recording verification.
+- T-060 deploy (deferred; separate decision, needs credentials).
+
+KEPT (cheap, already load-bearing, do not regress):
+- No em dashes in user-facing strings or seeds. `make check-copy` enforces it.
+- Team data goes through the scoped query layer. Handlers never filter team_id by hand.
+- Permissions enforced in the API, not just the UI. Coach-only keys ABSENT from
+ player payloads, never null.
+- Positions stored in landscape model coords (x 0-100 toward attacking goal, y 0-100
+ top to bottom). Orientation is render-only.
+- `make verify` green before the final merge.
+
+ADDED:
+- One-command demo seed: a fully populated coach account, so the app is never empty
+ when a coach opens it.
+- Screenshot capture pass + a README built around those screenshots.
+
+## Build order
+
+1. T-042 Sessions. The coach->player loop that closes the demo narrative.
+2. Demo seed script.
+3. T-050 Phone pass.
+4. T-051 Hardening, scoped to the demo path only.
+5. Screenshots + README.
+
+## Definition of done
+
+The Brief section 6 demo narrative runs end to end without a hitch, on desktop and
+on a phone viewport, and the README shows it.
diff --git a/docs/agent/FINISH_PROMPT.md b/docs/agent/FINISH_PROMPT.md
new file mode 100644
index 0000000..74fbffa
--- /dev/null
+++ b/docs/agent/FINISH_PROMPT.md
@@ -0,0 +1,184 @@
+# Agent prompt: finish Patterns of Play to demo-ready
+
+Finish Patterns of Play end to end so I can demo it to university varsity soccer
+coaches. Repo: /Users/brandanburgess/Documents/patternsofplay, branch `integration`.
+
+You are building, not orchestrating. Ignore the orchestrator/subagent protocol and the
+PR ceremony in CLAUDE.md: no worktrees, no per-ticket PRs, no CI polling loops. Work
+directly on `integration` and commit as you complete each phase. Read
+docs/agent/BUILD_TO_DEMO.md first, then this.
+
+## Ground truth (do not re-derive)
+
+The app already works. Merged and verified: platform/auth/teams with role-scoped join
+codes, scoped query layer, full schema (Alembic head 0005), seeded tactical content
+(12 patterns, 8 deliveries, 3 rotations, 6 formations, rondo map, 27 identities), the
+complete board engine (drag, lane graph with suggested/confirmed/blocked, zone
+overlays, declarative + keyframe animation player, recorder), all five screens
+(Whiteboard, Patterns, Formations, Roster, Identity), the permission suite, and the
+playstyle suggestion flow. `make verify` was green at the last commit.
+
+Read docs/agent/STATE.md for architecture notes and decisions already closed. Do not
+reopen them.
+
+Stack is React 19 + Vite + TypeScript (frontend/) and FastAPI + SQLAlchemy 2 + Alembic
++ SQLite WAL (backend/). docs/source/design-handoff/ holds static PNG mockups plus one
+HTML mockup file: those are a VISUAL SPEC ONLY. You write React. Never port the mockup
+HTML/CSS into the app; match its look using the existing design tokens
+(frontend/src/styles/tokens.css) and existing page CSS conventions.
+
+## Rules that still bind
+
+1. No em dashes in any user-facing string or seed file. Use periods, commas, colons,
+ parentheses. `make check-copy` fails on the character.
+2. Every team-scoped query goes through the scoped query layer (backend/app/scoped.py).
+ Route handlers never filter by team_id manually. Client input never supplies team_id.
+3. Permissions enforced in the API, not just the UI. Coach-only data (fit warnings,
+ receipts, join codes) must be ABSENT from player-role payloads, not null. Follow the
+ split-schema pattern already used for RosterOut/CoachRosterOut.
+4. Board positions stored in landscape model coords (x 0-100 toward the attacking goal,
+ y 0-100 top to bottom). Orientation is render-only. Portrait maps left=y, top=100-x.
+5. Reuse frontend/src/board/PatternPreviewBoard.tsx and pages/patternPreview.ts for any
+ read-only board rendering. Do not write a second board renderer.
+6. Scope discipline: the Brief section 1 scope table is final. Do not invent surfaces.
+ If content has no designed surface, it stays seed data.
+
+## The bar for the boards
+
+This is the product. Everything else is scaffolding around it. The boards must be
+genuinely functional and they must look good enough that a coach wants to keep using
+them:
+
+- Drag stays smooth with all 23 tokens on the pitch. No jank, no dropped pointer
+ capture, no popover or menu intercepting a drag (this bug already bit T-030 once).
+- Playback is smooth and readable: tokens ease rather than teleport, the ball chases
+ its bound player, trails/lane states read clearly at a glance.
+- Portrait and landscape both render correctly, and a pattern recorded in one replays
+ identically in the other.
+- Every board surface looks deliberate: consistent token styling, legible pitch
+ markings, sensible empty states. If a board looks unfinished, fix it even if no
+ ticket line asks for it.
+
+Treat any board polish gap you notice as in scope.
+
+## Phase 1: Sessions (T-042)
+
+The coach-to-player loop. Mockups: PNG 21, 22, 23, 26, 28 in
+docs/source/design-handoff/. Backend models already exist in
+backend/app/models/sessions.py.
+
+- Coach: draft builder + item picker with board thumbnails (reuse PatternPreviewBoard),
+ attach library patterns and saved recordings, add a note, send to the team.
+- Coach: receipts view, "watched by 3 of 12" style counter, per-player state.
+- Player: session list, session view, Watch deep-link that opens the item on the board
+ (portrait on phone), Mark as watched.
+- Un-skip and implement the two @pytest.mark.skip placeholder rows in
+ backend/tests/test_permissions.py ("suggest own playstyle", "sessions"). Players must
+ never receive receipt data in any payload.
+- Playwright journey in e2e/sessions.spec.ts using the e2e/fixtures.ts helpers, ending
+ in assertCleanPage.
+
+## Phase 2: Demo seed
+
+Add `scripts/seed_demo.py` and a `make demo` target that drops the dev DB, migrates,
+seeds library content, then creates a realistic demo state so the app is never empty
+when a coach opens it:
+
+- Coach account (print the credentials to stdout and put them in the README), a team,
+ and a player account joined to it.
+- 12-14 roster players with roles, flanks, and slider values filled in, including one
+ pair that triggers the double-exposure fit warning.
+- A saved custom pattern recorded on the whiteboard ("Our build-out vs press") with a
+ real multi-token animation, not a stub.
+- A selected formation with a keystone, a set team identity, one sent session with two
+ items and one receipt already marked watched.
+
+Idempotent, safe to rerun. This is what I will run before walking into a meeting.
+
+## Phase 3: Phone pass (T-050)
+
+Mockups: PNG 14-20, 23, 28, 35, 36, 43-45. iPhone 13 (390x844) is the target.
+
+- Icon rail nav, stacked single-column grids, no horizontal overflow anywhere.
+- Every board surface renders portrait on phone.
+- Sheets and drawers are reachable and dismissible by touch.
+- Cross-device test: record a pattern on desktop viewport, replay it on mobile
+ viewport, assert positions round-trip.
+
+## Phase 4: Hardening (T-051), demo-path scoped
+
+- Full em-dash sweep across the repo.
+- Permission suite runs in CI.
+- One Playwright journey, e2e/demo-path.spec.ts, that runs the Brief section 6
+ narrative straight through on both viewports: coach signs up, creates a team, adds
+ players with roles and sliders, opens Formations and loads 4-3-3 and taps the pivot
+ keystone, toggles the Rondo Map and taps the first-line zone, opens Patterns and
+ searches "third man" and plays A5, opens the Whiteboard and drags a build-out and
+ records and saves it as "Our build-out vs press", creates a session with A5 plus the
+ recording and a note and sends it, then as a player opens the session, watches A5
+ portrait, marks it watched, and back as coach the receipt counter reads 1 of N.
+
+Do not chase unrelated tech debt. If the demo path is green on both viewports and
+`make verify` passes, hardening is done.
+
+## Phase 5: Screenshots and README
+
+Write `e2e/screenshots.spec.ts` that runs against the `make demo` database and writes
+PNGs to docs/screenshots/. This is a capture script, not a test: it drives the real UI
+and takes full-page or element screenshots. No screen recording, no video.
+
+Capture at minimum, desktop 1440x900 unless noted:
+1. Whiteboard with the lane graph live, mid-drag state with confirmed and blocked lanes
+ visible.
+2. Whiteboard recording UI, trace visible.
+3. Patterns page, library sheet open with chips and search.
+4. A pattern mid-playback on the board with the animation clearly in progress.
+5. Formations page, 4-3-3 with the keystone pulsing, keycards visible.
+6. Rondo Map with a zone selected.
+7. Identity page, a reference team animation playing.
+8. Roster with the double-exposure fit warning showing.
+9. Session receipts view.
+10. Phone (390x844): pattern playing portrait, and the player session view.
+
+Time the captures so animated surfaces are caught mid-motion rather than at frame zero,
+and prefer the dark theme unless a screen reads better in another. These are marketing
+shots: if a screen looks empty or awkward, adjust the demo seed data until it looks
+good, then recapture.
+
+Then write README.md at the repo root:
+
+- One-line hook, then a two-sentence explanation of what the product does for a coach.
+- The hero screenshot right at the top.
+- "Why a coach cares": three or four bullets in plain football language, not
+ engineering language. No jargon, no architecture talk in this section.
+- A walkthrough section: the screenshots in demo-narrative order, each with a one-line
+ caption naming what the coach is doing.
+- Quickstart: `make bootstrap`, `make demo`, `make dev`, plus the demo login
+ credentials and the URL.
+- A short stack and architecture section at the bottom, for engineers.
+
+Keep the README tight. A coach should get the point from the images alone; the prose is
+support.
+
+## Working discipline (these are not optional)
+
+- NEVER end your turn to wait for a background task. If you start something in the
+ background, poll its output file with Read inside the same turn until it finishes.
+- Never run a foreground dev server. Never use Playwright headed mode, --ui, or
+ page.pause().
+- Run Playwright with explicit timeouts: --timeout=30000 --global-timeout=200000.
+- Never pipe `make verify` to `tail`: the pipeline masks the exit code. Run
+ `make verify > /tmp/verify.log 2>&1; echo "VERIFY_EXIT=$?"` and read the log
+ separately. Confirm the exit code, do not eyeball the tail.
+- `scripts/dev.sh` migrates AND seeds on every boot. Do not remove the seed line, CI
+ depends on it.
+- Commit after each phase with a Conventional Commit message. Do not open PRs.
+- If something is genuinely ambiguous about auth, tenancy, permissions, or data shape,
+ pick the option most consistent with what is already built, implement it, and note
+ the decision in your final summary. Do not stop to ask unless proceeding would be
+ destructive.
+
+## Report at the end
+
+State what shipped, anything you deliberately left out, the demo credentials, and the
+exact commands I run to show this to a coach.
diff --git a/docs/agent/STATE.md b/docs/agent/STATE.md
index 4ddc227..d1dbff8 100644
--- a/docs/agent/STATE.md
+++ b/docs/agent/STATE.md
@@ -1,47 +1,110 @@
# 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.
+Written 2026-07-16 (session 2 handoff). 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 the backlog does not.
-## Done and merged to main (all via CI-green PRs)
+## Done and merged to main (all via PRs; sessions 1 + 2)
| 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 |
+| #1-#6 | T-001..004, T-010, T-011, T-020..022 | Session 1: platform, content, board engine |
+| #7 | T-030 | Whiteboard page. Root-cause fix: View menu popover intercepted recorded drags |
+| #8 | T-031 | Patterns page + the seed-on-boot infra fix (scripts/dev.sh now migrates AND seeds on every boot; CI/fresh checkouts were failing without it) |
+| #9 | T-033 | Roster page. Migration 0003 players.flank |
+| #10 | T-032 | Formations page + rondo map. Reuses T-031's PatternPreviewBoard |
+| #11 | T-034 | Identity page. NOTE: merged before its PR check registered (process gap, fixed; post-merge CI on main was green) |
+| #12 | T-040 | Permission suite (Brief §3 table, table-driven test names). Zero enforcement gaps found; purely additive |
+| #13 | T-041 | Playstyle suggestion flow. No migration (table existed in 0002). Ships a name-match auto-claim of roster rows (see decisions below) |
+| #14 | T-043 | Founder-directed: role-scoped join codes (migration 0004) + head-coach member management |
+| #15 | T-012 | Founder-directed: identities.age_hint (migration 0005, re-pointed from 0004 at merge) |
-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.
+main == integration at PR #15 plus backlog/state chores. Every ticket was independently re-verified by the orchestrator (make verify, EXIT CODE checked) in its worktree AND after its integration merge.
-## Working agreements established this session
+## Founder decisions recorded 2026-07-16 (all six former open questions are CLOSED)
-- 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.
+1. Join codes are role-scoped: player code + coach code per team; the code determines the role on join, the account role never does. Head coach (= team creator, teams.created_by) can remove members and change member roles. Implemented in T-043.
+2. Join codes are coach-only in API payloads (keys absent for players). Implemented in T-043; T-040's pinned-ambiguity test was replaced with the new contract.
+3. identities.age_hint: schema amended via migration; all 27 identities backfilled (editorial U9+/U11+/U13+/U15+ scale; Bible 8.2.4 gives a rule, not a table). Founder will review the hints during post-deploy content QA. Implemented in T-012.
+4. Blurb spot-check sign-off: founder will do their own QA after deploy. No further action.
+5. Mid-playback orientation flips: not wanted; rotation between runs is the contract. Closed.
+6. Player.flank column: accepted as shipped (migration 0003).
-## IN FLIGHT: T-030 whiteboard page (needs adoption by the resuming session)
+Open items surfaced to the founder, awaiting reaction (NOT blocking):
+- T-041's roster-row linkage is a name-match auto-claim (exactly-one unclaimed row matching display_name claims it on the player's roster fetch). Replaceable by head-coach row assignment if the founder prefers.
+- T-043 member removal is a hard delete, no audit trail; removed members rejoin by code.
+- T-060 deploy credentials decision: STOP and ask before deploying.
-- 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.
+## Working agreements (carried + new this session)
-## Queue after T-030 merges
+- Orchestrator creates AND merges PRs once CI is green. Flow per ticket: agent completes in worktree -> orchestrator reviews diff + reruns make verify -> merge to integration -> verify again -> push -> PR to main -> confirm CI pass on the PR's CURRENT head -> squash merge -> reconcile origin/main back into integration -> BACKLOG status -> remove worktree -> dispatch next.
+- One ticket per PR. Squash titles: feat(scope): title (T-###).
+- SQUASH-MERGE HISTORY: after every squash merge, `git fetch origin main && git merge origin/main` into integration immediately. Skipping this causes phantom conflicts on the next PR (hit once at PR #8; the reconcile merge is always content-empty when done promptly).
+- VERIFY EXIT CODES: never pipe make verify to tail (pipeline exit code masked a red verify once). Run `make verify > log 2>&1; echo "VERIFY_EXIT=$?"` and read the log tail separately. Never chain push/PR-create behind a verify.
+- CI GATING: `gh pr checks N --watch` right after a push can exit "no checks reported". Poll `gh pr checks N` until an actual pass/fail registers (30s interval loop); never treat absence of checks as green. Confirm the pass is for the PR's current head SHA before merging.
+- MIGRATION PARALLELISM: agents building in parallel branch off the same alembic head; whichever merges second gets its revision/down_revision re-pointed by the orchestrator at merge (T-012 became 0005 this way). Always prove the chain from zero afterward (fresh DATABASE_URL, alembic upgrade head).
+- AGENT DISPATCH PROMPTS must include: explicit Playwright timeouts (--timeout=30000 --global-timeout=200000), no foreground servers, no headed/--ui/page.pause, dedicated ports, "confirm the verify EXIT CODE, not the eyeballed tail", and "NEVER end your turn to wait for a background task; poll its output file with Read inside the same turn" (three agents stalled out on this pattern before the instruction was added).
+- Ports per ticket (used so far): T-030 8130/5530, T-031 8131/5531, T-032 8132/5532, T-033 8133/5533, T-034 8134/5534, T-040 8140/5540, T-041 8141/5541, T-043 8143/5543, T-012 8112/5312. Scheme: 81## API / 55## web keyed to ticket number.
+- Agents that die (stream timeout / watchdog) leave work safely on disk in their worktree; resume the SAME agent via SendMessage first (context intact); dispatch a fresh agent to adopt the worktree only if resume fails repeatedly.
+- Environment: gh CLI authenticated (BrandanBurgess); no MCP servers (gh substitutes for github MCP); git push via keychain.
+- scripts/dev.sh migrates AND seeds on every boot (idempotent); CI and fresh checkouts depend on this. Do not remove the seed line.
-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).
+## Architecture notes for future tickets
-## Open founder questions (defaults shipped, not blocking)
+- Nav shell: AppShell takes an enabledKeys prop; pages activate via ENABLED_NAV_KEYS in App.tsx only. All five entries are live; AppShell.tsx should not need edits.
+- Read-only board rendering: frontend/src/board/PatternPreviewBoard.tsx (autoplay, pulsing tokens, zone overlays) + pages/patternPreview.ts converters. Formations/Identity reuse it; T-042 session thumbnails should too.
+- Coach-only payload pattern: split schemas (RosterOut/CoachRosterOut, TeamOut/CoachTeamOut) with response_model=None and manual model_dump, so coach-only keys are ABSENT for players, never null. Follow for receipts in T-042 (players must never see receipt data, Brief §5).
+- Permission tests: backend/tests/test_permissions.py is table-driven against Brief §3, one named test per row. Two rows are @pytest.mark.skip placeholders: "suggest own playstyle" (routes now exist from T-041; un-skip in T-042) and "sessions" (T-042 implements + un-skips).
+- Head-coach gate: app/deps.py require_head_coach (creator check), separate from require_role_on_team.
+- Roster row <-> user linkage: name-match auto-claim in roster.py _claim_matching_row. T-042's player session views key off membership, not roster rows, but be aware of it.
+- Alembic head: 0005_identity_age_hint. Chain 0001..0005 proven from zero.
-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+?
+## Session 3 (build to demo, single agent on `integration`)
+
+Ran under docs/agent/BUILD_TO_DEMO.md, which supersedes the orchestrator
+protocol above: one agent, one branch, no worktrees, no per-ticket PRs.
+T-042, T-050 and T-051 all landed, plus the demo seed and the README pass.
+
+Decisions taken while building (none reopen a closed question):
+
+1. Session RECIPIENTS are fixed at send time. Receipts are written for every
+ player-role member when the coach sends, and a player's session list is
+ gated on having one of those receipt rows. Someone who joins afterwards
+ does not appear in an already-sent session's denominator and does not see
+ it. Doc 03 section 6 says receipts exist "for every recipient at send
+ time"; this is that read, and it keeps the x/y counter stable.
+2. A player DOES see their own `you_watched` flag (it is what the Mark as
+ watched button reads). The coach-only line the design README draws is
+ "players never see each OTHER's status", so the player payload carries no
+ receipts list and no counter at all, only their own state.
+3. A sent session is immutable (edits 409). It is the record of what the
+ team was actually told.
+4. There is no persisted "selected formation" or "team identity" anywhere in
+ doc 03's schema, and Formations/Identity are board-first browse surfaces
+ with local selection. The demo seed therefore expresses "a shape is set"
+ through the one place the app does persist board state: the team's live
+ `boards` row, seeded with the 4-3-3 shape and two confirmed lanes. No new
+ table, no invented surface (Brief section 1 scope rule).
+5. Search is now hyphen-insensitive across all four browse surfaces
+ (frontend/src/pages/search.ts). The Brief's own demo narrative types
+ "third man" and the content is named "Third-Man Run", so the narrative
+ previously found nothing.
+
+Gaps found and closed while checking the product against the PNGs:
+
+- `.ctl-ghost` was only ever defined scoped to the save bar and saved-pattern
+ rows, so every later reuse rendered as a default browser button.
+- The four auth/onboarding screens had no styling at all (they predate the
+ token system). Now token-styled, no DOM change.
+- The swipe-up sheets pushed the page taller instead of overlaying it as a
+ bottom drawer (PNG 07/38/40), which scrolled the header and the top of the
+ board off screen.
+- The ball's gold trace while RECORDING (design README, PNG 03) was never
+ implemented: only playback drew a trail. Now drawn by the same overlay.
+- Keystone tokens showed Chromium's blue focus ring on the pitch.
+- The rondo zone card was clipped by the sheet handle.
+
+## Queue for the next session
+
+1. T-060 deploy (platform): Render + Litestream + prod Turso decision. STOP
+ for the founder's deploy credentials decision before any deploy action.
+2. Founder review items still open from session 2 (T-041 name-match roster
+ claim, T-043 hard-delete member removal, identity age_hint editorial QA).
diff --git a/docs/screenshots/01-whiteboard-lanes.png b/docs/screenshots/01-whiteboard-lanes.png
new file mode 100644
index 0000000..be75be2
Binary files /dev/null and b/docs/screenshots/01-whiteboard-lanes.png differ
diff --git a/docs/screenshots/02-whiteboard-recording.png b/docs/screenshots/02-whiteboard-recording.png
new file mode 100644
index 0000000..c43d2f9
Binary files /dev/null and b/docs/screenshots/02-whiteboard-recording.png differ
diff --git a/docs/screenshots/03-patterns-library-sheet.png b/docs/screenshots/03-patterns-library-sheet.png
new file mode 100644
index 0000000..c0a8b75
Binary files /dev/null and b/docs/screenshots/03-patterns-library-sheet.png differ
diff --git a/docs/screenshots/04-pattern-playing.png b/docs/screenshots/04-pattern-playing.png
new file mode 100644
index 0000000..bf90bf7
Binary files /dev/null and b/docs/screenshots/04-pattern-playing.png differ
diff --git a/docs/screenshots/05-formations-keystone.png b/docs/screenshots/05-formations-keystone.png
new file mode 100644
index 0000000..1420b74
Binary files /dev/null and b/docs/screenshots/05-formations-keystone.png differ
diff --git a/docs/screenshots/06-rondo-map.png b/docs/screenshots/06-rondo-map.png
new file mode 100644
index 0000000..6e946ba
Binary files /dev/null and b/docs/screenshots/06-rondo-map.png differ
diff --git a/docs/screenshots/07-identity-playing.png b/docs/screenshots/07-identity-playing.png
new file mode 100644
index 0000000..ad16175
Binary files /dev/null and b/docs/screenshots/07-identity-playing.png differ
diff --git a/docs/screenshots/08-roster-fit-warning.png b/docs/screenshots/08-roster-fit-warning.png
new file mode 100644
index 0000000..ef300e4
Binary files /dev/null and b/docs/screenshots/08-roster-fit-warning.png differ
diff --git a/docs/screenshots/09-session-receipts.png b/docs/screenshots/09-session-receipts.png
new file mode 100644
index 0000000..6f123e9
Binary files /dev/null and b/docs/screenshots/09-session-receipts.png differ
diff --git a/docs/screenshots/10-phone-pattern-portrait.png b/docs/screenshots/10-phone-pattern-portrait.png
new file mode 100644
index 0000000..543f6bc
Binary files /dev/null and b/docs/screenshots/10-phone-pattern-portrait.png differ
diff --git a/docs/screenshots/11-phone-player-session.png b/docs/screenshots/11-phone-player-session.png
new file mode 100644
index 0000000..772a629
Binary files /dev/null and b/docs/screenshots/11-phone-player-session.png differ
diff --git a/e2e/cross-device.spec.ts b/e2e/cross-device.spec.ts
new file mode 100644
index 0000000..4b42983
--- /dev/null
+++ b/e2e/cross-device.spec.ts
@@ -0,0 +1,141 @@
+// Cross-device round trip (T-050, Brief section 5 Phone DoD): "All board
+// surfaces render portrait per the mapping; a pattern saved on desktop
+// replays correctly on phone and vice versa."
+//
+// e2e/recorder.spec.ts already proves the coordinate mapping survives an
+// orientation flip inside ONE page (resize across the breakpoint). This
+// proves the whole round trip across TWO DEVICES: a coach records on a
+// 1440x900 browser, and the same coach, signed in on a separate iPhone 13
+// browser, replays it from the server and every token lands on the same
+// stored model coordinates, rendered through the portrait mapping.
+//
+// It runs once (under the desktop project only) because it drives both
+// viewports itself; running it again under the mobile project would just
+// swap which context is created first.
+
+import { test, expect, assertCleanPage, registerCoach, signIn, watchPage } 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 };
+}
+
+function parseTranslate(transform: string) {
+ const m = /translate\(([-\d.]+)\s+([-\d.]+)\)/.exec(transform)!;
+ return { px: Number(m[1]), py: Number(m[2]) };
+}
+
+async function readModel(page: Page, id: string): Promise {
+ const el = page.locator(`[data-token-id="${id}"]`);
+ return {
+ x: Number(await el.getAttribute("data-model-x")),
+ y: Number(await el.getAttribute("data-model-y")),
+ };
+}
+
+/** Drags a token to a model coordinate on whichever orientation the page
+ * is currently rendering (the mapping is render-only, so the target pixel
+ * differs per device while the stored model coordinate does not). */
+async function dragTokenTo(page: Page, id: string, m: Model) {
+ const orientation = (await page
+ .locator(".board-wrap")
+ .getAttribute("data-orientation")) as Orientation;
+ const box = (await page.getByTestId("board").boundingBox())!;
+ const vb = VB[orientation];
+ const target = expectedPixel(m, orientation);
+ const tokenBox = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!;
+ await page.mouse.move(tokenBox.x + tokenBox.width / 2, tokenBox.y + tokenBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(
+ box.x + (target.px / vb.width) * box.width,
+ box.y + (target.py / vb.height) * box.height,
+ { steps: 12 }
+ );
+ await page.mouse.up();
+}
+
+test.describe("cross-device: record on desktop, replay on a phone", () => {
+ test("token positions round-trip through the server, rendered portrait", async ({
+ browser,
+ page,
+ issues,
+ viewport,
+ }) => {
+ test.skip((viewport?.width ?? 1440) <= 700, "this journey drives both viewports itself");
+
+ // --- Device 1: the coach's laptop --------------------------------------
+ const { email } = await registerCoach(page, { teamName: "Cross Device FC" });
+ await expect(page.locator(".board-wrap")).toHaveAttribute("data-orientation", "landscape");
+
+ await page.getByTestId("record").click();
+ await dragTokenTo(page, "home-2", { x: 46, y: 14 });
+ await dragTokenTo(page, "home-7", { x: 74, y: 22 });
+ await dragTokenTo(page, "ball", { x: 46, y: 14 });
+ const recorded = {
+ "home-2": await readModel(page, "home-2"),
+ "home-7": await readModel(page, "home-7"),
+ ball: await readModel(page, "ball"),
+ };
+ await page.getByTestId("stop-record").click();
+ await page.getByTestId("record-name").fill("Wide overload, right");
+ await page.getByTestId("save-pattern").click();
+ await expect(
+ page.getByTestId("saved-pattern").filter({ hasText: "Wide overload, right" })
+ ).toHaveCount(1);
+
+ // --- Device 2: the same coach's phone -----------------------------------
+ const phoneContext = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ hasTouch: true,
+ isMobile: true,
+ });
+ const phone = await phoneContext.newPage();
+ watchPage(phone, issues);
+ await signIn(phone, email);
+ await expect(phone.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait");
+
+ const savedOnPhone = phone
+ .getByTestId("saved-pattern")
+ .filter({ hasText: "Wide overload, right" });
+ await expect(savedOnPhone).toHaveCount(1);
+ await savedOnPhone.getByRole("button", { name: "Replay" }).click();
+ await expect(phone.locator(".board-root")).toHaveAttribute("data-playing", "false", {
+ timeout: 20000,
+ });
+
+ for (const [id, want] of Object.entries(recorded)) {
+ const got = await readModel(phone, id);
+ // Model coordinates are device-independent (CLAUDE.md rule 8).
+ expect(got.x, `${id} model x`).toBeCloseTo(want.x, 0);
+ expect(got.y, `${id} model y`).toBeCloseTo(want.y, 0);
+
+ // And the phone RENDERS them through the portrait mapping
+ // (left = y, top = 100 - x), which is the half of the contract a
+ // model-coordinate check alone would not catch.
+ const expected = expectedPixel(got, "portrait");
+ const drawn = parseTranslate(
+ (await phone.locator(`[data-token-id="${id}"]`).getAttribute("transform"))!
+ );
+ expect(drawn.px, `${id} rendered px`).toBeCloseTo(expected.px, 0);
+ expect(drawn.py, `${id} rendered py`).toBeCloseTo(expected.py, 0);
+ }
+
+ await assertCleanPage(phone, issues);
+ await assertCleanPage(page, issues);
+ await phoneContext.close();
+ });
+});
diff --git a/e2e/demo-path.spec.ts b/e2e/demo-path.spec.ts
new file mode 100644
index 0000000..ce88288
--- /dev/null
+++ b/e2e/demo-path.spec.ts
@@ -0,0 +1,232 @@
+// The demo path (T-051, Brief section 6): the acceptance narrative, start
+// to finish, as one journey. Quoting the Brief verbatim, because this file
+// exists to be checkable against it line by line:
+//
+// "coach signs up, creates a team, adds six players with roles and
+// sliders; opens Formations, loads 4-3-3, taps the pivot keystone;
+// toggles the Rondo Map and taps the first-line zone; opens Patterns,
+// searches 'third man', plays A5 on the board; opens the whiteboard,
+// drags a build-out, records it, saves as 'Our build-out vs press';
+// creates a session with A5 plus the recording and a note, sends it;
+// switches to a player account on a phone, opens the session, watches
+// A5 portrait, marks watched; back on the coach account the receipt
+// counter reads 1 of N."
+//
+// It runs under BOTH Playwright projects, so the coach half runs once at
+// 1440x900 and once at 390x844. The player half always runs in its own
+// 390x844 context ("switches to a player account on a phone"), which is
+// also what makes "watches A5 portrait" a real assertion rather than an
+// accident of which project happens to be running.
+
+import {
+ test,
+ expect,
+ assertCleanPage,
+ registerCoach,
+ registerPlayer,
+ watchPage,
+} from "./fixtures";
+import type { Locator, Page } from "@playwright/test";
+
+// Chromium's mobile emulation shrinks the visual viewport once a text
+// input is focused and never restores it, so a coordinate-based click can
+// land on the wrong element on a form-heavy page below the fold. Same
+// mitigation e2e/roster.spec.ts already documents and uses.
+async function tap(locator: Locator) {
+ await locator.scrollIntoViewIfNeeded();
+ await locator.dispatchEvent("click");
+}
+
+async function setSlider(page: Page, key: string, value: number) {
+ 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";
+ pace: number;
+ }
+) {
+ await tap(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 setSlider(page, "pace", opts.pace);
+ await tap(page.getByTestId("player-save"));
+ await expect(page.getByTestId("player-save")).toHaveCount(0);
+}
+
+/** Drags a token by a pixel offset on whichever orientation is rendering.
+ * The demo narrative's "drags a build-out" is about the gesture and what
+ * it records, not about landing on an exact coordinate, so this stays
+ * deliberately simple; e2e/cross-device.spec.ts is where exact model
+ * coordinates are pinned. */
+async function dragToken(page: Page, id: string, dx: number, dy: number) {
+ const box = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!;
+ const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
+ await page.mouse.move(from.x, from.y);
+ await page.mouse.down();
+ await page.mouse.move(from.x + dx, from.y + dy, { steps: 12 });
+ await page.mouse.up();
+}
+
+test.describe("demo path: the Brief section 6 acceptance narrative", () => {
+ test("runs end to end, coach on this viewport and player on a phone", async ({
+ browser,
+ page,
+ issues,
+ }) => {
+ // --- "coach signs up, creates a team" ---------------------------------
+ const { joinCode } = await registerCoach(page, {
+ displayName: "Coach Demo",
+ teamName: "Demo Path FC",
+ });
+ await expect(page.getByTestId("board")).toBeVisible();
+
+ // The player joins now, before the send: receipts are written for every
+ // recipient at send time (doc 03 section 6), so the recipient has to be
+ // on the team by then. On their own phone, per the narrative.
+ const phoneContext = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ hasTouch: true,
+ isMobile: true,
+ });
+ const playerPage = await phoneContext.newPage();
+ watchPage(playerPage, issues);
+ await registerPlayer(playerPage, joinCode, { displayName: "Player Demo" });
+
+ // --- "adds six players with roles and sliders" -------------------------
+ await tap(page.getByTestId("nav-roster"));
+ const squad: Parameters[1][] = [
+ { name: "Ben W.", jersey: "1", roleCode: "sweeper_keeper", flank: "center", awr: "med", dwr: "high", pace: 2 },
+ { name: "Marco S.", jersey: "2", roleCode: "overlapping_fb", flank: "right", awr: "high", dwr: "high", pace: 5 },
+ { name: "Dev P.", jersey: "5", roleCode: "ball_playing_cb", flank: "center", awr: "med", dwr: "high", pace: 3 },
+ { name: "Nils B.", jersey: "4", roleCode: "single_pivot", flank: "center", awr: "med", dwr: "high", pace: 2 },
+ { name: "Jordan T.", jersey: "7", roleCode: "touchline_winger", flank: "right", awr: "high", dwr: "low", pace: 5 },
+ { name: "Sam O.", jersey: "9", roleCode: "runner_in_behind", flank: "center", awr: "high", dwr: "low", pace: 5 },
+ ];
+ for (const player of squad) await addPlayer(page, player);
+ await expect(page.getByTestId(/roster-row-\d+/)).toHaveCount(6);
+ // Marco (right back, AWR high) behind Jordan (right wing, AWR high /
+ // DWR low) is the designed double-exposure pair, and it is coach-only.
+ await expect(page.getByTestId("fit-warning-right")).toBeVisible();
+
+ // --- "opens Formations, loads 4-3-3, taps the pivot keystone" ----------
+ await tap(page.getByTestId("nav-formations"));
+ const formationsHandle = page.getByTestId("formations-sheet-handle");
+ if ((await formationsHandle.getAttribute("aria-expanded")) !== "true") {
+ await tap(formationsHandle);
+ }
+ await page.getByTestId("formations-search").fill("4-3-3");
+ await tap(page.getByTestId("formations-tile").first());
+ await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3");
+ // The pivot: slot "six", the single pivot keystone.
+ await page.locator('[data-token-id="six"]').click();
+ await expect(page.getByTestId("formations-keycard-title")).toHaveText("The 6 (single pivot)");
+ await tap(page.getByTestId("formations-keycard-close"));
+
+ // --- "toggles the Rondo Map and taps the first-line zone" -------------
+ await tap(page.getByTestId("formations-rondo-toggle"));
+ await expect(page.getByTestId("rondo-zone-layer")).toBeVisible();
+ await page.locator('[data-zone-key="first_line"]').click();
+ await expect(page.getByTestId("formations-zone-card")).toBeVisible();
+ await expect(page.getByTestId("formations-zone-title")).toContainText("4v2");
+ await tap(page.getByTestId("formations-rondo-active-toggle"));
+
+ // --- "opens Patterns, searches 'third man', plays A5 on the board" ----
+ await tap(page.getByTestId("nav-patterns"));
+ const patternsHandle = page.getByTestId("patterns-sheet-handle");
+ if ((await patternsHandle.getAttribute("aria-expanded")) !== "true") {
+ await tap(patternsHandle);
+ }
+ // Typed the way a coach says it, unhyphenated (pages/search.ts).
+ await page.getByTestId("patterns-search").fill("third man");
+ await expect(page.getByTestId("patterns-tile")).toHaveCount(1);
+ await tap(page.getByTestId("patterns-tile"));
+ await expect(page.getByTestId("patterns-meta-title")).toContainText("A5");
+ await expect(page.getByTestId("patterns-playing-pill")).toBeVisible();
+
+ // --- "opens the whiteboard, drags a build-out, records it, saves it" --
+ await tap(page.getByTestId("nav-whiteboard"));
+ await expect(page.getByTestId("board")).toBeVisible();
+ await tap(page.getByTestId("record"));
+ await expect(page.getByTestId("record-banner")).toBeVisible();
+ await dragToken(page, "home-5", 60, -20); // centre-back steps out
+ await dragToken(page, "home-2", 50, 10); // right back pushes on
+ await dragToken(page, "ball", 40, -30); // the ball follows the pass
+ await tap(page.getByTestId("stop-record"));
+ await page.getByTestId("record-name").fill("Our build-out vs press");
+ await tap(page.getByTestId("save-pattern"));
+ const saved = page.getByTestId("saved-pattern").filter({ hasText: "Our build-out vs press" });
+ await expect(saved).toHaveCount(1);
+ await expect(saved.getByTestId("saved-pattern-author")).toHaveText("COACH");
+
+ // --- "creates a session with A5 plus the recording and a note, sends" -
+ await tap(page.getByTestId("nav-sessions"));
+ await page.getByTestId("sessions-new-title").fill("Tuesday, wide overloads");
+ await tap(page.getByTestId("sessions-create"));
+ await page.getByTestId("session-note-input").fill("Watch both before training on Tuesday.");
+ await tap(page.getByTestId("session-note-save"));
+
+ await tap(page.getByTestId("session-add-item"));
+ await page.getByTestId("session-picker-search").fill("third man");
+ await expect(page.getByTestId("session-picker-row")).toHaveCount(1);
+ await tap(page.getByTestId("session-picker-row"));
+ await tap(page.getByTestId("session-picker-tab-saved"));
+ await page.getByTestId("session-picker-search").fill("build-out");
+ await tap(page.getByTestId("session-picker-row"));
+ await expect(page.getByTestId("session-item")).toHaveCount(2);
+
+ await tap(page.getByTestId("session-send"));
+ await expect(page.getByTestId("session-sent-pill")).toBeVisible();
+ await expect(page.getByTestId("session-viewed-counter")).toHaveText("0 of 1 viewed");
+
+ // --- "switches to a player account on a phone, opens the session,
+ // watches A5 portrait, marks watched" ------------------------------
+ await playerPage.getByTestId("nav-sessions").click();
+ await expect(playerPage.getByTestId("session-detail-title")).toHaveText(
+ "Tuesday, wide overloads"
+ );
+ await expect(playerPage.getByTestId("session-note")).toContainText("Watch both before training");
+ // Read-only: no receipt data reaches a player at all.
+ await expect(playerPage.getByTestId("session-receipt")).toHaveCount(0);
+ await expect(playerPage.getByTestId("session-viewed-counter")).toHaveCount(0);
+
+ const watchA5 = playerPage
+ .getByTestId("session-item")
+ .filter({ hasText: "Third-Man Run" })
+ .getByTestId("session-watch");
+ await watchA5.click();
+ await expect(playerPage.getByTestId("session-watch-title")).toContainText("Third-Man Run");
+ await expect(playerPage.getByTestId("pattern-board")).toBeVisible();
+ await expect(playerPage.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait");
+ await playerPage.getByTestId("session-watch-back").click();
+ await playerPage.getByTestId("session-mark-watched").click();
+ await expect(playerPage.getByTestId("session-watched-state")).toBeVisible();
+
+ // --- "back on the coach account the receipt counter reads 1 of N" -----
+ await page.reload();
+ await tap(page.getByTestId("nav-sessions"));
+ await expect(page.getByTestId("session-viewed-counter")).toHaveText("1 of 1 viewed");
+ await expect(page.getByTestId("session-receipt-state")).toHaveText("Viewed");
+
+ await assertCleanPage(playerPage, issues);
+ await assertCleanPage(page, issues);
+ await phoneContext.close();
+ });
+});
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
index c20c676..38f9b83 100644
--- a/e2e/fixtures.ts
+++ b/e2e/fixtures.ts
@@ -4,12 +4,24 @@ import { test as base, expect, Page } from "@playwright/test";
type Issues = { consoleErrors: string[]; failedRequests: string[]; serverErrors: string[] };
+/** Wires the clean-page listeners onto any page, not just the fixture's
+ * own. Journeys that need a SECOND account signed in at the same time
+ * (sessions, the demo path) open a second browser context, and its page
+ * would otherwise report console errors and 5xx responses that nothing
+ * ever asserts on. Call this right after creating that page. */
+export function watchPage(page: Page, issues: Issues): void {
+ 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()}`)
+ );
+}
+
export const test = base.extend<{ issues: Issues }>({
issues: async ({ page }, use) => {
const issues: Issues = { consoleErrors: [], failedRequests: [], serverErrors: [] };
- 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()}`));
+ watchPage(page, issues);
await use(issues);
},
});
@@ -108,6 +120,20 @@ export async function registerAndJoinTeam(
return { email };
}
+/** Signs an EXISTING account in on a fresh page (a second browser context,
+ * a second device). Pairs with registerCoach/registerPlayer, which return
+ * the email they created: the cross-device journey records on one device
+ * and replays on another as the SAME coach, which needs a real sign-in
+ * rather than another registration. */
+export async function signIn(page: Page, email: string): Promise {
+ await page.goto("/");
+ await page.getByRole("button", { name: /Already have an account/ }).click();
+ await page.getByLabel("Email").fill(email);
+ await page.getByLabel("Password").fill(PASSWORD);
+ await page.getByRole("button", { name: "Log in", exact: true }).click();
+ await expect(page.getByTestId("board")).toBeVisible();
+}
+
/** 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
diff --git a/e2e/phone.spec.ts b/e2e/phone.spec.ts
new file mode 100644
index 0000000..428569c
--- /dev/null
+++ b/e2e/phone.spec.ts
@@ -0,0 +1,142 @@
+// Phone pass (T-050, Brief step 24, PNG 14-20, 23, 28, 34-36, 43-45).
+//
+// The rest of the suite already runs every journey under BOTH Playwright
+// projects, and assertCleanPage fails any page that overflows horizontally,
+// so this file is not another copy of those journeys. It pins the things
+// that are specifically PHONE contracts and that no other spec asserts:
+//
+// - the sidebar collapses to the 52px icon rail (labels gone, icons kept)
+// - every one of the six pages renders portrait boards with no overflow
+// - the swipe-up sheets and the board's view menu open AND close by tap
+// - the player's session Watch view plays portrait on a phone
+//
+// It runs only under the mobile project; on desktop each test skips, since
+// every assertion here is about the phone breakpoint.
+
+import { test, expect, assertCleanPage, registerCoach, registerPlayer, watchPage } from "./fixtures";
+import type { Page } from "@playwright/test";
+
+const PAGES = ["whiteboard", "patterns", "sessions", "formations", "roster", "identity"] as const;
+
+test.beforeEach(({ viewport }) => {
+ test.skip((viewport?.width ?? 1440) > 700, "phone-breakpoint contracts only");
+});
+
+async function hasOverflow(page: Page): Promise {
+ return page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
+}
+
+test.describe("phone: icon rail, portrait boards, no overflow", () => {
+ test("every page fits the iPhone 13 frame", async ({ page, issues }) => {
+ await registerCoach(page, { teamName: "Phone FC" });
+
+ // The rail: icons only, no labels, and narrow (design README: "sidebar
+ // collapses to a 52px vertical icon rail").
+ const rail = page.locator(".app-sidebar");
+ const railBox = (await rail.boundingBox())!;
+ expect(railBox.width).toBeLessThanOrEqual(56);
+ await expect(page.locator(".app-nav-label").first()).toBeHidden();
+ for (const key of PAGES) {
+ await expect(page.getByTestId(`nav-${key}`)).toBeVisible();
+ }
+
+ for (const key of PAGES) {
+ await page.getByTestId(`nav-${key}`).click();
+ await expect(page.getByTestId(`nav-${key}`)).toHaveAttribute("aria-current", "page");
+ expect(await hasOverflow(page), `${key} overflows horizontally`).toBe(false);
+
+ // Every page that renders a board renders it PORTRAIT here. Roster is
+ // the one page in the nav with no board at all.
+ const board = page.locator(".board-wrap");
+ if ((await board.count()) > 0) {
+ await expect(board.first()).toHaveAttribute("data-orientation", "portrait");
+ const box = (await board.first().boundingBox())!;
+ // 7:10 portrait pitch (700x1000 viewBox), taller than it is wide.
+ expect(box.height).toBeGreaterThan(box.width);
+ expect(box.width).toBeLessThanOrEqual(390);
+ }
+ }
+
+ await assertCleanPage(page, issues);
+ });
+});
+
+test.describe("phone: sheets and menus are reachable and dismissible by touch", () => {
+ test("each swipe-up sheet and the board view menu opens and closes on tap", async ({
+ page,
+ issues,
+ }) => {
+ await registerCoach(page, { teamName: "Sheets FC" });
+
+ const sheets: [(typeof PAGES)[number], string, string][] = [
+ ["patterns", "patterns-sheet-handle", "patterns-sheet-body"],
+ ["formations", "formations-sheet-handle", "formations-sheet-body"],
+ ["identity", "identity-sheet-handle", "identity-sheet-body"],
+ ];
+
+ for (const [navKey, handle, body] of sheets) {
+ await page.getByTestId(`nav-${navKey}`).click();
+ const handleEl = page.getByTestId(handle);
+ await expect(handleEl).toBeVisible();
+ // tap(), not click(): this is the touch path a phone actually takes.
+ await handleEl.tap();
+ await expect(page.getByTestId(body)).toBeVisible();
+ expect(await hasOverflow(page), `${navKey} sheet overflows`).toBe(false);
+ await handleEl.tap();
+ await expect(page.getByTestId(body)).toHaveCount(0);
+ }
+
+ // The board's view menu is pinned to the viewport top on phone (it
+ // would otherwise blanket a narrow portrait pitch); it must still open
+ // and close by tap and never push the page sideways.
+ await page.getByTestId("nav-whiteboard").click();
+ await page.getByTestId("view-menu").tap();
+ await expect(page.getByTestId("zone-toggle-thirds")).toBeVisible();
+ expect(await hasOverflow(page)).toBe(false);
+ await page.getByTestId("zone-toggle-thirds").tap();
+ await expect(page.getByTestId("zone-toggle-thirds")).toBeChecked();
+ await page.getByTestId("view-menu").tap();
+ await expect(page.getByTestId("zone-toggle-thirds")).toHaveCount(0);
+
+ // The head coach's Manage team panel is the other overlay on this
+ // screen, and it is anchored near the right edge.
+ await page.getByTestId("team-members-toggle").tap();
+ await expect(page.getByTestId("team-members-panel")).toBeVisible();
+ expect(await hasOverflow(page)).toBe(false);
+ await page.getByTestId("team-members-toggle").tap();
+ await expect(page.getByTestId("team-members-panel")).toHaveCount(0);
+
+ await assertCleanPage(page, issues);
+ });
+});
+
+test.describe("phone: a player watches a session item portrait", () => {
+ test("the Watch deep-link plays on a portrait board", async ({ browser, page, issues }) => {
+ const { joinCode } = await registerCoach(page, { teamName: "Watch FC" });
+ const playerContext = await browser.newContext({ viewport: page.viewportSize() ?? undefined });
+ const playerPage = await playerContext.newPage();
+ watchPage(playerPage, issues);
+ await registerPlayer(playerPage, joinCode);
+
+ await page.getByTestId("nav-sessions").click();
+ await page.getByTestId("sessions-new-title").fill("Phone session");
+ await page.getByTestId("sessions-create").click();
+ await page.getByTestId("session-add-item").tap();
+ await page.getByTestId("session-picker-search").fill("third-man");
+ await page.getByTestId("session-picker-row").tap();
+ await page.getByTestId("session-send").tap();
+ await expect(page.getByTestId("session-sent-pill")).toBeVisible();
+
+ await playerPage.getByTestId("nav-sessions").click();
+ await playerPage.getByTestId("session-watch").first().tap();
+ await expect(playerPage.getByTestId("pattern-board")).toBeVisible();
+ await expect(playerPage.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait");
+ expect(await hasOverflow(playerPage)).toBe(false);
+ await playerPage.getByTestId("session-watch-back").tap();
+ await playerPage.getByTestId("session-mark-watched").tap();
+ await expect(playerPage.getByTestId("session-watched-state")).toBeVisible();
+
+ await assertCleanPage(playerPage, issues);
+ await playerContext.close();
+ });
+});
diff --git a/e2e/recorder.spec.ts b/e2e/recorder.spec.ts
index 7316654..0fa0bd4 100644
--- a/e2e/recorder.spec.ts
+++ b/e2e/recorder.spec.ts
@@ -170,3 +170,38 @@ test("record in one orientation, replay correctly in the other", async ({ page,
await assertCleanPage(page, issues);
});
+
+// The ball's gold trace while a take is being recorded (design README:
+// "Record (red dot): captures timestamped keyframes of every drag; ball
+// leaves a gold trace", PNG 03). It uses the same AnimationOverlay
+// polyline the playback trail uses, so this checks the one thing that
+// distinguishes it: the trace is being laid down DURING the recording,
+// before anything is saved or replayed.
+test("the ball leaves a gold trace while recording", async ({ page, issues }) => {
+ await registerCoach(page);
+ const trail = page.getByTestId("ball-trail");
+
+ // Nothing drawn on a fresh board (the attribute is not set at all until
+ // the overlay first renders).
+ expect(await trail.getAttribute("points")).toBeFalsy();
+
+ await page.getByTestId("record").click();
+ await dragTokenTo(page, "ball", { x: 40, y: 30 });
+ await dragTokenTo(page, "ball", { x: 65, y: 20 });
+
+ const points = (await trail.getAttribute("points")) ?? "";
+ expect(points.trim().length, "trace has geometry").toBeGreaterThan(0);
+ expect(points.trim().split(/\s+/).length, "trace has several waypoints").toBeGreaterThan(2);
+
+ // Dragging a PLAYER adds nothing to the trace: it is the ball's path.
+ const before = await trail.getAttribute("points");
+ await dragTokenTo(page, "home-7", { x: 78, y: 26 });
+ await expect(trail).toHaveAttribute("points", before!);
+
+ // Discarding the take clears it, so the next one starts on a clean pitch.
+ await page.getByTestId("stop-record").click();
+ await page.getByRole("button", { name: "Discard" }).click();
+ await expect(trail).toHaveAttribute("points", "");
+
+ await assertCleanPage(page, issues);
+});
diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts
new file mode 100644
index 0000000..ec5a341
--- /dev/null
+++ b/e2e/screenshots.spec.ts
@@ -0,0 +1,232 @@
+// Screenshot capture for docs/screenshots/ and the README.
+//
+// This is a CAPTURE SCRIPT, not a test. It asserts almost nothing: it
+// drives the real UI against the `make demo` database and writes PNGs.
+// Because it lives in the Playwright testDir alongside the real journeys,
+// it skips unless POP_SCREENSHOTS=1, so `make e2e` and `make verify` never
+// run it and never rewrite the images. Run it with `make screenshots`,
+// which reseeds the demo database first.
+//
+// Credentials and content come from scripts/seed_demo.py; if that file's
+// constants change, change them here too.
+//
+// Theme: the "pitch" theme (deep pitch green, mown stripes, trophy gold)
+// rather than "dark". Every shot here is a football board, and the flat
+// grey dark theme with its blue accent reads as a generic dashboard
+// instead of a pitch. The three themes all ship and all work; this is a
+// marketing choice about which one sells the product at a glance.
+//
+// Timing: animated surfaces are captured MID-motion, not at frame zero.
+// Playback runs at a known duration per surface, so each capture waits a
+// beat into the run rather than screenshotting the first frame.
+
+import { test, expect } from "./fixtures";
+import type { Page } from "@playwright/test";
+
+const OUT = "docs/screenshots";
+const COACH_EMAIL = "coach@example.com";
+const PLAYER_EMAIL = "player@example.com";
+const PASSWORD = "demo-pass-2026";
+const SESSION_TITLE = "Tuesday, wide overloads";
+
+const DESKTOP = { width: 1440, height: 900 };
+const PHONE = { width: 390, height: 844 };
+
+test.describe.configure({ mode: "serial" });
+
+test.beforeEach(() => {
+ test.skip(
+ process.env.POP_SCREENSHOTS !== "1",
+ "capture script, not a test: run `make screenshots`"
+ );
+});
+
+async function signInDemo(page: Page, email: string) {
+ await page.goto("/");
+ await page.getByRole("button", { name: /Already have an account/ }).click();
+ await page.getByLabel("Email").fill(email);
+ await page.getByLabel("Password").fill(PASSWORD);
+ await page.getByRole("button", { name: "Log in", exact: true }).click();
+ await expect(page.getByTestId("board")).toBeVisible();
+ // Pitch theme, and give the self-hosted fonts a moment to paint.
+ await page.getByTestId("theme-switch-pitch").click();
+ await page.evaluate(() => document.fonts.ready);
+}
+
+async function shot(page: Page, name: string) {
+ await page.screenshot({ path: `${OUT}/${name}.png` });
+}
+
+async function tokenCenter(page: Page, id: string) {
+ const box = (await page.locator(`[data-token-id="${id}"]`).boundingBox())!;
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
+}
+
+/** Landscape model coordinate to a client point on the rendered board, so
+ * these captures can place a token exactly where the shot needs it rather
+ * than nudging by guessed pixel offsets. */
+async function modelToClient(page: Page, m: { x: number; y: number }) {
+ const box = (await page.getByTestId("board").boundingBox())!;
+ return { x: box.x + (m.x / 100) * box.width, y: box.y + (m.y / 100) * box.height };
+}
+
+async function dragTo(page: Page, id: string, m: { x: number; y: number }) {
+ const from = await tokenCenter(page, id);
+ const to = await modelToClient(page, m);
+ await page.mouse.move(from.x, from.y);
+ await page.mouse.down();
+ await page.mouse.move(to.x, to.y, { steps: 14 });
+ await page.mouse.up();
+}
+
+test("captures the desktop set", async ({ browser }) => {
+ const context = await browser.newContext({ viewport: DESKTOP });
+ const page = await context.newPage();
+ await signInDemo(page, COACH_EMAIL);
+
+ // --- 01: the whiteboard with the lane graph live, mid-drag -------------
+ // The three lane states have to read together in one frame:
+ // confirmed (solid bright gold): the demo seed ships home-4 to home-5,
+ // and this shot leaves that line clear so it stays gold.
+ // blocked (dashed red plus the red interception dot): one opponent is
+ // parked on the home-2 to home-8 line, and only that line.
+ // suggested (dashed dim gold): everything else in range.
+ // An opponent dropped in the middle of the pitch blocks nearly every line
+ // at once and the whole board goes red, which is the opposite of the
+ // point, so this places it precisely rather than by pixel nudge.
+ await dragTo(page, "away-8", { x: 32, y: 23 });
+ await expect(page.getByTestId("board-save-status")).toHaveText("All changes saved");
+
+ // Now hold a second drag mid-flight, so the graph is visibly recomputing
+ // rather than settled.
+ const winger = await tokenCenter(page, "home-7");
+ const target = await modelToClient(page, { x: 60, y: 26 });
+ await page.mouse.move(winger.x, winger.y);
+ await page.mouse.down();
+ await page.mouse.move(target.x, target.y, { steps: 16 });
+ await page.waitForTimeout(120);
+ await shot(page, "01-whiteboard-lanes");
+ await page.mouse.up();
+
+ // --- 02: the recording UI with the ball's gold trace visible ------------
+ await page.getByTestId("record").click();
+ await expect(page.getByTestId("record-banner")).toBeVisible();
+ // A build-out: the centre-back steps out, the fullback goes high, and the
+ // ball travels keeper to centre-back to fullback, laying a gold trace
+ // behind it (design README, PNG 03).
+ await dragTo(page, "home-5", { x: 30, y: 34 });
+ await dragTo(page, "home-2", { x: 46, y: 12 });
+ const ball = await tokenCenter(page, "ball");
+ const via = await modelToClient(page, { x: 30, y: 34 });
+ const end = await modelToClient(page, { x: 46, y: 12 });
+ await page.mouse.move(ball.x, ball.y);
+ await page.mouse.down();
+ await page.mouse.move(via.x, via.y, { steps: 18 });
+ await page.mouse.move(end.x, end.y, { steps: 18 });
+ await page.waitForTimeout(120);
+ await shot(page, "02-whiteboard-recording");
+ await page.mouse.up();
+ await page.getByTestId("stop-record").click();
+ await page.getByRole("button", { name: "Discard" }).click();
+
+ // --- 03: the Patterns library sheet, chips and search ------------------
+ await page.getByTestId("nav-patterns").click();
+ await page.getByTestId("patterns-sheet-handle").click();
+ await expect(page.getByTestId("patterns-sheet-body")).toBeVisible();
+ await page.getByTestId("patterns-chip-combination").click();
+ await page.waitForTimeout(150);
+ await shot(page, "03-patterns-library-sheet");
+
+ // --- 04: a pattern mid-playback ----------------------------------------
+ await page.getByTestId("patterns-chip-all").click();
+ await page.getByTestId("patterns-search").fill("third man");
+ await page.getByTestId("patterns-tile").first().click();
+ await expect(page.getByTestId("patterns-playing-pill")).toBeVisible();
+ // A5 runs three declarative steps at 900ms each: land in the middle of
+ // the second, where the ball is in flight and the trail has built up.
+ await page.waitForTimeout(1400);
+ await shot(page, "04-pattern-playing");
+
+ // --- 05: Formations, 4-3-3, keystone pulsing with its keycard ----------
+ await page.getByTestId("nav-formations").click();
+ await expect(page.getByTestId("formations-meta-bar")).toContainText("4-3-3");
+ await page.locator('[data-token-id="six"]').click();
+ await expect(page.getByTestId("formations-keycard")).toBeVisible();
+ await page.waitForTimeout(400); // let the keystone pulse reach full glow
+ await shot(page, "05-formations-keystone");
+ await page.getByTestId("formations-keycard-close").click();
+
+ // --- 06: the Rondo Map with a zone selected ----------------------------
+ await page.getByTestId("formations-rondo-toggle").click();
+ await expect(page.getByTestId("rondo-zone-layer")).toBeVisible();
+ await page.locator('[data-zone-key="first_line"]').click();
+ await expect(page.getByTestId("formations-zone-card")).toBeVisible();
+ await page.waitForTimeout(150);
+ await shot(page, "06-rondo-map");
+
+ // --- 07: Identity, a reference team's signature idea playing -----------
+ await page.getByTestId("nav-identity").click();
+ await page.getByTestId("identity-sheet-handle").click();
+ await expect(page.getByTestId("identity-sheet-body")).toBeVisible();
+ await page.getByTestId("identity-search").fill("Barcelona");
+ await page.getByTestId("identity-tile").first().click();
+ await expect(page.getByTestId("identity-meta-bar")).toBeVisible();
+ await page.getByTestId("identity-details-toggle").click();
+ await page.waitForTimeout(1300); // mid-sequence, ball in flight
+ await shot(page, "07-identity-playing");
+
+ // --- 08: the Roster with the coach-only fit warning --------------------
+ await page.getByTestId("nav-roster").click();
+ await expect(page.getByTestId("fit-warning-right")).toBeVisible();
+ await page.getByTestId(/roster-row-\d+/).filter({ hasText: "Jordan Tavares" }).click();
+ await page.waitForTimeout(150);
+ await shot(page, "08-roster-fit-warning");
+
+ // --- 09: the session receipts view -------------------------------------
+ // The rail opens on the newest session, which the demo seed leaves as a
+ // draft (so a coach has something to send live); this shot wants the
+ // SENT one, with its counter and per-player receipts.
+ await page.getByTestId("nav-sessions").click();
+ await page.getByTestId("session-list-item").filter({ hasText: SESSION_TITLE }).click();
+ await expect(page.getByTestId("session-sent-pill")).toBeVisible();
+ await page.waitForTimeout(150);
+ await shot(page, "09-session-receipts");
+
+ await context.close();
+});
+
+test("captures the phone set", async ({ browser }) => {
+ const context = await browser.newContext({
+ viewport: PHONE,
+ hasTouch: true,
+ isMobile: true,
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // --- 10: a pattern playing portrait on a phone -------------------------
+ await signInDemo(page, COACH_EMAIL);
+ await page.getByTestId("nav-patterns").click();
+ await page.getByTestId("patterns-sheet-handle").tap();
+ await page.getByTestId("patterns-search").fill("third man");
+ await page.getByTestId("patterns-tile").first().tap();
+ await expect(page.locator(".board-wrap")).toHaveAttribute("data-orientation", "portrait");
+ await page.waitForTimeout(1400);
+ await shot(page, "10-phone-pattern-portrait");
+ await context.close();
+
+ // --- 11: the player's session view -------------------------------------
+ const playerContext = await browser.newContext({
+ viewport: PHONE,
+ hasTouch: true,
+ isMobile: true,
+ deviceScaleFactor: 2,
+ });
+ const playerPage = await playerContext.newPage();
+ await signInDemo(playerPage, PLAYER_EMAIL);
+ await playerPage.getByTestId("nav-sessions").tap();
+ await expect(playerPage.getByTestId("session-detail-title")).toBeVisible();
+ await playerPage.waitForTimeout(150);
+ await shot(playerPage, "11-phone-player-session");
+ await playerContext.close();
+});
diff --git a/e2e/sessions.spec.ts b/e2e/sessions.spec.ts
new file mode 100644
index 0000000..21aaa89
--- /dev/null
+++ b/e2e/sessions.spec.ts
@@ -0,0 +1,192 @@
+// Sessions journey (T-042, Brief step 23, PNG 21-23, 26, 28). Runs under
+// both Playwright projects (desktop landscape, iPhone 13 portrait) and
+// covers the "Roles and sessions" DoD line from Brief section 5:
+//
+// "Session receipts: Mark as watched increments the coach's x/y counter
+// and flips that player's row to Viewed; players never see receipt data
+// in any payload."
+//
+// plus the design README's Sessions spec: draft with reorder/remove items,
+// "+ Add from library" picker with mini-board thumbnails over both presets
+// and My patterns, coach note, send, gold SENT pill with the viewed
+// counter, and the player's read-only view with Watch deep-links.
+
+import {
+ test,
+ expect,
+ assertCleanPage,
+ registerCoach,
+ registerPlayer,
+ watchPage,
+} from "./fixtures";
+import type { Page } from "@playwright/test";
+
+async function recordAPattern(page: Page, name: string) {
+ await page.getByTestId("record").click();
+ const token = page.locator('[data-token-id="home-4"]');
+ const box = (await token.boundingBox())!;
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2 - 40, { steps: 10 });
+ await page.mouse.up();
+ await page.getByTestId("stop-record").click();
+ await page.getByTestId("record-name").fill(name);
+ await page.getByTestId("save-pattern").click();
+ await expect(page.getByTestId("saved-pattern").filter({ hasText: name })).toHaveCount(1);
+}
+
+test.describe("sessions: coach builds and sends, player watches and marks watched", () => {
+ test("the full classroom loop", async ({ browser, page, issues }) => {
+ // The coach records something of their own first, so the picker has
+ // both sources to offer (presets and My patterns).
+ const { joinCode } = await registerCoach(page, { teamName: "Sessions FC" });
+ await recordAPattern(page, "Our build-out vs press");
+
+ // A player must be on the team before the send: receipts are written
+ // for every recipient AT send time (doc 03 section 6).
+ const playerContext = await browser.newContext({
+ viewport: page.viewportSize() ?? undefined,
+ });
+ const playerPage = await playerContext.newPage();
+ watchPage(playerPage, issues);
+ await registerPlayer(playerPage, joinCode, { displayName: "Jordan T." });
+
+ // --- Coach: the draft builder ---------------------------------------
+ await page.getByTestId("nav-sessions").click();
+ await expect(page.getByTestId("nav-sessions")).toHaveAttribute("aria-current", "page");
+ await expect(page.getByTestId("sessions-empty")).toBeVisible();
+
+ await page.getByTestId("sessions-new-title").fill("Tuesday, wide overloads");
+ await page.getByTestId("sessions-create").click();
+ await expect(page.getByTestId("session-detail-title")).toHaveText("Tuesday, wide overloads");
+ await expect(page.getByTestId("session-draft-pill")).toBeVisible();
+ await expect(page.getByTestId("session-items-empty")).toBeVisible();
+ // An empty session cannot be sent.
+ await expect(page.getByTestId("session-send")).toBeDisabled();
+
+ await page.getByTestId("session-note-input").fill("Watch both before training on Tuesday.");
+ await page.getByTestId("session-note-save").click();
+
+ // Picker: library presets, each with a mini-board thumbnail.
+ await page.getByTestId("session-add-item").click();
+ await expect(page.getByTestId("session-picker")).toBeVisible();
+ await page.getByTestId("session-picker-search").fill("third-man");
+ await expect(page.getByTestId("session-picker-row")).toHaveCount(1);
+ await expect(page.getByTestId("session-picker-row").locator("svg.tile-thumb")).toBeVisible();
+ await page.getByTestId("session-picker-row").click();
+ await expect(page.getByTestId("session-item")).toHaveCount(1);
+
+ // Picker: My patterns, the coach's own recording.
+ await page.getByTestId("session-picker-tab-saved").click();
+ await page.getByTestId("session-picker-search").fill("build-out");
+ await expect(page.getByTestId("session-picker-row")).toHaveCount(1);
+ await page.getByTestId("session-picker-row").click();
+ await expect(page.getByTestId("session-item")).toHaveCount(2);
+ await page.getByTestId("session-add-item").click(); // close the picker
+
+ const itemNames = page.getByTestId("session-item").locator(".sessions-item-name");
+ await expect(itemNames.nth(0)).toContainText("Third-Man Run");
+ await expect(itemNames.nth(1)).toHaveText("Our build-out vs press");
+
+ // Reorder, then put it back (design README: "reorder/remove items").
+ await page.getByTestId("session-item-up").nth(1).click();
+ await expect(itemNames.nth(0)).toHaveText("Our build-out vs press");
+ await page.getByTestId("session-item-down").nth(0).click();
+ await expect(itemNames.nth(0)).toContainText("Third-Man Run");
+
+ // Remove works, and re-adding restores the pair.
+ await page.getByTestId("session-item-remove").nth(1).click();
+ await expect(page.getByTestId("session-item")).toHaveCount(1);
+ await page.getByTestId("session-add-item").click();
+ await page.getByTestId("session-picker-tab-saved").click();
+ await page.getByTestId("session-picker-row").filter({ hasText: "build-out" }).click();
+ await expect(page.getByTestId("session-item")).toHaveCount(2);
+
+ // The recipient shows as "Will receive" before the send.
+ await expect(page.getByTestId("session-receipt")).toHaveCount(1);
+ await expect(page.getByTestId("session-receipt-state")).toHaveText("Will receive");
+
+ // --- Send -------------------------------------------------------------
+ await page.getByTestId("session-send").click();
+ await expect(page.getByTestId("session-sent-pill")).toBeVisible();
+ await expect(page.getByTestId("session-viewed-counter")).toHaveText("0 of 1 viewed");
+ await expect(page.getByTestId("session-receipt-state")).toHaveText("Not yet");
+ // A sent session is a record, not a draft: no builder controls remain.
+ await expect(page.getByTestId("session-add-item")).toHaveCount(0);
+ await expect(page.getByTestId("session-item-remove")).toHaveCount(0);
+ await expect(page.getByTestId("session-note")).toContainText("Watch both before training");
+
+ // --- Player: read-only, Watch deep-link, Mark as watched ---------------
+ await playerPage.getByTestId("nav-sessions").click();
+ await expect(playerPage.getByTestId("session-list-item")).toHaveCount(1);
+ await expect(playerPage.getByTestId("session-list-item")).toContainText("New");
+ await expect(playerPage.getByTestId("session-detail-title")).toHaveText(
+ "Tuesday, wide overloads"
+ );
+ await expect(playerPage.getByTestId("session-note")).toContainText("Watch both before training");
+
+ // Receipts never render for a player (they are absent from the payload).
+ await expect(playerPage.getByTestId("session-receipt")).toHaveCount(0);
+ await expect(playerPage.getByTestId("session-viewed-counter")).toHaveCount(0);
+ await expect(playerPage.getByTestId("session-send")).toHaveCount(0);
+ await expect(playerPage.getByTestId("session-add-item")).toHaveCount(0);
+
+ // Watch opens the item on the board, playing.
+ await expect(playerPage.getByTestId("session-watch")).toHaveCount(2);
+ await playerPage.getByTestId("session-watch").first().click();
+ await expect(playerPage.getByTestId("session-watch-view")).toBeVisible();
+ await expect(playerPage.getByTestId("session-watch-title")).toContainText("Third-Man Run");
+ await expect(playerPage.getByTestId("pattern-board")).toBeVisible();
+ // Boards render portrait on a phone viewport and landscape otherwise,
+ // on this surface exactly like every other (design README).
+ const expectedOrientation =
+ (playerPage.viewportSize()?.width ?? 1440) <= 700 ? "portrait" : "landscape";
+ await expect(playerPage.locator(".board-wrap")).toHaveAttribute(
+ "data-orientation",
+ expectedOrientation
+ );
+ await playerPage.getByTestId("session-watch-back").click();
+ await expect(playerPage.getByTestId("session-watch-view")).toHaveCount(0);
+
+ await playerPage.getByTestId("session-mark-watched").click();
+ await expect(playerPage.getByTestId("session-watched-state")).toBeVisible();
+ await expect(playerPage.getByTestId("session-list-item")).toContainText("Watched");
+
+ // --- Back on the coach account: the counter has moved -----------------
+ await page.reload();
+ await page.getByTestId("nav-sessions").click();
+ await expect(page.getByTestId("session-viewed-counter")).toHaveText("1 of 1 viewed");
+ await expect(page.getByTestId("session-receipt-state")).toHaveText("Viewed");
+
+ await assertCleanPage(playerPage, issues);
+ await assertCleanPage(page, issues);
+ await playerContext.close();
+ });
+});
+
+test.describe("sessions: a player never sees a draft", () => {
+ test("drafts are invisible until they are sent", async ({ browser, page, issues }) => {
+ const { joinCode } = await registerCoach(page, { teamName: "Drafts FC" });
+ const playerContext = await browser.newContext({
+ viewport: page.viewportSize() ?? undefined,
+ });
+ const playerPage = await playerContext.newPage();
+ watchPage(playerPage, issues);
+ await registerPlayer(playerPage, joinCode);
+
+ await page.getByTestId("nav-sessions").click();
+ await page.getByTestId("sessions-new-title").fill("Matchday prep");
+ await page.getByTestId("sessions-create").click();
+ await expect(page.getByTestId("session-draft-pill")).toBeVisible();
+
+ await playerPage.getByTestId("nav-sessions").click();
+ await expect(playerPage.getByTestId("sessions-empty")).toBeVisible();
+ await expect(playerPage.getByTestId("session-list-item")).toHaveCount(0);
+ // The player has no way to make one either.
+ await expect(playerPage.getByTestId("sessions-create")).toHaveCount(0);
+ await expect(playerPage.getByTestId("sessions-new-title")).toHaveCount(0);
+
+ await assertCleanPage(playerPage, issues);
+ await playerContext.close();
+ });
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 297fded..b22b470 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,6 +7,7 @@ import { AppShell, type NavKey } from "./AppShell";
import { TeamOnboarding } from "./TeamOnboarding";
import { WhiteboardPage } from "./pages/WhiteboardPage";
import { PatternsPage } from "./pages/PatternsPage";
+import { SessionsPage } from "./pages/SessionsPage";
import { RosterPage } from "./pages/RosterPage";
import { FormationsPage } from "./pages/FormationsPage";
import { IdentityPage } from "./pages/IdentityPage";
@@ -14,11 +15,12 @@ import ThemeSwitcher from "./theme/ThemeSwitcher";
import "./App.css";
// Every screens ticket has landed (T-030 Whiteboard, T-031 Patterns,
-// T-032 Formations, T-033 Roster, T-034 Identity): all five nav entries
-// are live.
+// T-032 Formations, T-033 Roster, T-034 Identity) and T-042 adds Sessions:
+// all six nav entries are live.
const ENABLED_NAV_KEYS: readonly NavKey[] = [
"whiteboard",
"patterns",
+ "sessions",
"roster",
"formations",
"identity",
@@ -127,6 +129,8 @@ export default function App() {
>
{page === "patterns" ? (
setPage("whiteboard")} />
+ ) : page === "sessions" ? (
+
) : page === "roster" ? (
) : page === "formations" ? (
diff --git a/frontend/src/AppShell.css b/frontend/src/AppShell.css
index 30a3f8e..2384a06 100644
--- a/frontend/src/AppShell.css
+++ b/frontend/src/AppShell.css
@@ -222,10 +222,46 @@
.app-main {
padding: 10px;
}
- /* Team meta (name, role, join code, log out) stays visible on phone: the
+ /* Team meta (name, role, join codes, 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. */
+ already wraps (flex-wrap), so this never forces horizontal overflow.
+ What it DID do was eat a fifth of an iPhone 13's height before the
+ board started (T-050), so on phone it compresses: tighter padding, the
+ two join codes side by side on one line instead of stacked, and
+ smaller type throughout. Nothing is hidden. */
.app-topbar {
- padding: 10px 12px;
+ padding: 8px 12px;
+ gap: 8px;
+ }
+ .app-topbar-end {
+ gap: 8px;
+ }
+ .team-meta {
+ gap: 8px;
+ }
+ .team-meta-name {
+ font-size: 12px;
+ }
+ .team-meta-role,
+ .join-code {
+ font-size: 10px;
+ }
+ .join-codes {
+ flex-direction: row;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+ .team-meta-logout,
+ .team-members-toggle {
+ padding: 4px 10px;
+ font-size: 11px;
+ }
+ /* The panel is anchored to the toggle, which sits near the right edge on
+ phone: pin it to the viewport instead so it can never overhang. */
+ .team-members-panel {
+ right: -8px;
+ min-width: 0;
+ width: calc(100vw - 24px);
+ max-width: calc(100vw - 24px);
}
}
diff --git a/frontend/src/AppShell.tsx b/frontend/src/AppShell.tsx
index 0f3eeb8..81dba3f 100644
--- a/frontend/src/AppShell.tsx
+++ b/frontend/src/AppShell.tsx
@@ -28,6 +28,21 @@ function IconPatterns() {
);
}
+function IconSessions() {
+ // Paper plane (mockup nav, PNG 21-23): "send this to the team".
+ return (
+
+ );
+}
function IconFormations() {
return (