Files
bare/web/js/zoom.js
T
mayatnikovandClaude Opus 5 383e374f0f 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
2026-08-23 15:11:13 +03:00

70 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Запрет масштабирования — страховка к строке viewport (ADR-066).
//
// Основное средство — `maximum-scale=1, user-scalable=no` в index.html
// и `touch-action: manipulation` в app.css. Safari вправе не послушаться:
// он и раньше игнорировал `user-scalable=no` целиком. Здесь то, что
// работает независимо от его настроения, — и ничего больше: прокрутка,
// свайпы и обычные нажатия проходят как проходили.
// Щипок в Safari — это gesturestart/gesturechange/gestureend. В других
// браузерах их не бывает вовсе, и подписка ничего не стоит.
const GESTURES = ["gesturestart", "gesturechange", "gestureend"];
// Два тапа подряд ближе этого срока и этого расстояния друг к другу —
// двойной тап, то есть масштабирование. Пороги браузерные: свои цифры
// здесь всё равно ничем не проверить.
const TAP_APART = 350;
const TAP_NEAR = 40;
// Прошлый тап. До первого касания его не было вовсе: иначе тап в углу
// в первые триста миллисекунд жизни страницы сошёл бы за второй.
const last = { at: -Infinity, x: 0, y: 0 };
// lock вешает страховку. Слушатели непассивные: пассивному
// preventDefault не даёт ничего.
export function lock() {
for (const name of GESTURES) {
document.addEventListener(name, stop, { passive: false });
}
document.addEventListener("touchend", tap, { passive: false });
}
function stop(event) {
event.preventDefault();
}
// tap гасит второй тап подряд — он и масштабирует. Первый доходит
// до страницы целиком, поэтому нажатия, свайпы и прокрутка не меняются.
function tap(event) {
// Второй палец на экране — это щипок или прокрутка двумя, не тап.
if (event.touches.length > 0 || event.changedTouches.length !== 1) {
last.at = -Infinity;
return;
}
const touch = event.changedTouches[0];
const quick = event.timeStamp - last.at <= TAP_APART;
const near = Math.abs(touch.clientX - last.x) <= TAP_NEAR
&& Math.abs(touch.clientY - last.y) <= TAP_NEAR;
last.at = event.timeStamp;
last.x = touch.clientX;
last.y = touch.clientY;
if (!quick || !near || control(event.target)) {
return;
}
event.preventDefault();
}
// control — элемент управления. Второй тап по нему не гасится:
// preventDefault на touchend уносит с собой click, а нажать кнопку
// дважды подряд — обычное дело. Цена мала: до элементов управления зум
// по двойному тапу не доходит и без страховки — `touch-action:
// manipulation` стоит на документе.
//
// Строка сообщения — редактируемый блок, а не textarea (ADR-069), и она
// в этом перечне: погашенный тап не доносит до неё ни click, ни фокус,
// то есть второй тап подряд не ставил бы курсор в поле ввода.
function control(target) {
return typeof target?.closest === "function"
&& target.closest("button, input, textarea, select, label, a, [contenteditable]") !== null;
}