5.4 KiB
Implementation Plan: Fix HP Popover Overflow
Branch: 024-fix-hp-popover-overflow | Date: 2026-03-09 | Spec: spec.md
Input: Feature specification from /specs/024-fix-hp-popover-overflow/spec.md
Summary
The HP adjustment popover (HpAdjustPopover) overflows the right edge of the viewport when the trigger element (HP display) is positioned near the right side of the combatant row. This causes the popover to be partially hidden and introduces a horizontal scrollbar. The fix adds viewport-aware horizontal positioning using a useLayoutEffect measurement pattern, consistent with the existing ConditionPicker component which already handles vertical overflow.
Technical Context
Language/Version: TypeScript 5.8 (strict mode, verbatimModuleSyntax) Primary Dependencies: React 19, Tailwind CSS v4, Lucide React (icons) Storage: N/A (no storage changes — purely presentational fix) Testing: Vitest Target Platform: Web browser (desktop + mobile) Project Type: Web application (monorepo: apps/web + packages/domain + packages/application) Performance Goals: N/A (simple layout fix) Constraints: No heavy positioning libraries; lightweight DOM measurement only Scale/Scope: Single component fix (~20 lines changed)
Constitution Check
GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.
| Principle | Status | Notes |
|---|---|---|
| I. Deterministic Domain Core | PASS | No domain changes — adapter-layer only |
| II. Layered Architecture | PASS | Change is entirely in the adapter layer (web UI component) |
| III. Agent Boundary | N/A | No agent involvement |
| IV. Clarification-First | PASS | Bug fix with clear spec, no ambiguity |
| V. Escalation Gates | PASS | Scope matches spec exactly |
| VI. MVP Baseline Language | PASS | No scope restrictions involved |
| VII. No Gameplay Rules | PASS | No gameplay mechanics involved |
All gates pass. No violations.
Project Structure
Documentation (this feature)
specs/024-fix-hp-popover-overflow/
├── plan.md # This file
├── research.md # Phase 0 output
└── tasks.md # Phase 2 output (/speckit.tasks command)
Source Code (repository root)
apps/web/src/components/
├── hp-adjust-popover.tsx # FIX: Add viewport-aware horizontal positioning
└── combatant-row.tsx # Context: ClickableHp renders popover (relative parent)
Structure Decision: This is a single-component bug fix within the existing apps/web adapter layer. No new files, packages, or architectural changes needed.
Design
Root Cause
The HpAdjustPopover uses absolute positioning relative to its parent ClickableHp container (which has relative positioning). The popover renders to the right of the HP value display. When the HP display is near the right edge of the viewport (common since HP is in one of the rightmost grid columns), the popover extends beyond the viewport boundary.
The combatant list container has overflow-y-auto but no overflow-x: hidden, so the popover overflow manifests as a horizontal scrollbar on the page.
Approach: position: fixed with useLayoutEffect viewport clamping
Switch the popover from position: absolute to position: fixed and use a useLayoutEffect hook to:
- Measure the trigger element's viewport rect via
parentElement.getBoundingClientRect() - Measure the popover's own width via
getBoundingClientRect() - Position the popover just below the trigger (
trigger.bottom + 4) - Clamp the
leftcoordinate so the popover stays within viewport bounds (8px padding) - Render with
visibility: hiddenuntil positioned to avoid a flash
This approach was chosen because the popover's scroll container ancestor (overflow-y-auto) clips absolutely-positioned children, making position: absolute with right-0 or translateX ineffective — the popover gets clipped rather than repositioned.
Implementation Detail
- Replace
absolutewithfixedpositioning on the popover container - Add
useLayoutEffectthat readsparentElement.getBoundingClientRect()for the trigger position andel.getBoundingClientRect()for the popover width - Compute
left = trigger.left, clamped to[8, vw - popover.width - 8]usingdocument.documentElement.clientWidth - Set
top = trigger.bottom + 4 - Store computed
{ top, left }in state; rendervisibility: hiddenuntil positioned - Click-outside detection continues to work since
document.addEventListener("mousedown")is not affected by positioning scheme
Alternatives Considered
- CSS-only
overflow-x: hiddenon ancestor: Would clip the popover rather than reposition it, and could hide other legitimate content. - Heavy positioning libraries (Floating UI, Popper): Overkill for a small popover with a simple overflow case.
position: absolutewithright-0flip (ConditionPicker pattern): The popover's relative parent is too small (just the HP number), soright-0barely shifts the popover. Also, the scroll container's overflow clipping still clips the popover.position: absolutewithtranslateX: Same clipping issue — the scroll container clips the popover regardless of transform.
Post-Design Constitution Re-check
All gates still pass. The fix is entirely in the adapter layer, uses no domain logic, and stays within spec scope.