From c5f2ac26c6eafb86e61c9758d7888a3ec9a1cf93 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 24 Aug 2026 12:54:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B0=D1=82=20v1.1.1:=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BE=D1=82=D1=81=D1=82=D1=83=D0=BF=20=D0=BD=D0=B0=D0=B4=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/decisions/075-no-zoom-app-feel.md | 4 +- .../076-ios-input-accessory-is-system.md | 6 +- docs/identity/brief.md | 2 +- docs/ui.md | 4 +- web/app.css | 8 ++ web/js/ui/chat.js | 80 ++++++++++++++++++- 6 files changed, 97 insertions(+), 7 deletions(-) diff --git a/docs/decisions/075-no-zoom-app-feel.md b/docs/decisions/075-no-zoom-app-feel.md index aa8c6fd..04f1972 100644 --- a/docs/decisions/075-no-zoom-app-feel.md +++ b/docs/decisions/075-no-zoom-app-feel.md @@ -2,7 +2,7 @@ Уточняет [ADR-024](024-identity-and-ui.md): типографика из `docs/identity/brief.md` остаётся как записана, а страница перестаёт масштабироваться. -Уточнён [ADR-076](076-ios-input-accessory-is-system.md): строка сообщения снова стала `textarea`; она уже входит в перечень элементов управления, по которым второй тап не гасится. +Уточнён [ADR-076](076-ios-input-accessory-is-system.md): строка сообщения снова стала `textarea`, входит в перечень элементов управления, по которым второй тап не гасится, и заменяет весь нижний отступ на 6 px только при открытой экранной клавиатуре. ## Контекст @@ -22,7 +22,7 @@ - Страховка на JS — `web/js/zoom.js`: мета-строке Safari верен не всегда. Слушатели непассивные, иначе `preventDefault` не действует. `gesturestart`, `gesturechange`, `gestureend` — события щипка, они бывают только в Safari — гасятся целиком. Второй тап подряд, ближе 350 мс и 40 px к первому, гасится, если он не по элементу управления: `preventDefault` на `touchend` уносит с собой `click`, а нажать кнопку дважды подряд — обычное дело. Прокрутка, свайпы и одиночные нажатия не трогаются вовсе. - `overscroll-behavior: none` у страницы и `contain` у лент: резинового отскока и «потянуть для обновления» нет, прокрутка внутри лент прежняя. - Выделение текста снято с шапки, сайдбара, кнопок и разделителей: долгое нажатие по ним показывало лупу и «копировать», а копировать там нечего. С текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара (ADR-067) не снято — их копируют; версию на iPhone взять больше неоткуда, там нет ни строки адреса, ни консоли. -- `viewport-fit=cover` пускает страницу под вырез и системную полосу, поэтому отступы считаются с ней: подвал сайдбара и строка ввода добавляют к нижнему полю `env(safe-area-inset-bottom)`, страница — боковые и верхнюю вставки. Верхняя при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы. +- `viewport-fit=cover` пускает страницу под вырез и системную полосу, поэтому отступы считаются с ней: подвал сайдбара и строка ввода добавляют к нижнему полю `env(safe-area-inset-bottom)`, страница — боковые и верхнюю вставки. Когда открыта экранная клавиатура, обычные `20px + env(safe-area-inset-bottom)` строки ввода заменяются на 6 px по ADR-076: клавиатура уже закрывает домашний индикатор. Верхняя вставка при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы. ## Следствия diff --git a/docs/decisions/076-ios-input-accessory-is-system.md b/docs/decisions/076-ios-input-accessory-is-system.md index 99e7d6a..2736a2d 100644 --- a/docs/decisions/076-ios-input-accessory-is-system.md +++ b/docs/decisions/076-ios-input-accessory-is-system.md @@ -8,6 +8,8 @@ Проверка v1.1 на реальном iPhone опровергла предположение: полоса осталась и у `contenteditable`. Это часть клавиатурного интерфейса WebKit, а не узел страницы. Web API для её отключения нет. В нативной оболочке внешний вид клавиатуры может менять хост iOS; установленная PWA остаётся веб-страницей и такого доступа не получает. +На том же экране часть потерянного места оказалась не системной панелью: строка ввода всегда добавляла `20px + env(safe-area-inset-bottom)`. Safari сохраняет нижний safe-area домашнего индикатора и после появления клавиатуры, хотя её прямоугольная поверхность уже закрывает этот край. Получалась отдельная пустая полоса примерно той же высоты, что системная панель. + `contenteditable` при этом стоил клиенту ручной реализации подсказки, предела, вставки без разметки, переноса строки, `disabled`, счётчика, работы IME и запасного пути для браузеров без `plaintext-only`. Главное следствие ADR-069 не наступило, а цена осталась. ## Решение @@ -17,10 +19,12 @@ - Системная панель iOS принимается как ограничение веб-клиента. `inputmode="none"` и собственная экранная клавиатура не используются: убрать вместе с панелью системную клавиатуру означает потерять обычный набор, раскладки, диктовку и доступность. - Нативная iOS-оболочка не добавляется в Bare. Если абсолютное управление панелью станет требованием, это отдельный продуктовый и архитектурный выбор. - Тап по неинтерактивному месту истории вызывает `blur()` строки сообщения и закрывает клавиатуру. Кнопки и ссылки в ленте сохраняют своё действие. +- Пока поле сфокусировано и `visualViewport` уменьшен как при экранной клавиатуре, весь обычный нижний отступ строки ввода (`20px + safe-area` на узком экране, `28px + safe-area` на широком) заменяется на 6 px. Эвристика сверяет масштаб и ширину и требует существенного уменьшения высоты; один фокус не считается клавиатурой, поэтому с физической клавиатурой обычный отступ остаётся. При закрытии размеры пересчитываются. Смена ширины после фокуса и уход приложения в фон возвращают полный безопасный отступ до следующего тапа по полю: известное старое значение `visualViewport` после поворота или возврата не должно прижать ввод к домашнему индикатору. ## Следствия - Строка сообщения снова опирается на браузерное текстовое поле и стала заметно проще. Лишнее не проходит через `maxLength`; ручная обработка `beforeinput`, вставки, IME и текстовых узлов удалена. - Полоса со стрелками и «готово» на iPhone остаётся. Код и документация больше не обещают веб-странице контроль над системным UI. +- Пустой зазор между строкой ввода и системной панелью исчезает; место возвращается истории без вмешательства в клавиатуру. - Тап по истории возвращает место ленте без нажатия системной кнопки «готово». -- Проверка самой панели по-прежнему требует реального iPhone; поведение `textarea`, отправки и снятия фокуса проверяется в обычном браузере. +- Проверка самой панели и точного момента перестроения `visualViewport` по-прежнему требует реального iPhone в Safari и установленном PWA; поведение `textarea`, отправки, эвристики размеров и снятия фокуса проверяется в обычном браузере. diff --git a/docs/identity/brief.md b/docs/identity/brief.md index cb73764..27162aa 100644 --- a/docs/identity/brief.md +++ b/docs/identity/brief.md @@ -42,7 +42,7 @@ font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monos ## Компоновка - Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px. -- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят (ADR-075). +- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят. При открытой экранной клавиатуре её прямоугольник уже защищает край, поэтому обычные `20px + safe-area` строки ввода заменяются компактными 6 px (ADR-076). - Сообщения — поток, автор и время над текстом; на телефоне и планшете свои — у правого края, чужие — у левого, на десктопе все — у левого; ширина блока не больше `min(640px, 85%)` (ADR-077). - Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева. - Активный элемент списка — инверсия: фон ink, текст bone. diff --git a/docs/ui.md b/docs/ui.md index 5d046f2..5170cbf 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -8,7 +8,7 @@ Без inline-стилей и inline-скриптов (CSP). Рендер — `document.createElement` и `textContent`; `innerHTML` не используется нигде: сообщения — пользовательские данные. -Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone. +Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone; у строки весь нижний отступ становится 6 px, пока край уже закрыт экранной клавиатурой (ADR-076). ## Вход и регистрация @@ -42,7 +42,7 @@ Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053). -Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; нативный `maxLength` не пускает лишнее. Тап по неинтерактивному месту истории снимает фокус со строки и закрывает клавиатуру (ADR-076). +Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; нативный `maxLength` не пускает лишнее. Тап по неинтерактивному месту истории снимает фокус со строки и закрывает клавиатуру. Пока открыта экранная клавиатура, весь нижний отступ строки заменяется 6 px; при физической клавиатуре и после закрытия обычный отступ сохраняется (ADR-076). Строка сообщения — `textarea` (ADR-076): она принимает только текст, сама держит вставку, выделение, отмену, подсказку, предел и состояние `disabled`. Растёт под содержимое, дальше семи строк — прокрутка. Системную панель со стрелками и «готово» iOS рисует поверх клавиатуры и для `textarea`, и для `contenteditable`; у веб-страницы нет поддерживаемого API, чтобы её скрыть. Полное управление этой панелью потребовало бы отдельной нативной iOS-оболочки; собственная экранная клавиатура в веб-клиенте не делается. diff --git a/web/app.css b/web/app.css index 24bdb63..46b7285 100644 --- a/web/app.css +++ b/web/app.css @@ -885,6 +885,14 @@ input[type="password"] { padding: 0 16px calc(20px + env(safe-area-inset-bottom)); } +/* При открытой экранной клавиатуре нижний край уже закрыт её прямоугольной + поверхностью. Safari продолжает отдавать safe-area домашнего индикатора; + заменяем весь нижний отступ на компактные 6 px (ADR-076). */ + +.compose.is-keyboard { + padding-bottom: 6px; +} + .input { display: flex; align-items: center; diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index 457f97a..a760283 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -10,6 +10,10 @@ import { DESKTOP, clear, el, wide } from "./dom.js"; const LIMIT = 4000; const COUNTER_AT = 3500; const COMPOSER_ROWS = 7; +// Экранная клавиатура уменьшает visual viewport на сотни пикселей. Порог +// отсекает движение панелей браузера; доля высоты работает и на iPad. +const KEYBOARD_SHRINK = 120; +const KEYBOARD_SHRINK_RATIO = 0.18; // Разделители дат: на десктопе — полная дата, на мобильном — короткая, // как в эталоне. Время — ЧЧ:ММ в локальной зоне. @@ -91,8 +95,10 @@ export function renderChat(root, ctx, chatId) { }); root.append(view.feed); - root.append(composer(view)); + view.compose = composer(view); + root.append(view.compose); sizeField(view); + const offKeyboard = watchKeyboard(view); // Обычный тап по истории закрывает системную клавиатуру. Интерактивные // элементы ленты (сейчас это «повторить») ведут своё действие сами: // подменять их на жест закрытия клавиатуры нельзя (ADR-076). @@ -138,6 +144,7 @@ export function renderChat(root, ctx, chatId) { offNet(); offPeers(); offRooms(); + offKeyboard(); media.removeEventListener("change", onMedia); window.removeEventListener("resize", onResize); }; @@ -297,6 +304,77 @@ function sizeField(view) { field.rows = Math.max(1, Math.min(COMPOSER_ROWS, rows)); } +// watchKeyboard снимает нижний safe-area только по консервативной эвристике +// экранной клавиатуры. На iOS env(safe-area-inset-bottom) ошибочно остаётся +// прежним и кладёт под composer ещё одну пустую полосу (ADR-076). +// +// Одного focus недостаточно: поле бывает сфокусировано с физической +// клавиатурой. Экранную выдаёт уменьшившийся visual viewport при масштабе 1; +// pinch-зум, если Safari всё-таки его пропустил, за клавиатуру не считается. +function watchKeyboard(view) { + const viewport = window.visualViewport; + let frame = 0; + let focusWidth = 0; + const update = () => { + frame = 0; + const page = document.documentElement; + const threshold = Math.max(KEYBOARD_SHRINK, page.clientHeight * KEYBOARD_SHRINK_RATIO); + const shrunk = viewport !== null + && viewport !== undefined + && Math.abs(viewport.scale - 1) < 0.01 + // При повороте width обновляется у двух viewport не одновременно. + // До стабилизации размеров не принимаем этот переход за клавиатуру. + && Math.abs(page.clientWidth - viewport.width) < 2 + // WebKit иногда оставляет старую высоту visual viewport даже после + // поворота. Смена ширины после фокуса безопасно выключает компенсацию + // до следующего тапа по полю. + && Math.abs(page.clientWidth - focusWidth) < 2 + && page.clientHeight - viewport.height > threshold; + view.compose.classList.toggle("is-keyboard", document.activeElement === view.field && shrunk); + }; + const schedule = () => { + if (frame !== 0) cancelAnimationFrame(frame); + frame = requestAnimationFrame(update); + }; + const arm = () => { + focusWidth = document.documentElement.clientWidth; + schedule(); + }; + // На blur не полагаемся на порядок обновления activeElement в WebKit: + // класс должен исчезнуть сразу при закрытии клавиатуры в любом случае. + const blur = () => { + if (frame !== 0) cancelAnimationFrame(frame); + frame = 0; + focusWidth = 0; + view.compose.classList.remove("is-keyboard"); + }; + // После ухода приложения в фон WebKit может вернуть старую высоту. + // Не рискуем краем экрана: компактный отступ включится снова по тапу. + const visibility = () => { + if (document.hidden) { + blur(); + return; + } + schedule(); + }; + view.field.addEventListener("pointerdown", arm); + view.field.addEventListener("focus", arm); + view.field.addEventListener("blur", blur); + viewport?.addEventListener("resize", schedule); + window.addEventListener("resize", schedule); + document.addEventListener("visibilitychange", visibility); + schedule(); + return () => { + if (frame !== 0) cancelAnimationFrame(frame); + view.field.removeEventListener("pointerdown", arm); + view.field.removeEventListener("focus", arm); + view.field.removeEventListener("blur", blur); + viewport?.removeEventListener("resize", schedule); + window.removeEventListener("resize", schedule); + document.removeEventListener("visibilitychange", visibility); + }; +} + // allow открывает и закрывает ввод: предупреждение о ключе и уход // из комнаты гасят и поле, и кнопку «>» (ADR-016, ADR-044). function allow(view, on) {