Skip to content

W-051: DND-F03 combat UI

Type: feature · Size: M · Epic: E-dnd-m1 · Target: apps/demo-dnd · Breaking: no Depends: W-019, W-021, W-050 Refs: brief DND-F03; demo-dnd spec DND-055..059; W-019 (game area and key rule); W-021 (targeting actions and attack); W-050 (Actions group and banner)

Goal

Add the combat controls of apps/demo-dnd on the W-021 targeting actions: the game-area keys of DND-055 (A begins attack targeting, Escape cancels, Tab, Shift+Tab, and Enter act only while targeting is active), the "target" and "selected target" cell labels, click on a valid target's cell to select and confirm (DND-058), the "Attack" button in the "Actions" group with its "No target in reach" state (DND-056), and the combat log as a role="log" region with live updates and the newest entry in view (DND-057, DND-059). Keys follow the W-019 key rule and never run from a button or the devtools panel. The Poison Strike button and key P are W-023.

Steps

  • S1 (DND-055): targeting keys in the game-area keydown handler of W-019 (same key rule, event.target is the game area, event.repeat ignored): A calls beginTargeting('attack'), Escape calls cancelTargeting; Tab / Shift+Tab call cycleTarget(+1 / −1) with preventDefault and Enter calls confirmTarget only while Targeting.mode is not 'none'. While not targeting, Tab and Shift+Tab are not preventDefaulted and Enter does nothing. Component tests with a real world and real key events: A sets Targeting to the first valid target; Tab / Shift+Tab cycle with wrap-around and are preventDefaulted; Enter attacks the selected target (hero turn ends, log gains an 'attack' entry); Escape resets Targeting and releases Tab (not preventDefaulted); Tab and Enter with no targeting change nothing; keys from a button inside the game area call no action; a repeated Tab while targeting is still preventDefaulted; files: apps/demo-dnd/src/ui/input/GameArea.tsx, apps/demo-dnd/src/ui/input/targeting-keys.test.tsx
  • S2 (DND-055 cell labels): Grid cell labels in targeting mode (read Targeting and validTargets for the mode's range through useTrait/useQuery): a valid target's label gets the text "target" ("3,2 Goblin 2 (monster) target"), the selected one "selected target"; outside targeting the DND-015 labels are unchanged. Component tests: no "target" text when Targeting.mode is 'none'; with targeting active exactly the valid targets carry "target" and exactly the selected one "selected target"; the label updates after cycleTarget; an out-of-reach monster and heroes carry neither; files: apps/demo-dnd/src/ui/grid/Grid.tsx, apps/demo-dnd/src/ui/grid/Grid.targeting.test.tsx
  • S3 (DND-058): click on a cell in Grid while targeting is active: the cell of a valid target selects it and confirms (attack, via confirmTarget, so the attack resolves and the turn ends); any other cell (empty, hero, out-of-reach monster, adjacent empty cell) does not confirm and does not move the hero; with targeting inactive the DND-018 move click is unchanged. The click selects the clicked target before confirming, so a non-selected valid target is the one attacked. Component tests with a scripted Rng: click on the non-selected valid target attacks that one; click elsewhere changes no Health, Position, or log and leaves targeting as it was; adjacent empty cell while targeting does not call moveActive; adjacent click without targeting still moves; files: apps/demo-dnd/src/ui/grid/Grid.tsx, apps/demo-dnd/src/ui/grid/Grid.click-target.test.tsx
  • S4 (DND-056): "Attack" button in the "Actions" group of W-050 (next to "End turn"): it is disabled with the visible text "No target in reach" when validTargets for Attack.range is empty, and disabled outside a hero turn or after the hero acted; activating it calls beginTargeting('attack') and moves focus to the game area, so the S1 keys then apply. Focus also moves to the game area when the focused "Attack" button becomes disabled (same mechanism as W-050, DND-037). Component tests: enabled with a target in reach and visibly "No target in reach" when none (Wizard range vs. Fighter range 1); disabled during a monster turn and when status is not 'ongoing'; click sets Targeting and focuses the game area; Enter and Space on the focused button only activate that button (no confirmTarget, no attack, no log entry); A, Tab, Escape pressed on the button run no game action; focus is on the game area after the button becomes disabled; the button sits in the "Actions" group beside "End turn" and is reached by Tab from the game area when not targeting; files: apps/demo-dnd/src/ui/actions/AttackButton.tsx, apps/demo-dnd/src/ui/actions/Actions.tsx (from W-050), apps/demo-dnd/src/ui/actions/AttackButton.test.tsx
  • S5 (DND-057, DND-059): CombatLog view: a role="log" region labelled "Combat log" with aria-live="polite" listing the text of every CombatLog.entries entry oldest to newest (useTrait(world, CombatLog)), sets scrollTop = scrollHeight after each new entry so the newest is in view, and uses @workspace/ui theme colours (no colour-only meaning, contrast checked in A3); App.tsx renders it next to the game area. Component tests: region found by role and name with aria-live="polite"; empty log renders no items; entries appear in order after a real attack (text holds both names, every d20, bonus, total, AC, outcome word, "advantage" for a Poisoned target set up in the test, DND-051); the list updates live after attack and after a restart; files: apps/demo-dnd/src/ui/log/CombatLog.tsx, apps/demo-dnd/src/App.tsx, apps/demo-dnd/src/ui/log/CombatLog.test.tsx
  • S6 (acceptance): run pnpm turbo run typecheck lint test --filter=demo-dnd, then the spec's acceptance checklist item A3 (DND-059) steps 1, 3, and 4 for the dice in the log (hit, miss, critical hit, newest entry in view, contrast in both themes). A3 step 2 (attack a Poisoned monster) is not run here: it is accepted in W-023's acceptance, which depends on W-051

Log

  • 2026-11-01 created from the split of W-021 (Triage). Planner's draft: DND-055..059, M, 5 steps incl. A3 acceptance for DND-059. Held in Later until Depends are done
  • 2026-11-01 Depends W-019 (key rule and game area), W-021 (targeting actions), W-050 (Actions group and banner) (Triage)
  • 2026-11-01 steps S1..S6 written (Planner): keys, cell labels, cell click, Attack button, combat log, acceptance A3. Labels and click are separate steps because each needs its own Grid test file. File names of W-019 (GameArea.tsx, Grid.tsx) and W-050 (Actions.tsx) are assumed from those items; S4 needs the real names. A3 step 2 (Poisoned target) needs the W-023 Poisoned entity or a seeded setup
  • 2026-11-01 A3 split (user decision, Triage): the S6 acceptance covers A3 without step 2 (attack on a Poisoned monster). Step 2 runs in W-023 S6, which has a Poisoned monster
  • 2026-11-01 S6 runs A3 steps 1, 3, 4 only; A3 step 2 (attack a Poisoned monster) is accepted in W-023's acceptance, because W-023 depends on W-051 and W-051 cannot wait for it (Planner)
  • 2026-11-01 held: waits on the OQ6 decision after the W-039 S1 spike (decision logged in W-039 first) (Triage)