feat(tactics): Tactics Lab, phases, superiority engine, personnel panel (T-100 epic) - #19
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…T-030) Whiteboard page (Brief step 16, PNG 01-05/14/34): toolbar, view menu (zones + thresholds), record/replay, and save into My Patterns, all persisted through the scoped query layer (backend/app/routers/whiteboard.py) into the boards and saved_patterns rows (doc 03 4.2/4.3). Delete is coach-only, enforced server-side (403 for players), and every saved pattern is author-stamped from the caller's own membership, never a client-supplied field. Fixes a real reload bug: the view menu's settings popover floated above the toolbar and, left open, could visually sit on top of a token (e.g. the ball's default spot, or any token near the toolbar on a phone-width board). A drag or click landing there hit the popover instead of the token, and for a checkbox's label that silently flipped a zone toggle the user never touched, which then persisted to the boards row on reload. Fixed by closing the view menu when recording starts (a deliberate mode switch) and, for phone-width boards where the popover has no gap in the pitch it can occupy without covering some token, pinning it to the viewport's top edge instead of floating over the pitch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, dev.sh overlap with T-031)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Systematic permission test suite covering every Brief section 3 table row in both roles, API and UI: backend/tests/test_permissions.py names each test after its table row (whiteboard, delete-a-pattern, library/ formations/identity, roster/fit-warnings, plus the additive-only and player-token-gets-403 principles), and e2e/permissions.spec.ts sweeps a player-role visit across all five pages asserting coach-only controls and data are absent from the DOM, not merely hidden. Audit result: no enforcement gaps found. Whiteboard delete, roster CRUD, and fit_warnings exclusion were already API-enforced from their own tickets (T-030, T-033); this suite pins that behavior explicitly against the table rather than leaving it implicit across five separate files. Two table rows (Suggest own playstyle, Sessions) have no API surface yet in this codebase state, only their models: marked as named, skipped tests with a reason, for T-041/T-042 to turn into real assertions rather than silently omitted. One known ambiguity is pinned, not changed, per this ticket's own instruction: the API returns join_code to any team member while the UI shows it to coaches only (docs/agent/STATE.md open founder question 2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Player submits a free-text playstyle suggestion on their own roster profile, sees it pending; coach reviews from a team-wide queue with a gold badge and an Approve/Dismiss card; approve merges the text into players.playstyle_note (visible both roles), dismiss clears it with no merge. New backend/app/routers/suggestions.py enforces every direction at the API: only a player may submit, and only against their own linked roster row; only a coach may list the pending queue, approve, or dismiss (403 otherwise). No new migration: playstyle_suggestions already exists in the 0002 schema migration. Player-row linkage (needed for "own profile") did not exist yet before this ticket; app/routers/roster.py adds a minimal, UI-free claim: on a player's own GET /api/roster, an unclaimed row whose name uniquely matches their display_name becomes theirs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doc 06 sections 2.4, 4 and 5.1. The playback and rendering machinery a Formations page rebuild (T-106) sits on top of. No page consumes it yet. Phase morph. A third builder in the board engine that produces the same `Playback` object the declarative-spec and raw-keyframe builders already produce, driven by the same PlayerController on the same FrameLoop clock. One player, one timing model, one coordinate model, three builders. The two content formats stay separate and untouched; a morph is neither of them, it is a computed transition between two seeded position sets with no steps, no ball waypoints and no route badges. Binding is by SLOT. Not by array index, not by position code, not by coordinate. Slot identity is the only thing that survives a phase change, so it is what the animation is keyed on: the coach watches their left back walk into midfield. Tests bind a deliberately shuffled to-phase, a pair where two slots share a position code and one changes code entirely, the case where every slot moves at once, and the case where a slot's coordinates are identical in both phases and must not jitter by an ulp on any of 120 sampled frames. Opponent layer. Opponents are seeded in their own frame and crossed into ours by mirrorOpponent from the T-104 superiority engine and by nothing else. There is one mirror in the codebase and this calls it. Shape-level round trip is asserted over the full 101 by 101 integer grid. Their tokens render as side "away", the colour the board already defines for recorded opponents. Token ids are namespaced so both elevens can carry a slot called "LB" and still be two tokens on one board. Landscape model coords throughout; orientation appears nowhere in either module, and a portrait test proves the consequence rather than asserting the absence. 600ms, deterministic, no clock read and no randomness in the logic. PatternPreviewBoard is extended, not forked: one new optional prop, showRestart, defaulting to true so every existing caller is unchanged. A phase morph is a transition into a state the coach then reads, not something to replay. Benchmarked per doc 06 section 4 rather than assumed. Build median 0.008ms, a full second of 60fps playback with 22 tokens median 0.051ms, single frame sample 0.0005ms. Verify: POP_WEB_PORT=6273 POP_API_PORT=9100 make verify, exit 0. 213 vitest, 277 pytest, 100 e2e both viewports, em-dash scan clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doc 06 sections 5.1, 5.2 and 5.4, on top of T-032's board-first shell. Board, floating meta bar and page-level swipe-up sheet are unchanged, and so are the keystone pulse, the keycard, the details panel and the searchable formation browser. What is new is the meta bar's five controls and what they put on the same board. - Phase segment (Base / With the ball / Without the ball / Rest defence), morphing over 600ms through T-105's morphToPhase: one Playback, one clock, destination tokens. Variant chips reach the second and third seeded shape of a phase. A caption strip under the board names the resulting shape and its trigger. - Opposition toggle with an opponent formation picker and an opponent phase picker defaulted to their out-of-possession variant. Their shape is passed in their own frame as seeded and mirrored once, inside the board engine. - Live rondo counts from T-104's countZone. A ratio on screen is either COMPUTED from the two shapes on the pitch (verdict-coloured, data-source="computed") or the SEEDED no-opposition fallback (muted, dashed, data-source="seeded"). The ratio is out of the zone's display name entirely so the two can never be confused. - Rotations list filtered to the formation, playing each one on the board and opening a card whose risk line has equal visual weight to the gain: same grid track, same padding, same type scale, same colour, asserted in the journey. - Positional grid overlay, off by default, drawing the 5 by 5 grid and tinting any band gridOccupancy raises a check about. Breach copy is a question, never an error. Phone portrait: the meta bar collapses to an icon row and every control opens a bottom sheet; count chips shrink to the ratio and the read moves into the tapped zone card. The bar stops floating on phone, because six controls over a 7:10 board sit on top of the striker. The counterpress ring is no longer drawn. Doc 06 section 5.1 renders it only when a ball is placed, there is no ball on this page, and formation_phases has no ball column, so its seeded polygon (a bound on half the pitch) would have produced a meaningless ratio. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ring Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ball fallback Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…112) Doc 06 section 0 approved a six zone Rondo Map on all six formations. T-106 shipped five: it stopped drawing counterpress_ring because section 5.1 gates the ring on "a ball is placed or a phase with a defined ball position is active", formation_phases has no ball column, and the seeded polygon bounds half the pitch. Correct call on what it was shown. It was never shown section 2.3, which defines the ring as a circle of radius 18 centred on the ball OR, with no ball placed, on the centroid of our three most advanced players. That fallback is unconditional, so the ring is always renderable. Section 2.3 defines the object and gives it a centre for exactly this case; section 5.1's clause assumes a ball affordance the page does not have. Resolved toward 2.3. Wire: RondoZoneOut and the formations router now carry canonical_rondo, zone_kind and radius, added to the model by T-101 and seeded by T-103 but never put on the wire. ZONE_ORDER's last key was still the pre-0007 `counterpress`, which sorted the ring last by accident rather than by intent; it now names counterpress_ring. Render: the ring is a circle, never its seeded polygon. Centre comes from ringCentre() and recomputes with the phase, so it travels with the shape, which is the teaching point. Radius comes off the seeded column. Drawn in a 0 0 100 100 viewBox with preserveAspectRatio none, which is normalized render space, so the centre maps through modelToRender and the radius is the same number on both axes in either orientation. On screen that is an ellipse whose aspect is the pitch's, and that is the correct shape: it is the same locus pointInCircle counts inside, so the drawing and the count never disagree. The e2e measures this rather than asserting it. Counting: the ring goes through T-104's countZone as a circle zone, so there is no second containment test. It stays OUT of buildRead, whose spare/short read only means something across zones that partition; the ring overlaps by design. Its chip follows the same rule as every other zone, seeded and muted with opposition off, computed and verdict coloured with opposition on. Hit testing: only the ring's LINE takes a tap (pointer-events: stroke on a transparent 22px band). Its interior is not hit tested at all, so every tap inside it falls through to the polygon zone underneath. splitRondoName is gone. rondoDisplayName keeps the ratio out of the zone title; the fallback chip reads canonical_rondo off the wire. T-106's two five-zone assertions are corrected rather than loosened: five polygons plus one circle, and the ring's seeded polygon is still asserted absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…110)
Closes the spec gap T-108 flagged: doc 06 calls unit-balance warnings the
core mechanic but never says how a formation's eleven slots map to the
seven unit_balance_rules units.
Per the founder's approved decision (2026-08-07):
- seeds/formations.json now carries slot_family on all 66 slots, seeded
per formation rather than derived from position_code, which cannot
tell a back three's cb_wide from its cb_central nor a six from an
eight when both are CM.
- app/units.py holds the fixed slot_family-to-unit crosswalk, the
per-formation unit membership resolver, and the evaluator, which
reuses the role_clashes evaluation shape and carries the seeded
warning_copy through verbatim.
- POST /api/formations/{code}/balance is the coach-only surface. A
player token gets 403.
A unit the formation does not contain is never evaluated: a 4-3-3
resolves midfield_three, front_three, back_line and two wide units, and
double_pivot and strike_pair are reported in units_not_evaluated rather
than scored empty and made to trip every requires_duty rule. Nothing
assigned is a first-class silent 200, and requires_duty rules wait for a
complete unit so a coach is not shouted at mid-click.
Validator gains three checks: slot_family required on every formation
position, drawn from doc 06's ten families, and naming a family some
archetype actually belongs to. All 33 formation_phases rows still carry
their base formation's slot set, position codes and coordinates
unchanged.
Left undefined rather than invented: the 5-4-1's flat midfield four and
lone striker match no doc 06 unit, so that shape evaluates its back line
only. box_midfield stays reachable from no family, matching T-102
seeding it no rules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ance, footedness (T-107)
Adds the Formations page's personnel panel (doc 06 sections 2.6, 2.7,
5.3, 5.4) as a second sheet segment alongside "Browse formations": for
each of the eleven slots, a player picker from the team roster, an
archetype picker, the top-three ranked suggestions with the API's own
cited reason, and a coach-only footedness note computed per doc 06
section 2.7. A live unit balance read (POST /formations/{code}/balance)
renders inline underneath, reading the seeded warning_copy verbatim.
Backend: adds slot_family (optional, additive) to FormationPositionOut
and populates it in GET /formations, the one field the panel needs to
call the already-shipped /archetypes and /archetypes/suggest routes.
Suggestions, unit balance, and footedness notes are gated on role_on_team
=== "coach" both in which requests fire and in the DOM (absent, not
hidden, for a player token); T-106's "player role reaches every control
with no failed request" tripwire now also walks into the personnel panel
to prove it. Empty roster is a first-class state: the panel still ranks
archetypes and evaluates balance with nobody assigned. Playwright
journeys at both viewports cover the empty-roster state, assignment, an
archetype pick via a suggestion tap, the top-three cited reasons, a unit
balance note appearing as archetypes change, and a footedness note.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(T-109) Wire POP_WEB_PORT/POP_API_PORT into make verify/e2e/dev by deriving a stable per-worktree default from the checkout path (cksum of CURDIR, folded into 0-999), so parallel ticket worktrees no longer share one dev server and silently test the wrong worktree's code. Explicit env vars still win via ?=, preserving the manual-override contract every ticket in this epic relied on. Extend the Brief section 6 demo path with a Tactics Lab beat: the coach morphs to the in-possession phase, turns the opposition on, and reads a computed superiority, immediately after the existing seeded-rondo beat it deliberately contrasts with. Fix the stale `counterpress`/single `flank_corridor` inline comment on RondoZone.zone_key, which migration 0007 (T-111) obsoleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on (T-070) Derives transparent-background brand assets from the source logo PNG via a committed, re-runnable Pillow script (scripts/build_logo_assets.py) since the source sits on a solid red field that also appears inside the leaf, the shield ring, and every closed wordmark letterform (P, R, A, O). A plain border flood fill leaves those as opaque red islands, so the script keys background by colour distance plus connected-component size instead: large enclosed red regions (leaf, ring) stay opaque, small ones (trapped letter counters) go transparent alongside the outer field. - Nav rail: app-brand-dot placeholder replaced with the shield+stars mark (shield-mark-96/144.png) in both AppShell.tsx and App.tsx's pre-auth MinimalShell, decorative (alt="", aria-hidden) so "Patterns of Play" stays the h1's accessible name for App.test.tsx. - Sign-in: full lockup (shield + stars + grass + wordmark) added above the AuthForms card via a new .auth-lockup class; TeamOnboarding's create-team/join-code screens deliberately left text-only (brand moment belongs once, at the true entry point, and phone vertical space is tight). - favicon-16/32/48.png plus a flattened (opaque, iOS-safe) apple-touch-icon wired into index.html; title stays "Patterns of Play". All derivatives are RGBA PNGs, no baked theme colours in any component; the one exception (apple-touch-icon's navy backing) is a raster icon pixel, not a themed surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review defects on the T-070 logo integration: 1. logo-lockup.png's flat wordmark band was white text with no backing once the red field was keyed out, near-invisible on the board theme's light background. Dropped it from the crop entirely (the shield's own arched "PATTERNS OF PLAY" lettering, white on navy, already carries the name and reads on any ground); the sign-in img is now decorative (alt="", aria-hidden) since MinimalShell's h1 already exposes the page's accessible name. 2. favicon-16.png was a muddy downsample of the full crest: the star arc turned to noise and the shield silhouette didn't survive. Added a stars-free shield-only crop plus a build_favicon_mini flattening pass (shield navy + the leaf's own connected component only, dropping the gold border, the ring, and the arc lettering that can't survive resampling to 16px) so the favicon is designed for 16px instead of downsampled to it. 32px keeps the full engraved detail, which is legible at that size. Dropped favicon-48.png: nothing in the app requests it (no manifest, no browserconfig tile). Added a root favicon.ico (built from the same flattened mark) since Chrome requests /favicon.ico by habit regardless of <link rel="icon">, and that request 404ed with nothing at that path. 3. shield-mark-144.png was unused: sizes="28px" never selects a 144w candidate over the 96w one that already covers a 3x device. Dropped the file and the srcSet/sizes attributes in both AppShell.tsx and App.tsx's MinimalShell. scripts/build_logo_assets.py grows the row-window and connected-component helpers needed for the two new crops; still re-run by hand, still not a runtime dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pitch default theme becomes the red brand theme (shield navy chrome, brand red accent, shield gold advisories), and dark and board are restyled to the same brand. Founder palette directive of 2026-08-07, which supersedes the design README's gold-accent token table. Board decoupled from chrome first, because the rest was not safe without it. The board had no colours of its own: PitchMarkings filled with var(--pitch-turf, transparent), a token nothing defined, so the pitch only looked green because Board.css painted the wrapper with the chrome's --bg-stripe. Home mapped to --accent and away to --red, so a red accent collapsed the two teams. Confirmed lanes read --accent and blocked lanes --red, so a red accent made "this pass is on" and "this pass is blocked" the same colour. There is now a real board token layer (--pitch-turf, --pitch-stripe, --pitch-line, --token-face, --team-home, --team-away, --ball, --lane-*, --intercept, --mark, --zone, --keystone, --route-badge*), defined per theme with literal values and no chrome fallback. --bg-stripe is deleted so there is no chrome token left to mistake for turf. The board keeps the design README's football language in all three themes: green turf with mown stripes, gold "the pass is on", red "blocked / opposition / marking". Chrome resolves the accent-red vs status-red ambiguity by family and by form: --accent (brand red) is the only interactive colour and the only red fill, --warn (shield gold) carries advisories and read-only emphasis (fit warnings, clash notes, SENT pill, receipts, verdict chips, labels), and --red is failure only, rendered as text, a 1px outline, or a faint tint, never a fill on a control. The recording toggle keeps the accent but gains a ring and a pulse so colour alone never distinguishes it from the permanently active select tool. Contrast is computed, not eyeballed: every text pair clears 4.5:1 and every graphical pair that carries meaning clears 3:1, in all three themes. Left behind so the layers cannot be recoupled: - scripts/check_palette.py, run by make check-copy: both layers complete in every theme, no board token equal to a chrome colour, no football selector reading --accent/--glow/--red/--warn, AA on every pair, three distinct themes. - e2e/palette.spec.ts, both viewports: overrides --accent/--glow/--red at the document root and asserts the turf, both teams and a confirmed lane are unchanged, plus the brand-red/gold/crimson split on the roster and the coach-only fit warning absent from a player's DOM.
Three comments referenced frontend/src/styles/tokens.test.ts, which was never created. The static guard is scripts/check_palette.py, wired into make check-copy. A stale pointer to a nonexistent guard is the same failure mode this epic exists to fix in the design README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #18 squashed T-100, T-101 and T-104 onto main as one commit, which is not in integration's history, so git saw a conflict on every file those tickets touched that later tickets also changed. Integration is strictly ahead: main's only unique commit is that squash, and its content is already here. Resolved every conflict to integration's side; the resulting tree is identical to integration before the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes Epic T-100 (Tactics Lab) per
docs/source/06_Tactical_Depth_Spec.md. The Formations page stops being a shape browser and becomes a tactics lab.What a coach can now do
Pick a base shape and watch it morph into what it actually looks like with the ball (3-2-5, 2-3-5, box midfield) and without it (4-4-2 mid block, 5-4-1 low block). Drop an opponent shape on top and read where they are spare, where they are short, and which route connects them. Play a named rotation and see the shape reorganise, with its cost given equal weight to its benefit. Assign real roster players into slots, pick an archetype for each, and get told which combinations balance.
Every number on screen is computed from coordinates, not authored.
Tickets
0006: eight new tables, rondo_zones L/R corridor data migration0007: delete the orphancounterpressrondo zone rowDefinition of done (doc 06 section 6)
make verifygreen: lint, typecheck, pytest, vitest, e2e at both viewports, em-dash scan, seed validatorapp/scoped.py; cross-team read tests return nothingsource_refandcontent_versionpositions_jsonslot sets match their base formation exactly, validator-enforcedKnown gaps, tracked and deliberately not fixed here
The T-109 audit found three points where the DoD is not literally met. All are filed rather than papered over:
scripts/seed.pyloads onlyitemsso the file-level disclaimer never reaches the database.update_team_formationbulk-deletes slot rows with a raw query, bypassingapp/scoped.py(guarded by an upstreamscope.get(), and it mirrors a pre-existing idiom inroster.py).team_formations. The API exists; no ticket wired the UI.Open founder question: the 5-4-1 evaluates only its back line, because its flat midfield four matches no doc 06 unit.
Infrastructure
make verifynow derives a stable per-worktree port pair fromcksumof the checkout path, so parallel worktrees stop silently sharing one dev server. ExplicitPOP_WEB_PORT/POP_API_PORToverrides still win.🤖 Generated with Claude Code