One word at a time
Two long words typed at once could break the whole duel layout. The fix drops the side-by-side grid for a single panel that follows whoever is acting.
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:
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' };
}
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 accentAnimation 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 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.