fix(ui): rules drawer Esc handling — unblock board after drawer close
The T1 ModifierProfileEditor installed a window-level Esc handler that closed the modal but the RulesDrawer had no Esc handler of its own. Users hitting Esc with the drawer open (no modal) saw nothing happen; worse, with both open+modal, closing the modal left the drawer's pointer-events-blocking backdrop in place, silently breaking all board drag-interaction afterward. Fix: - Add useEffect-based Esc handler to RulesDrawer that closes it when no nested modal is active. - ModifierProfileEditor now uses capture-phase + stopImmediatePropagation so the drawer's Esc handler does NOT also fire on the same keystroke, preventing double-close. Add packages/chess/e2e/solo-smoke.spec.ts — 5 regression scenarios that would have caught this at T1 CI time. Test 4 specifically reproduces the original bug (drawer open → Esc → drag board pieces). Also queue 2 additional scenarios in the T2 plan since T2 work extends both drawer + editor further. All tests green: 1217 unit tests (94 files), 48 Playwright e2e in 1.3m.
This commit is contained in:
parent
8ae934f563
commit
728ad76a5e
5 changed files with 1262 additions and 3 deletions
547
.sisyphus/plans/modifier-profiles-t2.md
Normal file
547
.sisyphus/plans/modifier-profiles-t2.md
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
# Modifier Profiles — T2 Polish
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: UX and robustness polish on top of shipped T1 modifier profiles. Adds copy/paste between pieces, a visual "this piece is modified" indicator on the live board, inline conflict-resolution in the editor, undo/redo in the editor, proper turn-boundary queuing server-side (replaces T1's immediate-apply simplification), two-player consent model for mid-game swaps (replaces host-only), and richer source-chain attribution in the pinned inspection panel.
|
||||
>
|
||||
> **Deliverables**:
|
||||
> - Editor: copy/paste modifiers, undo/redo stack, inline conflict resolution UI
|
||||
> - Board: subtle modifier-indicator badge on modified pieces (no hover required)
|
||||
> - Server: turn-boundary queue (ADR-3 done properly), both-player consent for hot-swap
|
||||
> - Inspection: enhanced source-chain breakdown in pinned panel
|
||||
> - Full e2e vertical slice
|
||||
> - Updated ADR + user docs
|
||||
>
|
||||
> **Estimated Effort**: Medium (15 tasks)
|
||||
> **Parallel Execution**: YES — 4 waves
|
||||
> **Critical Path**: T1 (turn-queue server) → T3 (consent) → T13 (e2e)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
> "can we do t2 and t3? T2 first, then T3 (Recommended)"
|
||||
|
||||
### T1 Recap (shipped)
|
||||
T1 (completed) delivered the foundation: 6 built-in modifier descriptors, per-type + per-instance scope, editor UI, library persistence, URL sharing, hover tooltip, pinned panel, server-side validation, immediate-apply hot-swap, host-only authority. Full vertical slice working with 18/18 Playwright tests.
|
||||
|
||||
### T2 Rationale
|
||||
Two categories of work:
|
||||
1. **UX polish**: features users will notice immediately — copy/paste, visual indicators, undo/redo, better conflict messages.
|
||||
2. **Robustness fixes**: close the documented simplifications from T1's Implementation Retrospective — turn-boundary queue (not immediate) + two-player consent (not host-only).
|
||||
|
||||
### Architectural Decisions (inherited from T1 ADRs)
|
||||
|
||||
Most T2 work doesn't need new ADR decisions. Three small extensions:
|
||||
|
||||
**T2-ADR-1: Turn-boundary queue**
|
||||
- Client sends `modifier-profile.update` at any time.
|
||||
- Server enqueues to `room.pendingProfile`. Apply runs in the server's `onAfterMove` hook after next move is validated.
|
||||
- If pending profile is invalid when apply fires → NACK to sender, clear pending, no broadcast.
|
||||
- Multiple updates before apply: last-write-wins (replace pending).
|
||||
- Replaces T1 simplification where swap applied immediately on receipt.
|
||||
|
||||
**T2-ADR-2: Two-player consent**
|
||||
- Host sends `modifier-profile.propose` with candidate profile.
|
||||
- Server broadcasts `modifier-profile.proposal-pending` to opponent with profile contents.
|
||||
- Opponent sends `modifier-profile.consent` with approve/reject.
|
||||
- If approve → apply at next turn boundary (T2-ADR-1). If reject → clear proposal, broadcast `modifier-profile.rejected`.
|
||||
- Timeout: 60s → auto-reject.
|
||||
- Replaces T1's unilateral host authority.
|
||||
|
||||
**T2-ADR-3: Undo/redo in editor**
|
||||
- Editor maintains a snapshot stack of working-profile states.
|
||||
- Every meaningful user action (add/delete/edit a modifier) pushes a snapshot.
|
||||
- Cmd/Ctrl+Z undoes; Cmd/Ctrl+Shift+Z redoes.
|
||||
- Stack capped at 50 snapshots.
|
||||
- Cleared on save or cancel.
|
||||
|
||||
---
|
||||
|
||||
## Work Objectives
|
||||
|
||||
### Core Objective
|
||||
Polish the T1 modifier-profile system with features users expect from a mature editor + close the documented T1 simplifications (immediate-apply, host-only) to ship production-grade semantics.
|
||||
|
||||
### Concrete Deliverables
|
||||
|
||||
**Editor UX** (`packages/chess/src/ui/`):
|
||||
- `ModifierProfileEditor.tsx` — add undo/redo stack, toolbar with history controls
|
||||
- `PerTypePanel.tsx` — add "Copy" + "Paste" buttons on each modifier row
|
||||
- `PerInstancePanel.tsx` — add copy/paste between pieces ("copy from b1" → "paste to g1")
|
||||
- `ConflictResolutionPanel.tsx` — NEW: shows validation errors inline with suggested fixes + auto-resolve options
|
||||
|
||||
**Board UX** (`packages/chess/src/ui/`):
|
||||
- `ModifiedPieceIndicator.tsx` — NEW: small badge/glow on modified pieces
|
||||
- `GameView.tsx` + `Board.tsx` — wire indicator into piece rendering
|
||||
|
||||
**Server** (`packages/server/src/`):
|
||||
- `broadcast.ts` — `handleModifierProfileUpdate` now enqueues instead of applying
|
||||
- `game-session.ts` — new `applyPendingProfile()` method called after `applyMove()`
|
||||
- New handlers: `handleModifierProfilePropose`, `handleModifierProfileConsent`
|
||||
- `rooms.ts` — `Room.pendingProfile`, `Room.proposalState`, `Room.proposalTimeoutHandle`
|
||||
- `protocol.ts` — new messages: `modifier-profile.propose`, `modifier-profile.proposal-pending`, `modifier-profile.consent`, `modifier-profile.rejected`
|
||||
|
||||
**Inspection** (`packages/chess/src/ui/`):
|
||||
- `ModifierPinnedPanel.tsx` — enhanced source breakdown:
|
||||
- Per-instance entries labeled "(from layout square X)"
|
||||
- Per-type entries labeled "(applies to all {color} {type}s)"
|
||||
- Preset entries labeled "(from {preset name})"
|
||||
- Base values labeled "(default)"
|
||||
|
||||
**Docs**:
|
||||
- Update `docs/adr/modifier-profiles.md` — add T2-ADR-1, T2-ADR-2, T2-ADR-3 sections
|
||||
- Update `docs/user/modifier-profiles.md` — document new features
|
||||
|
||||
**E2E** (`packages/chess/e2e/`):
|
||||
- Extend `modifier-profiles.spec.ts` — 8 new scenarios
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- [ ] `bun run check` green (typecheck + lint + vitest)
|
||||
- [ ] Copy/paste between pieces: adding a per-instance modifier to b1, then copying to g1, produces identical entries at both squares
|
||||
- [ ] Undo/redo: 5 actions then undo 3 times then redo 2 times produces state equal to 4-action state
|
||||
- [ ] Visual indicator visible on modified pieces without requiring hover
|
||||
- [ ] Turn-boundary queue: profile update during mid-move doesn't apply until after opponent's move resolves
|
||||
- [ ] Two-player consent: black must approve before profile change broadcasts
|
||||
- [ ] Proposal timeout: 60s no-response → auto-reject
|
||||
- [ ] Inline conflict resolution shows specific error + suggested fix
|
||||
- [ ] Source chain in pinned panel distinguishes all 4 sources
|
||||
- [ ] Playwright e2e: 18 existing + 8 new = 26 scenarios all pass
|
||||
- [ ] ADR + user docs updated
|
||||
|
||||
### Must Have
|
||||
- All 8 UX/polish deliverables working end-to-end
|
||||
- Turn-boundary queue replaces immediate-apply (no regression — hot-swap still works)
|
||||
- Two-player consent with 60s timeout
|
||||
- Editor undo/redo with 50-snapshot cap
|
||||
- Shared schema between client/server (no drift)
|
||||
|
||||
### Must NOT Have (Guardrails)
|
||||
|
||||
- ❌ Custom modifier authoring (T3)
|
||||
- ❌ Cross-piece aura effects (T3)
|
||||
- ❌ Multi-profile stacking (post-T3)
|
||||
- ❌ Editor persistence across sessions (reload = fresh editor, library is separate)
|
||||
- ❌ Server-side undo (client-side only — no WS messages for undo state)
|
||||
- ❌ Breaking changes to T1 WS messages (all additions are NEW messages; `modifier-profile.update` becomes an alias for `propose` + auto-approve when single-player)
|
||||
- ❌ Mid-turn swap (turn-boundary gate is enforced)
|
||||
- ❌ Client computing effective modifiers differently from server
|
||||
|
||||
### Must NOT Have (AI Slop Patterns)
|
||||
|
||||
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
|
||||
- ❌ Generic names (`data`, `result`, `item`, `temp`)
|
||||
- ❌ Empty catch blocks
|
||||
- ❌ `console.log` in production code
|
||||
- ❌ Premature abstraction
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Test Decision
|
||||
- **Infrastructure**: vitest + Playwright, same as T1.
|
||||
- **Approach**: TDD for server + engine; Playwright scenarios for UX features.
|
||||
|
||||
### QA Policy
|
||||
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t2/task-{N}-{slug}.{ext}`.
|
||||
|
||||
---
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
```
|
||||
Wave 1 (Server robustness foundation — PARALLEL):
|
||||
├── T1: T2-ADR documentation (append to existing ADR)
|
||||
├── T2: Turn-boundary queue server-side
|
||||
└── T3: Two-player consent protocol
|
||||
|
||||
Wave 2 (Editor features — PARALLEL):
|
||||
├── T4: Undo/redo snapshot stack in editor
|
||||
├── T5: Copy/paste modifiers between pieces
|
||||
└── T6: Inline conflict resolution panel
|
||||
|
||||
Wave 3 (Board UX + Inspection polish — PARALLEL):
|
||||
├── T7: Modified-piece indicator on live board
|
||||
├── T8: Enhanced source-chain in pinned panel
|
||||
└── T9: Consent UI (proposal notification + approve/reject buttons)
|
||||
|
||||
Wave 4 (E2E + Docs — PARALLEL):
|
||||
├── T10: Playwright e2e additions (8 scenarios)
|
||||
├── T11: ADR updates
|
||||
└── T12: User docs updates
|
||||
|
||||
Wave FINAL (4 parallel reviewers):
|
||||
├── F1: Plan compliance audit
|
||||
├── F2: Code quality review
|
||||
├── F3: Manual QA
|
||||
└── F4: Scope fidelity check
|
||||
```
|
||||
|
||||
### Dependency Matrix
|
||||
|
||||
| Task | Depends On | Blocks |
|
||||
|------|------------|--------|
|
||||
| 1 | — | 2-12 |
|
||||
| 2 | 1 | 3, 10, 11 |
|
||||
| 3 | 2 | 9, 10, 11 |
|
||||
| 4 | 1 | 10 |
|
||||
| 5 | 1 | 10 |
|
||||
| 6 | 1 | 10 |
|
||||
| 7 | 1 | 10 |
|
||||
| 8 | 1 | 10 |
|
||||
| 9 | 3 | 10 |
|
||||
| 10 | 2-9 | F1-F4 |
|
||||
| 11 | 1, 2, 3 | F1 |
|
||||
| 12 | 4-9 | F1 |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
- [ ] 1. **T2-ADR documentation**
|
||||
|
||||
**What to do**:
|
||||
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
|
||||
- `## T2-ADR-1: Turn-boundary queue` — full semantics, last-write-wins, NACK on invalid at apply time
|
||||
- `## T2-ADR-2: Two-player consent` — proposal/consent flow, 60s timeout, auto-reject
|
||||
- `## T2-ADR-3: Editor undo/redo` — snapshot stack, 50-item cap, cleared on save/cancel
|
||||
|
||||
**Must NOT do**: Don't write any code in this task.
|
||||
|
||||
**Recommended Agent Profile**: `writing` — no skills
|
||||
|
||||
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] File has 3 new T2-ADR sections
|
||||
- [ ] `grep -c "^## T2-ADR-" docs/adr/modifier-profiles.md` → 3
|
||||
|
||||
**Commit**: `docs(adr): T2 polish architecture decisions`
|
||||
|
||||
- [ ] 2. **Turn-boundary queue server-side**
|
||||
|
||||
**What to do**:
|
||||
- Replace `handleModifierProfileUpdate`'s immediate-apply with queue semantics:
|
||||
- Add `Room.pendingProfile?: ModifierProfile` field (may already exist from T1 — re-use or add)
|
||||
- On receive: validate shape, store in `room.pendingProfile`, ack with `modifier-profile.queued`
|
||||
- On `applyMove()` success: check pendingProfile, validate against post-move session, apply via `reconcileProfileSwap`, broadcast `modifier-profile.updated`, clear pending
|
||||
- If pending profile invalid at apply time: NACK to original sender with specific error code, clear pending
|
||||
- Last-write-wins: new pending replaces old pending before apply fires
|
||||
|
||||
**Must NOT do**: Don't break T1 e2e tests. Don't apply mid-move.
|
||||
|
||||
**Recommended Agent Profile**: `deep` — concurrency-sensitive
|
||||
|
||||
**Parallelization**: Wave 1 (PARALLEL with T1, T3). Blocks: T3, T10, T11. Blocked By: T1.
|
||||
|
||||
**References**:
|
||||
- `packages/server/src/broadcast.ts` — current `handleModifierProfileUpdate`
|
||||
- `packages/server/src/game-session.ts` — `applyMove`, post-move hook location
|
||||
- `docs/adr/modifier-profiles.md` § T2-ADR-1
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Update sent during move — profile does NOT apply until opponent completes their move
|
||||
- [ ] 2 rapid updates → only the last is applied
|
||||
- [ ] Invalid pending at apply time → NACK to sender, no broadcast
|
||||
- [ ] `bun run test packages/server/src/ws.modifier-profile-update.test.ts` — all pass (updated)
|
||||
|
||||
**Commit**: `feat(server): turn-boundary queue for modifier profile updates`
|
||||
|
||||
- [ ] 3. **Two-player consent protocol**
|
||||
|
||||
**What to do**:
|
||||
- New WS messages in `protocol.ts`:
|
||||
- `modifier-profile.propose` (client→server) — host sends candidate profile
|
||||
- `modifier-profile.proposal-pending` (server→opponent) — notify with profile contents
|
||||
- `modifier-profile.consent` (client→server) — opponent sends approve/reject
|
||||
- `modifier-profile.rejected` (server→host) — proposal rejected or timed out
|
||||
- `broadcast.ts`:
|
||||
- `handleModifierProfilePropose` — store proposal + start 60s timeout
|
||||
- `handleModifierProfileConsent` — on approve: promote to pendingProfile (feeds into T2 queue); on reject: clear + broadcast rejected
|
||||
- On timeout: auto-reject
|
||||
- `rooms.ts`:
|
||||
- `Room.proposalState?: { profile, proposedBy, timeoutHandle, proposedAt }`
|
||||
- Solo mode / host-only mode: `modifier-profile.update` alias auto-approves
|
||||
|
||||
**Must NOT do**: Don't change `modifier-profile.update` semantics in solo mode.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 1. Blocks: T9, T10, T11. Blocked By: T2.
|
||||
|
||||
**References**:
|
||||
- `docs/adr/modifier-profiles.md` § T2-ADR-2
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Propose → opponent sees pending → approve → broadcast to both
|
||||
- [ ] Propose → opponent rejects → only host sees rejected, no broadcast to board
|
||||
- [ ] Propose → 60s timeout → auto-reject
|
||||
- [ ] Solo mode: propose = immediate apply (no consent step)
|
||||
|
||||
**Commit**: `feat(server): two-player consent for modifier profile swaps`
|
||||
|
||||
- [ ] 4. **Editor undo/redo snapshot stack**
|
||||
|
||||
**What to do**:
|
||||
- In `ModifierProfileEditor.tsx`:
|
||||
- `const [history, setHistory] = useState<ModifierProfile[]>([initialProfile])`
|
||||
- `const [historyIndex, setHistoryIndex] = useState(0)`
|
||||
- `const currentProfile = history[historyIndex]`
|
||||
- Every mutation path goes through `pushSnapshot(newProfile)` which truncates forward history and caps at 50
|
||||
- `undo()` / `redo()` adjust historyIndex
|
||||
- Keyboard handlers for Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z
|
||||
- Header toolbar with Undo/Redo buttons (disabled when at ends of stack)
|
||||
- `data-testid="undo-button"`, `data-testid="redo-button"`
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Add 3 modifiers → undo 2 → profile has 1 modifier
|
||||
- [ ] Undo then redo → same state
|
||||
- [ ] Cap at 50 snapshots (add 51st → oldest dropped)
|
||||
- [ ] Save or cancel clears history
|
||||
|
||||
**Commit**: `feat(ui): modifier editor undo/redo`
|
||||
|
||||
- [ ] 5. **Copy/paste modifiers between pieces**
|
||||
|
||||
**What to do**:
|
||||
- In `PerInstancePanel.tsx`:
|
||||
- When a square is selected: "Copy modifiers from this square" button
|
||||
- When a different square is selected after copy: "Paste modifiers here" button (disabled when clipboard empty)
|
||||
- Clipboard is component-local state (not OS clipboard)
|
||||
- Paste appends all clipboard entries to the target square, with the kind preserved
|
||||
- In `PerTypePanel.tsx`:
|
||||
- "Copy to clipboard" on each row
|
||||
- "Paste" button appends from clipboard
|
||||
- `data-testid="copy-modifier-b1"` etc.
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Add HP+2 to b1 → copy → paste on g1 → both have HP+2
|
||||
- [ ] Paste when clipboard empty: button disabled
|
||||
- [ ] Copy doesn't delete source
|
||||
|
||||
**Commit**: `feat(ui): copy/paste modifiers between pieces`
|
||||
|
||||
- [ ] 6. **Inline conflict resolution panel**
|
||||
|
||||
**What to do**:
|
||||
- New component `ConflictResolutionPanel.tsx`:
|
||||
- Shows validation errors from `validateProfile()` inline
|
||||
- Each error has a "Fix" button with a specific auto-resolve action:
|
||||
- `E_PROFILE_NO_KING` → "Add king to e1" button (or suggest switching layout)
|
||||
- `E_PROFILE_INVULN_KING` → "Remove invuln from king" button
|
||||
- `E_PROFILE_ORPHAN_INSTANCE` (warning) → "Remove orphan entry" button
|
||||
- `E_PROFILE_ATTR_LIMIT` → "Show affected pieces" + manual resolution
|
||||
- Panel visible whenever profile has errors/warnings; hidden when clean
|
||||
- Wire into `ModifierProfileEditor.tsx` — show panel at the top when dirty
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Create profile with invuln on king → panel shows error + Fix button
|
||||
- [ ] Click Fix → error resolved
|
||||
- [ ] Panel hidden when profile validates clean
|
||||
|
||||
**Commit**: `feat(ui): inline conflict resolution panel`
|
||||
|
||||
- [ ] 7. **Modified-piece indicator on live board**
|
||||
|
||||
**What to do**:
|
||||
- New component `ModifiedPieceIndicator.tsx`:
|
||||
- Small colored dot (or glow/border) shown on pieces that have any active modifier
|
||||
- Reads `MODIFIER_REGISTRY.list()` and checks `session.get(pieceId, attr)` for each
|
||||
- If any defined → indicator visible
|
||||
- CSS: absolute-positioned top-right corner of piece square, 8px dot
|
||||
- Wire into `Board.tsx` — render indicator alongside piece
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Piece with modifier: indicator visible
|
||||
- [ ] Piece without modifier: no indicator
|
||||
- [ ] Indicator updates reactively on profile swap
|
||||
|
||||
**Commit**: `feat(ui): modified-piece indicator on board`
|
||||
|
||||
- [ ] 8. **Enhanced source-chain in pinned panel**
|
||||
|
||||
**What to do**:
|
||||
- Update `ModifierPinnedPanel.tsx`:
|
||||
- For each modifier row, add source label:
|
||||
- If piece's attr value matches a profile.perInstance entry at its square → "(per-instance: {square})"
|
||||
- Else if matches a perType entry for this pieceType+color → "(per-type: all {color} {type}s)"
|
||||
- Else if some active preset declared this attr → "(preset: {preset.name})"
|
||||
- Else → "(default)"
|
||||
- Reads the engine's active profile + presets via a new `getModifierSource(engine, pieceId, kind)` helper in `packages/chess/src/modifiers/source.ts`
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Per-instance modifier → source labels show "per-instance: b1"
|
||||
- [ ] Per-type modifier → source labels show "per-type: all white knights"
|
||||
- [ ] piece-hp preset → source labels show "preset: Hit Points"
|
||||
|
||||
**Commit**: `feat(ui): enhanced modifier source chain in pinned panel`
|
||||
|
||||
- [ ] 9. **Consent UI (proposal notification + buttons)**
|
||||
|
||||
**What to do**:
|
||||
- New component `ModifierProposalDialog.tsx`:
|
||||
- Shows when opponent has pending proposal (`modifier-profile.proposal-pending` received)
|
||||
- Displays summary of proposed profile changes
|
||||
- Approve / Reject buttons
|
||||
- 60s countdown timer
|
||||
- Wire into `GameView.tsx` via WS message subscription
|
||||
- Host sees a "Proposal sent — waiting for opponent" state after proposing
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T3.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Host proposes → opponent sees dialog within 500ms
|
||||
- [ ] Approve → profile applies at next turn boundary
|
||||
- [ ] Reject → dialog closes on both sides, host sees "Proposal rejected"
|
||||
- [ ] Timeout → auto-reject
|
||||
|
||||
**Commit**: `feat(ui): consent dialog for modifier profile proposals`
|
||||
|
||||
- [ ] 10. **Playwright e2e additions (8 feature scenarios + solo regression guards)**
|
||||
|
||||
**What to do**:
|
||||
- Add 8 new T2-feature tests to `packages/chess/e2e/modifier-profiles.spec.ts`:
|
||||
1. Editor undo/redo: add 3 modifiers, undo 2, redo 1
|
||||
2. Copy modifier between squares (per-instance)
|
||||
3. Paste button disabled when clipboard empty
|
||||
4. Conflict panel shows error + Fix works
|
||||
5. Modified-piece indicator visible without hover
|
||||
6. Source chain distinguishes per-instance vs per-type
|
||||
7. (multiplayer) Proposal → approve → both clients see update
|
||||
8. (multiplayer) Proposal → reject → no update
|
||||
- **Keep and expand `packages/chess/e2e/solo-smoke.spec.ts`** (already exists from T1 post-audit fix). Adds ongoing regression coverage for solo play — these tests caught the T1 Rules-Drawer-Esc bug. Ensure the following scenarios remain and are extended:
|
||||
- play-solo button navigates to /game with fresh board
|
||||
- drag pawn e2→e4 resolves
|
||||
- 4-ply sequence (e4 e5 Nf3 Nc6) completes
|
||||
- **Rules drawer opens, closes via Esc, board remains interactive** (the T1 regression guard)
|
||||
- No console errors on solo game start
|
||||
- Add 2 more solo-regression scenarios in T2 since T2 touches drawer/editor further:
|
||||
- Rules drawer: backdrop click closes drawer (workaround path still works)
|
||||
- Modifier editor: Esc closes editor without dismissing drawer underneath (nested modal ordering)
|
||||
|
||||
**Why both?** `solo-smoke.spec.ts` is the canary — it exercises the baseline game flow without any modifier-profile setup. `modifier-profiles.spec.ts` covers feature paths. Keeping both prevents T2/T3 work from silently regressing solo play.
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: F1-F4. Blocked By: T2-T9.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] 26/26 modifier-profile tests pass (18 from T1 + 8 new)
|
||||
- [ ] 7+ solo-smoke tests pass (5 original + 2 T2 additions)
|
||||
- [ ] Multiplayer suite unchanged, still passes
|
||||
- [ ] Total Playwright runtime < 120s
|
||||
|
||||
**Commit**: `test(e2e): T2 polish vertical slice + solo regression guards`
|
||||
|
||||
- [ ] 11. **ADR updates — Implementation Retrospective T2 addendum**
|
||||
|
||||
**What to do**:
|
||||
- Append to `docs/adr/modifier-profiles.md`:
|
||||
- `## T2 Implementation Retrospective` section noting:
|
||||
- T1's immediate-apply simplification resolved (T2-ADR-1)
|
||||
- T1's host-only simplification resolved (T2-ADR-2)
|
||||
- Editor UX parity with modern standards (undo/redo, copy/paste)
|
||||
- Any deviations found during implementation
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T1, T2, T3.
|
||||
|
||||
**Commit**: `docs(adr): T2 implementation retrospective`
|
||||
|
||||
- [ ] 12. **User docs updates**
|
||||
|
||||
**What to do**:
|
||||
- Update `docs/user/modifier-profiles.md`:
|
||||
- Update "Hot-Swap" section: describe proposal/consent flow (remove T1's "host-only" note)
|
||||
- Add "Editor Features" section: undo/redo (keyboard shortcuts), copy/paste, conflict resolution
|
||||
- Add "Board Indicators" section: modified-piece glow
|
||||
- Update "In-Play Inspection" section: mention enhanced source chain
|
||||
- Update "Known Limitations" section: remove T1 items, add T3 preview
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T4-T9.
|
||||
|
||||
**Commit**: `docs(user): T2 modifier profile features`
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave
|
||||
|
||||
- [ ] F1. **Plan Compliance Audit** — `oracle`
|
||||
Verify all 12 tasks' deliverables exist. Verify T1 simplifications resolved. Check evidence files.
|
||||
Output: `Must Have [N/N] | Must NOT Have [N/N] | ADR Decisions [3/3] | VERDICT: APPROVE/REJECT`
|
||||
|
||||
- [ ] F2. **Code Quality Review** — `unspecified-high`
|
||||
`bun run check`. Scan for slop patterns. Verify server-shared schemas.
|
||||
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass] | VERDICT`
|
||||
|
||||
- [ ] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
|
||||
Execute all 26 Playwright scenarios. Multiplayer proposal/consent roundtrip. Undo/redo combos.
|
||||
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
|
||||
|
||||
- [ ] F4. **Scope Fidelity** — `deep`
|
||||
No T3 features smuggled (no custom authoring, no auras, no multi-profile stacking). No breaking WS changes.
|
||||
Output: `Tasks [N/N compliant] | Contamination [CLEAN] | VERDICT`
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. `docs(adr): T2 polish architecture decisions`
|
||||
2. `feat(server): turn-boundary queue for modifier profile updates`
|
||||
3. `feat(server): two-player consent for modifier profile swaps`
|
||||
4. `feat(ui): modifier editor undo/redo`
|
||||
5. `feat(ui): copy/paste modifiers between pieces`
|
||||
6. `feat(ui): inline conflict resolution panel`
|
||||
7. `feat(ui): modified-piece indicator on board`
|
||||
8. `feat(ui): enhanced modifier source chain in pinned panel`
|
||||
9. `feat(ui): consent dialog for modifier profile proposals`
|
||||
10. `test(e2e): T2 polish vertical slice`
|
||||
11. `docs(adr): T2 implementation retrospective`
|
||||
12. `docs(user): T2 modifier profile features`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
```bash
|
||||
bun run check # all green
|
||||
bun run test packages/server/ # all pass (including updated ws tests)
|
||||
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass
|
||||
```
|
||||
|
||||
### Final Checklist
|
||||
- [ ] All 9 feature tasks shipped end-to-end
|
||||
- [ ] T1 simplifications (immediate-apply, host-only) fully resolved
|
||||
- [ ] 26 Playwright e2e scenarios green
|
||||
- [ ] ADR + user docs updated
|
||||
- [ ] `bun run check` green
|
||||
- [ ] F1-F4 all APPROVE
|
||||
- [ ] User explicit "okay"
|
||||
601
.sisyphus/plans/modifier-profiles-t3.md
Normal file
601
.sisyphus/plans/modifier-profiles-t3.md
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
# Modifier Profiles — T3 User-Authored Custom Modifiers (DSL)
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: Users can author their own modifier categories at runtime via a constrained config DSL. A custom modifier is a named, versioned, data-only descriptor composed from atomic effect primitives ("add N to attribute X", "add direction Y", "reduce damage by N%"). No code execution — purely structured data. Custom modifiers register into a per-game registry (alongside built-ins), persist in a separate library, and travel through the network via `ModifierProfileSchema` with full validation. Forward-designed for a T4 scripted-modifier extension.
|
||||
>
|
||||
> **Deliverables**:
|
||||
> - Effect primitives catalog + registry for user-composable atoms
|
||||
> - `CustomModifierDescriptor` type — data-only, validated, sandboxed
|
||||
> - `CustomModifierEditor.tsx` — visual composer (no code typing)
|
||||
> - Per-room custom modifier registration via WS protocol
|
||||
> - Server-side validation of custom descriptors (anti-DoS, legality)
|
||||
> - Modifier Profile editor: custom modifiers appear in kind dropdown alongside built-ins
|
||||
> - Per-user library for custom descriptors + sharing
|
||||
> - Cross-piece aura effects (built on the same primitive model)
|
||||
> - Multi-profile stacking (ordered composition)
|
||||
> - Full e2e vertical slice
|
||||
> - T4 design note: how scripted modifiers would plug in
|
||||
>
|
||||
> **Estimated Effort**: Large (25 tasks, ~3-4 execution waves like T1)
|
||||
> **Parallel Execution**: YES — 5 waves
|
||||
> **Critical Path**: T1 (primitives ADR) → T3 (primitive registry) → T5 (custom descriptor type) → T13 (engine apply) → T19 (e2e)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
> "Start DSL, design for script upgrade" (T3 approach)
|
||||
> "T2 first, then T3 (Recommended)" (execution order)
|
||||
|
||||
### Pre-requisites
|
||||
- T1 (shipped): built-in modifier descriptors + registry + editor + server integration
|
||||
- T2 (must ship first): turn-boundary queue, two-player consent, undo/redo, copy/paste, conflict resolution
|
||||
|
||||
### T3 Rationale
|
||||
Extend the T1/T2 foundation so users can define new modifier CATEGORIES (not just configure existing ones).
|
||||
|
||||
Example user-authored modifier: "Shield — piece has 3 shield charges. Each incoming damage spends one charge instead of reducing HP. No charges → damage falls through." Users compose this from primitives:
|
||||
- `consume-attribute-on-damage` primitive with attr="ShieldCharges", consume=1, absorb=true
|
||||
- `seed-attribute` primitive with attr="ShieldCharges", value=3
|
||||
|
||||
### Architecture (new ADRs)
|
||||
|
||||
**T3-ADR-1: DSL is structured data, not code**
|
||||
Custom modifiers are composed from a fixed catalog of ~15 effect primitives. Each primitive is a TypeScript function (shipped in the engine) that takes parameters. Users compose primitives in the UI; the resulting JSON object IS the modifier. No eval, no sandbox, no script parsing — structured validation only.
|
||||
|
||||
**Rejected alternatives**:
|
||||
- Sandboxed script runtime (QuickJS, etc.) — defers to T4. Security surface too large for T3.
|
||||
- AST-based mini-language — same complexity as sandboxed script.
|
||||
- String template interpolation — too limited.
|
||||
|
||||
**T3-ADR-2: Effect Primitive catalog (T3 v1)**
|
||||
|
||||
Primitives are atomic, composable, pure. Full catalog:
|
||||
|
||||
| Primitive | Parameters | Effect |
|
||||
|-----------|------------|--------|
|
||||
| `seed-attribute` | attr, value | Seeds a fact on the piece at profile apply time |
|
||||
| `add-to-attribute` | attr, delta | Adds delta to existing attr value (additive stacking) |
|
||||
| `multiply-attribute` | attr, factor | Multiplies existing attr value |
|
||||
| `add-direction` | directions[] | Adds to DirectionAdditions |
|
||||
| `set-capture-flag` | flag | ORs into CaptureFlags |
|
||||
| `absorb-damage-with-attribute` | attr, rate | Each damage point consumes `rate` of attr instead of HP |
|
||||
| `reflect-damage` | percentage | Damage sends back to attacker at percentage |
|
||||
| `block-move-type` | moveType (capture / step / slide) | Filter out moves matching criteria |
|
||||
| `add-aura` | radius, targetAttr, delta | For each piece within radius, add delta to attr |
|
||||
| `on-turn-start` | primitive[] | Runs contained primitives at turn start |
|
||||
| `on-capture` | primitive[] | Runs contained primitives when this piece captures |
|
||||
| `on-damaged` | primitive[] | Runs contained primitives when this piece takes damage |
|
||||
| `conditional` | condition, then-primitive[], else-primitive[] | Condition-branch (e.g., "if HP < 2, do X") |
|
||||
| `modify-movement-range` | delta | RangeBonus integration |
|
||||
| `override-promotion` | target | PromotionOverride integration |
|
||||
|
||||
Users compose these in a visual editor. Each primitive has a known shape, validation, UI form, and engine integration. Adding a new primitive = one file (same pattern as descriptors in T1).
|
||||
|
||||
**T3-ADR-3: Custom modifier authoring scope**
|
||||
- **Per-room**: custom modifiers are registered on a room-by-room basis. Registration = send full descriptor via WS at room.create or via `custom-modifier.register` message.
|
||||
- **Per-user library**: users save their custom descriptors locally (like profiles). Separate library key `houserules:custom-modifiers:v1`.
|
||||
- **Sharing**: custom descriptors travel with profiles that reference them. A profile using a custom modifier "shield-v1" embeds the full `shield-v1` descriptor.
|
||||
- **Versioning**: custom descriptors have a `version: 1` field. v2+ is a new modifier id.
|
||||
- **Validation**: server-side validator checks primitive catalog membership, parameter bounds, recursion depth ≤ 3 (for on-turn-start / conditional nesting), total primitive count ≤ 50 per descriptor.
|
||||
|
||||
**T3-ADR-4: Registry extension — per-engine not per-process**
|
||||
Built-in descriptors use module-level `MODIFIER_REGISTRY`. Custom descriptors use a per-engine `customModifiers: Map<string, CustomModifierDescriptor>`. `MODIFIER_REGISTRY.get(id)` transparently consults per-engine custom registry when global misses. This prevents user modifiers from leaking across rooms.
|
||||
|
||||
**T3-ADR-5: T4 forward-design (scripted modifiers)**
|
||||
Future scripted modifiers would plug in as a new `ModifierDescriptor` type:
|
||||
```typescript
|
||||
interface ScriptedModifierDescriptor {
|
||||
type: "scripted";
|
||||
id: string;
|
||||
script: string; // QuickJS or similar
|
||||
permissions: Permission[];
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
The engine's integration point (same `apply` signature) doesn't know the difference. T3's validator gets a `validateCustomDescriptor` branch-point that's currently `type: "data"` only; T4 adds `type: "scripted"` with separate validation + sandboxed execution.
|
||||
|
||||
**T3-ADR-6: Multi-profile stacking (bundled with T3 since primitives enable it)**
|
||||
Profiles can be stacked. Engine maintains `activeProfiles: readonly ModifierProfile[]` instead of a single profile. Stacking rules per modifier kind (from ADR-4 of T1) apply across profiles. Conflict resolution: explicit priority order set by user. Default: profiles applied in registration order.
|
||||
|
||||
**T3-ADR-7: Aura effects (primitive `add-aura`)**
|
||||
Auras are effect primitives that apply to OTHER pieces within a radius. At profile-apply time, auras create derived facts on affected pieces. Derived facts are re-computed on every move (affected pieces may change). Implementation: `effectivePieceAttrs` engine integration for aura-derived attrs.
|
||||
|
||||
---
|
||||
|
||||
## Work Objectives
|
||||
|
||||
### Core Objective
|
||||
Ship user-authored custom modifier descriptors composed from an effect primitive catalog. Design the whole system so T4's scripted modifiers can plug in without redesigning.
|
||||
|
||||
### Concrete Deliverables
|
||||
|
||||
**Primitive catalog** (`packages/chess/src/modifiers/primitives/`):
|
||||
- `types.ts` — `EffectPrimitive`, `PrimitiveKind`, primitive-specific parameter types
|
||||
- `registry.ts` — `PRIMITIVE_REGISTRY`
|
||||
- Individual primitive files (15 files):
|
||||
- `seed-attribute.ts`, `add-to-attribute.ts`, `multiply-attribute.ts`
|
||||
- `add-direction.ts`, `set-capture-flag.ts`
|
||||
- `absorb-damage-with-attribute.ts`, `reflect-damage.ts`
|
||||
- `block-move-type.ts`, `modify-movement-range.ts`, `override-promotion.ts`
|
||||
- `add-aura.ts`
|
||||
- `on-turn-start.ts`, `on-capture.ts`, `on-damaged.ts`, `conditional.ts`
|
||||
- `index.ts` — barrel + side-effect registration
|
||||
- `validate.ts` — descriptor-level validator (recursion depth, primitive count, parameter validation per primitive)
|
||||
|
||||
**Custom modifier descriptor** (`packages/chess/src/modifiers/custom/`):
|
||||
- `types.ts` — `CustomModifierDescriptor`, `CustomModifierId`
|
||||
- `apply.ts` — executes primitive list at profile apply time (replaces built-in `apply` for custom modifiers)
|
||||
- `library.ts` — persistence at `houserules:custom-modifiers:v1`
|
||||
- `schema.ts` — Zod schema for full descriptor serialization
|
||||
|
||||
**Registry extension** (`packages/chess/src/modifiers/registry.ts`):
|
||||
- Extend `ModifierRegistryClass` with `customDescriptors: Map<string, CustomModifierDescriptor>`
|
||||
- `.registerCustom(engine, descriptor)` and `.getCustom(engine, id)` methods
|
||||
- `get(id)` falls back to customDescriptors if not in built-ins
|
||||
|
||||
**Aura engine support** (`packages/chess/src/modifiers/auras.ts`):
|
||||
- `computeAuraFacts(session)` — runs all active auras, computes derived attrs, updates session
|
||||
- Hooks: invoked after every move via pseudo-preset
|
||||
|
||||
**Multi-profile stacking** (`packages/chess/src/modifiers/apply.ts` + `reconcile.ts`):
|
||||
- `applyProfilesToSession(session, profiles: readonly ModifierProfile[], layout)` — stack in order
|
||||
- `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`
|
||||
|
||||
**Server protocol** (`packages/server/src/protocol.ts`):
|
||||
- New message: `custom-modifier.register` — attach custom descriptor to room
|
||||
- Extend `ModifierProfile` to include `customModifiers: readonly CustomModifierDescriptor[]` (embedded, not by-ref)
|
||||
- Validator: `validateCustomDescriptor(descriptor)` runs before allowing registration
|
||||
|
||||
**UI** (`packages/chess/src/ui/`):
|
||||
- `CustomModifierEditor.tsx` — visual primitive composer
|
||||
- `PrimitivePalettePanel.tsx` — catalog of 15 primitives, drag/drop or click-to-add
|
||||
- `PrimitiveInspectorPanel.tsx` — parameter editor for selected primitive
|
||||
- `CustomModifierLibrary.tsx` — library drawer for saved custom modifiers
|
||||
- Extend `PerTypePanel.tsx` + `PerInstancePanel.tsx` — "kind" dropdown shows custom modifiers alongside built-ins
|
||||
|
||||
**Profile stacking UI** (`packages/chess/src/ui/Lobby.tsx`):
|
||||
- Allow selecting multiple profiles (stacked, ordered)
|
||||
- Reorder via drag/drop
|
||||
- Show stacked effect preview
|
||||
|
||||
**Docs**:
|
||||
- Update `docs/adr/modifier-profiles.md` — add T3-ADR-1 through T3-ADR-7 sections
|
||||
- New `docs/user/custom-modifiers.md` — user guide for authoring custom modifiers
|
||||
- New `docs/adr/T4-scripted-modifiers-design.md` — forward-looking design doc
|
||||
|
||||
**E2E** (`packages/chess/e2e/`):
|
||||
- New `custom-modifiers.spec.ts` — ~15 scenarios
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- [ ] `bun run check` green
|
||||
- [ ] All 15 primitives registered and individually tested
|
||||
- [ ] A user can create a "Shield" custom modifier in the UI using `seed-attribute` + `absorb-damage-with-attribute` primitives
|
||||
- [ ] Custom modifier saves to library, reloads after page refresh, applies to a game
|
||||
- [ ] Multi-profile stacking: 2 profiles stacked → HP bonuses add, direction additions union
|
||||
- [ ] Aura: a piece with `add-aura` radius=2 targetAttr=HpBonus delta=+1 gives +1 HP to all pieces within 2 squares
|
||||
- [ ] Server rejects malformed custom descriptors (recursion too deep, unknown primitive, parameter out of range)
|
||||
- [ ] Playwright e2e: 15 new scenarios pass in `custom-modifiers.spec.ts`
|
||||
- [ ] Existing 26 T1+T2 e2e tests still pass (no regression)
|
||||
- [ ] ADR + user docs + T4 design note published
|
||||
|
||||
### Must Have
|
||||
- Full DSL with 15 T3 primitives covering the 6 built-in modifier behaviors + new capabilities (auras, conditionals, event hooks)
|
||||
- Custom modifiers compose in the editor alongside built-ins
|
||||
- Per-engine custom registry (no cross-room leakage)
|
||||
- Multi-profile stacking with explicit priority
|
||||
- Aura effects via `add-aura` primitive
|
||||
- Server-side validation of custom descriptors (anti-DoS)
|
||||
- T4 forward-design document
|
||||
|
||||
### Must NOT Have (Guardrails)
|
||||
|
||||
- ❌ Scripted modifiers (T4 — only the forward-design note lives here)
|
||||
- ❌ Sandboxed script runtime in T3
|
||||
- ❌ User-defined primitives (primitives are engine-shipped)
|
||||
- ❌ Cross-room custom modifier sharing (explicit re-registration per room)
|
||||
- ❌ Breaking changes to T1/T2 built-in modifiers
|
||||
- ❌ Breaking WS protocol changes (custom-modifier messages are additive)
|
||||
- ❌ Recursion depth > 3 in primitive nesting (DoS guard)
|
||||
- ❌ More than 50 primitives per custom descriptor
|
||||
- ❌ More than 10 custom modifiers per room (DoS guard)
|
||||
|
||||
### Must NOT Have (AI Slop Patterns)
|
||||
|
||||
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
|
||||
- ❌ Generic names (`data`, `result`, `item`, `temp`)
|
||||
- ❌ Empty catch blocks
|
||||
- ❌ `console.log` in production code
|
||||
- ❌ Switch statements on primitive kinds (use registry dispatch)
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Test Decision
|
||||
- **Infrastructure**: vitest + Playwright, same as T1/T2
|
||||
- **Approach**: TDD per primitive, Playwright for UX
|
||||
|
||||
### QA Policy
|
||||
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t3/task-{N}-{slug}.{ext}`.
|
||||
|
||||
---
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
```
|
||||
Wave 1 (Foundation — PARALLEL):
|
||||
├── T1: T3-ADR documentation
|
||||
├── T2: Primitive types + registry class
|
||||
└── T3: Custom modifier descriptor types
|
||||
|
||||
Wave 2 (Primitive implementations — HIGHLY PARALLEL, 15 in parallel if caps allow):
|
||||
├── T4-T8: 5 state primitives (seed-attribute, add-to-attribute, multiply-attribute, add-direction, set-capture-flag)
|
||||
├── T9-T11: 3 damage primitives (absorb-damage-with-attribute, reflect-damage, modify-movement-range)
|
||||
├── T12-T14: 3 control primitives (block-move-type, override-promotion, add-aura)
|
||||
└── T15-T18: 4 event primitives (on-turn-start, on-capture, on-damaged, conditional)
|
||||
|
||||
Wave 3 (Integration — PARALLEL):
|
||||
├── T19: Custom descriptor validator (depth cap, parameter check)
|
||||
├── T20: Custom descriptor Zod schema
|
||||
├── T21: Custom descriptor library persistence
|
||||
├── T22: Engine integration — applyCustomDescriptor
|
||||
└── T23: Multi-profile stacking in apply/reconcile
|
||||
|
||||
Wave 4 (Server + UI — PARALLEL):
|
||||
├── T24: Server custom-modifier.register handler + validation
|
||||
├── T25: CustomModifierEditor UI (primitive composer)
|
||||
├── T26: Extend PerTypePanel/PerInstancePanel for custom kinds
|
||||
├── T27: Multi-profile picker in Lobby
|
||||
└── T28: Aura engine integration (computeAuraFacts hook)
|
||||
|
||||
Wave 5 (E2E + Docs — PARALLEL):
|
||||
├── T29: Playwright e2e suite (15 scenarios)
|
||||
├── T30: ADR updates
|
||||
├── T31: User docs (docs/user/custom-modifiers.md)
|
||||
└── T32: T4 forward-design document
|
||||
|
||||
Wave FINAL (4 parallel reviewers):
|
||||
├── F1: Plan compliance audit
|
||||
├── F2: Code quality review
|
||||
├── F3: Manual QA
|
||||
└── F4: Scope fidelity check
|
||||
```
|
||||
|
||||
### Dependency Matrix (Abbreviated)
|
||||
|
||||
| Group | Depends On | Blocks |
|
||||
|-------|------------|--------|
|
||||
| T1 (ADR) | — | ALL |
|
||||
| T2 (primitives registry) | T1 | T4-T18 |
|
||||
| T3 (custom desc types) | T1 | T19-T28 |
|
||||
| T4-T18 (primitives) | T2 | T19, T22, T25 |
|
||||
| T19 (validator) | T2, T4-T18 | T24 |
|
||||
| T20 (schema) | T3 | T24 |
|
||||
| T21 (library) | T3, T20 | T27 |
|
||||
| T22 (engine apply) | T4-T18 | T23, T28 |
|
||||
| T23 (stacking) | T22 | T27, T29 |
|
||||
| T24 (server) | T19, T20 | T29 |
|
||||
| T25 (editor) | T4-T18 | T26, T29 |
|
||||
| T26 (panel ext) | T25 | T29 |
|
||||
| T27 (lobby stack) | T21, T23 | T29 |
|
||||
| T28 (aura hook) | T22 | T29 |
|
||||
| T29 (e2e) | T23-T28 | F1-F4 |
|
||||
| T30-T32 (docs) | T1, T29 | F1 |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
*(Abbreviated — each task follows the same 7-section format as T1/T2. Full details TBD when executing.)*
|
||||
|
||||
- [ ] 1. **T3-ADR documentation**
|
||||
|
||||
**What to do**: Append T3-ADR-1 through T3-ADR-7 to `docs/adr/modifier-profiles.md`.
|
||||
|
||||
**Recommended Agent Profile**: `writing`
|
||||
|
||||
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
|
||||
|
||||
**Commit**: `docs(adr): T3 custom modifier DSL architecture decisions`
|
||||
|
||||
- [ ] 2. **Primitive types + registry class**
|
||||
|
||||
**What to do**: Create `packages/chess/src/modifiers/primitives/types.ts` (EffectPrimitive, PrimitiveKind, parameter type families). Create `primitives/registry.ts` with `PRIMITIVE_REGISTRY` singleton.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 1. Blocks: T4-T18. Blocked By: T1.
|
||||
|
||||
**Commit**: `feat(engine): primitive types and registry`
|
||||
|
||||
- [ ] 3. **Custom modifier descriptor types**
|
||||
|
||||
**What to do**: Create `packages/chess/src/modifiers/custom/types.ts` — `CustomModifierDescriptor` shape with { id, name, description, version, primitives[], targetAttrs[] }.
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 1. Blocks: T19-T28. Blocked By: T1.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier descriptor types`
|
||||
|
||||
- [ ] 4-18. **15 primitive implementations** (one per primitive)
|
||||
|
||||
**Pattern** (one task per primitive):
|
||||
- Create `packages/chess/src/modifiers/primitives/{kind}.ts`:
|
||||
- Export descriptor implementing `EffectPrimitive<Params>`
|
||||
- Zod schema for params
|
||||
- Apply function (session mutation or engine hook registration)
|
||||
- Side-effect register in `primitives/index.ts`
|
||||
- Create `.test.ts` with 3+ scenarios
|
||||
|
||||
Primitives to implement:
|
||||
- T4: seed-attribute
|
||||
- T5: add-to-attribute
|
||||
- T6: multiply-attribute
|
||||
- T7: add-direction
|
||||
- T8: set-capture-flag
|
||||
- T9: absorb-damage-with-attribute
|
||||
- T10: reflect-damage
|
||||
- T11: modify-movement-range
|
||||
- T12: block-move-type
|
||||
- T13: override-promotion
|
||||
- T14: add-aura
|
||||
- T15: on-turn-start
|
||||
- T16: on-capture
|
||||
- T17: on-damaged
|
||||
- T18: conditional
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-high` (all)
|
||||
|
||||
**Parallelization**: Wave 2 (PARALLEL). Blocks: T19, T22, T25. Blocked By: T2.
|
||||
|
||||
**Commits**: `feat(engine): {kind} effect primitive` (×15)
|
||||
|
||||
- [ ] 19. **Custom descriptor validator**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/custom/validate.ts` — validates a CustomModifierDescriptor:
|
||||
- Every primitive kind is in PRIMITIVE_REGISTRY
|
||||
- Primitive params satisfy primitive's Zod schema
|
||||
- Recursion depth ≤ 3 (count nesting of on-turn-start / on-capture / on-damaged / conditional)
|
||||
- Total primitive count ≤ 50
|
||||
- No circular references (descriptor referencing itself is banned)
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T2, T4-T18.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier descriptor validator`
|
||||
|
||||
- [ ] 20. **Zod schema for custom descriptor serialization**
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T3.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier Zod schema`
|
||||
|
||||
- [ ] 21. **Custom modifier library persistence**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/custom/library.ts` — localStorage at `houserules:custom-modifiers:v1`. Mirror T1 library API.
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T27. Blocked By: T3, T20.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier library persistence`
|
||||
|
||||
- [ ] 22. **Engine integration — applyCustomDescriptor**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/custom/apply.ts` — when a profile's perType/perInstance entry references a custom kind, resolve the descriptor from per-engine registry, execute its primitive list on the target piece.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T23, T28. Blocked By: T4-T18.
|
||||
|
||||
**Commit**: `feat(engine): apply custom modifier descriptors`
|
||||
|
||||
- [ ] 23. **Multi-profile stacking in apply/reconcile**
|
||||
|
||||
**What to do**: Extend `applyProfileToSession` → `applyProfilesToSession(session, profiles, layout)`. Extend `reconcileProfileSwap` → `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`. Stacking per ADR-4 rules apply across multiple profiles.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T27, T29. Blocked By: T22.
|
||||
|
||||
**Commit**: `feat(engine): multi-profile stacking`
|
||||
|
||||
- [ ] 24. **Server custom-modifier.register handler**
|
||||
|
||||
**What to do**:
|
||||
- New WS message `custom-modifier.register` with full descriptor payload
|
||||
- Server validates via T19's validator
|
||||
- Registers per-room (per-engine registry)
|
||||
- Rejects if > 10 custom modifiers per room
|
||||
- Broadcasts `custom-modifier.registered` to opponent
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T19, T20.
|
||||
|
||||
**Commit**: `feat(server): custom-modifier.register WS handler`
|
||||
|
||||
- [ ] 25. **CustomModifierEditor.tsx — primitive composer UI**
|
||||
|
||||
**What to do**: Visual editor with:
|
||||
- PrimitivePalettePanel — 15 primitives grouped by category
|
||||
- Primitive tree view — shows nesting for on-turn-start / conditional
|
||||
- PrimitiveInspectorPanel — parameter form per primitive, driven by primitive's Zod schema
|
||||
- Add/delete/reorder primitives
|
||||
- Save/load from library
|
||||
- Inline validation (run T19 validator live)
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T26, T29. Blocked By: T4-T18.
|
||||
|
||||
**Commit**: `feat(ui): custom modifier editor`
|
||||
|
||||
- [ ] 26. **Extend PerTypePanel/PerInstancePanel for custom kinds**
|
||||
|
||||
**What to do**: Kind dropdown iterates `MODIFIER_REGISTRY.list() + customRegistry.list()`. Custom kinds use the primitive composer for value input (or show a simplified parameter form).
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T25.
|
||||
|
||||
**Commit**: `feat(ui): custom modifiers in modifier profile panels`
|
||||
|
||||
- [ ] 27. **Multi-profile picker in Lobby**
|
||||
|
||||
**What to do**: Lobby's profile picker becomes multi-select with ordering. Drag to reorder. Selected profiles stack in UI-visible order.
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T21, T23.
|
||||
|
||||
**Commit**: `feat(ui): multi-profile stacking in lobby`
|
||||
|
||||
- [ ] 28. **Aura engine integration**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/auras.ts` — `computeAuraFacts(session)` runs after every move via pseudo-preset. Walks all aura-primitive-declared modifiers, finds affected pieces within radius, updates derived facts.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T22.
|
||||
|
||||
**Commit**: `feat(engine): aura effect computation`
|
||||
|
||||
- [ ] 29. **Playwright e2e suite (15 scenarios)**
|
||||
|
||||
**What to do**: Create `packages/chess/e2e/custom-modifiers.spec.ts`:
|
||||
1. Open custom modifier editor from modifier profile editor
|
||||
2. Create "Shield" custom modifier via primitive composer
|
||||
3. Save custom modifier to library
|
||||
4. Reload page → custom modifier still in library
|
||||
5. Use custom modifier in a profile (per-type entry)
|
||||
6. Start solo game with profile using custom modifier — verify behavior
|
||||
7. Multi-profile stacking (2 profiles) — HP bonuses add
|
||||
8. Aura effect — piece within radius gets derived attr
|
||||
9. Aura updates after move (target moves out of radius → fact retracted)
|
||||
10. Server rejects custom modifier with > 50 primitives
|
||||
11. Server rejects custom modifier with > 3 recursion depth
|
||||
12. Custom modifier sharing via multiplayer — both clients see same behavior
|
||||
13. Conditional primitive works (if HP < 2, do X)
|
||||
14. on-turn-start primitive fires
|
||||
15. absorb-damage-with-attribute works (shield absorbs before HP)
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
|
||||
|
||||
**Parallelization**: Wave 5. Blocks: F1-F4. Blocked By: T22-T28.
|
||||
|
||||
**Commit**: `test(e2e): custom modifier DSL vertical slice`
|
||||
|
||||
- [ ] 30. **ADR updates — T3 Implementation Retrospective**
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 5. Blocks: F1. Blocked By: T1, T22-T28.
|
||||
|
||||
**Commit**: `docs(adr): T3 implementation retrospective`
|
||||
|
||||
- [ ] 31. **User docs: docs/user/custom-modifiers.md**
|
||||
|
||||
**What to do**: New user guide:
|
||||
- What are custom modifiers?
|
||||
- Opening the editor
|
||||
- The 15 effect primitives (one subsection each with 1 example)
|
||||
- Composing primitives (simple to complex)
|
||||
- Saving & sharing
|
||||
- Multi-profile stacking
|
||||
- Aura effects (radius, affected pieces)
|
||||
- Limitations (50 primitive cap, depth cap, 10 per room)
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Commit**: `docs(user): custom modifier DSL user guide`
|
||||
|
||||
- [ ] 32. **T4 forward-design document**
|
||||
|
||||
**What to do**: Create `docs/adr/T4-scripted-modifiers-design.md`:
|
||||
- Why T4 is deferred (security complexity)
|
||||
- Sandbox candidates evaluated (QuickJS, Duktape, custom mini-interpreter)
|
||||
- Descriptor shape extension (`type: "data" | "scripted"`)
|
||||
- Permission system sketch
|
||||
- Validation strategy (static analysis of script before execution)
|
||||
- How T3 primitives can be migrated (scripted modifier that wraps a primitive sequence)
|
||||
- Open questions
|
||||
|
||||
**Recommended Agent Profile**: `writing`
|
||||
|
||||
**Commit**: `docs(adr): T4 scripted modifiers forward-design`
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave
|
||||
|
||||
- [ ] F1. **Plan Compliance Audit** — `oracle`
|
||||
Verify all 15 primitives, all 32 tasks' deliverables, T3-ADR decisions reflected. No T4 scripted modifiers shipped (only design doc).
|
||||
Output: `Primitives [15/15] | Tasks [N/N] | ADRs [7/7] | VERDICT`
|
||||
|
||||
- [ ] F2. **Code Quality Review** — `unspecified-high`
|
||||
`bun run check`. Scan for slop. Verify registry-dispatch pattern (no hardcoded kind switches).
|
||||
Output: `Build [PASS] | Lint [PASS] | Tests [N pass] | VERDICT`
|
||||
|
||||
- [ ] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
|
||||
Execute all 15 new e2e scenarios. Author a Shield custom modifier via UI, play a game with it, verify full behavior end-to-end.
|
||||
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
|
||||
|
||||
- [ ] F4. **Scope Fidelity** — `deep`
|
||||
No scripted modifiers (only forward-design doc). No cross-room leakage. Recursion cap enforced. Primitive count cap enforced.
|
||||
Output: `Tasks [N/N compliant] | T4 smuggling [CLEAN] | VERDICT`
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. `docs(adr): T3 custom modifier DSL architecture decisions`
|
||||
2. `feat(engine): primitive types and registry`
|
||||
3. `feat(engine): custom modifier descriptor types`
|
||||
4-18. `feat(engine): {kind} effect primitive` (×15)
|
||||
19. `feat(engine): custom modifier descriptor validator`
|
||||
20. `feat(engine): custom modifier Zod schema`
|
||||
21. `feat(engine): custom modifier library persistence`
|
||||
22. `feat(engine): apply custom modifier descriptors`
|
||||
23. `feat(engine): multi-profile stacking`
|
||||
24. `feat(server): custom-modifier.register WS handler`
|
||||
25. `feat(ui): custom modifier editor`
|
||||
26. `feat(ui): custom modifiers in modifier profile panels`
|
||||
27. `feat(ui): multi-profile stacking in lobby`
|
||||
28. `feat(engine): aura effect computation`
|
||||
29. `test(e2e): custom modifier DSL vertical slice`
|
||||
30. `docs(adr): T3 implementation retrospective`
|
||||
31. `docs(user): custom modifier DSL user guide`
|
||||
32. `docs(adr): T4 scripted modifiers forward-design`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
```bash
|
||||
bun run check # all green
|
||||
bun run test packages/chess/src/modifiers/primitives/ # all 15 primitive tests pass
|
||||
bun run test packages/chess/src/modifiers/custom/ # apply + validate + library pass
|
||||
bunx playwright test e2e/custom-modifiers.spec.ts # 15/15 pass
|
||||
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass (no regression)
|
||||
```
|
||||
|
||||
### Final Checklist
|
||||
- [ ] All 15 primitives registered and individually tested
|
||||
- [ ] Custom modifier editor UI functional
|
||||
- [ ] Server validates + registers custom descriptors per-room
|
||||
- [ ] Multi-profile stacking works
|
||||
- [ ] Auras work (derived facts update on move)
|
||||
- [ ] 15 Playwright e2e + 26 existing pass (41 total)
|
||||
- [ ] ADR + user docs + T4 design note published
|
||||
- [ ] `bun run check` green
|
||||
- [ ] F1-F4 all APPROVE
|
||||
- [ ] User explicit "okay"
|
||||
90
packages/chess/e2e/solo-smoke.spec.ts
Normal file
90
packages/chess/e2e/solo-smoke.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Solo-play smoke (T2 preview tests)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('play-solo button navigates to /game with fresh board', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game', { timeout: 5000 });
|
||||
await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
|
||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||
});
|
||||
|
||||
test('drag pawn e2 to e4 resolves', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page
|
||||
.locator('[data-square="e2"] [data-piece]')
|
||||
.dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('can play multiple moves in sequence (4-ply)', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
|
||||
const drag = async (from: string, to: string) => {
|
||||
await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`));
|
||||
// small wait for animation / state update
|
||||
await page.waitForTimeout(100);
|
||||
};
|
||||
|
||||
await drag('e2', 'e4');
|
||||
await drag('e7', 'e5');
|
||||
await drag('g1', 'f3');
|
||||
await drag('b8', 'c6');
|
||||
|
||||
// After 4 plies: e4, e5, f3, c6 all occupied; origins empty
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rules drawer opens without breaking board interaction', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||||
// Close drawer (esc or similar) and verify board still works
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
// Drag a move
|
||||
await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('no console errors on solo game start', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.waitForTimeout(1000); // let any async init complete
|
||||
await expect(page.locator('[data-square="e2"]')).toBeVisible();
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('Errors:', errors);
|
||||
throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -57,13 +57,22 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
|||
}, [isOpen]);
|
||||
|
||||
// Esc closes the modal regardless of focus.
|
||||
//
|
||||
// We use capture phase + stopImmediatePropagation so that other
|
||||
// window-level Esc handlers (e.g. the RulesDrawer behind us) do not
|
||||
// ALSO fire on the same keystroke. Without this, pressing Esc with
|
||||
// both editor+drawer open would close both, leaving neither visible
|
||||
// but the drawer's pointer-events-blocking backdrop briefly lingers.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
e.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
|
|
|||
|
|
@ -105,6 +105,18 @@ export function RulesDrawer({
|
|||
};
|
||||
}, [open]);
|
||||
|
||||
// Esc closes the drawer. Nested modals (ModifierProfileEditor) attach
|
||||
// their own capture-phase Esc listeners with stopImmediatePropagation,
|
||||
// so this only fires when no nested modal is open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
const toggle = (id: string, name: string) => {
|
||||
const currently = activeById.get(id);
|
||||
const next: PresetActivation[] = currently
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue