diff --git a/.claude/commands/integrate-issue.md b/.claude/commands/integrate-issue.md index eaf3a46..d8a6aea 100644 --- a/.claude/commands/integrate-issue.md +++ b/.claude/commands/integrate-issue.md @@ -128,7 +128,7 @@ Report completion and suggest next steps based on scope: - **Straightforward change** (1-2 stories, clear acceptance scenarios): "Implement the changes and commit" - **Larger change** (multiple stories, cross-cutting concerns): "Use `rpi-research` to investigate the affected code, then `rpi-plan` to create a phased implementation plan, then `rpi-implement` to execute it" - **Complex or ambiguous change**: "Run `/speckit.clarify` to resolve remaining ambiguities before implementing" -- Always: "Run `/sync-issue ` to update the Gitea issue with the new acceptance criteria" +- Only if the spec adds substantive new criteria not already captured in the issue: "Run `/sync-issue ` to update the Gitea issue with the new acceptance criteria". Skip this if the spec merely reformulates what the issue already says into Given/When/Then format. ## Behavior Rules diff --git a/.claude/commands/sync-issue.md b/.claude/commands/sync-issue.md index 9b49cd6..262f676 100644 --- a/.claude/commands/sync-issue.md +++ b/.claude/commands/sync-issue.md @@ -156,6 +156,7 @@ Report success with the issue URL. - Always preview before updating — never push without user confirmation. - If the spec has no user stories or acceptance scenarios, abort with a clear message suggesting the user run `/speckit.specify` first. - Acceptance criteria must be business-level. If you find yourself writing implementation details, rewrite at a higher level of abstraction. +- Do NOT sync when the issue's existing acceptance criteria already capture the same requirements as the spec. The spec's Given/When/Then format is not needed in the issue — if the only difference is formatting, skip the sync and tell the user the criteria already align. - Use `curl` for all API calls — do not rely on `gh` CLI. - Always use HEREDOC for the JSON payload to handle special characters in the body. - Escape double quotes and newlines properly in the JSON body. diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index 77fd8aa..dd5bb6f 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -3,7 +3,7 @@ import { type ConditionId, deriveHpStatus, } from "@initiative/domain"; -import { Brain, X } from "lucide-react"; +import { Brain, Pencil, X } from "lucide-react"; import { type Ref, useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../lib/utils"; import { AcShield } from "./ac-shield"; @@ -44,14 +44,19 @@ function EditableName({ name, combatantId, onRename, + onShowStatBlock, }: { name: string; combatantId: CombatantId; onRename: (id: CombatantId, newName: string) => void; + onShowStatBlock?: () => void; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(name); const inputRef = useRef(null); + const clickTimerRef = useRef>(undefined); + const longPressTimerRef = useRef>(undefined); + const longPressTriggeredRef = useRef(false); const commit = useCallback(() => { const trimmed = draft.trim(); @@ -67,6 +72,46 @@ function EditableName({ requestAnimationFrame(() => inputRef.current?.select()); }, [name]); + useEffect(() => { + return () => { + clearTimeout(clickTimerRef.current); + clearTimeout(longPressTimerRef.current); + }; + }, []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (longPressTriggeredRef.current) { + longPressTriggeredRef.current = false; + return; + } + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = undefined; + startEditing(); + } else { + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = undefined; + onShowStatBlock?.(); + }, 250); + } + }, + [startEditing, onShowStatBlock], + ); + + const handleTouchStart = useCallback(() => { + longPressTriggeredRef.current = false; + longPressTimerRef.current = setTimeout(() => { + longPressTriggeredRef.current = true; + startEditing(); + }, 500); + }, [startEditing]); + + const cancelLongPress = useCallback(() => { + clearTimeout(longPressTimerRef.current); + }, []); + if (editing) { return ( { - e.stopPropagation(); - startEditing(); - }} - className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors" - > - {name} - + <> + + + ); } @@ -487,9 +546,12 @@ export function CombatantRow({ dimmed && "opacity-50", )} > - - - + onToggleCondition(id, conditionId)} diff --git a/apps/web/src/components/condition-picker.tsx b/apps/web/src/components/condition-picker.tsx index 63238e2..b4ce1d6 100644 --- a/apps/web/src/components/condition-picker.tsx +++ b/apps/web/src/components/condition-picker.tsx @@ -64,12 +64,21 @@ export function ConditionPicker({ }: ConditionPickerProps) { const ref = useRef(null); const [flipped, setFlipped] = useState(false); + const [maxHeight, setMaxHeight] = useState(undefined); useLayoutEffect(() => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); - setFlipped(rect.bottom > window.innerHeight); + const spaceBelow = window.innerHeight - rect.top; + const spaceAbove = rect.bottom; + const shouldFlip = + rect.bottom > window.innerHeight && spaceAbove > spaceBelow; + setFlipped(shouldFlip); + const available = shouldFlip ? spaceAbove : spaceBelow; + if (rect.height > available) { + setMaxHeight(available - 16); + } }, []); useEffect(() => { @@ -88,9 +97,10 @@ export function ConditionPicker({
{CONDITION_DEFINITIONS.map((def) => { const Icon = ICON_MAP[def.iconName]; diff --git a/specs/001-combatant-management/spec.md b/specs/001-combatant-management/spec.md index e6e5397..4098fb7 100644 --- a/specs/001-combatant-management/spec.md +++ b/specs/001-combatant-management/spec.md @@ -114,6 +114,28 @@ A user attempts to edit a combatant that no longer exists or provides an invalid --- +**Story C3 — Rename trigger UX (Priority: P1)** + +A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name, clicks a hover-revealed pencil icon, or long-presses on touch devices. + +**Acceptance Scenarios**: + +1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered. + +2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name. + +3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** a pencil icon appears next to the name. + +4. **Given** the pencil icon is visible, **When** the user clicks it, **Then** inline edit mode is entered for that combatant's name. + +5. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name. + +6. **Given** a combatant row is visible on a touch device, **When** the user views the combatant name without hovering, **Then** no pencil icon is permanently visible. + +7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored. + +--- + ### Clearing the Encounter **Story D1 — Clear encounter to start fresh (Priority: P1)** @@ -273,7 +295,7 @@ EditCombatant MUST return an `"invalid-name"` error when the new name is empty o EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted. #### FR-024 — Edit: UI -The UI MUST provide an inline name-edit mechanism (click-to-edit input field) for each combatant. The updated name MUST be immediately visible after submission. +The UI MUST provide an inline name-edit mechanism for each combatant, activated by double-clicking the name, clicking a hover-revealed pencil icon, or long-pressing on touch devices. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. The updated name MUST be immediately visible after submission. #### FR-025 — ConfirmButton: Reusable component The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow. @@ -346,6 +368,9 @@ All domain events MUST be returned as plain data values from operations, not dis - **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates. - **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently. - **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable. +- **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures. +- **Pencil icon on touch devices**: The hover pencil icon MUST NOT be permanently visible on touch devices. Long-press is the touch equivalent for entering edit mode. +- **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode. --- @@ -380,4 +405,4 @@ All domain events MUST be returned as plain data values from operations, not dis - Cross-tab synchronization is not required for the MVP baseline. - The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline. - The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state. -- The inline name-edit mechanism (click-to-edit input field) is used for combatant renaming. More complex UX (modal dialogs, undo/redo) is not in the MVP baseline. +- The inline name-edit mechanism is activated by double-click, hover pencil icon, or long-press (touch). Single-clicking the name opens the stat block panel.