5.6 KiB
Research: Fix Zero-HP Opacity
Feature: 020-fix-zero-hp-opacity Date: 2026-03-06
Root Cause Analysis
Decision: The bug is caused by CSS opacity inheritance on the row container
Rationale: In combatant-row.tsx:312, the class opacity-50 is applied to the outermost <div> when the combatant's HP status is "unconscious". CSS opacity creates a new stacking context and affects all descendant elements — it cannot be overridden by child elements setting their own opacity. The HpAdjustPopover and ConditionPicker are rendered as children of this container (positioned absolute within it), so they inherit the 50% opacity.
Alternatives considered:
- Portal-based rendering: Move popouts to a React portal outside the row container. This would work but is over-engineered for this fix and would break the current relative positioning pattern used by both components.
- CSS
filter: opacity(): Same inheritance problem as theopacityproperty.
Fix Approach
Decision: Replace opacity-50 on the row container with scoped opacity on row content elements only
Rationale: Instead of making the entire row container semi-transparent, apply the dimming effect only to the static content elements within the row (the grid row with name/initiative/HP/AC and the condition tags area). The popout/dropdown elements sit outside this visual flow and should not be dimmed. This can be achieved by:
- Removing
opacity-50from the outer container. - Adding a wrapper
<div>(or applying directly to the existing grid<div>and conditions<div>) withopacity-50when unconscious — but not wrapping the popover/picker elements.
However, since the HpAdjustPopover is rendered inside ClickableHp which is inside the grid, and ConditionPicker is rendered inside the conditions container, the cleanest approach is:
- Apply
opacity-50to the inner grid and conditions container directly rather than the outer wrapper. - Since the popovers are absolutely positioned children of these containers, they'll still inherit opacity. The actual fix needs to use a different visual treatment that doesn't cascade.
Decision: Use text/color-based dimming instead of CSS opacity
Rationale: Replace the opacity-50 approach with text-muted-foreground (or similar muted color classes) applied to the row's static text elements. This achieves the same visual "dimmed" effect for the unconscious state without using CSS opacity, which unavoidably cascades to absolutely-positioned children.
Alternatively, apply opacity-50 specifically to individual leaf elements (initiative input, name, AC display, HP display, condition tags) rather than a container. However, the simplest single-point fix is:
- Keep
opacity-50on the row container but addopacity-100(via a utility class) to the popover/picker wrapper elements. Since CSS opacity on a child cannot override a parent's opacity, this won't work directly.
Final Decision: Use CSS filter or restructure to isolate popovers
The simplest correct fix: move the popovers outside the opacity scope by changing the structure so the row content that gets dimmed is a separate child from the popover containers.
Approach:
- Remove
opacity-50from the outer row<div>. - Wrap the row's visible content (grid + conditions area) in a
<div>that receivesopacity-50when unconscious. - Render
HpAdjustPopoverandConditionPickeras siblings of this wrapper (still within the row for positioning context) rather than deep inside the dimmed content.
Rejected for being too invasive to component structure. The popover is deeply nested inside ClickableHp.
Actual Simplest Fix: Use text-opacity / color-based dimming
Replace the container-level opacity-50 with Tailwind's color-based dimming:
text-muted-foreground/50for text elements- Or simply use a lighter text color and reduced border opacity
But: This changes more styling rules than necessary and may not look identical.
Pragmatic Fix: Apply opacity-50 to the grid and conditions wrapper, and reset opacity on popover elements using isolation
Since CSS opacity on a parent always affects children, the real fix is to not apply opacity to any ancestor of the popovers. The most surgical approach:
- Remove
opacity-50from the outer<div>. - Add
opacity-50to the inner grid<div>(line 316). - Add
opacity-50to the conditions container<div>(line 388). - The
HpAdjustPopover(absolutely positioned,z-10) andConditionPicker(also absolutely positioned) are children of elements within these containers — they'll still be affected.
Final approach: The only way to truly isolate is to ensure popovers aren't DOM children of dimmed elements. Use a two-sibling layout within the relative containers:
For ClickableHp: The popover is already inside a <div className="relative">. We can't change that without moving the component.
Conclusion: Simplest correct approach
Apply opacity-50 to individual non-interactive display elements within the row rather than any container that has popover children. Specifically:
- Remove
opacity-50from the outer row container (line 312). - When
status === "unconscious", pass a prop or apply conditional classes to:- The initiative input wrapper
- The name display
- The AC display
- The HP number display (the button text, not the popover)
- The condition tags
- The remove button
- Popovers and pickers remain unaffected since they're not inside any dimmed container.
This is the most targeted fix with minimal structural changes.