[UI] Причесать модалки тренажёра

Todoist 6h5fVWmwHhVfM4fF · ветка fix/modals-polish · коммит готов локально, PR не открыт (см. блокер)
⚠ Блокер: нет прав на запись в GitHub.
Ни в песочнице агента, ни на nexus.it-ptitsa-mentor.ru (там /opt/education-platform — только git fetch для деплоя) нет токена/ключа с правом push в it-ptitsa-mentor/education-platform. Установил gh CLI сам — авторизовать нечем. Нужен GH_TOKEN/gh auth login в среде агента, либо кто-то с доступом применяет патч из этого отчёта и открывает PR вручную.

Что сделано

1. Причина «двойного скролла» (найдена, не просто симптом)

В CSS уже было правило .modal-open .app-main { overflow:hidden }, но класс modal-open нигде не выставлялся — каждая модалка вместо этого ставила document.body.style.overflow = "hidden". А реальные скролл-контейнеры — .app-main, .home, .lesson-aside — у каждого свой независимый overflow-y: auto, никак не связанный с body. Фон скроллился под модалкой одновременно с её собственным внутренним скроллом.

2. Фикс

Новый общий хук packages/web/src/hooks/useModalController.ts (заменяет продублированный useEffect в трёх компонентах): В ExercisePassModal: onConfirm = navigate(continueLink.to); onClose() — то же самое, что клик по CTA «Далее».

3. Единый базовый отступ + компактность

Добавлены CSS-переменные --modal-pad-x/--modal-pad-y на .course-modal-panel. Раньше шапка модалки (1.2rem ≈ 19.2px) и тело/ячейки таблицы (1rem ≈ 16px) имели разный горизонтальный отступ — текст не совпадал по левому краю. Теперь везде 1.1rem. Плюс компактнее: меньше max-height/width панелей (напр. topic-lessons-modal-panel: 56rem/42rem → 52rem/38rem), меньше заголовок (1.15rem → 1.05rem), без потери читаемости и touch-таргетов (≥44px сохранены).

Скриншоты ДО / ПОСЛЕ

TopicLessonsModal — десктоп 1280px

До, 1280px
ДО — паддинг шапки не совпадает с таблицей, панель шире (56rem)
После, 1280px
ПОСЛЕ — единый отступ, компактнее (52rem)

TopicLessonsModal — мобилка 360px (bottom sheet)

До, 360px
ДО
После, 360px
ПОСЛЕ

TopicLessonsModal ПОСЛЕ — 414px и 768px (без горизонтального скролла)

После, 414px
414px
После, 768px
768px

ExercisePassModal — десктоп 1280px и мобилка 375px

До, 1280px
ДО, 1280px
После, 1280px
ПОСЛЕ, 1280px (компактнее, gap 1rem→0.85rem)
До, 375px
ДО, 375px
После, 375px
ПОСЛЕ, 375px — Enter сразу после открытия → переход по CTA «Далее», Esc/Tab-фокус на кнопках не задет

Проверки (зелёные)

 pnpm test — 163/163 (28 файлов)
 pnpm test:feature — 28/28 (6 файлов)
 pnpm -r typecheck — 4/4 пакета (shared, runner, web, api)
 Двойной скролл: программно проверено (scrollHeight>clientHeight) на 360×600 — остаётся только один скролл-контейнер внутри модалки
 Enter → primary action (навигация по CTA), не срабатывает повторно если фокус на кнопке/ссылке (Tab-навигация проверена вручную)
 Esc закрывает модалку (не сломан)
 Мобилки 360/414/768 — без горизонтального скролла (scrollWidth == viewport)
 Sticky-раскладка редактора урока (lesson-main--fill) — не тронута, скриншот совпадает 1:1 с ДО
 onPassed-флоу ExercisePassModal (markUnitDone → refreshProgress → showPassModal) — код не менялся

Патч (ветка fix/modals-polish, 1 коммит поверх main@a562592)

5 файлов: +115/-57. Применить: git checkout -b fix/modals-polish && git apply patch.diff либо git am.

Показать полный diff (git format-patch)
From ea43194eda33b6934cdeeb79c57163d10d996af4 Mon Sep 17 00:00:00 2001
From: Claude Agent <noreply@anthropic.com>
Date: Mon, 3 Aug 2026 18:40:05 +0000
Subject: [PATCH] fix(web): compact trainer modals, single scroll, Enter
 confirms action
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Причёсываем модалки тренажёра (ExercisePassModal, TopicLessonsModal,
LessonNavigatorModal) под аккуратный компактный вид (ориентир — Хекслет):

- единый базовый паддинг контента (--modal-pad-x/--modal-pad-y на
  .course-modal-panel) — раньше шапка и тело/таблица модалок имели
  разный горизонтальный отступ (19.2px vs 16px), из-за чего текст не
  выравнивался по левому краю;
- компактнее: меньше max-height/width панелей, чуть меньше заголовок
  и вертикальные отступы — без потери читаемости и touch-таргетов;
- убран двойной скролл: `.modal-open .app-main` был мёртвым правилом
  (класс `modal-open` никогда не выставлялся, а вместо него каждая
  модалка ставила `overflow:hidden` на document.body — который не
  является реальным скролл-контейнером, т.к. `.app-main`/`.home`
  скроллятся independently). Новый общий хук useModalController
  реально навешивает класс `modal-open` на body, и CSS-правило
  расширено на `.home` и `.lesson-aside` — при открытой модалке скроллится
  только её собственное содержимое;
- Enter подтверждает основное действие модалки (например, переход по
  CTA «Далее» в ExercisePassModal), не мешая нативному Enter на уже
  сфокусированных кнопках/ссылках и не ломая закрытие по Escape.

Общая логика (Escape/Enter/scroll-lock/фокус) вынесена в
packages/web/src/hooks/useModalController.ts, чтобы не дублировать её
в трёх компонентах.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 .../web/src/components/ExercisePassModal.tsx  | 26 ++++----
 .../src/components/LessonNavigatorModal.tsx   | 17 +----
 .../web/src/components/TopicLessonsModal.tsx  | 18 +-----
 packages/web/src/hooks/useModalController.ts  | 64 +++++++++++++++++++
 packages/web/src/styles/global.css            | 47 ++++++++++----
 5 files changed, 115 insertions(+), 57 deletions(-)
 create mode 100644 packages/web/src/hooks/useModalController.ts

diff --git a/packages/web/src/components/ExercisePassModal.tsx b/packages/web/src/components/ExercisePassModal.tsx
index e8bcdb8..e15e89f 100644
--- a/packages/web/src/components/ExercisePassModal.tsx
+++ b/packages/web/src/components/ExercisePassModal.tsx
@@ -1,5 +1,6 @@
-import { useEffect, useId, useRef } from "react";
-import { Link } from "react-router-dom";
+import { useId } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { useModalController } from "../hooks/useModalController";
 import type { LessonNavLink } from "../lib/lesson-units";
 
 type ExercisePassModalProps = {
@@ -12,20 +13,15 @@ export const ExercisePassModal = ({
   onClose,
 }: ExercisePassModalProps) => {
   const titleId = useId();
-  const panelRef = useRef<HTMLDivElement>(null);
+  const navigate = useNavigate();
 
-  useEffect(() => {
-    const onKey = (e: KeyboardEvent) => {
-      if (e.key === "Escape") onClose();
-    };
-    document.addEventListener("keydown", onKey);
-    document.body.style.overflow = "hidden";
-    panelRef.current?.focus();
-    return () => {
-      document.removeEventListener("keydown", onKey);
-      document.body.style.overflow = "";
-    };
-  }, [onClose]);
+  const panelRef = useModalController<HTMLDivElement>({
+    onClose,
+    onConfirm: () => {
+      navigate(continueLink.to);
+      onClose();
+    },
+  });
 
   return (
     <div className="course-modal" role="presentation" onClick={onClose}>
diff --git a/packages/web/src/components/LessonNavigatorModal.tsx b/packages/web/src/components/LessonNavigatorModal.tsx
index 5f147ea..acf596e 100644
--- a/packages/web/src/components/LessonNavigatorModal.tsx
+++ b/packages/web/src/components/LessonNavigatorModal.tsx
@@ -1,6 +1,7 @@
-import { useEffect, useId, useRef, useState } from "react";
+import { useId, useState } from "react";
 import { Link } from "react-router-dom";
 import { isLessonComplete, type Course, type LessonUnit } from "../course";
+import { useModalController } from "../hooks/useModalController";
 import { lessonUnitPath, preferredLessonUnit } from "../lib/lesson-units";
 
 type LessonNavigatorModalProps = {
@@ -20,24 +21,12 @@ export const LessonNavigatorModal = ({
   onClose,
 }: LessonNavigatorModalProps) => {
   const titleId = useId();
-  const panelRef = useRef<HTMLDivElement>(null);
   const [expandedKey, setExpandedKey] = useState<string | null>(() => {
     const [moduleSlug, topicSlug] = currentLessonId.split("/");
     return moduleSlug && topicSlug ? topicKey(moduleSlug, topicSlug) : null;
   });
 
-  useEffect(() => {
-    const onKey = (e: KeyboardEvent) => {
-      if (e.key === "Escape") onClose();
-    };
-    document.addEventListener("keydown", onKey);
-    document.body.style.overflow = "hidden";
-    panelRef.current?.focus();
-    return () => {
-      document.removeEventListener("keydown", onKey);
-      document.body.style.overflow = "";
-    };
-  }, [onClose]);
+  const panelRef = useModalController<HTMLDivElement>({ onClose });
 
   return (
     <div className="course-modal" role="presentation" onClick={onClose}>
diff --git a/packages/web/src/components/TopicLessonsModal.tsx b/packages/web/src/components/TopicLessonsModal.tsx
index 64efe2c..17399e3 100644
--- a/packages/web/src/components/TopicLessonsModal.tsx
+++ b/packages/web/src/components/TopicLessonsModal.tsx
@@ -1,7 +1,8 @@
-import { useEffect, useId, useRef } from "react";
+import { useId } from "react";
 import { createPortal } from "react-dom";
 import { Link } from "react-router-dom";
 import { isUnitDone, type Module, type Topic } from "../course";
+import { useModalController } from "../hooks/useModalController";
 
 type TopicLessonsModalProps = {
   module: Module;
@@ -17,20 +18,7 @@ export const TopicLessonsModal = ({
   onClose,
 }: TopicLessonsModalProps) => {
   const titleId = useId();
-  const panelRef = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    const onKey = (e: KeyboardEvent) => {
-      if (e.key === "Escape") onClose();
-    };
-    document.addEventListener("keydown", onKey);
-    document.body.style.overflow = "hidden";
-    panelRef.current?.focus();
-    return () => {
-      document.removeEventListener("keydown", onKey);
-      document.body.style.overflow = "";
-    };
-  }, [onClose]);
+  const panelRef = useModalController<HTMLDivElement>({ onClose });
 
   return createPortal(
     <div className="course-modal" role="presentation" onClick={onClose}>
diff --git a/packages/web/src/hooks/useModalController.ts b/packages/web/src/hooks/useModalController.ts
new file mode 100644
index 0000000..49e64ac
--- /dev/null
+++ b/packages/web/src/hooks/useModalController.ts
@@ -0,0 +1,64 @@
+import { useEffect, useRef } from "react";
+
+/**
+ * Элементы, у которых Enter уже что-то делает нативно (клик по кнопке/ссылке,
+ * перевод строки в textarea и т. д.) — для них мы не должны перехватывать
+ * Enter и вызывать onConfirm повторно.
+ */
+const INTERACTIVE_TAGS = new Set(["BUTTON", "A", "INPUT", "TEXTAREA", "SELECT"]);
+
+type UseModalControllerOptions = {
+  /** Закрыть модалку — вызывается по Escape и клику по оверлею. */
+  onClose: () => void;
+  /**
+   * Главное действие модалки (например, кнопка «Далее»). Срабатывает по Enter,
+   * если фокус не находится на отдельном интерактивном элементе (у него уже
+   * есть свой обработчик Enter).
+   */
+  onConfirm?: () => void;
+};
+
+/**
+ * Общая логика модалок тренажёра: Escape закрывает, Enter подтверждает
+ * основное действие, фон блокируется на время показа (единственный скролл —
+ * внутри модалки), фокус уходит на панель модалки при открытии.
+ */
+export const useModalController = <T extends HTMLElement>({
+  onClose,
+  onConfirm,
+}: UseModalControllerOptions) => {
+  const panelRef = useRef<T>(null);
+
+  useEffect(() => {
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") {
+        onClose();
+        return;
+      }
+
+      if (event.key === "Enter" && onConfirm) {
+        const target = event.target as HTMLElement | null;
+        const isInteractive =
+          target != null &&
+          (INTERACTIVE_TAGS.has(target.tagName) || target.isContentEditable);
+        if (isInteractive) return;
+
+        event.preventDefault();
+        onConfirm();
+      }
+    };
+
+    document.addEventListener("keydown", onKeyDown);
+    document.body.classList.add("modal-open");
+    document.body.style.overflow = "hidden";
+    panelRef.current?.focus();
+
+    return () => {
+      document.removeEventListener("keydown", onKeyDown);
+      document.body.classList.remove("modal-open");
+      document.body.style.overflow = "";
+    };
+  }, [onClose, onConfirm]);
+
+  return panelRef;
+};
diff --git a/packages/web/src/styles/global.css b/packages/web/src/styles/global.css
index cab0915..07a312a 100644
--- a/packages/web/src/styles/global.css
+++ b/packages/web/src/styles/global.css
@@ -195,7 +195,13 @@ body.is-resizing-vertical {
   z-index: 0;
 }
 
-.modal-open .app-main { overflow: hidden; }
+/* Пока открыта модалка (см. useModalController), фон полностью зафиксирован:
+   и .app-main, и вложенный .home — у обоих свой independent overflow-y:auto,
+   поэтому раньше при открытой модалке фон и её содержимое скроллились
+   одновременно («двойной скролл»). */
+.modal-open .app-main,
+.modal-open .home,
+.modal-open .lesson-aside { overflow: hidden; }
 
 .app-main {
   position: relative;
@@ -2181,10 +2187,25 @@ body.is-resizing-vertical {
 
 
 .course-modal { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; padding: 1.25rem; background: rgba(8, 8, 10, 0.72); backdrop-filter: blur(4px); }
-.course-modal-panel { width: min(32rem, 100%); max-height: min(80vh, 36rem); overflow: hidden; display: flex; flex-direction: column; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 24px 64px rgba(0,0,0,0.45); }
-.course-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--border); }
-.course-modal-kicker { margin: 0 0 0.25rem; font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); }
-.course-modal-title { margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 1.15rem; line-height: 1.25; }
+.course-modal-panel {
+  /* Единый базовый отступ контента модалки — используется в шапке, теле
+     и ячейках таблиц всех модалок тренажёра, чтобы левый край текста везде
+     совпадал (см. docs/prompts/telegram-agent-skill.md — «причесать модалки»). */
+  --modal-pad-x: 1.1rem;
+  --modal-pad-y: 0.8rem;
+  width: min(32rem, 100%);
+  max-height: min(76vh, 32rem);
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-md);
+  background: var(--surface-raised);
+  box-shadow: 0 24px 64px rgba(0,0,0,0.45);
+}
+.course-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: var(--modal-pad-y) var(--modal-pad-x); border-bottom: 1px solid var(--border); }
+.course-modal-kicker { margin: 0 0 0.2rem; font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); }
+.course-modal-title { margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; line-height: 1.25; }
 .course-modal-close { flex: none; width: 2rem; height: 2rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-muted); font-size: 1.25rem; line-height: 1; cursor: pointer; transition: border-color 0.15s, color 0.15s; }
 .course-modal-close:hover { border-color: var(--accent); color: var(--accent); }
 .course-modal-lessons { list-style: none; margin: 0; padding: 0.5rem; overflow-y: auto; flex: 1; }
@@ -2196,8 +2217,8 @@ body.is-resizing-vertical {
 .course-modal-lesson-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .course-modal-lesson-meta { display: flex; gap: 0.35rem; font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.03em; text-transform: uppercase; color: var(--text-subtle); }
 .course-modal-lesson-meta span { padding: 0.15rem 0.35rem; border: 1px solid var(--border); border-radius: 4px; }
-.course-modal-panel--navigator { width: min(38rem, 100%); max-height: min(85vh, 40rem); }
-.course-navigator-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0.5rem 0.65rem 0.75rem; }
+.course-modal-panel--navigator { width: min(38rem, 100%); max-height: min(80vh, 36rem); }
+.course-navigator-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0.6rem var(--modal-pad-x) var(--modal-pad-x); }
 .course-navigator-module { margin-bottom: 1rem; }
 .course-navigator-module:last-child { margin-bottom: 0; }
 .course-navigator-module-title {
@@ -2348,13 +2369,13 @@ body.is-resizing-vertical {
 }
 
 /* ── Topic lessons modal table ──────────────────────────── */
-.topic-lessons-modal-panel { width: min(56rem, 94vw); max-height: min(80vh, 42rem); display: flex; flex-direction: column; }
+.topic-lessons-modal-panel { width: min(52rem, 94vw); max-height: min(76vh, 38rem); display: flex; flex-direction: column; }
 .topic-lessons-table-wrap { flex: 1; overflow-y: auto; overscroll-behavior: contain; }
 .topic-lessons-table { width: 100%; border-collapse: collapse; }
 .topic-lessons-th {
   position: sticky;
   top: 0;
-  padding: 0.6rem 1rem;
+  padding: 0.55rem var(--modal-pad-x);
   font-family: var(--font-mono);
   font-size: 0.68rem;
   letter-spacing: 0.05em;
@@ -2371,7 +2392,7 @@ body.is-resizing-vertical {
 .topic-lessons-row:last-child { border-bottom: none; }
 .topic-lessons-row:hover { background: var(--accent-dim); }
 .topic-lessons-row.is-active { background: color-mix(in srgb, var(--accent) 10%, transparent); }
-.topic-lessons-td { padding: 0.65rem 1rem; font-size: 0.88rem; color: var(--text-muted); vertical-align: middle; }
+.topic-lessons-td { padding: 0.55rem var(--modal-pad-x); font-size: 0.88rem; color: var(--text-muted); vertical-align: middle; }
 .topic-lessons-td--num { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-subtle); }
 .topic-lessons-td--unit { text-align: center; }
 .topic-lessons-link { color: var(--text); text-decoration: none; display: block; }
@@ -2764,12 +2785,12 @@ body.is-resizing-vertical {
   gap: 0.75rem;
   padding-bottom: 0.35rem;
 }
-.exercise-pass-modal-panel { width: min(24rem, 100%); max-height: none; }
+.exercise-pass-modal-panel { width: min(23rem, 100%); max-height: none; }
 .exercise-pass-modal-body {
   display: flex;
   flex-direction: column;
-  gap: 1rem;
-  padding: 1.1rem 1.2rem 1.25rem;
+  gap: 0.85rem;
+  padding: var(--modal-pad-y) var(--modal-pad-x) var(--modal-pad-x);
 }
 .exercise-pass-modal-text {
   margin: 0;
-- 
2.39.5