Use when adding or modifying frontend UI components — interactive overlays for WaitingFor states, game board elements, card choice modals, animation effects, or any React component that interfaces with the engine via GameAction dispatch.
التثبيت
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
Use when adding or modifying frontend UI components — interactive overlays for WaitingFor states, game board elements, card choice modals, animation effects, or any React component that interfaces with the engine via GameAction dispatch.
Adding a Frontend Component
Hard rules — all frontend work must respect these (see CLAUDE.md § Design Principles):
The frontend is a display layer, not a logic layer. It renders engine-provided state and dispatches user actions — nothing more. It must never compute, derive, filter, or re-interpret game data. If a component needs a value the engine doesn't expose, the fix is to add it to the engine's output — not to calculate it client-side. Any "smart" frontend code is a bug.
CR-correctness is non-negotiable. The frontend must never contradict the Comprehensive Rules. If it displays information (legal targets, valid choices, game state), that information must come directly from the engine, which is the CR-validated source of truth. Never approximate engine logic in TypeScript.
Build reusable component patterns. New overlays and modals should follow existing patterns (CardChoiceModal, ModeChoiceModal). Extract shared behavior into composable components rather than duplicating across one-off implementations.
All frontend-authored text is internationalized. Every user-facing string the frontend authors (titles, labels, buttons, tooltips, placeholders, log templates, status messages) MUST go through t() via react-i18next — never a hardcoded literal in JSX. The boundary rule: a string gets t() if and only if the frontend authored it. Engine/card-database pass-through (card names, Oracle text, interpolated enum strings like phase/mana/counter type) stays raw — it is localized by a separate MTGJSON content pipeline, not t(). See client/src/i18n/README.md (the authority) before adding any string.
The React/TypeScript frontend communicates with the Rust engine through a transport-agnostic adapter layer. Game state flows from engine → adapter → Zustand stores → React components. Player actions flow in reverse via dispatch(). This skill covers wiring new UI components into this pipeline.
Before you start: Trace how ScryChoice works end-to-end. The current path is: WaitingFor::ScryChoice in Rust → TypeScript type in adapter/types.ts → GamePage.tsx renders CardChoiceModal → CardChoiceModal routes to ScryModal → dispatch({ type: "SelectCards", data: { cards } }).
Ephemeral UI state — targeting mode, combat selections, hovered/selected objects. Combat selections stay in uiStore until the player confirms (optimistic UI pattern).
Dispatch Pipeline — client/src/game/dispatch.ts
User action
→ Capture DOM snapshot (pre-animation positions)
→ adapter.submitAction(action)
→ normalizeEvents(events) → AnimationSteps
→ enqueueSteps (animation store)
→ Wait for animation duration
→ Update gameStore (state, waitingFor, legalActions)
→ Save to localStorage
All overlays gate on waitingFor.data.player === playerId to prevent the wrong player from seeing choices in multiplayer.
Checklist — Adding a New Frontend Component
Phase 1 — TypeScript Types
client/src/adapter/types.ts — WaitingFor union (if new interactive state)
Add a variant matching the Rust WaitingFor enum. Must match the serde output format exactly:
client/src/adapter/types.ts — GameObject interface (if new fields on objects)
Add optional fields with ?: to avoid breaking existing state deserialization.
For non-overlay components (new zone display, counter indicators, status badges):
Place in the relevant subdirectory (components/board/, components/zone/, etc.)
Subscribe to useGameStore for data
No dispatch needed if read-only
Phase 2.5 — Internationalize User-Facing Text
Every string the component authors must be a translation key, not a literal. Do this as you write the component, not as a cleanup pass.
Pick the namespace by source directory. A component's namespace is where it lives, not what it's about: components/draft/* → "draft", components/lobby/* → "multiplayer", in-game overlays (components/modal/, board/, combat/, etc.) → "game". common is the implicit default ns (shared buttons like Cancel/Confirm may already live there — reuse before adding).
const { t } = useTranslation("game"); // opt into a ns; common is always available
Add the key to client/src/i18n/locales/en/<ns>.json first. English is the typing oracle — referencing a key that doesn't exist in en/ fails type-check. Other locales fall back to English automatically; you do not edit es/fr/de/it/pt (machine-translated separately).
Plurals via CLDR, not string math. Use key_one / key_other + t(key, { count }). Never count === 1 ? "item" : "items".
Interpolate engine data raw into a translated template. The template is chrome (translate it); the values are engine data (leave raw): t("yourOverlay.summary", { counterType, count }).
Leave engine/card pass-through untouched. Card names, Oracle/reminder text, and enum strings (phase, mana type, counter type) are not wrapped — they are localized by the content pipeline (hooks/useEngineCardData.ts), not t(). When in doubt, apply the boundary rule: did the frontend author this sentence, or is it engine data flowing through?
Never call i18n.changeLanguage directly. The preferences store owns the active language: usePreferencesStore.getState().setLanguage(lng).
See client/src/i18n/README.md for the full convention set.
Phase 3 — GamePage Routing
client/src/pages/GamePage.tsx — conditional render
Add your overlay to the GamePageContent component alongside existing overlays:
If your overlay is a card choice type, integrate into the existing CardChoiceModal switch instead of adding a new top-level conditional.
Phase 4 — Animation Integration (if applicable)
client/src/animation/eventNormalizer.ts — Event grouping
If your new GameEvent should trigger visual effects:
Add to OWN_STEP_TYPES if it should always start a new animation step
Add to MERGE_TYPES if it should merge into the preceding step
Add duration to EVENT_DURATIONS in animation/types.ts
client/src/components/animation/AnimationOverlay.tsx — Visual effect
Add rendering for your event type if it needs VFX (particles, arcs, screen effects).
Phase 5 — Game Log (if applicable)
client/src/viewmodel/logFormatting.ts — Event formatting
Add a case for your GameEvent type to produce a human-readable log string. Translate the template, interpolate engine data raw — t("log.yourEvent", { objectId, amount }) where the sentence is chrome and the IDs/amounts are engine data. The log type label is frontend-authored (translate it); the card/object it refers to is not.
client/src/components/log/LogEntry.tsx — Custom rendering (if needed)
Most events use the default text format. Only add custom rendering for events that need icons, card references, or special formatting.
Phase 6 — Multiplayer Considerations
Player gating — Every interactive overlay MUST check waitingFor.data.player === playerId. Without this, both players see the choice UI.
State filtering — If the component displays hidden information (opponent's hand, library cards), ensure the server-side filter in crates/server-core/src/filter.rs correctly hides/reveals cards. The frontend should display whatever the filtered state contains — don't add client-side visibility logic.
Phase 7 — Component Tests
Colocate the test: client/src/components/modal/__tests__/YourOverlay.test.tsx — every component directory keeps its tests in a sibling __tests__/.
Build state with factory convenience methods from client/src/test/factories/. Chain methods instead of passing raw override objects — this is the required pattern:
Available: gameObjectFactory (card types, zones, tapped(), named(), withId(), commander()…), gameStateFactory (withPlayers, withObjects, withStack, inPhase, commander(), one method per WaitingFor variant), waitingForFactory (standalone WaitingFor builder), playerFactory, buildEngineAdapterMock. If your new WaitingFor variant or field has no convenience method, add one to the factory class — don't hand-roll object literals in tests; literals drift from the serde contract as types evolve.
Object, player, and stack-entry ids auto-increment via fishery sequence, so builds never collide — only chain .withId() when the test asserts on a specific id. Field values are otherwise deterministic (no faker): the engine contract is exact.
Seed the store, don't mount providers. Components read Zustand directly, so tests arrange by setting store state. Use setGameStoreForTest from test/helpers/gameStoreHelpers.ts (wraps setState in act() and returns a stubbed dispatch), and cleanup + reset in afterEach.
Assert the dispatch contract. The highest-value assertion for an interactive overlay is that the interaction produces the exact GameAction the engine expects:
This is the serde boundary — a misspelled field deserializes to nothing in prod.
Test the player gate. Seed waitingFor with the other player's ID (e.g. gameStateFactory.targetSelection({ player: 1 }) while rendering as player 0) and assert the overlay does not render.
Assert real English strings.src/test-setup.ts initializes i18next with the real en/*.json locales, so screen.getByText(/confirm/i) works. A string that won't resolve usually means the key is missing from en/<ns>.json (Phase 2.5).
Queries and interaction: prefer semantic queries (getByRole, getByText); fireEvent is the suite's prevailing convention. Use await screen.findBy* for async appearance instead of single-assertion waitFor.
Run via Tilt: check the test-frontend resource (tilt logs test-frontend / ./scripts/tilt-wait.sh test-frontend check-frontend) — do not run pnpm test directly while Tilt is up.
Dark theme: bg-gray-900 base, bg-gray-800 cards, ring-1 ring-gray-700 borders
Accent colors: Cyan (text-cyan-400) for info, Amber (text-amber-400) for warnings, Emerald (text-emerald-400) for success, Red (text-red-400) for danger