# #404 — Дружеский мэтч **DESIGN_INPUT_ONLY · один рекомендуемый концепт · related #526 / #523.** Editable `index.html`, `styles.css`, `prototype.js`; без зависимостей и реальных запросов. Открыть локально. Синтетические игроки/ответы отмечены над продуктовым кадром, управление — под ним. Все аватары намеренно представлены инициалами, не вымышленными фотографиями. Source implementation, реальные mutual, live auth/permissions, DEV и UAT: **NOT_TESTED**. PNG и Chromium-проверка — следующий шаг родительского процесса; публикации нет. ## Решение и происхождение «Дружеский мэтч»: крупный двухстрочный заголовок, две равные аватарки и имена на лёгкой разметке корта, волан между игроками, lime «Написать», вторичная «Продолжить поиск». Подпись «Вы оба хотите сыграть в бадминтон» объясняет взаимный интерес; нижняя строка прямо отделяет его от дружбы и автоматической отправки. Desktop — центральный modal 600px, mobile — полноэкранное состояние текущего finder. X и продолжение возвращают в уже продвинувшуюся подборку; повторного advance нет. Длинные имена переносятся без обрезки; при нехватке высоты прокручивается поверхность, действия остаются достижимыми. Safe-area и цели ≥44px; короткий вход 180ms, reduced-motion отключает движение. **STYLE_GUIDE_PARITY:** проверены SHA-256 `/opt/gobadminton-clean-qa/style-guide/src/App.tsx` = `04187d4d7d70c8c61e9ca2f08b6b6325a98539a3ec90c711c48ab771f675b61c`, `src/index.css` = `f660e31973a6142784308f22728c751d7bb7432fb2ac0136bac032dbc6b1db2a`. Mapping: результат → ConstructionKit `Modal/result` + responsive expanded overlay; игроки → `Avatar`/purple variant; действие → primary lime/ghost; typography → Onest, hero ink/purple; радиусы 12/18/28. Owner new direct — основание выразительной спортивной композиции, а не новый site-wide стиль. SVG X дословно из `consumer/CloseButton.tsx` (24px, stroke 2, round, линии 6→18). Локальные Onest/logo/courtphoto уже предоставлены. Evolving product CSS не читался. ## Точное подключение (все пути от `apps/web/src/`) Источник: `inputs/source`, commit **ad6913bff55c15c0b31060cbc19279eb07f4739e**; все 15 хешей `inputs/source-proof.json` совпали. GitHub findings предоставлены parent; этот запуск не обращался к сети и не перепроверял число комментариев. - `components/consumer/pages/PlayersDirectoryPage.tsx`: один page-owned match state и один обработчик validated confirmation, рядом с текущим `offers`/`PlayerBrowseOfferOpHost` (~489), переживающий смену карточки. Небольшой `components/consumer/players/PlayerBrowseMatch.tsx` допустим как чистая presentation. **На mobile** содержимое мэтча размещать в существующем `` (~519), заменяя/скрывая deck; не открывать второй modal поверх него. Тот же владелец inert (~453) и close/focus. Escape/X при мэтче сначала снимают мэтч и возвращают в finder; обычный `closeMobileCards` остаётся единственным закрытием finder. **Desktop** page-owned host открывает один dialog, inert окружающий каталог, возвращает фокус на доступный следующий control. Заголовок получает initial focus; одна focus trap, не два независимых cleanup/lock. - `components/consumer/players/PlayerBrowseDeck.tsx:onConfirmedRequest` (~1022): существующий predicate допускает и outgoing; это недостаточно для celebration. Передать captured peer presentation (publicPlayerId, displayName, допустимый avatar) **с момента admission**, а не `current` следующей карточки. Mobile flight уже продвинул deck до receipt (~1039); новый host никогда не продвигает его ещё раз. - `components/consumer/players/PlayerBrowsePlayInterest.tsx`: desktop mutation сейчас не имеет strict `validateSuccess`. Использовать `lib/play-interests.ts:isValidConfirmedPlayInterestReceipt` для send и captured actor/peer/privateGeneration. Withdraw остаётся собственной успешной операцией без celebration: валидатор выбирать по frozen pending method/path/body, не ломать withdraw общей проверкой send. Не менять глобальную политику `useSocialMutation`. - `components/consumer/players/PlayerBrowseOffers.tsx`: frozen operation уже содержит token/actorId/privateGeneration/peerId/peerName; validator и settled guard уже есть. `usePlayerBrowseOffers.ts:onOperationConfirmed` (~597) проверяет activeToken/opMatchesCurrentActor. После этих проверок передать тому же page handler immutable receipt+presentation; не запускать вторую мутацию/новый registry. Обе ветки сходятся в одном dedup host. Показывать только validated **mutation** receipt `{interestId: UUID, version: decimal string, outcome:'mutual'}` текущего actor/private generation и captured peer. `packages/contracts/src/friendship.generated.ts:11` не содержит peer, conversation или contact permission. Outgoing, optimistic flight, UNKNOWN, malformed, withdrawn, GET/history mutual не открывают экран. In-memory seen key = actor + privateGeneration + interestId + version; отметить до показа/подавления, повтор не открывать. Имена/фото — только краткоживущий payload, без persistent cache; publicPlayerId не приравнивать к userId. **Late receipt:** active + visible finder → немедленный показ; закрытый finder → обычный существующий peer-bound status без фокусировки; navigated/unmounted/hidden → без modal, без отложенного auto-open при возврате. Подавленный receipt считается обработанным. Если уже открыт мэтч, следующий не вытесняет его и не создаёт очередь. Session change/block/revoke закрывают текущую поверхность, очищают payload, отменяют contact load и инвалидируют generation; старый ответ не восстанавливает CTA. Existing comment «late receipts must not steal focus» сохранён. ## Общение — отдельный свежий gate Переиспользовать `components/consumer/players/ContactAction.tsx` (`contactAction`, `useContactState`) и `lib/messaging-api.ts:getContactState` — `/v1/me/contact-state?targetPublicPlayerId=...`. Проверять target и actor/generation/peer/request epoch. При открытии — loading без href; error — «Повторить проверку»; blocked/declined/unavailable — без href, продолжение видно. `none + canRequestContact=true` → «Написать», `/messages?to=`; accepted → «Написать» на существующую `/messages?conversation=...` (локальная presentation label); pending incoming/outgoing → **«Открыть запрос»**, тот же conversation route, честная подпись ожидания. Не менять shared labels/policy ради этой поверхности. `components/consumer/messages/MessagesPage.tsx` уже маршрутизирует `to` в `NewContact.tsx`, где повторно проверяются contact-state/conversation peer/getPlayer.canRequestContact. Только явный Submit с текстом и clientMessageId вызывает `/v1/conversations/requests`. CTA только навигация; не friend/accept/send/unlock. Здесь gap клиентской связки; необходимость нового backend не доказана. ## Дополнение Owner: карта (#404 / #526) `PlayersDirectoryPage.tsx:380 mobileFinderZone` сейчас содержит plain `Link.pb-finder-map-secondary`; desktop ~370 уже использует `components/consumer/games/GamesViewAction.tsx`. Привести mobile к той же pill + lime tile + purple `FoldedMapGlyph` из `components/consumer/NavIcon.tsx` и Games tokens (`styles/radar-geographic.css`). `GamesViewAction` всегда создаёт fixed wrapper и собственный nav-measure effect: **не вставлять второй fixed wrapper** в существующую зону. Минимально выделить общий presentational content/tile из него, применить внутри текущего sibling с «Свайп»; позиционированием над bottom nav владеет только `mobileFinderZone`. Либо явный in-flow вариант без собственного effect. Сохранить activationId/currentRadar gates; Radar сам не включать. Отдельного redesign нет. ## Локальная проверка и передача `window.setScenario(state,width)` синхронно создаёт новый синтетический receipt; width задаёт ширину frame (для responsive capture viewport также поставить 390×844 или 1280×900). State: `allowed`, `accepted`, `loading`, `error`, `blocked`, `declined`, `unavailable`, `pending_incoming`, `pending_outgoing`, `long-name`, `wrapped-heading`, `missing-avatars`; отрицательные: `outgoing`, `withdrawn`, `malformed`, `unknown`, `optimistic`, `history`, `closed`, `hidden`, `navigated`. Object `{scenario,contact,selfName,peerName,heading,scope}` допустим. API существует до загрузки изображений; перед PNG дождаться `document.fonts.ready`. `measureScenario()` возвращает geometry/focus/inert/receipt/counter/contactTicket/navigationIntent. `matchPrototype.dismiss(); matchPrototype.replayReceipt()` → не открывает повторно. Retry оставляет loading: `resolveContact(measureScenario().contactTicket,'allowed')` моделирует ответ; после `invalidate('block')` старый ticket должен вернуть false. Проверить один mutual из каждой ветки, captured peer после advance, Escape/Tab/Shift+Tab/focus return, все contact labels/href, long content/no horizontal overflow на двух viewport. Дальше обязательные независимая QA, CI, DEV acceptance и smoke по действующему процессу; local examples их не заменяют.