The hover tooltip previously rendered on every piece regardless of
whether it had any modifier facts, showing just a piece-type header and
'No active modifiers' — noise with zero information the user can't
already see on the board.
Now returns null when there are no modifier rows. The pinned panel
(click-to-pin) keeps its empty-state copy because an explicit pin is a
deliberate inspect action where confirming 'nothing here' is valid.
Tests:
- Inverted the two T24 hover tests to assert the tooltip does NOT render
on unmodified pieces (b1 knight, e2 pawn on a vanilla solo game).
- Added a positive test: hover a modified pawn (HP +1 from a seeded
profile) and assert the tooltip + at least one row are visible.
P6 (source chain in pinned panel):
Required two server-side changes to make the badge actually meaningful:
- Add `profile` field to GameStatePayload schema (server emits it,
client receives it) so multiplayer clients see the room's active
profile metadata, not just the modifier facts.
- Make `ChessEngine.activeProfile` mutable via `setActiveProfile()`
so PredictionManager can sync it from `game.state` snapshots.
Also wire `modifier-profile.updated` through GameClient + Prediction-
Manager so hot-swap broadcasts update the engine's profile field
reactively.
Fix Lobby.handlePlaySolo's resetToFreshGame to forward the selected
profile to the new ChessEngine — otherwise the local engine had
modifier facts (via server reconcile) but no profile metadata,
breaking source-chain attribution and any other profile-aware UI.
P7 (multiplayer propose → approve → both observe updated):
P8 (multiplayer propose → reject → no updated broadcast):
Implemented at the WS-protocol level using two parallel raw sockets
per test (mirrors multiplayer.spec.ts pattern). Critical sequencing:
- Both sockets opened concurrently via Promise.all so opponent is
listening BEFORE host's propose arrives at the server (otherwise
proposal-pending broadcasts to nobody and the test deadlocks).
- Token must travel at the envelope level, not in payload, for the
server's reconnect-by-token path to fire (otherwise hits ROOM_FULL
on the second connection from each player).
- game.move payload uses algebraic notation strings ('a2', 'a3'), not
square indices — the protocol schema only accepts strings.
- Host re-uses original room.create token, opponent re-uses their
join token. Server's reconnectManager treats both as grace-window
reconnects since the original WS closed cleanly.
Verification:
- 1231 unit tests pass (96 files)
- 58/58 Playwright tests pass in 1.9 min (was 55 + 3 fixme)
- Total Playwright surface coverage: solo-smoke (7) + multiplayer (2) +
full-flow (1) + layouts (24) + modifier-profiles (24 — including all
8 T2-polish tests, 0 fixme).
Adds 8 Playwright scenarios to modifier-profiles.spec.ts under a new
'T2 polish' describe block:
P1 editor undo/redo across 3 distinct type-modifier adds
P2 copy / paste wire: Copy lights the Paste button with a count
P3 paste-type-modifier disabled when clipboard empty (baseline)
P4 conflict panel: seed an invuln-king profile via localStorage,
bind layout=classic, Load, observe error + Fix clears it
P5 modifier-indicator rendered without hover (create-room path,
with the same no-WS-server test.skip fallback T26 uses)
P6 source-chain in pinned panel — test.fixme; ModifierPinnedPanel
computes row.source but does not render it yet
P7 multiplayer propose->approve e2e — test.fixme; needs a
two-context harness this spec doesn't have today. Protocol
coverage lives at packages/server/src/ws.modifier-profile-
consent.test.ts.
P8 multiplayer propose->reject e2e — same harness gap as P7.
Adds 2 regression tests to solo-smoke.spec.ts:
- Rules drawer: clicking the backdrop (far-left of viewport)
closes the drawer and leaves the board interactive. Regression
guard for the stuck-overlay pointer-events bug.
- Modifier editor: Esc closes the editor but leaves the drawer
open (capture-phase stopImmediatePropagation); a second Esc
then closes the drawer. Documents the nested-Esc ordering
contract and guards against a future change that would cascade
both closes on one keystroke.
Result: 55 Playwright passing, 3 skipped (all documented fixme).
bun run check green.
- Create ModifierPinnedPanel.tsx: fixed-position side panel with piece
header, modifier list (label + describe() value), and close button
- Board.tsx: add onPieceClick prop, fire on piece click (distinct from drag)
- GameView.tsx: add pinnedPieceId state; clicking a piece toggles pin;
clicking same piece again or × closes panel; panel renders fixed right-4
- 2 new e2e tests: click b1 pins panel with 'knight' text; × dismisses it
Adds ModifierTooltip component that reads MODIFIER_REGISTRY attrs from
engine.session for the hovered piece and renders them as labelled rows.
The tooltip always appears on piece hover (piece type + color header) and
shows modifier rows only when modifier facts are set on the entity.
Board.tsx gains an optional onPieceHover callback; GameView.tsx tracks
hoveredPieceId and renders the tooltip absolutely in the board wrapper.
A 120ms hide-delay prevents flicker when cursor briefly leaves a piece.
Two Playwright tests added: hover shows tooltip with piece name; hover
over unmodified piece shows zero modifier-tooltip-row elements.
Adds a modifier profile picker next to the layout picker in the Lobby, and a header badge in GameView that surfaces the active profile's name.
Lobby:
- New <select data-testid="profile-picker"> loads entries from loadLibrary() on mount and refreshes when the ModifierProfileEditor closes (auto-selecting the most recently updated entry).
- Selecting a saved profile sets the active ModifierProfile; selecting 'Custom…' opens the existing editor modal.
- URL param ?modifierProfile=<b64> decodes + pre-selects even when the profile isn't in the local library, via a synthetic '<name> (from link)' option so the <select> can reflect the choice without collapsing it.
- handleCreate now sends payload.profile when a profile is selected and stashes modifier-profile-name in sessionStorage.
- handleJoin reads profile from the server's room.joined echo so late joiners see the badge on first paint.
GameView:
- New ModifierProfileBadge component mirrors LayoutBadge but reads modifier-profile-name from sessionStorage and uses fuchsia tones so it's visually distinct when both badges are present.
lobby-request.ts:
- OneShotRoomResult exposes the optional profile field the server now echoes (T19).
E2E:
- 2 new Playwright tests: 'create room with profile — badge shows in game' seeds the library via localStorage, selects the profile, creates the room, and asserts the badge text. 'URL pre-select loads profile in picker' base64-encodes a profile into ?modifierProfile= and verifies the picker shows the correct value + 'from link' synthetic label.
All 8 modifier-profiles e2e tests pass; bun run check green (1213/1213 unit tests).
Adds ModifierProfileEditor modal shell with 3 placeholder panels
(T21 catalog / T22 board preview / T23 profile list). Esc closes
the modal via a window keydown listener active only while isOpen.
Wires a 'Modifier Profiles' button into the RulesDrawer footer that
opens the editor. Adds e2e/modifier-profiles.spec.ts with 2 tests:
open-from-drawer and esc-to-close.