fix/modals-polish · коммит готов локально, PR не открыт (см. блокер)/opt/education-platform —
только git fetch для деплоя) нет токена/ключа с правом push в
it-ptitsa-mentor/education-platform. Установил gh CLI сам — авторизовать нечем.
Нужен GH_TOKEN/gh auth login в среде агента, либо кто-то с доступом
применяет патч из этого отчёта и открывает PR вручную.
.modal-open .app-main { overflow:hidden }, но класс
modal-open нигде не выставлялся — каждая модалка вместо этого ставила
document.body.style.overflow = "hidden". А реальные скролл-контейнеры —
.app-main, .home, .lesson-aside — у каждого свой независимый
overflow-y: auto, никак не связанный с body. Фон скроллился под модалкой
одновременно с её собственным внутренним скроллом.
packages/web/src/hooks/useModalController.ts (заменяет продублированный
useEffect в трёх компонентах):
onConfirm), но не мешает нативному Enter
на уже сфокусированной кнопке/ссылке (проверяется тег target — button/a/input/textarea/select
обрабатывают Enter сами);class="modal-open" на body — CSS-правило расширено
на .home и .lesson-aside;ExercisePassModal: onConfirm = navigate(continueLink.to); onClose() —
то же самое, что клик по CTA «Далее».
--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 сохранены).
pnpm test — 163/163 (28 файлов)pnpm test:feature — 28/28 (6 файлов)pnpm -r typecheck — 4/4 пакета (shared, runner, web, api)onPassed-флоу ExercisePassModal (markUnitDone → refreshProgress → showPassModal) — код не менялся5 файлов: +115/-57. Применить: git checkout -b fix/modals-polish && git apply patch.diff либо git am.
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