Skip to content

Improve desktop hand fan and hover previews#6239

Merged
matthewevans merged 6 commits into
phase-rs:mainfrom
parthmishra:codex/desktop-hand-fan-hover
Jul 22, 2026
Merged

Improve desktop hand fan and hover previews#6239
matthewevans merged 6 commits into
phase-rs:mainfrom
parthmishra:codex/desktop-hand-fan-hover

Conversation

@parthmishra

@parthmishra parthmishra commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • widen and flatten the player and opponent hand fans, share their geometry with attachment fans, and keep compact-height/mobile sizing proportional
  • animate desktop hand previews from the hovered card into a bottom-pinned preview with smoother reduced-motion-aware transitions and a cast shadow
  • keep delayed hover previews stable across transient layout shifts and add a preference to hide the preview footer
  • reuse the game worker's loaded card database for display queries so iOS does not allocate a second WASM runtime and full card corpus during startup/resume
  • lock the in-game document viewport on mobile while preserving independently scrollable drawers and the game menu

Resolution scope

The new fanned-hand hover and source-card preview animation is intentionally desktop-resolution/pointer-hover only. Mobile keeps the established interaction: tap the resting hand to open the full-hand drawer, then tap a playable card there to cast it. Mobile-specific changes in this PR are limited to responsive fan/pip sizing and viewport/menu stability.

Why

The former desktop fan was steep and tightly overlapped, making neighboring cards difficult to reach. Hover previews also appeared abruptly, could disappear when a configured hover delay raced an animated layout shift, and lacked a clear visual separation from the battlefield.

Separately, iOS could exceed its WebContent memory budget when card inspection initialized a second WASM runtime/card database during game startup. Routing display queries through the resident game worker removes that duplicate allocation.

Demo

hand-fan-hover-720.mov

Validation

  • npx vitest run — 2,133 tests passed across 248 files
  • npx tsc -b --noEmit
  • npx eslint src — no errors (18 existing warnings outside the changed files)
  • pre-push repository gate: formatting, clippy, 598 parser tests, 1,357 AI tests, card-data generation/validation, and coverage regression check passed

Closes #4377

Summary by CodeRabbit

  • New Features
    • Added hand-anchored desktop card previews (preview now positions/sizes relative to the hovered hand card), with smoother reduced-motion behavior.
    • Introduced a card-preview footer option (show/hide), including localized setting text and hints.
    • Enhanced hand and opponent-hand fan layouts to better adapt across screen sizes, with improved hover/animation spacing.
  • Bug Fixes
    • Fixed engine-card fallback so oracle text falls back correctly when it’s explicitly cleared.
    • Made hover preview timing more resilient to brief pointer movement, reducing unintended dismissals.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request optimizes frontend memory usage and rendering performance by routing display-only card queries through the shared WasmAdapter worker instead of duplicating the WASM module on the main thread. It also unifies hand fan layouts across viewports using a shared geometry module, introduces viewport locking to prevent iOS rubber-band scrolling, and refactors hover preview latency to avoid spurious clears. The review feedback suggests improving the robustness of the card art fallback check by using loose equality for oracleText, and preventing potential test pollution by ensuring module-scoped timers are cleared in test suites via clearInspect().

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread client/src/components/card/CardImage.tsx
Comment thread client/src/stores/__tests__/uiStoreHoverLatency.test.ts
Comment thread client/src/components/card/__tests__/CardPreview.test.tsx
@matthewevans

Copy link
Copy Markdown
Member

Deferred by maintainer intake policy — not ignored.

This current head (681c5641ccbd3931fe33217bfa2021f5cb7e9221) was triaged as a frontend-only change (client/index.html, client/src/adapter/__tests__/boundary-guardrails.test.ts, client/src/adapter/__tests__/wasm-adapter.test.ts, client/src/adapter/engine-worker-client.ts, client/src/adapter/engine-worker.ts, client/src/adapter/wasm-adapter.ts, client/src/components/board/AttachmentFan.tsx, client/src/components/card/CardImage.tsx, client/src/components/card/CardPreview.tsx, client/src/components/card/GameCardPreview.tsx, client/src/components/card/__tests__/CardImage.test.tsx, client/src/components/card/__tests__/CardPreview.test.tsx, client/src/components/card/__tests__/fanGeometry.test.ts, client/src/components/card/fanGeometry.ts, client/src/components/chrome/GameMenu.tsx, client/src/components/chrome/__tests__/GameMenu.test.tsx, client/src/components/hand/CompanionFanCard.tsx, client/src/components/hand/OpponentHand.tsx, client/src/components/hand/PlayerHand.tsx, client/src/components/hand/__tests__/OpponentHand.test.tsx, client/src/components/hand/__tests__/PlayerHand.test.ts, client/src/components/hand/handFanPresentation.ts, client/src/components/mana/ManaCostPips.tsx, client/src/components/mana/__tests__/ManaCostPips.test.tsx, client/src/components/settings/PreferencesModal.tsx, client/src/components/settings/__tests__/PreferencesModal.cardPreview.test.tsx, client/src/hooks/__tests__/useGameViewportLock.test.tsx, client/src/hooks/useEngineCardData.ts, client/src/hooks/useGameViewportLock.ts, client/src/i18n/locales/de/settings.json, client/src/i18n/locales/en/settings.json, client/src/i18n/locales/es/settings.json, client/src/i18n/locales/fr/settings.json, client/src/i18n/locales/it/settings.json, client/src/i18n/locales/pl/settings.json, client/src/i18n/locales/pt/settings.json, client/src/index.css, client/src/pages/GamePage.tsx, client/src/providers/GameProvider.tsx, client/src/stores/__tests__/preferencesStore.test.ts, client/src/stores/__tests__/uiStoreHoverLatency.test.ts, client/src/stores/preferencesStore.ts, client/src/stores/uiStore.ts) by parthmishra. The local frontend-review allowlist does not include this author, so this route does not perform an implementation-diff review or approve the PR.

A maintainer must explicitly take this PR or add a local frontend-review exception before it can receive substantive review. The defer label is a routing marker only, not a verdict on the change.

@matthewevans matthewevans added the defer-fe Frontend/client/UI PR deferred to Matt's direct review label Jul 20, 2026
@matthewevans matthewevans self-assigned this Jul 20, 2026
@matthewevans matthewevans added the feature Larger-scoped feature label Jul 20, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer value/scope review for current head 59f0644fa0c6cdac1964e56e1fea58043dc72f4a:

  • The shared-worker card queries remove the duplicate main-thread WASM/card corpus at the correct adapter boundary; the worker remains the single engine authority.
  • The hand fan/preview work centralizes geometry and presentation rather than duplicating surface-specific calculations, with targeted boundary and interaction tests.
  • This is broad client work, but the touched seams form one coherent card-viewing interaction path (memory, preview, fan layout, and viewport behavior). The older fix(client): card hover preview flicker with follow-cursor and delay #4407 remains deferred as a narrow alternate fix for the same issue; it is not being enqueued alongside this comprehensive implementation.

No workflow/instruction paths are in the contributor diff. The current checks are green. A maintainer rebase onto current main completed cleanly locally, but GitHub rejected pushing the rewritten fork branch because the available OAuth token lacks workflow scope; the merge queue will perform the authoritative speculative rebase.

@matthewevans matthewevans removed the defer-fe Frontend/client/UI PR deferred to Matt's direct review label Jul 20, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer value/scope review for current head 59f0644fa0c6cdac1964e56e1fea58043dc72f4a:

  • The shared-worker card queries remove the duplicate main-thread WASM/card corpus at the correct adapter boundary; the worker remains the single engine authority.
  • The hand fan/preview work centralizes geometry and presentation rather than duplicating surface-specific calculations, with targeted boundary and interaction tests.
  • This is broad client work, but the touched seams form one coherent card-viewing interaction path (memory, preview, fan layout, and viewport behavior). The older #4407 remains deferred as a narrow alternate fix for the same issue; it is not being enqueued alongside this comprehensive implementation.

No workflow/instruction paths are in the contributor diff. The current checks are green. A maintainer rebase onto current main completed cleanly locally, but GitHub rejected pushing the rewritten fork branch because the available OAuth token lacks workflow scope; the merge queue will perform the authoritative speculative rebase.

@matthewevans matthewevans removed their assignment Jul 20, 2026
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a8f60388-808e-4662-a1f4-ad8142928919

📥 Commits

Reviewing files that changed from the base of the PR and between 3743163 and 423816b.

📒 Files selected for processing (7)
  • client/src/components/card/__tests__/CardPreview.test.tsx
  • client/src/components/card/__tests__/GameCardPreview.test.tsx
  • client/src/components/hand/OpponentHand.tsx
  • client/src/components/hand/handFanPresentation.ts
  • client/src/hooks/__tests__/useGameViewportLock.test.tsx
  • client/src/hooks/useGameViewportLock.ts
  • client/src/stores/__tests__/preferencesStore.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • client/src/hooks/useGameViewportLock.ts
  • client/src/hooks/tests/useGameViewportLock.test.tsx
  • client/src/stores/tests/preferencesStore.test.ts
  • client/src/components/hand/OpponentHand.tsx
  • client/src/components/card/tests/CardPreview.test.tsx

📝 Walkthrough

Walkthrough

Changes

The pull request updates desktop card previews with hand-origin anchoring, motion transitions, configurable footer content, and hover-latency handling. It also introduces shared responsive hand-fan geometry, applies viewport locking and safe-area styling to game pages, and adds related settings, localization, markup, and tests.

Card preview lifecycle and settings

Layer / File(s) Summary
Preview state and footer preference
client/src/stores/uiStore.ts, client/src/stores/preferencesStore.ts, client/src/components/settings/*, client/src/i18n/locales/*, client/src/stores/__tests__/*
Preview timing now tracks pending state and pointer location more precisely, while a persisted card-preview footer preference is exposed through settings and localized across supported locales.
Hand-origin preview rendering
client/src/components/card/CardPreview.tsx, client/src/components/card/GameCardPreview.tsx, client/src/components/card/CardImage.tsx, client/src/components/card/__tests__/*
Desktop previews can measure a source hand card, anchor from the hand, animate through exit transitions, conditionally render footer content, and use fallback data when oracleText is nullish.

Responsive hand-fan presentation

Layer / File(s) Summary
Fan geometry profiles and metrics
client/src/components/card/fanGeometry.ts, client/src/components/hand/handFanPresentation.ts, client/src/components/card/__tests__/fanGeometry.test.ts, client/src/components/hand/__tests__/PlayerHand.test.ts, client/src/components/board/AttachmentFan.tsx
Fan calculations now support compact and wide profiles, shared vertical metrics, viewport-aware sizing, and profile-specific rotation, arc, overlap, and spread tests.
Player and opponent fan integration
client/src/components/hand/PlayerHand.tsx, client/src/components/hand/OpponentHand.tsx, client/src/components/hand/__tests__/OpponentHand.test.tsx
Player and opponent hands use shared geometry and vertical metrics for card sizing, placement, animation, metadata, and compact-height scaling.
Companion fan motion
client/src/components/hand/CompanionFanCard.tsx
Companion cards receive resting and hover Y positions instead of using fixed animation offsets.

Game viewport and UI surface updates

Layer / File(s) Summary
Game viewport locking and safe-area layout
client/src/hooks/useGameViewportLock.ts, client/src/pages/GamePage.tsx, client/src/index.css, client/index.html, client/src/hooks/__tests__/useGameViewportLock.test.tsx
Game pages apply document and body viewport locks, safe-area-aware sizing, landscape hand positioning, and viewport-fit=cover metadata.
Scrollable menu and UI surface hooks
client/src/components/chrome/GameMenu.tsx, client/src/components/chrome/__tests__/GameMenu.test.tsx, client/src/components/mana/ManaCostPips.tsx
The game menu adds bounded touch scrolling classes, and the mana backdrop receives a dedicated data attribute.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GameCardPreview
  participant CardPreview
  participant HandCard
  participant CardImagePreview
  GameCardPreview->>CardPreview: pass handSourceObjectId for hand objects
  CardPreview->>HandCard: measure source bounding rectangle
  CardPreview->>CardImagePreview: render bottom-anchored compact preview
  CardPreview->>CardImagePreview: apply footer preference and motion state
Loading

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also bundles unrelated fan geometry, viewport lock, menu, and preview-footer changes beyond #4377. Split the unrelated UI/layout work into separate PRs or add linked issues for it, and keep this PR focused on hover-preview persistence.
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main desktop hand fan and hover preview changes.
Linked Issues check ✅ Passed The PR stabilizes delayed hover previews so they remain visible until the cursor leaves, matching #4377.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@parthmishra
parthmishra marked this pull request as ready for review July 22, 2026 00:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
client/src/components/card/__tests__/CardPreview.test.tsx (1)

101-135: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guarantee the manually-appended hand-source node is removed even on assertion failure.

Both tests append a source element with data-object-id="101" to document.body and only source.remove() on the success path. cleanup()/the afterEach at Line 54-55 unmount React and reset prefs but don't remove these hand-crafted nodes. If an assertion throws, the stale (hovered) node survives into the next test, where the [data-hand-card][data-object-id="101"] query can resolve it and flip the anchored-vs-normal outcome — order-dependent pollution. Remove appended nodes in afterEach (or try/finally).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/card/__tests__/CardPreview.test.tsx` around lines 101 -
135, Ensure the manually appended hand-source nodes created by the affected
CardPreview tests are removed even when assertions fail. Update the shared
afterEach cleanup to remove these nodes, or wrap each test’s append-and-assert
flow in try/finally, while preserving the existing React cleanup and preference
reset behavior.
client/src/components/hand/handFanPresentation.ts (1)

12-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize the 0.78 / 1.4 card-scale ratios instead of duplicating them as literals. The opponent-card scale (0.78) and hand-card height scale (1.4) are hardcoded independently in two files; if either changes, the other silently falls out of sync and the mirrored opponent fan's vertical depth stops matching the actual rendered card size.

  • client/src/components/hand/handFanPresentation.ts#L12-L14: export HAND_CARD_HEIGHT_SCALE = 1.4 and OPPONENT_CARD_SCALE = 0.78 as named constants, and derive OPPONENT_HAND_VERTICAL_SCALE = OPPONENT_CARD_SCALE / HAND_CARD_HEIGHT_SCALE from them (also reuse HAND_CARD_HEIGHT_SCALE in playerHandFanSizingStyle's * 1.4).
  • client/src/components/hand/OpponentHand.tsx#L69-L71: import and use OPPONENT_CARD_SCALE in the --opponent-hand-card-w/-h calc strings instead of the literal 0.78.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/hand/handFanPresentation.ts` around lines 12 - 14,
Centralize the card-scale ratios in handFanPresentation.ts by exporting
HAND_CARD_HEIGHT_SCALE as 1.4 and OPPONENT_CARD_SCALE as 0.78, deriving
OPPONENT_HAND_VERTICAL_SCALE from them, and reusing HAND_CARD_HEIGHT_SCALE in
playerHandFanSizingStyle instead of the literal 1.4. In OpponentHand.tsx, import
OPPONENT_CARD_SCALE and use it in both opponent card width and height
calculations instead of 0.78.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/hooks/useGameViewportLock.ts`:
- Around line 16-24: Update useGameViewportLock to track shared ownership of
GAME_VIEWPORT_LOCK_CLASS across overlapping hook instances using a reference
count or ownership tokens for both root and body; only remove each class when
its final owner unmounts. Add a regression test covering overlapping mounts and
unmounting the first instance while the second remains mounted, ensuring the
lock persists until the last unmount.

In `@client/src/stores/__tests__/preferencesStore.test.ts`:
- Line 18: Update the preferences store tests around buildDefaultPreferences and
the v27→v28 migration so the default showCardPreviewFooter assertion exercises a
fresh initialization or rehydration path without the shared reset forcing it to
true. Keep the shared reset for test isolation, but remove or bypass its
explicit showCardPreviewFooter override in the default-focused test.

---

Nitpick comments:
In `@client/src/components/card/__tests__/CardPreview.test.tsx`:
- Around line 101-135: Ensure the manually appended hand-source nodes created by
the affected CardPreview tests are removed even when assertions fail. Update the
shared afterEach cleanup to remove these nodes, or wrap each test’s
append-and-assert flow in try/finally, while preserving the existing React
cleanup and preference reset behavior.

In `@client/src/components/hand/handFanPresentation.ts`:
- Around line 12-14: Centralize the card-scale ratios in handFanPresentation.ts
by exporting HAND_CARD_HEIGHT_SCALE as 1.4 and OPPONENT_CARD_SCALE as 0.78,
deriving OPPONENT_HAND_VERTICAL_SCALE from them, and reusing
HAND_CARD_HEIGHT_SCALE in playerHandFanSizingStyle instead of the literal 1.4.
In OpponentHand.tsx, import OPPONENT_CARD_SCALE and use it in both opponent card
width and height calculations instead of 0.78.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 28a96eb1-38c6-462c-9117-b895fc0b0a2d

📥 Commits

Reviewing files that changed from the base of the PR and between b9aa8cb and 3743163.

📒 Files selected for processing (34)
  • client/index.html
  • client/src/components/board/AttachmentFan.tsx
  • client/src/components/card/CardImage.tsx
  • client/src/components/card/CardPreview.tsx
  • client/src/components/card/GameCardPreview.tsx
  • client/src/components/card/__tests__/CardPreview.test.tsx
  • client/src/components/card/__tests__/fanGeometry.test.ts
  • client/src/components/card/fanGeometry.ts
  • client/src/components/chrome/GameMenu.tsx
  • client/src/components/chrome/__tests__/GameMenu.test.tsx
  • client/src/components/hand/CompanionFanCard.tsx
  • client/src/components/hand/OpponentHand.tsx
  • client/src/components/hand/PlayerHand.tsx
  • client/src/components/hand/__tests__/OpponentHand.test.tsx
  • client/src/components/hand/__tests__/PlayerHand.test.ts
  • client/src/components/hand/handFanPresentation.ts
  • client/src/components/mana/ManaCostPips.tsx
  • client/src/components/settings/PreferencesModal.tsx
  • client/src/components/settings/__tests__/PreferencesModal.cardPreview.test.tsx
  • client/src/hooks/__tests__/useGameViewportLock.test.tsx
  • client/src/hooks/useGameViewportLock.ts
  • client/src/i18n/locales/de/settings.json
  • client/src/i18n/locales/en/settings.json
  • client/src/i18n/locales/es/settings.json
  • client/src/i18n/locales/fr/settings.json
  • client/src/i18n/locales/it/settings.json
  • client/src/i18n/locales/pl/settings.json
  • client/src/i18n/locales/pt/settings.json
  • client/src/index.css
  • client/src/pages/GamePage.tsx
  • client/src/stores/__tests__/preferencesStore.test.ts
  • client/src/stores/__tests__/uiStoreHoverLatency.test.ts
  • client/src/stores/preferencesStore.ts
  • client/src/stores/uiStore.ts

Comment thread client/src/hooks/useGameViewportLock.ts Outdated
Comment thread client/src/stores/__tests__/preferencesStore.test.ts
@matthewevans matthewevans self-assigned this Jul 22, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused client-side work. I found three merge-blocking gaps on the current head 374316300d24191fd5d20dcfa03dce9501e2e717:

  1. useGameViewportLock does not preserve the lock for overlapping hook owners. Each mount snapshots whether the class existed before it mounted; with two hook instances, both snapshots are false, so unmounting either instance removes the root and body classes while the other game viewport is still mounted. Use shared ownership/reference counting (including pre-existing external ownership) and add an overlapping-mount regression test.

  2. The preview-footer default assertion is masked by the shared test reset. The reset sets showCardPreviewFooter: true before the test, so the assertion does not prove buildDefaultPreferences() or the v27-to-v28 migration supplies the default. Exercise a fresh initialization or rehydration path without that override; keep the reset for isolation.

  3. The new hand-origin preview behavior is tested by rendering CardPreview with a hand-source id directly. Production computes and passes that id through GameCardPreview/PlayerHand; add an integration-level test that drives that production wiring so a dropped prop or wrong source selection fails.

The current head also remains behind main, and Rust lint plus shard 2 are still pending. Please address the three gaps and update against current main; then this can be reviewed again.

@matthewevans matthewevans removed their assignment Jul 22, 2026
@matthewevans matthewevans self-assigned this Jul 22, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: the current head resolves the prior blocking findings and passed independent implementation review; the merge queue will gate on remaining required checks.

@matthewevans
matthewevans enabled auto-merge July 22, 2026 00:45
@matthewevans matthewevans removed their assignment Jul 22, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 22, 2026
Merged via the queue into phase-rs:main with commit 11238f4 Jul 22, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Card Hover Disappearing — Hover card preview disappears after a brief moment when settings are configured "follow curso…

2 participants