iOS: запрет масштабирования, версия в подвале, самообновление, строка ввода

Отзыв с iPhone после первого живого прогона.

Масштабирование запрещено (ADR-066): viewport как в соседнем sixlines плюс
viewport-fit=cover, мета-теги установленного приложения, touch-action против
двойного тапа и страховка на JS — гашение жестов iOS, потому что мета-строку
Safari вправе проигнорировать. Размеры шрифтов не тронуты: 14 px из брифа
остаются, поднимать поля до 16 px владелец отклонил. Цена — доступность
для тех, кому зум нужен — записана в ADR и в ui.md честно.

Версия в подвале (ADR-065, 067): GET /api/config отдаёт короткую ревизию
и время коммита. Именно коммита, а не сборки: штамп компиляции в бинаре
means каждая пересборка даёт новый хеш, и обещание ADR-022 «бинарь собирается
из тега байт в байт» перестаёт проверяться.

Самообновление PWA (ADR-068, 070): в режиме приложения на iPhone перезагрузки
нет, и старая версия жила бы вечно. Проверка при возврате в приложение,
включение ждущей оболочки, перезагрузка — но не тогда, когда в строке набран
текст. Замок от петли сравнивает версию оболочки, а не считает перезагрузки:
счётчик отменял саму фичу, долгоживущая вкладка переставала обновляться.
Замерено: было 30, 60, 120, 240 секунд между релизами, стало ровно 30.

Строка ввода — редактируемый блок (ADR-069): iOS рисует над клавиатурой
полосу со стрелками и «готово» для полей формы, и убрать её со страницы
нельзя. Для contenteditable не рисует. Режим plaintext-only с проверкой,
что он применился, и запасным путём с разбором вставки: невалидное значение
атрибута в части браузеров делает блок нередактируемым вовсе. Чтение
и запись только через textContent, innerHTML в клиенте по-прежнему нет.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
2026-08-23 15:11:13 +03:00
co-authored by Claude Opus 5
parent cfd0ec0ce9
commit 383e374f0f
31 changed files with 1462 additions and 79 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-022: Деплой — nginx, systemd, кросс-сборка
Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`) и [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева).
Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`), [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева) и [ADR-065](065-version-and-commit-time.md) (версия и время коммита в `GET /api/config`, времени компиляции в бинаре нет).
## Контекст
@@ -2,6 +2,8 @@
Кому уходит пуш и тексты уведомления — [ADR-045](045-push-addressed-to-recipient.md). Адрес перехода, состояния настроек и жизнь подписки на клиенте — [ADR-046](046-push-client.md).
Изменён [ADR-068](068-pwa-self-update.md): установленная версия оболочки больше не ждёт закрытия всех вкладок — её включает страница, и она же перезагружается.
## Контекст
ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker.
+2
View File
@@ -1,5 +1,7 @@
# ADR-024: Айдентика «Скобы», интерфейс и язык
Уточнён [ADR-066](066-no-zoom-app-feel.md) (масштабирование запрещено, размеры шрифтов прежние), [ADR-067](067-version-in-sidebar-foot.md) (версия и время коммита в подвале сайдбара) и [ADR-069](069-message-input-is-editable-block.md) (строка сообщения — редактируемый блок).
## Контекст
Исследование айдентики (Claude Design, «Исследование айдентики Bare») дало шесть направлений и две мини-айдентики; мок чата построен на варианте 1h «Скобы» и использует только моноширинный шрифт, без «пузырей». Открытые вопросы: язык интерфейса и i18n, визуальная айдентика. Мок содержит элементы, которых в scope v1 нет.
@@ -2,6 +2,8 @@
Уточняет [ADR-022](022-deploy-nginx-systemd.md): проверка подлинности бинаря опирается на ревизию, значит ревизия обязана быть честной.
Уточнён [ADR-065](065-version-and-commit-time.md): чтение build info переехало в `internal/build`, ревизия печатается короткой и рядом с временем коммита; `+dirty` и `unknown` — как здесь. Формат стал проверяемым тестом — разбор build info отделён от `debug.ReadBuildInfo`; сама простановка `vcs.*` по-прежнему проверяется руками.
## Контекст
`docs/threat-model.md` называет единственное смягчение против активно-злонамеренного оператора: «статика внутри бинаря, хеш которого сверяется со сборкой из тега: подмену можно заметить». `docs/deploy.md` доводит это до двух проверок после деплоя — `sha256sum` на сервере и `bare version`.
@@ -0,0 +1,29 @@
# ADR-065: Версия — короткая ревизия, «время сборки» — время коммита
Уточняет [ADR-022](022-deploy-nginx-systemd.md) (подлинность бинаря проверяется сверкой хеша со сборкой из тега) и [ADR-057](057-version-marks-dirty-tree.md) (`+dirty` у сборки из изменённого дерева).
Доведён до интерфейса [ADR-067](067-version-in-sidebar-foot.md): версия и время коммита стоят в подвале сайдбара.
## Контекст
Интерфейс должен называть версию, которая сейчас работает: иначе разговор о поломке начинается с угадывания, обновилось ли то, что человек видит.
Ревизия у сервера уже есть — `revision()` в `cmd/bare` читает `vcs.revision` и `vcs.modified` (ADR-057), — но живёт в `main` и снаружи недоступна. `internal/api` до неё не дотягивается.
Второй вопрос — «время сборки». Естественный ответ, штамп момента компиляции через `-ldflags -X`, ломает ADR-022: артефакт деплоя — один файл, и его подлинность проверяется тем, что сборка из тега даёт тот же хеш. Со временем компиляции внутри каждая пересборка одного и того же коммита даёт другой файл, и сверка перестаёт что-либо значить. Рядом с ревизией лежит `vcs.time` — время коммита, приходящее из git вместе с ней. Оно воспроизводимо и отвечает на настоящий вопрос: какой код работает, а не когда его компилировали.
## Решение
- Чтение build info переезжает в `internal/build`: полная ревизия, признак изменённого дерева, время коммита. `cmd/bare` и `internal/api` берут версию оттуда — двух форматов у одной величины не заводится.
- Версия — семь символов ревизии. У сборки из изменённого дерева к ним дописывается `+dirty` (ADR-057), без ревизии вовсе — `unknown`.
- «Время сборки» — это `vcs.time`, время коммита. Штампа момента компиляции в бинаре нет и не появится.
- `GET /api/config` отдаёт `version` (строка) и `commitAt` (миллисекунды Unix, `0` если времени нет). Отдельного `/api/version` нет: конфигурацию клиент читает до входа и так, а второй эндпоинт ради двух полей — лишний маршрут. В других ответах версии тоже нет.
- `bare version` печатает то же самое: короткую ревизию и время коммита в UTC одной строкой.
## Следствия
- Сверка «хеш файла на сервере против сборки из тега» остаётся проверяемой: в бинаре не осталось ничего, что менялось бы от пересборки.
- `bare version` больше не печатает полный хеш. Семи символов хватает и человеку, и `git show`; `docs/deploy.md` говорит то же.
- Время в интерфейсе отстаёт от момента деплоя ровно настолько, насколько деплой отстал от коммита. Это и спрашивают.
- Сборка не из git — `go run`, `go build -buildvcs=false`, тестовый бинарь — показывает `unknown` и `0`. Тесты видят именно её: `vcs.*` тестовому бинарю не проставляются, и это состояние проверяется ими прямо.
- Клиенту остаётся показать два поля; где именно и какими словами — `docs/ui.md`.
+32
View File
@@ -0,0 +1,32 @@
# ADR-066: Масштабирования нет, размеры шрифтов прежние
Уточняет [ADR-024](024-identity-and-ui.md): типографика из `docs/identity/brief.md` остаётся как записана, а страница перестаёт масштабироваться.
Уточнён [ADR-069](069-message-input-is-editable-block.md): строка сообщения стала редактируемым блоком, и она в перечне элементов управления, по которым второй тап не гасится.
## Контекст
На iPhone интерфейс жил в постоянных микрозумах. Тап по строке ввода приближал страницу: iOS Safari обязан приблизить поле мельче 16 px и обратно масштаб не возвращает. Двойной тап приближал её же, щипок — тем более. Ощущения приложения не было: страница ездила под пальцами.
Лечится это двумя способами, и они друг друга исключают. Первый — вырастить поля до 16 px: причина фокус-зума уходит, масштаб остаётся человеку. Второй — запретить масштабирование целиком. Владелец выбрал второй и отклонил первый прямо: «интерфейс должен быть как в нативном приложении, это нужно запретить», «с 16 шрифтом всё станет огромное».
Образец рядом — sixlines.ru той же команды: там `maximum-scale=1, user-scalable=no`, и на устройствах владельца страница не масштабируется.
## Решение
- Масштабирование запрещено, включая щипок. `viewport``width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`.
- Размеры шрифтов не меняются: поля ввода остаются 14 px, остальное — как в `docs/identity/brief.md`. 16 px на полях владельцем отклонены.
- Установленное приложение объявляется двумя мета-строками сразу: `mobile-web-app-capable` и его исторический двойник `apple-mobile-web-app-capable` — iOS до сих пор смотрит на второй. Имя на экране «Домой» — `apple-mobile-web-app-title` со значением `bare`.
- `apple-mobile-web-app-status-bar-style``default`. Тема одна и светлая (ADR-024): `black-translucent` пустил бы страницу под системную полосу и написал бы её часы и значки белым по bone, то есть по светлому. `default` оставляет полосу над страницей, тёмными знаками по светлому фону.
- `touch-action: manipulation` на `html`: снимает зум по двойному тапу и задержку 300 мс, которую браузер держит, ожидая второго касания. Двойной тап — жест документа, поэтому правило на корне действует на всю страницу.
- Страховка на 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` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы.
## Следствия
- Человек, которому нужно увеличить мелкое, средства лишается. Это сознательная цена ощущения приложения, и платят её все. Записана в `docs/ui.md`, «Доступность», рядом с контрастом и целями нажатия — чтобы её видели, а не находили.
- Safari вправе не послушаться: `user-scalable=no` он игнорирует с десятой версии, и `maximum-scale` однажды может пойти тем же путём. Тогда вернётся и фокус-зум на поле в 14 px — лечить его 16-м шрифтом отклонено, а другого лекарства нет.
- Двойной тап по кнопке не гасится страховкой: масштаба он и так не даёт, а второе нажатие важнее.
- Клиент вырос на один модуль — `web/js/zoom.js`. Он в оболочке service worker и в раскладке `docs/plan.md`.
@@ -0,0 +1,26 @@
# ADR-067: Версия и время коммита в подвале сайдбара
Доводит до интерфейса [ADR-065](065-version-and-commit-time.md): сервер отдаёт `version` и `commitAt`, а где их показывать, там не решено.
## Контекст
`GET /api/config` называет работающую версию и время коммита (ADR-065). Клиент читает конфигурацию при запуске и так — ради `kdfIterations` и `vapidPublicKey`. Показать их негде: `docs/ui.md` такого элемента не знает.
Спрашивают об этом при каждой поломке: обновилось ли то, что человек видит. В установленном на «Домой» приложении вопрос острее — там нет ни адресной строки, ни консоли, и другого способа узнать версию у человека нет.
## Решение
- Версия стоит в подвале сайдбара, рядом с «ты: @nick»: это единственное постоянное место интерфейса, где уже написано, кто и где мы.
- Текст — `версия · дд.мм чч:мм`, время коммита в местной зоне. Год не показывается: вопрос «что сейчас работает», а не летопись. Времени нет (`commitAt = 0`) — остаётся одна версия; конфигурации нет вовсе — строки нет.
- Цвет `stone`, размер подписи 11 px, без рамок и фона. Акцентом версия не бывает: `mark` — один смысловой элемент на экран (ADR-024), и это непрочитанные, а не служебная строка.
- Не кнопка и не ссылка: нажимать в ней нечего. Стоит рядом с кнопкой «ты: @nick», а не внутри неё — иначе экранный диктор зачитывал бы хеш как часть названия кнопки. Выделяется, в отличие от остального сайдбара (ADR-066): на iPhone скопировать её иначе нечем.
- В сайдбаре 224 px строка и версия рядом не помещаются никогда: `версия · дд.мм чч:мм` — это 179 px при внутренней ширине 184, а «ты: @nick» просит ещё восемьдесят с лишним. Значит, на десктопе версия всегда занимает вторую строку и стоит справа; на мобильном, где сайдбар во всю ширину, обе стоят в одной строке. Ник, которому не хватило и целой строки, обрезается многоточием; версия — никогда: половина хеша бесполезна.
- Текст ради одной строки не режется: вариант «в подвале ревизия, время в `title`» отклонён — `title` не показывается на телефоне, а телефон и есть место, ради которого строка заведена.
## Следствия
- Версия видна с любого экрана, где виден сайдбар, и на мобильном — в списке чатов.
- Разговор о поломке начинается с версии, а не с «попробуйте обновиться».
- Подвал сайдбара стал двухстрочным и вырос с 47 px мока до 76. Это принятое отклонение от `docs/identity/screens.html`, блок 2a: там подвал — одна строка «ты: @nick» без версии и без 44 px цели нажатия. Мок остаётся снимком айдентики на момент ADR-024 и за интерфейсом не идёт: в нём нет и строки `+ новый чат`, и баннера установки.
- Названа версия сервера, а не оболочки, которую исполняет браузер: `version` приезжает с `GET /api/config`, а страница и её модули — из кэша service worker. После релиза первое открытие показывает новую версию под старой оболочкой; расходятся они до применения обновления (ADR-068), а с недописанным сообщением в поле — сколь угодно долго. Показывать рядом версию кэша — отдельное решение, здесь его нет.
- Записано в `docs/ui.md`, «Список чатов (сайдбар)». Отдельного запроса ради этого нет: поля приезжают с конфигурацией (ADR-065).
+33
View File
@@ -0,0 +1,33 @@
# ADR-068: Приложение обновляется само
Меняет политику обновления из [ADR-023](023-push-and-service-worker.md): новая оболочка забирает управление, не дожидаясь закрытия всех вкладок.
Изменён [ADR-070](070-update-compares-shell-version.md): второй замок — растущая пауза и счётчик `bare-updates` — заменён сверкой версии оболочки.
Уточнён [ADR-072](072-deferred-update-returns-by-timer.md): к отложенному набранным вкладка возвращается ещё и по таймеру — поле пустеет и молча.
## Контекст
`sw.js` обновлялся по умолчанию браузера: новая версия устанавливается, ждёт, и берёт управление, когда закрыты все вкладки приложения. Для сайта это правильно — страница не перезагружается под руками.
Для установленного на «Домой» приложения это означает «никогда». Его не закрывают: iOS держит его в списке приложений неделями. Строки адреса и кнопки перезагрузки в нём нет — обновить оболочку человеку нечем вовсе. Он остаётся на версии, которая была при установке, и починенное на сервере до него не доезжает.
Перезагрузка при этом не бесплатна: она уносит набранное в строке ввода и в формах. Пароль в форме входа теряется так же, как черновик сообщения.
## Решение
- Клиент спрашивает сервер об обновлении (`registration.update()`) при запуске и при каждом возвращении в приложение (`visibilitychange``visible`), но не чаще раза в минуту. Проверка — один условный запрос за `/sw.js`. Без сети запрос падает молча: офлайн не сбой.
- Установленная версия не включается сама. Её включает страница: `postMessage("skip-waiting")` воркеру, который ждёт. Воркер отвечает `skipWaiting()`, `activate` забирает клиентов (`clients.claim` там уже есть), браузер шлёт `controllerchange`, страница перезагружается. Просьба уходит только тогда, когда страницей уже кто-то управляет: первая установка проходит через то же состояние «установлен», но включать там нечего — воркер активируется сам, а страница и так свежая.
- Набранное откладывает всё: и просьбу включиться, и перезагрузку. Проверяется весь документ, а не строка ввода: клиент — одна страница, и знать про экраны здесь незачем. Набранным считается непустое `textarea` и непустое поле, куда набирают текст (`text`, `password`, `search`, `email`, `url`, `tel`, `number`); чекбокс, файл, скрытое поле и кнопка не в счёт — у них значение непусто по определению, и одно такое поле запретило бы обновление насовсем. Отложенное трогается с места, когда поля опустели (событие `input`) или когда человек вернулся в приложение.
- Ждёт каждая вкладка за себя. `skipWaiting()` меняет контроллёра у всех клиентов профиля сразу, а вкладок одного профиля бывает несколько (ADR-035): вкладка с пустыми полями включает новую оболочку, и соседняя с недописанным сообщением остаётся под ней на старой странице — до своей перезагрузки, которой она ждёт по своим полям. Договариваться вкладкам не о чем: терять нечего никому, а пересчёт голосов стоил бы протокола между ними.
- Петля закрыта двумя замками. Первый: `controllerchange` перезагружает страницу один раз за её жизнь. Первую установку он отсекает — там контроллёр появляется впервые, `clients.claim` меняет его и без обновления, а страница и так свежая; замок эту смену съедает и запоминает, что страница стала управляемой. Дальше смена контроллёра означает новую оболочку, чью бы просьбу воркер ни исполнял — свою или соседней вкладки. Второй: в `sessionStorage` лежат время последней перезагрузки ради обновления (`bare-updated-at`) и их число в этой вкладке (`bare-updates`), а пауза перед следующей удваивается — 30 секунд, минута, две, четыре и дальше. Второй замок нужен против сервера, отдающего новый `sw.js` на каждый запрос: постоянная пауза задавала бы такой петле только темп, а растущая гасит её — после десятой перезагрузки ждать четыре часа, после двенадцатой сутки, дальше пауза перестаёт расти за ненадобностью. Обычные релизы её не замечают: между ними проходит больше.
- Версия кэша в `sw.js` по-прежнему меняется при релизе, и по-прежнему руками (`docs/deploy.md`): именно её смена делает файл воркера другим и запускает всё описанное.
## Следствия
- Установленное приложение доезжает до новой версии само — при следующем открытии или в течение минуты после того, как его открыли.
- Перезагрузка случается посреди сеанса. Терять ей нечего: набранное её откладывает, история в IndexedDB её переживает, открытый чат задан адресом и открывается снова.
- Человек, оставивший недописанное сообщение, остаётся на старой версии, пока не допишет или не сотрёт. Это правильный размен: версия важнее черновика только на словах.
- Вкладка с недописанным сообщением какое-то время исполняет старую оболочку под новым воркером: соседняя вкладка включила его, не спросив. Это ничего не стоит — модули страницы уже загружены, догружать их некому (`import()` в клиенте нет), а `/api/` мимо кэша идёт всегда.
- У клиента появилось хранилище кроме IndexedDB — два ключа в `sessionStorage`, живущие не дольше вкладки. Записано в `docs/storage.md`.
- Отката к «ждать закрытия всех вкладок» не предвидится: на iOS это состояние недостижимо.
@@ -0,0 +1,36 @@
# ADR-069: Строка сообщения — редактируемый блок, а не поле формы
Уточняет [ADR-024](024-identity-and-ui.md) («Чат» и «Доступность» в `docs/ui.md`) и [ADR-066](066-no-zoom-app-feel.md): в перечне элементов управления, по которым второй тап не гасится, прибавился редактируемый блок.
Уточнён [ADR-071](071-input-limit-cuts-what-arrives.md): предел держится до вставки и режет приходящее, а не хвост блока.
## Контекст
На iPhone при фокусе в строке сообщения над клавиатурой висит системная полоса помощника форм: две стрелки перехода между полями и «готово». Стрелки неактивны — поле на экране одно, переходить некуда, — а полоса занимает место и ломает ощущение приложения, ради которого принят ADR-066.
Убрать её со страницы нечем: полосу рисует система, и для `input` и `textarea` она бывает всегда. Единственный работающий приём — перестать быть полем формы: у редактируемого блока (`contenteditable`) iOS полосы не показывает.
Цена приёма известна заранее. Блок принимает вставку с разметкой, ставит на Enter `<div>` и `<br>`, не знает ни `placeholder`, ни `maxLength`, ни `disabled`, а `innerHTML` в этом клиенте запрещён (ADR-001, CSP): сообщения — пользовательские данные.
## Решение
- Блоком становится только строка сообщения в чате. Вход, регистрация, смена пароля и «новый чат» остаются на настоящих `input`: там нужен `type="password"` для менеджеров паролей, а полоса на разовом экране не мешает.
- Блок открывается значением `plaintext-only`: в нём браузер кладёт внутрь только текст, чем бы ни был буфер обмена. Значение не назначается, а проверяется чтением — незнакомое значение атрибута делает блок нередактируемым вовсе, то есть строка ввода перестала бы работать молча. Не применилось — остаётся `true`, и тогда вставку, перетаскивание и перенос строки разбирает сам клиент: текст берётся из `text/plain` и вписывается узлом текста.
- Значение читается и пишется только `textContent`. `innerHTML` не появляется ни здесь, ни где-либо ещё.
- Подсказка — правило CSS `:empty::before` с `content: attr(...)`; текст кладётся атрибутом `data-*`. Опустевший блок очищается от `<br>`, который оставляет в нём браузер: с лишним узлом `:empty` не срабатывает и подсказки не видно.
- Клавиши прежние: на десктопе Enter отправляет, Shift+Enter переносит; на мобильном Enter переносит, отправляет кнопка «>». `enterkeyhint``enter`: подпись клавиши обещает то, что клавиша делает, а `send` обещал бы отправку, которой по нажатию не будет.
- `autocapitalize="sentences"` и `autocorrect="on"`: сообщение — обычная речь, и заглавная в начале предложения с исправлением опечаток тут к месту. В форме входа и в «новом чате» они, наоборот, выключены — там ник и пароль.
- Предел 4000 символов и счётчик после 3500 держит сам блок: длина считается по `textContent`, лишнее обрезается у того, что приходит в блок, — так же, как считал `maxLength` у `textarea` (ADR-071).
- Заблокированный ввод (предупреждение о ключе, уход из комнаты) перестаёт быть редактируемым и говорит об этом `aria-disabled`: `disabled` у блока нет.
- Доступность: `role="textbox"`, `aria-multiline="true"`, подпись `aria-label` тем же текстом, что подсказка; цель нажатия на мобильном не меньше 44 px; фокус при открытии чата на десктопе — как был.
- `web/js/zoom.js`: блок добавлен в перечень элементов управления. Гашение второго тапа уносит с собой `click`, а с ним и курсор — второй тап подряд не ставил бы курсор в строку ввода.
- `web/js/pwa.js`: проверка «есть ли черновик» (ADR-068) смотрит и в редактируемый блок — `value` у него нет, набранное лежит в `textContent`. Считается и заблокированный блок: недописанное в нём остаётся.
## Следствия
- Полоса помощника форм в чате исчезает. На экранах входа и «нового чата» она остаётся — там она и не мешает.
- Проверить это можно только на iPhone: Chrome такой полосы не рисует вовсе, и никакой прогон её не увидит.
- Строка ввода перестала быть элементом формы. Отправку это не меняет: Enter и раньше обрабатывал клиент, а кнопка «>» остаётся кнопкой отправки формы.
- В запасном пути (браузер без `plaintext-only`) отмена набранного (cmd+z) не помнит нашей вставки: она делается руками, а не `execCommand` — тот в Chrome разбирает перенос строки в `<div>`, чего в блоке быть не должно. Там же держится `<br>`-заполнитель последней строки: перенос в самом конце браузер не рисует, и без заполнителя курсор оставался бы на прежней строке.
- Автозаполнение и менеджеры паролей блока не касаются — в чате им нечего заполнять.
- Записано в `docs/ui.md`, «Чат» и «Доступность».
@@ -0,0 +1,29 @@
# ADR-070: Обновление сверяется с версией оболочки, а не со счётчиком перезагрузок
Заменяет второй замок из [ADR-068](068-pwa-self-update.md): растущая пауза уходит, различение петли и релиза остаётся.
## Контекст
ADR-068 закрыл петлю перезагрузок двумя замками. Второй — пауза, которая удваивается с каждой перезагрузкой ради обновления: 30 секунд, минута, две, четыре. Считает её счётчик `bare-updates` в `sessionStorage`; он только растёт и обнуляется лишь смертью вкладки.
Счётчик не различает петлю и обычный релиз. Каждая законная перезагрузка удваивает паузу перед следующей, и вкладка, которая живёт долго, обновляться перестаёт: после десятого релиза ждать четыре часа, после двенадцатого — сутки. Долгоживущая вкладка — это ровно установленное на «Домой» приложение, которое не закрывают неделями, то есть тот самый случай, ради которого ADR-068 и написан. Защита от петли отменяла фичу, которую защищала.
Различие между петлёй и релизом есть, и оно не в числе перезагрузок: **при петле версия оболочки после перезагрузки не меняется, при релизе меняется**. Сервер, отдающий новый файл воркера на каждый запрос, отдаёт ту же оболочку — версия в `sw.js` пишется руками при релизе (`docs/deploy.md`), и сама собой она не меняется.
## Решение
- `sw.js` отвечает на сообщение `version` своей константой `VERSION`. Ответ уходит каналом вопроса (`MessageChannel`): вопросов бывает два подряд и к разным воркерам.
- Страница спрашивает версию у своего контроллёра при запуске. Это версия оболочки, код которой она исполняет: модули пришли из кэша именно этого воркера.
- Смена контроллёра ведёт к перезагрузке, только если версия включившейся оболочки другая. Та же версия означает, что сервер отдал другой файл воркера при неизменной оболочке: перезагрузка вернула бы ту же самую страницу, и следующий запрос за `sw.js` начал бы всё сначала. Петля обрывается здесь и целиком — перезагрузок в ней не случается вовсе.
- Версия, которой не назвали (воркер прежнего выпуска такого вопроса не знает, ответа нет за три секунды), считается другой: обновление до выпуска, который отвечает, важнее. Повторяться этому не с чего — после перезагрузки отвечают оба.
- Первая установка запоминает версию нового контроллёра: страница пришла из сети, и оболочка, которую она исполняет, — та, что только что встала.
- Пауза между перезагрузками остаётся, но постоянная — 30 секунд, и `bare-updates` уходит. Замком от петли она больше не служит; это предел частоты на случай сервера, который отдаёт разные версии на каждый запрос. Отметка времени `bare-updated-at` остаётся в `sessionStorage`.
- Отложенная паузой перезагрузка возвращается сама, по таймеру: другого повода может и не быть — поля пусты, вкладка открыта, а вкладка спрашивает сервер об обновлении только при запуске и при возвращении в приложение (ADR-068).
## Следствия
- Десять релизов подряд за жизнь одной вкладки доезжают все и через одну и ту же паузу: замер — 29–30 секунд на релиз. Прежний замок на тех же релизах давал 30, 60, 120, 240 секунд и дальше вдвое.
- Сервер, отдающий новый `sw.js` на каждый запрос при неизменной версии, перезагрузок не вызывает ни одной. Воркер он при этом меняет — это дело браузера, и стоит оно одной установки в минуту, не чаще: чаще страница сервер не спрашивает.
- Релиз без смены `VERSION` страницу не перезагружает. Это не новое ограничение: ADR-023 обещал такому релизу только обновление статики по ETag.
- В `sw.js` появился второй вопрос от страницы. Протокола (`docs/protocol.md`) это не касается: разговор идёт внутри браузера.
- `docs/storage.md`: у клиента остаётся один ключ в `sessionStorage` вместо двух.
@@ -0,0 +1,35 @@
# ADR-071: Предел строки сообщения держится до вставки
Уточняет [ADR-069](069-message-input-is-editable-block.md): предел 4000 символов держит по-прежнему сам блок, но режет он приходящее, а не набранное.
## Контекст
Предел проверялся после вставки: блок переписывался значением, обрезанным по пределу. Резался при этом хвост всего содержимого, а не то, что только что пришло. Замер: в блоке 3950 «A», курсор на позиции 10, вставка 200 символов из буфера — в блоке 4000 символов, из них 3800 «A»: полтораста набранных символов исчезли. Отмена их не вернула: присваивание `textContent` сносит стек отмены браузера. `maxLength` у прежней `textarea` вёл себя обратно — резал вставляемое и не трогал ни одного уже набранного символа.
Тем же переписыванием блока стояли рядом ещё три расхождения с прежним полем:
- обрезка шла по единицам UTF-16 и разрывала суррогатную пару. 3999 «q» и набранное «😀» оставляли в блоке одинокий старший суррогат, и он доезжал собеседнику. `maxLength` эмодзи на границе просто не принимал.
- проверка срабатывала во время композиции IME. У предела композиция обрывалась на полуслове, а подтверждённый ввод не попадал в блок вовсе.
- курсор уезжал в конец на каждом нажатии. Править середину полного сообщения было нельзя; `maxLength` лишний символ не пускал и курсора не трогал.
И два счёта мимо: заполнитель последней строки, который Chrome держит в `plaintext-only` вторым `\n` в самом конце, попадал и в счётчик, и в предел — перенос в конце стоил двух символов из четырёх тысяч. А в запасном пути (браузер без `plaintext-only`) перетаскивание текста внутри самого блока раздваивало его: вставку там делаем мы, отменяя действие браузера, и уносить исходное браузеру после этого нечем.
## Решение
- Предел держится до вставки, а не после. `beforeinput` знает и что придёт (`data`, для буфера и мыши — `dataTransfer`), и что заменится (`getTargetRanges`). Влезающее вставляет браузер сам — тогда и отмена остаётся его. Не влезающее отменяется и вписывается обрезанным по свободному месту.
- Свободное место — предел минус то, что останется от набранного, когда заменяемое уйдёт. Набранное не режется никогда: сверх предела не проходит только приходящее.
- Резать по границе суррогатной пары нельзя: не влезла старшая половина — не вставляется и она. Эмодзи, которому не хватило одного символа, не попадает в блок вовсе — так же, как не пускал его `maxLength`.
- Композиция IME не трогается ничем: ни проверкой до вставки, ни обрезкой после. Лишнее снимается один раз, на `compositionend`; пока идёт композиция, счётчик стоит на месте.
- Обрезка после вставки остаётся последней страховкой — для того, что приехало мимо `beforeinput`. Она снимает ровно хвост сверх предела правкой текстовых узлов, а не переписывает блок: курсор остаётся там, где стоял, стек отмены цел.
- Считается и режется то, что уедет собеседнику: заполнитель последней строки в счёт не идёт. В `plaintext-only` это лишний `\n` в самом конце — в `textContent` он виден, а в сообщении его нет, его снимает `trim` при отправке. В запасном пути заполнитель — `<br>`, которого в `textContent` нет вовсе, и перенос в конце там настоящий.
- Перетаскивание изнутри блока в запасном пути объявляется копированием (`effectAllowed = "copy"`). Переноса при отменённом действии по умолчанию не выйдет, и молчаливое удвоение становится честной копией.
## Следствия
- Вставка в середину длинного сообщения больше не стирает набранное, и отмена работает везде, где вставку делает браузер.
- Отмена по-прежнему не помнит нашу вставку там, где её делаем мы: при переполнении и в запасном пути (ADR-069). Терять там уже нечего — часть вставленного и так не влезла.
- Одинокого суррогата в сообщении не бывает.
- Счётчик у порога говорит правду: перенос в конце стоит одного символа, а не двух.
- Курсор на пределе остаётся на месте: полное сообщение можно править с середины.
- Перетаскивание изнутри блока прогоном не проверяется: самодельный `DataTransfer` присвоения не принимает, а настоящее перетаскивание браузер без окна из страницы не начинает. Проверка — руками, в браузере без `plaintext-only`.
- Записано в `docs/ui.md`, «Чат».
@@ -0,0 +1,24 @@
# ADR-072: Отложенное обновление возвращается по таймеру
Уточняет [ADR-068](068-pwa-self-update.md): поводов вернуться к отложенному было два, и оба мимо самого частого случая.
## Контекст
Обновление ждёт, пока в полях вкладки набранное: перезагрузка унесла бы его. Вернуться к отложенному могли только событие `input` и возвращение в приложение.
Строка ввода пустеет и без того, и без другого. Отправка сообщения чистит блок присваиванием — события `input` это не порождает. Экран, с которого ушли, выбывает из документа вместе со своим полем — тем более.
Замер: во вкладке с черновиком соседняя вкладка включила новый выпуск, контроллёр сменился, перезагрузка отложена. Enter отправляет сообщение, поле пусто, вкладка на виду — за полторы минуты перезагрузки нет, оболочка прежняя. Один цикл смены видимости — перезагрузка через две секунды. То есть после самого обычного действия, «написал и отправил», приложение остаётся на старой оболочке до следующего возвращения в него, хотя платить за обновление уже нечем. Ради этого случая ADR-068 и написан.
## Решение
- Отложенное набранным возвращается само, по таймеру: полминуты, тем же таймером, которым возвращается отложенное паузой между перезагрузками (ADR-070). Поля пусты — обновление идёт дальше; набранное на месте — таймер заводится снова.
- Таймер заводится только тогда, когда обновление уже ждёт: оболочка сменилась или установленная версия ждёт включения. Иначе проверка полей крутилась бы вхолостую всю жизнь вкладки.
- Событие `input` остаётся: опустевшее поле оно замечает сразу, а таймер — не позже чем через полминуты.
## Следствия
- Отправленное сообщение больше не держит вкладку на старой оболочке: обновление доезжает в течение полуминуты после того, как поле опустело.
- Уход с экрана — то же самое: поле пропадает вместе с экраном, и заметить это иначе нечем.
- Пока обновление ждёт, а в полях набранное, вкладка раз в полминуты обходит свои поля. Это дешевле, чем протокол между экранами и обновлением.
- Записано в `docs/ui.md`, «Сеть и состояния».