Implement the 020-fix-zero-hp-opacity feature that replaces container-level opacity dimming with element-level opacity on individual leaf elements so that HP popover and condition picker render at full opacity for unconscious combatants
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions)
|
||||
- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons) (020-fix-zero-hp-opacity)
|
||||
|
||||
## Recent Changes
|
||||
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||
|
||||
@@ -154,17 +154,24 @@ function ClickableHp({
|
||||
currentHp,
|
||||
maxHp,
|
||||
onAdjust,
|
||||
dimmed,
|
||||
}: {
|
||||
currentHp: number | undefined;
|
||||
maxHp: number | undefined;
|
||||
onAdjust: (delta: number) => void;
|
||||
dimmed?: boolean;
|
||||
}) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
return (
|
||||
<span className="inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
--
|
||||
</span>
|
||||
);
|
||||
@@ -180,6 +187,7 @@ function ClickableHp({
|
||||
status === "bloodied" && "text-amber-400",
|
||||
status === "unconscious" && "text-red-400",
|
||||
status === "healthy" && "text-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{currentHp}
|
||||
@@ -271,6 +279,7 @@ export function CombatantRow({
|
||||
}: CombatantRowProps) {
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
const dimmed = status === "unconscious";
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const prevHpRef = useRef(currentHp);
|
||||
@@ -309,7 +318,6 @@ export function CombatantRow({
|
||||
: combatant.isConcentrating
|
||||
? "border-l-2 border-l-purple-400"
|
||||
: "border-l-2 border-l-transparent",
|
||||
status === "unconscious" && "opacity-50",
|
||||
isPulsing && "animate-concentration-pulse",
|
||||
)}
|
||||
>
|
||||
@@ -323,7 +331,9 @@ export function CombatantRow({
|
||||
className={cn(
|
||||
"flex items-center justify-center transition-opacity",
|
||||
combatant.isConcentrating
|
||||
? "opacity-100 text-purple-400"
|
||||
? dimmed
|
||||
? "opacity-50 text-purple-400"
|
||||
: "opacity-100 text-purple-400"
|
||||
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -336,7 +346,10 @@ export function CombatantRow({
|
||||
inputMode="numeric"
|
||||
value={initiative ?? ""}
|
||||
placeholder="--"
|
||||
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
||||
className={cn(
|
||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
@@ -351,10 +364,14 @@ export function CombatantRow({
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -362,20 +379,31 @@ export function CombatantRow({
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||
dimmed={dimmed}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm tabular-nums text-muted-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
className={cn(
|
||||
"h-7 w-7 text-muted-foreground hover:text-destructive",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onClick={() => onRemove(id)}
|
||||
title="Remove combatant"
|
||||
aria-label="Remove combatant"
|
||||
@@ -386,11 +414,13 @@ export function CombatantRow({
|
||||
|
||||
{/* Conditions */}
|
||||
<div className="relative ml-[calc(3rem+0.75rem)]">
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
{pickerOpen && (
|
||||
<ConditionPicker
|
||||
activeConditions={combatant.conditions}
|
||||
|
||||
35
specs/020-fix-zero-hp-opacity/checklists/requirements.md
Normal file
35
specs/020-fix-zero-hp-opacity/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Fix Zero-HP Opacity
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-06
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. The Assumptions section references CSS opacity as root cause context, which is acceptable for a bug-fix spec since it describes the observed problem rather than prescribing an implementation.
|
||||
- Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
14
specs/020-fix-zero-hp-opacity/data-model.md
Normal file
14
specs/020-fix-zero-hp-opacity/data-model.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Data Model: Fix Zero-HP Opacity
|
||||
|
||||
**Feature**: 020-fix-zero-hp-opacity
|
||||
**Date**: 2026-03-06
|
||||
|
||||
## Entities
|
||||
|
||||
No data model changes required. This is a purely visual/CSS bug fix in the adapter layer.
|
||||
|
||||
The existing `deriveHpStatus()` domain function already correctly returns `"unconscious"` for combatants at 0 HP. The rendering logic that consumes this status is the only thing that changes.
|
||||
|
||||
## State
|
||||
|
||||
No new state is introduced. The existing `status` derived value (`"healthy" | "bloodied" | "unconscious"`) continues to drive the visual treatment.
|
||||
81
specs/020-fix-zero-hp-opacity/plan.md
Normal file
81
specs/020-fix-zero-hp-opacity/plan.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Implementation Plan: Fix Zero-HP Opacity
|
||||
|
||||
**Branch**: `020-fix-zero-hp-opacity` | **Date**: 2026-03-06 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/020-fix-zero-hp-opacity/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Fix a CSS bug where `opacity-50` on the combatant row container cascades to absolutely-positioned popover and dropdown children, making the HP adjust popover and condition picker unusable for unconscious (0 HP) combatants. The fix removes the container-level opacity and applies dimming to individual leaf elements instead, so overlay components render at full opacity.
|
||||
|
||||
## 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)
|
||||
**Testing**: Vitest (existing test suite)
|
||||
**Target Platform**: Web browser (Vite 6 dev server)
|
||||
**Project Type**: Web application (monorepo: apps/web + packages/domain + packages/application)
|
||||
**Performance Goals**: N/A (visual fix only)
|
||||
**Constraints**: Must pass `pnpm check` merge gate
|
||||
**Scale/Scope**: Single file change (`combatant-row.tsx`)
|
||||
|
||||
## 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. Fix is adapter-layer only. |
|
||||
| II. Layered Architecture | PASS | Change is entirely in `apps/web` (adapter layer). No cross-layer imports added. |
|
||||
| III. Agent Boundary | N/A | No agent layer involvement. |
|
||||
| IV. Clarification-First | PASS | Bug is clearly defined; no ambiguous decisions. |
|
||||
| V. Escalation Gates | PASS | Fix is within spec scope. |
|
||||
| VI. MVP Baseline Language | PASS | No scope language changes. |
|
||||
| VII. No Gameplay Rules | PASS | No gameplay logic involved. |
|
||||
|
||||
**Post-Phase 1 re-check**: All gates still pass. The fix is a single-file CSS class restructuring in the adapter layer with no architectural implications.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/020-fix-zero-hp-opacity/
|
||||
├── plan.md # This file
|
||||
├── research.md # Root cause analysis and fix approach
|
||||
├── data-model.md # No data model changes (documented)
|
||||
├── quickstart.md # Verification steps
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/web/src/components/
|
||||
└── combatant-row.tsx # Single file to modify
|
||||
```
|
||||
|
||||
**Structure Decision**: This is a bug fix touching a single component file. No new files, modules, or structural changes needed. The existing monorepo structure (`apps/web` → `packages/application` → `packages/domain`) is unchanged.
|
||||
|
||||
## Fix Approach
|
||||
|
||||
### Current (broken)
|
||||
|
||||
`opacity-50` is applied to the outermost row `<div>` when `status === "unconscious"` (line 312). This creates a CSS stacking context that dims **all** descendants, including the absolutely-positioned `HpAdjustPopover` and `ConditionPicker`.
|
||||
|
||||
### Proposed (fixed)
|
||||
|
||||
1. **Remove** `opacity-50` from the outer row container.
|
||||
2. **Add** an `unconscious` conditional class to each leaf/display element within the row:
|
||||
- Concentration button
|
||||
- Initiative input
|
||||
- Name display (`EditableName`)
|
||||
- AC display (`AcDisplay`)
|
||||
- HP value button (inside `ClickableHp`)
|
||||
- HP separator slash
|
||||
- Max HP display (`MaxHpDisplay`)
|
||||
- Condition tags (`ConditionTags`)
|
||||
- Remove button
|
||||
3. The `HpAdjustPopover` and `ConditionPicker` are **not** wrapped in any dimmed element and render at full opacity.
|
||||
|
||||
**Implementation detail**: Apply `opacity-50` to the inner grid container and the conditions container while ensuring popovers (`HpAdjustPopover`, `ConditionPicker`) are rendered as siblings outside those dimmed wrappers, not as children. This is the simplest approach — it avoids scattering opacity classes across every leaf element.
|
||||
47
specs/020-fix-zero-hp-opacity/quickstart.md
Normal file
47
specs/020-fix-zero-hp-opacity/quickstart.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Quickstart: Fix Zero-HP Opacity
|
||||
|
||||
**Feature**: 020-fix-zero-hp-opacity
|
||||
**Date**: 2026-03-06
|
||||
|
||||
## Problem
|
||||
|
||||
When a combatant reaches 0 HP, the entire row gets `opacity-50` applied to the outermost container. This cascades to absolutely-positioned child elements (HP adjust popover and condition picker dropdown), making them semi-transparent and hard to use.
|
||||
|
||||
## Fix Location
|
||||
|
||||
**Single file**: `apps/web/src/components/combatant-row.tsx`
|
||||
|
||||
## Fix Strategy
|
||||
|
||||
1. Remove `opacity-50` from the outer row `<div>` (line 312).
|
||||
2. Apply `opacity-50` to individual leaf/display elements within the row when `status === "unconscious"`:
|
||||
- Initiative input
|
||||
- Name display
|
||||
- AC display
|
||||
- HP current value button (inside `ClickableHp`)
|
||||
- HP separator slash
|
||||
- Max HP display
|
||||
- Concentration button
|
||||
- Condition tags
|
||||
- Remove button
|
||||
3. The `HpAdjustPopover` and `ConditionPicker` components — which are absolutely positioned overlays — are **not** wrapped in any dimmed container, so they render at full opacity.
|
||||
|
||||
## Key Constraint
|
||||
|
||||
CSS `opacity` on a parent element creates a stacking context that affects all children. There is no way to "undo" a parent's opacity on a child element. The fix must ensure popovers are never DOM descendants of an element with reduced opacity.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
pnpm --filter web dev # Start dev server
|
||||
# 1. Add a combatant, set max HP, reduce current HP to 0
|
||||
# 2. Click HP value — popover should appear at full opacity
|
||||
# 3. Click conditions area — picker should appear at full opacity
|
||||
# 4. Row content (name, initiative, etc.) should still appear dimmed
|
||||
```
|
||||
|
||||
## Quality Gate
|
||||
|
||||
```bash
|
||||
pnpm check # Must pass before commit
|
||||
```
|
||||
84
specs/020-fix-zero-hp-opacity/research.md
Normal file
84
specs/020-fix-zero-hp-opacity/research.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 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 the `opacity` property.
|
||||
|
||||
## 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:
|
||||
|
||||
1. Removing `opacity-50` from the outer container.
|
||||
2. Adding a wrapper `<div>` (or applying directly to the existing grid `<div>` and conditions `<div>`) with `opacity-50` when 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-50` to 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-50` on the row container but add `opacity-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**:
|
||||
1. Remove `opacity-50` from the outer row `<div>`.
|
||||
2. Wrap the row's visible content (grid + conditions area) in a `<div>` that receives `opacity-50` when unconscious.
|
||||
3. Render `HpAdjustPopover` and `ConditionPicker` as 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/50` for 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:
|
||||
|
||||
1. Remove `opacity-50` from the outer `<div>`.
|
||||
2. Add `opacity-50` to the inner grid `<div>` (line 316).
|
||||
3. Add `opacity-50` to the conditions container `<div>` (line 388).
|
||||
4. The `HpAdjustPopover` (absolutely positioned, `z-10`) and `ConditionPicker` (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:
|
||||
|
||||
1. Remove `opacity-50` from the outer row container (line 312).
|
||||
2. 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
|
||||
3. Popovers and pickers remain unaffected since they're not inside any dimmed container.
|
||||
|
||||
This is the most targeted fix with minimal structural changes.
|
||||
82
specs/020-fix-zero-hp-opacity/spec.md
Normal file
82
specs/020-fix-zero-hp-opacity/spec.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Feature Specification: Fix Zero-HP Opacity
|
||||
|
||||
**Feature Branch**: `020-fix-zero-hp-opacity`
|
||||
**Created**: 2026-03-06
|
||||
**Status**: Draft
|
||||
**Input**: User description: "There is a bug in the system. When a creature is at 0 HP the popout menu for health delta and the condition dropdown are shown transparent. The user expects to still use the menus like they would be used for any other creature with an amount of hitpoints > 0."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Adjust HP on an Unconscious Combatant (Priority: P1)
|
||||
|
||||
A game master clicks on the HP area of a combatant at 0 HP to open the health-delta popout menu. The popout appears at full opacity and is fully interactive, just like it would for any combatant with HP above zero. The GM can enter a healing value or further damage without any visual impairment.
|
||||
|
||||
**Why this priority**: This is the core bug. Without being able to interact with the HP popout at full visibility, the GM cannot heal unconscious creatures or apply further damage, which are critical gameplay actions.
|
||||
|
||||
**Independent Test**: Can be fully tested by reducing a combatant to 0 HP, opening the HP-delta popout, and verifying it renders at full opacity and accepts input normally.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant is at 0 HP (unconscious), **When** the user opens the health-delta popout, **Then** the popout menu renders at full opacity (not dimmed/transparent).
|
||||
2. **Given** a combatant is at 0 HP (unconscious), **When** the user enters a positive HP delta in the popout, **Then** the healing is applied identically to how it works for combatants above 0 HP.
|
||||
3. **Given** a combatant is at 0 HP (unconscious), **When** the user enters a negative HP delta in the popout, **Then** the damage is applied identically to how it works for combatants above 0 HP.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Manage Conditions on an Unconscious Combatant (Priority: P1)
|
||||
|
||||
A game master clicks the conditions area of a combatant at 0 HP to open the condition-picker dropdown. The dropdown appears at full opacity and is fully interactive, allowing the GM to add or remove conditions on the unconscious creature.
|
||||
|
||||
**Why this priority**: Conditions are frequently relevant for unconscious creatures (e.g., adding Prone, removing Poisoned). The dropdown must be usable regardless of HP status.
|
||||
|
||||
**Independent Test**: Can be fully tested by reducing a combatant to 0 HP, opening the condition dropdown, and verifying it renders at full opacity and toggles conditions normally.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant is at 0 HP (unconscious), **When** the user opens the condition dropdown, **Then** the dropdown renders at full opacity (not dimmed/transparent).
|
||||
2. **Given** a combatant is at 0 HP (unconscious), **When** the user toggles a condition in the dropdown, **Then** the condition is applied or removed identically to how it works for combatants above 0 HP.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Row Still Visually Indicates Unconscious State (Priority: P2)
|
||||
|
||||
While the popout menus and dropdowns must be fully opaque, the combatant row itself should still visually communicate that the creature is at 0 HP / unconscious. The visual distinction should not be lost entirely; it should just not affect interactive overlays.
|
||||
|
||||
**Why this priority**: Preserving the unconscious visual indicator is important for at-a-glance status recognition, but it must not compromise usability of interactive elements.
|
||||
|
||||
**Independent Test**: Can be tested by verifying that an unconscious combatant row still looks visually distinct from healthy combatants while its popouts render at full opacity.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant is at 0 HP, **When** the user views the encounter list, **Then** the combatant row still has a visual indicator that distinguishes it from combatants above 0 HP.
|
||||
2. **Given** a combatant is at 0 HP, **When** the user opens any popout or dropdown on that row, **Then** the popout/dropdown itself is not affected by the unconscious visual styling.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a combatant is healed from 0 HP while the popout is open? The row should transition to normal styling smoothly; the open popout should remain functional.
|
||||
- What happens when a combatant drops to 0 HP while the popout is open? The row should transition to unconscious styling; the open popout should remain at full opacity and functional.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST render the health-delta popout menu at full opacity for combatants at 0 HP.
|
||||
- **FR-002**: System MUST render the condition-picker dropdown at full opacity for combatants at 0 HP.
|
||||
- **FR-003**: System MUST keep all popout/dropdown interactions (input, toggle, dismiss) fully functional for combatants at 0 HP, identical to combatants above 0 HP.
|
||||
- **FR-004**: System MUST still visually distinguish unconscious combatants (0 HP) from conscious ones in the row itself, without affecting overlay elements.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: All popout menus and dropdowns for 0-HP combatants render at 100% opacity (visually identical to those on combatants above 0 HP).
|
||||
- **SC-002**: Users can complete HP adjustment and condition management workflows on 0-HP combatants without any additional steps or difficulty compared to other combatants.
|
||||
- **SC-003**: Unconscious combatants remain visually distinguishable from conscious combatants in the encounter list.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The root cause is a CSS opacity rule applied to the entire combatant row container when a combatant is unconscious, which cascades to child elements including popouts and dropdowns.
|
||||
- The fix involves scoping the unconscious visual styling so it does not affect overlay elements (popouts, dropdowns) that are rendered as children of the row.
|
||||
- No domain or application layer changes are needed; this is a purely visual/adapter-layer fix.
|
||||
101
specs/020-fix-zero-hp-opacity/tasks.md
Normal file
101
specs/020-fix-zero-hp-opacity/tasks.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Tasks: Fix Zero-HP Opacity
|
||||
|
||||
**Input**: Design documents from `/specs/020-fix-zero-hp-opacity/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, quickstart.md
|
||||
|
||||
**Tests**: No test tasks included (not explicitly requested in spec). Existing tests must continue to pass via `pnpm check`.
|
||||
|
||||
**Organization**: Tasks are grouped by user story. US1 and US2 share the same root cause fix, so they are addressed together in Phase 2 (Foundational). US3 is verified as part of the same change.
|
||||
|
||||
## 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
|
||||
|
||||
**Purpose**: No setup required. This is a single-file bug fix in an existing codebase with all tooling already configured.
|
||||
|
||||
(No tasks — skip to Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Core Fix)
|
||||
|
||||
**Purpose**: Remove the container-level `opacity-50` that cascades to popover/dropdown children and replace it with element-level dimming that preserves the unconscious visual indicator without affecting overlays.
|
||||
|
||||
- [x] T001 Remove `opacity-50` from the outer row container `<div>` (the `status === "unconscious" && "opacity-50"` expression on line 312) in `apps/web/src/components/combatant-row.tsx`
|
||||
- [x] T002 Apply `opacity-50` to the inner grid `<div>` (line 316) when `status === "unconscious"` and restructure the JSX so that `HpAdjustPopover` (currently rendered inside `ClickableHp` within the grid) is moved to render as a sibling outside the grid container, not a child of it, in `apps/web/src/components/combatant-row.tsx`
|
||||
- [x] T003 Apply `opacity-50` to the conditions container `<div>` (line 388) when `status === "unconscious"` and restructure the JSX so that `ConditionPicker` is rendered as a sibling outside the dimmed conditions wrapper, not a child of it, in `apps/web/src/components/combatant-row.tsx`
|
||||
- [x] T004 Verify that the `HpAdjustPopover` component renders at full opacity (no inherited dimming) when opened on a 0-HP combatant, by running `pnpm --filter web dev` and manually testing
|
||||
- [x] T005 Verify that the `ConditionPicker` component renders at full opacity (no inherited dimming) when opened on a 0-HP combatant, by running `pnpm --filter web dev` and manually testing
|
||||
- [x] T005a Verify edge case transitions: (a) heal a 0-HP combatant while the HP popout is open — row should transition to normal styling, popout stays functional; (b) reduce a combatant to 0 HP while the HP popout is open — row should transition to dimmed styling, popout stays at full opacity. Manual test via `pnpm --filter web dev`.
|
||||
|
||||
**Checkpoint**: At this point, both US1 (HP popover) and US2 (condition picker) should render at full opacity for 0-HP combatants.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 3 - Row Visual Distinction (Priority: P2)
|
||||
|
||||
**Goal**: Unconscious combatants remain visually distinguishable from conscious combatants.
|
||||
|
||||
**Independent Test**: View encounter list with a mix of conscious and unconscious combatants; unconscious rows should look dimmed while their popouts are full opacity.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T006 [US3] Verify that unconscious combatant rows still appear visually dimmed (via the element-level `opacity-50` applied in T002/T003) compared to healthy combatants in `apps/web/src/components/combatant-row.tsx`
|
||||
|
||||
**Checkpoint**: All user stories verified — unconscious rows are dimmed, but popouts/dropdowns render at full opacity.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T007 Run `pnpm check` to verify all quality gates pass (knip, format, lint, typecheck, test)
|
||||
- [x] T008 Run quickstart.md validation steps to confirm end-to-end fix
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 2 (Foundational)**: No dependencies — start immediately. T001 must complete before T002/T003. T002 and T003 can run in parallel. T004/T005 are manual verification after T002/T003.
|
||||
- **Phase 3 (US3)**: Depends on Phase 2 completion (visual distinction is a side effect of the fix approach).
|
||||
- **Phase 4 (Polish)**: Depends on Phase 2 and Phase 3 completion.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (HP popover)**: Addressed by T001 + T002. No dependency on other stories.
|
||||
- **US2 (Condition picker)**: Addressed by T001 + T003. No dependency on other stories.
|
||||
- **US3 (Visual distinction)**: Verified by T006. Depends on T002/T003 being correctly implemented.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T002 and T003 can be done in parallel (different sections of the same file, but non-overlapping regions)
|
||||
- T004 and T005 can be verified in parallel
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (All Stories in One Pass)
|
||||
|
||||
1. Complete T001 (remove container opacity) — this is the root cause fix
|
||||
2. Complete T002 + T003 (apply element-level dimming) — preserves visual distinction
|
||||
3. Verify T004 + T005 + T006 (manual testing)
|
||||
4. Run T007 (quality gate) + T008 (quickstart validation)
|
||||
5. **Done** — single commit, single file change
|
||||
|
||||
This is a small, surgical bug fix. All three user stories are addressed by the same structural change in `combatant-row.tsx`. The entire fix can be completed in a single pass.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Single file change: `apps/web/src/components/combatant-row.tsx`
|
||||
- No domain or application layer changes
|
||||
- No new dependencies
|
||||
- No new files
|
||||
- The key insight: CSS `opacity` on a parent creates a stacking context that dims all children. The fix must ensure popovers are never DOM descendants of a dimmed element.
|
||||
Reference in New Issue
Block a user