diff --git a/.husky/pre-commit b/.husky/pre-commit index 919ea6b0..9c459a3f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm lint:fix && git add -u +pnpm lint:fix && git add -u && pnpm --filter frontend lint:css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..12d012a5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +Root coordination contract for AI and human contributors in this repo. Detailed architecture and working guidelines live in [CLAUDE.md](./CLAUDE.md); this file states the non-negotiables and where to look. + +## Scope + +- Applies to the whole monorepo: `frontend/`, `backend/`, `mobile/`, `website/`, `shared/`, `contracts/ethereum/`, `contracts/solana/`, `indexer-go/`, `proto/`. +- No nested `AGENTS.md` files exist yet. If one is added under a package, it may tighten rules for that subtree but must not relax the rules here. + +Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are expected by default; deviations should be explained in the PR. `MAY` is optional. + +## Non-Negotiables + +- `MUST NOT` edit the golden test vectors in `contracts/test-vectors/{battle,xp}.json` to make a failing test pass. If a vector fails, the Go or Rust port has drifted from the Solidity contract; fix the drifted port, never the vector. +- `MUST` update all three combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`) when changing combat logic. Never patch one leg alone. +- `MUST NOT` assume a shared cross-chain TypeScript interface exists in `shared/`. EVM and Solana logic are intentionally duplicated side by side in `frontend/src/chains/{ethereum,solana}/`. +- `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, and `proto` are MIT; everything else is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. +- `MUST NOT` treat the v1 contract gaps documented in `contracts/plan-contract-upgrade.md` (no battle authorization, the `changeDna` cheat, client-supplied Solana starter-pet DNA) as bugs to silently patch. They are the known baseline the v2 rewrite is designed around. +- `MUST` run the smallest scoped lint/test/build command for the package you touched (see Command Baseline below), not a full monorepo run, unless the change is broad. +- `SHOULD NOT` trust `DEVELOPMENT.md`, `contracts/ethereum/README.md`, or the root `eth:deploy` / `eth:vrf:watch` scripts at face value. Several reference commands removed in a past refactor; see CLAUDE.md's Commands section for what is actually current. + +## Command Baseline + +- Install: `pnpm install` (root), or `pnpm install:all` (root + frontend + website + backend + mobile + contracts/ethereum) +- Dev: `pnpm dev:fe` / `pnpm dev:be` / `pnpm dev:mobile` / `pnpm dev:web`, or `pnpm fe:eth:local` / `pnpm fe:sol:local` for a full local chain + backend + frontend stack +- Lint: `pnpm lint` (covers frontend, shared, website, mobile only, not backend or contracts/ethereum) +- Test: `pnpm test` (equals `contracts/ethereum` test only; per-package test commands are in CLAUDE.md) +- Build: `pnpm build` + +Full per-package lint/test/build matrix and single-test syntax: see [CLAUDE.md](./CLAUDE.md#commands). + +## Where To Look + +- Behavioral guidelines and full architecture: [CLAUDE.md](./CLAUDE.md) +- Data flow and component map: [docs/architecture.md](./docs/architecture.md) +- Test suite conventions: [docs/testing.md](./docs/testing.md) +- Backend API surface: [backend/API.md](./backend/API.md) +- Indexer internals: [indexer-go/README.md](./indexer-go/README.md) +- Contract v1-to-v2 migration plan: [contracts/plan-contract-upgrade.md](./contracts/plan-contract-upgrade.md) +- Contribution workflow: [CONTRIBUTING.md](./CONTRIBUTING.md) +- Reporting vulnerabilities: [SECURITY.md](./SECURITY.md) + +## Enforcement + +Mechanical checks over prose, where they exist: + +- ESLint per package (`frontend`, `shared`, `website`, `mobile`), plus a custom CSS-naming check in `frontend` (`lint:css`). +- Golden test vectors (`contracts/test-vectors/{battle,xp}.json`), run by Hardhat, Anchor, and `indexer-go`'s `combat_golden_test.go`, are the cross-language enforcement for combat-simulator parity. +- CI coverage workflow (`.github/workflows/coverage.yml`) runs frontend/backend/shared vitest coverage on every PR and posts a combined comment. +- There is no repo-wide `agents:check` or module-boundary lint yet. Rely on the per-package commands above and the golden vectors until one exists. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3a938025 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@AGENTS.md + +The import above pulls in the cross-tool non-negotiables and command baseline from [AGENTS.md](./AGENTS.md), shared with other coding agents (Cursor, Codex, etc.). Everything below is Claude Code specific. + +## What this is + +`do-not-stop` (product name **cryptopets**) is a pnpm monorepo for a Web3 pet-battling game deployed on both Ethereum (Hardhat/Sepolia) and Solana (local validator/devnet). Live demo: https://cryptopets.vercel.app. + +## Working Guidelines + +**Think before coding.** State assumptions explicitly rather than silently picking between interpretations. If a request is ambiguous, or a simpler approach exists than the one implied, say so before implementing, especially around the chain-parity and licensing boundaries documented below, where a wrong guess is expensive to unwind. + +**Simplicity first.** No speculative abstractions, no unrequested configurability, no error handling for scenarios that can't occur at the call site. If a change could be half the size, make it that size. + +**Surgical changes.** Touch only what the task requires; match each file's existing style even where you'd choose differently. When your edit makes an import, variable, or function unused, remove it, but leave pre-existing dead code alone and just flag it. Never drive-by "fix" one leg of the combat simulator (Solidity/Rust/Go) without updating the other two and rerunning the golden vectors (see Architecture). + +**Goal-driven execution.** For multi-step work, state a short plan with a verification step per item, e.g. "port fix to indexer-go, then verify with `go test ./internal/combat -run TestName` against the golden vector." Use the smallest per-package command from the table below that actually exercises the change, not the whole suite, unless the change is broad. + +**Loops and autonomy.** "Done" means the relevant command from the table above passes, not "looks right." Work on a branch so changes are easy to revert. Autonomous or `/loop`-driven runs need an explicit stop condition (a passing test, a clean lint run) and an iteration cap; don't loop indefinitely on judgment calls like game-balance tuning or design decisions, those are a human call. If you hit the cap or get stuck, stop and report what you tried and what's blocking, rather than thrashing or guessing further. + +**Text.** In commit messages, PR descriptions, and docs written for this repo: no em-dashes, no filler ("it's worth noting," "essentially"), no LLM tells ("it's not just X, it's Y," "delve"). Reread before finishing and cut anything that doesn't earn its place. + +**Commit messages.** After any set of file changes, automatically draft and show a Conventional Commits message (`feat:`, `fix:`, `docs:`, `chore:`, etc., matching this repo's history) in a copyable code block, without waiting to be asked. Scope it to the actual uncommitted change set (check `git status`) and call out any unrelated modified files so they can be excluded. Do not run `git commit` yourself; the user commits manually unless they explicitly ask you to. + +## Commands + +Run from repo root unless noted. Package manager is **pnpm** (`packageManager: pnpm@9.15.9`). + +### Install / dev +```bash +pnpm install # root only +pnpm install:all # root + frontend + website + backend + mobile + contracts/ethereum +pnpm dev:fe # frontend only +pnpm dev:be # backend only +pnpm dev:mobile # mobile only +pnpm dev:web # website only +pnpm eth:node # local Hardhat network +pnpm eth:deploy # deploy contracts to it +pnpm sol:docker # start Solana validator (docker-compose) +pnpm sol:inject-ngrok # tunnel local Solana RPC for mobile/device testing +pnpm fe:eth:local # HH node + deploy + VRF watcher + backend + frontend, concurrently +pnpm mobile:eth:local # same, with mobile instead of frontend +pnpm fe:sol:local # backend + frontend + Solana docker/ngrok, concurrently +``` +> `pnpm eth:deploy` and `pnpm eth:vrf:watch` currently reference `deploy:inject` and `vrf:watch` scripts that **no longer exist** in `contracts/ethereum/package.json` (that package was refactored to `scripts/deploy.ts` plus Hardhat Ignition). If these fail, deploy directly with `pnpm --prefix contracts/ethereum deploy` (or `deploy:sepolia` / `deploy:base-sepolia`) instead of chasing the root wrapper script. `DEVELOPMENT.md` and `contracts/ethereum/README.md` also document a few commands (`pnpm dev`, `pnpm clean`, `pnpm vrf:watch`) that don't exist in the current root/package scripts, so treat those docs as partially stale and trust `package.json` scripts blocks over prose. + +### Lint / test / build (root aggregates) +```bash +pnpm lint # frontend lint:check + @shared/core lint + website lint + mobile lint +pnpm lint:fix +pnpm test # equals contracts/ethereum test (Hardhat/Mocha), NOT a full monorepo test run +pnpm build # compile contracts + build backend + frontend + website +``` +**`pnpm lint` and `pnpm test` do not cover every package.** `backend` has no lint script at all, and neither `backend` nor `contracts/ethereum` are in the root `lint` aggregate. Run per-package commands below when touching those. + +### Per-package commands +| Package | lint | test | build | single test | +|---|---|---|---|---| +| `frontend` | `pnpm --filter frontend lint:check` (`eslint . --max-warnings 0` + CSS naming check) | `pnpm --filter frontend test` (vitest) | `pnpm --filter frontend build` (`tsc -b && vite build`) | `pnpm --filter frontend exec vitest run ` or `-t ""` | +| `backend` | *(none)* | `pnpm --filter backend test` (vitest) | `pnpm --filter backend build` (`prisma generate && tsc`) | `pnpm --filter backend exec vitest run ` | +| `shared` (`@shared/core`) | `pnpm --filter @shared/core lint` | `pnpm --filter @shared/core test` (vitest) | *(none, consumed as raw TS)* | same vitest pattern | +| `mobile` | `pnpm --filter mobile lint` | `pnpm --filter mobile test` (jest) | *(none, RN, use `android`/`ios` scripts)* | `pnpm --filter mobile exec jest ` or `-t ""` | +| `website` | `pnpm --filter website lint` (`next lint`) | *(no test script)* | `pnpm --filter website build` | n/a | +| `contracts/ethereum` | *(none)* | `pnpm --prefix contracts/ethereum test` (`pnpm hh test`) | `pnpm compile` (`pnpm hh compile --force`) | `pnpm --prefix contracts/ethereum hh test test/.test.ts` | +| `contracts/solana/cryptopets` | n/a | Anchor test suite (see package README) | `anchor build` | n/a | +| `indexer-go` | `go vet ./...` | `go test ./...` (unit only; Postgres tests are gated on `TEST_DATABASE_URL` and **truncate tables**, scratch DB only) | `go build -o bin/indexer ./cmd/indexer` | `go test ./internal/combat -run TestName` | + +`*_test:coverage` scripts exist for frontend/backend/shared (`vitest run --coverage`); CI runs these in `.github/workflows/coverage.yml` and posts a combined PR comment. Coverage requires `@vitest/coverage-v8` to match the installed `vitest` major version, or coverage collection breaks repo-wide. + +## Architecture + +### Component map +| Component | Stack | Role | +|---|---|---| +| `frontend` | React 19, Vite, Wagmi, Viem, TanStack Query | Web app, wallet integration | +| `backend` | Node.js, Express, TypeScript, Prisma, JWT | REST + GraphQL + gRPC API server | +| `mobile` | React Native | Cross-platform client | +| `website` | Next.js | Marketing/docs site | +| `indexer-go` | Go | Optional cross-chain indexer (EVM pull + Solana push) | +| `contracts/ethereum` | Solidity, Hardhat | EVM contracts + subgraph | +| `contracts/solana/cryptopets` | Rust, Anchor | Solana programs | +| `shared` (`@shared/core`) | TypeScript | Common utils/types/hooks, consumed as raw TS (no build step), shared by frontend + mobile | +| `proto` | Protobuf/Buf | gRPC contract (`GameDataService`) between `indexer-go` and `backend` | + +### Data flow +On-chain events (EVM via subgraph watermark polling, Solana via WebSocket push + backfill) are mirrored into Prisma-owned Postgres (`pet_roster`, `battle_history`) by **two parallel indexers**: the backend's built-in Node `RosterIndexer`, and the optional Go `indexer-go`. The Node indexer is the source of truth in local dev; `indexer-go` is promotable later and can additionally stream settled battles straight to the backend over gRPC (`StreamLiveBattles`, defined in `proto/cryptopets.proto`). If `indexer-go` is down, the backend circuit-breaks back to reading Postgres directly (`ROSTER_READ_SOURCE` env var controls `grpc` vs `postgres`). Frontend, mobile, and website all talk to the backend via REST + GraphQL; none of them read chain state directly. See `docs/architecture.md`, `backend/API.md`, `indexer-go/README.md`. + +Note: `docs/README.md` and `docs/architecture.md` link to `indexer-go/ARCHITECTURE.md`, which doesn't exist. The real doc is `indexer-go/README.md`. + +### No shared cross-chain interface: logic is duplicated per chain, deliberately +There is **no** shared TypeScript abstraction that both chains implement. `frontend/src/chains/ethereum/` (wagmi + in-tree ABI JSONs: `combatSimAbi.json`, `gameConfigAbi.json`, `gameLogicAbi.json`, `petCoreAbi.json`) and `frontend/src/chains/solana/` (Anchor wallet/provider/signer) sit side by side and are wired independently. Don't assume a generic `ChainAdapter`-style interface exists in `shared/`; check the concrete chain directory instead. + +### Combat simulator is ported three times: golden vectors keep them in sync +The battle/combat logic is implemented independently in `contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, and pure Go in `indexer-go/internal/combat/`. All three are validated against the same golden test vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, and `combat_golden_test.go` respectively. Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. +**If a golden vector test fails, the Go (or Rust) implementation has drifted from the Solidity contract. Fix the drifted port, never edit the vector.** + +### VRF / randomness +- EVM breeding uses Chainlink VRF v2.5 (async commit, coordinator fulfills). Locally this needs a VRF watcher process (`vrf-fulfill-watcher.ts` used to provide this; see the stale-script note in Commands above). `pnpm fe:eth:local` was designed to run it automatically. +- Solana breeding uses Switchboard On-Demand (commit then settle), also async. +- EVM in-battle randomness (`battle()`/`attack()`) currently uses `Utils.randMod` (keccak of timestamp/sender/nonce). This is a known-predictable v1 weakness, not a hidden bug; see `contracts/plan-contract-upgrade.md` for the v2 redesign (stat-based combat, no more on-chain pseudo-randomness for battles). + +### Known v1 contract limitations (design context, not regressions to "fix") +`contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. + +### Hardhat specifics worth knowing +- Contract sources live in `contracts/ethereum/src/` (not `contracts/`): `PetCore.sol`, `GameLogic.sol`, `GameConfig.sol`, `CombatSim.sol`, `DnaLib.sol`, `TestDeployer.sol`. +- Both compiler profiles (`default` and `production`) are pinned to `viaIR` explicitly. Hardhat Ignition silently drops viaIR/optimizer settings from a flat config, and `CombatSim.sol` hits "stack too deep" without it. +- The `localhost` network hardcodes the 5 standard Hardhat dev private keys; only live networks (Sepolia, Base Sepolia, see `scripts/networks.ts`) read `PRIVATE_KEY` from env. +- Deployment is Hardhat Ignition-based (`ignition/modules/CryptoPetsV2Live.ts`); use `pnpm --prefix contracts/ethereum deploy:status` / `deploy:visualize` to inspect. + +### Solana local setup +`contracts/solana/docker-compose.yml` runs two services: `solana-dev` (the validator itself, ports 8899/8900/9900) and an **ngrok tunnel** service exposing the local RPC (needs `NGROK_AUTHTOKEN`, ngrok web UI on 4040). This is how mobile/on-device testing reaches a local validator (`pnpm sol:inject-ngrok`), and it isn't documented in `DEVELOPMENT.md`. + +### indexer-go internals +Two chain adapters (Solana WS push, EVM subgraph pull) behind a `ChainIndexer` interface feed a single version-guarded pgx batch writer into Postgres, plus a gRPC push path. Layout: `cmd/indexer` (binary, supports `-scan-once`), `internal/{indexer,evm,solana,store,combat,battlebus,grpcsrv}`, `pb/` (buf-generated). An optional in-memory read cache (`ROSTER_CACHE_ENABLED`) is write-through and version-guarded; it's only coherent while `indexer-go` is the sole writer, so it should stay off during shadow-mode (dual-indexer) operation and only be enabled at promotion. + +### Auth +Backend auth is nonce, then wallet-signature, then JWT (`backend/README.md`), guarding a single `/graphql` endpoint; the authenticated wallet becomes the matchmaking `caller` context (`backend/API.md`). Roster/battle reads are read-only projections of what the indexer(s) wrote; the backend no longer decodes contract events itself. `winEstimate` returns `null` (not an error) when unavailable, so treat that as a degraded UI state, not a failure. + +### Testing conventions +See `docs/testing.md` for the full per-package suite table. Test work is expected to land on dedicated branches per test type/area (e.g. `test/frontend-modules`), not mixed into feature branches, with coverage reported after each change. + +## Licensing + +This monorepo has split licensing; see the table in `README.md`. `contracts/ethereum`, `contracts/solana`, `indexer-go`, and `proto` are MIT; everything else (`frontend`, `backend`, `mobile`, `website`, `shared`) is PolyForm Noncommercial 1.0.0 (root `LICENSE`). Match the license of whichever package you're editing when adding new files. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 120c24dd..bf25915b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -37,7 +37,7 @@ export default tseslint.config( 'import/no-duplicates': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-empty-object-type': 'off', 'react-refresh/only-export-components': 'off', 'prefer-const': 'error', diff --git a/frontend/package.json b/frontend/package.json index 36d264e6..cf6bb499 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "prebuild": "node ./scripts/ensure-workspace-link.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", - "lint:check": "eslint . --max-warnings 0", + "lint:check": "eslint . --max-warnings 0 && node ./scripts/check-css-naming.mjs", + "lint:css": "node ./scripts/check-css-naming.mjs", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", "preview": "pnpm vite preview", diff --git a/frontend/scripts/check-css-naming.mjs b/frontend/scripts/check-css-naming.mjs new file mode 100644 index 00000000..32797120 --- /dev/null +++ b/frontend/scripts/check-css-naming.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * CSS naming guard — fails if any stylesheet reintroduces BEM `__` element + * class names (e.g. `.cp-shell__content`, `.breed-parent__stat-fill`). + * + * The frontend refactor removed every `__` BEM name in favour of CSS Modules + * (local `camelCase`) and flat global classes; this check keeps it that way. + * Run via `pnpm lint:css`. Zero dependencies on purpose — a full stylelint setup + * would flag unrelated pre-existing style issues and needs CSS-Modules-aware + * config; this enforces the one convention we actually agreed on. + * + * NOTE: `--` view-modifiers on the intentionally-global theming classes + * (`.interaction-standalone--battle`, …) are allowed — they are not the target. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src'); + +/** A class selector whose name contains a `__` (BEM element). */ +const BEM_ELEMENT = /\.[a-zA-Z][a-zA-Z0-9-]*__[a-zA-Z0-9-]+/g; + +/** Recursively collect every *.css file (CSS Modules included) under a dir. */ +function cssFiles(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...cssFiles(full)); + else if (entry.endsWith('.css')) out.push(full); + } + return out; +} + +const violations = []; +for (const file of cssFiles(SRC)) { + const lines = readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + for (const match of line.matchAll(BEM_ELEMENT)) { + violations.push({ file: relative(process.cwd(), file), line: i + 1, name: match[0] }); + } + }); +} + +if (violations.length > 0) { + console.error('✗ CSS naming check failed — BEM `__` element class names are not allowed.\n'); + for (const v of violations) { + console.error(` ${v.file}:${v.line} ${v.name}`); + } + console.error( + '\nUse a CSS Module with a local camelCase class (`import s from \'./index.module.css\'`)' + + '\nor a flat global class instead of BEM `__`. See frontend/REFACTORING_PLAN.md.', + ); + process.exit(1); +} + +console.log('✓ CSS naming check passed — no BEM `__` element class names found.'); diff --git a/frontend/src/assets/nav-icons/battle.svg b/frontend/src/assets/nav-icons/battle.svg new file mode 100644 index 00000000..fcd2298b --- /dev/null +++ b/frontend/src/assets/nav-icons/battle.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/breed.svg b/frontend/src/assets/nav-icons/breed.svg new file mode 100644 index 00000000..efef437b --- /dev/null +++ b/frontend/src/assets/nav-icons/breed.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/gallery.svg b/frontend/src/assets/nav-icons/gallery.svg new file mode 100644 index 00000000..c68b7e32 --- /dev/null +++ b/frontend/src/assets/nav-icons/gallery.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/items.svg b/frontend/src/assets/nav-icons/items.svg new file mode 100644 index 00000000..98d08b07 --- /dev/null +++ b/frontend/src/assets/nav-icons/items.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/levelup.svg b/frontend/src/assets/nav-icons/levelup.svg new file mode 100644 index 00000000..19215855 --- /dev/null +++ b/frontend/src/assets/nav-icons/levelup.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/marriage.svg b/frontend/src/assets/nav-icons/marriage.svg new file mode 100644 index 00000000..08f6512e --- /dev/null +++ b/frontend/src/assets/nav-icons/marriage.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/rename.svg b/frontend/src/assets/nav-icons/rename.svg new file mode 100644 index 00000000..1ac53187 --- /dev/null +++ b/frontend/src/assets/nav-icons/rename.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/shard.svg b/frontend/src/assets/nav-icons/shard.svg new file mode 100644 index 00000000..453540ac --- /dev/null +++ b/frontend/src/assets/nav-icons/shard.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/train.svg b/frontend/src/assets/nav-icons/train.svg new file mode 100644 index 00000000..0f7dfac3 --- /dev/null +++ b/frontend/src/assets/nav-icons/train.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/common/dashboard-panel/index.css b/frontend/src/components/common/dashboard-panel/index.css index 0d5894a5..a14146d5 100644 --- a/frontend/src/components/common/dashboard-panel/index.css +++ b/frontend/src/components/common/dashboard-panel/index.css @@ -18,7 +18,7 @@ --pc-font-title: clamp(1.25rem, 1.5rem, 1.75rem); --pc-text: #f6f3ff; --pc-text-muted: rgb(195 210 255 / 70%); - --pc-border: var(--neon-border-soft); + --pc-border: var(--cp-border-soft); --pc-surface-bg: linear-gradient(180deg, rgb(10 16 32 / 92%), rgb(6 10 22 / 94%)); --pc-refresh-size: 36px; --pc-transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); @@ -57,7 +57,7 @@ } @media (prefers-contrast: more) { - border: 2px solid var(--color-text); + border: 2px solid var(--cp-color-text); } } @@ -80,13 +80,13 @@ .heading { margin: 0; - font-family: var(--title-font); + font-family: var(--cp-title-font); font-size: var(--pc-font-title); font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--pc-text); - text-shadow: 0 0 12px var(--neon-text-glow); + text-shadow: 0 0 12px var(--cp-text-glow); @media (max-width: 480px) { font-size: 20px; @@ -156,7 +156,7 @@ panel that opts into a `.refresh` action gets the same look). */ .refresh { background: rgb(5 13 30 / 92%); - color: var(--neon-cyan); + color: var(--cp-cyan); border: 1px solid rgb(125 214 255 / 32%); border-radius: 2px; padding: var(--pc-spacing-sm); @@ -171,7 +171,7 @@ box-shadow: inset 0 0 12px rgb(125 214 255 / 14%), 0 0 10px rgb(125 214 255 / 18%); &:hover:not(:disabled) { - color: var(--neon-violet); + color: var(--cp-violet); border-color: rgb(181 140 255 / 42%); transform: translateY(-1px); box-shadow: inset 0 0 16px rgb(181 140 255 / 18%), 0 0 16px rgb(181 140 255 / 28%); diff --git a/frontend/src/components/common/transaction-status/index.css b/frontend/src/components/common/transaction-status/index.module.css similarity index 84% rename from frontend/src/components/common/transaction-status/index.css rename to frontend/src/components/common/transaction-status/index.module.css index 5162fed9..f7880445 100644 --- a/frontend/src/components/common/transaction-status/index.css +++ b/frontend/src/components/common/transaction-status/index.module.css @@ -1,16 +1,17 @@ -.transaction-status { +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. */ +.root { --tx-inset: 1.25rem; --tx-slide-duration: 0.3s; --tx-state-duration: 0.6s; position: fixed; - z-index: var(--z-modal); + z-index: var(--cp-z-modal); inset-block-start: var(--tx-inset); inset-inline-end: var(--tx-inset); min-width: 300px; max-width: 400px; border-radius: 2px; - border: 1px solid var(--neon-border-soft); + border: 1px solid var(--cp-border-soft); background: linear-gradient(180deg, rgb(10 16 32 / 96%), rgb(6 10 22 / 98%)); box-shadow: inset 0 0 18px rgb(125 214 255 / 8%), 0 0 20px rgb(125 214 255 / 20%), 0 16px 40px rgb(0 0 0 / 45%); @@ -20,8 +21,8 @@ .content { display: flex; align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-sm); + gap: var(--cp-spacing-sm); + padding: var(--cp-spacing-sm); @media (max-width: 768px) { padding: 0.75rem; @@ -36,12 +37,12 @@ width: 2rem; height: 2rem; border-radius: 50%; - font-size: var(--font-size-base); + font-size: var(--cp-font-size-base); @media (max-width: 768px) { width: 1.75rem; height: 1.75rem; - font-size: var(--font-sm); + font-size: var(--cp-font-size-sm); } } @@ -49,7 +50,7 @@ width: 1.25rem; height: 1.25rem; border: 2px solid rgb(125 214 255 / 18%); - border-top-color: var(--neon-cyan); + border-top-color: var(--cp-cyan); border-radius: 50%; animation: tx-spin 1s linear infinite; box-shadow: 0 0 8px rgb(125 214 255 / 28%); @@ -68,7 +69,7 @@ .title { margin-block-end: 0.25rem; font-weight: 700; - font-size: var(--font-sm); + font-size: var(--cp-font-size-sm); color: #f6f3ff; letter-spacing: 0.4px; @@ -79,8 +80,8 @@ .hash { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; - font-size: var(--font-xs); - color: var(--neon-cyan); + font-size: 0.75rem; + color: var(--cp-cyan); word-break: break-all; @media (max-width: 768px) { @@ -95,18 +96,18 @@ justify-content: center; width: 1.5rem; height: 1.5rem; - padding: var(--radius-sm); + padding: 0.25rem; border: 1px solid transparent; border-radius: 2px; background: transparent; - font-size: var(--font-xl); + font-size: var(--cp-font-size-xl); line-height: 1; color: rgb(195 210 255 / 60%); cursor: pointer; - transition: var(--transition-fast); + transition: var(--cp-transition-fast); &:hover:not(:disabled) { - color: var(--neon-cyan); + color: var(--cp-cyan); border-color: rgb(125 214 255 / 42%); box-shadow: 0 0 10px rgb(125 214 255 / 24%); } @@ -126,7 +127,7 @@ &.confirming .icon { background: rgb(125 214 255 / 14%); - color: var(--neon-cyan); + color: var(--cp-cyan); border: 1px solid rgb(125 214 255 / 38%); box-shadow: 0 0 12px rgb(125 214 255 / 22%); } @@ -206,14 +207,14 @@ } @media (prefers-reduced-motion: reduce) { - .transaction-status, - .transaction-status.confirmed, - .transaction-status.error { + .root, + .root.confirmed, + .root.error { animation: none; } - .transaction-status .spinner { + .root .spinner { animation: none; - border-top-color: var(--neon-border-soft); + border-top-color: var(--cp-border-soft); } } diff --git a/frontend/src/components/common/transaction-status/index.tsx b/frontend/src/components/common/transaction-status/index.tsx index e918f38b..46e74f87 100644 --- a/frontend/src/components/common/transaction-status/index.tsx +++ b/frontend/src/components/common/transaction-status/index.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; +import clsx from 'clsx'; import type { TxLifecycle } from '@shared/core'; import { Tones } from '@constants/tones'; import Icon, { CheckIcon } from '@components/ui/icon'; -import './index.css'; +import styles from './index.module.css'; interface TransactionStatusProps { /** Mutation lifecycle from a `@shared/core` pet hook (e.g. `useTransferPet`). */ @@ -57,25 +58,25 @@ const TransactionStatus: React.FC = ({ lifecycle }) => { const status = isConfirming ? 'confirming' : 'confirmed'; return ( -
-
-
+
+
+
{isConfirming ? ( -
+
) : ( - + )}
-
-
+
+
{isConfirming ? 'Confirming transaction...' : 'Transaction confirmed!'}
-
+
{displayHash.slice(0, 10)}...{displayHash.slice(-8)}
+ +
+ {NAV_ITEMS.map((item) => { + const isActive = item.path != null && currentPath === item.path; + return ( + + ); + })} +
+ +
+ + {/* Placeholder — Daily Quests (pending real quest data) */} +
+
◈ Daily Quests
+
    +
  • +
    + ⚔ Win 3 battles + +50 XP +
    +
    +
    +
    +
  • +
  • +
    + 💪 Train a pet + +25 XP +
    +
    +
    +
    +
  • +
  • +
    + 🥚 Breed a new pet + +100 XP +
    +
    +
    +
    +
  • +
+
+ + {/* Placeholder — global rank footer (pending leaderboard data) */} +
+ + 🏆 + +
+
RANK #3 GLOBAL
+
649 Total Wins
+
+
+ + ); +}; + +export default Sidebar; diff --git a/frontend/src/components/layout/sidebar/nav-items.ts b/frontend/src/components/layout/sidebar/nav-items.ts new file mode 100644 index 00000000..b7bd0c8b --- /dev/null +++ b/frontend/src/components/layout/sidebar/nav-items.ts @@ -0,0 +1,51 @@ +import { type IconTone } from '@components/ui/icon'; +import { + BATTLE_PATH, + BREED_PATH, + DASHBOARD_HOME, + LEVELUP_PATH, + MARRIAGE_PATH, + RENAME_PATH, + TRAIN_PATH, +} from '@constants/interactionRoutes'; +import galleryIcon from '@assets/nav-icons/gallery.svg'; +import battleIcon from '@assets/nav-icons/battle.svg'; +import breedIcon from '@assets/nav-icons/breed.svg'; +import levelupIcon from '@assets/nav-icons/levelup.svg'; +import trainIcon from '@assets/nav-icons/train.svg'; +import itemsIcon from '@assets/nav-icons/items.svg'; +import shardIcon from '@assets/nav-icons/shard.svg'; +import marriageIcon from '@assets/nav-icons/marriage.svg'; +import renameIcon from '@assets/nav-icons/rename.svg'; + +export type NavItem = { + id: string; + label: string; + /** URL of the Claude-Design-generated nav icon (colored SVG, viewBox 48×48). */ + iconSrc: string; + /** Drives the active border-left / glow accent (see sidebar/index.css). */ + tone: IconTone; + /** Route this item navigates to. Omitted for deferred (not-yet-built) items. */ + path?: string; + /** Deferred features (Inventory / Shard Forge) render disabled with a "Soon" tag. */ + deferred?: boolean; +}; + +/** + * Left-nav actions, ordered to match the redesign mock. Icons are the + * Claude-Design-generated SVGs (`src/assets/nav-icons/`). Gallery + six feature + * screens are wired to real routes; Inventory and Shard Forge are deferred (see + * FRONTEND_REDESIGN_PLAN.md §8) and shown disabled until backend/contract + * support lands. + */ +export const NAV_ITEMS: readonly NavItem[] = [ + { id: 'gallery', label: 'Gallery', iconSrc: galleryIcon, tone: 'cyan', path: DASHBOARD_HOME }, + { id: 'battle', label: 'Battle Arena', iconSrc: battleIcon, tone: 'magenta', path: BATTLE_PATH }, + { id: 'breed', label: 'Breeding Lab', iconSrc: breedIcon, tone: 'amber', path: BREED_PATH }, + { id: 'levelup', label: 'Level Up', iconSrc: levelupIcon, tone: 'violet', path: LEVELUP_PATH }, + { id: 'train', label: 'Training Ground', iconSrc: trainIcon, tone: 'cyan', path: TRAIN_PATH }, + { id: 'items', label: 'Inventory', iconSrc: itemsIcon, tone: 'amber', deferred: true }, + { id: 'shard', label: 'Shard Forge', iconSrc: shardIcon, tone: 'cyan', deferred: true }, + { id: 'marriage', label: 'Marriage', iconSrc: marriageIcon, tone: 'magenta', path: MARRIAGE_PATH }, + { id: 'rename', label: 'Rename Pet', iconSrc: renameIcon, tone: 'cyan', path: RENAME_PATH }, +]; diff --git a/frontend/src/components/layout/top-bar/index.module.css b/frontend/src/components/layout/top-bar/index.module.css new file mode 100644 index 00000000..8bf0d604 --- /dev/null +++ b/frontend/src/components/layout/top-bar/index.module.css @@ -0,0 +1,134 @@ +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + The gold/streak badge keyframes live here (only used by this component); CSS + Modules scopes both the @keyframes and the animation reference consistently. */ +.topbar { + height: var(--cp-shell-header-height); + flex-shrink: 0; + display: flex; + align-items: center; + padding: 0 24px; + gap: 12px; + background: rgb(5 8 18 / 96%); + border-bottom: 1px solid rgb(101 131 255 / 16%); + backdrop-filter: blur(14px); + box-shadow: 0 4px 24px rgb(0 0 0 / 55%); + /* Stacking context above the content so the wallet dropdown menu, which + drops below the header, renders on top instead of being clipped/covered. */ + position: relative; + z-index: 10; +} + +.title { + font-family: var(--cp-title-font); + font-size: 18px; + font-weight: 900; + letter-spacing: 2.5px; + text-transform: uppercase; + color: #f4f2ff; + text-shadow: 0 0 14px rgb(185 160 255 / 70%), 0 0 36px rgb(108 72 255 / 40%); + white-space: nowrap; + flex-shrink: 0; +} + +.spacer { + flex: 1; +} + +.badges { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.badge { + display: flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 5px 12px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.strong { + font-family: var(--cp-title-font); + font-size: 10px; + letter-spacing: 1px; +} + +.muted { + font-size: 10px; + font-weight: 600; +} + +.sep { + width: 1px; + height: 11px; + background: rgb(255 215 0 / 28%); +} + +.gold { + background: rgb(255 215 0 / 8%); + border: 1px solid rgb(255 215 0 / 35%); + color: var(--cp-gold); + animation: cp-glow-pulse 3s ease-in-out infinite; +} + +.gold .muted { + color: rgb(255 215 0 / 65%); +} + +.streak { + background: rgb(255 207 112 / 7%); + border: 1px solid rgb(255 207 112 / 28%); + color: var(--cp-amber); + animation: cp-streak-fire 2.2s ease-in-out infinite; +} + +.wins { + background: rgb(125 214 255 / 6%); + border: 1px solid rgb(125 214 255 / 18%); + color: rgb(125 214 255 / 82%); +} + +.wallet { + flex-shrink: 0; +} + +/* AccountDropdown defaults to position:fixed (a corner overlay). Inside the + header it must flow inline with the badges — position:static neutralizes the + fixed top/right offsets so the wallet + network switcher don't float over + (and overlap) the rest of the header. */ +.wallet :global(.account-dropdown) { + position: static; + gap: 8px; +} + +@media (max-width: 720px) { + .gold, + .streak { + display: none; + } +} + +@keyframes cp-glow-pulse { + 0%, + 100% { + box-shadow: 0 0 14px rgb(255 215 0 / 25%); + } + 50% { + box-shadow: 0 0 32px rgb(255 215 0 / 55%); + } +} + +@keyframes cp-streak-fire { + 0%, + 100% { + box-shadow: 0 0 10px rgb(255 207 112 / 25%); + } + 50% { + box-shadow: 0 0 22px rgb(255 123 203 / 45%); + } +} diff --git a/frontend/src/components/layout/top-bar/index.tsx b/frontend/src/components/layout/top-bar/index.tsx new file mode 100644 index 00000000..35fb0e63 --- /dev/null +++ b/frontend/src/components/layout/top-bar/index.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import clsx from 'clsx'; +import { useChainCapabilities, usePetList } from '@shared/core'; + +import AccountDropdown from '@components/wallet/account-dropdown'; +import styles from './index.module.css'; + +const TITLE = 'Crypto Pets'; + +/** + * Shell header: wordmark, player status badges, and the wallet control. + * + * Total wins is derived from the real pet list. The Gold tier / level and the + * win-streak badges are static placeholders pending real progression data + * (see FRONTEND_REDESIGN_PLAN.md §8 Q3). + */ +const TopBar: React.FC = () => { + const { isConnected } = useChainCapabilities(); + const { pets } = usePetList(); + + const totalWins = useMemo( + () => pets.reduce((sum, pet) => sum + (pet.winCount ?? 0), 0), + [pets], + ); + + return ( +
+
{TITLE}
+
+ + {isConnected && ( +
+ {/* Placeholder — player tier/level (no progression data yet) */} +
+ 🥇 + GOLD + + Lv 24 +
+ {/* Placeholder — win streak */} +
+ 🔥 + 7 STREAK +
+ {/* Real — total wins across owned pets */} +
+ + {totalWins} Wins +
+
+ )} + +
+ +
+
+ ); +}; + +export default TopBar; diff --git a/frontend/src/components/pet/collection/pet-collection-layout/index.css b/frontend/src/components/pet/collection/pet-collection-layout/index.css deleted file mode 100644 index b6c3b530..00000000 --- a/frontend/src/components/pet/collection/pet-collection-layout/index.css +++ /dev/null @@ -1,34 +0,0 @@ -/* Pet-collection-specific tokens + container scope. - Shared surface / title-bar / refresh styles live in `common/dashboard-panel/index.css`. - Page-specific sizing lives in `components/layout/index.css`. */ - -.dashboard-panel.pet-collection { - --spacing-xs: clamp(0.125rem, 0.25rem, 0.375rem); - --spacing-sm: clamp(0.25rem, 0.5rem, 0.75rem); - --spacing-md: clamp(0.5rem, 1rem, 1.25rem); - --spacing-lg: clamp(0.75rem, 1.5rem, 2rem); - --spacing-xl: clamp(1rem, 2rem, 2.5rem); - --spacing-2xl: clamp(1.5rem, 2.5rem, 3rem); - --spacing-3xl: clamp(2rem, 3rem, 3.5rem); - --border-radius: 1rem; - --border-radius-sm: 0.5rem; - --border-radius-lg: 0.75rem; - --font-size-xs: clamp(0.625rem, 0.75rem, 0.875rem); - --font-size-sm: clamp(0.75rem, 0.875rem, 1rem); - --font-size-base: clamp(0.875rem, 1rem, 1.125rem); - --font-size-lg: clamp(1rem, 1.125rem, 1.25rem); - --font-size-xl: clamp(1.125rem, 1.25rem, 1.375rem); - --font-size-2xl: clamp(1.25rem, 1.5rem, 1.75rem); - --font-size-3xl: clamp(1.5rem, 1.75rem, 2rem); - --font-size-4xl: clamp(1.75rem, 2rem, 2.25rem); - --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - --transition-fast: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - /* Container query scope for `pet-grid` / `pet-card` (see pet-gallery/index.css). */ - container-type: inline-size; - container-name: pet-collection; - - @media (max-width: 480px) { - padding: 16px; - } -} diff --git a/frontend/src/components/pet/collection/pet-collection-layout/index.tsx b/frontend/src/components/pet/collection/pet-collection-layout/index.tsx deleted file mode 100644 index dc0eb745..00000000 --- a/frontend/src/components/pet/collection/pet-collection-layout/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import DashboardPanel from '@components/common/dashboard-panel'; -import './index.css'; - -export type PetCollectionLayoutProps = { - title: React.ReactNode; - /** Muted line under the title (e.g. “Connect your wallet…”). */ - description?: React.ReactNode; - /** Controls aligned to the header corner (e.g. refresh). */ - actions?: React.ReactNode; - children?: React.ReactNode; - /** Extra classes on the outer panel (e.g. `wallet-disconnected`). */ - className?: string; -}; - -/** - * Shell for the dashboard pet list. Composes the shared `DashboardPanel` with - * a `pet-collection` modifier so list-specific styles can scope to this panel. - */ -const PetCollectionLayout: React.FC = ({ - title, - description, - actions, - children, - className, -}) => { - const isWalletDisconnected = className?.includes('wallet-disconnected'); - const composedClass = `pet-collection${className ? ` ${className}` : ''}`; - - return ( - - {children} - - ); -}; - -export default PetCollectionLayout; diff --git a/frontend/src/components/pet/collection/pet-gallery/index.css b/frontend/src/components/pet/collection/pet-gallery/index.css deleted file mode 100644 index 5e8b19c4..00000000 --- a/frontend/src/components/pet/collection/pet-gallery/index.css +++ /dev/null @@ -1,637 +0,0 @@ -/* Pet list content inside PetCollectionLayout (scoped tokens come from ancestor `.pet-collection`). */ - -/* Loading States */ -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--spacing-2xl); - color: var(--color-text-muted); - - @media (max-width: 480px) { - padding: var(--spacing-lg); - } -} - -.loading-spinner { - width: 40px; - height: 40px; - border: 3px solid rgb(125 214 255 / 18%); - border-block-start: 3px solid var(--neon-cyan); - border-radius: 50%; - animation: spin 1s linear infinite; - margin-block-end: var(--spacing-sm); - box-shadow: 0 0 16px rgb(125 214 255 / 30%); -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.error-container { - text-align: center; - padding: var(--spacing-2xl); - color: #ff9aa9; - text-shadow: 0 0 12px rgb(255 110 196 / 32%); - - @media (max-width: 480px) { - padding: var(--spacing-lg); - } -} - -/* Empty State — summoning altar hero. Fits the remaining surface height. */ -.empty-state { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - text-align: center; - padding: 8px; - color: rgb(195 210 255 / 75%); - overflow: hidden; - - & .altar { - --altar-size: clamp(120px, 18vh, 200px); - position: relative; - width: var(--altar-size); - height: var(--altar-size); - flex-shrink: 0; - } - - & .ring { - position: absolute; - inset: 0; - margin: auto; - border-radius: 50%; - pointer-events: none; - } - - & .ring-outer { - inset: 0; - border: 1px dashed rgb(125 214 255 / 28%); - box-shadow: inset 0 0 30px rgb(125 214 255 / 8%), 0 0 22px rgb(125 214 255 / 14%); - animation: altar-spin 22s linear infinite; - } - - & .ring-mid { - inset: 18%; - border: 1px solid rgb(181 140 255 / 32%); - box-shadow: inset 0 0 24px rgb(181 140 255 / 12%), 0 0 20px rgb(181 140 255 / 20%); - animation: altar-spin 16s linear infinite reverse; - } - - & .ring-inner { - inset: 32%; - border: 1px solid rgb(255 110 196 / 30%); - background: radial-gradient(circle, rgb(181 140 255 / 22%), transparent 70%); - box-shadow: inset 0 0 22px rgb(255 110 196 / 14%), 0 0 26px rgb(181 140 255 / 28%); - animation: altar-pulse 3.6s ease-in-out infinite; - } - - & .core { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: clamp(1.8rem, 5vh, 3rem); - animation: altar-float 4.2s ease-in-out infinite; - } - - & .orb { - position: absolute; - font-size: clamp(1rem, 1.6vh, 1.25rem); - line-height: 1; - animation: altar-float 5s ease-in-out infinite; - } - - & .orb-tl { - top: 6%; - left: 8%; - animation-delay: -0.2s; - } - & .orb-tr { - top: 8%; - right: 6%; - animation-delay: -1.1s; - } - & .orb-bl { - bottom: 10%; - left: 10%; - animation-delay: -2.4s; - } - & .orb-br { - bottom: 8%; - right: 8%; - animation-delay: -3.3s; - } - - & .empty-copy { - display: flex; - flex-direction: column; - gap: 4px; - max-width: 360px; - } - - & h3 { - margin: 0; - font-family: var(--title-font); - font-size: var(--font-size-base); - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: #f6f3ff; - text-shadow: 0 0 12px var(--neon-text-glow); - } - - & p { - margin: 0; - font-size: var(--font-size-xs); - color: rgb(195 210 255 / 60%); - letter-spacing: 0.2px; - } - - @media (max-width: 480px) { - padding: 16px 12px 12px; - gap: 10px; - - & .altar { - --altar-size: 160px; - } - } -} - -@keyframes altar-spin { - to { - transform: rotate(360deg); - } -} - -@keyframes altar-pulse { - 0%, - 100% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.06); - opacity: 0.85; - } -} - -@keyframes altar-float { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-4px); - } -} - -@media (prefers-reduced-motion: reduce) { - .empty-state .ring, - .empty-state .core, - .empty-state .orb { - animation: none; - } -} - -/* Pet grid with container queries */ -.pet-grid { - display: grid; - /* `min(320px, 100%)` lets each track shrink below 320px when the panel is - narrower than one card, preventing horizontal overflow in the 50/50 split. */ - grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); - grid-auto-rows: min-content; - align-content: start; - align-items: start; - gap: var(--spacing-md); - width: 100%; - min-width: 0; - min-height: 0; - flex: 1 1 auto; - overflow-y: auto; - overflow-x: hidden; - container-type: inline-size; - container-name: pet-grid; - - scrollbar-width: thin; - scrollbar-color: var(--neon-cyan) transparent; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, var(--neon-cyan), var(--neon-violet)); - border-radius: 3px; - box-shadow: 0 0 8px rgb(125 214 255 / 32%); - } - - &::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, var(--neon-violet), var(--neon-magenta)); - } - - @media (max-width: 480px) { - grid-template-columns: 1fr; - gap: var(--spacing-sm); - margin-block-start: 0; - padding-block: var(--spacing-sm); - padding-inline: var(--spacing-xs); - } -} - -/* Pet cards */ -.pet-card { - /* Size to content (the cooldown status row just makes a card a touch taller). */ - min-height: 260px; - height: auto; - width: 100%; - background: linear-gradient(180deg, rgb(10 16 32 / 92%), rgb(6 10 22 / 94%)); - border-radius: var(--border-radius-lg); - padding: 0; - /* Elevation only — no inset cyan glow / no outer color halo. */ - box-shadow: 0 6px 22px rgb(0 0 0 / 35%); - border: 1px solid var(--neon-border-soft); - transition: var(--transition); - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; - container-type: inline-size; - container-name: pet-card; - - &:hover { - border-color: var(--neon-border-strong); - box-shadow: 0 12px 32px rgb(0 0 0 / 45%); - } - - &:focus-within { - outline: 2px solid var(--neon-violet); - outline-offset: 2px; - } - - @media (max-width: 480px) { - padding: var(--spacing-sm); - } - - @media (prefers-contrast: more) { - border: 2px solid var(--color-text); - } -} - -.pet-visual { - position: relative; - background: radial-gradient(circle at 30% 30%, rgb(125 214 255 / 24%), transparent 60%), - radial-gradient(circle at 80% 80%, rgb(181 140 255 / 22%), transparent 60%), #0a0f1c; - border-block-end: 1px solid var(--neon-border-soft); - border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; - height: 140px; - flex-shrink: 0; - margin: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.pet-avatar { - font-size: 3rem; - line-height: 1; - filter: drop-shadow(0 0 12px rgb(181 140 255 / 45%)); -} - -/* Pet card header */ -.pet-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-block-end: 0; - gap: var(--spacing-xs); - - & h3 { - margin: 0; - font-family: var(--title-font); - font-size: var(--font-size-base); - font-weight: 700; - letter-spacing: 0.4px; - color: #f6f3ff; - text-shadow: 0 0 10px var(--neon-text-glow); - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - @media (max-width: 480px) { - font-size: var(--font-size-sm); - } - } - - @media (max-width: 480px) { - margin-block-end: 0; - } -} - -/* Rarity Badge */ -.rarity-badge { - position: absolute; - top: 6px; - right: 6px; - color: #fff; - padding: 2px 6px; - border-radius: 2px; - font-size: var(--font-size-xs); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - box-shadow: inset 0 0 12px rgb(255 255 255 / 14%), 0 0 14px rgb(255 255 255 / 22%); - backdrop-filter: blur(4px); - - @media (max-width: 480px) { - top: 6px; - right: 6px; - } -} - -.level-badge { - position: absolute; - left: 6px; - bottom: 6px; - background: rgb(5 13 30 / 78%); - color: var(--neon-cyan); - border: 1px solid rgb(125 214 255 / 32%); - padding: 2px 8px; - border-radius: 999px; - font-size: var(--font-size-xs); - font-weight: 700; - letter-spacing: 0.4px; - box-shadow: inset 0 0 10px rgb(125 214 255 / 14%), 0 0 10px rgb(125 214 255 / 22%); -} - -.element-tag { - position: absolute; - right: 6px; - bottom: 6px; - background: rgb(5 13 30 / 78%); - color: var(--neon-magenta); - border: 1px solid rgb(255 110 196 / 28%); - padding: 2px 8px; - border-radius: 999px; - font-size: var(--font-size-xs); - font-weight: 700; - box-shadow: inset 0 0 10px rgb(255 110 196 / 12%), 0 0 10px rgb(255 110 196 / 18%); -} - -.skill-badge { - position: absolute; - top: 6px; - left: 6px; - background: rgb(5 13 30 / 78%); - color: var(--neon-amber, #ffcf70); - border: 1px solid rgb(255 207 112 / 32%); - padding: 2px 8px; - border-radius: 999px; - font-size: var(--font-size-xs); - font-weight: 700; - letter-spacing: 0.4px; - box-shadow: inset 0 0 10px rgb(255 207 112 / 12%), 0 0 10px rgb(255 207 112 / 18%); -} - -.pet-main-info { - display: grid; - gap: 6px; - margin-bottom: 8px; - padding: 12px 16px 0; -} - -.pet-dna { - color: rgb(195 210 255 / 60%); - font-size: var(--font-size-xs); - font-weight: 500; - letter-spacing: 0.3px; -} - -.xp-row { - display: flex; - justify-content: space-between; - align-items: center; - font-size: var(--font-size-xs); - color: rgb(195 210 255 / 70%); - letter-spacing: 0.4px; -} - -.pet-record { - display: flex; - align-items: center; - gap: 3px; - font-size: 10px; - font-variant-numeric: tabular-nums; - letter-spacing: 0.3px; - - .record-wins { - color: rgb(125 214 255 / 75%); - font-weight: 700; - } - .record-sep { - color: rgb(195 210 255 / 30%); - } - .record-losses { - color: rgb(195 210 255 / 45%); - font-weight: 600; - } - .record-breeds { - color: rgb(195 210 255 / 35%); - font-style: italic; - margin-left: 4px; - } -} - -.xp-value { - color: var(--neon-cyan); - font-weight: 700; - text-shadow: 0 0 8px rgb(125 214 255 / 32%); -} - -.xp-bar { - height: 6px; - background: rgb(5 13 30 / 78%); - border-radius: 999px; - overflow: hidden; - border: 1px solid var(--neon-border-soft); - box-shadow: inset 0 0 6px rgb(0 0 0 / 35%); -} - -.xp-fill { - height: 100%; - background: var(--neon-gradient-line); - box-shadow: 0 0 10px rgb(181 140 255 / 45%); -} - -.pet-properties { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 6px; - margin-bottom: var(--spacing-xs); - padding: 0 16px; -} - -.property-item { - background: rgb(5 13 30 / 78%); - border: 1px solid rgb(125 214 255 / 18%); - border-radius: 2px; - padding: 6px 4px; - display: grid; - justify-items: center; - gap: 0; - transition: border-color 180ms ease, box-shadow 180ms ease; - - &:hover { - border-color: rgb(181 140 255 / 36%); - box-shadow: 0 0 10px rgb(181 140 255 / 18%); - } -} - -.property-name { - text-transform: capitalize; - color: rgb(195 210 255 / 60%); - font-size: var(--font-size-xs); - line-height: 1.1; -} - -.property-value { - color: #f6f3ff; - font-weight: 700; - font-size: var(--font-size-xs); - line-height: 1.1; - text-shadow: 0 0 6px rgb(181 140 255 / 28%); -} - -.pet-status { - padding: 0 12px; - margin-block-start: var(--spacing-xs); - display: flex; - flex-direction: column; - gap: 4px; - - @media (max-width: 480px) { - margin-block-start: var(--spacing-xs); - } -} - -.status { - padding: 4px 8px; - border-radius: var(--border-radius-sm); - font-size: var(--font-size-xs); - font-weight: 600; - text-align: center; - - &.cooldown { - background: rgb(255 181 67 / 8%); - color: #ffd07b; - border: 1px solid rgb(255 181 67 / 36%); - border-radius: 2px; - text-transform: uppercase; - letter-spacing: 0.6px; - box-shadow: inset 0 0 12px rgb(255 181 67 / 12%), 0 0 12px rgb(255 181 67 / 18%); - text-shadow: 0 0 10px rgb(255 181 67 / 32%); - } - - @media (max-width: 480px) { - padding: 4px 6px; - font-size: var(--font-size-xs); - } -} - -/* Pet actions — anchored to the bottom so cards with/without cooldown align. */ -.pet-actions { - margin-top: auto; - padding: var(--spacing-sm) 16px 14px; - display: flex; - justify-content: center; -} - -.send-button { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border-radius: 2px; - padding: 9px 24px; - font-size: var(--font-size-sm); - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - cursor: pointer; - transition: var(--transition); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); - - /* Default (ready) — emerald: pet is ready for action. */ - color: #9effd4; - border: 1px solid rgb(0 255 157 / 42%); - box-shadow: inset 0 0 12px rgb(0 255 157 / 12%), 0 0 12px rgb(0 255 157 / 18%); - - &.is-ready:hover, - &:hover { - transform: translateY(-1px); - border-color: rgb(0 255 157 / 64%); - box-shadow: inset 0 0 18px rgb(0 255 157 / 20%), 0 0 18px rgb(0 255 157 / 32%); - } - - &:active { - transform: translateY(0); - } - - /* Cooldown — amber: same shape, color signals "not ready yet". */ - &.on-cooldown { - color: #ffd07b; - border-color: rgb(255 181 67 / 44%); - box-shadow: inset 0 0 12px rgb(255 181 67 / 12%), 0 0 12px rgb(255 181 67 / 20%); - - &:hover { - border-color: rgb(255 181 67 / 70%); - box-shadow: inset 0 0 18px rgb(255 181 67 / 22%), 0 0 18px rgb(255 181 67 / 32%); - } - } - - @media (max-width: 480px) { - padding: 8px 18px; - font-size: var(--font-size-sm); - } -} - -@media (prefers-reduced-motion: reduce) { - .pet-card, - .send-button { - transition: none; - transform: none; - } - - .pet-card:hover { - transform: none; - } -} - -/* Modern CSS Features - Container Queries */ -@container pet-collection (max-width: 600px) { - .pet-grid { - grid-template-columns: 1fr; - } -} - -@container pet-card (max-width: 300px) { - .pet-header { - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-sm); - } -} diff --git a/frontend/src/components/pet/collection/pet-gallery/index.module.css b/frontend/src/components/pet/collection/pet-gallery/index.module.css new file mode 100644 index 00000000..8c3be666 --- /dev/null +++ b/frontend/src/components/pet/collection/pet-gallery/index.module.css @@ -0,0 +1,590 @@ +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Idle gallery — stat strip + leaderboard + pet card grid (redesign mock). */ + +.idle { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.idleMessage { + align-items: center; + justify-content: center; + text-align: center; +} + +.prompt { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: rgb(195 210 255 / 75%); + + & h2 { + margin: 0; + font-family: var(--cp-title-font); + font-size: var(--cp-font-size-lg); + letter-spacing: 0.6px; + color: #f6f3ff; + text-shadow: 0 0 12px var(--cp-text-glow); + } + + & p { + margin: 0; + font-size: var(--cp-font-size-sm); + color: rgb(195 210 255 / 60%); + } +} + +/* ── Stat strip ─────────────────────────────────────────────────────────── */ +.stats { + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.stat { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-radius: 10px; + border: 1px solid rgb(101 131 255 / 20%); + background: var(--cp-surface); +} + +.statIcon { + font-size: 22px; + line-height: 1; + flex-shrink: 0; +} + +.statBody { + display: flex; + flex-direction: column; + min-width: 0; +} + +.statValue { + font-family: var(--cp-title-font); + font-size: 22px; + font-weight: 800; + line-height: 1; +} + +.statLabel { + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: rgb(195 210 255 / 50%); + margin-top: 3px; +} + +.cyan { + border-color: rgb(125 214 255 / 20%); + background: linear-gradient(135deg, rgb(125 214 255 / 9%), rgb(181 140 255 / 6%)); +} +.cyan .statValue { + color: var(--cp-cyan); +} +.violet { + border-color: rgb(181 140 255 / 20%); + background: linear-gradient(135deg, rgb(181 140 255 / 9%), rgb(255 123 203 / 6%)); +} +.violet .statValue { + color: var(--cp-violet); +} +.gold { + border-color: rgb(255 215 0 / 20%); + background: linear-gradient(135deg, rgb(255 215 0 / 9%), rgb(255 207 112 / 6%)); +} +.gold .statValue { + color: var(--cp-gold); +} + +/* ── Leaderboard (placeholder) — full-width row, horizontal entries ──────── */ +.leaderboard { + flex-shrink: 0; + overflow: hidden; + padding: 12px 18px; + border-radius: 10px; + border: 1px solid rgb(101 131 255 / 16%); + background: linear-gradient(180deg, rgb(10 16 32 / 90%), rgb(6 10 22 / 95%)); +} + +.leaderboardTitle { + font-family: var(--cp-title-font); + font-size: 8px; + font-weight: 700; + letter-spacing: 1.8px; + text-transform: uppercase; + color: rgb(125 214 255 / 50%); + margin-bottom: 9px; +} + +.leaderboardList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + gap: 8px; +} + +.lbRow { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border-radius: 6px; + background: rgb(5 13 30 / 55%); + border: 1px solid rgb(101 131 255 / 12%); +} + +.lbRow.isMe { + background: rgb(255 215 0 / 8%); + border-color: rgb(255 215 0 / 25%); +} + +.lbRank { + font-size: 10px; + font-weight: 700; + color: rgb(195 210 255 / 55%); + flex-shrink: 0; +} + +.lbRow.isMe .lbRank { + color: var(--cp-gold); +} + +.lbName { + font-size: 11px; + font-weight: 500; + color: rgb(195 210 255 / 78%); + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lbRow.isMe .lbName { + font-weight: 700; + color: #f6f3ff; +} + +.lbTier { + font-size: 9px; + font-weight: 700; + color: rgb(195 210 255 / 55%); + flex-shrink: 0; +} + +/* ── Pet grid ───────────────────────────────────────────────────────────── */ +.petGrid { + flex: 1; + min-height: 0; + overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 14px; + align-content: start; + padding-bottom: 4px; + scrollbar-width: thin; + scrollbar-color: rgb(125 214 255 / 35%) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--cp-cyan), var(--cp-violet)); + border-radius: 3px; + } +} + +/* ── Pet card ───────────────────────────────────────────────────────────── */ +.petCard { + display: flex; + flex-direction: column; + min-height: 280px; + border-radius: 12px; + border: 1px solid rgb(101 131 255 / 22%); + background: var(--cp-surface); + box-shadow: 0 4px 20px rgb(0 0 0 / 45%); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.petCard:hover { + border-color: rgb(181 140 255 / 42%); + box-shadow: 0 8px 32px rgb(0 0 0 / 65%), 0 0 18px rgb(181 140 255 / 10%); +} + +.petCard:focus-within { + outline: 2px solid var(--cp-violet); + outline-offset: 2px; +} + +.rarityBar { + height: 3px; + border-radius: 12px 12px 0 0; + flex-shrink: 0; +} + +.visual { + position: relative; + height: 130px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: radial-gradient(circle at 30% 30%, rgb(181 140 255 / 20%), transparent 60%), #070e1e; + border-bottom: 1px solid rgb(101 131 255 / 12%); +} + +.avatar { + font-size: 3.4rem; + line-height: 1; + animation: cp-float 3.8s ease-in-out infinite; + filter: drop-shadow(0 0 16px rgb(181 140 255 / 50%)); +} + +.rarity { + position: absolute; + top: 8px; + right: 8px; + border: 1px solid; + border-radius: 3px; + padding: 2px 7px; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.9px; + text-transform: uppercase; + background: rgb(5 13 30 / 55%); +} + +.level { + position: absolute; + bottom: 8px; + left: 8px; + background: rgb(5 13 30 / 88%); + color: var(--cp-cyan); + border: 1px solid rgb(125 214 255 / 32%); + border-radius: 999px; + padding: 2px 8px; + font-size: 9px; + font-weight: 700; +} + +.skill { + position: absolute; + top: 8px; + left: 8px; + background: rgb(255 207 112 / 10%); + color: var(--cp-amber); + border: 1px solid rgb(255 207 112 / 28%); + border-radius: 999px; + padding: 2px 7px; + font-size: 9px; + font-weight: 700; + max-width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.info { + padding: 12px 14px 0; + display: flex; + flex-direction: column; + gap: 7px; +} + +.head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} + +.name { + font-family: var(--cp-title-font); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.4px; + color: #f6f3ff; +} + +.petClass { + font-size: 10px; + color: rgb(195 210 255 / 50%); + margin-top: 2px; +} + +.hp { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + background: rgb(15 255 174 / 7%); + border: 1px solid rgb(15 255 174 / 22%); + border-radius: 5px; + padding: 4px 7px; +} + +.hpLabel { + font-size: 8px; + color: rgb(195 210 255 / 45%); +} + +.hpValue { + font-size: 11px; + font-weight: 700; + color: var(--cp-emerald); +} + +.xpRow { + display: flex; + justify-content: space-between; + margin-bottom: 4px; +} + +.xpLabel { + font-size: 8px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: rgb(125 214 255 / 50%); +} + +.xpValue { + font-size: 10px; + font-weight: 700; + color: var(--cp-cyan); +} + +.xpTrack { + height: 5px; + background: var(--cp-inset); + border-radius: 999px; + overflow: hidden; +} + +.xpFill { + height: 100%; + background: var(--cp-gradient-line); + border-radius: 999px; + box-shadow: 0 0 8px rgb(181 140 255 / 50%); +} + +.record { + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.wins { + color: rgb(125 214 255 / 82%); + font-weight: 700; +} +.sep { + color: rgb(195 210 255 / 25%); +} +.losses { + color: rgb(195 210 255 / 50%); + font-weight: 600; +} +.dot { + color: rgb(195 210 255 / 20%); + margin: 0 2px; +} +.wr { + color: rgb(195 210 255 / 38%); +} + +.cardStats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + padding: 10px 14px 0; +} + +.statTile { + background: rgb(5 13 30 / 70%); + border: 1px solid rgb(101 131 255 / 12%); + border-radius: 4px; + padding: 5px 4px; + text-align: center; +} + +.tileLabel { + font-size: 8px; + text-transform: uppercase; + color: rgb(195 210 255 / 40%); +} + +.tileValue { + font-size: 12px; + font-weight: 700; + color: #f6f3ff; + margin-top: 1px; +} + +.status { + padding: 8px 14px 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.cooldown { + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-align: center; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgb(255 181 67 / 8%); + color: #ffd07b; + border: 1px solid rgb(255 181 67 / 36%); +} + +.actions { + margin-top: auto; + padding: 10px 14px 14px; + display: flex; + gap: 8px; +} + +.battleBtn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px; + border-radius: 5px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--cp-magenta); + border: 1px solid rgb(255 123 203 / 32%); + background: linear-gradient(135deg, rgb(255 123 203 / 10%), rgb(181 140 255 / 10%)); + cursor: pointer; + transition: all 0.2s ease; +} + +.battleBtn:hover { + background: linear-gradient(135deg, rgb(255 123 203 / 20%), rgb(181 140 255 / 15%)); + box-shadow: 0 0 16px rgb(255 123 203 / 28%); + border-color: rgb(255 123 203 / 32%); +} + +.sendBtn { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 38px; + padding: 8px; + border-radius: 5px; + cursor: pointer; + transition: all 0.2s ease; + background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); + color: #9effd4; + border: 1px solid rgb(0 255 157 / 42%); +} + +.sendBtn.onCooldown { + color: #ffd07b; + border-color: rgb(255 181 67 / 44%); +} + +.sendBtn:hover { + transform: translateY(-1px); + box-shadow: 0 0 12px rgb(0 255 157 / 24%); +} + +/* ── Summon tile ────────────────────────────────────────────────────────── */ +.summonTile { + min-height: 280px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + border-radius: 12px; + border: 1px dashed rgb(125 214 255 / 18%); + background: rgb(5 13 30 / 40%); + cursor: pointer; + transition: all 0.2s ease; +} + +.summonTile:hover { + background: rgb(125 214 255 / 4%); + border-color: rgb(125 214 255 / 38%); + box-shadow: none; +} + +.summonPlus { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + color: rgb(125 214 255 / 45%); + background: rgb(125 214 255 / 9%); + border: 1px solid rgb(125 214 255 / 22%); +} + +.summonLabel { + font-size: 12px; + font-weight: 600; + color: rgb(125 214 255 / 45%); +} + +/* Local copy of the shared float keyframe: the global cp-float in + styles/animations.css is still used by 4 non-module stylesheets, so a module + reference can't resolve to it — CSS Modules scope animation-name. */ +@keyframes cp-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-9px); + } +} + +@media (prefers-reduced-motion: reduce) { + .petCard, + .sendBtn { + transition: none; + } + .avatar { + animation: none; + } +} + +@media (max-width: 640px) { + .stats { + grid-template-columns: 1fr; + } + .leaderboardList { + flex-direction: column; + } + .petGrid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/components/pet/collection/pet-gallery/index.tsx b/frontend/src/components/pet/collection/pet-gallery/index.tsx index 877eed51..bd0811ab 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.tsx +++ b/frontend/src/components/pet/collection/pet-gallery/index.tsx @@ -1,54 +1,72 @@ -import React, { useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import clsx from 'clsx'; import { getGeneration, - getPropertyEmoji, - getXpNumbers, - getXpPercent, + getLifePercent, getPetAvatar, getPetClass, - getPetElement, getPetProperties, getPetSkill, getRarityColor, getRarityName, + getXpNumbers, + getXpPercent, useChainCapabilities, usePetList, type Pet, } from '@shared/core'; +import { BATTLE_PATH } from '@constants/interactionRoutes'; import { Tones } from '@constants/tones'; -import Icon, { - CloseIcon, - CrystalIcon, - DragonIcon, - EggIcon, - MagicIcon, - PawIcon, - SendIcon, - SparklesIcon, -} from '@components/ui/icon'; +import Icon, { BattleIcon, CloseIcon, PawIcon, SendIcon } from '@components/ui/icon'; import NeonButton from '@components/ui/neon-button'; import CreatePetModal from '@components/pet/creation/create-pet-modal'; -import PetCollectionLayout from '@components/pet/collection/pet-collection-layout'; import SendPetModal from '@components/pet/transfer/send-pet-modal'; import { useNotifyError } from '@hooks/useNotifyError'; import { usePetCooldowns } from '@hooks/usePetCooldowns'; -import './index.css'; +import styles from './index.module.css'; + +/** Placeholder leaderboard rows — pending real ranking data (plan §8 Q3). */ +const LEADERBOARD_PLACEHOLDER = [ + { rank: 1, name: 'CryptoKing', wins: 842, tier: 'Diamond', me: false }, + { rank: 2, name: 'DragonMstr', wins: 721, tier: 'Diamond', me: false }, + { rank: 3, name: 'You', wins: 649, tier: 'Gold', me: true }, + { rank: 4, name: 'PetLegend', wins: 511, tier: 'Gold', me: false }, + { rank: 5, name: 'BreedKing', wins: 402, tier: 'Platinum', me: false }, +] as const; + +/** Four stat tiles derived from the pet's DNA properties. AGI has no backing in + * the data model, so the fourth tile shows VIT (life); see plan §8 Q2. */ +const petStatTiles = (pet: Pet): { label: string; value: number }[] => { + const p = getPetProperties(pet); + return [ + { label: 'STR', value: p.attack }, + { label: 'INT', value: p.intelligence }, + { label: 'DEF', value: p.defense }, + { label: 'VIT', value: p.life }, + ]; +}; + +const winRatio = (pet: Pet): number => { + const total = pet.winCount + pet.lossCount; + return total === 0 ? 0 : Math.round((pet.winCount / total) * 100); +}; const PetGallery: React.FC = () => { + const navigate = useNavigate(); const { isConnected } = useChainCapabilities(); const { pets, isLoading, error, refetch } = usePetList(); const notifyError = useNotifyError(); - const [loading, setLoading] = useState(false); const [sendModalOpen, setSendModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [sendSelection, setSendSelection] = useState<{ pet: Pet; petId: bigint } | null>(null); - // Owns the 1s tick and the per-pet readiness math so the view stays declarative. const { statusFor } = usePetCooldowns(pets); - useEffect(() => { - setLoading(isLoading); - }, [isLoading]); + const totalWins = useMemo( + () => pets.reduce((sum, pet) => sum + (pet.winCount ?? 0), 0), + [pets], + ); useEffect(() => { if (!error) return; @@ -67,236 +85,227 @@ const PetGallery: React.FC = () => { if (!isConnected) { return ( - - - Your Pet Collection - - } - description="Connect your wallet to view your pets" - /> +
+
+ +

Your Pet Collection

+

Connect your wallet to view your pets.

+
+
); } return ( - <> - - - Your Pets - - } - actions={ - - } - > - {loading && ( -
-
-

Loading your pets...

+
+ {/* Stat strip — Pets & Wins are real; Global rank is a placeholder */} +
+
+ + 🐾 + +
+
{pets.length}
+
Pets
- )} - - {error && ( -
-

- - Failed to load pet data. Please try again. -

- refetch()}> - Try Again - +
+
+ + ⚔ + +
+
{totalWins}
+
Wins
- )} - - {!loading && !error && pets.length === 0 && ( -
-
- - - - - - - - - - - - - - - - - - -
-
-

Awaken your first companion

-

Step into the altar — name a pet and bring it to life.

-
- setCreateModalOpen(true)}> - - Create your first pet - +
+
+ + 🏆 + +
+
#3
+
Global Rank
- )} +
+
- {!loading && !error && pets.length > 0 && ( -
- {pets.map((pet) => { - const cd = statusFor(pet); - return ( -
-
-
- {getRarityName(pet.rarity)} -
-
{getPetElement(pet.dna)}
- {getPetSkill(pet.speciesId) ? ( -
- {getPetSkill(pet.speciesId)?.name} -
- ) : null} -
{getPetAvatar(pet.dna)}
-
Lv. {pet.level}
+ {/* Leaderboard — full-width row below the stats (placeholder ranking data) */} +
+
🏆 Leaderboard
+
    + {LEADERBOARD_PLACEHOLDER.map((row) => ( +
  • + #{row.rank} + {row.name} + {row.tier} +
  • + ))} +
+
+ + {isLoading && ( +
+
+

Loading your pets...

+
+ )} + + {error && !isLoading && ( +
+

+ + Failed to load pet data. Please try again. +

+ refetch()}> + Try Again + +
+ )} + + {!isLoading && !error && ( +
+ {pets.map((pet) => { + const cd = statusFor(pet); + const rarityColor = getRarityColor(pet.rarity); + const xp = getXpNumbers(pet); + const skill = getPetSkill(pet.speciesId); + return ( +
+
+
+
+ {getRarityName(pet.rarity)}
+
Lv. {pet.level}
+ {skill ? ( +
+ {skill.name} +
+ ) : null} +
{getPetAvatar(pet.dna)}
+
-
-
-

{pet.name}

- +
+
+
+
{pet.name}
+
{getPetClass(pet.dna)} · Gen{' '} {pet.generation ?? getGeneration(pet.dna)} +
+
+
+ HP + + {getLifePercent(pet)}%
-
- XP - - {getXpNumbers(pet).xpCurrent}/ - {getXpNumbers(pet).xpMax} +
+ +
+
+ XP + + {xp.xpCurrent}/{xp.xpMax}
-
+
- {(pet.winCount > 0 || - pet.lossCount > 0 || - (pet.breedCount != null && pet.breedCount > 0)) && ( -
- {pet.winCount}W - / - - {pet.lossCount}L - - {pet.breedCount != null && pet.breedCount > 0 && ( - - · {pet.breedCount} bred - - )} -
- )}
-
- {Object.entries(getPetProperties(pet)).map( - ([key, value]) => ( -
- - {getPropertyEmoji(key)} - - {value} -
- ), - )} +
+ {pet.winCount}W + / + {pet.lossCount}L + · + {winRatio(pet)}% WR
+
- {cd.onCooldown && ( -
- {cd.battleOnCooldown && ( -
- ⚔️ Battle ready in {cd.battleLabel} -
- )} - {cd.breedOnCooldown && ( -
- 🥚 Breed ready in {cd.breedLabel} -
- )} - {cd.trainOnCooldown && ( -
- 💪 Train ready in {cd.trainLabel} -
- )} +
+ {petStatTiles(pet).map((tile) => ( +
+
{tile.label}
+
{tile.value}
- )} + ))} +
-
- + {cd.onCooldown && ( +
+ {cd.battleOnCooldown && ( +
+ ⚔️ Battle ready in {cd.battleLabel} +
+ )} + {cd.breedOnCooldown && ( +
+ 🥚 Breed ready in {cd.breedLabel} +
+ )} + {cd.trainOnCooldown && ( +
+ 💪 Train ready in {cd.trainLabel} +
+ )}
+ )} + +
+ +
- ); - })} -
- )} - +
+ ); + })} + + +
+ )} {sendModalOpen && sendSelection && ( { )} setCreateModalOpen(false)} /> - +
); }; diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.css b/frontend/src/components/pet/creation/create-pet-modal/index.module.css similarity index 73% rename from frontend/src/components/pet/creation/create-pet-modal/index.css rename to frontend/src/components/pet/creation/create-pet-modal/index.module.css index 92b813f8..7b96d309 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.css +++ b/frontend/src/components/pet/creation/create-pet-modal/index.module.css @@ -1,20 +1,21 @@ -/* Body content only — the overlay/dialog/header/close chrome comes from . +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Body content only — the overlay/dialog/header/close chrome comes from . Tokens: frontend/src/styles/variables.css */ -.create-pet-body { - --focus-ring: 0 0 0 2px var(--neon-cyan), 0 0 12px rgb(125 214 255 / 32%); +.createPetBody { + --focus-ring: 0 0 0 2px var(--cp-cyan), 0 0 12px rgb(125 214 255 / 32%); & > p { - margin: 0 0 var(--spacing-xl); + margin: 0 0 var(--cp-spacing-xl); color: rgb(195 210 255 / 70%); - font-size: var(--font-size-base); + font-size: var(--cp-font-size-base); line-height: 1.55; } .form { display: flex; flex-direction: column; - gap: var(--spacing-lg); + gap: var(--cp-spacing-lg); } .field { @@ -39,7 +40,7 @@ letter-spacing: 0.25px; background: rgb(4 10 26 / 96%); color: var(--cp-text-body-dark); - caret-color: var(--neon-cyan); + caret-color: var(--cp-cyan); transition: border-color 0.2s ease, box-shadow 0.2s ease; outline: none; box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), inset 0 1px 0 rgb(125 214 255 / 8%); @@ -49,7 +50,7 @@ } &:focus { - border-color: var(--neon-cyan); + border-color: var(--cp-cyan); box-shadow: var(--focus-ring); } @@ -60,7 +61,7 @@ @media (max-width: 768px) { padding: 10px 12px; - font-size: var(--font-size-sm); + font-size: var(--cp-font-size-sm); } } } diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.tsx b/frontend/src/components/pet/creation/create-pet-modal/index.tsx index 31bbeb4a..8be54970 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.tsx +++ b/frontend/src/components/pet/creation/create-pet-modal/index.tsx @@ -8,7 +8,7 @@ import NeonModal from '@components/ui/neon-modal'; import TransactionStatus from '@components/common/transaction-status'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; -import './index.css'; +import styles from './index.module.css'; interface CreatePetModalProps { isOpen: boolean; @@ -108,15 +108,15 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => { Create Your First Pet } - contentClassName="create-pet-body" + contentClassName={styles.createPetBody} >

Give your pet a unique name and bring it to life! You can only create one pet initially — breed to grow your collection!

-
-
+
+
` sets a wash + accent; the shared chrome + below left-aligns the title bar to match the redesign mock. + ============================================================================= */ +.interaction-standalone--battle { + --view-wash: var(--cp-wash-battle); + --view-accent: 255 123 203; +} +.interaction-standalone--breed { + --view-wash: var(--cp-wash-breed); + --view-accent: 255 207 112; +} +.interaction-standalone--levelup { + --view-wash: var(--cp-wash-levelup); + --view-accent: 181 140 255; +} +.interaction-standalone--train { + --view-wash: var(--cp-wash-train); + --view-accent: 125 214 255; +} +.interaction-standalone--marriage { + --view-wash: var(--cp-wash-marriage); + --view-accent: 255 123 203; +} +.interaction-standalone--changename { + --view-wash: var(--cp-wash-rename); + --view-accent: 125 214 255; +} - & h3 { - margin-bottom: 8px; - } +/* Wash sits behind the panel content instead of the flat card gradient. */ +.dashboard-panel.pet-interactions.interaction-standalone .surface { + background: var(--view-wash, var(--pc-surface-bg)); + border-color: rgb(var(--view-accent, 101 131 255) / 22%); +} - .sub { - margin: 0 !important; - font-size: 0.95rem; - color: var(--zi-text-muted); +/* Left-aligned header: ← Back · accent-glow title · subtitle, with an accent rule. */ +.dashboard-panel.pet-interactions.interaction-standalone .title-bar { + justify-content: flex-start; + gap: 14px; + padding-block-end: 12px; + margin-block-end: var(--pc-spacing-md); + border-block-end: 1px solid rgb(var(--view-accent, 101 131 255) / 16%); +} + +.dashboard-panel.pet-interactions.interaction-standalone .title-bar .intro { + width: auto; + text-align: start; +} + +.dashboard-panel.pet-interactions.interaction-standalone .title-bar .heading { + text-shadow: 0 0 14px rgb(var(--view-accent, 185 160 255) / 50%); +} + +.dashboard-panel.pet-interactions.interaction-standalone .title-bar .panel-back { + position: static; + transform: none; + border: 1px solid rgb(101 131 255 / 22%); + border-radius: 5px; + padding: 6px 12px; + font-weight: 600; + + &:hover { + border-color: rgb(101 131 255 / 50%); } } +/* Keep the centered layout for the connect/empty state cards (no back-nav flow). */ +.dashboard-panel.pet-interactions.interaction-standalone.wallet-disconnected .title-bar { + justify-content: center; + border-block-end: none; + padding-block-end: 0; +} + +/* Primary CTA — adopt the mock's large Orbitron accent button, retinted to the + per-view accent by overriding the NeonButton tone vars. */ +.dashboard-panel.pet-interactions.interaction-standalone .action-controls .neon-btn { + --btn-text: rgb(var(--view-accent, 125 214 255) / 100%); + --btn-border: rgb(var(--view-accent, 125 214 255) / 55%); + --btn-glow: rgb(var(--view-accent, 125 214 255) / 26%); + --btn-inner: rgb(var(--view-accent, 125 214 255) / 14%); + font-family: var(--cp-title-font); + letter-spacing: 2px; + border-radius: 8px; +} + +.dashboard-panel.pet-interactions.interaction-standalone .action-controls .neon-btn.size-md { + padding: 14px 40px; + font-size: 0.9rem; +} + .help-text { font-style: italic; color: #888; @@ -212,7 +294,7 @@ letter-spacing: 0.25px; background: rgb(4 10 26 / 96%); color: var(--zi-text, #f6f3ff); - caret-color: var(--neon-cyan); + caret-color: var(--cp-cyan); transition: border-color 0.2s ease, box-shadow 0.2s ease; box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), inset 0 1px 0 rgb(125 214 255 / 8%); @@ -227,55 +309,9 @@ &:focus { outline: none; - border-color: var(--neon-cyan); - box-shadow: var(--zi-focus, 0 0 0 2px var(--neon-cyan)); - } - } -} - -.win-estimate { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 8px 16px; - margin-block: 8px 0; - font-size: 12px; - letter-spacing: 0.4px; - min-height: 30px; - - .win-estimate-label { - color: rgb(195 210 255 / 50%); - text-transform: uppercase; - font-weight: 600; - } - - .win-estimate-value { - font-size: 20px; - font-weight: 800; - letter-spacing: -0.5px; - line-height: 1; - - &.favorable { - color: rgb(125 214 255); - text-shadow: 0 0 12px rgb(125 214 255 / 50%); + border-color: var(--cp-cyan); + box-shadow: var(--zi-focus, 0 0 0 2px var(--cp-cyan)); } - &.unfavorable { - color: rgb(255 130 130); - text-shadow: 0 0 12px rgb(255 130 130 / 40%); - } - } - - .win-estimate-samples { - color: rgb(195 210 255 / 30%); - font-size: 10px; - margin-inline-start: 2px; - } - - .win-estimate-loading, - .win-estimate-unavailable { - color: rgb(195 210 255 / 30%); - font-style: italic; } } @@ -301,7 +337,7 @@ /* Secondary / ghost action (e.g. battle Cancel) — not a NeonButton tone. */ .cancel-button { padding: 12px 24px; - border: 1px solid var(--neon-border-soft); + border: 1px solid var(--cp-border-soft); border-radius: 2px; font-size: 14px; font-weight: 600; @@ -314,7 +350,7 @@ &:hover { color: #f6f3ff; - border-color: var(--neon-border-strong); + border-color: var(--cp-border-strong); transform: translateY(-1px); box-shadow: 0 0 12px rgb(125 214 255 / 22%); } diff --git a/frontend/src/components/pet/interactions/overview/index.css b/frontend/src/components/pet/interactions/overview/index.css deleted file mode 100644 index 842a3570..00000000 --- a/frontend/src/components/pet/interactions/overview/index.css +++ /dev/null @@ -1,252 +0,0 @@ -/* Dashboard interactions hub (PetInteractions) — hub-card styles only. - Shared tokens/primitives live in ../interactions.css; surface/title-bar styles - live in common/dashboard-panel/index.css. */ - -.action-buttons { - display: flex; - flex-direction: column; - gap: 10px; - flex: 1 1 auto; - min-height: 0; - min-width: 0; - width: 100%; - max-width: none; - margin: 0 auto; - - @media (max-width: 480px) { - gap: 10px; - } - - /* Each hub card shares the column height equally so the panel never scrolls. */ - & > .breeding-lab-card, - & > .battle-arena-card, - & > .feature-action-card { - flex: 1 1 0; - min-height: 0; - min-width: 0; - } -} - -/* Shared by breeding, battle, and feature hub cards */ -.hub-divider { - height: 1px; - background: var(--zi-border); -} - -.breeding-lab-card { - background: var(--zi-surface-bg); - border: 1px solid var(--zi-border); - border-radius: 12px; - padding: 10px; - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - - .header { - font-size: 14px; - font-weight: 700; - color: var(--zi-text); - text-align: left; - } - - .content { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - display: grid; - /* `minmax(0, 1fr)` lets the columns shrink below their intrinsic - (nowrap text) min size so the card never pushes the panel wider. */ - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - gap: 8px; - align-items: center; - - @media (max-width: 768px) { - grid-template-columns: 1fr; - } - } - - .parent-item { - background: var(--zi-card-bg); - border: 1px solid var(--zi-border); - border-radius: 8px; - padding: 8px; - display: flex; - flex-direction: column; - gap: 2px; - text-align: left; - min-width: 0; - } - - .parent-name { - font-size: 12px; - font-weight: 700; - color: var(--zi-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .parent-meta { - font-size: 11px; - color: var(--zi-text-muted); - } - - .egg { - font-size: 22px; - filter: drop-shadow(0 1px 4px rgba(255, 200, 0, 0.45)); - - @media (max-width: 768px) { - justify-self: center; - } - } -} - -/* Hub card action button — layout only; colour/glow come from . */ -.hub-action { - flex: 0 0 auto; -} - -.battle-arena-card { - background: var(--zi-surface-bg); - border: 1px solid var(--zi-border); - border-radius: 12px; - padding: 10px; - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - - .header { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 14px; - font-weight: 700; - color: var(--zi-text); - - .left-badge { - background: rgb(255 181 67 / 10%); - color: #ffd07b; - border: 1px solid rgb(255 181 67 / 44%); - border-radius: 999px; - padding: 2px 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.4px; - text-transform: uppercase; - box-shadow: 0 0 8px rgb(255 181 67 / 22%); - } - } - - .content { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - gap: 8px; - align-items: center; - - .pet-item { - background: var(--zi-card-bg); - border: 1px solid var(--zi-border); - border-radius: 8px; - padding: 8px; - display: grid; - gap: 6px; - min-width: 0; - - .pet-name { - font-size: 12px; - font-weight: 700; - color: var(--zi-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .life-track { - height: 7px; - background: rgb(5 13 30 / 78%); - border: 1px solid rgb(0 255 157 / 18%); - border-radius: 999px; - overflow: hidden; - - .life-fill { - height: 100%; - background: linear-gradient(90deg, #0fffae, #9effd4); - box-shadow: 0 0 10px rgb(0 255 157 / 45%); - } - } - } - - .center { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - - .icon { - width: 30px; - height: 30px; - border-radius: 50%; - background: radial-gradient( - circle, - rgb(255 110 196 / 35%), - rgb(255 110 196 / 8%) 70% - ); - border: 1px solid rgb(255 110 196 / 60%); - display: flex; - align-items: center; - justify-content: center; - color: var(--neon-magenta); - box-shadow: inset 0 0 10px rgb(255 110 196 / 24%), 0 0 14px rgb(255 110 196 / 32%); - } - - .vs { - font-size: 11px; - font-weight: 700; - color: var(--zi-text-muted); - } - } - - @media (max-width: 768px) { - grid-template-columns: 1fr; - } - } -} - -.feature-action-card { - background: var(--zi-surface-bg); - border: 1px solid var(--zi-border); - border-radius: 12px; - padding: 10px; - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - - .header { - font-size: 14px; - font-weight: 700; - color: var(--zi-text); - text-align: left; - } - - .content { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - justify-content: center; - font-size: 12px; - line-height: 1.35; - color: var(--zi-text-muted); - background: var(--zi-card-bg); - border: 1px solid var(--zi-border); - border-radius: 8px; - padding: 10px; - text-align: left; - } -} diff --git a/frontend/src/components/pet/interactions/overview/index.tsx b/frontend/src/components/pet/interactions/overview/index.tsx deleted file mode 100644 index 8083a21c..00000000 --- a/frontend/src/components/pet/interactions/overview/index.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { - getLifePercent, - getReadyPetsUnified, - useChainCapabilities, - useFees, - useOpponents, - usePetList, -} from '@shared/core'; -import type { InteractionAction } from '@constants/interactionRoutes'; -import { - BATTLE_PATH, - BREED_PATH, - DASHBOARD_HOME, - LEVELUP_PATH, - TRAIN_PATH, - MARRIAGE_PATH, - RENAME_PATH, -} from '@constants/interactionRoutes'; -import { Tones } from '@constants/tones'; -import Icon, { - BattleIcon, - EggIcon, - LevelUpIcon, - MarriageIcon, - QuillIcon, - TrainIcon, -} from '@components/ui/icon'; -import DashboardPanel from '@components/common/dashboard-panel'; -import NeonButton from '@components/ui/neon-button'; -import BattlePanel from '@components/pet/interactions/panels/battle'; -import BreedPanel from '@components/pet/interactions/panels/breed'; -import LevelUpPanel from '@components/pet/interactions/panels/level-up'; -import TrainPanel from '@components/pet/interactions/panels/train'; -import MarriagePanel from '@components/pet/interactions/panels/marriage'; -import RenamePanel from '@components/pet/interactions/panels/rename'; -import StateCard from '@components/pet/interactions/state-card'; -import '../interactions.css'; -import './index.css'; - -/** Map `interactions/:action` segment (e.g. `rename`) to internal action id. */ -const parseActionParam = (raw: string | undefined): InteractionAction | null => { - if (!raw) return null; - if (raw === 'rename') return 'changename'; - if ( - raw === 'breed' || - raw === 'battle' || - raw === 'levelup' || - raw === 'train' || - raw === 'marriage' - ) - return raw; - return null; -}; - -/** - * Dashboard interactions hub (`/main`). - * Standalone `/breed` … `/rename` are separate router entries + `InteractionStandalone`. - */ -const PetInteractions: React.FC = () => { - const navigate = useNavigate(); - const { action: actionParam } = useParams<{ action?: string }>(); - const capabilities = useChainCapabilities(); - const { isConnected } = capabilities; - const { pets, isLoading } = usePetList(); - - const action = useMemo(() => parseActionParam(actionParam), [actionParam]); - const readyPets = useMemo(() => getReadyPetsUnified(pets), [pets]); - - const activeChainKind = capabilities.activeKind; - - const fees = useFees(); - const trainFeeLabel = - fees.trainFee != null - ? `From ${fees.formatAmount(fees.trainFee)} — cost scales with level.` - : "Cost scales with the pet's level."; - - // Preview an on-chain rival for the Battle Arena card (opponents come from - // the roster, not a second owned pet). - const { opponents } = useOpponents({ chain: activeChainKind }); - - useEffect(() => { - if (actionParam !== undefined && actionParam !== '' && action === null) { - navigate(DASHBOARD_HOME, { replace: true }); - } - }, [actionParam, action, navigate]); - - if (!isConnected) { - return ( - - - Pet Interactions - - } - description="Connect your wallet to interact with your pets" - /> - ); - } - - if (isLoading && pets.length === 0) { - return ( - - - Pet Interactions - - } - > -
-
-

Loading your pets...

-
-
- ); - } - - if (pets.length === 0) { - return ( - - - Pet Interactions - - } - description="You don't have any pets yet." - helpText="Go to the dashboard and create your first pet." - /> - ); - } - - const previewParentA = readyPets[0]?.pet; - const previewParentB = readyPets[1]?.pet; - // Closest-level opponent to the fighter, for the Battle Arena VS preview. - const previewOpponent = - opponents.length > 0 - ? [...opponents].sort( - (a, b) => - Math.abs(a.level - (previewParentA?.level ?? a.level)) - - Math.abs(b.level - (previewParentA?.level ?? b.level)), - )[0] - : undefined; - const availableBattles = Math.min(3, readyPets.length > 0 ? 3 : 0); - // Battle only needs one ready pet — the opponent comes from the on-chain roster. - const battleDisabledHint = readyPets.length < 1 ? 'You need a ready pet to battle' : undefined; - - return ( - - - Pet Interactions - - } - > - {!action && ( -
-
-
- - Breeding Lab -
-
-
-
- - {previewParentA?.name ?? 'Parent A'} - - - {previewParentA ? `Lv.${previewParentA.level}` : 'Select'} - -
-
- -
-
- - {previewParentB?.name ?? 'Parent B'} - - - {previewParentB ? `Lv.${previewParentB.level}` : 'Select'} - -
-
- navigate(BREED_PATH)} - disabled={pets.length < 1} - > - Start breeding - -
-
-
- - - Battle Arena - - {availableBattles} left -
-
-
-
- - {previewParentA?.name ?? 'Fighter A'} - -
-
-
-
-
-
- -
-
VS
-
-
- - {previewOpponent?.name ?? 'On-chain rival'} - -
-
-
-
-
- navigate(BATTLE_PATH)} - disabled={readyPets.length < 1} - title={battleDisabledHint} - > - Start battle - -
-
-
- - Level Up -
-
-
- Boost your pet stats by leveling up. -
- {capabilities.levelUpFee - ? `From ${capabilities.levelUpFee.amount} ${capabilities.levelUpFee.symbol} — cost rises with your pet's level.` - : 'Costs a small SOL fee per level.'} -
- navigate(LEVELUP_PATH)} - disabled={readyPets.length < 1} - > - Open level up - -
-
-
- - Training Ground -
-
-
- Train your pet for an instant XP boost. -
- {trainFeeLabel} -
- navigate(TRAIN_PATH)} - disabled={readyPets.length < 1} - > - Open training - -
-
-
- - Marriage -
-
-
- Marry two pets to unlock cross-owner breeding. -
- Propose, accept, or divorce. -
- navigate(MARRIAGE_PATH)} - disabled={pets.length < 1} - > - Open marriage - -
-
-
- - Change Name -
-
-
- Rename your pet. -
- {capabilities.renameMinLevel > 1 - ? `Requires level ${capabilities.renameMinLevel} or higher.` - : 'Pick a new identity for your companion.'} -
- navigate(RENAME_PATH)} - disabled={readyPets.length < 1} - > - Open rename - -
-
- )} - - {action === 'breed' && } - - {action === 'battle' && } - - {action === 'levelup' && } - - {action === 'train' && } - - {action === 'marriage' && } - - {action === 'changename' && } - - ); -}; - -export default PetInteractions; diff --git a/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.module.css b/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.module.css new file mode 100644 index 00000000..6e822a94 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.module.css @@ -0,0 +1,52 @@ +/* CSS Module — class names are local; reference via `import styles from './pet-showcase.module.css'`. + Shared ring-avatar hero (level-up, rename). Accent via --sc-accent (rgb triple), + set inline on the root element. */ +.root { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-bottom: 18px; + text-align: center; +} + +.hero { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.avatar { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 98px; + height: 98px; + font-size: 3.5rem; + line-height: 1; + border-radius: 18px; + animation: cp-float 3.6s ease-in-out infinite; + filter: drop-shadow(0 0 28px rgb(var(--sc-accent) / 80%)); +} + +/* Local copy of the shared float keyframe (kept in-module so the scoped + animation-name resolves). */ +@keyframes cp-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-9px); + } +} + +@media (prefers-reduced-motion: reduce) { + .avatar { + animation: none; + } +} diff --git a/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.tsx b/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.tsx new file mode 100644 index 00000000..e97f44d0 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/_shared/pet-showcase.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import styles from './pet-showcase.module.css'; + +export type ShowcaseAccent = 'violet' | 'cyan'; + +const ACCENT_RGB: Record = { + violet: '181 140 255', + cyan: '125 214 255', +}; + +export type PetShowcaseProps = { + /** Rendered inside the ring hero (typically the pet's emoji avatar). */ + avatar: React.ReactNode; + accent: ShowcaseAccent; + /** Panel-specific content rendered below the hero (name, badges, XP, …). */ + children?: React.ReactNode; +}; + +/** + * Avatar hero shared by the level-up and rename panels: a floating avatar + * tinted by `accent`. Panel-specific details (level transition, live name + * preview, requirements) are passed as children and rendered beneath the hero. + */ +const PetShowcase: React.FC = ({ avatar, accent, children }) => ( +
+
+ {avatar} +
+ {children} +
+); + +export default PetShowcase; diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css b/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css index 06ee18c6..fb03166e 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css +++ b/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css @@ -31,7 +31,7 @@ max-width: 85%; padding: 8px 10px; border-radius: 10px; - border: 1px solid var(--neon-border-soft); + border: 1px solid var(--cp-border-soft); background: rgb(5 13 30 / 78%); animation: battle-dialogue-in 0.25s ease; diff --git a/frontend/src/components/pet/interactions/panels/battle/index.css b/frontend/src/components/pet/interactions/panels/battle/index.css deleted file mode 100644 index d3bbdfb1..00000000 --- a/frontend/src/components/pet/interactions/panels/battle/index.css +++ /dev/null @@ -1,988 +0,0 @@ -/* Battle setup — arena VS header + card pickers (scoped under `.battle-setup`). */ - -.dashboard-panel.pet-interactions .interface.battle-setup { - display: flex; - flex-direction: column; - gap: 16px; - padding: 16px; - min-width: 0; - max-width: 100%; - overflow-x: clip; - /* Fill the panel height so the stage stretches instead of hugging the top. */ - flex: 1 1 auto; - min-height: 0; -} - -.battle-setup-arena { - margin: 0; - position: relative; - overflow: hidden; - - /* Card shell — directional fighter/opponent tint with deep dark base */ - background: linear-gradient( - 90deg, - rgb(125 214 255 / 5%) 0%, - transparent 38%, - transparent 62%, - rgb(255 110 196 / 5%) 100% - ), - radial-gradient(ellipse 100% 120% at 50% 110%, rgb(255 110 196 / 9%), transparent 55%), - linear-gradient(170deg, rgb(11 18 40 / 97%), rgb(5 9 22 / 99%)); - border: 1px solid rgb(255 110 196 / 26%); - border-radius: 16px; - padding: 14px; - display: flex; - flex-direction: column; - gap: 12px; - box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(255 110 196 / 4%), - 0 0 24px rgb(255 110 196 / 8%), 0 8px 32px rgb(0 0 0 / 30%); - - /* Suppress the hub-divider — gap handles spacing */ - .hub-divider { - display: none; - } - - .header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - font-size: 13px; - font-weight: 700; - color: var(--zi-text); - } - - .arena-actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; - } - - .arena-badge { - background: rgb(255 110 196 / 10%); - color: #ff9ad6; - border: 1px solid rgb(255 110 196 / 40%); - border-radius: 999px; - padding: 3px 10px; - font-size: 10px; - font-weight: 800; - letter-spacing: 0.6px; - text-transform: uppercase; - box-shadow: 0 0 10px rgb(255 110 196 / 18%), inset 0 0 8px rgb(255 110 196 / 6%); - white-space: nowrap; - } - - .arena-slot { - background: rgb(4 8 20 / 82%); - border: 1px solid var(--zi-border); - border-radius: 12px; - padding: 10px; - display: grid; - gap: 8px; - min-width: 0; - max-width: 100%; - width: 100%; - min-height: 88px; - box-sizing: border-box; - overflow: hidden; - transition: border-color 0.2s ease, box-shadow 0.2s ease; - - &.is-empty { - border-style: dashed; - align-content: center; - justify-items: center; - text-align: center; - background: rgb(4 8 18 / 55%); - - .slot-placeholder { - animation: battle-slot-pulse 2.4s ease-in-out infinite; - } - } - - &.is-flash { - animation: battle-slot-flash 0.52s ease; - } - - /* Fighter (left) = cyan identity */ - &.arena-slot-fighter { - border-color: rgb(125 214 255 / 20%); - - &.is-empty { - border-color: rgb(125 214 255 / 16%); - } - - &.is-selected { - border-color: rgb(125 214 255 / 52%); - box-shadow: inset 0 0 18px rgb(125 214 255 / 10%), 0 0 10px rgb(125 214 255 / 8%); - animation: battle-slot-enter 0.42s cubic-bezier(0.22, 1, 0.36, 1); - } - } - - /* Opponent (right) = magenta identity */ - &.arena-slot-opponent { - border-color: rgb(255 110 196 / 20%); - - &.is-empty { - border-color: rgb(255 110 196 / 16%); - } - - &.is-selected { - border-color: rgb(255 110 196 / 58%); - box-shadow: inset 0 0 18px rgb(255 110 196 / 12%), 0 0 10px rgb(255 110 196 / 8%); - animation: battle-slot-enter 0.42s cubic-bezier(0.22, 1, 0.36, 1); - } - } - } - - &.is-ready { - border-color: rgb(255 110 196 / 42%); - box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(255 110 196 / 6%), - 0 0 32px rgb(255 110 196 / 14%), 0 8px 32px rgb(0 0 0 / 30%); - - .center { - .icon { - animation: battle-vs-glow 1.8s ease-in-out infinite; - } - - .vs { - animation: battle-vs-pulse 1.8s ease-in-out infinite; - color: #ff9ad6; - text-shadow: 0 0 16px rgb(255 110 196 / 55%); - } - } - } - - &.is-fighting { - animation: battle-arena-clash 0.55s ease-in-out infinite; - - .center .icon { - animation: battle-vs-spin 0.85s linear infinite; - } - } - - &.is-result { - border-color: rgb(0 255 157 / 40%); - box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(0 255 157 / 8%), - 0 0 30px rgb(0 255 157 / 16%), 0 8px 32px rgb(0 0 0 / 30%); - } - - .content { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - gap: 10px; - align-items: center; - min-width: 0; - overflow: hidden; - } - - .center { - flex-shrink: 0; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 0 4px; - - .icon { - width: 44px; - height: 44px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - background: radial-gradient( - circle at center, - rgb(255 110 196 / 20%), - rgb(255 110 196 / 6%) 65%, - transparent - ); - border: 1px solid rgb(255 110 196 / 34%); - box-shadow: inset 0 0 14px rgb(255 110 196 / 22%), 0 0 20px rgb(255 110 196 / 12%); - } - - .vs { - font-size: 15px; - font-weight: 900; - letter-spacing: 3px; - color: rgb(255 110 196 / 58%); - text-shadow: 0 0 10px rgb(255 110 196 / 28%); - line-height: 1; - } - } - - .slot-placeholder { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.5px; - text-transform: uppercase; - color: var(--zi-text-muted); - } - - .slot-row { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - } - - .slot-avatar { - flex-shrink: 0; - width: 38px; - height: 38px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - line-height: 1; - border-radius: 10px; - background: rgb(5 13 30 / 82%); - border: 1px solid rgb(125 214 255 / 16%); - } - - .slot-meta { - min-width: 0; - display: grid; - gap: 2px; - } - - .slot-name { - font-size: 13px; - font-weight: 700; - color: var(--zi-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .slot-sub { - font-size: 10px; - color: var(--zi-text-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .life-track { - height: 5px; - background: rgb(4 8 18 / 90%); - border-radius: 999px; - overflow: hidden; - } - - .life-fill { - height: 100%; - background: linear-gradient(90deg, #0dffb4, #7dffcc); - box-shadow: 0 0 8px rgb(0 255 157 / 55%); - border-radius: 999px; - } -} - -.battle-picker-section { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; - max-width: 100%; - overflow-x: clip; - - .section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - } - - .section-title { - margin: 0; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: var(--zi-text-muted); - } - - .section-hint { - font-weight: 600; - letter-spacing: 0.2px; - text-transform: none; - color: rgb(195 210 255 / 52%); - } - - .section-action { - background: transparent; - border: 1px solid rgb(125 214 255 / 28%); - border-radius: 2px; - color: var(--neon-cyan); - padding: 4px 10px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.5px; - text-transform: uppercase; - cursor: pointer; - transition: var(--transition, all 0.2s ease); - - &:hover:not(:disabled) { - border-color: var(--neon-cyan); - box-shadow: 0 0 10px rgb(125 214 255 / 24%); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &.section-action-primary { - border-color: rgb(255 110 196 / 42%); - color: #ff9ad6; - box-shadow: 0 0 8px rgb(255 110 196 / 16%); - - &:hover:not(:disabled) { - border-color: rgb(255 110 196 / 68%); - box-shadow: 0 0 12px rgb(255 110 196 / 28%); - } - } - } -} - -/* Arena stage: fighters lane on the outer-left, the VS arena card (with win odds - + Start Battle) in the center, opponents lane on the outer-right. Each lane - scrolls vertically and independently so neither pushes the action off-screen. */ -.battle-stage { - display: flex; - gap: 14px; - width: 100%; - min-width: 0; - align-items: stretch; - /* Stretch to fill the battle-setup height; lanes scroll inside their column. */ - flex: 1 1 auto; - min-height: 0; -} - -/* Center column holds the arena card, win odds, and the action bar. It gets the - most room so the VS slots stay legible. */ -.battle-center { - flex: 1.6 1 0; - min-width: 0; - display: flex; - flex-direction: column; - gap: 16px; - /* Center the arena (and odds + action bar) vertically within the full-height - column, so the VS card sits in the middle rather than hugging the top. */ - justify-content: center; -} - -.battle-stage .battle-picker-section { - flex: 1 1 0; - min-width: 0; - background: rgb(8 14 30 / 42%); - border: 1px solid var(--zi-border); - border-radius: 12px; - padding: 12px; - box-shadow: inset 0 0 24px rgb(0 0 0 / 22%); -} - -/* Lane identity: fighters read cyan (you), opponents read magenta (them). */ -.battle-stage .battle-picker-section[aria-label='Your fighters'] { - border-color: rgb(125 214 255 / 32%); - box-shadow: inset 0 0 24px rgb(125 214 255 / 6%); -} - -.battle-stage .battle-picker-section[aria-label='Opponents'] { - border-color: rgb(255 110 196 / 32%); - box-shadow: inset 0 0 24px rgb(255 110 196 / 6%); -} - -.battle-stage .battle-picker-section[aria-label='Opponents'] .section-title { - color: #ff9ad6; -} - -/* The header stays pinned while the card list below it scrolls. */ -.battle-stage .section-head { - flex: 0 0 auto; -} - -.battle-picker-strip, -.battle-opponent-grid { - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; - max-width: 100%; - box-sizing: border-box; - overflow-x: hidden; - overflow-y: auto; - /* Fill the lane's remaining height (below its header) and scroll within. */ - flex: 1 1 auto; - min-height: 0; - padding: 4px; - scroll-padding: 4px; - scrollbar-width: thin; - scrollbar-color: var(--neon-cyan) transparent; - - .battle-picker-card { - flex: 0 0 auto; - width: 100%; - max-width: 100%; - } -} - -.battle-opponent-grid { - scrollbar-color: #ff9ad6 transparent; -} - -/* Below 960px the three columns get cramped: stack them, with the arena + - Start Battle on top, then the two lanes. */ -@media (max-width: 960px) { - /* Stacked: let the content take its natural height and the panel scroll, - rather than flex-filling a single viewport of stacked lanes. */ - .dashboard-panel.pet-interactions .interface.battle-setup, - .battle-stage { - flex: 0 0 auto; - } - - .battle-stage { - flex-direction: column; - } - - .battle-center { - order: -1; - } - - .battle-picker-strip, - .battle-opponent-grid { - flex: 0 0 auto; - max-height: clamp(180px, 30vh, 360px); - } -} - -.battle-picker-card { - appearance: none; - background: var(--zi-card-bg); - border: 2px solid var(--zi-border); - border-radius: 10px; - padding: 10px; - display: grid; - gap: 8px; - min-width: 0; - max-width: 100%; - overflow: hidden; - text-align: left; - cursor: pointer; - color: inherit; - box-sizing: border-box; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - - &:hover:not(:disabled) { - border-color: rgb(125 214 255 / 45%); - } - - &:focus-visible { - outline: none; - border-color: var(--neon-cyan); - box-shadow: inset 0 0 0 1px rgb(125 214 255 / 55%), inset 0 0 12px rgb(125 214 255 / 16%); - } - - &.is-selected { - border-color: rgb(255 110 196 / 70%); - box-shadow: inset 0 0 16px rgb(255 110 196 / 20%); - animation: battle-card-select 0.38s cubic-bezier(0.22, 1, 0.36, 1); - - &:focus-visible { - border-color: rgb(255 110 196 / 85%); - box-shadow: inset 0 0 0 1px rgb(255 110 196 / 55%), - inset 0 0 16px rgb(255 110 196 / 24%); - } - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - } - - .card-top { - display: flex; - align-items: flex-start; - gap: 8px; - min-width: 0; - } - - .card-avatar { - flex-shrink: 0; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.6rem; - line-height: 1; - border-radius: 10px; - background: radial-gradient(circle at 30% 30%, rgb(125 214 255 / 20%), transparent 60%), - rgb(5 13 30 / 82%); - border: 1px solid rgb(125 214 255 / 18%); - } - - .card-body { - min-width: 0; - display: grid; - gap: 2px; - } - - .card-name { - font-size: 13px; - font-weight: 700; - color: var(--zi-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .card-meta { - font-size: 11px; - color: var(--zi-text-muted); - } - - .card-stats { - display: flex; - flex-wrap: wrap; - gap: 6px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.3px; - text-transform: uppercase; - } - - .stat-pill { - padding: 2px 6px; - border-radius: 999px; - border: 1px solid rgb(125 214 255 / 22%); - color: rgb(195 210 255 / 85%); - background: rgb(5 13 30 / 72%); - - &.rarity { - color: #fff; - border-color: transparent; - } - - &.match-even { - color: #9effd4; - border-color: rgb(0 255 157 / 45%); - background: rgb(0 255 157 / 10%); - } - - &.match-easy { - color: #9de8ff; - border-color: rgb(125 214 255 / 45%); - background: rgb(125 214 255 / 10%); - } - - &.match-risky { - color: #ffd07b; - border-color: rgb(255 181 67 / 50%); - background: rgb(255 181 67 / 10%); - } - - &.match-danger { - color: #ff9aa9; - border-color: rgb(255 110 196 / 55%); - background: rgb(255 110 196 / 12%); - } - } - - &.match-risky:not(.is-selected) { - border-color: rgb(255 181 67 / 32%); - } - - &.match-danger:not(.is-selected) { - border-color: rgb(255 110 196 / 38%); - } - - &.match-even:not(.is-selected) { - border-color: rgb(0 255 157 / 22%); - } -} - -.battle-picker-empty { - padding: 16px; - border: 1px dashed rgb(125 214 255 / 24%); - border-radius: 10px; - text-align: center; - color: var(--zi-text-muted); - font-size: 13px; - line-height: 1.45; -} - -.battle-setup { - .action-controls { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; - margin-top: 4px; - - button[type='button']:not(.cancel-button) { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border: 1px solid rgb(255 110 196 / 48%); - border-radius: 2px; - color: #ff9ad6; - padding: 10px 18px; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - cursor: pointer; - box-shadow: inset 0 0 12px rgb(255 110 196 / 14%), 0 0 12px rgb(255 110 196 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(255 110 196 / 70%); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - } - } - } -} - -.battle-result-overlay { - position: fixed; - inset: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: rgb(4 8 22 / 82%); - backdrop-filter: blur(8px); - animation: battle-result-enter 0.3s ease; -} - -.battle-result-card { - width: min(100%, 480px); - background: linear-gradient(180deg, rgb(10 16 36 / 98%), rgb(6 10 24 / 99%)); - border: 1px solid rgb(0 255 157 / 42%); - border-radius: 16px; - padding: 32px 28px 28px; - text-align: center; - box-shadow: inset 0 0 32px rgb(0 255 157 / 8%), 0 0 40px rgb(0 255 157 / 16%), - 0 24px 60px rgb(0 0 0 / 55%); - animation: battle-result-pop 0.45s cubic-bezier(0.22, 1, 0.36, 1); - - .art { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; - - svg { - width: 160px; - height: 106px; - filter: drop-shadow(0 0 12px rgb(0 255 157 / 30%)); - } - } - - .title { - margin: 0 0 10px; - font-size: 26px; - font-weight: 700; - letter-spacing: 1px; - text-transform: uppercase; - color: #9effd4; - } - - .message { - margin: 0 0 10px; - font-size: 15px; - line-height: 1.5; - color: var(--zi-text); - } - - .opponent { - margin: 0 0 20px; - font-size: 13px; - color: var(--zi-text-muted); - } - - .actions { - display: flex; - flex-direction: row; - gap: 12px; - justify-content: center; - flex-wrap: wrap; - margin-top: 4px; - } - - &.is-defeat { - border-color: rgb(255 110 196 / 42%); - box-shadow: inset 0 0 32px rgb(255 110 196 / 8%), 0 0 40px rgb(255 110 196 / 16%), - 0 24px 60px rgb(0 0 0 / 55%); - - .art svg { - filter: drop-shadow(0 0 12px rgb(255 110 196 / 30%)); - } - - .title { - color: #ff9ad6; - } - } - - &.is-pending { - border-color: rgb(125 214 255 / 28%); - box-shadow: inset 0 0 24px rgb(125 214 255 / 5%), 0 0 28px rgb(125 214 255 / 10%), - 0 24px 60px rgb(0 0 0 / 55%); - - .art svg { - filter: drop-shadow(0 0 10px rgb(125 214 255 / 28%)); - animation: battle-result-pending-spin 2s linear infinite; - } - } -} - -.battle-result-rematch, -.battle-result-done { - appearance: none; - border-radius: 2px; - padding: 11px 22px; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; -} - -.battle-result-rematch { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border: 1px solid rgb(255 110 196 / 55%); - color: #ff9ad6; - box-shadow: inset 0 0 12px rgb(255 110 196 / 14%), 0 0 12px rgb(255 110 196 / 22%); - - &.is-defeat { - border-color: rgb(255 110 196 / 38%); - box-shadow: inset 0 0 10px rgb(255 110 196 / 10%), 0 0 8px rgb(255 110 196 / 16%); - } - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(255 110 196 / 75%); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - } -} - -.battle-result-done { - background: transparent; - border: 1px solid rgb(125 214 255 / 28%); - color: var(--neon-cyan); - - &:hover:not(:disabled) { - border-color: var(--neon-cyan); - box-shadow: 0 0 10px rgb(125 214 255 / 22%); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - } -} - -@keyframes battle-slot-pulse { - 0%, - 100% { - opacity: 0.55; - } - - 50% { - opacity: 1; - } -} - -@keyframes battle-slot-enter { - from { - opacity: 0.7; - } - - to { - opacity: 1; - } -} - -@keyframes battle-slot-flash { - 0% { - box-shadow: inset 0 0 0 rgb(255 110 196 / 0%); - } - - 40% { - box-shadow: inset 0 0 18px rgb(255 110 196 / 30%); - } - - 100% { - box-shadow: inset 0 0 14px rgb(255 110 196 / 18%); - } -} - -@keyframes battle-card-select { - 0%, - 100% { - box-shadow: inset 0 0 16px rgb(255 110 196 / 20%); - } - - 45% { - box-shadow: inset 0 0 22px rgb(255 110 196 / 32%); - } -} - -@keyframes battle-vs-pulse { - 0%, - 100% { - opacity: 0.85; - } - - 50% { - opacity: 1; - } -} - -@keyframes battle-vs-glow { - 0%, - 100% { - box-shadow: inset 0 0 14px rgb(255 110 196 / 22%), 0 0 20px rgb(255 110 196 / 12%); - } - - 50% { - box-shadow: inset 0 0 20px rgb(255 110 196 / 38%), 0 0 28px rgb(255 110 196 / 22%); - } -} - -@keyframes battle-vs-spin { - to { - transform: rotate(360deg); - } -} - -@keyframes battle-arena-clash { - 0%, - 100% { - box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(255 110 196 / 4%), - 0 0 24px rgb(255 110 196 / 8%), 0 8px 32px rgb(0 0 0 / 30%); - } - - 50% { - box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 60px rgb(255 110 196 / 20%), - 0 0 40px rgb(255 110 196 / 22%), 0 8px 32px rgb(0 0 0 / 30%); - } -} - -@keyframes battle-result-enter { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes battle-result-pop { - from { - opacity: 0; - transform: scale(0.92) translateY(8px); - } - - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -@keyframes battle-result-pending-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: reduce) { - .battle-setup-arena { - .arena-slot.is-empty .slot-placeholder, - .arena-slot.is-selected, - .arena-slot.is-flash, - &.is-ready .center .icon, - &.is-ready .center .vs, - &.is-fighting, - &.is-fighting .center .icon { - animation: none !important; - } - } - - .battle-picker-card.is-selected, - .battle-result-overlay, - .battle-result-card, - .battle-result-card.is-pending .art svg { - animation: none !important; - } -} - -@media (max-width: 768px) { - .battle-setup-arena { - .content { - grid-template-columns: 1fr; - } - - .center { - flex-direction: row; - justify-content: center; - gap: 10px; - - .icon { - width: 36px; - height: 36px; - } - } - - .header { - flex-wrap: wrap; - } - - .arena-actions { - width: 100%; - justify-content: space-between; - } - } -} - -.pending-battle-notice { - margin: 10px 0; - padding: 10px 12px; - border: 1px solid rgb(255 207 112 / 35%); - border-radius: 8px; - background: rgb(40 28 5 / 45%); - color: var(--neon-amber, #ffcf70); - font-size: var(--font-size-sm); -} - -.pending-battle-notice p { - margin: 0 0 8px; -} - -.pending-battle-actions { - display: flex; - gap: 8px; -} - -.pending-battle-actions button { - padding: 4px 12px; - border-radius: 6px; - cursor: pointer; -} - -.pending-battle-actions button:disabled { - opacity: 0.6; - cursor: default; -} diff --git a/frontend/src/components/pet/interactions/panels/battle/index.module.css b/frontend/src/components/pet/interactions/panels/battle/index.module.css new file mode 100644 index 00000000..05885a8e --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/battle/index.module.css @@ -0,0 +1,871 @@ +/* CSS Module — class names are local; reference via `import styles from '.../index.module.css'`. + Battle setup (fighter-vs-rival showdown) + full-scene battle overlay. Shared + chrome (.interface/.cancel-button) stays global in interactions.css; the + Battle Log's dialogue comes from the global battle-dialogue.css. */ + +/* Battle setup — scoped under the global .interface chrome + local .battleSetup. */ +:global(.dashboard-panel.pet-interactions .interface).battleSetup { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + min-width: 0; + max-width: 100%; + overflow-x: clip; + /* Fill the panel height so the showdown stretches instead of hugging the top. */ + flex: 1 1 auto; + min-height: 0; +} + +/* ── Battle setup showdown (fighter vs on-chain rival) — laid out like the mock ── */ +.showdown { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + gap: 8px; + margin-bottom: 8px; +} + +.combatantCol { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + padding: 0 8px; +} + +.combatantColLabel { + font-size: 9px; + font-weight: 700; + letter-spacing: 1.8px; + text-transform: uppercase; +} + +.combatantColFighter .combatantColLabel { + color: rgb(125 214 255 / 55%); +} + +.combatantColRival .combatantColLabel { + color: rgb(255 123 203 / 55%); +} + +/* Selection controls */ +.combatantSelect { + display: flex; + gap: 8px; + flex-wrap: wrap; + + select { + flex: 1; + min-width: 0; + background: rgb(4 10 26 / 96%); + border: 1px solid rgb(101 131 255 / 28%); + border-radius: 6px; + padding: 8px 12px; + font-size: 12px; + font-family: var(--cp-content-font); + color: var(--cp-text-body-dark); + cursor: pointer; + + &:focus-visible { + outline: none; + border-color: var(--cp-cyan); + } + } +} + +.combatantColRival .combatantSelect select:focus-visible { + border-color: var(--cp-magenta); +} + +.combatantSelectBtn { + flex-shrink: 0; + background: rgb(5 13 30 / 70%); + border: 1px solid rgb(255 123 203 / 28%); + border-radius: 6px; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: rgb(255 123 203 / 80%); + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease; + + &:hover:not(:disabled) { + border-color: rgb(255 123 203 / 55%); + color: var(--cp-magenta); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +/* Combatant card */ +.combatantCard { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + min-height: 300px; + padding: 20px; + border-radius: 12px; + background: linear-gradient(180deg, rgb(10 16 32 / 97%), rgb(6 10 22 / 99%)); + border: 1px solid rgb(125 214 255 / 28%); + box-shadow: 0 0 28px rgb(125 214 255 / 7%); +} + +.combatantCardRival { + border-color: rgb(255 123 203 / 28%); + box-shadow: 0 0 28px rgb(255 123 203 / 7%); +} + +.combatantCard.isEmpty { + align-items: center; + justify-content: center; + border-style: dashed; + box-shadow: none; +} + +.combatantCardPlaceholder { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: rgb(195 210 255 / 40%); +} + +.combatantCardAvatarWrap { + width: 108px; + height: 108px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: radial-gradient(circle at 50% 45%, rgb(125 214 255 / 18%), transparent 65%), #070e1e; + border: 1px solid rgb(125 214 255 / 14%); +} + +.combatantCardRival .combatantCardAvatarWrap { + background: radial-gradient(circle at 50% 45%, rgb(255 123 203 / 18%), transparent 65%), #070e1e; + border-color: rgb(255 123 203 / 14%); +} + +.combatantCardAvatar { + font-size: 4rem; + line-height: 1; + animation: cp-float 3.5s ease-in-out infinite; + filter: drop-shadow(0 0 22px rgb(125 214 255 / 55%)); +} + +.combatantCardRival .combatantCardAvatar { + animation-duration: 4s; + animation-delay: -1.8s; + filter: drop-shadow(0 0 22px rgb(255 123 203 / 55%)); +} + +.combatantCardName { + font-family: var(--cp-title-font); + font-size: 16px; + font-weight: 700; + color: #f4f2ff; + text-align: center; +} + +.combatantCardMeta { + font-size: 11px; + color: rgb(195 210 255 / 50%); + text-align: center; +} + +.combatantCardOwner { + font-size: 10px; + color: rgb(195 210 255 / 35%); + font-variant-numeric: tabular-nums; +} + +.combatantCardStats { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} + +.combatantStat { + background: rgb(5 13 30 / 80%); + border: 1px solid rgb(125 214 255 / 15%); + border-radius: 6px; + padding: 8px; + text-align: center; +} + +.combatantCardRival .combatantStat { + border-color: rgb(255 123 203 / 15%); +} + +.combatantStatLabel { + font-size: 8px; + text-transform: uppercase; + color: rgb(195 210 255 / 40%); +} + +.combatantStatVal { + font-family: var(--cp-title-font); + font-size: 16px; + font-weight: 800; + color: var(--cp-cyan); + margin-top: 1px; +} + +.combatantCardRival .combatantStatVal { + color: var(--cp-magenta); +} + +.combatantCardHp { + width: 100%; + margin-top: auto; +} + +.combatantCardHpHead { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + color: rgb(125 214 255 / 50%); +} + +.combatantCardRival .combatantCardHpHead { + color: rgb(255 123 203 / 50%); +} + +.combatantCardHpVal { + color: var(--cp-emerald); +} + +.combatantCardHpTrack { + height: 7px; + background: rgb(5 13 30 / 80%); + border: 1px solid rgb(15 255 174 / 14%); + border-radius: 999px; + overflow: hidden; +} + +.combatantCardHpFill { + height: 100%; + border-radius: 999px; +} + +.combatantCardHpFillFighter { + background: linear-gradient(90deg, #0fffae, #7dd6ff); + box-shadow: 0 0 10px rgb(15 255 174 / 50%); +} + +.combatantCardHpFillRival { + background: linear-gradient(90deg, #0fffae, #ff7bcb); + box-shadow: 0 0 10px rgb(15 255 174 / 40%); +} + +/* VS + win rate column */ +.vs { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 0 20px; + flex-shrink: 0; +} + +.vsMark { + font-family: var(--cp-title-font); + font-size: 38px; + font-weight: 900; + line-height: 1; + color: var(--cp-magenta); + text-shadow: 0 0 30px rgb(255 123 203 / 80%); + animation: cp-vs-pulse 2s ease-in-out infinite; +} + +.vsDivider { + width: 1px; + height: 50px; + background: linear-gradient(180deg, transparent, rgb(255 123 203 / 45%), transparent); +} + +.vsWinrate { + background: rgb(10 16 32 / 95%); + border: 1px solid rgb(101 131 255 / 18%); + border-radius: 10px; + padding: 12px 18px; + text-align: center; +} + +.vsWinrateLabel { + font-size: 8px; + font-weight: 700; + letter-spacing: 1.2px; + text-transform: uppercase; + color: rgb(125 214 255 / 45%); + margin-bottom: 4px; +} + +.vsWinrateVal { + font-family: var(--cp-title-font); + font-size: 24px; + font-weight: 900; + color: var(--cp-cyan); + text-shadow: 0 0 14px rgb(125 214 255 / 60%); +} + +/* Action row */ +.actions { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-top: 8px; +} + +@media (max-width: 760px) { + .showdown { + grid-template-columns: 1fr; + } + .vs { + flex-direction: row; + padding: 8px 0; + } + .vsDivider { + width: 50px; + height: 1px; + } + .combatantCard { + min-height: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .combatantCardAvatar, + .vsMark { + animation: none !important; + } +} + +/* ── Battle scene overlay (fighting + result) — laid out like the mock ─────── */ +.scene { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: radial-gradient(circle at 50% 50%, rgb(255 123 203 / 14%), transparent 60%), #020508; + animation: cp-battle-enter 0.32s ease both; +} + +.scene.isResult { + align-items: center; + justify-content: center; + gap: 20px; + padding: 32px 24px; + background: radial-gradient(circle at 50% 40%, rgb(125 214 255 / 10%), transparent 60%), #020508; + overflow-y: auto; +} + +.sceneBanner { + flex-shrink: 0; + text-align: center; + padding: 18px 24px 10px; + font-family: var(--cp-title-font); + font-size: 12px; + font-weight: 700; + letter-spacing: 3px; + text-transform: uppercase; + color: rgb(255 123 203 / 80%); + text-shadow: 0 0 20px rgb(255 123 203 / 60%); +} + +/* HP bars */ +.sceneHp { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 20px; + padding: 4px 28px 16px; +} + +.sceneHpSide { + flex: 1; + min-width: 0; +} + +.sceneHpHead { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.sceneHpName { + font-family: var(--cp-title-font); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sceneHpName.isFighter { + color: var(--cp-cyan); +} + +.sceneHpName.isEnemy { + color: var(--cp-magenta); +} + +.sceneHpVal { + font-size: 12px; + font-weight: 700; + color: var(--cp-emerald); + flex-shrink: 0; +} + +.sceneHpTrack { + height: 11px; + background: rgb(5 13 30 / 92%); + border: 1px solid rgb(125 214 255 / 18%); + border-radius: 999px; + overflow: hidden; +} + +.sceneHpTrack.isEnemy { + border-color: rgb(255 123 203 / 18%); +} + +.sceneHpFill { + height: 100%; + border-radius: 999px; + transition: width 0.55s ease; +} + +.sceneHpFill.isFighter { + background: linear-gradient(90deg, #0fffae, #7dd6ff); + box-shadow: 0 0 12px rgb(15 255 174 / 60%); +} + +.sceneHpFill.isEnemy { + margin-left: auto; + background: linear-gradient(90deg, #ff7bcb, #b58cff); + box-shadow: 0 0 12px rgb(255 123 203 / 60%); +} + +.sceneHpVs { + flex-shrink: 0; + font-family: var(--cp-title-font); + font-size: 13px; + font-weight: 900; + color: rgb(255 123 203 / 65%); +} + +/* Arena */ +.sceneArena { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 8px 20px; +} + +.sceneFighter { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +/* Decorative attack flashes (ambient — the real battle resolves atomically) */ +.sceneHit { + position: absolute; + top: 24px; + left: 50%; + font-size: 40px; + pointer-events: none; + opacity: 0; + z-index: 2; +} + +.sceneHitIn { + animation: cp-attack-left 1.6s ease-in-out 0.4s infinite; +} + +.sceneHitOut { + animation: cp-attack-right 1.6s ease-in-out 1.2s infinite; +} + +.sceneAvatar { + font-size: 5rem; + line-height: 1; + animation: cp-float 3.6s ease-in-out infinite; +} + +.sceneAvatar.isFighter { + filter: drop-shadow(0 0 26px rgb(125 214 255 / 75%)); +} + +.sceneAvatar.isEnemy { + animation-duration: 4s; + animation-delay: -1.8s; + filter: drop-shadow(0 0 26px rgb(255 123 203 / 75%)); +} + +.sceneLabel { + font-family: var(--cp-title-font); + font-size: 12px; + font-weight: 700; + letter-spacing: 1px; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sceneLabel.isFighter { + color: var(--cp-cyan); +} + +.sceneLabel.isEnemy { + color: var(--cp-magenta); +} + +.sceneClash { + flex-shrink: 0; + width: 72px; + text-align: center; + font-size: 28px; + opacity: 0.65; + animation: cp-vs-pulse 1.4s ease-in-out infinite; +} + +.sceneStatus { + flex-shrink: 0; + margin: 0 20px 16px; + text-align: center; + font-size: 12px; + color: rgb(125 214 255 / 70%); +} + +/* Battle Log panel (the chatting) */ +.log { + flex-shrink: 0; + margin: 0 20px 20px; + padding: 12px 16px; + background: rgb(5 13 30 / 88%); + border: 1px solid rgb(101 131 255 / 18%); + border-radius: 8px; + max-height: 190px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgb(125 214 255 / 35%) transparent; + + /* The dialogue markup comes from the global battle-dialogue.css component. */ + :global(.battle-dialogue) { + margin: 0; + max-height: none; + padding: 0; + } +} + +.logTitle { + font-family: var(--cp-title-font); + font-size: 8px; + font-weight: 700; + letter-spacing: 2.2px; + text-transform: uppercase; + color: rgb(125 214 255 / 45%); + margin-bottom: 8px; +} + +.logWaiting { + margin: 0; + font-size: 12px; + font-style: italic; + color: rgb(195 210 255 / 35%); +} + +.scene.isResult .log { + width: min(100%, 520px); + margin: 0; +} + +/* Result banner */ +.sceneResult { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + animation: cp-victory-burst 0.6s ease both; +} + +.sceneResult.isDefeat { + animation: cp-defeat-drop 0.5s ease both; +} + +.sceneResult.isPending { + animation: cp-battle-enter 0.4s ease both; +} + +.sceneArt { + margin-bottom: 6px; + + svg { + width: 180px; + height: 120px; + filter: drop-shadow(0 0 14px rgb(0 255 157 / 30%)); + } +} + +.sceneResult.isDefeat .sceneArt svg { + filter: drop-shadow(0 0 14px rgb(255 123 203 / 30%)); +} + +.sceneResult.isPending .sceneArt svg { + filter: drop-shadow(0 0 10px rgb(125 214 255 / 28%)); + animation: battle-result-pending-spin 2s linear infinite; +} + +.sceneResultTitle { + font-family: var(--cp-title-font); + font-size: 48px; + font-weight: 900; + line-height: 1.1; + color: var(--cp-emerald); + text-shadow: 0 0 40px rgb(15 255 174 / 55%); +} + +.sceneResult.isDefeat .sceneResultTitle { + color: var(--cp-magenta); + text-shadow: 0 0 40px rgb(255 123 203 / 55%); +} + +.sceneResult.isPending .sceneResultTitle { + font-size: 32px; + color: var(--cp-cyan); + text-shadow: 0 0 30px rgb(125 214 255 / 50%); +} + +.sceneResultSub { + margin-top: 6px; + font-size: 14px; + color: rgb(195 210 255 / 55%); +} + +.sceneResultVs { + font-size: 12px; + color: rgb(195 210 255 / 40%); +} + +.sceneActions { + display: flex; + gap: 14px; + justify-content: center; + flex-wrap: wrap; +} + +.resultRematch, +.resultDone { + appearance: none; + border-radius: 2px; + padding: 11px 22px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} + +.resultRematch { + background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); + border: 1px solid rgb(255 110 196 / 55%); + color: #ff9ad6; + box-shadow: inset 0 0 12px rgb(255 110 196 / 14%), 0 0 12px rgb(255 110 196 / 22%); + + &.isDefeat { + border-color: rgb(255 110 196 / 38%); + box-shadow: inset 0 0 10px rgb(255 110 196 / 10%), 0 0 8px rgb(255 110 196 / 16%); + } + + &:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgb(255 110 196 / 75%); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.resultDone { + background: transparent; + border: 1px solid rgb(125 214 255 / 28%); + color: var(--cp-cyan); + + &:hover:not(:disabled) { + border-color: var(--cp-cyan); + box-shadow: 0 0 10px rgb(125 214 255 / 22%); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +/* battle-only keyframe, kept with its sole consumer. */ +@keyframes battle-result-pending-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Battle-only keyframes moved here from styles/animations.css (sole consumer). */ +@keyframes cp-battle-enter { + from { + opacity: 0; + transform: scale(0.94); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes cp-vs-pulse { + 0%, + 100% { + transform: scale(1); + filter: drop-shadow(0 0 18px #ff7bcb); + } + 50% { + transform: scale(1.14); + filter: drop-shadow(0 0 42px #ff7bcb); + } +} +@keyframes cp-attack-left { + 0% { + opacity: 0; + transform: translateX(-24px) scale(0.6); + } + 40% { + opacity: 1; + transform: translateX(8px) scale(1.4); + } + 100% { + opacity: 0; + transform: translateX(70px) scale(0.3); + } +} +@keyframes cp-attack-right { + 0% { + opacity: 0; + transform: translateX(24px) scale(0.6); + } + 40% { + opacity: 1; + transform: translateX(-8px) scale(1.4); + } + 100% { + opacity: 0; + transform: translateX(-70px) scale(0.3); + } +} +@keyframes cp-victory-burst { + 0% { + opacity: 0; + transform: scale(0.4) rotate(-8deg); + } + 65% { + opacity: 1; + transform: scale(1.08) rotate(3deg); + } + 100% { + opacity: 1; + transform: none; + } +} +@keyframes cp-defeat-drop { + from { + opacity: 0; + transform: scale(1.3) translateY(-30px); + } + to { + opacity: 1; + transform: none; + } +} + +/* Local copy of the shared float keyframe (other panels still use the global). */ +@keyframes cp-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-9px); + } +} + +@media (prefers-reduced-motion: reduce) { + .scene, + .sceneAvatar, + .sceneClash, + .sceneHit, + .sceneResult, + .sceneResult.isPending .sceneArt svg { + animation: none !important; + } +} + +.pendingNotice { + margin: 10px 0; + padding: 10px 12px; + border: 1px solid rgb(255 207 112 / 35%); + border-radius: 8px; + background: rgb(40 28 5 / 45%); + color: var(--cp-amber, #ffcf70); + font-size: var(--cp-font-size-sm); + + p { + margin: 0 0 8px; + } +} + +.pendingActions { + display: flex; + gap: 8px; + + button { + padding: 4px 12px; + border-radius: 6px; + cursor: pointer; + + &:disabled { + opacity: 0.6; + cursor: default; + } + } +} diff --git a/frontend/src/components/pet/interactions/panels/battle/index.tsx b/frontend/src/components/pet/interactions/panels/battle/index.tsx index 1fadc347..7b40c5a2 100644 --- a/frontend/src/components/pet/interactions/panels/battle/index.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/index.tsx @@ -3,7 +3,6 @@ import TransactionStatus from '@components/common/transaction-status'; import BattleOverlay from './parts/battle-overlay'; import BattleSetup from './parts/battle-setup'; import { useBattlePanel } from '@hooks/battle/useBattlePanel'; -import './index.css'; export type BattlePanelProps = { /** `false` when embedded under the dashboard interactions hub. */ diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/arena-slot.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/arena-slot.tsx deleted file mode 100644 index 492240a4..00000000 --- a/frontend/src/components/pet/interactions/panels/battle/parts/arena-slot.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { getLifePercent, getPetAvatar, type OpponentPet, type Pet } from '@shared/core'; - -type ArenaSlotProps = { - pet?: Pet | OpponentPet | null; - placeholder: string; - ownerLabel?: string; - side: 'fighter' | 'opponent'; - flash?: boolean; -}; - -/** One side of the VS arena — empty placeholder or the selected pet with a life bar. */ -const ArenaSlot: React.FC = ({ pet, placeholder, ownerLabel, side, flash }) => { - if (!pet) { - return ( -
- {placeholder} -
- ); - } - - return ( -
-
- - {getPetAvatar(pet.dna)} - -
- {pet.name} - - Lv.{pet.level} - {ownerLabel ? ` · ${ownerLabel}` : ''} - -
-
-
-
-
-
- ); -}; - -export default ArenaSlot; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx index 82f64604..0b39b0b2 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx @@ -1,13 +1,22 @@ import React from 'react'; -import type { DialogueTurn, OpponentPet } from '@shared/core'; +import clsx from 'clsx'; +import { + getLifePercent, + getPetAvatar, + type DialogueTurn, + type OpponentPet, + type Pet, +} from '@shared/core'; import BattleResultArt from '../battle-result-art'; import BattleDialogue from '../battle-dialogue'; import type { BattleOutcome } from '../types'; +import styles from '../index.module.css'; export type BattleOverlayProps = { open: boolean; showResult: boolean; battleOutcome: BattleOutcome; + fighter?: Pet | null; opponent?: OpponentPet; // Result phase resultTurns: DialogueTurn[]; @@ -31,14 +40,42 @@ export type BattleOverlayProps = { opponentName: string; }; +/** Battle Log panel (the chatting) — shared by the fighting and result scenes. */ +const BattleLog: React.FC<{ + turns: DialogueTurn[]; + isLoading: boolean; + attackerName: string; + defenderName: string; + onComplete?: () => void; + waiting: string; +}> = ({ turns, isLoading, attackerName, defenderName, onComplete, waiting }) => ( +
+
⚔ Battle Log
+ {isLoading || turns.length > 0 ? ( + + ) : ( +

{waiting}

+ )} +
+); + /** - * Full-screen battle overlay. Stays open continuously across the phases: + * Full-scene battle overlay laid out like the redesign mock: an in-scene arena + * (HP bars + facing-off avatars) with the fighters' conversation shown in a + * Battle Log panel at the bottom. Stays open continuously across the phases: * taunts → battling (pre-result) → result reactions + actions. */ const BattleOverlay: React.FC = ({ open, showResult, battleOutcome, + fighter, opponent, resultTurns, dialogueLoading, @@ -62,94 +99,150 @@ const BattleOverlay: React.FC = ({ const isVictory = battleOutcome?.result === 'victory'; const isDefeat = battleOutcome?.result === 'defeat'; - const resultCardClass = [ - 'battle-result-card', - battleOutcome === null ? 'is-pending' : isVictory ? '' : 'is-defeat', - ] - .filter(Boolean) - .join(' '); - return ( -
-
- {showResult ? ( - <> -
- + const fighterHp = fighter ? getLifePercent(fighter) : 100; + const enemyHp = opponent ? getLifePercent(opponent) : 100; + const fighterAvatar = fighter ? getPetAvatar(fighter.dna) : '❓'; + const enemyAvatar = opponent ? getPetAvatar(opponent.dna) : '❓'; + + // ── Fighting scene (taunts / battle underway) ────────────────────────────── + if (!showResult) { + return ( +
+
⚔ {preResultTitle} ⚔
+ +
+
+
+ {fighterName} + {fighterHp} HP
-

- {battleOutcome === null - ? 'Resolving…' - : isVictory - ? 'Victory!' - : 'Defeated'} -

-

- {battleOutcome === null - ? 'Checking battle outcome…' - : isVictory - ? battleOutcome.leveledUp - ? 'Your pet won and leveled up!' - : 'Your pet won the battle!' - : 'Your pet was defeated. Train harder and try again!'} -

- {opponent && battleOutcome !== null ? ( -

- {isVictory - ? `vs ${opponent.name} (Lv.${opponent.level})` - : `Lost to ${opponent.name} (Lv.${opponent.level})`} -

- ) : null} - {battleOutcome !== null && (dialogueLoading || resultTurns.length > 0) ? ( - +
- ) : null} - {battleOutcome !== null && ( -
- - -
- )} - - ) : ( - <> -

{preResultTitle}

- {tauntsLoading || tauntsTurns.length > 0 ? ( - +
+
VS
+
+
+ {enemyHp} HP + {opponentName} +
+
+
- ) : null} - {preResultStatus ?

{preResultStatus}

: null} - - )} +
+
+
+ +
+
+ + ⚡ + + + {fighterAvatar} + + {fighterName} +
+ + ⚔ + +
+ + 💥 + + + {enemyAvatar} + + {opponentName} +
+
+ + + {preResultStatus ? ( +

{preResultStatus}

+ ) : null} +
+ ); + } + + // ── Result scene ─────────────────────────────────────────────────────────── + const bannerClass = clsx( + styles.sceneResult, + battleOutcome === null && styles.isPending, + isDefeat && styles.isDefeat, + ); + + return ( +
+
+
+ +
+
+ {battleOutcome === null ? 'Resolving…' : isVictory ? 'VICTORY!' : 'DEFEATED'} +
+
+ {battleOutcome === null + ? 'Checking battle outcome…' + : isVictory + ? battleOutcome.leveledUp + ? 'Your pet won and leveled up!' + : 'Your pet won the battle!' + : 'Your pet was defeated. Train harder and try again!'} +
+ {opponent && battleOutcome !== null ? ( +
+ {isVictory + ? `vs ${opponent.name} (Lv.${opponent.level})` + : `Lost to ${opponent.name} (Lv.${opponent.level})`} +
+ ) : null}
+ + {battleOutcome !== null ? ( + + ) : null} + + {battleOutcome !== null && ( +
+ + +
+ )}
); }; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index 4084d009..bc63d4be 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -1,35 +1,38 @@ import React from 'react'; -import { type OpponentPet, type Pet, type ReadyPet, type WinEstimateResult } from '@shared/core'; +import clsx from 'clsx'; +import { + getLifePercent, + getPetAvatar, + getPetClass, + getPetProperties, + getRarityColor, + getRarityName, + type OpponentPet, + type Pet, + type ReadyPet, + type WinEstimateResult, +} from '@shared/core'; import { Tones } from '@constants/tones'; import { AuthActionButton } from '@components/common'; import Icon, { BattleIcon } from '@components/ui/icon'; -import ArenaSlot from './arena-slot'; -import FighterPickerCard from './fighter-picker-card'; -import OpponentPickerCard from './opponent-picker-card'; import PendingBattleNotice from './pending-battle-notice'; import OpenToChallengesToggle from './open-to-challenges-toggle'; import { opponentKey, shortAddress } from '../battle-utils'; +import styles from '../index.module.css'; export type BattleSetupProps = { isStandaloneView: boolean; subtitle: string; - arenaClassName: string; - isArenaFighting: boolean; - isArenaReady: boolean; - showResult: boolean; selectedFighter: Pet | null; opponent?: OpponentPet; - opponentSlotFlash: boolean; randomMatchDisabled: boolean; onRandomMatch: () => void; readyPets: ReadyPet[]; selectedPet1: string; onSelectFighter: (petId: string) => void; sortedOpponents: OpponentPet[]; - fighterLevel: number | null; selectedOpponentKey: string; onSelectOpponent: (key: string) => void; - selectedOpponentCardRef: React.Ref; opponentsLoading: boolean; onRefreshOpponents: () => void; onBattle: () => void; @@ -39,27 +42,94 @@ export type BattleSetupProps = { winEstimate: WinEstimateResult; }; -/** The battle setup screen: arena, fighter/opponent pickers, and action controls. */ +/** Four real DNA-backed stats. AGI has no data backing, so we keep the app's + * STR / INT / DEF / VIT set rather than the mock's STR/AGI/INT/DEF. */ +const STAT_KEYS = [ + { label: 'STR', key: 'attack' }, + { label: 'INT', key: 'intelligence' }, + { label: 'DEF', key: 'defense' }, + { label: 'VIT', key: 'life' }, +] as const; + +/** One combatant's full stat card (fighter or rival), or an empty prompt. */ +const CombatantCard: React.FC<{ + pet: Pet | OpponentPet | null; + side: 'fighter' | 'rival'; + emptyLabel: string; + owner?: string; +}> = ({ pet, side, emptyLabel, owner }) => { + if (!pet) { + return ( +
+ {emptyLabel} +
+ ); + } + const props = getPetProperties(pet); + const rarityColor = getRarityColor(pet.rarity); + const hp = getLifePercent(pet); + return ( +
+
+ + {getPetAvatar(pet.dna)} + +
+
{pet.name}
+
+ Lv.{pet.level} · {getPetClass(pet.dna)} ·{' '} + {getRarityName(pet.rarity).toUpperCase()} +
+ {owner ?
{owner}
: null} +
+ {STAT_KEYS.map((stat) => ( +
+
{stat.label}
+
{props[stat.key]}
+
+ ))} +
+
+
+ HP + {hp}/100 +
+
+
+
+
+
+ ); +}; + +/** The battle setup screen: a fighter-vs-rival showdown with selection controls. */ const BattleSetup: React.FC = ({ isStandaloneView, subtitle, - arenaClassName, - isArenaFighting, - isArenaReady, - showResult, selectedFighter, opponent, - opponentSlotFlash, randomMatchDisabled, onRandomMatch, readyPets, selectedPet1, onSelectFighter, sortedOpponents, - fighterLevel, selectedOpponentKey, onSelectOpponent, - selectedOpponentCardRef, opponentsLoading, onRefreshOpponents, onBattle, @@ -67,202 +137,143 @@ const BattleSetup: React.FC = ({ battleButtonLabel, onCancel, winEstimate, -}) => ( -
- {!isStandaloneView && ( - <> -

- - Battle Pets -

-

{subtitle}

- - )} +}) => { + const winRate = winEstimate.isLoading + ? '…' + : winEstimate.winProbability != null + ? `${Math.round(winEstimate.winProbability * 100)}%` + : '—'; -
-
-
-
Your fighters
-
- {readyPets.length === 0 ? ( -
- No ready pets. Wait for cooldowns to finish before battling. -
- ) : ( -
- {readyPets.map(({ id, pet }) => ( - - ))} -
- )} -
+ const opponentEmpty = opponentsLoading + ? 'Finding challengers…' + : sortedOpponents.length === 0 + ? 'No opponents available' + : 'Select an opponent'; -
-
-
- - - Battle Arena - -
- - - {isArenaFighting - ? 'Fighting' - : showResult - ? 'Complete' - : isArenaReady - ? 'Ready' - : 'Setup'} - -
-
-
-
- -
-
- -
-
VS
-
- + return ( +
+ {!isStandaloneView && ( + <> +

+ + Battle Pets +

+

{subtitle}

+ + )} + +
+ {/* Your fighter */} +
+
Your Fighter
+
+
+
- - {opponent ? ( - - ) : null} - - - {isArenaReady && ( -
- {winEstimate.isLoading ? ( - Calculating odds… - ) : winEstimate.winProbability != null ? ( - <> - Win odds - = 0.5 - ? ' favorable' - : ' unfavorable' - }`} - > - {Math.round(winEstimate.winProbability * 100)}% - - {winEstimate.samples != null && ( - - ({winEstimate.samples.toLocaleString()} sim) - - )} - - ) : ( - Odds unavailable - )} + {/* VS + win rate */} +
+
VS
+
+
+
Win Rate
+
{winRate}
- )} +
+
-
- - {battleButtonLabel} - - + {/* On-chain rival */} +
+
On-Chain Rival
+
+ + + +
+
-
-
-
- Opponents - {fighterLevel != null ? ( - · sorted by level match - ) : null} -
- -
- {opponentsLoading && sortedOpponents.length === 0 ? ( -
Finding challengers in the arena…
- ) : sortedOpponents.length === 0 ? ( -
- No opponents available right now. Check back after more players join the - roster. -
- ) : ( -
- {sortedOpponents.map((o) => { - const key = opponentKey(o.owner, o.id); - return ( - - ); - })} -
- )} -
+ + {opponent ? ( + + ) : null} + + +
+ + ⚔ {battleButtonLabel} + + +
-
-); + ); +}; export default BattleSetup; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/fighter-picker-card.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/fighter-picker-card.tsx deleted file mode 100644 index 29ee9405..00000000 --- a/frontend/src/components/pet/interactions/panels/battle/parts/fighter-picker-card.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { getPetAvatar, getRarityColor, getRarityName, type Pet } from '@shared/core'; - -type FighterPickerCardProps = { - pet: Pet; - petId: string; - selected: boolean; - onSelect: (petId: string) => void; -}; - -/** Selectable card for one of the player's own ready fighters. */ -const FighterPickerCard: React.FC = ({ - pet, - petId, - selected, - onSelect, -}) => ( - -); - -export default FighterPickerCard; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/opponent-picker-card.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/opponent-picker-card.tsx deleted file mode 100644 index 23da7367..00000000 --- a/frontend/src/components/pet/interactions/panels/battle/parts/opponent-picker-card.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { getPetAvatar, getRarityColor, getRarityName, type OpponentPet } from '@shared/core'; -import { getLevelDelta, getMatchLabel, getMatchTier } from '../battle-matchmaking'; -import { opponentKey, shortAddress } from '../battle-utils'; - -type OpponentPickerCardProps = { - opponent: OpponentPet; - fighterLevel: number | null; - selected: boolean; - onSelect: (key: string) => void; - cardRef?: React.Ref; -}; - -/** Selectable card for a challenger, tagged with a level-match tier. */ -const OpponentPickerCard: React.FC = ({ - opponent, - fighterLevel, - selected, - onSelect, - cardRef, -}) => { - const key = opponentKey(opponent.owner, opponent.id); - const levelDelta = getLevelDelta(fighterLevel, opponent.level); - const matchTier = getMatchTier(levelDelta); - const matchLabel = getMatchLabel(matchTier, levelDelta); - - return ( - - ); -}; - -export default OpponentPickerCard; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx index 78919d8e..d1e70836 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { usePendingBattle, usePendingSolanaBattle } from '@shared/core'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; +import styles from '../index.module.css'; type PendingBattleNoticeProps = { /** Pet to check for an unresolved battle; null/empty renders nothing. */ @@ -37,7 +38,7 @@ const PendingBattleNotice: React.FC = ({ if (solanaPending.isPending && !pending.isPending) { const busy = solanaPending.cancel.isPending; return ( -
+

You have an unresolved battle on Solana. {solanaPending.canCancel @@ -45,7 +46,7 @@ const PendingBattleNotice: React.FC = ({ : ' Starting a new battle will resume it automatically.'}

{solanaPending.canCancel && ( -
+
diff --git a/frontend/src/components/pet/interactions/panels/breed/index.css b/frontend/src/components/pet/interactions/panels/breed/index.css deleted file mode 100644 index 5072a016..00000000 --- a/frontend/src/components/pet/interactions/panels/breed/index.css +++ /dev/null @@ -1,140 +0,0 @@ -.breed-tabs { - display: flex; - gap: 4px; - margin-bottom: 0; - border-bottom: 1px solid rgb(120 200 255 / 18%); - padding-bottom: 0; -} - -.breed-tab { - flex: 1; - padding: 10px 16px; - background: transparent; - border: 1px solid transparent; - border-bottom: none; - border-radius: 8px 8px 0 0; - color: rgb(180 210 255 / 55%); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.4px; - text-transform: uppercase; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - - &:hover:not(.active) { - color: rgb(180 210 255 / 88%); - background: rgb(120 200 255 / 6%); - } - - &.active { - color: var(--zi-text, #f6f3ff); - background: rgb(120 200 255 / 8%); - border-color: rgb(120 200 255 / 22%); - border-bottom-color: rgb(5 13 30 / 92%); - margin-bottom: -1px; - } - - &.spouse-tab.active { - color: #ffd07b; - background: rgb(255 181 67 / 8%); - border-color: rgb(255 181 67 / 24%); - border-bottom-color: rgb(5 13 30 / 92%); - } -} - -.breed-tab-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - background: rgb(255 181 67 / 28%); - border: 1px solid rgb(255 181 67 / 40%); - border-radius: 9px; - font-size: 11px; - font-weight: 700; - color: #ffd07b; -} - -.breed-tab-panel { - padding-top: 18px; - display: flex; - flex-direction: column; - gap: 14px; -} - -.breed-tab-hint { - font-size: 13px; - color: rgb(180 210 255 / 58%); - margin: 0; -} - -/* Partner (spouse) info — mirrors .picker .field select sizing exactly */ -.spouse-value { - display: flex; - align-items: center; - padding: 11px 14px; - font-size: 13px; - font-weight: 500; - letter-spacing: 0.25px; - background: rgb(255 181 67 / 6%); - border: 1px solid rgb(255 181 67 / 22%); - border-radius: 4px; - color: #ffd07b; - font-size: 13px; - font-weight: 600; -} - -.spouse-placeholder { - color: rgb(255 181 67 / 35%); - font-weight: 400; - font-style: italic; -} - -/* Stud fee notice */ -.stud-fee-notice { - font-size: 12px; - color: rgb(255 181 67 / 72%); - padding: 8px 12px; - background: rgb(255 181 67 / 5%); - border: 1px solid rgb(255 181 67 / 16%); - border-radius: 6px; - - strong { - color: #ffd07b; - } -} - -/* Relative / lineage conflict warning */ -.breed-relative-warning { - font-size: 13px; - color: rgb(255 110 110 / 85%); - padding: 8px 12px; - background: rgb(255 80 80 / 6%); - border: 1px solid rgb(255 80 80 / 20%); - border-radius: 6px; -} - -/* Empty state when no pets are married */ -.breed-no-married { - text-align: center; - padding: 28px 16px; - display: flex; - flex-direction: column; - gap: 6px; - - p { - margin: 0; - font-size: 13px; - color: rgb(180 210 255 / 45%); - } - - strong { - color: rgb(255 181 67 / 75%); - } -} diff --git a/frontend/src/components/pet/interactions/panels/breed/index.module.css b/frontend/src/components/pet/interactions/panels/breed/index.module.css new file mode 100644 index 00000000..3947a1e9 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/index.module.css @@ -0,0 +1,695 @@ +/* CSS Module — class names are local; reference via `import styles from '.../index.module.css'`. + Breed panel: tabs + parent previews + DNA helix/egg centre. Shared chrome + (.interface/.picker/.field/.action-controls) stays global in interactions.css; + the primary CTA reaches neon-button's global .neon-btn via :global(). */ +.tabs { + display: flex; + gap: 4px; + margin-bottom: 0; + border-bottom: 1px solid rgb(120 200 255 / 18%); + padding-bottom: 0; +} + +.tab { + flex: 1; + padding: 10px 16px; + background: transparent; + border: 1px solid transparent; + border-bottom: none; + border-radius: 8px 8px 0 0; + color: rgb(180 210 255 / 55%); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + + &:hover:not(.active) { + color: rgb(180 210 255 / 88%); + background: rgb(120 200 255 / 6%); + } + + &.active { + color: var(--zi-text, #f6f3ff); + background: rgb(120 200 255 / 8%); + border-color: rgb(120 200 255 / 22%); + border-bottom-color: rgb(5 13 30 / 92%); + margin-bottom: -1px; + } + + &.spouseTab.active { + color: #ffd07b; + background: rgb(255 181 67 / 8%); + border-color: rgb(255 181 67 / 24%); + border-bottom-color: rgb(5 13 30 / 92%); + } +} + +.tabBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: rgb(255 181 67 / 28%); + border: 1px solid rgb(255 181 67 / 40%); + border-radius: 9px; + font-size: 11px; + font-weight: 700; + color: #ffd07b; +} + +.tabPanel { + padding-top: 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.tabHint { + font-size: 13px; + color: rgb(180 210 255 / 58%); + margin: 0; +} + +/* Partner (spouse) info — mirrors .picker .field select sizing exactly */ +.spouseValue { + display: flex; + align-items: center; + padding: 11px 14px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.25px; + background: rgb(255 181 67 / 6%); + border: 1px solid rgb(255 181 67 / 22%); + border-radius: 4px; + color: #ffd07b; +} + +.spousePlaceholder { + color: rgb(255 181 67 / 35%); + font-weight: 400; + font-style: italic; +} + +/* Stud fee notice */ +.studFeeNotice { + font-size: 12px; + color: rgb(255 181 67 / 72%); + padding: 8px 12px; + background: rgb(255 181 67 / 5%); + border: 1px solid rgb(255 181 67 / 16%); + border-radius: 6px; + + strong { + color: #ffd07b; + } +} + +/* Relative / lineage conflict warning */ +.relativeWarning { + font-size: 13px; + color: rgb(255 110 110 / 85%); + padding: 8px 12px; + background: rgb(255 80 80 / 6%); + border: 1px solid rgb(255 80 80 / 20%); + border-radius: 6px; +} + +/* Empty state when no pets are married */ +.noMarried { + text-align: center; + padding: 28px 16px; + display: flex; + flex-direction: column; + gap: 6px; + + p { + margin: 0; + font-size: 13px; + color: rgb(180 210 255 / 45%); + } + + strong { + color: rgb(255 181 67 / 75%); + } +} + +/* ── Parent A · egg · Parent B preview (real stats) ─────────────────────────── */ +.parents { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: start; + gap: 12px; + margin-bottom: 10px; +} + +/* Parent column = card + Prev/Next cycle row (mock-style selection) */ +.parentCol { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.cycle { + display: flex; + gap: 8px; +} + +.cycleBtn { + flex: 1; + background: rgb(5 13 30 / 70%); + border: 1px solid rgb(101 131 255 / 22%); + border-radius: 6px; + padding: 8px; + font-size: 12px; + font-weight: 600; + color: rgb(195 210 255 / 65%); + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease; + + &:hover:not(:disabled) { + color: #f6f3ff; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.cycleBtnA { + border-color: rgb(255 207 112 / 20%); + color: rgb(255 207 112 / 65%); + + &:hover:not(:disabled) { + border-color: rgb(255 207 112 / 50%); + color: var(--cp-amber); + } +} + +.cycleBtnB { + border-color: rgb(181 140 255 / 20%); + color: rgb(181 140 255 / 65%); + + &:hover:not(:disabled) { + border-color: rgb(181 140 255 / 50%); + color: var(--cp-violet); + } +} + +/* With-Spouse tab: ◀ current pet ▶ cycle selector (replaces the dropdown) */ +.cycleSelect { + display: flex; + align-items: stretch; + gap: 8px; + + .cycleBtn { + flex: 0 0 auto; + min-width: 42px; + color: rgb(125 214 255 / 70%); + border-color: rgb(125 214 255 / 28%); + + &:hover:not(:disabled) { + border-color: var(--cp-cyan); + color: var(--cp-cyan); + } + } +} + +.cycleCurrent { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid rgb(125 214 255 / 28%); + background: rgb(4 10 26 / 96%); + font-size: 13px; + color: var(--zi-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── DNA helix + compatibility + egg centre ─────────────────────────────────── */ +.dna { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 140px; + padding: 4px 0; +} + +/* Breed action button, sitting between the two pets under the egg. */ +.dnaAction { + width: 100%; + margin-top: 2px; + + :global(.neon-btn) { + width: 100%; + min-width: 0; + padding: 11px 8px; + font-size: 11px; + letter-spacing: 0.4px; + white-space: nowrap; + } +} + +.dnaLabel { + font-size: 8px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgb(195 210 255 / 32%); +} + +.dnaHelix { + display: block; + overflow: visible; + filter: drop-shadow(0 0 4px rgb(125 214 255 / 25%)); +} + +.dnaStrand { + animation: cp-strand-glow 2.5s ease-in-out infinite; +} +.dnaStrandB { + animation-delay: 0.5s; +} +.dnaNode { + animation: cp-node-blink 2.5s ease-in-out infinite; +} + +/* Compatibility ring */ +.dnaCompat { + position: relative; + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dnaCompatSvg { + position: absolute; + inset: 0; + transform: rotate(-90deg); +} + +.dnaCompatArc { + filter: drop-shadow(0 0 5px rgb(125 214 255 / 70%)); + animation: cp-compat-draw 0.9s ease both; + transition: stroke-dasharray 0.5s ease; +} + +.dnaCompatText { + position: relative; + z-index: 1; + text-align: center; + line-height: 1; +} + +.dnaCompatPct { + display: block; + font-family: var(--cp-title-font); + font-size: 14px; + font-weight: 900; + color: var(--cp-cyan); +} + +.dnaCompatCap { + display: block; + margin-top: 2px; + font-size: 7px; + letter-spacing: 0.8px; + text-transform: uppercase; + color: rgb(195 210 255 / 45%); +} + +/* Egg / offspring estimate */ +.dnaEgg { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px 6px; + border-radius: 10px; + border: 1px solid rgb(255 207 112 / 18%); + background: rgb(10 16 32 / 85%); +} + +.dnaEggEmoji { + font-size: 26px; + line-height: 1; + filter: drop-shadow(0 0 10px rgb(255 207 112 / 80%)); + animation: cp-float 2.8s ease-in-out infinite; +} + +.dnaEggRarity { + font-family: var(--cp-title-font); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.dnaEggGen { + font-size: 9px; + color: rgb(195 210 255 / 40%); +} + +.parent { + min-width: 0; + border-radius: 12px; + overflow: hidden; + border: 1px solid rgb(120 150 255 / 12%); + background: linear-gradient(180deg, rgb(10 16 32 / 85%), rgb(6 10 22 / 90%)); +} + +.parentB { + border-color: rgb(120 150 255 / 12%); +} + +.parent.isEmpty { + display: flex; + align-items: center; + justify-content: center; + min-height: 128px; + border-style: dashed; +} + +.parentPlaceholder { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: rgb(195 210 255 / 40%); +} + +/* Avatar panel with rarity / level / bred badges */ +.parentVisual { + position: relative; + height: 74px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: radial-gradient(circle at 50% 45%, rgb(255 207 112 / 12%), transparent 62%); +} + +.parentB .parentVisual { + background: radial-gradient(circle at 50% 45%, rgb(181 140 255 / 12%), transparent 62%); +} + +.parentRarity { + position: absolute; + top: 8px; + right: 8px; + border: 1px solid; + border-radius: 3px; + padding: 2px 7px; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.9px; + text-transform: uppercase; + background: rgb(5 13 30 / 55%); +} + +.parentLevel { + position: absolute; + top: 8px; + left: 8px; + background: rgb(5 13 30 / 88%); + color: var(--cp-cyan); + border: 1px solid rgb(125 214 255 / 32%); + border-radius: 999px; + padding: 2px 8px; + font-size: 9px; + font-weight: 700; +} + +.parentB .parentLevel { + color: var(--cp-violet); + border-color: rgb(181 140 255 / 32%); +} + +.parentBred { + position: absolute; + bottom: 6px; + right: 8px; + background: rgb(255 207 112 / 12%); + color: var(--cp-amber); + border: 1px solid rgb(255 207 112 / 30%); + border-radius: 999px; + padding: 2px 8px; + font-size: 9px; + font-weight: 700; +} + +.parentB .parentBred { + background: rgb(181 140 255 / 12%); + color: var(--cp-violet); + border-color: rgb(181 140 255 / 30%); +} + +.parentAvatar { + font-size: 2.1rem; + line-height: 1; + flex-shrink: 0; + animation: cp-float 3.5s ease-in-out infinite; + filter: drop-shadow(0 0 18px rgb(255 207 112 / 55%)); +} + +.parentB .parentAvatar { + animation-duration: 4.2s; + filter: drop-shadow(0 0 18px rgb(181 140 255 / 55%)); +} + +.parentBody { + padding: 8px 12px 9px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.parentName { + font-family: var(--cp-title-font); + font-size: 13px; + font-weight: 700; + color: #f4f2ff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.parentClass { + font-size: 10px; + color: rgb(195 210 255 / 50%); + margin-bottom: 2px; +} + +.parentStats { + display: flex; + flex-direction: column; + gap: 5px; +} + +.parentStat { + display: flex; + align-items: center; + gap: 7px; +} + +.parentStatLabel { + font-size: 8px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + width: 24px; + flex-shrink: 0; +} + +.parentStatValue { + font-size: 11px; + font-weight: 800; + color: #f4f2ff; + width: 22px; + text-align: right; + flex-shrink: 0; +} + +.parentStatTrack { + flex: 1; + height: 5px; + background: var(--cp-inset); + border-radius: 999px; + overflow: hidden; +} + +.parentStatFill { + height: 100%; + border-radius: 999px; + animation: cp-bar-grow 0.7s ease both; +} + +/* XP / HP meters */ +.parentMeters { + margin-top: 3px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.parentMeter { + display: flex; + align-items: center; + gap: 6px; +} + +.parentMeterLabel { + font-size: 8px; + font-weight: 700; + width: 18px; + flex-shrink: 0; +} + +.parentMeterLabelXp { + color: rgb(125 214 255 / 50%); +} + +.parentMeterLabelHp { + color: rgb(15 255 174 / 50%); +} + +.parentMeterTrack { + flex: 1; + height: 4px; + background: var(--cp-inset); + border-radius: 999px; + overflow: hidden; +} + +.parentMeterFill { + height: 100%; + border-radius: 999px; +} + +.parentMeterFillXp { + background: linear-gradient(90deg, #7dd6ff, #b58cff); +} + +.parentMeterFillHp { + background: linear-gradient(90deg, #0fffae, #7dd6ff); +} + +.parentMeterValue { + font-size: 9px; + white-space: nowrap; + flex-shrink: 0; +} + +.parentMeterValueXp { + color: rgb(125 214 255 / 70%); +} + +.parentMeterValueHp { + color: rgb(15 255 174 / 70%); +} + +/* W / L record */ +.parentRecord { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 3px; + padding-top: 6px; + border-top: 1px solid rgb(101 131 255 / 10%); + font-size: 10px; + color: rgb(195 210 255 / 45%); +} + +.parentWr { + font-weight: 700; + color: rgb(125 214 255 / 70%); +} + +.parentB .parentWr { + color: rgb(181 140 255 / 70%); +} + +/* Breed-only keyframes moved here from styles/animations.css (sole consumer). */ +@keyframes cp-strand-glow { + 0%, + 100% { + filter: drop-shadow(0 0 3px rgb(125 214 255 / 40%)); + } + 50% { + filter: drop-shadow(0 0 9px rgb(125 214 255 / 90%)); + } +} +@keyframes cp-node-blink { + 0%, + 100% { + opacity: 0.65; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.4); + } +} +@keyframes cp-compat-draw { + from { + stroke-dasharray: 0 251; + } +} +@keyframes cp-bar-grow { + from { + width: 0; + } +} + +/* Local copy of the shared float keyframe (battle/pet-showcase still use the + global cp-float, so a module reference needs its own scoped copy). */ +@keyframes cp-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-9px); + } +} + +@media (max-width: 560px) { + .parents { + grid-template-columns: 1fr; + } + .dna { + justify-self: center; + width: 100%; + max-width: 200px; + } +} + +@media (prefers-reduced-motion: reduce) { + .dnaEggEmoji, + .dnaStrand, + .dnaNode, + .dnaCompatArc, + .parentAvatar, + .parentStatFill { + animation: none; + } +} diff --git a/frontend/src/components/pet/interactions/panels/breed/index.tsx b/frontend/src/components/pet/interactions/panels/breed/index.tsx index 460815af..f22e85a3 100644 --- a/frontend/src/components/pet/interactions/panels/breed/index.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/index.tsx @@ -20,7 +20,6 @@ import OwnPetsTab from './parts/own-pets-tab'; import WithSpouseTab from './parts/with-spouse-tab'; import StudFeeBalance from './parts/stud-fee-balance'; import type { BreedPanelProps, BreedTab } from './types'; -import './index.css'; const BREED_FAIL_MESSAGE = 'Failed to breed pets. Please try again.'; const AWAITING_HINT = 'Hang tight—your new pet will show up in a moment.'; @@ -200,69 +199,72 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { } }; + const breedButton = ( + + {buttonLabel} + + ); + return ( <> -
- {!isStandaloneView && ( -

- - Breed Pets -

- )} - - - - {tab === 'own' && ( - - )} - - {tab === 'spouse' && ( - - )} - - - -
- - {buttonLabel} - -
+ {!isStandaloneView && ( +

+ + Breed Pets +

+ )} + + + + {tab === 'own' && ( + + )} + + {tab === 'spouse' && ( + + )} + + + + {/* Own-pets breeding renders the action in the DNA centre (between the + two pets); the spouse tab has no centre preview, so keep it here. */} + {tab === 'spouse' &&
{breedButton}
} - {breed.isAwaitingFulfillment &&

{AWAITING_HINT}

} -
+ {breed.isAwaitingFulfillment &&

{AWAITING_HINT}

} {success && (
diff --git a/frontend/src/components/pet/interactions/panels/breed/parts/breed-dna-center.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/breed-dna-center.tsx new file mode 100644 index 00000000..19176a2e --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/breed-dna-center.tsx @@ -0,0 +1,173 @@ +import React, { useMemo } from 'react'; +import clsx from 'clsx'; +import { + getGeneration, + getPetProperties, + getRarityColor, + getRarityName, + type Pet, +} from '@shared/core'; +import styles from '../index.module.css'; + +type BreedDnaCenterProps = { + petA: Pet | null; + petB: Pet | null; + /** Breed action button, rendered under the egg between the two pets. */ + action?: React.ReactNode; +}; + +const CIRCUMFERENCE = 2 * Math.PI * 36; // r=36 → ≈226.19 + +const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, n)); + +/** + * Estimated breeding outcome, derived from the two selected parents' real DNA + * stats. Nothing here is authoritative — the contract decides the true result — + * but it gives the Breeding Lab its signature preview: genetic compatibility, + * likely offspring rarity, and next generation. + */ +const estimateOutcome = (petA: Pet, petB: Pet) => { + const a = getPetProperties(petA); + const b = getPetProperties(petB); + + const totalA = a.life + a.attack + a.defense + a.intelligence; + const totalB = b.life + b.attack + b.defense + b.intelligence; + // Combined stat strength (both parents strong → healthier offspring). + const strength = clamp((totalA + totalB) / 2 / 360, 0, 1); + // Genetic diversity — complementary parents breed truer. + const diversity = clamp( + (Math.abs(a.life - b.life) + + Math.abs(a.attack - b.attack) + + Math.abs(a.defense - b.defense) + + Math.abs(a.intelligence - b.intelligence)) / + 4 / + 80, + 0, + 1, + ); + const compat = clamp(Math.round((0.45 + strength * 0.35 + diversity * 0.12) * 100), 42, 99); + + const rarity = Math.max(petA.rarity, petB.rarity); + const genA = petA.generation ?? getGeneration(petA.dna); + const genB = petB.generation ?? getGeneration(petB.dna); + const gen = Math.max(genA, genB) + 1; + + return { compat, rarity, gen }; +}; + +/** Animated DNA double-helix — mirrors the redesign mock's Breeding Lab centre. */ +const DnaHelix: React.FC<{ animated: boolean }> = ({ animated }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +/** + * Central column of the breed preview: an animated DNA helix, a genetic + * compatibility ring, and an egg with the estimated offspring rarity + gen. + * Falls back to a muted placeholder until both parents are selected. + */ +const BreedDnaCenter: React.FC = ({ petA, petB, action }) => { + const outcome = useMemo( + () => (petA && petB ? estimateOutcome(petA, petB) : null), + [petA, petB], + ); + + const dash = outcome ? (outcome.compat / 100) * CIRCUMFERENCE : 0; + const rarityColor = outcome ? getRarityColor(outcome.rarity) : 'rgb(195 210 255 / 45%)'; + const rarityName = outcome ? getRarityName(outcome.rarity) : '—'; + + return ( +
+
+ DNA Strand +
+ + + +
+ + + + +
+ {outcome ? `${outcome.compat}%` : '—'} + Compat +
+
+ +
+ 🥚 + + {rarityName} + + {outcome ? `Gen ${outcome.gen}` : 'Select parents'} +
+ + {action ?
{action}
: null} +
+ ); +}; + +export default BreedDnaCenter; diff --git a/frontend/src/components/pet/interactions/panels/breed/parts/breed-parents-preview.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/breed-parents-preview.tsx new file mode 100644 index 00000000..3804a340 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/breed-parents-preview.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import clsx from 'clsx'; +import { + getLifePercent, + getPetAvatar, + getPetClass, + getPetProperties, + getRarityColor, + getRarityName, + getXpNumbers, + getXpPercent, + type Pet, +} from '@shared/core'; +import BreedDnaCenter from './breed-dna-center'; +import styles from '../index.module.css'; + +type BreedParentsPreviewProps = { + petA: Pet | null; + petB: Pet | null; + /** Breed action button, rendered in the DNA centre between the two pets. */ + action?: React.ReactNode; + /** Cycle controls under each parent card (Prev / Next). */ + onPrevA?: () => void; + onNextA?: () => void; + onPrevB?: () => void; + onNextB?: () => void; +}; + +/** ◀ Prev / Next ▶ cycle row under a parent card. */ +const CycleRow: React.FC<{ side: 'a' | 'b'; onPrev?: () => void; onNext?: () => void }> = ({ + side, + onPrev, + onNext, +}) => { + if (!onPrev && !onNext) return null; + return ( +
+ + +
+ ); +}; + +/** Four real DNA-backed stats. AGI has no backing in the data model, so we keep + * the gallery's STR / INT / DEF / VIT set rather than the mock's STR/AGI/INT/DEF. */ +const STAT_ROWS = [ + { label: 'STR', key: 'attack', color: '#ff7bcb' }, + { label: 'INT', key: 'intelligence', color: '#b58cff' }, + { label: 'DEF', key: 'defense', color: '#7dd6ff' }, + { label: 'VIT', key: 'life', color: '#0fffae' }, +] as const; + +const winRatio = (pet: Pet): number => { + const total = pet.winCount + pet.lossCount; + return total === 0 ? 0 : Math.round((pet.winCount / total) * 100); +}; + +const ParentCard: React.FC<{ pet: Pet | null; side: 'a' | 'b' }> = ({ pet, side }) => { + if (!pet) { + return ( +
+
Select a parent
+
+ ); + } + const props = getPetProperties(pet); + const rarityColor = getRarityColor(pet.rarity); + const xp = getXpNumbers(pet); + const hp = getLifePercent(pet); + + return ( +
+
+
+ {getRarityName(pet.rarity)} +
+
Lv.{pet.level}
+ {pet.breedCount != null && ( +
{pet.breedCount} bred
+ )} + {getPetAvatar(pet.dna)} +
+ +
+
{pet.name}
+
{getPetClass(pet.dna)}
+ +
+ {STAT_ROWS.map((row) => { + const value = props[row.key]; + return ( +
+ + {row.label} + + {value} +
+
+
+
+ ); + })} +
+ +
+
+ + XP + +
+
+
+ + {xp.xpCurrent}/{xp.xpMax} + +
+
+ + HP + +
+
+
+ + {hp}% + +
+
+ +
+ + {pet.winCount}W / {pet.lossCount}L + + {winRatio(pet)}% WR +
+
+
+ ); +}; + +/** Parent A · 🥚 · Parent B preview for the breed panel — all from real pet + * stats. The breed action button sits in the DNA centre between the two pets. */ +const BreedParentsPreview: React.FC = ({ + petA, + petB, + action, + onPrevA, + onNextA, + onPrevB, + onNextB, +}) => ( +
+
+ + +
+ +
+ + +
+
+); + +export default BreedParentsPreview; diff --git a/frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx index 9a3188e5..e7ad6ff5 100644 --- a/frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import clsx from 'clsx'; import type { BreedTab } from '../types'; +import styles from '../index.module.css'; type BreedTabBarProps = { tab: BreedTab; @@ -8,17 +10,17 @@ type BreedTabBarProps = { /** My Pets / With Spouse switcher. */ const BreedTabBar: React.FC = ({ tab, onChange }) => ( -
+
+
+ {selected ? ( + <> + {selected.name} · Lv {selected.level} + {selected.spouseId ? ` ↔ #${selected.spouseId}` : ''} + + ) : ( + Select a pet + )} +
+ +
+
+
+ Partner's pet +
+ {!spousePetId ? ( + — select your pet first — + ) : marriageLoading ? ( + Checking… + ) : spouseId ? ( + + ) : ( + Not married + )}
- )} - {areRelated && ( -

- Your pet and their spouse are relatives and cannot breed together. +

+
+ + {/* Not-married hint */} + {spousePetId && !marriageLoading && !isMarried && ( +
+

This pet is not married yet.

+

+ Go to the Marriage page to propose first.

- )} - {/* Only show recovery notice for the user's own pet. - The spouse's pet also has a pending flag while the breed - is in-flight, but the user can't settle/cancel it and - showing those buttons there is confusing. */} - {showPendingNotices && ( - - )} -
- - onChildNameChange(e.target.value)} - placeholder="Name for the new pet…" - maxLength={20} - />
- - )} -
-); + )} + + {/* Married — show stud fee + breed inputs */} + {spouseId && ( + <> + {studFeeLabel && ( +
+ Stud fee: {studFeeLabel} — paid to the spouse owner. +
+ )} + {areRelated && ( +

+ Your pet and their spouse are relatives and cannot breed together. +

+ )} + {/* Only show recovery notice for the user's own pet. + The spouse's pet also has a pending flag while the breed + is in-flight, but the user can't settle/cancel it and + showing those buttons there is confusing. */} + {showPendingNotices && ( + + )} +
+ + onChildNameChange(e.target.value)} + placeholder="Name for the new pet…" + maxLength={20} + /> +
+ + )} +
+ ); +}; export default WithSpouseTab; diff --git a/frontend/src/components/pet/interactions/panels/level-up/index.module.css b/frontend/src/components/pet/interactions/panels/level-up/index.module.css new file mode 100644 index 00000000..754db3bf --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/level-up/index.module.css @@ -0,0 +1,79 @@ +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Level-up details below the shared ring-avatar hero (level transition + XP). + Shared chrome (.interface/.picker/.field/.action-controls) stays global. */ +.name { + font-family: var(--cp-title-font); + font-size: 20px; + font-weight: 800; + color: #f4f2ff; + text-shadow: 0 0 14px rgb(181 140 255 / 50%); +} + +.petClass { + font-size: 12px; + color: rgb(195 210 255 / 50%); + margin-top: -6px; +} + +.transition { + display: flex; + align-items: center; + gap: 10px; +} + +.badge { + font-family: var(--cp-title-font); + font-size: 12px; + font-weight: 700; + border-radius: 999px; + padding: 5px 14px; +} + +.badgeCur { + color: var(--cp-violet); + background: rgb(181 140 255 / 12%); + border: 1px solid rgb(181 140 255 / 35%); +} + +.badgeNext { + color: var(--cp-gold); + background: rgb(255 215 0 / 12%); + border: 1px solid rgb(255 215 0 / 40%); + box-shadow: 0 0 12px rgb(255 215 0 / 20%); +} + +.arrow { + font-size: 20px; + color: rgb(255 215 0 / 65%); +} + +.xp { + width: 100%; + max-width: 240px; +} + +.xpRow { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: rgb(125 214 255 / 55%); +} + +.xpTrack { + height: 8px; + background: var(--cp-inset); + border-radius: 999px; + overflow: hidden; + border: 1px solid rgb(125 214 255 / 12%); +} + +.xpFill { + height: 100%; + background: var(--cp-gradient-line); + border-radius: 999px; + box-shadow: 0 0 10px rgb(181 140 255 / 50%); +} diff --git a/frontend/src/components/pet/interactions/panels/level-up/index.tsx b/frontend/src/components/pet/interactions/panels/level-up/index.tsx index 5802cd74..fc33a857 100644 --- a/frontend/src/components/pet/interactions/panels/level-up/index.tsx +++ b/frontend/src/components/pet/interactions/panels/level-up/index.tsx @@ -1,8 +1,13 @@ import React, { useMemo, useState } from 'react'; +import clsx from 'clsx'; import TransactionStatus from '@components/common/transaction-status'; import NeonButton from '@components/ui/neon-button'; import { + getPetAvatar, + getPetClass, getReadyPetsUnified, + getXpNumbers, + getXpPercent, useChainCapabilities, useFees, useLevelUpPet, @@ -13,6 +18,8 @@ import { useTxErrorToast } from '@hooks/useTxErrorToast'; import Icon, { CheckIcon } from '@components/ui/icon'; import { Tones } from '@constants/tones'; import SyncMetadataButton from './sync-metadata-button'; +import PetShowcase from '../_shared/pet-showcase'; +import styles from './index.module.css'; export type LevelUpPanelProps = { isStandaloneView?: boolean; @@ -46,7 +53,9 @@ const LevelUpPanel: React.FC = ({ isStandaloneView = true }) }); const readyPets = useMemo(() => getReadyPetsUnified(pets), [pets]); const fees = useFees(); - const selectedLevel = readyPets.find(({ id }) => id === selectedPet)?.pet.level; + const selectedPetObj = readyPets.find(({ id }) => id === selectedPet)?.pet ?? null; + const selectedLevel = selectedPetObj?.level; + const selectedXp = selectedPetObj ? getXpNumbers(selectedPetObj) : null; // Level-up fee is level-scaled: baseFee × (100 + (level-1)²) / 100. const levelUpCost = useMemo(() => { @@ -91,7 +100,7 @@ const LevelUpPanel: React.FC = ({ isStandaloneView = true })
{!isStandaloneView && ( <> -

⬆️ Level Up Pet

+

⬆️ Level Up Pet

{levelUpFee ? `Pay from ${levelUpFee.amount} ${levelUpFee.symbol} to level up your pet — cost rises with level` @@ -100,6 +109,38 @@ const LevelUpPanel: React.FC = ({ isStandaloneView = true }) )} + {selectedPetObj && ( + +

{selectedPetObj.name}
+
{getPetClass(selectedPetObj.dna)}
+
+ + Lv.{selectedPetObj.level} + + + → + + + Lv.{selectedPetObj.level + 1} + +
+
+
+ XP + + {selectedXp?.xpCurrent}/{selectedXp?.xpMax} + +
+
+
+
+
+ + )} +
diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.css b/frontend/src/components/pet/interactions/panels/marriage/index.css deleted file mode 100644 index 559659c0..00000000 --- a/frontend/src/components/pet/interactions/panels/marriage/index.css +++ /dev/null @@ -1,366 +0,0 @@ -/* ── Marriage tab UI ─────────────────────────────────────────────────────── */ - -/* - * Marriage page — romantic / warm theme - * - * Palette — cherry blossom / dusty rose (high-lightness, low-saturation pinks): - * rose-dust hsl(347 50% 80%) → rgb(232 175 188) tabs, borders - * blush hsl(345 55% 89%) → rgb(244 212 220) text, light accents - * peach-warm hsl(20 45% 83%) → rgb(234 203 185) second partner warmth - * heart-pink hsl(345 45% 75%) → rgb(219 157 172) heart colour - * - * None of these are anywhere near blood-red — they are soft pastels that read - * as cherry blossom / strawberry cream against the dark background. - */ - -.marriage-interface { - display: flex; - flex-direction: column; - gap: 0; - background: linear-gradient(160deg, rgb(232 175 188 / 4%) 0%, transparent 55%); - border-radius: 0 0 10px 10px; -} - -.marriage-tabs { - display: flex; - gap: 4px; - margin-bottom: 0; - border-bottom: 1px solid rgb(232 175 188 / 20%); - padding-bottom: 0; -} - -.marriage-tab { - flex: 1; - padding: 10px 16px; - background: transparent; - border: 1px solid transparent; - border-bottom: none; - border-radius: 8px 8px 0 0; - color: rgb(232 175 188 / 55%); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.4px; - text-transform: uppercase; - cursor: pointer; - transition: all 0.2s ease; - - &:hover:not(.active) { - color: rgb(232 175 188 / 88%); - background: rgb(232 175 188 / 6%); - } - - &.active { - color: #f0d5de; - background: rgb(232 175 188 / 9%); - border-color: rgb(232 175 188 / 26%); - border-bottom-color: rgb(5 13 30 / 92%); - margin-bottom: -1px; - } -} - -.marriage-tab-panel { - padding-top: 20px; -} - -.marriage-tab-hint { - font-size: 13px; - color: rgb(232 175 188 / 58%); - margin: 0 0 16px !important; - text-align: left !important; -} - -.marriage-status-section { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid rgb(232 175 188 / 16%); - display: flex; - flex-direction: column; - gap: 10px; -} - -.marriage-status-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: rgb(232 175 188 / 60%); -} - -/* ── Action buttons ───────────────────────────────────────────────────── */ - -/* Layout only — colour/glow come from . */ -.propose-button { - grid-column: 1 / -1; -} - -/* ── Marriage list & cards ────────────────────────────────────────────── */ - -.marriage-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 10px; - - &:empty::after { - content: 'No married pets yet. Send a proposal to find a match! 💕'; - display: block; - padding: 14px 12px; - font-size: 12px; - color: rgb(232 175 188 / 38%); - font-style: italic; - text-align: center; - } -} - -/* Romantic two-pet marriage card — cherry blossom palette */ -.marriage-card { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px 16px; - border: 1px solid rgb(232 175 188 / 28%); - border-radius: 12px; - background: linear-gradient(135deg, rgb(244 212 220 / 7%) 0%, rgb(234 203 185 / 5%) 100%); - box-shadow: 0 0 20px rgb(219 157 172 / 8%), inset 0 1px 0 rgb(244 212 220 / 10%); - transition: box-shadow 0.3s ease, border-color 0.3s ease; - - &:hover { - border-color: rgb(232 175 188 / 44%); - box-shadow: 0 0 28px rgb(219 157 172 / 14%), inset 0 1px 0 rgb(244 212 220 / 14%); - } -} - -.marriage-pair { - display: flex; - align-items: center; - gap: 14px; - flex: 1; - min-width: 0; -} - -.marriage-partner { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; -} - -.partner-name { - font-size: 14px; - font-weight: 700; - color: #f0d5de; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.partner-meta { - font-size: 11px; - color: rgb(232 175 188 / 48%); -} - -/* Heart — soft candy pink, gentle pulse, no vivid red */ -.marriage-heart { - font-size: 20px; - color: #e8a0b4; - text-shadow: 0 0 10px rgb(219 157 172 / 55%); - flex-shrink: 0; - animation: marriage-heartbeat 2.2s ease-in-out infinite; -} - -@keyframes marriage-heartbeat { - 0%, - 100% { - transform: scale(1); - opacity: 0.85; - } - 35% { - transform: scale(1.2); - opacity: 1; - } - 50% { - transform: scale(1.08); - opacity: 0.95; - } -} - -/* Legacy row class kept for any stray usage */ -.marriage-row { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - border: 1px solid rgb(232 175 188 / 20%); - border-radius: 8px; - background: rgb(5 13 30 / 50%); -} - -.marriage-row .marriage-pet { - font-weight: 700; -} - -.marriage-row .marriage-status { - margin-left: auto; - opacity: 0.85; - font-size: var(--font-size-xs); -} - -/* Propose tab — sent proposals section */ -.sent-proposals-section { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid rgb(125 214 255 / 14%); - display: flex; - flex-direction: column; - gap: 8px; -} - -.sent-proposals-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: rgb(195 210 255 / 50%); -} - -/* Outgoing proposal card variant (amber tint vs green for incoming) */ -.outgoing-proposal { - border-color: rgb(255 181 67 / 22%) !important; - background: rgb(255 181 67 / 4%) !important; -} - -/* Empty state when all OutgoingProposalRow components return null */ -.sent-proposals-section .proposals-list:empty::after { - content: 'No pending proposals sent.'; - display: block; - padding: 10px 12px; - font-size: 12px; - color: rgb(125 214 255 / 35%); - font-style: italic; - text-align: center; -} - -/* Accept tab — tab badge (unread count) */ -.marriage-tab-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 4px; - margin-left: 6px; - border-radius: 9px; - background: rgb(0 255 157 / 22%); - border: 1px solid rgb(0 255 157 / 48%); - color: #9effd4; - font-size: 11px; - font-weight: 700; - line-height: 1; -} - -/* Accept tab — empty state */ -.proposals-empty { - padding: 20px 12px; - font-size: 13px; - color: rgb(125 214 255 / 40%); - font-style: italic; - text-align: center; -} - -/* Accept tab — proposals list */ -.proposals-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.proposal-card { - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 12px; - border: 1px solid rgb(0 255 157 / 22%); - border-radius: 8px; - background: rgb(0 255 157 / 4%); -} - -.proposal-pets { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - font-weight: 600; - flex-wrap: wrap; -} - -.proposal-proposer { - color: rgb(195 210 255 / 90%); -} - -.proposal-arrow { - color: rgb(0 255 157 / 60%); -} - -.proposal-target { - color: #9effd4; -} - -.proposal-id { - font-weight: 400; - font-size: 12px; - opacity: 0.7; -} - -.proposal-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.proposal-expiry { - font-size: 11px; - color: rgb(125 214 255 / 45%); -} - -/* Confirm accept modal — body content only; chrome comes from . */ -.confirm-body { - margin: 0; - font-size: 14px; - color: rgb(195 210 255 / 80%); - line-height: 1.5; -} - -.confirm-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 16px; -} - -.confirm-cancel { - padding: 8px 18px; - border-radius: 6px; - background: transparent; - border: 1px solid rgb(125 214 255 / 25%); - color: rgb(195 210 255 / 70%); - font-size: 13px; - cursor: pointer; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: rgb(125 214 255 / 8%); - border-color: rgb(125 214 255 / 50%); - } - - &:disabled { - opacity: 0.45; - cursor: not-allowed; - } -} diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.module.css b/frontend/src/components/pet/interactions/panels/marriage/index.module.css new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.tsx b/frontend/src/components/pet/interactions/panels/marriage/index.tsx index 2e4790cc..e1e637dc 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/index.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import clsx from 'clsx'; import { useQueryClient } from '@tanstack/react-query'; import { useChainCapabilities, @@ -18,7 +19,7 @@ import AcceptTab from './parts/accept-tab'; import ActiveMarriages from './parts/active-marriages'; import AcceptConfirmDialog from './parts/accept-confirm-dialog'; import type { MarriagePanelProps, MarriageTab, PendingAccept } from './types'; -import './index.css'; +import styles from './index.module.css'; const MarriagePanel: React.FC = ({ isStandaloneView = true }) => { const { kind, activeKind, walletAddress } = useChainCapabilities(); @@ -108,7 +109,7 @@ const MarriagePanel: React.FC = ({ isStandaloneView = true } return ( <> -
+
{!isStandaloneView && ( <>

💍 Marriage

@@ -123,15 +124,31 @@ const MarriagePanel: React.FC = ({ isStandaloneView = true } /> {tab === 'propose' && ( - + <> +
+ + 💝 + +
+
+ Marriage unlocks cross-owner breeding +
+
+ Bond two pets to breed across wallets — propose, accept, or + divorce anytime. +
+
+
+ + )} {tab === 'accept' && ( diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx index 48b035a3..c054fe64 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx @@ -2,6 +2,7 @@ import React from 'react'; import NeonButton from '@components/ui/neon-button'; import NeonModal from '@components/ui/neon-modal'; import type { PendingAccept } from '../types'; +import styles from '../index.module.css'; type AcceptConfirmDialogProps = { pending: PendingAccept; @@ -27,12 +28,12 @@ const AcceptConfirmDialog: React.FC = ({ title="💒 Accept Proposal?" contentClassName="marriage-confirm-body" > -

+

{pending.proposal.proposerPetName} (#{pending.proposal.proposerPetId}) will marry your {targetPetName(pending.myPetId)} (#{pending.myPetId}).

-
- diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx index e6eb76ac..7986e66a 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { type IncomingProposal } from '@shared/core'; import IncomingProposalRow from './incoming-proposal-row'; +import styles from '../index.module.css'; type AcceptTabProps = { proposals: IncomingProposal[]; @@ -18,17 +19,17 @@ const AcceptTab: React.FC = ({ targetPetName, onAccept, }) => ( -
-

+

+

Pending proposals from other players to marry one of your pets.

{isLoading ? ( -
Checking for proposals…
+
Checking for proposals…
) : proposals.length === 0 ? ( -
No pending proposals for your pets.
+
No pending proposals for your pets.
) : ( -
    +
      {proposals.map((p) => ( = ({ busy, onDivorce, }) => ( -
      - ❤ Your marriages -
        +
        + ❤ Your marriages +
          {chainPets.map((p) => ( = ({ busy, onAccept, }) => ( -
        • -
          - +
        • +
          + {proposal.proposerPetName}{' '} - #{proposal.proposerPetId} + #{proposal.proposerPetId} - - + → + your {targetPetName(proposal.targetPetId)}{' '} - #{proposal.targetPetId} + #{proposal.targetPetId}
          -
          - Expires {formatExpiry(proposal.expiry)} +
          + Expires {formatExpiry(proposal.expiry)} onAccept(proposal)}> Accept @@ -38,3 +39,4 @@ const IncomingProposalRow: React.FC = ({ ); export default IncomingProposalRow; + diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx index ab5f5dc4..e38fe156 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx @@ -7,6 +7,7 @@ import { type PetChain, } from '@shared/core'; import { AuthActionButton } from '@components/common'; +import styles from '../index.module.css'; type MarriageCardProps = { pet: Pet; @@ -34,20 +35,20 @@ const MarriageCard: React.FC = ({ pet, chain, petById, onDivo const spouseLevel = fromMap?.level ?? fetched.level; return ( -
        • -
          -
          - {pet.name} - +
        • +
          +
          + {pet.name} + #{pet.id} · Lv {pet.level}
          - + -
          - {spouseName} - +
          + {spouseName} + #{spouseId} {spouseLevel != null ? ` · Lv ${spouseLevel}` : ''} diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx index 137d32a3..08bb09c5 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import clsx from 'clsx'; import type { MarriageTab } from '../types'; +import styles from '../index.module.css'; type MarriageTabBarProps = { tab: MarriageTab; @@ -9,21 +11,21 @@ type MarriageTabBarProps = { /** Propose / Accept switcher with a badge for pending incoming proposals. */ const MarriageTabBar: React.FC = ({ tab, onChange, proposalCount }) => ( -
          +
          ); diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx index ddc36734..3dd19b68 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React from 'react'; +import clsx from 'clsx'; import { formatExpiry, useMarriageInfo, type Pet } from '@shared/core'; import { AuthActionButton } from '@components/common'; +import styles from '../index.module.css'; type OutgoingProposalRowProps = { pet: Pet; @@ -27,16 +29,16 @@ const OutgoingProposalRow: React.FC = ({ const expirySec = info.proposalExpiry ? Number(info.proposalExpiry) : 0; return ( -
        • -
          - - {pet.name} #{pet.id} +
        • +
          + + {pet.name} #{pet.id} - - #{info.proposalPetIdB?.toString()} + → + #{info.proposalPetIdB?.toString()}
          -
          - Expires {formatExpiry(expirySec)} +
          + Expires {formatExpiry(expirySec)} = ({ }; export default OutgoingProposalRow; + diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx index d9befa3b..1368d6fe 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx @@ -3,6 +3,7 @@ import { type Pet, type PetChain } from '@shared/core'; import { AuthActionButton } from '@components/common'; import PetSearchDropdown from '@components/ui/pet-search-dropdown'; import OutgoingProposalRow from './outgoing-proposal-row'; +import styles from '../index.module.css'; type ProposeTabProps = { chainPets: Pet[]; @@ -37,8 +38,8 @@ const ProposeTab: React.FC = ({ }; return ( -
          -

          +

          +

          Select one of your pets, then search for your partner's pet to send a marriage proposal.

          @@ -67,7 +68,7 @@ const ProposeTab: React.FC = ({ />
          void handlePropose()} @@ -77,9 +78,9 @@ const ProposeTab: React.FC = ({
          {chainPets.length > 0 && ( -
          - Sent proposals -
            +
            + Sent proposals +
              {chainPets.map((p) => ( = ({ isStandaloneView = true }) => const [selectedPet, setSelectedPet] = useState(''); const [newName, setNewName] = useState(''); + const [activeTheme, setActiveTheme] = useState(null); const [success, setSuccess] = useState(null); // Settlement is lifecycle-driven (EVM: receipt confirmed; Solana: resolve). @@ -49,6 +71,10 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) => [readyPets, renameMinLevel], ); + const selectedPetObj = selectablePets.find(({ id }) => id === selectedPet)?.pet ?? null; + const previewName = newName.trim() || selectedPetObj?.name || 'New Name'; + const meetsMin = newName.trim().length >= 2; + const handleChangeName = async () => { if (!isConnected) { notifyError('Please connect your wallet first', undefined, 'rename-validation'); @@ -86,6 +112,35 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) => )} + {selectedPetObj && ( + +
              + {previewName} +
              +
              + {getPetClass(selectedPetObj.dna)} · Lv.{selectedPetObj.level} +
              +
              +
              + {meetsMin ? '✓' : '○'} Min 2 characters +
              +
              + ✓ Max {MAX_NAME_LEN} characters ({newName.length}) +
              +
              +
              + )} +
              @@ -109,14 +164,54 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) => id="rename-new-name" type="text" value={newName} - onChange={(e) => setNewName(e.target.value)} + onChange={(e) => { + setNewName(e.target.value); + setActiveTheme(null); + }} placeholder="Enter new name..." - maxLength={20} + maxLength={MAX_NAME_LEN} />
              -
              +
              +
              Name Theme
              +
              + {RENAME_THEMES.map((theme, i) => ( + + ))} +
              +
              + +
              = ({ isStandaloneView = true }) => { }); const readyPets = useMemo(() => getReadyPetsUnified(pets), [pets]); const fees = useFees(); - const selectedLevel = readyPets.find(({ id }) => id === selectedPet)?.pet.level; + const selectedPetObj = readyPets.find(({ id }) => id === selectedPet)?.pet ?? null; + const selectedLevel = selectedPetObj?.level; + const selectedXp = selectedPetObj ? getXpNumbers(selectedPetObj) : null; // Train fee is level-scaled: baseFee × (100 + 2·level) / 100. const trainCost = useMemo(() => { @@ -79,6 +92,32 @@ const TrainPanel: React.FC = ({ isStandaloneView = true }) => { )} + {selectedPetObj && ( +
              +
              + Lv.{selectedPetObj.level} + + {getPetAvatar(selectedPetObj.dna)} + +
              +
              +
              {selectedPetObj.name}
              +
              + {getPetClass(selectedPetObj.dna)} +
              +
              +
              +
              +
              + {selectedXp?.xpCurrent}/{selectedXp?.xpMax} XP +
              +
              +
              + )} +
              diff --git a/frontend/src/components/pet/interactions/standalone/index.tsx b/frontend/src/components/pet/interactions/standalone/index.tsx index 562ea8eb..a0d33440 100644 --- a/frontend/src/components/pet/interactions/standalone/index.tsx +++ b/frontend/src/components/pet/interactions/standalone/index.tsx @@ -26,10 +26,13 @@ const InteractionStandalone: React.FC = ({ const header = STANDALONE_INTERACTION_HEADERS[action]; const goBack = () => navigate(DASHBOARD_HOME); + // Per-view class drives the background wash + accent color (see interactions.css). + const viewClass = `interaction-standalone interaction-standalone--${action}`; + if (!isConnected) { return ( @@ -45,7 +48,7 @@ const InteractionStandalone: React.FC = ({ if (isLoading && pets.length === 0) { return ( @@ -65,7 +68,7 @@ const InteractionStandalone: React.FC = ({ if (pets.length === 0) { return ( @@ -82,7 +85,7 @@ const InteractionStandalone: React.FC = ({ if (minPets > 1 && pets.length < minPets) { return ( @@ -99,7 +102,7 @@ const InteractionStandalone: React.FC = ({ return ( diff --git a/frontend/src/components/pet/transfer/send-pet-modal/index.css b/frontend/src/components/pet/transfer/send-pet-modal/index.module.css similarity index 81% rename from frontend/src/components/pet/transfer/send-pet-modal/index.css rename to frontend/src/components/pet/transfer/send-pet-modal/index.module.css index 40a22cff..20727369 100644 --- a/frontend/src/components/pet/transfer/send-pet-modal/index.css +++ b/frontend/src/components/pet/transfer/send-pet-modal/index.module.css @@ -1,8 +1,9 @@ -/* Body content only — the overlay/dialog/header/close chrome comes from . */ -.send-pet-body { +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Body content only — the overlay/dialog/header/close chrome comes from . */ +.sendPetBody { .preview { background: rgb(5 13 30 / 78%); - border: 1px solid var(--neon-border-soft); + border: 1px solid var(--cp-border-soft); border-radius: 2px; padding: 16px; margin-bottom: 20px; @@ -10,13 +11,13 @@ & h3 { margin: 0 0 12px 0; - font-family: var(--title-font); + font-family: var(--cp-title-font); color: #f6f3ff; font-size: 1.25rem; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; - text-shadow: 0 0 10px var(--neon-text-glow); + text-shadow: 0 0 10px var(--cp-text-glow); } } @@ -65,12 +66,12 @@ &:focus { outline: none; - border-color: var(--neon-cyan); - box-shadow: 0 0 0 2px var(--neon-cyan), 0 0 12px rgb(125 214 255 / 32%); + border-color: var(--cp-cyan); + box-shadow: 0 0 0 2px var(--cp-cyan), 0 0 12px rgb(125 214 255 / 32%); } &.invalid { - border-color: var(--neon-magenta); + border-color: var(--cp-magenta); box-shadow: 0 0 12px rgb(255 110 196 / 28%); } @@ -108,11 +109,11 @@ transition: all 0.2s; background: rgb(5 13 30 / 92%); color: rgb(195 210 255 / 78%); - border: 1px solid var(--neon-border-soft); + border: 1px solid var(--cp-border-soft); &:hover:not(:disabled) { color: #f6f3ff; - border-color: var(--neon-border-strong); + border-color: var(--cp-border-strong); box-shadow: 0 0 12px rgb(125 214 255 / 22%); } diff --git a/frontend/src/components/pet/transfer/send-pet-modal/index.tsx b/frontend/src/components/pet/transfer/send-pet-modal/index.tsx index 3060fe93..bf961dad 100644 --- a/frontend/src/components/pet/transfer/send-pet-modal/index.tsx +++ b/frontend/src/components/pet/transfer/send-pet-modal/index.tsx @@ -1,11 +1,11 @@ -import React, { useState } from 'react'; +import React, { useState } from 'react'; import { useChainCapabilities, usePetList, useTransferPet } from '@shared/core'; import TransactionStatus from '@components/common/transaction-status'; import NeonButton from '@components/ui/neon-button'; import NeonModal from '@components/ui/neon-modal'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; -import './index.css'; +import styles from './index.module.css'; interface SendPetModalProps { isOpen: boolean; @@ -91,11 +91,11 @@ const SendPetModal: React.FC = ({ isOpen, onClose, pet, petId isOpen={isOpen} onRequestClose={handleClose} title="Send Pet" - contentClassName="send-pet-body" + contentClassName={styles.sendPetBody} > -
              +

              {pet.name}

              -
              +

              Level: {pet.level}

              @@ -108,7 +108,7 @@ const SendPetModal: React.FC = ({ isOpen, onClose, pet, petId
              -
              +
              = ({ isOpen, onClose, pet, petId }} placeholder={addressPlaceholder} disabled={isPending} - className={inputInvalid ? 'invalid' : ''} + className={inputInvalid ? styles.invalid : undefined} />
              -
              - = ({ isOpen, onClose, pet, petId }; export default SendPetModal; + diff --git a/frontend/src/components/ui/icon/index.css b/frontend/src/components/ui/icon/index.module.css similarity index 64% rename from frontend/src/components/ui/icon/index.css rename to frontend/src/components/ui/icon/index.module.css index 1845a397..bad553d3 100644 --- a/frontend/src/components/ui/icon/index.css +++ b/frontend/src/components/ui/icon/index.module.css @@ -1,4 +1,5 @@ -.neon-icon { +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. */ +.icon { display: inline-flex; align-items: center; justify-content: center; @@ -8,31 +9,31 @@ margin-inline-end: 0.4em; flex-shrink: 0; - &.no-gap { + &.noGap { margin-inline-end: 0; vertical-align: middle; } - &.tone-cyan { - color: var(--neon-cyan); + &.cyan { + color: var(--cp-cyan); } - &.tone-violet { - color: var(--neon-violet); + &.violet { + color: var(--cp-violet); } - &.tone-magenta { - color: var(--neon-magenta); + &.magenta { + color: var(--cp-magenta); } - &.tone-emerald { + &.emerald { color: #9effd4; } - &.tone-amber { + &.amber { color: #ffd07b; } - &.glow-soft { + &.soft { filter: drop-shadow(0 0 6px currentColor); } - &.glow-strong { + &.strong { filter: drop-shadow(0 0 10px currentColor) drop-shadow(0 0 18px currentColor); } diff --git a/frontend/src/components/ui/icon/index.tsx b/frontend/src/components/ui/icon/index.tsx index 28bcbf47..6e8204cc 100644 --- a/frontend/src/components/ui/icon/index.tsx +++ b/frontend/src/components/ui/icon/index.tsx @@ -17,8 +17,9 @@ import { GiUpgrade, } from 'react-icons/gi'; import { IoCheckmarkSharp, IoClose, IoCopy, IoPauseSharp, IoWarning } from 'react-icons/io5'; +import clsx from 'clsx'; import { type Tone } from '@constants/tones'; -import './index.css'; +import styles from './index.module.css'; export type IconTone = Exclude; export type IconGlow = 'none' | 'soft' | 'strong'; @@ -33,23 +34,25 @@ export type IconProps = { size?: IconSize; tone?: IconTone; glow?: IconGlow; + /** Drop the trailing inline gap (icon sits flush against following content). */ + noGap?: boolean; className?: string; title?: string; }; const TONE_CLASS: Record = { - cyan: 'tone-cyan', - violet: 'tone-violet', - magenta: 'tone-magenta', - emerald: 'tone-emerald', - amber: 'tone-amber', - inherit: 'tone-inherit', + cyan: styles.cyan, + violet: styles.violet, + magenta: styles.magenta, + emerald: styles.emerald, + amber: styles.amber, + inherit: '', }; const GLOW_CLASS: Record = { none: '', - soft: 'glow-soft', - strong: 'glow-strong', + soft: styles.soft, + strong: styles.strong, }; const Icon: React.FC = ({ @@ -57,12 +60,11 @@ const Icon: React.FC = ({ size = '1em', tone = 'inherit', glow = 'soft', + noGap = false, className, title, }) => { - const cls = ['neon-icon', TONE_CLASS[tone], GLOW_CLASS[glow], className] - .filter(Boolean) - .join(' '); + const cls = clsx(styles.icon, TONE_CLASS[tone], GLOW_CLASS[glow], noGap && styles.noGap, className); return ( & { as?: 'article' | 'div' | 'section'; @@ -9,7 +9,7 @@ type NeonCardProps = React.HTMLAttributes & { const NeonCard = ({ as = 'article', className, children, ...props }: NeonCardProps) => { const Tag = as; - const classes = clsx('neon-card', className); + const classes = clsx(styles.card, className); return ( diff --git a/frontend/src/components/ui/pet-search-dropdown/index.css b/frontend/src/components/ui/pet-search-dropdown/index.module.css similarity index 80% rename from frontend/src/components/ui/pet-search-dropdown/index.css rename to frontend/src/components/ui/pet-search-dropdown/index.module.css index 82d179c0..34e95771 100644 --- a/frontend/src/components/ui/pet-search-dropdown/index.css +++ b/frontend/src/components/ui/pet-search-dropdown/index.module.css @@ -1,4 +1,8 @@ -.pet-search-dropdown { +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Pet search + select dropdown. NOTE: `.psd-input` stays a GLOBAL class because + interactions.css targets it via `input:not(.psd-input)` to exclude this search + field from the generic field-input styling. */ +.petSearchDropdown { position: relative; width: 100%; @@ -10,7 +14,7 @@ /* ── Input wrapper ─────────────────────────────────────────────────────────── */ -.psd-input-wrap { +.psdInputWrap { position: relative; display: flex; align-items: center; @@ -24,23 +28,23 @@ min-height: 42px; cursor: text; - &:hover:not(.is-selected) { + &:hover:not(.isSelected) { border-color: rgb(125 214 255 / 55%); box-shadow: inset 0 0 20px rgb(125 214 255 / 7%), 0 0 10px rgb(125 214 255 / 14%); } - &.is-open { - border-color: var(--neon-cyan); - box-shadow: 0 0 0 2px var(--neon-cyan), 0 0 12px rgb(125 214 255 / 32%); + &.isOpen { + border-color: var(--cp-cyan); + box-shadow: 0 0 0 2px var(--cp-cyan), 0 0 12px rgb(125 214 255 / 32%); } - &.is-selected { + &.isSelected { border-color: rgb(125 214 255 / 55%); box-shadow: inset 0 0 20px rgb(125 214 255 / 7%), 0 0 8px rgb(125 214 255 / 16%); } } -.psd-input { +:global(.psd-input) { flex: 1; min-width: 0; background: transparent; @@ -50,32 +54,32 @@ font-size: 13px; font-weight: 500; letter-spacing: 0.25px; - caret-color: var(--neon-cyan); + caret-color: var(--cp-cyan); padding: 11px 0; &::placeholder { color: rgb(125 214 255 / 28%); font-style: italic; } +} - /* when a pet is selected, dim the name slightly to signal "read mode" */ - .is-selected & { - color: #f6f3ff; - font-weight: 600; - } +/* when a pet is selected, dim the name slightly to signal "read mode" */ +.isSelected :global(.psd-input) { + color: #f6f3ff; + font-weight: 600; } -.psd-selected-badge { +.psdSelectedBadge { flex-shrink: 0; font-size: 11px; font-weight: 600; letter-spacing: 0.4px; - color: var(--neon-cyan); + color: var(--cp-cyan); opacity: 0.75; white-space: nowrap; } -.psd-chevron { +.psdChevron { position: absolute; inset-inline-end: 13px; inset-block-start: 50%; @@ -88,11 +92,11 @@ &.up { transform: translateY(-50%) rotate(180deg); - color: var(--neon-cyan); + color: var(--cp-cyan); } } -.psd-clear { +.psdClear { position: absolute; inset-inline-end: 10px; inset-block-start: 50%; @@ -113,7 +117,7 @@ /* ── Dropdown list ─────────────────────────────────────────────────────────── */ -.psd-dropdown { +.psdDropdown { /* position/top/left/width/z-index are applied inline by the portal — only visual styles here */ background: rgb(4 10 26 / 98%); border: 1px solid rgb(125 214 255 / 30%); @@ -124,10 +128,10 @@ max-height: 240px; overflow-y: auto; scrollbar-width: thin; - scrollbar-color: var(--neon-cyan) transparent; + scrollbar-color: var(--cp-cyan) transparent; } -.psd-state-row { +.psdStateRow { padding: 12px 14px; font-size: 12px; color: rgb(125 214 255 / 45%); @@ -137,7 +141,7 @@ /* ── Result rows ───────────────────────────────────────────────────────────── */ -.psd-row { +.psdRow { display: flex; align-items: center; gap: 8px; @@ -160,11 +164,11 @@ } &.active { - box-shadow: inset 2px 0 0 var(--neon-cyan); + box-shadow: inset 2px 0 0 var(--cp-cyan); } } -.psd-row-name { +.psdRowName { flex: 1; min-width: 0; font-size: 13px; @@ -176,7 +180,7 @@ letter-spacing: 0.2px; } -.psd-row-id { +.psdRowId { flex-shrink: 0; font-size: 11px; font-weight: 600; @@ -185,7 +189,7 @@ font-variant-numeric: tabular-nums; } -.psd-row-level { +.psdRowLevel { flex-shrink: 0; font-size: 11px; font-weight: 700; diff --git a/frontend/src/components/ui/pet-search-dropdown/index.tsx b/frontend/src/components/ui/pet-search-dropdown/index.tsx index f21df83a..04b5dde7 100644 --- a/frontend/src/components/ui/pet-search-dropdown/index.tsx +++ b/frontend/src/components/ui/pet-search-dropdown/index.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import clsx from 'clsx'; import { useSearchPets } from '@shared/core'; import type { OpponentPet, PetChain } from '@shared/core'; -import './index.css'; +import styles from './index.module.css'; export type PetSearchDropdownProps = { chain: PetChain | null; @@ -158,13 +159,13 @@ const PetSearchDropdown: React.FC = ({ ? createPortal(
              - {isLoading &&
              Searching…
              } + {isLoading &&
              Searching…
              } {!isLoading && filtered.length === 0 && inputText.trim() && ( -
              No pets found
              +
              No pets found
              )} {filtered.map((pet, i) => ( ))}
              , @@ -189,11 +190,13 @@ const PetSearchDropdown: React.FC = ({ : null; return ( -
              +
              = ({ spellCheck={false} /> {isSelected && ( - + #{selected.id} · Lv {selected.level} )} {isSelected ? ( ) : ( - + )} diff --git a/frontend/src/components/ui/toast/index.css b/frontend/src/components/ui/toast/index.module.css similarity index 91% rename from frontend/src/components/ui/toast/index.css rename to frontend/src/components/ui/toast/index.module.css index 962467b6..b127ad1a 100644 --- a/frontend/src/components/ui/toast/index.css +++ b/frontend/src/components/ui/toast/index.module.css @@ -1,4 +1,5 @@ -.toast-viewport { +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. */ +.viewport { position: fixed; top: 16px; right: 16px; @@ -23,16 +24,16 @@ box-shadow: 0 10px 28px rgb(0 0 0 / 42%), inset 0 0 16px rgb(125 214 255 / 6%); animation: toast-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1); - &.toast-error { + &.error { border-color: rgb(255 110 196 / 42%); box-shadow: 0 10px 28px rgb(0 0 0 / 42%), inset 0 0 16px rgb(255 110 196 / 10%); } - &.toast-info { + &.info { border-color: rgb(125 214 255 / 32%); } - &.toast-success { + &.success { border-color: rgb(0 255 157 / 42%); box-shadow: 0 10px 28px rgb(0 0 0 / 42%), inset 0 0 16px rgb(0 255 157 / 10%); } @@ -85,7 +86,7 @@ } @media (max-width: 480px) { - .toast-viewport { + .viewport { left: 16px; right: 16px; width: auto; diff --git a/frontend/src/components/ui/toast/index.tsx b/frontend/src/components/ui/toast/index.tsx index 326933ba..d5936605 100644 --- a/frontend/src/components/ui/toast/index.tsx +++ b/frontend/src/components/ui/toast/index.tsx @@ -1,7 +1,8 @@ import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import clsx from 'clsx'; import Icon, { CheckIcon, CloseIcon, PauseIcon, WarningIcon } from '@components/ui/icon'; import { Tones, type Tone } from '@constants/tones'; -import './index.css'; +import styles from './index.module.css'; export type ToastTone = 'error' | 'info' | 'success'; @@ -66,22 +67,19 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre return ( {children} -
              +
              {toasts.map((toast) => { - const IconComponent = toneIcon(toast.tone ?? 'error'); + const tone = toast.tone ?? 'error'; + const IconComponent = toneIcon(tone); return ( -
              - - +
              + + -

              {toast.message}

              +

              {toast.message}

              setIsOpen(false)} title="Select Solana Network" - className="network-neon-modal" - contentClassName="network-neon-modal-content" + className={styles.networkNeonModal} + contentClassName={styles.networkNeonModalContent} > -
              +
              {SOLANA_NETWORKS.map((network) => { const isActive = currentNetwork === network.name; return ( - ), -})); -vi.mock('@components/pet/interactions/panels/battle/parts/opponent-picker-card', () => ({ - default: ({ opponent, onSelect }: { opponent: { id: string }; onSelect: (k: string) => void }) => ( - - ), -})); -vi.mock('@components/common', () => ({ - AuthActionButton: ({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: React.ReactNode }) => ( - - ), -})); -// Siblings that reach into PetsConfig/wagmi/Anchor — stub them out. -vi.mock('@components/pet/interactions/panels/battle/parts/pending-battle-notice', () => ({ - default: () => null, -})); -vi.mock('@components/pet/interactions/panels/battle/parts/open-to-challenges-toggle', () => ({ - default: () => null, -})); - -import BattleSetup, { - type BattleSetupProps, -} from '@components/pet/interactions/panels/battle/parts/battle-setup'; - -const readyPet = (id: string, name: string): ReadyPet => - ({ id, pet: { id, name, level: 3 } as Pet }) as ReadyPet; -const opp = (id: string): OpponentPet => - ({ id, owner: '0xowner', name: `Foe ${id}`, level: 5 }) as unknown as OpponentPet; - -const baseProps = (over: Partial = {}): BattleSetupProps => ({ - isStandaloneView: true, - subtitle: 'Pick a fight', - arenaClassName: 'arena', - isArenaFighting: false, - isArenaReady: false, - showResult: false, - selectedFighter: null, - opponent: undefined, - opponentSlotFlash: false, - randomMatchDisabled: false, - onRandomMatch: vi.fn(), - readyPets: [readyPet('1', 'Alpha')], - selectedPet1: '', - onSelectFighter: vi.fn(), - sortedOpponents: [opp('o1')], - fighterLevel: null, - selectedOpponentKey: '', - onSelectOpponent: vi.fn(), - selectedOpponentCardRef: null, - opponentsLoading: false, - onRefreshOpponents: vi.fn(), - onBattle: vi.fn(), - battleDisabled: false, - battleButtonLabel: 'Battle!', - onCancel: vi.fn(), - winEstimate: { isLoading: false, winProbability: null, samples: null }, - ...over, -}); - -describe('BattleSetup', () => { - it('shows the subtitle only in the embedded (non-standalone) view', () => { - const { rerender } = render(); - expect(screen.queryByText('Pick a fight')).not.toBeInTheDocument(); - - rerender(); - expect(screen.getByText('Pick a fight')).toBeInTheDocument(); - }); - - it.each([ - [{ isArenaFighting: true }, 'Fighting'], - [{ showResult: true }, 'Complete'], - [{ isArenaReady: true }, 'Ready'], - [{}, 'Setup'], - ] as const)('shows the arena badge %o as %s', (over, badge) => { - render(); - expect(screen.getByText(badge)).toBeInTheDocument(); - }); - - it('renders the selected fighter and opponent in the arena slots', () => { - render( - , - ); - expect(screen.getByTestId('arena-fighter')).toHaveTextContent('Alpha'); - expect(screen.getByTestId('arena-opponent')).toHaveTextContent('Foe o1'); - }); - - it('shows an empty message when there are no ready fighters', () => { - render(); - expect( - screen.getByText('No ready pets. Wait for cooldowns to finish before battling.'), - ).toBeInTheDocument(); - }); - - it('selects a fighter from the strip', async () => { - const onSelectFighter = vi.fn(); - render(); - - await userEvent.click(screen.getByRole('button', { name: 'fighter-1' })); - expect(onSelectFighter).toHaveBeenCalledWith('1'); - }); - - it('shows the level-match hint when a fighter level is known', () => { - const { rerender } = render(); - expect(screen.queryByText(/sorted by level match/)).not.toBeInTheDocument(); - - rerender(); - expect(screen.getByText(/sorted by level match/)).toBeInTheDocument(); - }); - - it('shows a finding state while opponents load with none yet', () => { - render(); - expect(screen.getByText('Finding challengers in the arena…')).toBeInTheDocument(); - }); - - it('shows a no-opponents message when the roster is empty', () => { - render(); - expect(screen.getByText(/No opponents available right now/)).toBeInTheDocument(); - }); - - it('refreshes opponents, with a loading label while pending', async () => { - const onRefreshOpponents = vi.fn(); - const { rerender } = render(); - - await userEvent.click(screen.getByRole('button', { name: 'Refresh' })); - expect(onRefreshOpponents).toHaveBeenCalledOnce(); - - rerender(); - expect(screen.getByRole('button', { name: 'Loading…' })).toBeDisabled(); - }); - - it('wires the battle and cancel controls', async () => { - const onBattle = vi.fn(); - const onCancel = vi.fn(); - render(); - - await userEvent.click(screen.getByRole('button', { name: 'Battle!' })); - await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); - expect(onBattle).toHaveBeenCalledOnce(); - expect(onCancel).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/tests/components/pet/battle/fighter-picker-card.test.tsx b/frontend/tests/components/pet/battle/fighter-picker-card.test.tsx deleted file mode 100644 index 0f88b4e2..00000000 --- a/frontend/tests/components/pet/battle/fighter-picker-card.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { Pet } from '@shared/core'; - -vi.mock('@shared/core', () => ({ - getPetAvatar: (dna: unknown) => `avatar:${dna}`, - getRarityColor: () => 'rgb(1, 2, 3)', - getRarityName: (rarity: unknown) => `rarity:${rarity}`, -})); - -import FighterPickerCard from '@components/pet/interactions/panels/battle/parts/fighter-picker-card'; - -const pet = (over: Partial = {}): Pet => - ({ - id: 'p1', - name: 'Sparky', - level: 4, - dna: 7, - rarity: 'rare', - winCount: 3, - lossCount: 1, - ...over, - }) as unknown as Pet; - -describe('FighterPickerCard', () => { - it('renders the pet identity, rarity and win/loss record', () => { - render(); - - expect(screen.getByText('Sparky')).toBeInTheDocument(); - expect(screen.getByText('Lv.4')).toBeInTheDocument(); - expect(screen.getByText('avatar:7')).toBeInTheDocument(); - expect(screen.getByText('rarity:rare')).toBeInTheDocument(); - expect(screen.getByText('3W / 1L')).toBeInTheDocument(); - }); - - it('reflects the selected state via class and aria-pressed', () => { - render(); - - const btn = screen.getByRole('button'); - expect(btn).toHaveClass('is-selected'); - expect(btn).toHaveAttribute('aria-pressed', 'true'); - }); - - it('calls onSelect with the pet id on click', async () => { - const onSelect = vi.fn(); - render(); - - await userEvent.click(screen.getByRole('button')); - expect(onSelect).toHaveBeenCalledWith('p1'); - }); -}); diff --git a/frontend/tests/components/pet/battle/opponent-picker-card.test.tsx b/frontend/tests/components/pet/battle/opponent-picker-card.test.tsx deleted file mode 100644 index 4cceff2f..00000000 --- a/frontend/tests/components/pet/battle/opponent-picker-card.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { OpponentPet } from '@shared/core'; - -vi.mock('@shared/core', () => ({ - getPetAvatar: (dna: unknown) => `avatar:${dna}`, - getRarityColor: () => 'rgb(1, 2, 3)', - getRarityName: (rarity: unknown) => `rarity:${rarity}`, -})); - -import OpponentPickerCard from '@components/pet/interactions/panels/battle/parts/opponent-picker-card'; - -const owner = '0x1234567890abcdef'; -const opponent = (over: Partial = {}): OpponentPet => - ({ - id: 'o1', - owner, - name: 'Rival', - level: 5, - dna: 9, - rarity: 'epic', - winCount: 2, - lossCount: 4, - ...over, - }) as unknown as OpponentPet; - -describe('OpponentPickerCard', () => { - it('renders identity, shortened owner and record', () => { - render( - , - ); - - expect(screen.getByText('Rival')).toBeInTheDocument(); - expect(screen.getByText('Lv.5 · 0x1234…cdef')).toBeInTheDocument(); - expect(screen.getByText('2W / 4L')).toBeInTheDocument(); - }); - - it('omits the match tier when there is no fighter level', () => { - const { container } = render( - , - ); - - expect(container.querySelector('.stat-pill.match-even')).toBeNull(); - expect(container.querySelector('button')?.className).not.toMatch(/match-/); - }); - - it('tags an even match when fighter and opponent levels align', () => { - const { container } = render( - , - ); - - expect(container.querySelector('button')).toHaveClass('match-even'); - expect(screen.getByText('Even match')).toHaveClass('stat-pill', 'match-even'); - }); - - it('reflects selection and calls onSelect with the opponent key', async () => { - const onSelect = vi.fn(); - render( - , - ); - - const btn = screen.getByRole('button'); - expect(btn).toHaveClass('is-selected'); - expect(btn).toHaveAttribute('aria-pressed', 'true'); - - await userEvent.click(btn); - expect(onSelect).toHaveBeenCalledWith(`${owner}::o1`); - }); -}); diff --git a/frontend/tests/components/pet/collection/pet-collection-layout.test.tsx b/frontend/tests/components/pet/collection/pet-collection-layout.test.tsx deleted file mode 100644 index ee522b6a..00000000 --- a/frontend/tests/components/pet/collection/pet-collection-layout.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; - -import PetCollectionLayout from '@components/pet/collection/pet-collection-layout'; - -describe('PetCollectionLayout', () => { - it('composes the pet-collection panel with its heading id', () => { - const { container } = render(); - - const heading = screen.getByRole('heading', { name: 'My Pets' }); - expect(heading).toHaveAttribute('id', 'pet-collection-heading'); - expect(container.querySelector('.dashboard-panel')).toHaveClass('pet-collection'); - }); - - it('renders description, actions and children by default', () => { - const { container } = render( - Refresh}> - list content - , - ); - - expect(container.querySelector('.caption')).toHaveTextContent('desc'); - expect(container.querySelector('.actions')).toHaveTextContent('Refresh'); - expect(screen.getByText('list content')).toBeInTheDocument(); - }); - - it('centers the description and hides the body when wallet-disconnected', () => { - const { container } = render( - - list content - , - ); - - expect(container.querySelector('.dashboard-panel')).toHaveClass('pet-collection', 'wallet-disconnected'); - expect(container.querySelector('.state-body .caption')).toHaveTextContent('Connect'); - expect(screen.queryByText('list content')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/tests/components/pet/collection/pet-gallery.test.tsx b/frontend/tests/components/pet/collection/pet-gallery.test.tsx index 5c59c8ca..e5e2002a 100644 --- a/frontend/tests/components/pet/collection/pet-gallery.test.tsx +++ b/frontend/tests/components/pet/collection/pet-gallery.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import userEvent from '@testing-library/user-event'; const notifyError = vi.fn(); @@ -12,7 +13,21 @@ vi.mock('@components/pet/creation/create-pet-modal', () => ({ default: ({ isOpen }: { isOpen: boolean }) => (isOpen ?
              : null), })); -const isPetReady = vi.fn(() => true); +// Per-pet readiness comes from usePetCooldowns; statusFor returns a cooldown VM. +const cooldownStatus = { + onCooldown: false, + battleReady: true, + battleOnCooldown: false, + battleLabel: '', + breedOnCooldown: false, + breedLabel: '', + trainOnCooldown: false, + trainLabel: '', +}; +vi.mock('@hooks/usePetCooldowns', () => ({ + usePetCooldowns: () => ({ statusFor: () => cooldownStatus }), +})); + const petList = { pets: [] as Array>, isLoading: false, @@ -23,24 +38,28 @@ const capabilities = { isConnected: true }; vi.mock('@shared/core', () => ({ getGeneration: () => 0, - getPropertyEmoji: () => '🔥', + getLifePercent: () => 80, getXpNumbers: () => ({ xpCurrent: 10, xpMax: 100 }), getXpPercent: () => 10, getPetAvatar: () => 'avatar', getPetClass: () => 'Warrior', - getPetElement: () => 'Fire', - getPetProperties: () => ({ strength: 5 }), + getPetProperties: () => ({ life: 5, attack: 6, defense: 7, intelligence: 8 }), + getPetSkill: () => null, getRarityColor: () => 'rgb(1, 2, 3)', getRarityName: () => 'Rare', - getTimeUntilReady: () => '5m', - getPetSkill: () => null, - isPetReady: (...a: unknown[]) => isPetReady(...a), useChainCapabilities: () => capabilities, usePetList: () => petList, })); import PetGallery from '@components/pet/collection/pet-gallery'; +const renderGallery = () => + render( + + + , + ); + const aPet = (over: Record = {}) => ({ id: '1', chain: 'evm', @@ -48,6 +67,8 @@ const aPet = (over: Record = {}) => ({ level: 3, dna: 7, rarity: 2, + winCount: 4, + lossCount: 1, readyAt: '0', ...over, }); @@ -56,27 +77,32 @@ beforeEach(() => { vi.clearAllMocks(); capabilities.isConnected = true; Object.assign(petList, { pets: [], isLoading: false, error: null }); - isPetReady.mockReturnValue(true); + Object.assign(cooldownStatus, { + onCooldown: false, + battleReady: true, + battleOnCooldown: false, + battleLabel: '', + }); }); describe('PetGallery', () => { it('asks to connect when the wallet is disconnected', () => { capabilities.isConnected = false; - const { container } = render(); + const { container } = renderGallery(); - expect(screen.getByText('Connect your wallet to view your pets')).toBeInTheDocument(); - expect(container.querySelector('.wallet-disconnected')).not.toBeNull(); + expect(screen.getByText('Connect your wallet to view your pets.')).toBeInTheDocument(); + expect(container.querySelector('.idleMessage')).not.toBeNull(); }); it('shows a loading state', () => { petList.isLoading = true; - render(); + renderGallery(); expect(screen.getByText('Loading your pets...')).toBeInTheDocument(); }); it('shows an error state and notifies', () => { petList.error = new Error('boom'); - render(); + renderGallery(); expect(screen.getByText('Failed to load pet data. Please try again.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Try Again' })).toBeInTheDocument(); @@ -87,39 +113,36 @@ describe('PetGallery', () => { ); }); - it('shows the empty altar and opens the create modal', async () => { - render(); - expect(screen.getByText('Awaken your first companion')).toBeInTheDocument(); + it('shows the summon tile and opens the create modal', async () => { + renderGallery(); + expect(screen.getByRole('button', { name: /Summon a Pet/ })).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: /Create your first pet/ })); + await userEvent.click(screen.getByRole('button', { name: /Summon a Pet/ })); expect(screen.getByTestId('create-modal')).toBeInTheDocument(); }); it('renders a pet card and opens the send modal', async () => { petList.pets = [aPet()]; - render(); + renderGallery(); - expect(screen.getByRole('heading', { name: 'Sparky' })).toBeInTheDocument(); + expect(screen.getByText('Sparky')).toBeInTheDocument(); expect(screen.getByText('Lv. 3')).toBeInTheDocument(); expect(screen.getByText('Rare')).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: /Send/ })); + await userEvent.click(screen.getByRole('button', { name: /Send Sparky/ })); expect(screen.getByTestId('send-modal')).toBeInTheDocument(); }); it('shows a cooldown status when the pet is not ready', () => { - isPetReady.mockReturnValue(false); + Object.assign(cooldownStatus, { + onCooldown: true, + battleReady: false, + battleOnCooldown: true, + battleLabel: '5m', + }); petList.pets = [aPet()]; - render(); + renderGallery(); expect(screen.getByText(/ready in 5m/i)).toBeInTheDocument(); }); - - it('refetches when the refresh button is clicked', async () => { - petList.pets = [aPet()]; - render(); - - await userEvent.click(screen.getByTitle('Refresh')); - expect(petList.refetch).toHaveBeenCalled(); - }); }); diff --git a/frontend/tests/components/pet/battle/battle-dialogue.test.tsx b/frontend/tests/components/pet/interactions/panels/battle/battle-dialogue.test.tsx similarity index 100% rename from frontend/tests/components/pet/battle/battle-dialogue.test.tsx rename to frontend/tests/components/pet/interactions/panels/battle/battle-dialogue.test.tsx diff --git a/frontend/tests/components/pet/battle/battle-matchmaking.test.ts b/frontend/tests/components/pet/interactions/panels/battle/battle-matchmaking.test.ts similarity index 100% rename from frontend/tests/components/pet/battle/battle-matchmaking.test.ts rename to frontend/tests/components/pet/interactions/panels/battle/battle-matchmaking.test.ts diff --git a/frontend/tests/components/pet/battle/battle-overlay.test.tsx b/frontend/tests/components/pet/interactions/panels/battle/battle-overlay.test.tsx similarity index 89% rename from frontend/tests/components/pet/battle/battle-overlay.test.tsx rename to frontend/tests/components/pet/interactions/panels/battle/battle-overlay.test.tsx index 8cb93247..b2623e52 100644 --- a/frontend/tests/components/pet/battle/battle-overlay.test.tsx +++ b/frontend/tests/components/pet/interactions/panels/battle/battle-overlay.test.tsx @@ -13,7 +13,7 @@ import BattleOverlay, { type BattleOverlayProps, } from '@components/pet/interactions/panels/battle/parts/battle-overlay'; -const opponent = { name: 'Rival', level: 5 } as unknown as OpponentPet; +const opponent = { name: 'Rival', level: 5, dna: 1n } as unknown as OpponentPet; const baseProps = (over: Partial = {}): BattleOverlayProps => ({ open: true, @@ -57,7 +57,7 @@ describe('BattleOverlay', () => { />, ); - expect(screen.getByText('Battle Starting')).toBeInTheDocument(); + expect(screen.getByText(/Battle Starting/)).toBeInTheDocument(); expect(screen.getByText('Awaiting signature')).toBeInTheDocument(); expect(screen.getByTestId('dialogue')).toHaveTextContent('1'); }); @@ -73,7 +73,7 @@ describe('BattleOverlay', () => { it('renders a victory with the opponent line', () => { render(); - expect(screen.getByText('Victory!')).toBeInTheDocument(); + expect(screen.getByText('VICTORY!')).toBeInTheDocument(); expect(screen.getByText('Your pet won the battle!')).toBeInTheDocument(); expect(screen.getByText('vs Rival (Lv.5)')).toBeInTheDocument(); }); @@ -86,16 +86,16 @@ describe('BattleOverlay', () => { it('renders a defeat with a defeat-styled rematch button', () => { render(); - expect(screen.getByText('Defeated')).toBeInTheDocument(); + expect(screen.getByText('DEFEATED')).toBeInTheDocument(); expect(screen.getByText('Lost to Rival (Lv.5)')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Rematch' })).toHaveClass('is-defeat'); + expect(screen.getByRole('button', { name: /Rematch/ })).toHaveClass('isDefeat'); }); it('gates the result actions until the dialogue is done', () => { render(); - expect(screen.getByRole('button', { name: 'Rematch' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Leave' })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Rematch/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Leave/ })).toBeDisabled(); }); it('shows a preparing label and wires the action callbacks', async () => { @@ -103,8 +103,8 @@ describe('BattleOverlay', () => { const onDone = vi.fn(); const { rerender } = render(); - await userEvent.click(screen.getByRole('button', { name: 'Rematch' })); - await userEvent.click(screen.getByRole('button', { name: 'Leave' })); + await userEvent.click(screen.getByRole('button', { name: /Rematch/ })); + await userEvent.click(screen.getByRole('button', { name: /Leave/ })); expect(onRematch).toHaveBeenCalledOnce(); expect(onDone).toHaveBeenCalledOnce(); diff --git a/frontend/tests/components/pet/battle/battle-result-art.test.tsx b/frontend/tests/components/pet/interactions/panels/battle/battle-result-art.test.tsx similarity index 100% rename from frontend/tests/components/pet/battle/battle-result-art.test.tsx rename to frontend/tests/components/pet/interactions/panels/battle/battle-result-art.test.tsx diff --git a/frontend/tests/components/pet/interactions/panels/battle/battle-setup.test.tsx b/frontend/tests/components/pet/interactions/panels/battle/battle-setup.test.tsx new file mode 100644 index 00000000..ea307f30 --- /dev/null +++ b/frontend/tests/components/pet/interactions/panels/battle/battle-setup.test.tsx @@ -0,0 +1,192 @@ +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { OpponentPet, Pet, ReadyPet } from '@shared/core'; + +// AuthActionButton reaches into auth/session context — stub to a plain button. +vi.mock('@components/common', () => ({ + AuthActionButton: ({ + onClick, + disabled, + children, + }: { + onClick: () => void; + disabled?: boolean; + children: ReactNode; + }) => ( + + ), +})); +// Siblings that reach into PetsConfig/wagmi/Anchor — stub them out. +vi.mock('@components/pet/interactions/panels/battle/parts/pending-battle-notice', () => ({ + default: () => null, +})); +vi.mock('@components/pet/interactions/panels/battle/parts/open-to-challenges-toggle', () => ({ + default: () => null, +})); + +import BattleSetup, { + type BattleSetupProps, +} from '@components/pet/interactions/panels/battle/parts/battle-setup'; +import { opponentKey } from '@components/pet/interactions/panels/battle/battle-utils'; + +// Pets carry a real DNA + rarity so the shared DNA-derived helpers (stats, avatar, +// class, rarity) render the CombatantCard without stubbing @shared/core. +const readyPet = (id: string, name: string): ReadyPet => + ({ id, pet: { id, name, level: 3, dna: 1n, rarity: 0 } as unknown as Pet }) as ReadyPet; +const opp = (id: string): OpponentPet => + ({ id, owner: '0xF00d', name: `Foe ${id}`, level: 5, dna: 2n, rarity: 1 }) as unknown as OpponentPet; + +const baseProps = (over: Partial = {}): BattleSetupProps => ({ + isStandaloneView: true, + subtitle: 'Pick a fight', + selectedFighter: null, + opponent: undefined, + randomMatchDisabled: false, + onRandomMatch: vi.fn(), + readyPets: [readyPet('1', 'Alpha')], + selectedPet1: '', + onSelectFighter: vi.fn(), + sortedOpponents: [opp('o1')], + selectedOpponentKey: '', + onSelectOpponent: vi.fn(), + opponentsLoading: false, + onRefreshOpponents: vi.fn(), + onBattle: vi.fn(), + battleDisabled: false, + battleButtonLabel: 'Battle!', + onCancel: vi.fn(), + winEstimate: { isLoading: false, winProbability: null, samples: null }, + ...over, +}); + +describe('BattleSetup (fighter-vs-rival showdown)', () => { + it('shows the heading + subtitle only in the embedded (non-standalone) view', () => { + const { rerender } = render(); + expect(screen.queryByText('Pick a fight')).not.toBeInTheDocument(); + expect(screen.queryByText('Battle Pets')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Pick a fight')).toBeInTheDocument(); + expect(screen.getByText('Battle Pets')).toBeInTheDocument(); + }); + + it('labels the fighter, rival and VS columns', () => { + render(); + expect(screen.getByText('Your Fighter')).toBeInTheDocument(); + expect(screen.getByText('On-Chain Rival')).toBeInTheDocument(); + expect(screen.getByText('VS')).toBeInTheDocument(); + }); + + it('lists ready fighters in the fighter select and reports a selection', async () => { + const onSelectFighter = vi.fn(); + render( + , + ); + + const select = screen.getByRole('combobox', { name: 'Choose your fighter' }); + expect(screen.getByRole('option', { name: 'Alpha (Lv 3)' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Beta (Lv 3)' })).toBeInTheDocument(); + + await userEvent.selectOptions(select, '2'); + expect(onSelectFighter).toHaveBeenCalledWith('2'); + }); + + it('prompts for a fighter when none are ready', () => { + render(); + // Appears both as the select's placeholder option and the empty card label. + expect(screen.getAllByText('No ready fighters').length).toBeGreaterThan(0); + }); + + it('renders the selected fighter and opponent cards', () => { + render( + , + ); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Foe o1')).toBeInTheDocument(); + }); + + it('wires the Random and Refresh opponent controls', async () => { + const onRandomMatch = vi.fn(); + const onRefreshOpponents = vi.fn(); + const { rerender } = render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: /Random/ })); + expect(onRandomMatch).toHaveBeenCalledOnce(); + + await userEvent.click(screen.getByRole('button', { name: 'Refresh' })); + expect(onRefreshOpponents).toHaveBeenCalledOnce(); + + rerender(); + expect(screen.getByRole('button', { name: '…' })).toBeDisabled(); + }); + + it('reports the chosen opponent by its composite key', async () => { + const onSelectOpponent = vi.fn(); + render(); + + const select = screen.getByRole('combobox', { name: 'Select an opponent' }); + await userEvent.selectOptions( + select, + screen.getByRole('option', { name: 'Foe o1 (Lv 5)' }), + ); + expect(onSelectOpponent).toHaveBeenCalledWith(opponentKey('0xF00d', 'o1')); + }); + + it('shows finding / empty states for the opponent roster', () => { + const { rerender } = render( + , + ); + expect(screen.getAllByText('Finding challengers…').length).toBeGreaterThan(0); + + rerender(); + expect(screen.getAllByText('No opponents available').length).toBeGreaterThan(0); + }); + + it('renders the win rate from the estimate', () => { + const { rerender } = render(); + expect(screen.getByText('—')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('50%')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('…')).toBeInTheDocument(); + }); + + it('wires the battle and cancel controls', async () => { + const onBattle = vi.fn(); + const onCancel = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /Battle!/ })); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onBattle).toHaveBeenCalledOnce(); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/tests/components/pet/battle/battle-utils.test.ts b/frontend/tests/components/pet/interactions/panels/battle/battle-utils.test.ts similarity index 100% rename from frontend/tests/components/pet/battle/battle-utils.test.ts rename to frontend/tests/components/pet/interactions/panels/battle/battle-utils.test.ts diff --git a/frontend/tests/components/pet/panels/breed.test.tsx b/frontend/tests/components/pet/interactions/panels/breed.test.tsx similarity index 68% rename from frontend/tests/components/pet/panels/breed.test.tsx rename to frontend/tests/components/pet/interactions/panels/breed.test.tsx index 8edbf611..bebadce1 100644 --- a/frontend/tests/components/pet/panels/breed.test.tsx +++ b/frontend/tests/components/pet/interactions/panels/breed.test.tsx @@ -1,5 +1,6 @@ +import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, within } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const navigate = vi.fn(); @@ -15,7 +16,7 @@ vi.mock('@components/common', () => ({ }: { onClick: () => void; disabled?: boolean; - children: React.ReactNode; + children: ReactNode; }) => (