import Figure from '../components/Figure.astro'

Both duel modes showed the player's word and the opponent's word side by side, each rendered as fixed-width letter cells in its own column. Type or receive two long words at once and the columns pushed past the viewport, breaking the page layout rather than just looking cramped. The root cause traced to a `1fr 1fr` grid whose items default to `min-width: auto`, holding a no-wrap flex row of `WordChars` cells that never had permission to shrink. A dedicated design pass took the fix past patching that one grid, toward a design where two long words could never collide on screen again, on any viewport.

The map resolved into a spec built on one rule: exactly one word renders as letter cells at a time. The other player's word collapses to a small text chip instead of a second column. Which word gets the cells is called ownership, and it has to survive things a naive "whoever's turn it is" check would miss, mid-animation state and a PvP client resyncing from the server after a dropped connection among them:

```typescript
export function selectPanelOwner(inputs: WordPanelInputs): WordPanelOwner {
  if (inputs.playerAnimating) return { owner: 'player', mode: 'animating' };
  if (inputs.opponentAnimating) return { owner: 'opponent', mode: 'animating' };
  if (!inputs.isPlayerTurn) return { owner: 'opponent', mode: 'draft' };
  return { owner: 'player', mode: 'input' };
}
```

<Figure caption="The selectPanelOwner priority order: animation state outranks turn state outranks input state. This ensures the panel always shows the word worth watching, and survives resync after connection loss.">

```mermaid
stateDiagram-v2
  accTitle: selectPanelOwner priority hierarchy
  accDescr: The panel owner is determined by priority: if either player or opponent is animating, that player owns the panel. Otherwise, if it is not the player's turn, opponent owns it. Otherwise, player owns it. Animation outranks turn state outranks input state.
  
  [*] --> CheckAnimation
  CheckAnimation --> PlayerAnimating: playerAnimating
  CheckAnimation --> OpponentAnimating: opponentAnimating
  CheckAnimation --> CheckTurn: neither animating
  
  PlayerAnimating --> [*]
  OpponentAnimating --> [*]
  
  CheckTurn --> OpponentDraft: isPlayerTurn == false
  CheckTurn --> PlayerInput: isPlayerTurn == true
  
  OpponentDraft --> [*]
  PlayerInput --> [*]
  
  note right of PlayerAnimating
    Owner: player<br/>Priority: highest
  end note
  
  note right of OpponentAnimating
    Owner: opponent<br/>Priority: highest
  end note
  
  note right of OpponentDraft
    Owner: opponent<br/>Priority: medium
  end note
  
  note right of PlayerInput
    Owner: player<br/>Priority: lowest
  end note
  
  class PlayerAnimating,OpponentAnimating accent
```

</Figure>

Animation always outranks turn state, since a word already playing across the board is the thing worth watching regardless of whose turn comes next, and the whole function reads only from authoritative state, never from animation events themselves, so a PvP client that resyncs mid-animation always lands on the same answer the server would give. Two deviations from the written spec came out of the same session that built it: a crashed word shows as an error-styled chip once the turn passes rather than lingering in the panel, and the PvE opponent's last word now shows as a chip while the player types theirs, matching the rule PvP already followed of keeping the move you're answering in view.

The old two-column markup, duplicated across both duel pages, condensed into one shared `DuelWordPanel` component built on the same [`WordChars`](/posts/the-crash-that-used-to-win) letter renderer the lives system had already unified. New tests target the panel deliberately at the width where the bug used to happen: a 390px viewport typing a fifteen-letter word while the opponent's word animates, in both PvE and a phone-sized PvP guest view, with zero horizontal overflow in either.