Implement the 032-inline-confirm-buttons feature that replaces single-click destructive actions with a reusable ConfirmButton component providing inline two-step confirmation (click to arm, click to execute), applied to the remove combatant and clear encounter buttons, with CSS scale pulse animation, 5-second auto-revert, click-outside/Escape/blur dismissal, full keyboard accessibility, and 13 unit tests via @testing-library/react

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-11 09:51:21 +01:00
parent d101906776
commit 0747d044f3
17 changed files with 1364 additions and 32 deletions

View File

@@ -0,0 +1,151 @@
# Tasks: Inline Confirmation Buttons
**Input**: Design documents from `/specs/032-inline-confirm-buttons/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
**Tests**: Not explicitly requested in spec. Tests included for the ConfirmButton component since its state machine logic is well-suited to unit testing and the spec defines precise acceptance scenarios.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: CSS animation and reusable component that all user stories depend on
- [x] T001 Add `animate-confirm-pulse` scale pulse keyframe animation in `apps/web/src/index.css` — define a `@keyframes confirm-pulse` (scale 1 → 1.15 → 1) and register it as `@utility animate-confirm-pulse` following the existing `animate-concentration-pulse` pattern
- [x] T002 Create `ConfirmButton` component in `apps/web/src/components/ui/confirm-button.tsx` — wraps the existing `Button` component with a two-step confirmation flow: accepts `onConfirm`, `icon`, `label`, `className?`, `disabled?` props; manages `isConfirming` state via `useState`; on first click sets `isConfirming=true` and starts a 5-second `setTimeout` to auto-revert; on second click calls `onConfirm()`; in confirm state renders `Check` icon with `bg-destructive text-primary-foreground rounded-md animate-confirm-pulse`; adds `mousedown` click-outside listener and `keydown` Escape listener (both with cleanup); reverts on focus loss via `onBlur`; calls `e.stopPropagation()` on all clicks; updates `aria-label` to reflect confirm state (e.g., "Confirm remove combatant" when armed, using `label` prop as base); cleans up timer on unmount
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Unit tests for the ConfirmButton state machine
- [x] T003 Write unit tests for `ConfirmButton` in `apps/web/src/__tests__/confirm-button.test.tsx` — test: (1) renders default icon in idle state, (2) first click transitions to confirm state with Check icon and destructive background, (3) second click in confirm state calls `onConfirm`, (4) auto-reverts after 5 seconds (use `vi.useFakeTimers`), (5) Escape key reverts to idle, (6) click outside reverts to idle, (7) disabled prop prevents entering confirm state, (8) unmount cleans up timer, (9) independent instances don't interfere with each other
**Checkpoint**: ConfirmButton component is fully functional and tested. User story integration can begin.
---
## Phase 3: User Story 1 — Confirm-to-delete for removing a combatant (Priority: P1) MVP
**Goal**: The remove combatant (X) button uses ConfirmButton instead of deleting immediately.
**Independent Test**: Add a combatant, click X once (verify confirm state with checkmark), click again (verify removal). Wait 5s after first click (verify revert). Press Escape (verify cancel).
### Implementation for User Story 1
- [x] T004 [US1] Replace the remove `<Button>` with `<ConfirmButton>` in `apps/web/src/components/combatant-row.tsx` — swap the existing `<Button variant="ghost" size="icon" ... onClick={onRemove(id)}>` (around line 546) with `<ConfirmButton icon={<X size={16} />} label="Remove combatant" onConfirm={() => onRemove(id)} className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity" />`; the ConfirmButton handles `stopPropagation` internally (per T002) so the parent row click is not triggered
**Checkpoint**: Remove combatant now requires two clicks. Single-click accidental deletion is eliminated.
---
## Phase 4: User Story 2 — Confirm-to-clear for resetting the encounter (Priority: P2)
**Goal**: The clear encounter (trash) button uses ConfirmButton instead of `window.confirm()`.
**Independent Test**: Add combatants, click trash once (verify confirm state), click again (verify encounter cleared). Verify disabled state when no combatants.
### Implementation for User Story 2
- [x] T005 [US2] Replace the trash `<Button>` with `<ConfirmButton>` in `apps/web/src/components/turn-navigation.tsx` — swap the existing `<Button variant="ghost" size="icon" ... onClick={onClearEncounter}>` (around line 77) with `<ConfirmButton icon={<Trash2 className="h-5 w-5" />} label="Clear encounter" onConfirm={onClearEncounter} disabled={!hasCombatants} className="h-8 w-8 text-muted-foreground" />`
- [x] T006 [US2] Remove `window.confirm()` guard from `clearEncounter` callback in `apps/web/src/hooks/use-encounter.ts` — delete the `if (!window.confirm(...)) return;` block (around line 229) since confirmation is now handled by the ConfirmButton UI component
**Checkpoint**: Clear encounter now uses inline confirmation instead of browser dialog. Both destructive actions have consistent UX.
---
## Phase 5: User Story 3 — Keyboard-accessible confirmation flow (Priority: P2)
**Goal**: All confirmation flows are fully operable via keyboard (Enter/Space to activate, Escape to cancel, Tab-away to revert).
**Independent Test**: Tab to a destructive button, press Enter (confirm state), Enter again (action executes). Tab to button, press Enter (confirm state), press Escape (reverts). Tab to button, press Enter (confirm state), Tab away (reverts).
### Implementation for User Story 3
- [x] T007 [US3] Verify keyboard handling in `apps/web/src/components/ui/confirm-button.tsx` — ensure the component uses a native `<button>` element (via the `Button` wrapper) so Enter/Space activation works by default; verify that the `onKeyDown` handler for Escape is attached and works in confirm state; verify `onBlur` revert fires when tabbing away; verify dynamic `aria-label` (built into T002) works correctly; run manual keyboard testing
- [x] T008 [US3] Add keyboard-specific unit tests in `apps/web/src/__tests__/confirm-button.test.tsx` — append tests: (1) Enter key triggers confirm state, (2) Space key triggers confirm state, (3) Enter in confirm state calls `onConfirm`, (4) Escape in confirm state reverts, (5) blur event reverts confirm state
**Checkpoint**: All confirmation flows work identically via mouse and keyboard.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Quality gates and final validation
- [x] T009 Run `pnpm check` to verify all quality gates pass (audit, knip, biome, typecheck, test/coverage, jscpd)
- [ ] T010 Run quickstart.md manual validation — start dev server with `pnpm --filter web dev`, test all scenarios from quickstart.md: add combatant, click X (confirm state), click again (removal), click X then wait 5s (revert), click trash (confirm), click again (clear), keyboard flows (Tab/Enter/Escape)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on T001, T002 from Setup
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion
- **User Story 2 (Phase 4)**: Depends on Phase 2 completion — independent of US1
- **User Story 3 (Phase 5)**: Depends on Phase 2 completion — verifies keyboard behavior built into T002
- **Polish (Phase 6)**: Depends on all user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Phase 2 — no dependencies on other stories
- **User Story 2 (P2)**: Can start after Phase 2 — independent of US1 (different files)
- **User Story 3 (P2)**: Can start after Phase 2 — may refine T002 implementation, so best done after US1/US2 to avoid rework
### Parallel Opportunities
- T001 and T002 are sequential (T002 uses the animation from T001)
- T004 (US1) and T005+T006 (US2) can run in parallel after Phase 2 (different files)
- T007 and T008 (US3) are sequential within their story
---
## Parallel Example: User Stories 1 & 2
```bash
# After Phase 2 completes, launch US1 and US2 in parallel:
Task: T004 [US1] "Replace remove Button with ConfirmButton in combatant-row.tsx"
Task: T005 [US2] "Replace trash Button with ConfirmButton in turn-navigation.tsx"
Task: T006 [US2] "Remove window.confirm() from use-encounter.ts"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001, T002) — create animation and component
2. Complete Phase 2: Foundational (T003) — test the component
3. Complete Phase 3: User Story 1 (T004) — integrate with combatant remove
4. **STOP and VALIDATE**: Test remove combatant confirmation independently
5. Commit and verify `pnpm check` passes
### Incremental Delivery
1. Setup + Foundational → ConfirmButton ready and tested
2. Add User Story 1 → Remove combatant has confirmation → MVP
3. Add User Story 2 → Clear encounter uses inline confirm → Consistent UX
4. Add User Story 3 → Keyboard accessibility verified and tested → Complete
5. Polish → Quality gates, manual validation → Ship
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story is independently completable and testable
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- The ConfirmButton component (T002) is the critical path — all stories depend on it