Replace single-click rename with double-click, pencil icon, and long-press (#6)
Single-clicking a combatant name now opens the stat block panel instead of entering edit mode. Renaming is triggered by double-click, a hover pencil icon, or long-press on touch. Also fixes condition picker positioning when near viewport edges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <number>` 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 <number>` 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(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 (
|
||||
<Input
|
||||
@@ -85,16 +130,30 @@ function EditableName({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={cancelLongPress}
|
||||
onTouchCancel={cancelLongPress}
|
||||
onTouchMove={cancelLongPress}
|
||||
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing();
|
||||
}}
|
||||
className="truncate text-left text-sm text-foreground hover:text-hover-neutral transition-colors"
|
||||
aria-label="Edit name"
|
||||
className="hidden shrink-0 items-center text-muted-foreground hover:text-hover-neutral group-hover:inline-flex"
|
||||
>
|
||||
{name}
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -487,9 +546,12 @@ export function CombatantRow({
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
</span>
|
||||
<EditableName
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onShowStatBlock={onShowStatBlock}
|
||||
/>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
|
||||
@@ -64,12 +64,21 @@ export function ConditionPicker({
|
||||
}: ConditionPickerProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(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({
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute z-10 w-fit rounded-md border border-border bg-background p-1 shadow-lg",
|
||||
flipped ? "bottom-full mb-1" : "mt-1",
|
||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
>
|
||||
{CONDITION_DEFINITIONS.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user