diff --git a/DECISIONS.md b/DECISIONS.md index fee05c3..c2945d3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,10 +1,11 @@ # Pips — принятые решения Обмен пиксель-арт-карточками через QR-коды, файлы, ссылки и печать. Без -сервера и без аккаунтов. Здесь только то, о чём договорились. Как что -устроено — в README соответствующих модулей. +сервера и без аккаунтов. Здесь только то, о чём договорились, и почему — +по темам, в нынешнем виде (пересмотренное помечено *пересмотрено* и +объяснено на месте). Как что устроено — в README соответствующих модулей. -Обновлено: 27 октября 2026. +Обновлено: 28 октября 2026. ## Концепция @@ -21,133 +22,118 @@ ## Формат -- Холст до **64×64** в приложении (формат держит до 128), пресеты 16/24/32/48/64. - Это правило одно для нарисованных и импортированных карточек (см. «Что можно подписать»). -- Палитра до **32 цветов**. 256 цветов — нет. Пиксели хранятся **байт на пиксель** под deflate: на 22 работах это меньше упаковки по 4 бита в 21 случае из 22 и на треть меньше упаковки по 5 бит. -- На обороте: один хэндл (Telegram, Instagram, X, Bluesky, Mastodon, VK, TikTok, другое; до 48 байт) и одна ссылка — только https, без `https://`, до 64 байт, хост строчными буквами; IP-адреса, порты, `user@` и другие схемы невыразимы форматом. +- Холст до **64×64** в приложении (формат держит до 128), всегда квадратный: + 16/24/32/48/64. Граница — время передачи: кадр карусели 200 байт, показ + раз в 220 мс; камера с нашим декодером (iPhone, Firefox) берёт новый кадр + примерно раз в 0,36 с, плюс ~1,5 с навести. Рисунок 64×64 весит 0,9–2 КБ + (рисованный — фото, 32 цвета), 8 кадров анимации — до ~2,1 КБ: 5–11 + кадров карусели, 3–6 секунд. 96×96 — уже 3,7–4,4 КБ и ~10 с, 128×128 — + 13–16 с: на этом «ну сколько ещё». Поэтому сверх размера держим бюджет + ~4 КБ (≈10 с). +- **Что можно подписать** — одно правило для всего, что попадает на холст + (`web/src/budget.js`): сторона до 64, передача не дольше ~10 с (~4 КБ). + Редактор всё время показывает, сколько кодов и секунд займёт карточка. +- Палитра до **32 цветов**. 256 цветов — нет. Пиксели хранятся **байт на + пиксель** под deflate: на 22 работах это меньше упаковки по 4 бита в 21 + случае из 22 и на треть меньше упаковки по 5 бит. +- Палитры: 33 встроенные по одному байту (номера 0–63: 0–15 — наши, 16+ — + сторонние) или своя инлайн; импорт `.hex` и `.gpl`. Встроенная экономит + до 96 байт в QR. +- Пустая клетка — индекс 0 непрозрачной карточки. Готовая палитра остаётся + номером (0 байт), пока её первым цветом не рисуют вместе с пустыми + клетками; иначе и для своих палитр в карточку идут только использованные + цвета. +- Анимация: кадры и одна задержка на все. +- На обороте: один хэндл (Telegram, Instagram, X, Bluesky, Mastodon, VK, + TikTok, другое; до 48 байт) и одна ссылка — только https, без + `https://`, до 64 байт, хост строчными буквами; IP-адреса, порты, + `user@` и другие схемы невыразимы форматом. - **Теги — только локальные**, в карточку не попадают. -- Сертификат экземпляра и подпись пересылающего — в **конверте** передачи, отдельно от неизменной карточки. Если передаёт автор, подпись конверта не нужна. -- Палитры: 33 встроенные по одному байту (номера 0–63: 0–15 — наши, 16+ — сторонние) или своя инлайн; импорт `.hex` и `.gpl`. Встроенная экономит до 96 байт в QR. -- Анимация: формат поддерживает, редактор — тоже (кадры, калька, одна задержка). - Приложение подписывает **каждую** свою карточку (Ed25519), ключ создаётся при первом запуске. Формат подпись не требует: чужие неподписанные карточки (из другой реализации) принимаются, но помечаются. -- Транспорт: одиночный QR (base45, alphanumeric), карусель на fountain-кодах - для крупных карточек, файл `.pips`, ссылка с карточкой во фрагменте `#`, - печать — один плотный QR (на бумаге карусели нет). -- Карусель — свой простой формат вместо BC-UR: систематический случайный линейный код над GF(2), до 256 фрагментов, CRC-32. Подключиться можно с любого кадра; в среднем нужно ~1,6 кадра сверх минимума. Одиночный код — до 600 байт, уровень коррекции M. +- Сертификат экземпляра и подпись пересылающего — в **конверте** передачи, + отдельно от неизменной карточки. Если передаёт автор, подпись конверта + не нужна. -## Интерфейс +## Передача -- Вкладки: **Коллекция · Рисовать · Сканер · Импорт · Профиль**; сканер — - главное действие, круглая кнопка по центру. В ленте, на карточке, в архиве и - корзине панель уезжает: экран отдан карточкам. Редактор — полноэкранный режим. -- Всё доступно видимой кнопкой; жесты только ускоряют обратимое. -- **Коллекция** — одна ось масштаба: месяцы ← недели (по умолчанию) → лента → - карточка, щипок ведёт по ней непрерывно. Подпись группы — ячейка сетки. - Свои и полученные карточки вместе; фильтр «Все · Мои · Полученные», - поиск по названию, автору и #тегам, группировка по дате или по авторам — - в обзоре по месяцам, фильтр виден плашкой на всех уровнях. -- **Лента** — соседние карточки наложены, центральная приподнята. Вниз — в - архив (карточка садится на полосу архива поверх неё, лента смыкается во - время полёта, «Вернуть» в тосте), вверх — код в цвете рамки (своя — копия или - номерной экземпляр, чужая — копия от вас). Те же действия — в панели снизу. -- **Архив и корзина** — такие же места, как коллекция: те же уровни и жесты; - вниз из архива — обратно в коллекцию, из корзины — восстановить. -- Пустая коллекция начинается с визитки: узор из ключа, ник; по нажатию — - код получателя для номерного экземпляра. -- Смена набора карточек (фильтр, место) — уходящие тают на месте, оставшиеся - переезжают, новые появляются ряд за рядом. -- **Оборот** — путь по цепочке рук, когда получена, подпись и экземпляр, хэндл и - ссылка (текст + «копировать», ссылка открывается только через подтверждение - с доменом), картинка мелко; личные **теги** (до 16; на обороте две строки, остальные — за чипом «+N») и - **заметка** (поле в линейку на оставшееся место: что не помещается — не пишется). Теги и заметка - меняются только после карандаша — без случайной клавиатуры; тег вне правки — - фильтр коллекции. Теги и заметка — только на устройстве и в резервной копии. -- **Редактор** — рисуем саму карточку: холст в бумажной рамке, название - пишется в подписи под ним. Палец рисует прямо по холсту; для мелочей — - точный режим с прицелом и кнопкой «Ставить». Жесты: два пальца — масштаб - и сдвиг, тап двумя — отменить, тремя — вернуть, долгое нажатие — пипетка. - Инструменты: карандаш (pixel-perfect), ластик, заливка, линия, фигуры, - выделение, пипетка, зеркало, дизеринг; превью 1:1; палитра до 32 цветов. - **Анимация — с первой версии**: кадры, калька, одна задержка на все. -- **Отмена в редакторе — дерево, а не стопка.** Отменяется всё: штрихи, - кадры, палитра, размер, рамка, название. Изменение после отмены не стирает - отменённое, а начинает рядом ветку; в истории (удержать «Отменить») можно - вернуться к любому шагу любой ветки. Мелкие шаги подряд (набор названия, - протаскивание цвета, примерка палитр) — один шаг. Отмена открывает кадр, - где было изменение, и подсвечивает тронутые клетки. Черновик хранится - вместе со всей историей и переживает перезагрузку. Храним до 16 МБ шагов: - дальше забываются самые старые. -- Выделение — прямоугольник на открытом кадре: перенос (или копия), - отражение, поворот на четверть, стирание, стрелки двигают по клетке. - Пустые клетки переносимого ничего не закрывают; ушедшее за край теряется. - Пипетки в окне цвета нет: всё на холсте — уже цвета палитры, а их - выбирают долгим нажатием на холст. -- Пустая клетка — индекс 0 непрозрачной карточки. Готовая палитра остаётся - номером (0 байт), пока её первым цветом не рисуют вместе с пустыми - клетками; иначе и для своих палитр в карточку идут только использованные - цвета. -- **Холст всегда квадратный, 16–64 пикселя** (16/24/32/48/64). Граница — - время передачи: кадр карусели 200 байт, показ раз в 220 мс; камера с - нашим декодером (iPhone, Firefox) берёт новый кадр примерно раз в 0,36 с, - плюс ~1,5 с навести. Рисунок 64×64 весит 0,9–2 КБ (рисованный — - фото, 32 цвета), 8 кадров анимации — до ~2,1 КБ: 5–11 кадров карусели, - 3–6 секунд. 96×96 — уже 3,7–4,4 КБ и ~10 с, 128×128 — 13–16 с: на этом - «ну сколько ещё». Поэтому сверх размера держим бюджет ~4 КБ (≈10 с), а - редактор всё время показывает, сколько кодов и секунд займёт карточка. -- **Импорт (дальше)**: любая картинка — обрезать в квадрат, уменьшить - с усреднением до размера холста, полупрозрачность — порогом или на фон - рамки, затем подобрать палитру. Цель — много карточек на старте. -- Путь карточки: коллекция → архив → корзина (30 дней, затем удаляется сама) → - насовсем. Безвозвратное — только кнопкой с подтверждением. -- Нежелательное: скрыть карточку и заблокировать автора по ключу, локально. -- Интерфейс на русском и английском с первого дня (i18n). -- Шрифты: **Literata** (заголовки; у Fraunces нет кириллицы) и IBM Plex Sans, лежат в приложении — без Google Fonts. +- Один и тот же **конверт** уходит любым путём: одиночный QR (base45, + alphanumeric), карусель для крупных карточек, файл `.pips`, ссылка, + картинка карточки, печать — один плотный QR (на бумаге карусели нет). +- Карусель — свой простой формат вместо BC-UR: систематический случайный + линейный код над GF(2), до 256 фрагментов, CRC-32. Подключиться можно с + любого кадра; в среднем нужно ~1,6 кадра сверх минимума. Одиночный код — + до 600 байт, уровень коррекции M. +- Файл `.pips` — четыре байта подписи формата и конверт со всеми + подписями. Старые файлы с голой карточкой по-прежнему принимаются. +- **Ссылка** — для того, кого нет рядом: адрес приложения, карточка после + `#` (на сервер не уходит). Открытая ссылка приносит карточку тем же путём + проверки, что и скан (`ui/arrival.js`), и сразу убирается из адреса — + перезагрузка не добавит её снова. Если ключа ещё нет, первый экран + говорит, что карточка ждёт, и она появляется после создания или + восстановления ключа. Неполная ссылка (обрезал мессенджер) так и + называется. Ссылки ведут на эту копию приложения, а если она не на + https — на опубликованную. +- **QR остаётся кодом, а не ссылкой.** Ссылка открывала бы приложение + системной камерой, но base45 содержит пробел и `%` — в адресе они + недопустимы, — так что ссылка в QR — это base64 в байтовом режиме: на + ~35% больше бит (карточка в 600 байт — ~6,7 тыс. бит вместо ~5 тыс.), + плотнее код и больше кадров карусели. Для обмена на месте это хуже. + Вернуться к вопросу, если понадобится «приглашение» для людей без + приложения (отдельный маленький QR-ссылка на сайт, без карточки). +- **Картинка карточки** (1080×1440): бумага рамки, рисунок, имя, автор и, + если карточка помещается в один код, этот код — в цветах рамки, на + небольшой скруглённой подложке, одинаковой по виду на любой бумаге: на + светлой подложка — осветлённая бумага, модули — чернила, смягчённые к + серому ровно настолько, насколько позволяет контраст (≥ 7:1); на тёмной — + подложка светлого тона чернил, модули цветом бумаги (светлый код на + тёмном читают не все). Модуль ≥ 3 px; тест читает код на всех восьми + бумагах после JPEG, в полный размер и в ¾. Тот же конверт лежит и внутри + PNG (свой блок `piPs`, `png-card.js`): файл открывается в Pips как сама + карточка, с автором и подписями; если мессенджер пережал картинку или + это скриншот — карточку читает код на ней. Поэтому так можно отдать и + чужую карточку: автор виден и подписан. +- Скачанный файл называется латиницей: иначе некоторые браузеры называют + его просто «download». +- **Срока действия кода нет**: тираж решает сертификат, а срок обходится + переводом часов. Под него зарезервирован флаг конверта — можно добавить + позже без поломки формата. ## Данные и безопасность -- Всё локально: IndexedDB плюс `navigator.storage.persist()`, напоминание - о бэкапе. -- **Бэкап** — свой файл `.pipsbak`, зашифрованный **ключом личности** - (XChaCha20-Poly1305, ключ = SHA-256(домен ‖ seed)), без пароля. - *Пересмотрено* (было: ZIP + пароль через age): коллекцию без ключа всё - равно не восстановить — свои карточки подписаны им, — значит, 24 слова - уже обязательны; пароль добавил бы второй секрет, который забывают, и - ~40 КБ WASM. Секретного ключа в копии нет. Владелец (публичный ключ) - виден в заголовке — чтобы сказать «это копия другого ключа», а не - «повреждено». Восстановление только добавляет: существующие карточки - не трогает (заметку и теги берёт, только где их нет), журналы выдачи - **объединяет**, из профиля заполняет только пустые поля. +- Всё локально: IndexedDB плюс `navigator.storage.persist()` (пока браузер + может удалить данные, в «Профиле» есть кнопка попросить его этого не + делать), напоминание о копии. +- Запись в хранилище считается сделанной, когда транзакция завершилась, а + не когда принят запрос: иначе переполненный диск выдаёт ложное «сохранено». +- Версия раскладки данных — это номер версии самой базы IndexedDB + (`schema.js`). Новая версия приложения переводит данные шагами внутри + транзакции обновления: всё или ничего. Старые вкладки получают сигнал и + уступают («обновите страницу»). Старое приложение данные новее себя не + открывает и говорит об этом, а не показывает пустую коллекцию. Шаг после + выпуска не меняется: новая раскладка — новый шаг в конце. +- Подпись Ed25519 (`ed25519-dalek`) над `"pips-v1:card"` + каноническая форма. +- Ключ восстанавливается **24 словами BIP39 (английский список)** и + **бумажным QR**. Слова — это сам 32-байтный секрет плюс контрольная + сумма, без PBKDF2. Бумажный QR — тот же секрет в base45, **без пароля**: + хранить как паспорт, приложение об этом предупреждает. +- **Резервная копия** — свой файл `.pipsbak`, зашифрованный **ключом + личности** (XChaCha20-Poly1305, ключ = SHA-256(домен ‖ seed)), без + пароля. *Пересмотрено* (было: ZIP + пароль через age): коллекцию без + ключа всё равно не восстановить — свои карточки подписаны им, — значит, + 24 слова уже обязательны; пароль добавил бы второй секрет, который + забывают, и ~40 КБ WASM. Секретного ключа в копии нет. Владелец + (публичный ключ) виден в заголовке — чтобы сказать «это копия другого + ключа», а не «повреждено». Восстановление только добавляет: существующие + карточки не трогает (заметку и теги берёт, только где их нет), журналы + выдачи **объединяет**, из профиля заполняет только пустые поля. Копию + больше лимита ядра (64 МБ) не читаем в память вовсе. - **Напоминание о копии считает изменения**, а не число карточек: какие карточки добавлены, перемещены, изменены или удалены, менялся ли профиль, сколько выдано номеров. Выданный после копии номер делает её устаревшей сразу: восстановление из неё выдало бы этот номер ещё раз. Копия, восстановленная на пустое устройство, считается свежей. -- **Ключ можно сменить из «Профиля»** («Восстановить другой ключ») — тем же - экраном восстановления, что на новом телефоне; копия чужого ключа - предлагает это сама. Карточки остаются; «свои» — всегда те, что подписаны - нынешним ключом, поэтому карточки прежнего ключа становятся чужими (их - номера отсюда не выдать — об этом спрашивается заранее). Прежняя копия - после смены ключа не считается. -- Подпись Ed25519 (`ed25519-dalek`) над `"pips-v1:card"` + каноническая форма. -- Ключ восстанавливается **24 словами BIP39 (английский список)**: слова — это сам - 32-байтный секрет плюс контрольная сумма, без PBKDF2. Бумажный QR — тот же - секрет в base45, **без пароля**: хранить как паспорт, приложение об этом - предупреждает. -- **Сертификат экземпляра** (~105 Б): версия, номер N, тираж M (0 — без верхней - границы), ключ получателя, дата выдачи, подпись автора. Digest карточки не - передаётся — получатель вычисляет его сам. Получатель показывает свой «код - получателя» (ключ + ник), автор сканирует, затем отдаёт карточку с - сертификатом. Повторную выдачу одного номера без сервера не запретить; - приложение автора ведёт счётчик. -- **Конверт**: флаги, карточка, сертификат (если есть), ник, ключ и подпись - отправителя. Подпись обязательна, если отправитель не автор. -- **Срока действия кода нет**: тираж решает сертификат, а срок обходится - переводом часов. Под него зарезервирован флаг конверта — можно добавить - позже без поломки формата. -- Ключ автора восстанавливается фразой из 24 слов **и** бумажным QR из того - же секрета. - **Восстановление — один шаг.** На первом экране сначала (если есть) файл копии: его владелец виден без ключа, поэтому ключ сверяется с копией до того, как что-то сохранено. Затем ключ — словами или сканом бумажного QR. @@ -159,64 +145,25 @@ сразу: ячейка, с которой слово уже не получится; затем — «не сходятся» по контрольной сумме. Вставленная фраза раскладывается по ячейкам. Список слов берётся из ядра, а не дублируется в JS. +- **Ключ можно сменить** («Профиль» → «Ключ» → «Сменить ключ») — тем же + экраном восстановления, что на новом телефоне; копия чужого ключа + предлагает это сама. Это другое действие, чем «Восстановить» у резервной + копии. Карточки остаются; «свои» — всегда те, что подписаны нынешним + ключом, поэтому карточки прежнего ключа становятся чужими (их номера + отсюда не выдать — об этом спрашивается заранее). Прежняя копия после + смены ключа не считается. +- Секреты в «Профиле» показываются и скрываются той же кнопкой; текст + бумажного ключа свёрнут — печатается QR. -## Техника - -- **Rust:** минимальная версия 1.85 (её требует `zeroize` из зависимостей; резолвер 3 не даёт `cargo update` поднять её молча). Линты в CI — на зафиксированной версии, поднимаем осознанно. -- **PWA + ядро на Rust → WASM.** iOS позже; через PWA он работает без App Store. -- Хостинг — GitHub Pages на их домене (пока). Смена домена сломает уже - разосланные ссылки — учесть до первого публичного релиза. -- **Монорепо, маленькие независимые модули.** Каждый крейт решает одну задачу, - без лишних зависимостей, по возможности `no_std`, публикуется отдельно; - к каждому — CLI-утилита в духе coreutils (stdin → stdout, коды выхода - 0/1/2). Приложение — тонкий слой поверх них. -- Всё, что приходит снаружи, разбирается строго: отказ вместо починки, - лимиты проверяются до выделения памяти, каждый парсер под фаззером. -- Спека — CC0, код — Apache-2.0. Спеку публикуем после второй независимой - реализации. - -## Импорт картинок - -- **PNG декодируем сами.** Крейт `png` с его deflate-движками добавлял - ~85 КБ к WASM (две копии `miniz_oxide`, `fdeflate`, `flate2`); свой - декодер — ~10 КБ поверх уже имеющегося `miniz_oxide`. Риск своего кода - закрыт дифференциальным тестом: `png` остался только в тестах как эталон. - Итог: импорт стоит +44 КБ WASM (gzip +19 КБ). -- Сортировки в ядре — вставками: стандартные стоят ~7 КБ WASM на каждый тип - элемента, а сортируем мы списки до 257 элементов. -- Цвета берём как записаны (gamma/ICC игнорируем): палитра должна совпасть - точно, а пиксель-художник выбирает цвета числами. -- Aseprite сводим его же целочисленной формулой normal-наложения: учебная - «source over» расходится на ±1 в канале, а карточка должна выглядеть как - в редакторе. Проверено на файлах с экспортом из самого Aseprite. -- Отказ вместо «починки»: полупрозрачность, разная длительность кадров, - режимы наложения кроме normal. -- **Больше 32 цветов** — *пересмотрено*: было «отказ», стало «подобрать - свою палитру» (median cut, ≤32 цвета, целочисленно). На реальных - картинках фиксированная палитра заметно меняет оттенки, а подобранная - почти неотличима от оригинала. Преобразование видно (строка состояния, - выделенное поле палитры, «оригинал/карточка» по нажатию) и обратимо - выбором любой палитры. Ящик для деления выбирается по «размах × √пикселей»: - средняя ошибка чуть выше, чем у «размах × пиксели», зато мелкий акцент - (глаз, блик) не растворяется уже с 16 цветов. - Увеличение снимается автоматически, но отключается одной галочкой. -- Пиксель-арт браузерный декодер (canvas) не трогает: управление цветом - может сдвинуть значения, а для Aseprite его нет вовсе. Браузер нужен - только фото и картинкам крупнее холста: он декодирует (JPEG, WebP…) и - уменьшает выбранный квадрат, а палитру подбирает всё равно ядро. -- **Импорт живёт в редакторе** («Ещё → Новая карточка → Из картинки»), - отдельной вкладки нет. Результат — обычный рисунок на холсте: название, - рамку и подпись даёт редактор, прежний рисунок возвращает «Отменить». - Имя файла названием карточки не становится. -- Карточка квадратная. Пиксель-арт до 64×64 ложится как есть: неквадратный - — в середину холста, который его вмещает, на пустые клетки (ничего не - теряется). Крупнее или фото — через обрезку: квадрат двигают пальцем и - тянут за углы (колесо — на компьютере). Пиксель-арт режется по своей - сетке и не больше 64, чтобы пиксели остались; фото уменьшается до - выбранного размера. - -## Экземпляры и чтение QR +## Экземпляры +- **Сертификат экземпляра** (~105 Б): версия, номер N, тираж M (0 — без + верхней границы), ключ получателя, дата выдачи, подпись автора. Digest + карточки не передаётся — получатель вычисляет его сам. Получатель + показывает свой «код получателя» (ключ + ник), автор сканирует, затем + отдаёт карточку с сертификатом. +- **Конверт**: флаги, карточка, сертификат (если есть), ник, ключ и подпись + отправителя. Подпись обязательна, если отправитель не автор. - Номера выдаёт только устройство автора: журнал выдачи хранится у карточки и **входит в резервную копию** — иначе после восстановления нумерация началась бы с №1. Тираж закрепляется первым экземпляром. Следующий номер — @@ -226,19 +173,34 @@ двух устройств или копия старше выданных номеров. Поэтому: номерные экземпляры — с одного устройства; журналы при восстановлении объединяются; выдача сразу помечает копию устаревшей. -- **Что можно подписать** — одно правило для редактора и импорта - (`web/src/budget.js`): сторона до 64, передача не дольше ~10 с (~4 КБ). - Импорт показывает ту же стоимость передачи и объясняет отказ. - Квадратным холст обязан быть в редакторе; импорт пока принимает и - неквадратные картинки (обрезка в квадрат — в «Импорт (дальше)»). - На обороте путь карточки: «автор → … → @отправитель → вам». Конверт хранит только последнего отправителя, поэтому между автором и чужим отправителем — «…», а не прямая стрелка. -- `BarcodeDetector` есть только в Chrome на Android и macOS. Для Firefox, - Safari и Chrome на Linux — свой читатель (rqrr) в отдельном WASM: его - таблицы Рида — Соломона весят ~200 КБ, и в основное ядро он не входит. -- Список файлов для офлайн-кэша и версия кэша генерируются при сборке сайта - (`scripts/build-site.sh`), а не ведутся руками. + +## Интерфейс + +### Общее + +- Вкладки: **Коллекция · Рисовать · Сканер · Отдать · Профиль**; сканер — + главное действие, круглая кнопка по центру. В ленте, на карточке, в + архиве и корзине панель уезжает: экран отдан карточкам. Редактор — + полноэкранный режим. +- Всё доступно видимой кнопкой; жесты только ускоряют обратимое. +- **Интерфейс ведёт себя как предметы**: ничто не меняет размер и не + скачет, когда меняется содержимое (другая карточка, появившаяся панель, + поднятый палец). Место под то, что появляется, держится заранее, или + новое ложится поверх прежнего в том же месте. +- Уведомления — всегда в одном месте, под верхней панелью: низ занят то + вкладками, то действиями карточки, то полосой архива, а уведомление, + которое переезжает вслед за ними, прыгает прямо во время чтения. +- Интерфейс на русском и английском с первого дня. Отмена называет + действие, а не инструмент («Отменено: штрих карандашом»). +- Шрифты: **Literata** (заголовки; у Fraunces нет кириллицы) и IBM Plex + Sans, лежат в приложении — без Google Fonts. +- Новая версия ставится в фоне и включается при следующем запуске; если + она готова, пока приложение открыто, тост предлагает «Обновить» — + страница перезагружается целиком. Открытая страница никогда не берёт + модули новой версии вперемешку со старыми. - **Журнал ошибок** — на телефоне нет инструментов разработчика, поэтому приложение само записывает то, что пошло не так: непойманные ошибки и сбои, у которых нет объяснимого кода ядра (ошибки ядра с кодом — это @@ -250,139 +212,237 @@ «Поделиться» устройства, иначе буфер обмена, иначе файл), очистить; текст отчёта можно прочитать целиком до отправки. Непойманная ошибка один раз за запуск говорит о себе тостом со ссылкой на журнал. -- **Карандаш — в двух видах.** Обычный сглаживает линию (pixel-perfect): - клетка, которая только поворачивает угол, убирается — поэтому на миг - появляется и исчезает, когда палец сворачивает. Он никогда не убирает - клетку, которую тот же штрих уже нарисовал (возврат по своей линии, - пересечение) — это было ошибкой. Второй вид — «каждая клетка, углы как - есть», для углов, нарисованных нарочно. Оба — в меню карандаша (второе - касание), вместе с шахматкой. -- **Анимированные карточки играют там, куда смотрят**: в центре ленты и - открытая — по одной за раз. В сетке и полосе архива — первый кадр (ради - батареи) и маленький знак «▶» в углу. При «уменьшить движение» в системе - не играют нигде. -- **У каждой своей карточки есть название.** Подписать без него нельзя ни - в редакторе, ни в импорте: кнопка «Подписать» не прячется, а ставит - курсор в поле названия, встряхивает его и говорит, зачем оно (название - остаётся с карточкой у всех, к кому она попадёт). Формат пустое название - допускает — карточка из другого приложения или старая; такая везде - показывается как «Без названия» (курсивом, приглушённо), а не пустым - местом или прочерком. -- Панель вкладок уступает место полосе архива: пока карточка летит в - архив и пока полоса показывает, куда она легла, вкладки внизу не - появляются; возвращаются, когда полоса ушла. Одно уходит — другое - приходит, ничто не садится поверх. -- **Ссылка на карточку** — для того, кого нет рядом: в листе «Поделиться» - под кодом кнопка «Отправить ссылкой» (лист «Поделиться» устройства, иначе - буфер обмена). Ссылка — адрес приложения, карточка после `#` (на сервер не - уходит); открытая ссылка приносит карточку так же, как скан, тем же путём - проверки (`ui/arrival.js`), и сразу убирается из адреса — перезагрузка не - добавит её снова. Если ключа ещё нет, первый экран говорит, что карточка - ждёт, и она появляется после создания или восстановления ключа. Неполная - ссылка (обрезал мессенджер) так и называется. Ссылки ведут на эту копию - приложения, а если она не на https — на опубликованную. -- **QR остаётся кодом, а не ссылкой.** Ссылка открывала бы приложение - системной камерой, но base45 содержит пробел и `%` — в адресе они - недопустимы, — так что ссылка в QR — это base64 в байтовом режиме: на ~35% - больше бит (карточка в 600 байт — ~6,7 тыс. бит вместо ~5 тыс.), плотнее - код и больше кадров карусели. Для обмена на месте это хуже; передача тем, - кого нет рядом, — ссылкой. Вернуться к вопросу, если понадобится - «приглашение» для людей без приложения (отдельный маленький QR-ссылка на - сайт, без карточки). - **Режим разработчика** — в конце «Профиля», свёрнутый «Для разработчиков»; включается только точной фразой `I am a developer` (никакое касание или - опечатка его не включит), выключается кнопкой. Работает и на опубликованном - сайте — чтобы проверять на своём телефоне. Образцы карточек добавляются - после подтверждения и убираются одной кнопкой (приложение помнит, какие - карточки — образцы). Образцы рисует `scripts/samples.py`; в них нет чужих - работ: только нарисованное скриптом и картинки, переданные автором проекта - для этой цели. Они нарочно разные: 16–64, своя и встроенная палитры, пустые - клетки и сплошной фон, анимация, разные рамки. -- Новая версия ставится в фоне и включается при следующем запуске; если - она готова, пока приложение открыто, тост предлагает «Обновить» — - страница перезагружается целиком. Открытая страница никогда не берёт - модули новой версии вперемешку со старыми. - -## Проверки - -- **Браузерные тесты** (`tests/e2e`, node:test + Playwright) — сценарии - целиком на настоящем ядре: импорт (включая неподвижность раскладки), - обмен двумя «телефонами» через QR — код фотографируется и открывается - «Из картинки» нашим же читателем, отказы на повреждённые и поддельные - коды, экземпляры с тиражом, резервная копия, восстановление и смена - ключа, редактор. Идут в Chromium и Firefox; блокируют публикацию. -- **Фаззинг** (`fuzz/`, cargo-fuzz): карточка (с проверкой «одно написание - на карточку»), конверт и все сканируемые коды, base45 (строгость: - принятое кодируется обратно в тот же текст), палитры, PNG/Aseprite с - импортом, резервная копия, карусель. Затравки — `fuzz/seeds`. + опечатка его не включит), выключается кнопкой. Работает и на + опубликованном сайте — чтобы проверять на своём телефоне. Образцы + карточек добавляются после подтверждения и убираются одной кнопкой + (приложение помнит, какие карточки — образцы). Образцы рисует + `scripts/samples.py`; в них нет чужих работ: только нарисованное скриптом + и картинки, переданные автором проекта для этой цели. Они нарочно + разные: 16–64, своя и встроенная палитры, пустые клетки и сплошной фон, + анимация, разные рамки. -## Интерфейс: решения по отзывам +### Коллекция -- Только что полученная (или сделанная) карточка открывается крупно в - коллекции, где она теперь и лежит. -- Вкладка «Отдать» (на месте «Импорта»): свои карточки с кодом наготове — - выбранная лежит на бумаге своей рамки тем же компонентом, что и лист - «Поделиться»; внизу ряд своих карточек, выбранная приподнята. Код - получателя здесь не нужен — он для получения, и живёт на сканере. -- Способы отдать, везде одинаковые: код, «Ссылкой», «Файлом» (.pips — тот - же конверт, что в коде, со всеми подписями) и «Картинкой». Скачанный - файл называется латиницей: иначе некоторые браузеры называют его просто - «download». -- «Картинкой» — карточка целиком (1080×1440): бумага рамки, рисунок, имя, - автор и, если карточка помещается в один код, этот код — в цветах рамки, - на небольшой скруглённой подложке, одинаковой по виду на любой бумаге: - на светлой подложка — осветлённая бумага, модули — чернила, смягчённые к - серому ровно настолько, насколько позволяет контраст (≥ 7:1); на тёмной — - подложка светлого тона чернил, модули цветом бумаги (светлый код на - тёмном читают не все). Модуль ≥ 3 px; тест читает код на всех восьми бумагах после JPEG, - в полный размер и в ¾. Тот же конверт - лежит и внутри PNG (свой блок `piPs`, `png-card.js`). Файл открывается в - Pips как сама карточка, с автором и подписями; если мессенджер пережал - картинку или это скриншот — карточку читает код на ней. Поэтому так - можно отдать и чужую карточку: автор виден и подписан. Открытая в - редакторе «Из картинки», такая картинка сначала предлагает добавить - карточку в коллекцию. -- «Новая из неё» — только у своих карточек: рисунок уходит в редактор как - новая карточка, без названия. Чужой рисунок своим стать не может. -- Секреты на вкладке «Профиль» показываются и скрываются той же кнопкой; текст - бумажного ключа свёрнут — печатается QR. -- Уведомления — всегда в одном месте, под верхней панелью: низ занят то - вкладками, то действиями карточки, то полосой архива, а уведомление, - которое переезжает вслед за ними, прыгает прямо во время чтения. -- Архив и корзина в шапке — только у сеток. Рядом с одной карточкой корзина - читается как «удалить её», а в архив карточку и так отправляет её панель. +- **Одна ось масштаба**: месяцы ← недели (по умолчанию) → лента → + карточка, щипок ведёт по ней непрерывно. Подпись группы — ячейка сетки. + Свои и полученные карточки вместе; фильтр «Все · Мои · Полученные», + поиск по названию, автору и #тегам, группировка по дате или по авторам — + в обзоре по месяцам, фильтр виден плашкой на всех уровнях. - Подписи недель: число и месяц не разрываются («26 окт. –» / «1 нояб.»), шрифт ближе к месячному. Клетки остаются квадратными. Редкие недели не склеиваем: подпись «28 сент. – 18 окт.» длиннее и хуже читается, а настоящие коллекции растут скачками вокруг встреч, не по карточке в неделю. -- Профиль сохраняется сам, как язык: после паузы в наборе или при уходе из - поля; «Сохранено» мелькает у заголовка группы. Соцсеть — приставка в том же - поле, что и имя: без имени она читается как пустое поле, а не как выбор. -- «Сменить ключ» (группа «Ключ») и «Восстановить» (резервная копия) — два - разных действия, без второго «восстановить». Пока браузер может удалить - данные, рядом кнопка попросить его этого не делать. +- **Лента** — соседние карточки наложены, центральная приподнята. Вниз — в + архив (карточка садится на полосу архива поверх неё, лента смыкается во + время полёта, «Вернуть» в тосте), вверх — код в цвете рамки (своя — копия + или номерной экземпляр, чужая — копия от вас). Те же действия — в панели + снизу. - Лента после пальца: медленная рука — ближайшая карточка, без броска; свайп — одна карточка в его сторону; резкий бросок — чем быстрее, тем дальше (до 10). Скорость берётся только из последних 100 мс движения, и её нет, если палец перед отрывом замер: аккуратное движение не бросает. -- Ряд видов у карандаша и фигур — маленькое меню: Esc и касание мимо - закрывают только его, и это касание ничего под ним не нажимает. -- Запись в хранилище считается сделанной, когда транзакция завершилась, а - не когда принят запрос: иначе переполненный диск выдаёт ложное «сохранено». -- Резервную копию больше лимита ядра (64 МБ) не читаем в память вовсе. -- Версия раскладки данных — это номер версии самой базы IndexedDB - (`schema.js`). Новая версия приложения переводит данные шагами внутри - транзакции обновления: всё или ничего. Старые вкладки получают сигнал и - уступают («обновите страницу»). Старое приложение данные новее себя не - открывает и говорит об этом, а не показывает пустую коллекцию. Шаг после - выпуска не меняется: новая раскладка — новый шаг в конце. -- Редактор: у неподвижной картинки полоса кадров — одна кнопка «Кадр - анимации»; плитка проигрывания и пронумерованные кадры — с двух кадров. - Пустой холст не подписывается. -- Сканер (везде, где он есть): пока камера выключена, рамка сама — кнопка - «Включить камеру»; подсказки и ошибки — под её надписью, внутри уголков. - С камерой — внизу, поверх видео. Без доступа рамка так и говорит и - повторяет запрос по касанию. +- Панель вкладок уступает место полосе архива: пока карточка летит в + архив и пока полоса показывает, куда она легла, вкладки внизу не + появляются; возвращаются, когда полоса ушла. Одно уходит — другое + приходит, ничто не садится поверх. +- **Архив и корзина** — такие же места, как коллекция: те же уровни и жесты; + вниз из архива — обратно в коллекцию, из корзины — восстановить. Кнопки + архива и корзины в шапке — только у сеток: рядом с одной карточкой + корзина читается как «удалить её», а в архив карточку и так отправляет + её панель. +- Путь карточки: коллекция → архив → корзина (30 дней, затем удаляется + сама) → насовсем. Безвозвратное — только кнопкой с подтверждением. +- Смена набора карточек (фильтр, место) — уходящие тают на месте, оставшиеся + переезжают, новые появляются ряд за рядом. +- Только что полученная (или сделанная) карточка открывается крупно в + коллекции, где она теперь и лежит. +- Пустая коллекция начинается с визитки: узор из ключа, ник; по нажатию — + код получателя для номерного экземпляра. +- **Оборот** — путь по цепочке рук, когда получена, подпись и экземпляр, + хэндл и ссылка (текст + «копировать», ссылка открывается только через + подтверждение с доменом), картинка мелко; личные **теги** (до 16; на + обороте две строки, остальные — за чипом «+N») и **заметка** (поле в + линейку на оставшееся место: что не помещается — не пишется). Теги и + заметка меняются только после карандаша — без случайной клавиатуры; тег + вне правки — фильтр коллекции. Теги и заметка — только на устройстве и в + резервной копии. +- **Анимированные карточки играют там, куда смотрят**: в центре ленты и + открытая — по одной за раз. В сетке и полосе архива — первый кадр (ради + батареи) и маленький знак «▶» в углу. При «уменьшить движение» в системе + не играют нигде. +- «Новая из неё» — только у своих карточек: рисунок уходит в редактор как + новая карточка, без названия. Чужой рисунок своим стать не может. +- Нежелательное (позже): скрыть карточку и заблокировать автора по ключу, + локально. + +### Редактор + +- Рисуем саму карточку: холст в бумажной рамке, название пишется в подписи + под ним. Палец рисует прямо по холсту; для мелочей — точный режим с + прицелом и кнопкой «Ставить». Жесты: два пальца — масштаб и сдвиг, тап + двумя — отменить, тремя — вернуть, долгое нажатие — пипетка. Когда поднят + один из пальцев, оставшиеся продолжают от нынешнего вида, а не от начала + жеста — масштаб не отскакивает. +- Инструменты: карандаш, ластик, заливка, линия, фигуры, выделение, + пипетка, зеркало, шахматка; превью 1:1; палитра до 32 цветов; кадры, + калька, одна задержка на все. +- **Карандаш — в двух видах.** Обычный сглаживает линию (pixel-perfect): + клетка, которая только поворачивает угол, убирается — поэтому на миг + появляется и исчезает, когда палец сворачивает. Он никогда не убирает + клетку, которую тот же штрих уже нарисовал (возврат по своей линии, + пересечение). Второй вид — «клетка за клеткой, без сглаживания углов», + для углов, нарисованных нарочно. Оба — в меню карандаша (второе касание), + вместе с шахматкой. Ряд видов у карандаша и фигур — маленькое меню: Esc + и касание мимо закрывают только его, и это касание ничего под ним не + нажимает. +- **Отмена — дерево, а не стопка.** Отменяется всё: штрихи, кадры, палитра, + размер, рамка, название. Изменение после отмены не стирает отменённое, а + начинает рядом ветку; в истории (удержать «Отменить») можно вернуться к + любому шагу любой ветки. Мелкие шаги подряд (набор названия, протаскивание + цвета, примерка палитр) — один шаг. Отмена открывает кадр, где было + изменение, и подсвечивает тронутые клетки. Черновик хранится вместе со + всей историей и переживает перезагрузку. Храним до 16 МБ шагов: дальше + забываются самые старые. +- Выделение — прямоугольник на открытом кадре: перенос (или копия), + отражение, поворот на 90°, стирание, стрелки двигают по клетке. Пустые + клетки переносимого ничего не закрывают; ушедшее за край теряется. + Пипетки в окне цвета нет: всё на холсте — уже цвета палитры, а их + выбирают долгим нажатием на холст. +- **Ничто не сдвигает холст**: полоса выделения ложится поверх полосы + кадров (в точном режиме — поверх подсказки на панели), в том же месте. +- У неподвижной картинки полоса кадров — одна кнопка «Кадр анимации»; + плитка проигрывания и пронумерованные кадры — с двух кадров. +- Сетка, клетки, шашки и калька холста рисуются на одном холсте экранного + разрешения, клетка — от целого пикселя экрана до целого. Сам этот холст + стоит на целых CSS-пикселях, которые заодно целые пиксели экрана (при + 2,625 — каждый восьмой): браузер ставит холст на целые CSS-пиксели, и + иначе пересэмплирует его — линии расплываются и сходят с клеток. +- **У каждой своей карточки есть название.** Подписать без него нельзя: + кнопка «Подписать» не прячется, а ставит курсор в поле названия, + встряхивает его и говорит, зачем оно (название остаётся с карточкой у + всех, к кому она попадёт). Формат пустое название допускает — карточка + из другого приложения или старая; такая везде показывается как «Без + названия» (курсивом, приглушённо), а не пустым местом или прочерком. + Пустой холст тоже не подписывается. + +### Импорт картинок + +- **Импорт живёт в редакторе** («Ещё → Новая карточка → Из картинки»), + отдельной вкладки нет. Результат — обычный рисунок на холсте: название, + рамку и подпись даёт редактор, прежний рисунок возвращает «Отменить». + Имя файла названием карточки не становится. Картинка карточки Pips + сначала предлагает добавить саму карточку в коллекцию. +- Карточка квадратная. Пиксель-арт до 64×64 ложится как есть: неквадратный + — в середину холста, который его вмещает, на пустые клетки (ничего не + теряется). Крупнее или фото — через обрезку: квадрат двигают пальцем и + тянут за углы (колесо — на компьютере). Пиксель-арт режется по своей + сетке и не больше 64, чтобы пиксели остались; фото уменьшается до + выбранного размера. +- **PNG декодируем сами.** Крейт `png` с его deflate-движками добавлял + ~85 КБ к WASM (две копии `miniz_oxide`, `fdeflate`, `flate2`); свой + декодер — ~10 КБ поверх уже имеющегося `miniz_oxide`. Риск своего кода + закрыт дифференциальным тестом: `png` остался только в тестах как эталон. + Итог: импорт стоит +44 КБ WASM (gzip +19 КБ). +- Пиксель-арт браузерный декодер (canvas) не трогает: управление цветом + может сдвинуть значения, а для Aseprite его нет вовсе. Браузер нужен + только фото и картинкам крупнее холста: он декодирует (JPEG, WebP…) и + уменьшает выбранный квадрат, а палитру подбирает всё равно ядро. +- Цвета берём как записаны (gamma/ICC игнорируем): палитра должна совпасть + точно, а пиксель-художник выбирает цвета числами. +- Aseprite сводим его же целочисленной формулой normal-наложения: учебная + «source over» расходится на ±1 в канале, а карточка должна выглядеть как + в редакторе. Проверено на файлах с экспортом из самого Aseprite. +- Отказ вместо «починки»: полупрозрачность, разная длительность кадров, + режимы наложения кроме normal. +- **Больше 32 цветов** — *пересмотрено*: было «отказ», стало «подобрать + свою палитру» (median cut, ≤32 цвета, целочисленно). На реальных + картинках фиксированная палитра заметно меняет оттенки, а подобранная + почти неотличима от оригинала. Преобразование видно и обратимо выбором + любой палитры. Ящик для деления выбирается по «размах × √пикселей»: + средняя ошибка чуть выше, чем у «размах × пиксели», зато мелкий акцент + (глаз, блик) не растворяется уже с 16 цветов. Увеличение снимается + автоматически, но отключается одной галочкой. +- Сортировки в ядре — вставками: стандартные стоят ~7 КБ WASM на каждый + тип элемента, а сортируем мы списки до 257 элементов. + +### Отдать, Сканер, Профиль + +- **«Отдать»**: свои карточки с кодом наготове — выбранная лежит на бумаге + своей рамки тем же компонентом, что и лист «Поделиться»; внизу ряд своих + карточек, выбранная приподнята. Код получателя здесь не нужен — он для + получения и живёт на сканере. +- Способы отдать, везде одинаковые: код, «Ссылкой», «Файлом» и + «Картинкой» (см. «Передача»). +- Белый квадрат QR-кода всегда одного размера; код в нём — с целым числом + пикселей экрана (не CSS-пикселей) на модуль: шаг мелкий, и код любого + размера почти заполняет квадрат. Название — в одну строку, под заметки + место на две. Панель номерного экземпляра ложится поверх кода, в том же + месте. На «Отдать» сторона квадрата — сколько оставляет экран (180–300), + измеренная, а не вычисленная из размера окна. +- **Сканер** (везде, где он есть): пока камера выключена, рамка сама — + кнопка «Включить камеру»; подсказки и ошибки — под её надписью, внутри + уголков. С камерой — внизу, поверх видео. Без доступа рамка так и + говорит и повторяет запрос по касанию. +- **Профиль** сохраняется сам, как язык: после паузы в наборе или при уходе + из поля; «Сохранено» мелькает у заголовка группы. Соцсеть — приставка в + том же поле, что и имя: без имени она читается как пустое поле, а не как + выбор. + +## Техника + +- **Rust:** минимальная версия 1.85 (её требует `zeroize` из зависимостей; + резолвер 3 не даёт `cargo update` поднять её молча). Линты в CI — на + зафиксированной версии, поднимаем осознанно. +- **PWA + ядро на Rust → WASM.** iOS позже; через PWA он работает без App Store. +- Хостинг — GitHub Pages на их домене (пока). Смена домена сломает уже + разосланные ссылки — учесть до первого публичного релиза. +- **Монорепо, маленькие независимые модули.** Каждый крейт решает одну + задачу, без лишних зависимостей, по возможности `no_std`, публикуется + отдельно; к каждому — CLI-утилита в духе coreutils (stdin → stdout, коды + выхода 0/1/2). Приложение — тонкий слой поверх них. +- Всё, что приходит снаружи, разбирается строго: отказ вместо починки, + лимиты проверяются до выделения памяти, каждый парсер под фаззером. +- `BarcodeDetector` есть только в Chrome на Android и macOS. Для Firefox, + Safari и Chrome на Linux — свой читатель (rqrr) в отдельном WASM: его + таблицы Рида — Соломона весят ~200 КБ, и в основное ядро он не входит. +- Список файлов для офлайн-кэша и версия кэша генерируются при сборке сайта + (`scripts/build-site.sh`), а не ведутся руками. +- **Размеры для раскладки — из CSS, а не копией в JS.** Где коду нужно + число, которое задаёт стиль (подпись карточки, шаг полосы архива, место + над и под карточками), оно лежит в CSS-переменной и читается + (`cssNumber` в `dom.js`) или измеряется. Сами по себе ограничения вроде + «не больше половины экрана» — выбор, а не копия, и живут в коде. +- **CSS — общий для всех**, поэтому у класса один хозяин: модуль, который + его создаёт. Общий словарь (кнопки, поля, листы, части карточки) + перечислен в `tests/unit/css-classes.test.mjs`; тест не пускает второй + модуль с тем же именем и правила для классов, которых никто не создаёт. +- Строки интерфейса — в `i18n.js`, у обоих языков одни ключи с теми же + аргументами; ключ, который код не спрашивает, удаляется. Это проверяет + `tests/unit/i18n.test.mjs`. +- Спека — CC0, код — Apache-2.0. Спеку публикуем после второй независимой + реализации. + +## Проверки + +- **Модульные тесты** (`tests/unit`, node:test, без браузера): логика + интерфейса, хранилище и его версии, PNG-блок карточки, раскладка + коллекции, ввод в редакторе — и согласованность CSS-классов и строк + интерфейса. +- **Браузерные тесты** (`tests/e2e`, node:test + Playwright) — сценарии + целиком на настоящем ядре: обмен двумя «телефонами» через QR — код + фотографируется и открывается «Из картинки» нашим же читателем, ссылкой, + файлом и картинкой; отказы на повреждённые и поддельные коды, экземпляры + с тиражом, резервная копия, восстановление и смена ключа, редактор + (включая неподвижность раскладки, сетку при плотности экрана 2,625 и 3 и + жесты), коллекция. Идут в Chromium и Firefox; блокируют публикацию. + Жесты в них синтетические — перед слиянием в `main` их стоит пройти + пальцами на телефоне. +- **Фаззинг** (`fuzz/`, cargo-fuzz): карточка (с проверкой «одно написание + на карточку»), конверт и все сканируемые коды, base45 (строгость: + принятое кодируется обратно в тот же текст), палитры, PNG/Aseprite с + импортом, резервная копия, карусель. Затравки — `fuzz/seeds`. ## Модули @@ -393,8 +453,8 @@ | `pips-palette` | встроенные палитры, импорт `.hex`/`.gpl`; утилита `pips-palette` | готов; сторонние палитры сверены с Lospec и проверяются тестом | | `pips-sign` | ключи, подписи, сертификаты экземпляров, код получателя, конверт, фраза и бумажный ключ; утилита `pips-sign` | готов | | `pips-qr` | одиночный код и карусель (fountain); утилита `pips-qr` | готов | -| `pips-raster` | PNG/Aseprite → картинка карточки: свой строгий PNG, Aseprite со слоями, снятие увеличения, подбор палитры; утилита `pips-raster`; `no_std` | импорт готов; экспорт PNG и лист печати — позже | +| `pips-raster` | PNG/Aseprite → картинка карточки: свой строгий PNG, Aseprite со слоями, снятие увеличения, подбор палитры; утилита `pips-raster`; `no_std` | импорт готов; лист печати — позже | | `pips-scan` | чтение QR (rqrr) для браузеров без `BarcodeDetector` — отдельный WASM ~100 КБ gzip, грузится при первом сканировании, а не с ядром | готов | | `pips-store` | резервная копия: строгий формат, шифрование ключом личности; утилита `pips-backup`; `no_std` | готов | -| `web/` | PWA на чистых ES-модулях без сборки и зависимостей; строгая CSP, шрифты свои; интерфейс из небольших компонентов (`src/kit/`, см. `web/README.md`) | онбординг, коллекция на оси масштаба (месяцы · недели · лента · карточка) с поиском, тегами, архивом и корзиной, код и экземпляры из ленты, импорт, «Сканер» (+ код получателя), «Профиль» (+ резервная копия) | +| `web/` | PWA на чистых ES-модулях без сборки и зависимостей; строгая CSP, шрифты свои; интерфейс из небольших компонентов (`src/kit/`, см. `web/README.md`) | онбординг, коллекция на оси масштаба (месяцы · недели · лента · карточка) с поиском, тегами, архивом и корзиной; редактор с импортом картинок; «Сканер» (+ код получателя); «Отдать» (код, ссылка, файл, картинка карточки); «Профиль» (+ резервная копия, журнал ошибок) | | `pips-wasm` | фасад ядра для приложения + привязки wasm-bindgen; `scripts/build-wasm.sh` | готов; собирается и работает в браузере (проверено) | diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 7f71280..0a57f46 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -526,7 +526,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s // A line, undone with the keyboard, redone with the button. await stroke([2, 2], [9, 2]); assert.equal(await cell(5, 2), '#17151f'); - assert.equal(await undoLabel(), 'Отменить: карандаш'); + assert.equal(await undoLabel(), 'Отменить: штрих карандашом'); await p.keyboard.press('Control+z'); assert.equal(await cell(5, 2), null); await p.click('.ed-bar .ib >> nth=2'); @@ -574,7 +574,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s await p.keyboard.press('d'); await stroke([2, 20], [9, 20]); assert.deepEqual([await cell(2, 20), await cell(3, 20), await cell(4, 20)], ['#17151f', null, '#17151f']); - assert.equal(await undoLabel(), 'Отменить: шахматка'); + assert.equal(await undoLabel(), 'Отменить: штрих шахматкой'); await p.keyboard.press('d'); // Zooming brings the whole card closer; 0 puts it back. @@ -591,7 +591,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s await p.click('.bottom-sheet.on .btn.key'); assert.match(await p.textContent('.ed-pal-name'), /Sweetie 16/); assert.notEqual(await cell(5, 2), '#17151f'); - assert.equal(await undoLabel(), 'Отменить: другая палитра'); + assert.equal(await undoLabel(), 'Отменить: смена палитры'); // A second frame, a title; the meter says one code. await p.click('.ed-frame.add'); @@ -606,7 +606,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s await tab(p, 'draw'); await p.locator('.ed-frame-list .ed-frame >> nth=1').waitFor(); assert.equal(await p.inputValue('.ed-title'), 'Лесенка'); - assert.match(await undoLabel(), /название/); + assert.match(await undoLabel(), /смена названия/); // Signed, it opens in the collection. await p.click('.ed-sign'); @@ -1022,8 +1022,8 @@ test('the tab bar waits for the card to land in the archive and the strip to go' }); test('the editor grid lies exactly on the cells, from edge to edge', async () => { - for (const desk of [false, true]) { - const p = await env.phone('pixelfox', undefined, desk); + for (const [desk, scale] of [[false, 1], [true, 1], [false, 2.625], [false, 3]]) { + const p = await env.phone('pixelfox', undefined, desk, scale); await tab(p, 'draw'); await p.locator('.ed-art').waitFor(); await p.waitForTimeout(400); @@ -1041,6 +1041,15 @@ test('the editor grid lies exactly on the cells, from edge to edge', async () => }); // The grid's lines start and end on the art's edges (within a pixel). m.art.forEach((a, i) => assert.ok(Math.abs(a - m.grid[i]) <= 1.5, `${desk ? 'computer' : 'phone'}: ${JSON.stringify(m)}`)); + // And the canvas they are on lies on whole device pixels, one of its + // pixels to each: anything else is resampled, and a line blurs over two. + // At 2.625 that is only every 8th CSS pixel. + const fit = await p.evaluate(() => { + const c = /** @type {HTMLCanvasElement} */ (document.querySelector('.ed-lines')), r = c.getBoundingClientRect(), k = devicePixelRatio; + const whole = (v) => Math.abs(v - Math.round(v)) < 1e-3; + return { ok: whole(r.left) && whole(r.top) && whole(r.left * k) && whole(r.top * k) && Math.abs(r.width * k - c.width) < 1e-3 && Math.abs(r.height * k - c.height) < 1e-3, r: [r.left, r.top, r.width, r.height], c: [c.width, c.height], k }; + }); + assert.ok(fit.ok, JSON.stringify(fit)); await p.context().close(); } }); @@ -1359,3 +1368,44 @@ test('the code on a card picture takes the paper’s colours and still reads, on assert.deepEqual(results, Array(16).fill(true), `every paper, at both sizes: ${results.map(Number).join('')}`); await p.context().close(); }); + +test('the editor holds still: a pinch keeps its zoom as the fingers lift, a selection moves nothing', async () => { + const p = await env.phone('stillfox'); + await tab(p, 'draw'); + await p.locator('.ed-art').waitFor(); + const width = () => p.evaluate(() => document.querySelector('.ed-art').getBoundingClientRect().width); + const w0 = await width(); + /** A finger on the canvas, by its offset from the middle. */ + const finger = (type, id, dx, dy = 0) => p.evaluate(([type, id, dx, dy]) => { + const el = document.querySelector('.ed-card-wrap'), r = el.getBoundingClientRect(); + el.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: id, pointerType: 'touch', clientX: r.left + r.width / 2 + dx, clientY: r.top + r.height / 2 + dy })); + }, [type, id, dx, dy]); + // Two fingers spread to three times as far apart: the zoom. + await finger('pointerdown', 1, -30); + await finger('pointerdown', 2, 30); + for (const d of [45, 60, 75, 90]) { await finger('pointermove', 1, -d); await finger('pointermove', 2, d); } + const w1 = await width(); + assert.ok(w1 > w0 * 2, `zoomed in (${w0} → ${w1})`); + // One lifts first; the other moves a little before it lifts too. + await finger('pointerup', 2, 90); + await finger('pointermove', 1, -80, 6); + await finger('pointermove', 1, -70, 12); + await finger('pointerup', 1, -70, 12); + await p.waitForTimeout(400); + assert.ok(Math.abs((await width()) - w1) < 1, `the zoom stays as the fingers lift (${w1} → ${await width()})`); + + // A selection, plain and precise: the card does not move by a pixel. + const rect = () => p.evaluate(() => JSON.stringify(document.querySelector('.ed-card-wrap').getBoundingClientRect())); + await p.click('.ed-tool-select'); + for (const precise of [false, true]) { + if (precise) await p.click('.ed-tool-precise'); + const r0 = await rect(); + await p.keyboard.press('Control+a'); + await p.waitForTimeout(100); + assert.equal(await rect(), r0, `${precise ? 'precise' : 'plain'}: the card stays as the selection's bar comes`); + await p.keyboard.press('Escape'); + await p.waitForTimeout(100); + assert.equal(await rect(), r0, `${precise ? 'precise' : 'plain'}: and as it goes`); + } + await p.context().close(); +}); diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs index 9a97c84..63b5330 100644 --- a/tests/e2e/helpers.mjs +++ b/tests/e2e/helpers.mjs @@ -44,10 +44,10 @@ export async function setup() { const browser = await engine.launch(); const dir = await mkdtemp(join(tmpdir(), 'pips-e2e-')); const errors = []; - /** A fresh phone at its first screen; `desk`: a computer's screen instead. */ - async function blank(name, desk = false) { + /** A fresh phone at its first screen; `desk`: a computer's screen instead; `scale`: device pixels to a CSS pixel. */ + async function blank(name, desk = false, scale = 1) { const viewport = desk ? { width: 1440, height: 900 } : { width: 390, height: 844 }; - const ctx = await browser.newContext({ viewport, locale: 'ru-RU', acceptDownloads: true }); + const ctx = await browser.newContext({ viewport, locale: 'ru-RU', acceptDownloads: true, deviceScaleFactor: scale }); const p = await ctx.newPage(); p.on('pageerror', (e) => errors.push(`${name}: ${e.message}`)); p.on('console', (m) => { if (m.type() === 'error') errors.push(`${name}: ${m.text()}`); }); @@ -55,8 +55,8 @@ export async function setup() { return p; } /** A fresh phone with a key; with `words`, restored instead of created. */ - async function phone(nick, words, desk = false) { - const p = await blank(nick, desk); + async function phone(nick, words, desk = false, scale = 1) { + const p = await blank(nick, desk, scale); if (words) { await p.click('text=У меня уже есть ключ'); await p.fill('.words-entry input >> nth=0', words); // a phrase pasted fills every cell diff --git a/tests/unit/css-classes.test.mjs b/tests/unit/css-classes.test.mjs new file mode 100644 index 0000000..4e285b8 --- /dev/null +++ b/tests/unit/css-classes.test.mjs @@ -0,0 +1,96 @@ +// The class names between the stylesheets and the code. All CSS here is +// global, so two parts of the app that pick the same name for different +// things style each other without a word (it happened: a strip of the +// palette and a place in the editor's layout were both .ed-strip). And a +// rule for a class nobody makes any more is dead weight that looks alive. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOT = new URL('../../web/', import.meta.url).pathname; +const walk = (d) => readdirSync(d).flatMap((f) => { + const p = join(d, f); + return statSync(p).isDirectory() ? (f === 'pkg' ? [] : walk(p)) : [p]; +}); +const files = walk(ROOT); +const js = files.filter((f) => f.endsWith('.js')); +const css = files.filter((f) => f.endsWith('.css')); +const html = files.filter((f) => f.endsWith('.html')); + +/** + * Classes meant to be used by any part of the app: the shared vocabulary + * (buttons, fields, sheets, bars) and the card's own parts, which the + * editor lays out as the card does. Any other class belongs to the one + * module that makes it. A new shared one is added here on purpose. + */ +const SHARED = new Set([ + // layout of a screen and its bar + 'view', 'bar', 'title', 'sub', 'scroll', 'empty', 'lead', 'group', 'wide', 'pill', + // buttons and their rows + 'btn', 'key', 'text-btn', 'icon-btn', 'row-btns', 'add', + // fields and texts + 'field', 'check', 'hint', 'small', + // sheets + 'grab', 'scrim', + // the card and the key's pictures, wherever they are shown + 'face', 'front', 'cap', 'idcard', 'word-grid', + // the editor's own pieces, shared by its sheets and its layout + 'ed-note', 'ed-swatch', 'ed-set-row', 'ed-place', +]); + +/** The words inside the string and template literals of a source (the + * static parts of a template), and the prefixes a template completes + * (`is-${kind}` makes every is-…). */ +function words(src) { + const out = new Set(), prefixes = new Set(); + // Quoted strings anywhere, those inside a template's ${…} too; then + // the templates' own parts. + const quoted = [...src.matchAll(/'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"/g)].map((m) => m[1] ?? m[2]); + const templates = [...src.matchAll(/`((?:[^`\\]|\\.)*)`/g)].map((m) => m[1]); + for (const text of [...quoted, ...templates]) { + for (const part of text.split(/\$\{[^}]*\}/)) for (const w of part.match(/[a-z][\w-]*/g) ?? []) out.add(w); + for (const p of text.matchAll(/([a-z][\w-]*-)\$\{/g)) prefixes.add(p[1]); + } + return { out, prefixes }; +} + +/** The classes a CSS file's selectors name. */ +function selectorClasses(src) { + const sel = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/@import[^;]*;/g, '').replace(/\{[^{}]*\}/g, '{}'); + return new Set([...sel.matchAll(/\.([a-z][\w-]*)/g)].map((m) => m[1])); +} + +test('every class the stylesheets style is made somewhere', () => { + const made = new Set(), prefixes = new Set(); + for (const f of js) { + const w = words(readFileSync(f, 'utf8')); + w.out.forEach((x) => made.add(x)); + w.prefixes.forEach((x) => prefixes.add(x)); + } + for (const f of html) for (const m of readFileSync(f, 'utf8').matchAll(/class="([^"]*)"/g)) m[1].split(/\s+/).forEach((x) => made.add(x)); + const dead = []; + for (const f of css) { + for (const c of selectorClasses(readFileSync(f, 'utf8'))) { + if (!made.has(c) && ![...prefixes].some((p) => c.startsWith(p))) dead.push(`${relative(ROOT, f)}: .${c}`); + } + } + assert.deepEqual(dead, [], 'rules for classes nothing makes'); +}); + +test('a class is made by one module, unless it is shared on purpose', () => { + /** @type {Map>} */ + const by = new Map(); + for (const f of js) { + const src = readFileSync(f, 'utf8'); + for (const m of src.matchAll(/class:\s*(['`])([^'`]*)\1/g)) { + for (const c of m[2].replace(/\$\{[^}]*\}/g, ' ').split(/\s+/)) { + if (!/^[a-z][\w-]*$/.test(c) || SHARED.has(c)) continue; + if (!by.has(c)) by.set(c, new Set()); + by.get(c)?.add(relative(ROOT, f)); + } + } + } + const twice = [...by].filter(([, f]) => f.size > 1).map(([c, f]) => `.${c}: ${[...f].join(', ')}`); + assert.deepEqual(twice, [], 'one name for two things: rename one, or add it to SHARED if it is one thing'); +}); diff --git a/tests/unit/editor-input.test.mjs b/tests/unit/editor-input.test.mjs index cbcd94a..7c9e1d3 100644 --- a/tests/unit/editor-input.test.mjs +++ b/tests/unit/editor-input.test.mjs @@ -48,7 +48,24 @@ test('a second finger takes the stroke back; a quick two-finger tap undoes', () s.down(2, 100, 10); s.up(2, 100, 10); s.up(1, 10, 10); - assert.deepEqual(s.log0(), [['draw', 10], ['cancel'], ['view'], ['view-end'], ['taps', 2]]); + // The view starts again from the finger that stays (see the next test). + assert.deepEqual(s.log0(), [['draw', 10], ['cancel'], ['view'], ['view'], ['view-end'], ['taps', 2]]); +}); + +test('a finger left after a pinch carries on from where the view is, not from the pinch’s start', () => { + const starts = []; + const on = {}; + drawInput(/** @type {any} */ ({ addEventListener: (type, f) => { on[type] = f; }, setPointerCapture() {} }), { + draw: () => ({ move() {}, end() {}, cancel() {} }), pick() {}, taps() {}, + view: { start: (m, d) => starts.push([m.x, d]), move() {}, end() {} }, + }); + const ev = (type, id, x) => on[type]({ type, pointerId: id, pointerType: 'touch', clientX: x, clientY: 0, button: 0, target: { closest: () => null } }); + ev('pointerdown', 1, 0); + ev('pointerdown', 2, 100); + ev('pointermove', 2, 300); + ev('pointerup', 2, 300); + // Measured from its start, one finger has no spread, and the zoom would spring back. + assert.deepEqual(starts, [[50, 100], [0, 0]]); }); test('three fingers tapped together redo', () => { diff --git a/tests/unit/i18n.test.mjs b/tests/unit/i18n.test.mjs new file mode 100644 index 0000000..1103cf3 --- /dev/null +++ b/tests/unit/i18n.test.mjs @@ -0,0 +1,49 @@ +// The two languages kept in step with each other and with the code: the +// same keys, the same arguments, no string the code never asks for, and +// no key the code asks for that is not there (it would show as the key). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const { dictionaries } = await import('../../web/src/i18n.js'); +const { ru, en } = dictionaries; + +const SRC = new URL('../../web/src/', import.meta.url).pathname; +const walk = (d) => readdirSync(d).flatMap((f) => { + const p = join(d, f); + return statSync(p).isDirectory() ? walk(p) : [p]; +}); +const code = walk(SRC).filter((f) => f.endsWith('.js') && !f.endsWith('i18n.js')).map((f) => ({ f: relative(SRC, f), s: readFileSync(f, 'utf8') })); +const all = code.map((c) => c.s).join('\n'); + +test('both languages have the same strings, taking the same arguments', () => { + const ruKeys = Object.keys(ru).sort(), enKeys = Object.keys(en).sort(); + assert.deepEqual(ruKeys.filter((k) => !(k in en)), [], 'in Russian only'); + assert.deepEqual(enKeys.filter((k) => !(k in ru)), [], 'in English only'); + const shape = (v) => (typeof v === 'function' ? `fn/${v.length}` : typeof v); + assert.deepEqual(ruKeys.filter((k) => shape(ru[k]) !== shape(en[k])).map((k) => `${k}: ${shape(ru[k])} vs ${shape(en[k])}`), []); +}); + +test('every string is asked for somewhere, and every string asked for is there', () => { + // Keys made in code: `ed.h.${tool}`, reason('backup.err', e). + const prefixes = new Set([ + ...[...all.matchAll(/`([a-z][\w.-]*\.)\$\{/g)].map((m) => m[1]), + ...[...all.matchAll(/reason\('([a-z][\w.-]*)'/g)].map((m) => `${m[1]}.`), + ]); + const quoted = new Set([...all.matchAll(/'([a-z][\w-]*(?:\.[\w-]+)+)'/g)].map((m) => m[1])); + // And `${key}.title`: a key with a suffix, the key itself quoted. + const suffixes = new Set([...all.matchAll(/\$\{[^}]*\}(\.[\w-]+)`/g)].map((m) => m[1])); + const used = (k) => quoted.has(k) || [...prefixes].some((p) => k.startsWith(p)) || [...suffixes].some((x) => k.endsWith(x) && quoted.has(k.slice(0, -x.length))); + const unused = Object.keys(ru).filter((k) => !used(k)); + assert.deepEqual(unused, [], 'strings nothing asks for'); + // Literal keys handed to t(): each must exist. + const missing = []; + for (const { f, s } of code) for (const m of s.matchAll(/\bt\('([^']+)'/g)) if (!(m[1] in ru)) missing.push(`${f}: ${m[1]}`); + assert.deepEqual(missing, [], 'asked for, but not there'); +}); + +test('no Russian quotes or untranslated words in English', () => { + const bad = Object.entries(en).filter(([, v]) => /[«»а-яё]/i.test(typeof v === 'function' ? v.toString() : v)).map(([k]) => k); + assert.deepEqual(bad, []); +}); diff --git a/web/README.md b/web/README.md index 1bf8995..a3351e0 100644 --- a/web/README.md +++ b/web/README.md @@ -104,6 +104,15 @@ thing, with one way of fitting together: screen until another screen needs it. - **Every number has a reason** written next to it: a constant is named, and says where it comes from (the format, a measurement, DECISIONS.md). +- **A size the CSS sets is read, not copied.** When code must lay out + something the same way the stylesheet does, the value lives in a CSS + custom property (`cssNumber()` in `dom.js`) or is measured. +- **A class has one owner**: the module that makes it. CSS is global, so a + second module picking the same name styles the first. The shared + vocabulary (buttons, fields, sheets, the card's parts) is listed in + `tests/unit/css-classes.test.mjs`, which also refuses rules for classes + nothing makes. `tests/unit/i18n.test.mjs` keeps both languages and the + code in step. - **Fix causes, not symptoms.** A bug is understood before it is fixed, and the fix comes with a test that fails without it. diff --git a/web/css/app.css b/web/css/app.css index b053725..1ff44e8 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -39,6 +39,8 @@ --paper: #f6f3eb; --paper2: #e8e3d6; --back: #efeadf; --ink: #1c1d22; --dim: #8e8578; --cobalt: #3b5fa8; --cobalt-hi: #6d8fd6; --ember: #c4392f; --warn-bg: #3a2327; --warn: #e8a7a1; --serif: "Literata", Georgia, serif; --sans: "IBM Plex Sans", system-ui, sans-serif; + /* A card's caption: this share of the frame's width plus this much (read by stage.js too). */ + --cap-share: .19; --cap-add: 10px; --tabs: 66px; --safe-b: env(safe-area-inset-bottom, 0px); --safe-t: env(safe-area-inset-top, 0px); color-scheme: dark; } @@ -93,9 +95,6 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt .icon-btn { border: 0; background: none; color: var(--text2); width: 42px; height: 42px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; position: relative; } .icon-btn:hover { background: var(--raise); } -.badge { position: absolute; top: 4px; right: 2px; min-width: 16px; height: 16px; border-radius: 8px; padding: 0 4px; - background: var(--raise2); color: var(--text2); font-size: 10px; line-height: 16px; font-weight: 600; - box-shadow: 0 0 0 2px var(--void); } .btn { border: 0; border-radius: 13px; min-height: 48px; padding: 12px 16px; background: var(--raise2); color: #dde0e8; font-weight: 500; font-size: 14.5px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; } .btn.key { background: var(--cobalt); color: #f2efe7; } @@ -150,7 +149,7 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt .art.playing .anim-mark { opacity: 0; } .anim-mark::after { content: ''; position: absolute; inset: 28% 24% 28% 36%; background: var(--shade-ink); opacity: .75; clip-path: polygon(0 0, 100% 50%, 0 100%); } -.cap { height: calc(19cqw + 10px); min-height: 34px; display: flex; flex-direction: column; justify-content: center; overflow: hidden; } +.cap { height: calc(var(--cap-share) * 100cqw + var(--cap-add)); min-height: 34px; display: flex; flex-direction: column; justify-content: center; overflow: hidden; } .cap b { font: 600 max(11px, 6.2cqw)/1.3 var(--serif); color: var(--shade-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cap i { font-style: normal; font-size: max(10px, 4.6cqw); line-height: 1.35; color: var(--shade-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .frame.bare .cap { display: none; } @@ -165,7 +164,7 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt .seg button { flex: 1; border: 0; background: none; border-radius: 8px; padding: 7px; font-size: 13px; font-weight: 500; color: var(--text3); } .seg button[aria-pressed="true"] { background: var(--raise2); color: var(--text); } -.qr-box { width: fit-content; margin-inline: auto; background: #fff; border-radius: 16px; padding: 14px; box-shadow: 0 18px 36px -24px #000; } +.qr-box { box-sizing: content-box; display: grid; place-items: center; margin-inline: auto; background: #fff; border-radius: 16px; padding: 14px; box-shadow: 0 18px 36px -24px #000; } .qr-box canvas { display: block; image-rendering: pixelated; } .qr-note { font-size: 12.5px; color: var(--text2); text-align: center; } @@ -252,7 +251,6 @@ a.btn { text-decoration: none; } .import-status.bad { color: var(--warn); font-weight: 500; } .import-extras:empty { display: none; } .import-extras .check { margin: 10px 0; } -.field.attention { box-shadow: inset 0 0 0 2px var(--cobalt); } .shades { display: flex; flex-wrap: wrap; gap: 8px; } .shade { width: 34px; height: 34px; border-radius: 9px; border: 0; box-shadow: inset 0 0 0 1px #0003; } .shade[aria-checked="true"] { box-shadow: 0 0 0 2px var(--void), 0 0 0 4px var(--cobalt); } diff --git a/web/css/collection.css b/web/css/collection.css index c868e73..483d61f 100644 --- a/web/css/collection.css +++ b/web/css/collection.css @@ -12,7 +12,8 @@ .level-switch:disabled { cursor: default; } .filter-chip { border: 0; border-radius: 99px; padding: 1px 9px; font-size: 12px; font-weight: 500; background: #6d8fd62e; color: var(--cobalt-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } -.safe-probe { position: fixed; visibility: hidden; pointer-events: none; padding: env(safe-area-inset-top, 0px) 0 env(safe-area-inset-bottom, 0px); } +.view.collection-view { --room-top: 76px; --room-bottom: calc(var(--tabs) + 18px); } /* above and below the cards: the bar; the tabs or the card's actions */ +.safe-probe { position: fixed; visibility: hidden; pointer-events: none; padding: calc(var(--room-top) + var(--safe-t)) 0 calc(var(--room-bottom) + var(--safe-b)); } /* ------------------------------------------------------------- stage -- */ .stage-host { position: absolute; inset: 0; z-index: 0; } @@ -37,7 +38,7 @@ .frame.on-stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; } /* --cap: how much of the caption shows, 0 (a bare tile) … 1 (the full frame). */ .on-stage .front, .turn .front { padding-bottom: calc(3.6cqw * (1 - var(--cap, 1))); } -.on-stage .cap, .turn .cap { height: calc((19cqw + 10px) * var(--cap, 1)); min-height: 0; opacity: calc(var(--cap, 1) * 1.4 - .4); } +.on-stage .cap, .turn .cap { height: calc((var(--cap-share) * 100cqw + var(--cap-add)) * var(--cap, 1)); min-height: 0; opacity: calc(var(--cap, 1) * 1.4 - .4); } .collection-view.selecting .on-stage::before { content: ""; position: absolute; z-index: 2; right: 6%; top: 6%; width: 22px; height: 22px; border-radius: 50%; background: #0007; box-shadow: inset 0 0 0 1.5px #fffc; } .collection-view.selecting .on-stage.chosen::before { background: var(--cobalt-hi) center/70% no-repeat; box-shadow: none; @@ -152,11 +153,15 @@ input.tag.add::placeholder { color: var(--shade-dim); } .first-run .row-btns { width: min(320px, 100%); margin-top: 4px; } /* The share sheet's code. */ -.share-code { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 100%; overflow-y: auto; scrollbar-width: none; } +.share-code { position: relative; width: 100%; } +.share-face { display: flex; flex-direction: column; align-items: center; gap: 8px; } +.share-face.under { visibility: hidden; } +.share-over { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; overflow-y: auto; scrollbar-width: none; } +.share-over[hidden] { display: none; } .share-code p { margin: 0; font-size: 13px; text-align: center; max-width: 34ch; color: var(--shade-dim); } -.share-code p.small { font-size: 11.5px; } +.share-code p.small { font-size: 11.5px; min-height: 2lh; } /* one code or a carousel: the same two lines' room */ .share-code .qr-box { box-shadow: none; } -.share-ways { margin-top: 4px; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; } +.share-ways { padding-top: 4px; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; } .share-link { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 99px; padding: 8px 12px; background: color-mix(in srgb, var(--shade-ink) 8%, transparent); color: var(--shade-ink); font: 500 13px var(--sans); } .share-link .i { width: 16px; height: 16px; } diff --git a/web/css/editor.css b/web/css/editor.css index 55dff3b..ba78e6a 100644 --- a/web/css/editor.css +++ b/web/css/editor.css @@ -41,7 +41,7 @@ .ed-layer { position: absolute; inset: 0; width: 100%; height: 100%; image-rendering: pixelated; } .ed-onion { opacity: .45; } .ed-art.playing .ed-onion { display: none; } -.ed-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 2; } +.ed-lines { position: absolute; left: 0; top: 0; pointer-events: none; z-index: 2; } /* sized and placed by canvas.js, on whole device pixels */ /* Zoomed in, the card is brought closer: scaled from its corner, clipped by its place. */ .ed-card-wrap { overflow: hidden; touch-action: none; } .ed-card { transform-origin: 0 0; } @@ -97,11 +97,16 @@ .ed-shape[aria-pressed="true"] { background: var(--raise); color: var(--text); } /* The selection's actions take the place of the frames while there is a box. */ -.ed-sel-bar { display: flex; gap: 4px; justify-content: center; padding: 10px 12px 4px; } +.ed-place { display: grid; } +.ed-place > * { grid-area: 1 / 1; } +.ed-view.precise .ed-main > .ed-place { display: none; } +.ed-sel-bar { display: flex; gap: 4px; justify-content: center; align-items: center; padding: 10px 12px 4px; } +.ed-pad .ed-sel-bar { min-height: 0; padding: 0; } +.ed-view.selecting .ed-pad p { visibility: hidden; } .ed-sel-bar[hidden] { display: none; } .ed-sel-btn { width: 44px; height: 44px; border: 0; border-radius: 12px; background: var(--raise2); color: var(--text); display: grid; place-items: center; } .ed-sel-btn[aria-pressed="true"] { background: var(--cobalt); color: #fff; } -.ed-view.selecting .ed-frames { display: none; } +.ed-view.selecting .ed-frames { visibility: hidden; } /* keeps its height: the canvas stays put */ /* The precise mode: the pad takes the place of the frames. */ .ed-pad { display: none; flex: 1; min-height: 180px; margin: 4px 12px 8px; border-radius: 22px; background: var(--raise); border: 1px solid var(--line); diff --git a/web/css/kit/strip.css b/web/css/kit/strip.css index 7b05052..9f4f543 100644 --- a/web/css/kit/strip.css +++ b/web/css/kit/strip.css @@ -1,10 +1,11 @@ /* kit/strip.js — a plain dark strip from the bottom with the latest arrivals. */ -.strip { position: absolute; z-index: 45; left: 0; right: 0; bottom: 0; border-radius: 30px 30px 0 0; background: #0c0d11; +.strip { --mini: 56px; --slot: 64px; /* a mini and the step between two (read by strip.js) */ + position: absolute; z-index: 45; left: 0; right: 0; bottom: 0; border-radius: 30px 30px 0 0; background: #0c0d11; padding: 16px max(16px, calc((100% - 1100px) / 2 + 16px)) calc(20px + var(--safe-b)); transform: translateY(101%); pointer-events: none; box-shadow: 0 -20px 40px -24px #000, inset 0 1px 0 #ffffff0d; } .strip.anim { transition: transform .55s cubic-bezier(.2, .9, .25, 1); } .strip-head { display: flex; justify-content: space-between; align-items: baseline; color: var(--text3); font-size: 12.5px; margin-bottom: 12px; } .strip-head b { font: 600 17px var(--serif); color: var(--text2); } -.strip-row { position: relative; height: 64px; } -.strip .mini { position: absolute; top: 0; left: 0; width: 56px; height: 56px; transition: transform .45s cubic-bezier(.2, .9, .25, 1), opacity .3s; } +.strip-row { position: relative; height: var(--slot); } +.strip .mini { position: absolute; top: 0; left: 0; width: var(--mini); height: var(--mini); transition: transform .45s cubic-bezier(.2, .9, .25, 1), opacity .3s; } .strip-empty { position: absolute; left: 16px; right: 16px; bottom: calc(28px + var(--safe-b)); color: var(--text3); font-size: 13px; line-height: 1.4; } diff --git a/web/css/kit/top-sheet.css b/web/css/kit/top-sheet.css index 1762421..a3b7817 100644 --- a/web/css/kit/top-sheet.css +++ b/web/css/kit/top-sheet.css @@ -7,7 +7,7 @@ transform: translateY(-101%); } .top-sheet.anim { transition: transform .55s cubic-bezier(.2, .9, .25, 1); } .top-sheet-body { width: 100%; max-width: 420px; display: flex; flex-direction: column; align-items: center; gap: 12px; } -.top-sheet-body > b, .paper-body > b { font: 600 19px/1.15 var(--serif); text-align: center; } +.top-sheet-body > b, .paper-body > b { font: 600 19px/1.15 var(--serif); text-align: center; max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } /* one line: a long name does not move the code */ .top-sheet .grab { width: 40px; height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--shade-ink) 25%, transparent); } :is(.top-sheet, .on-paper) .seg { background: color-mix(in srgb, var(--shade-ink) 8%, transparent); box-shadow: none; } :is(.top-sheet, .on-paper) .seg button { color: var(--shade-dim); } diff --git a/web/src/collection/share.js b/web/src/collection/share.js index 959dd95..d97a535 100644 --- a/web/src/collection/share.js +++ b/web/src/collection/share.js @@ -32,17 +32,22 @@ export function fillShare(body, entry, o = {}) { let stop = () => {}; let release = () => {}; keepAwake().then((r) => (release = r)); - // The code area keeps one height whether it shows a code or the - // numbered-copy panel, so switching between them does not jump. - const code = h('div', { class: 'share-code' }); - code.style.height = `${size + 28 + 72 + 52}px`; // + the row of ways + // The code area is as tall as the code with its notes and ways: the + // numbered-copy panel lies over it, in the same place, so switching + // between them does not jump (and the code stays laid out under it). + const face = h('div', { class: 'share-face' }); + const over = h('div', { class: 'share-over', hidden: true }); + const code = h('div', { class: 'share-code' }, face, over); /** @param {Uint8Array} env @param {string} note */ const showCode = (env, note) => { stop(); const q = qrFor(env, size); stop = q.stop; - code.replaceChildren(q.el, + over.hidden = true; + over.replaceChildren(); + face.classList.remove('under'); + face.replaceChildren(q.el, h('p', {}, note), h('p', { class: 'small' }, q.plan.kind === 'single' ? t('give.single', env.length) : t('give.carousel', q.plan.fragments)), ways(env)); @@ -73,8 +78,10 @@ export function fillShare(body, entry, o = {}) { if (v === 'copy') return copy(); stop(); // The panel shows the issue log: drawn again whenever a copy is issued. - const draw = () => code.replaceChildren(editionPanel(entry.rec.digest)); + const draw = () => over.replaceChildren(editionPanel(entry.rec.digest)); draw(); + over.hidden = false; + face.classList.add('under'); const unsub = subscribe(draw); stop = () => { unsub(); }; }, diff --git a/web/src/collection/stage.js b/web/src/collection/stage.js index c71b933..a7b497b 100644 --- a/web/src/collection/stage.js +++ b/web/src/collection/stage.js @@ -11,7 +11,7 @@ // The stage knows how cards move, not what they mean: taps, holds and pulls // are reported to its owner (the collection view), which decides. -import { h } from '../dom.js'; +import { h, cssNumber } from '../dom.js'; import { Spring, SPRINGS, loop, mix, clamp, ease, rubber } from '../kit/motion.js'; import { gestures } from '../kit/gestures.js'; import { grid, feedRect, feedWidth, feedTarget, runs, groupKey } from './layout.js'; @@ -44,6 +44,8 @@ import { grid, feedRect, feedWidth, feedTarget, runs, groupKey } from './layout. * }} o */ export function createStage(o) { + /** A card's caption, as the CSS draws it (.cap): this share of the width plus this much. */ + const cap = { share: 0, add: 0 }; const Z = new Spring(1, SPRINGS.zoom); const scroll = [new Spring(0, SPRINGS.scroll), new Spring(0, SPRINGS.scroll)]; const fling = [0, 0]; @@ -81,6 +83,8 @@ export function createStage(o) { /** Both grids, with their labels' words, for the current list and frame. */ function layouts() { fr = o.frame(); + cap.share = cssNumber('--cap-share'); + cap.add = cssNumber('--cap-add'); return /** @type {const} */ ([0, 1]).map((l) => { const g = grid(l, runs(list, (c) => groupKey(l, o.by(), c)), fr); return { ...g, texts: g.labels.map((lab) => o.label(l, lab)) }; @@ -148,7 +152,7 @@ export function createStage(o) { r.y += dragY.v; } } - const hgt = r.w + (r.w * 0.19 + 10) * r.cap; // as .cap in app.css + const hgt = r.w + (r.w * cap.share + cap.add) * r.cap; c.rr = { x: r.x, y: r.y, w: r.w, h: hgt, cap: r.cap, o: r.o, z: r.z }; const s = c.el.style; s.width = `${r.w}px`; diff --git a/web/src/collection/view.js b/web/src/collection/view.js index bb7d933..ad1f08e 100644 --- a/web/src/collection/view.js +++ b/web/src/collection/view.js @@ -43,8 +43,6 @@ import { weekLabel, monthLabel } from './dates.js'; /** @typedef {import('./query.js').Place} Place */ /** @typedef {import('./stage.js').Item} Item */ -/** The room the bar takes above the cards, and the tabs or the card's actions below. */ -const TOP = 76, BOTTOM = 84; /** Not a finger here (a mouse, or nothing known): the buttons for what a * pinch or a swipe does show. Headless browsers report no pointer at all. */ const MOUSE = !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches); @@ -113,17 +111,18 @@ export function collectionView(o) { const el = h('section', { class: 'view collection-view' }, host, placeEmpty, first.el, searchPanel, bar, fly, strip.el, feedNav, feedBar.el, selectBar.el, coach, ...sheet.els); - // The safe-area insets in pixels, measured once per size. + // The room above and below the cards, safe-area insets included, in + // pixels: the CSS keeps it (.safe-probe), measured once per size. const probe = h('div', { class: 'safe-probe', 'aria-hidden': 'true' }); - let safe = { t: 0, b: 0 }; + let room = { t: 0, b: 0 }; const measure = () => { const cs = getComputedStyle(probe); - safe = { t: parseFloat(cs.paddingTop) || 0, b: parseFloat(cs.paddingBottom) || 0 }; + room = { t: parseFloat(cs.paddingTop) || 0, b: parseFloat(cs.paddingBottom) || 0 }; }; /** Read by the stage when it lays the cards out, never while drawing. @returns {import('./layout.js').Frame} */ const frameOf = () => ({ W: el.clientWidth || innerWidth, H: el.clientHeight || innerHeight, - top: TOP + safe.t, bottom: BOTTOM + safe.b, lead: searchPanel.offsetHeight + 8, + top: room.t, bottom: room.b, lead: searchPanel.offsetHeight + 8, }); el.append(probe); diff --git a/web/src/dom.js b/web/src/dom.js index c6ddf5e..46de874 100644 --- a/web/src/dom.js +++ b/web/src/dom.js @@ -153,3 +153,13 @@ export function shake(el) { el.classList.add('shake'); navigator.vibrate?.(8); } + +/** + * A length or a number the CSS keeps in a custom property, for code that + * must lay things out the same way: one value, in the stylesheet, instead + * of a copy here that drifts. 0 when it is not set. + * @param {string} name e.g. '--slot' @param {Element} [el] where it is read (inherited) + */ +export function cssNumber(name, el = document.documentElement) { + return parseFloat(getComputedStyle(el).getPropertyValue(name)) || 0; +} diff --git a/web/src/editor/canvas.js b/web/src/editor/canvas.js index 5869f85..f1ba864 100644 --- a/web/src/editor/canvas.js +++ b/web/src/editor/canvas.js @@ -6,11 +6,13 @@ // // Zooming brings the whole card closer, paper and all, like a card lifted // to the eye: the card is scaled and moved inside its viewport, which clips -// it. Three small canvases at the picture's own resolution, scaled up with -// pixelated sampling, keep cells, checks and onion exactly in line at any -// zoom; the thin lines (grid, cursor, the light of an undo) are drawn on one -// more canvas over the viewport, at the screen's resolution, so they stay -// sharp however close the card is. +// it. What is drawn over the card's art — the checks, the cells, the onion, +// the grid, the cursor, the light of an undo — is drawn on one canvas over +// the viewport, at the screen's resolution, every cell edge on the same +// whole device pixel as the grid line beside it. Left to the browser (a +// small canvas scaled up), a cell edge falls where its rounding puts it, +// which at most sizes and screens is a pixel off the grid. The card keeps +// three small canvases of its own for its layout and the tests' eyes. import { h } from '../dom.js'; import { T } from './model.js'; @@ -31,6 +33,8 @@ export function createCanvas() { let viewport = el, target = el; let size = 0, zoom = 1, tx = 0, ty = 0, paper = 0xf6f3eb; + /** What the screen layer draws: the frame, its colours, the onion frame, the checks' two tones. */ + const shown = { buf: /** @type {Uint8Array|null} */ (null), colors: /** @type {number[]} */ ([]), onion: /** @type {Uint8Array|null} */ (null), checks: ['#000', '#000'] }; /** @type {{cursor: {x: number, y: number}|null, grid: boolean, flash: {cells: number[], until: number}|null, box: import('./raster.js').Rect|null}} */ const marks = { cursor: null, grid: true, flash: null, box: null }; @@ -63,6 +67,7 @@ export function createCanvas() { function drawChecker() { const { h: hu, s, v } = toHsv(paper); const a = fromHsv(hu, s, v * 0.9), b = fromHsv(hu, s, v * 0.85); + shown.checks = [a, b].map((c) => `rgb(${rgb(c).join(',')})`); const g = /** @type {CanvasRenderingContext2D} */ (checker.getContext('2d')); const img = g.createImageData(size, size); for (let i = 0; i < size * size; i++) { @@ -74,18 +79,44 @@ export function createCanvas() { /** The thin things, over the viewport, at the screen's resolution. */ function drawLines() { - const W = viewport.clientWidth, H = viewport.clientHeight, dpr = devicePixelRatio || 1; - const pw = Math.round(W * dpr), ph = Math.round(H * dpr); - if (lines.width !== pw || lines.height !== ph) { lines.width = pw; lines.height = ph; } + const dpr = devicePixelRatio || 1; + // One canvas pixel to one device pixel, exactly: the canvas covers the + // viewport from a place and with a size that are whole CSS pixels and + // whole device pixels at once — otherwise the browser resamples it, and + // the edges blur off the grid. + const r = viewport.getBoundingClientRect(), q = step(dpr); + const left = Math.floor(r.left / q) * q, top = Math.floor(r.top / q) * q; // in page CSS pixels + const cw = Math.ceil((r.right - left) / q) * q, chh = Math.ceil((r.bottom - top) / q) * q; + const ox = Math.round(left * dpr), oy = Math.round(top * dpr), pw = Math.round(cw * dpr), ph = Math.round(chh * dpr); + if (lines.width !== pw || lines.height !== ph) { + lines.width = pw; + lines.height = ph; + Object.assign(lines.style, { width: `${cw}px`, height: `${chh}px` }); + } + Object.assign(lines.style, { left: `${left - r.left}px`, top: `${top - r.top}px` }); const g = /** @type {CanvasRenderingContext2D} */ (lines.getContext('2d')); g.clearRect(0, 0, pw, ph); if (!size) return; const a = art(), cell = a.w / size; - const X = (/** @type {number} */ k) => Math.round((a.x + k * cell) * dpr), Y = (/** @type {number} */ k) => Math.round((a.y + k * cell) * dpr); + /** A cell edge on the device's pixel grid, in the canvas's pixels. */ + const X = (/** @type {number} */ k) => Math.round((r.left + a.x + k * cell) * dpr) - ox, Y = (/** @type {number} */ k) => Math.round((r.top + a.y + k * cell) * dpr) - oy; g.save(); g.beginPath(); g.rect(X(0), Y(0), X(size) - X(0), Y(size) - Y(0)); g.clip(); // nothing over the paper + // The checks, then the onion, then the cells: each cell from one whole + // device pixel to the next, exactly where the grid's lines are. + g.fillStyle = shown.checks[1]; + g.fillRect(X(0), Y(0), X(size) - X(0), Y(size) - Y(0)); + g.fillStyle = shown.checks[0]; + for (let y = 0; y < size; y++) for (let x = (y + 1) % 2; x < size; x += 2) g.fillRect(X(x), Y(y), X(x + 1) - X(x), Y(y + 1) - Y(y)); + if (shown.onion) { + g.globalAlpha = 0.45; + g.fillStyle = `rgb(${ONION.join(',')})`; + cells(g, shown.onion, X, Y, () => g.fillStyle); + g.globalAlpha = 1; + } + if (shown.buf) cells(g, shown.buf, X, Y, (v) => `rgb(${rgb(shown.colors[v] ?? 0).join(',')})`); if (marks.grid && cell >= GRID_MIN) { g.strokeStyle = 'rgba(0,0,0,.2)'; g.lineWidth = 1; @@ -128,6 +159,25 @@ export function createCanvas() { } } + /** + * The cells of a frame on the screen layer, one colour run at a time. + * @param {CanvasRenderingContext2D} g @param {Uint8Array} buf + * @param {(k: number) => number} X @param {(k: number) => number} Y @param {(v: number) => string|CanvasGradient|CanvasPattern} paint + */ + function cells(g, buf, X, Y, paint) { + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const v = buf[y * size + x]; + if (v === T) continue; + let e = x + 1; // a run of one colour is one rectangle + while (e < size && buf[y * size + e] === v) e++; + g.fillStyle = paint(v); + g.fillRect(X(x), Y(y), X(e) - X(x), Y(y + 1) - Y(y)); + x = e - 1; + } + } + } + /** @param {HTMLCanvasElement} cv @param {Uint8Array|null} buf @param {number[]} colors @param {number[]|null} tint */ function drawCells(cv, buf, colors, tint) { const g = /** @type {CanvasRenderingContext2D} */ (cv.getContext('2d')); @@ -181,6 +231,9 @@ export function createCanvas() { } drawCells(pix, doc.frames[f], doc.colors, null); drawCells(onion, o.onion == null ? null : doc.frames[o.onion], doc.colors, ONION); + shown.buf = doc.frames[f]; + shown.colors = doc.colors; + shown.onion = o.onion == null ? null : doc.frames[o.onion]; place(); }, /** The cell under a point of the page (it may lie outside). @param {{x: number, y: number}} p */ @@ -251,6 +304,18 @@ export function createCanvas() { }; } +/** + * The fewest whole CSS pixels that are also whole device pixels. Browsers + * put a canvas on whole CSS pixels (Chrome rounds its place and size), so + * at 2.625 only every 8th one starts a device pixel: a canvas anywhere + * else is resampled, and every line on it blurs over two pixels. + * @param {number} dpr + */ +function step(dpr) { + for (let q = 1; q <= 64; q++) if (Math.abs(q * dpr - Math.round(q * dpr)) < 1e-6) return q; + return 1; +} + /** Where `el` sits inside `outer`, before transforms. @param {HTMLElement} el @param {HTMLElement} outer */ function offsetIn(el, outer) { let x = 0, y = 0; diff --git a/web/src/editor/input.js b/web/src/editor/input.js index 7ecc331..510319d 100644 --- a/web/src/editor/input.js +++ b/web/src/editor/input.js @@ -141,7 +141,15 @@ export function drawInput(el, o) { g = null; o.point?.(e.pointerType === 'touch' ? null : { x: e.clientX, y: e.clientY }); } else if (g?.kind === 'view') { - if (pts.size) return; // the gesture ends with the last finger + if (pts.size) { + // The gesture ends with the last finger. Until then the fingers left + // carry on from where the view is now: measured from its start, a + // single finger has no spread, and the zoom would spring back. + g.mid0 = mid(); + g.d0 = spread(); + o.view.start(g.mid0, g.d0); + return; + } const v = g; g = null; o.view.end(); diff --git a/web/src/editor/precise.js b/web/src/editor/precise.js index 3e3ef75..219fa1b 100644 --- a/web/src/editor/precise.js +++ b/web/src/editor/precise.js @@ -35,7 +35,9 @@ export function precisePad(o) { const put = hold('ed-put', null, t('ed.precise.put'), false); const erase = hold('ed-round', 'eraser', t('ed.precise.erase'), true); const pick = h('button', { type: 'button', class: 'ed-round', 'aria-label': t('ed.tool.pick'), title: t('ed.tool.pick'), onclick: o.pick }, icon('pick')); - const el = h('div', { class: 'ed-pad', 'aria-label': t('ed.precise') }, hint, h('div', { class: 'ed-pad-btns' }, pick, erase, put)); + // The hint's place: the selection's actions take it over (view.js). + const top = h('div', { class: 'ed-place' }, hint); + const el = h('div', { class: 'ed-pad', 'aria-label': t('ed.precise') }, top, h('div', { class: 'ed-pad-btns' }, pick, erase, put)); // Any finger on the pad (not on its buttons) moves the cursor. /** @type {Map} */ @@ -55,7 +57,7 @@ export function precisePad(o) { for (const ev of ['pointerup', 'pointercancel']) el.addEventListener(ev, (e) => fingers.delete(/** @type {PointerEvent} */ (e).pointerId)); return { - el, + el, top, /** * What the big button will do, in a word, and the colour it puts (none * for a tool that puts no colour). @param {string} label @param {string|null} css diff --git a/web/src/editor/view.js b/web/src/editor/view.js index 5b2ada7..85670fe 100644 --- a/web/src/editor/view.js +++ b/web/src/editor/view.js @@ -151,7 +151,10 @@ export function editorView() { const aside = h('aside', { class: 'ed-aside' }); const wrap = h('div', { class: 'ed-card-wrap' }, card, coords); canvas.mount(wrap, card); - const main = h('div', { class: 'ed-main' }, wrap, frames.el); + // The frames' strip and the selection's actions share one place, one on + // top of the other: it is as tall as the taller, whichever shows. + const strip = h('div', { class: 'ed-place' }, frames.el); + const main = h('div', { class: 'ed-main' }, wrap, strip); const el = h('section', { class: 'view ed-view' }, bar, h('div', { class: 'ed-body' }, tools.el, main, h('div', { class: 'ed-lower' }, palette.el, pad.el), aside), ...more.els, ...palettes.els, ...colors.els, ...history.els, ...frameSheet.els); @@ -179,7 +182,14 @@ export function editorView() { model, selection, pick: (c) => pickAt(c), now: () => ({ frame: state.frame, color: state.color, shape: state.shape, pen: state.pen, mirror: state.mirror, axes: state.axes }), }); - main.insertBefore(selection.bar, frames.el); + // The selection's actions take a place that is already there, so the + // canvas never moves when a box appears: the frames' strip, or, in the + // precise mode (no strip), the top of the pad, over its hint. + const placeSelBar = () => { + if (state.precise) pad.top.append(selection.bar); + else strip.append(selection.bar); + }; + placeSelBar(); /** The pipette: an empty cell picks the eraser. @param {Cell} c */ function pickAt(c) { @@ -433,7 +443,10 @@ export function editorView() { settings.setAxes(state.axes); const puts = state.tool === 'pen' || state.tool === 'fill' || state.tool === 'shapes'; pad.mode(t(`ed.precise.${state.tool}`), puts ? hex(d.colors[state.color] ?? 0) : null); - el.classList.toggle('precise', state.precise); + if (el.classList.contains('precise') !== state.precise) { + el.classList.toggle('precise', state.precise); + placeSelBar(); + } canvas.el.classList.toggle('playing', state.playing); if (state.precise) { cursor = { x: clamp(cursor.x, 0, d.size - 0.01), y: clamp(cursor.y, 0, d.size - 0.01) }; diff --git a/web/src/i18n.js b/web/src/i18n.js index 561897d..8fc3356 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -27,8 +27,8 @@ const ru = { 'import.palette.builtin': (/** @type {string} */ name) => `палитра ${name}`, 'import.palette.own': (/** @type {number} */ n) => `свои цвета: ${n}`, 'import.changed': (/** @type {number} */ n) => `${n} ${plural(n, 'пиксель сменил', 'пикселя сменили', 'пикселей сменили')} цвет`, - 'import.code.single': (/** @type {number} */ n) => `${n} байт, один QR`, - 'import.code.carousel': (/** @type {number} */ n, /** @type {number} */ k, /** @type {number} */ s) => `${n} байт, карусель из ${k} QR, ~${s} с`, + 'import.code.single': (/** @type {number} */ n) => `${n} ${plural(n, 'байт', 'байта', 'байт')}, один QR`, + 'import.code.carousel': (/** @type {number} */ n, /** @type {number} */ k, /** @type {number} */ s) => `${n} ${plural(n, 'байт', 'байта', 'байт')}, карусель из ${k} QR, ~${s} с`, 'import.opt.keep-scale': 'Не уменьшать — оставить исходный размер', 'import.opt.first-frame': 'Только первый кадр', 'import.opt.palette': 'Палитра', 'import.opt.palette.auto': 'Точные цвета файла', @@ -57,20 +57,20 @@ const ru = { 'edition.status.open': (/** @type {number} */ n) => `Выдано: ${n} · тираж без ограничения`, 'edition.issue': (/** @type {number} */ n) => `Выдать №${n}`, 'edition.run-out': 'Тираж разошёлся', 'edition.how': 'Получатель открывает «Сканер» → «Код получателя», вы сканируете его код — и показываете ему свой.', - 'edition.scan.title': 'Код получателя', 'edition.point': 'Наведите на код получателя на его экране', + 'edition.scan.title': 'Код получателя', 'edition.point': 'Наведите камеру на код получателя', 'edition.not-recipient': 'Это не код получателя. Попросите открыть «Сканер» → «Код получателя»', 'edition.copy': (/** @type {number} */ n) => `№${n}`, 'edition.copy.of': (/** @type {number} */ n, /** @type {number} */ of) => `№${n} из ${of}`, 'edition.confirm': (/** @type {string} */ label, /** @type {string} */ nick, /** @type {string} */ key) => `Выдать ${label} для @${nick} (ключ ${key})? Номер будет занят.`, 'edition.yes': 'Выдать', 'edition.show': (/** @type {string} */ label, /** @type {string} */ nick) => `${label} для @${nick}. Покажите этот код получателю — только он сможет назвать экземпляр своим.`, 'edition.done': 'Готово', - 'scan.loading': 'Подключаю распознавание QR…', 'scan.failed': 'Не удалось загрузить распознавание QR. Проверьте сеть и попробуйте снова', + 'scan.loading': 'Загружается распознавание QR…', 'scan.failed': 'Не удалось загрузить распознавание QR. Проверьте сеть и попробуйте снова', 'scan.none': 'На картинке не нашлось QR-кода', 'scan.no-camera': 'Нет доступа к камере', 'scan.no-camera.how': 'Разрешите её в настройках браузера или откройте картинку', - 'give.single': (/** @type {number} */ b) => `${b} байт · один код. Если не читается — прибавьте яркость.`, + 'give.single': (/** @type {number} */ b) => `${b} ${plural(b, 'байт', 'байта', 'байт')} · один код. Если не читается — прибавьте яркость экрана.`, 'give.carousel': (/** @type {number} */ n) => `Большая карточка: коды сменяются. Пусть камера смотрит, пока не наберёт ${n} ${plural(n, 'часть', 'части', 'частей')}.`, 'give.copy-of': (/** @type {string} */ a) => `Вы передаёте копию. Автор — ${a}.`, - 'receive.title': 'Сканер', 'receive.start': 'Включить камеру', 'receive.point': 'Наведите на код на чужом экране', + 'receive.title': 'Сканер', 'receive.start': 'Включить камеру', 'receive.point': 'Наведите камеру на код на другом экране', 'receive.image': 'Из картинки', 'receive.file': 'Файл .pips', 'receive.paste': 'Вставить ссылку из буфера', 'receive.clipboard-empty': 'В буфере обмена пусто', 'receive.paste-here': 'Вставьте ссылку в поле', 'receive.paste.placeholder': 'Ссылка или текст кода', @@ -78,7 +78,7 @@ const ru = { 'receive.got': 'Карточка в коллекции', 'receive.dup': 'Такая карточка уже есть', 'receive.not-pips': 'Это не код Pips', 'receive.damaged': 'Код Pips, но повреждён. Попробуйте отсканировать ещё раз', 'receive.forged': 'Подпись не сходится: код повреждён или подделан. Карточка не добавлена', 'receive.recipient': 'Это код получателя — его сканирует автор при выдаче экземпляра', 'receive.paper': 'Это чей-то секретный ключ. Его нельзя показывать — тот, кто его видел, может подписывать карточки от имени владельца.', - 'me.title': 'Профиль', 'me.nick': 'Ник', 'me.handle': 'Соцсеть', 'me.handle.ph': 'Имя там', 'me.network': 'Соцсеть', 'me.link': 'Ссылка', + 'me.title': 'Профиль', 'me.nick': 'Ник', 'me.handle': 'Соцсеть', 'me.handle.ph': 'Имя в соцсети', 'me.network': 'Соцсеть', 'me.link': 'Ссылка', 'me.link.hint': 'Например, kitsu.art/shop — https:// добавится сам', 'me.card-back': 'На обороте ваших карточек', 'me.saved': 'Сохранено', 'backup.title': 'Резервная копия', 'backup.save': 'Сохранить копию', 'backup.restore': 'Восстановить', @@ -115,17 +115,17 @@ const ru = { 'me.storage': 'Хранилище', 'me.storage.persisted': 'Данные защищены от автоочистки браузера', 'me.storage.not-persisted': 'Браузер может очистить данные при нехватке места — делайте резервные копии', 'me.storage.ask': 'Попросить браузер не удалять', 'me.storage.granted': 'Браузер обещал не удалять данные', - 'me.storage.refused': 'Браузер не согласился — пусть выручают копии. Установленное на экран «Домой» приложение он охраняет охотнее', + 'me.storage.refused': 'Браузер отказал — полагайтесь на резервные копии. Приложение, установленное на главный экран, браузер бережёт охотнее', 'me.storage.memory': 'Хранилище браузера недоступно: всё пропадёт, когда страница закроется', 'me.language': 'Язык', 'me.language.later': 'Язык сменится при следующем запуске', 'dev.title': 'Для разработчиков', 'dev.phrase': 'Фраза для входа', 'dev.gate': 'Инструменты для проверки приложения. Чтобы их включить, введите фразу I am a developer — точно так, с пробелами и заглавной I.', 'dev.on': 'Режим разработчика включён', 'dev.off': 'Выключить режим разработчика', 'dev.go': 'Да', - 'dev.samples.hint': (/** @type {number} */ n) => `Образцы — ${n} карточек разных размеров и палитр, часть анимированные; часть ваши, остальные от выдуманных авторов. Их можно убрать одной кнопкой.`, + 'dev.samples.hint': (/** @type {number} */ n) => `Образцы — ${n} ${plural(n, 'карточка', 'карточки', 'карточек')} разных размеров и палитр; некоторые анимированы. Часть из них ваши, остальные от выдуманных авторов. Убрать их можно одной кнопкой.`, 'dev.samples.add': 'Добавить образцы', 'dev.samples.remove': (/** @type {number} */ n) => (n ? `Убрать образцы (${n})` : 'Убрать образцы'), - 'dev.samples.add.ask': (/** @type {number} */ n) => `Добавить в коллекцию ${n} карточек-образцов? Свои подпишутся вашим ключом.`, - 'dev.samples.remove.ask': (/** @type {number} */ n) => `Удалить ${n} карточек-образцов насовсем? Остальные карточки не тронутся.`, + 'dev.samples.add.ask': (/** @type {number} */ n) => `Добавить в коллекцию образцы (${n})? Ваши будут подписаны вашим ключом.`, + 'dev.samples.remove.ask': (/** @type {number} */ n) => `Удалить образцы (${n}) насовсем? Остальные карточки останутся.`, 'dev.samples.added': (/** @type {number} */ n) => `Добавлено образцов: ${n}`, 'dev.samples.removed': (/** @type {number} */ n) => `Удалено образцов: ${n}`, 'app.update': 'Готова новая версия Pips', 'app.update.go': 'Обновить', @@ -133,7 +133,7 @@ const ru = { 'onb.arrival': (/** @type {string} */ title, /** @type {string} */ a) => `Вам прислали карточку «${title}»${a ? ` от @${a}` : ''}. Создайте ключ или восстановите свой — и она сразу будет в коллекции.`, 'link.bad': 'Ссылка на карточку неполная — возможно, её обрезал мессенджер. Попросите прислать ещё раз', 'share.link': 'Ссылкой', 'share.file': 'Файлом', 'share.png': 'Картинкой', 'share.ways': 'Другие способы', - 'receive.no-card-in-picture': 'В этой картинке нет карточки Pips. Код с картинки читает «Из картинки»', + 'receive.no-card-in-picture': 'В этой картинке нет карточки Pips. Чтобы прочитать код с картинки, выберите «Из картинки»', 'picture.scan': 'Откройте в Pips: Сканер → Из картинки', 'picture.file': 'Откройте файл в Pips: Сканер → Из картинки', 'share.file.saved': (/** @type {string} */ n) => `Сохранено: ${n}`, 'share.link.copied': 'Ссылка скопирована', 'share.link.failed': 'Не удалось скопировать ссылку', @@ -146,7 +146,7 @@ const ru = { 'onb.done': 'Готово', 'onb.restore.title': 'Восстановление', 'onb.restore.lead': 'Ключ вернёт вашу подпись, копия — коллекцию. Без копии вернётся только ключ.', 'onb.restore.file': 'Резервная копия', 'onb.restore.file.pick': 'Выбрать файл копии', 'onb.restore.file.drop': 'Убрать', - 'onb.restore.file.off': 'Если есть — выберите её первой: приложение проверит, что ключ от неё.', + 'onb.restore.file.off': 'Если копия есть, выберите её первой: приложение проверит, что ключ к ней подходит.', 'onb.restore.file.on': 'Откроется вашим ключом — карточки, заметки и профиль вернутся вместе с ним.', 'onb.restore.key': 'Ключ', 'onb.restore.words': '24 слова', 'onb.restore.paper': 'Бумажный ключ', 'onb.restore.words.lead': 'По порядку, как записаны. Достаточно первых четырёх букв каждого слова.', @@ -156,7 +156,7 @@ const ru = { 'onb.switch.lead': 'Этот ключ займёт место нынешнего. Карточки на устройстве останутся; копия, если есть, добавится к ним.', 'onb.key.count': (/** @type {number} */ n) => `Слов: ${n} из 24`, 'onb.key.unknown': (/** @type {number} */ n) => `Слова № ${n} нет в списке — проверьте его`, - 'onb.key.sum': 'Все слова из списка, но вместе не сходятся: одно из них другое или два переставлены.', + 'onb.key.sum': 'Все слова есть в списке, но вместе не сходятся: одно слово неверное или два переставлены местами.', 'onb.key.found': 'Ключ найден', 'onb.key.fits': 'подходит к копии', 'onb.key.mismatch': (/** @type {string} */ k) => `Это ключ ${k}, а копия — от другого. Проверьте слова или выберите другой файл.`, 'onb.key.not-paper': 'Это не бумажный ключ Pips', @@ -201,7 +201,7 @@ const ru = { 'first.text': 'Это ваша визитка: узор сложен из вашего ключа. Покажите её автору — он выпустит вам номерной экземпляр.', 'first.draw': 'Нарисовать', 'first.scan': 'Сканировать', 'first.card': 'ваша визитка', 'back.edit': 'Изменить заметку и теги', 'back.done': 'Готово', 'back.tag.remove': 'Убрать тег', - 'back.tag.filter': 'Показать все карточки с этим тегом', 'back.tags.none': 'Тегов нет — карандаш, чтобы добавить', + 'back.tag.filter': 'Показать все карточки с этим тегом', 'back.tags.none': 'Тегов нет — нажмите на карандаш, чтобы добавить', 'back.tag.add': '+ тег', 'back.tag.ph': 'тег', 'back.note.ph': 'Заметка видна только вам и с карточкой не передаётся. Где, от кого, что за история…', 'back.note.none': 'Заметки нет', 'back.path.gap': 'Код хранит только последнего отправителя: до него карточка могла пройти через другие руки', @@ -216,18 +216,18 @@ const ru = { 'card.untitled': 'Без названия', 'card.need-title': 'Сначала назовите карточку: название останется с ней у всех, к кому она попадёт', 'ed.tools': 'Инструменты', 'ed.tool.pen': 'Карандаш', 'ed.tool.eraser': 'Ластик', 'ed.tool.fill': 'Заливка', 'ed.tool.shapes': 'Линия и фигуры', 'ed.tool.pick': 'Пипетка', - 'ed.tool.select': 'Выделение', 'ed.pen.solid': 'Карандаш — сглаживает углы линий', 'ed.pen.square': 'Карандаш — каждая клетка, углы как есть', 'ed.pen.dither': 'Шахматкой (дизеринг)', + 'ed.tool.select': 'Выделение', 'ed.pen.solid': 'Карандаш — сглаживает углы линий', 'ed.pen.square': 'Карандаш — клетка за клеткой, без сглаживания углов', 'ed.pen.dither': 'Шахматкой (дизеринг)', 'ed.sel': 'Выделение', 'ed.sel.copy': 'Двигать копию', 'ed.sel.flip-x': 'Отразить слева направо', - 'ed.sel.flip-y': 'Отразить сверху вниз', 'ed.sel.turn': 'Повернуть на четверть', 'ed.sel.erase': 'Стереть выделенное', + 'ed.sel.flip-y': 'Отразить сверху вниз', 'ed.sel.turn': 'Повернуть на 90°', 'ed.sel.erase': 'Стереть выделенное', 'ed.sel.all': 'Выделить всё', 'ed.sel.drop': 'Снять выделение', - 'ed.h.dither': 'шахматка', 'ed.h.move': 'перенос', 'ed.h.copy': 'копия', 'ed.h.flip': 'отражение', 'ed.h.turn': 'поворот', - 'ed.h.erase-sel': 'стёрто выделенное', + 'ed.h.dither': 'штрих шахматкой', 'ed.h.move': 'перенос', 'ed.h.copy': 'копирование', 'ed.h.flip': 'отражение', 'ed.h.turn': 'поворот', + 'ed.h.erase-sel': 'стирание выделенного', 'ed.shape.line': 'Линия', 'ed.shape.rect': 'Прямоугольник', 'ed.shape.rect-fill': 'Закрашенный прямоугольник', 'ed.shape.ellipse': 'Эллипс', 'ed.shape.ellipse-fill': 'Закрашенный эллипс', 'ed.mirror': 'Зеркало', 'ed.precise': 'Точный режим', 'ed.more': 'Ещё', - 'ed.precise.hint': 'Ведите здесь — прицел идёт следом. Держите «Ставить» и ведите — получится линия.', + 'ed.precise.hint': 'Ведите пальцем здесь — прицел повторит движение. Чтобы провести линию, удерживайте «Ставить» и ведите.', 'ed.precise.put': 'Ставить', 'ed.precise.pen': 'Ставить', 'ed.precise.eraser': 'Стереть', 'ed.precise.fill': 'Залить', 'ed.precise.shapes': 'Фигура', 'ed.precise.select': 'Выделить', 'ed.precise.pick': 'Взять цвет', 'ed.precise.erase': 'Стереть под прицелом', - 'ed.frames': 'Кадры', 'ed.frame.add': 'Новый кадр: копия открытого', 'ed.frame.first': 'Кадр анимации', + 'ed.frames': 'Кадры', 'ed.frame.add': 'Новый кадр — копия текущего', 'ed.frame.first': 'Кадр анимации', 'ed.empty': 'Холст пуст — сначала нарисуйте что-нибудь', 'ed.frame.menu': 'Кадр', 'ed.frame.n': (/** @type {any} */ n, /** @type {any} */ all) => `Кадр ${n} из ${all}`, 'ed.frame.open': (/** @type {any} */ n, /** @type {any} */ all) => `Кадр ${n} из ${all}, открыт`, 'ed.frame.copy': 'Копия кадра', 'ed.frame.left': 'Сдвинуть влево', 'ed.frame.right': 'Сдвинуть вправо', @@ -237,14 +237,14 @@ const ru = { 'ed.pal.own': 'Своя палитра', 'ed.pal.own-n': (/** @type {any} */ n) => `Своя · ${n} ${plural(n, 'цвет', 'цвета', 'цветов')}`, 'ed.pal.count': (/** @type {any} */ n) => `${n} ${plural(n, 'цвет', 'цвета', 'цветов')}`, 'ed.pal.page': (/** @type {any} */ a, /** @type {any} */ b) => `Цвета ${a}–${b}`, 'ed.pal.color': (/** @type {any} */ n, /** @type {any} */ c) => `Цвет ${n}, ${c}`, 'ed.pal.add': 'Добавить цвет', - 'ed.pal.note': 'Рисунок сразу перекрашен в ближайшие цвета — посмотрите на холсте. Готовая палитра ничего не весит в коде: в карточку идёт только её номер.', + 'ed.pal.note': 'Рисунок уже перекрашен в ближайшие цвета — посмотрите на холст. Готовая палитра не занимает места в коде: в карточку записывается только её номер.', 'ed.apply': 'Применить', 'ed.done': 'Готово', 'ed.color.title': 'Цвет палитры', 'ed.color.new': 'Новый цвет', 'ed.color.add': 'Добавить', 'ed.color.was': 'Было и стало', 'ed.color.h': 'Оттенок', 'ed.color.s': 'Насыщенность', 'ed.color.v': 'Яркость', 'ed.color.ramp': 'Ступени от тени к свету', 'ed.color.ramp-add': 'Добавить все', 'ed.color.ramp-added': 'Ступени добавлены в палитру.', 'ed.color.step': (/** @type {any} */ n, /** @type {any} */ c) => `Ступень ${n}, ${c}`, - 'ed.color.note': 'Каждый свой цвет добавляет к коду 3 байта. Пиксели этого цвета меняются вместе с ним.', - 'ed.color.note-builtin': 'Готовая палитра станет своей: каждый цвет добавит к коду 3 байта.', + 'ed.color.note': 'Каждый собственный цвет добавляет к коду 3 байта. Пиксели этого цвета изменятся вместе с ним.', + 'ed.color.note-builtin': 'Готовая палитра станет собственной: каждый её цвет добавит к коду 3 байта.', 'ed.set.size': 'Размер холста', 'ed.set.shade': 'Цвет рамки', 'ed.set.shade.n': (/** @type {any} */ n) => `Рамка ${n}`, 'ed.set.delay': 'Кадр, мс', 'ed.set.axes': 'Зеркало', 'ed.set.axes.x': 'Слева направо', 'ed.set.axes.y': 'Сверху вниз', 'ed.set.axes.xy': 'Оба', 'ed.set.grid': 'Сетка на холсте', 'ed.set.new': 'Новая карточка', @@ -259,24 +259,24 @@ const ru = { 'pic.unreadable': 'Не удалось открыть картинку: браузер не знает этот формат', 'pic.crop.label': 'Область картинки: двигайте и тяните за углы', 'pic.crop.pixel': 'Картинка больше холста: выберите квадрат — пиксели останутся как есть', - 'pic.crop.photo': 'Карточка квадратная: двигайте рамку, тяните за углы. Дальше можно дорисовать', - 'ed.size.crop': (/** @type {any} */ n) => `При размере ${n}×${n} края рисунка обрежутся. Отменить можно будет кнопкой «Отменить».`, 'ed.size.yes': 'Обрезать', - 'ed.fresh': 'Холст чистый', 'ed.history': 'История изменений', 'ed.hold-history': 'удерживайте — вся история', + 'pic.crop.photo': 'Карточка квадратная: передвиньте рамку или потяните за угол. Потом рисунок можно дорисовать', + 'ed.size.crop': (/** @type {any} */ n) => `При размере ${n}×${n} края рисунка обрежутся. Это можно будет отменить.`, 'ed.size.yes': 'Обрезать', + 'ed.fresh': 'Новый чистый холст', 'ed.history': 'История изменений', 'ed.hold-history': 'удерживайте, чтобы открыть всю историю', 'ed.undo': 'Отменить', 'ed.redo': 'Вернуть', 'ed.undo.what': (/** @type {any} */ w) => `Отменить: ${w}`, 'ed.redo.what': (/** @type {any} */ w) => `Вернуть: ${w}`, 'ed.undone': (/** @type {any} */ w) => `Отменено: ${w}`, 'ed.redone': (/** @type {any} */ w) => `Возвращено: ${w}`, 'ed.h.start': 'Начало', 'ed.h.now': 'только что', 'ed.h.min': (/** @type {any} */ n) => `${n} мин`, 'ed.h.hours': (/** @type {any} */ n) => `${n} ч`, - 'ed.h.frame': (/** @type {any} */ n) => `кадр ${n}`, 'ed.h.branch': (/** @type {any} */ n) => `Другой вариант — их здесь ${n}`, - 'ed.h.pen': 'карандаш', 'ed.h.eraser': 'ластик', 'ed.h.fill': 'заливка', 'ed.h.line': 'линия', 'ed.h.rect': 'прямоугольник', - 'ed.h.ellipse': 'эллипс', 'ed.h.clear': 'очистка кадра', 'ed.h.frame-add': 'новый кадр', 'ed.h.frame-remove': 'удаление кадра', - 'ed.h.frame-move': 'перестановка кадров', 'ed.h.size': 'размер холста', 'ed.h.palette': 'другая палитра', 'ed.h.color': 'цвет палитры', - 'ed.h.color-add': 'новый цвет', 'ed.h.title': 'название', 'ed.h.shade': 'цвет рамки', 'ed.h.delay': 'скорость анимации', - 'ed.h.new': 'новая карточка', + 'ed.h.frame': (/** @type {any} */ n) => `кадр ${n}`, 'ed.h.branch': (/** @type {any} */ n) => `Развилка: ${n} ${plural(n, 'вариант', 'варианта', 'вариантов')}`, + 'ed.h.pen': 'штрих карандашом', 'ed.h.eraser': 'стирание ластиком', 'ed.h.fill': 'заливка', 'ed.h.line': 'линия', 'ed.h.rect': 'прямоугольник', + 'ed.h.ellipse': 'эллипс', 'ed.h.clear': 'очистка кадра', 'ed.h.frame-add': 'добавление кадра', 'ed.h.frame-remove': 'удаление кадра', + 'ed.h.frame-move': 'перестановка кадров', 'ed.h.size': 'смена размера холста', 'ed.h.palette': 'смена палитры', 'ed.h.color': 'изменение цвета', + 'ed.h.color-add': 'добавление цвета', 'ed.h.title': 'смена названия', 'ed.h.shade': 'смена цвета рамки', 'ed.h.delay': 'смена скорости анимации', + 'ed.h.new': 'замена холста', 'ed.budget': (/** @type {any} */ n, /** @type {any} */ s) => `${n} QR · ~${s} с`, 'ed.budget.over': (/** @type {any} */ s) => `Долго · ~${s} с`, 'ed.budget.colors': 'Много цветов', 'ed.budget.label': 'Передача', - 'ed.budget.one.why': (/** @type {any} */ b) => `${b} байт: один QR-код, передаётся сразу.`, - 'ed.budget.loop.why': (/** @type {any} */ b, /** @type {any} */ n, /** @type {any} */ s) => `${b} байт: ${n} кодов по кругу, около ${s} с на передачу. Меньше кадров или цветов — быстрее.`, - 'ed.budget.over.why': (/** @type {any} */ b) => `${b} байт — передача займёт больше 10 секунд. Уберите кадры, цвета или мелкие детали.`, - 'ed.budget.colors.why': 'С пустыми клетками помещается 31 цвет: уберите один или закрасьте пустые клетки.', + 'ed.budget.one.why': (/** @type {any} */ b) => `${b} ${plural(b, 'байт', 'байта', 'байт')}: один QR-код, передаётся сразу.`, + 'ed.budget.loop.why': (/** @type {any} */ b, /** @type {any} */ n, /** @type {any} */ s) => `${b} ${plural(b, 'байт', 'байта', 'байт')}: ${n} ${plural(n, 'код', 'кода', 'кодов')} по кругу, передача займёт около ${s} с. Чем меньше кадров и цветов, тем быстрее.`, + 'ed.budget.over.why': (/** @type {any} */ b) => `${b} ${plural(b, 'байт', 'байта', 'байт')} — передача займёт больше 10 секунд. Уберите часть кадров, цветов или мелких деталей.`, + 'ed.budget.colors.why': 'Если есть прозрачные клетки, помещается только 31 цвет: уберите один цвет или закрасьте прозрачные клетки.', }; const en = { @@ -302,39 +302,39 @@ const en = { 'import.size.scaled': (/** @type {number[]} */ ...a) => `${a[0]}×${a[1]} → ${a[2]}×${a[3]} (×${a[4]} upscaling removed)`, 'import.frames': (/** @type {number} */ n, /** @type {number} */ ms) => `${n} frames of ${ms} ms`, 'import.palette.builtin': (/** @type {string} */ name) => `palette ${name}`, - 'import.palette.own': (/** @type {number} */ n) => `own colours: ${n}`, + 'import.palette.own': (/** @type {number} */ n) => `${n} colours of its own`, 'import.changed': (/** @type {number} */ n) => `${n} pixel${n === 1 ? '' : 's'} recoloured`, 'import.code.single': (/** @type {number} */ n) => `${n} bytes, one QR`, 'import.code.carousel': (/** @type {number} */ n, /** @type {number} */ k, /** @type {number} */ s) => `${n} bytes, a carousel of ${k} QR codes, ~${s} s`, 'import.opt.keep-scale': 'Keep the original size', 'import.opt.first-frame': 'First frame only', 'import.opt.palette': 'Palette', 'import.opt.palette.auto': 'Exact colours of the file', - 'import.opt.palette.fit': (/** @type {number} */ n) => `Own, fitted · ${n}`, - 'import.palette.fitted': (/** @type {number} */ had, /** @type {number} */ n) => `${had > 256 ? 'over 256' : had} colours in the file → own ${n}`, - 'import.fitted-auto': 'An own palette was fitted — tap the card to compare.', + 'import.opt.palette.fit': (/** @type {number} */ n) => `Fitted to the picture · ${n}`, + 'import.palette.fitted': (/** @type {number} */ had, /** @type {number} */ n) => `${had > 256 ? 'over 256' : had} colours in the file → a palette of ${n}`, + 'import.fitted-auto': 'A palette was fitted to the picture — tap the card to compare.', 'import.err.unknown_format': 'Not a PNG or Aseprite file', 'import.err.too_large': 'The file is too large to import', 'import.err.malformed': 'The file is damaged or badly written', 'import.err.unsupported': 'The file uses something a card cannot show (blend modes, tilemaps or translucent groups). Flatten the layers in Aseprite and try again', 'import.err.partial_alpha': (/** @type {number[]} */ ...a) => `Semi-transparent pixel at ${a[0]},${a[1]} (frame ${a[2]}). A card pixel is either a colour or transparent`, 'import.err.too_big': (/** @type {number[]} */ ...a) => `${a[0]}×${a[1]} is larger than a card (up to 128×128)`, - 'import.err.too_many_colors': (/** @type {number} */ n) => `${n > 256 ? 'More than 256' : n} colours, a card holds 32. Choose a palette below to map them to the nearest`, + 'import.err.too_many_colors': (/** @type {number} */ n) => `${n > 256 ? 'More than 256' : n} colours, but a card holds 32. Choose a palette below and they are mapped to the nearest`, 'import.err.too_many_frames': 'More than 64 frames', - 'import.err.unequal_delays': 'Frames have different durations and a card has one. Even them out in Aseprite or take the first frame only', + 'import.err.unequal_delays': 'The frames last different times, but a card has one frame time. Even them out in Aseprite or take only the first frame', 'import.err.delay_out_of_range': (/** @type {number} */ ms) => `A frame lasts ${ms} ms; 20 to 5000 are allowed`, - 'give.title': 'Give', 'give.sub': 'show the code — it is scanned off your screen', 'give.yours': 'Your cards', 'give.hint': 'Let the other phone scan the code.', 'give.new': 'Draw a new one', - 'give.empty.title': 'Nothing to give yet', 'give.empty': 'Your cards will be here, their codes ready.', 'give.empty.draw': 'Draw', + 'give.title': 'Give', 'give.sub': 'show the code — the other phone scans it from your screen', 'give.yours': 'Your cards', 'give.hint': 'Let the other phone scan the code.', 'give.new': 'Draw a new one', + 'give.empty.title': 'Nothing to give yet', 'give.empty': 'Your cards will appear here, each with its code ready.', 'give.empty.draw': 'Draw', 'give.mode.copy': 'Copy', 'give.mode.edition': 'Numbered', 'edition.my-code': 'Recipient code', 'edition.my-code.hint': (/** @type {string} */ nick) => `Show it to the artist: they will issue you a numbered copy in the name of @${nick}.`, 'edition.need-nick': 'Set a nick in Profile first', - 'edition.run': 'Run', 'edition.run.open': 'open', + 'edition.run': 'Run', 'edition.run.open': 'unlimited', 'edition.run.hint': 'The run is fixed by the first copy. Only this device knows the numbers — keep a backup.', 'edition.status.of': (/** @type {number} */ n, /** @type {number} */ of) => `${n} of ${of} issued`, - 'edition.status.open': (/** @type {number} */ n) => `${n} issued · open run`, + 'edition.status.open': (/** @type {number} */ n) => `${n} issued · unlimited run`, 'edition.issue': (/** @type {number} */ n) => `Issue №${n}`, 'edition.run-out': 'The run is complete', 'edition.how': 'The recipient opens Scan → Recipient code; you scan their code, then show them yours.', - 'edition.scan.title': 'Recipient code', 'edition.point': 'Point at the recipient code on their screen', + 'edition.scan.title': 'Recipient code', 'edition.point': 'Point the camera at the recipient code', 'edition.not-recipient': 'Not a recipient code. Ask them to open Scan → Recipient code', 'edition.copy': (/** @type {number} */ n) => `№${n}`, 'edition.copy.of': (/** @type {number} */ n, /** @type {number} */ of) => `№${n} of ${of}`, 'edition.confirm': (/** @type {string} */ label, /** @type {string} */ nick, /** @type {string} */ key) => `Issue ${label} to @${nick} (key ${key})? The number will be taken.`, @@ -344,10 +344,10 @@ const en = { 'scan.loading': 'Loading the QR reader…', 'scan.failed': 'Could not load the QR reader. Check the connection and try again', 'scan.none': 'No QR code found in the picture', 'scan.no-camera': 'No access to the camera', 'scan.no-camera.how': 'Allow it in the browser settings, or open a picture', - 'give.single': (/** @type {number} */ b) => `${b} bytes · one code. If it won’t scan, turn the brightness up.`, + 'give.single': (/** @type {number} */ b) => `${b} bytes · one code. If it won’t scan, turn the screen brightness up.`, 'give.carousel': (/** @type {number} */ n) => `A large card: the codes cycle. Keep the camera on it until it has ${n} parts.`, 'give.copy-of': (/** @type {string} */ a) => `You are passing on a copy. The artist is ${a}.`, - 'receive.title': 'Scan', 'receive.start': 'Start camera', 'receive.point': 'Point at the code on the other screen', + 'receive.title': 'Scan', 'receive.start': 'Start camera', 'receive.point': 'Point the camera at the code on the other screen', 'receive.image': 'From picture', 'receive.file': '.pips file', 'receive.paste': 'Paste a link from the clipboard', 'receive.clipboard-empty': 'The clipboard is empty', 'receive.paste-here': 'Paste the link into the field', 'receive.paste.placeholder': 'Link or code text', @@ -355,7 +355,7 @@ const en = { 'receive.got': 'Card added to the collection', 'receive.dup': 'You already have this card', 'receive.not-pips': 'Not a Pips code', 'receive.damaged': 'A Pips code, but damaged. Try scanning again', 'receive.forged': 'The signature does not match: the code is damaged or forged. Card not added', 'receive.recipient': 'That is a recipient code — the artist scans it to issue a copy', 'receive.paper': 'That is someone’s secret key. Anyone who sees it can sign cards as its owner.', - 'me.title': 'Profile', 'me.nick': 'Name', 'me.handle': 'Social', 'me.handle.ph': 'Name there', 'me.network': 'Social network', 'me.link': 'Link', + 'me.title': 'Profile', 'me.nick': 'Name', 'me.handle': 'Social media', 'me.handle.ph': 'Your name there', 'me.network': 'Social network', 'me.link': 'Link', 'me.link.hint': 'For example kitsu.art/shop — https:// is added for you', 'me.card-back': 'On the back of your cards', 'me.saved': 'Saved', 'backup.title': 'Backup', 'backup.save': 'Save a backup', 'backup.restore': 'Restore', @@ -375,7 +375,7 @@ const en = { 'backup.err.too_large': 'The file is too large', 'me.key': 'Key', 'me.words': '24 words', 'me.paper': 'Paper key', 'me.switch': 'Switch key', - 'me.switch.confirm': (/** @type {number} */ n) => `${n} of your cards ${n === 1 ? 'is' : 'are'} signed with the present key. After the switch they stay with you, but their numbered copies can no longer be issued from here. If this key's words are not written down, write them down first.`, + 'me.switch.confirm': (/** @type {number} */ n) => `${n} of your cards ${n === 1 ? 'is' : 'are'} signed with the current key. After the switch they stay with you, but their numbered copies can no longer be issued from here. If this key's words are not written down, write them down first.`, 'me.switch.go': 'Switch key', 'me.switch.done': 'Key switched', 'me.switch.same': 'The same key: the backup was added', 'me.words.hide': 'Hide the words', 'me.paper.hide': 'Hide the key', 'me.paper.text': 'Show the key as text', 'me.secret.confirm': 'Show this only to yourself. Anyone who sees it can sign cards as you. Show it?', @@ -392,7 +392,7 @@ const en = { 'me.storage': 'Storage', 'me.storage.persisted': 'Protected from automatic browser clean-up', 'me.storage.not-persisted': 'The browser may clear data when space runs low — keep backups', 'me.storage.ask': 'Ask the browser to keep it', 'me.storage.granted': 'The browser will keep the data', - 'me.storage.refused': 'The browser said no — backups it is. It is more willing once the app is on the home screen', + 'me.storage.refused': 'The browser said no — rely on backups. Browsers are more willing once the app is on the home screen', 'me.storage.memory': 'Browser storage is unavailable: everything is lost when this page closes', 'me.language': 'Language', 'me.language.later': 'The language changes on the next start', 'dev.title': 'For developers', 'dev.phrase': 'Phrase to enter', @@ -407,14 +407,14 @@ const en = { 'dev.samples.removed': (/** @type {number} */ n) => `Samples removed: ${n}`, 'app.update': 'A new version of Pips is ready', 'app.update.go': 'Update', 'onb.title': 'Pips', 'onb.lead': 'Trade pixel-art cards through QR codes. No server, no accounts: everything stays on this device.', - 'onb.arrival': (/** @type {string} */ title, /** @type {string} */ a) => `Someone sent you the card «${title}»${a ? ` by @${a}` : ''}. Create your key or restore yours — and it is in your collection at once.`, + 'onb.arrival': (/** @type {string} */ title, /** @type {string} */ a) => `Someone sent you the card “${title}”${a ? ` by @${a}` : ''}. Create your key or restore yours — and it is in your collection at once.`, 'link.bad': 'The card link is not whole — a messenger may have cut it. Ask for it again', 'share.link': 'Link', 'share.file': 'File', 'share.png': 'Picture', 'share.ways': 'Other ways', - 'receive.no-card-in-picture': 'This picture holds no Pips card. «From picture» reads a code off it', + 'receive.no-card-in-picture': 'This picture holds no Pips card. To read a code off it, choose “From picture”', 'picture.scan': 'Open in Pips: Scan → From picture', 'picture.file': 'Open the file in Pips: Scan → From picture', 'share.file.saved': (/** @type {string} */ n) => `Saved: ${n}`, 'share.link.copied': 'Link copied', 'share.link.failed': 'Could not copy the link', - 'share.link.text': (/** @type {string} */ title) => `The card «${title}» — open it in Pips`, + 'share.link.text': (/** @type {string} */ title) => `The card “${title}” — open it in Pips`, 'onb.create': 'Create my key', 'onb.restore': 'I already have a key', 'onb.words.title': 'Your 24 words', 'onb.words.lead': 'These words are your key. Cards recognise their artist by it, and if this phone is lost, the words bring it back. Write them on paper and show no one.', @@ -423,14 +423,14 @@ const en = { 'onb.done': 'Done', 'onb.restore.title': 'Restore', 'onb.restore.lead': 'The key brings back your signature; a backup, your collection. Without a backup, only the key comes back.', 'onb.restore.file': 'Backup', 'onb.restore.file.pick': 'Choose the backup file', 'onb.restore.file.drop': 'Remove', - 'onb.restore.file.off': 'If you have one, choose it first: the app will check that the key is the one it belongs to.', + 'onb.restore.file.off': 'If you have one, choose it first: the app will check that the key fits it.', 'onb.restore.file.on': 'It opens with your key — cards, notes and profile come back with it.', 'onb.restore.key': 'Key', 'onb.restore.words': '24 words', 'onb.restore.paper': 'Paper key', 'onb.restore.words.lead': 'In order, as written down. The first four letters of each word are enough.', 'onb.restore.paper.point': 'Point the camera at the paper key', 'onb.restore.go': 'Restore', 'onb.back': 'Back', 'onb.switch.title': 'Another key', - 'onb.switch.lead': 'This key takes the present one’s place. The cards on the device stay; a backup, if there is one, joins them.', + 'onb.switch.lead': 'This key replaces the current one. The cards on the device stay; a backup, if there is one, is added to them.', 'onb.key.count': (/** @type {number} */ n) => `${n} of 24 words`, 'onb.key.unknown': (/** @type {number} */ n) => `Word ${n} is not in the list — check it`, 'onb.key.sum': 'Every word is in the list, but together they do not add up: one is different, or two are swapped.', @@ -442,7 +442,7 @@ const en = { 'err.generic': 'Something went wrong', 'core.failed': 'Could not load the app core.', 'store.reload': 'Reload', - 'store.blocked.title': 'Other tabs need closing', 'store.blocked': 'Pips is open in another tab, an older version. Close it: the data will be updated and the app opens by itself.', + 'store.blocked.title': 'Close the other tabs', 'store.blocked': 'Pips is open in another tab, in an older version. Close it: the data will be updated and the app will open by itself.', 'store.newer.title': 'Pips has been updated', 'store.newer': 'The data on this device was stored by a newer version. Reload to open it.', 'store.replaced.title': 'Pips has been updated', 'store.replaced': 'A newer version, open in another tab, has updated the data. Reload this page too.', 'store.upgrade-failed.title': 'The data was not updated', 'store.upgrade-failed': 'The data could not be moved to the new version. It is as it was, and whole. Reload; if that does not help, open the journal and send a report.', @@ -453,7 +453,7 @@ const en = { 'collection.select': 'Select', 'collection.done': 'Done', 'collection.select-title': 'Select', 'collection.selected': (/** @type {number} */ n) => `${n} selected`, 'collection.bin-note': 'deleted after 30 days', 'collection.back': 'Back', - 'collection.coach': 'Pinch in for weeks and months. Spread out for the feed and a card.', + 'collection.coach': 'Pinch for weeks and months; spread your fingers for the feed and a single card.', 'collection.coach.desk': 'Click or “+” to go closer, “−” to step out, Esc to go back. The wheel and the arrows leaf through.', 'zoom.in': 'Closer', 'zoom.out': 'Further', @@ -478,7 +478,7 @@ const en = { 'first.text': 'This is your calling card: its pattern comes from your key. Show it to an artist and they can issue you a numbered copy.', 'first.draw': 'Draw', 'first.scan': 'Scan', 'first.card': 'your calling card', 'back.edit': 'Edit note and tags', 'back.done': 'Done', 'back.tag.remove': 'Remove tag', - 'back.tag.filter': 'Show every card with this tag', 'back.tags.none': 'No tags — the pencil adds them', + 'back.tag.filter': 'Show every card with this tag', 'back.tags.none': 'No tags — tap the pencil to add some', 'back.tag.add': '+ tag', 'back.tag.ph': 'tag', 'back.note.ph': 'A note only you see; it never leaves with the card. Where, from whom, the story…', 'back.note.none': 'No note', 'back.path.gap': 'A code names only its last sender: before them the card may have passed through other hands', @@ -493,18 +493,18 @@ const en = { 'card.untitled': 'Untitled', 'card.need-title': 'Name the card first: the name stays with it, for everyone it reaches', 'ed.tools': 'Tools', 'ed.tool.pen': 'Pencil', 'ed.tool.eraser': 'Eraser', 'ed.tool.fill': 'Fill', 'ed.tool.shapes': 'Line and shapes', 'ed.tool.pick': 'Pipette', - 'ed.tool.select': 'Selection', 'ed.pen.solid': 'Pencil — smooths the corners of a line', 'ed.pen.square': 'Pencil — every cell, corners kept', 'ed.pen.dither': 'Checkered (dither)', + 'ed.tool.select': 'Selection', 'ed.pen.solid': 'Pencil — smooths the corners of a line', 'ed.pen.square': 'Pencil — cell by cell, corners not smoothed', 'ed.pen.dither': 'Checkered (dither)', 'ed.sel': 'Selection', 'ed.sel.copy': 'Move a copy', 'ed.sel.flip-x': 'Mirror left to right', - 'ed.sel.flip-y': 'Mirror top to bottom', 'ed.sel.turn': 'Turn a quarter', 'ed.sel.erase': 'Erase the selection', + 'ed.sel.flip-y': 'Mirror top to bottom', 'ed.sel.turn': 'Rotate 90°', 'ed.sel.erase': 'Erase the selection', 'ed.sel.all': 'Select all', 'ed.sel.drop': 'Deselect', - 'ed.h.dither': 'checkered', 'ed.h.move': 'move', 'ed.h.copy': 'copy', 'ed.h.flip': 'mirror', 'ed.h.turn': 'turn', - 'ed.h.erase-sel': 'selection erased', + 'ed.h.dither': 'checkered stroke', 'ed.h.move': 'move', 'ed.h.copy': 'copying', 'ed.h.flip': 'mirroring', 'ed.h.turn': 'rotation', + 'ed.h.erase-sel': 'erasing the selection', 'ed.shape.line': 'Line', 'ed.shape.rect': 'Rectangle', 'ed.shape.rect-fill': 'Filled rectangle', 'ed.shape.ellipse': 'Ellipse', 'ed.shape.ellipse-fill': 'Filled ellipse', 'ed.mirror': 'Mirror', 'ed.precise': 'Precise mode', 'ed.more': 'More', - 'ed.precise.hint': 'Move a finger here — the cursor follows. Hold “Put” and move to draw a line.', + 'ed.precise.hint': 'Move a finger here and the cursor follows. To draw a line, hold “Put” while you move.', 'ed.precise.put': 'Put', 'ed.precise.pen': 'Put', 'ed.precise.eraser': 'Erase', 'ed.precise.fill': 'Fill', 'ed.precise.shapes': 'Shape', 'ed.precise.select': 'Select', 'ed.precise.pick': 'Pick', 'ed.precise.erase': 'Erase under the cursor', - 'ed.frames': 'Frames', 'ed.frame.add': 'New frame: a copy of the open one', 'ed.frame.first': 'Animation frame', + 'ed.frames': 'Frames', 'ed.frame.add': 'New frame — a copy of the current one', 'ed.frame.first': 'Animation frame', 'ed.empty': 'The canvas is empty — draw something first', 'ed.frame.menu': 'Frame', 'ed.frame.n': (/** @type {any} */ n, /** @type {any} */ all) => `Frame ${n} of ${all}`, 'ed.frame.open': (/** @type {any} */ n, /** @type {any} */ all) => `Frame ${n} of ${all}, open`, 'ed.frame.copy': 'Copy the frame', 'ed.frame.left': 'Move left', 'ed.frame.right': 'Move right', @@ -514,50 +514,52 @@ const en = { 'ed.pal.own': 'Own palette', 'ed.pal.own-n': (/** @type {any} */ n) => `Own · ${n} colour${n === 1 ? '' : 's'}`, 'ed.pal.count': (/** @type {any} */ n) => `${n} colour${n === 1 ? '' : 's'}`, 'ed.pal.page': (/** @type {any} */ a, /** @type {any} */ b) => `Colours ${a}–${b}`, 'ed.pal.color': (/** @type {any} */ n, /** @type {any} */ c) => `Colour ${n}, ${c}`, 'ed.pal.add': 'Add a colour', - 'ed.pal.note': 'The picture takes the nearest colours at once — have a look. A ready palette costs nothing in the code: the card carries only its number.', + 'ed.pal.note': 'The picture has already taken the nearest colours — have a look at the canvas. A ready palette takes no room in the code: the card stores only its number.', 'ed.apply': 'Apply', 'ed.done': 'Done', 'ed.color.title': 'Palette colour', 'ed.color.new': 'New colour', 'ed.color.add': 'Add', 'ed.color.was': 'Before and after', 'ed.color.h': 'Hue', 'ed.color.s': 'Saturation', 'ed.color.v': 'Brightness', 'ed.color.ramp': 'Steps from shadow to light', 'ed.color.ramp-add': 'Add all', 'ed.color.ramp-added': 'The steps are in the palette.', 'ed.color.step': (/** @type {any} */ n, /** @type {any} */ c) => `Step ${n}, ${c}`, - 'ed.color.note': 'Each colour of your own adds 3 bytes to the code. Pixels of this colour change with it.', - 'ed.color.note-builtin': 'The ready palette becomes your own: each colour adds 3 bytes to the code.', + 'ed.color.note': 'Each colour of your own adds 3 bytes to the code. Pixels of this colour change along with it.', + 'ed.color.note-builtin': 'The ready palette becomes your own: each of its colours adds 3 bytes to the code.', 'ed.set.size': 'Canvas size', 'ed.set.shade': 'Frame colour', 'ed.set.shade.n': (/** @type {any} */ n) => `Frame ${n}`, - 'ed.set.delay': 'Frame, ms', 'ed.set.axes': 'Mirror', 'ed.set.axes.x': 'Left to right', 'ed.set.axes.y': 'Top to bottom', 'ed.set.axes.xy': 'Both', + 'ed.set.delay': 'Frame time, ms', 'ed.set.axes': 'Mirror', 'ed.set.axes.x': 'Left to right', 'ed.set.axes.y': 'Top to bottom', 'ed.set.axes.xy': 'Both', 'ed.set.grid': 'Grid on the canvas', 'ed.set.new': 'New card', - 'ed.new.blank': 'Blank canvas', 'ed.new.blank.note': '«Undo» brings this one back', 'ed.new.picture': 'From a picture', 'ed.new.picture.note': 'PNG, Aseprite, photo', + 'ed.new.blank': 'Blank canvas', 'ed.new.blank.note': '“Undo” brings this one back', 'ed.new.picture': 'From a picture', 'ed.new.picture.note': 'PNG, Aseprite, photo', 'act.derive': 'New from it', 'act.derive.too-big': 'This card is larger than the editor’s canvas', - 'ed.from-picture': 'The picture is on the canvas', 'ed.from-card': 'A new card from the old one: name it and sign', - 'pic.has-card': (/** @type {string} */ title, /** @type {string} */ by) => `This picture holds the card «${title}» by ${by}. Add it to the collection as it is, signed by its artist, or take the drawing onto the canvas below.`, + 'ed.from-picture': 'The picture is on the canvas', 'ed.from-card': 'A new card from the old one: name it and sign it', + 'pic.has-card': (/** @type {string} */ title, /** @type {string} */ by) => `This picture holds the card “${title}” by ${by}. Add it to the collection as it is, signed by its artist, or take the drawing onto the canvas below.`, 'pic.add-card': 'Add to the collection', 'pic.title': 'From a picture', 'pic.place': 'Onto the canvas', 'pic.other': 'Another picture', - 'pic.not-square': 'Larger than 64×64 — try other options or another picture', + 'pic.not-square': 'Larger than 64×64 — choose other settings or another picture', 'pic.ase-square': 'The Aseprite canvas is larger than 64×64: make it smaller there', 'pic.unreadable': 'Could not open the picture: the browser does not know this format', - 'pic.crop.label': 'Part of the picture: move it and pull the corners', + 'pic.crop.label': 'Part of the picture: move it and drag the corners', 'pic.crop.pixel': 'Larger than the canvas: choose a square — the pixels stay as they are', - 'pic.crop.photo': 'A card is square: move the frame, pull its corners. You can draw on it after', - 'ed.size.crop': (/** @type {any} */ n) => `At ${n}×${n} the edges of the picture are cut off. “Undo” brings them back.`, 'ed.size.yes': 'Cut', - 'ed.fresh': 'A clean canvas', 'ed.history': 'History of changes', 'ed.hold-history': 'hold for the whole history', + 'pic.crop.photo': 'Cards are square: move the frame or drag a corner. You can draw over it afterwards', + 'ed.size.crop': (/** @type {any} */ n) => `At ${n}×${n} the edges of the picture are cut off. This can be undone.`, 'ed.size.yes': 'Cut', + 'ed.fresh': 'A new blank canvas', 'ed.history': 'History of changes', 'ed.hold-history': 'hold to see the whole history', 'ed.undo': 'Undo', 'ed.redo': 'Redo', 'ed.undo.what': (/** @type {any} */ w) => `Undo: ${w}`, 'ed.redo.what': (/** @type {any} */ w) => `Redo: ${w}`, 'ed.undone': (/** @type {any} */ w) => `Undone: ${w}`, 'ed.redone': (/** @type {any} */ w) => `Redone: ${w}`, 'ed.h.start': 'Start', 'ed.h.now': 'just now', 'ed.h.min': (/** @type {any} */ n) => `${n} min`, 'ed.h.hours': (/** @type {any} */ n) => `${n} h`, - 'ed.h.frame': (/** @type {any} */ n) => `frame ${n}`, 'ed.h.branch': (/** @type {any} */ n) => `Another way — ${n} here`, - 'ed.h.pen': 'pencil', 'ed.h.eraser': 'eraser', 'ed.h.fill': 'fill', 'ed.h.line': 'line', 'ed.h.rect': 'rectangle', - 'ed.h.ellipse': 'ellipse', 'ed.h.clear': 'cleared frame', 'ed.h.frame-add': 'new frame', 'ed.h.frame-remove': 'deleted frame', - 'ed.h.frame-move': 'frames reordered', 'ed.h.size': 'canvas size', 'ed.h.palette': 'another palette', 'ed.h.color': 'palette colour', - 'ed.h.color-add': 'new colour', 'ed.h.title': 'title', 'ed.h.shade': 'frame colour', 'ed.h.delay': 'animation speed', - 'ed.h.new': 'new card', + 'ed.h.frame': (/** @type {any} */ n) => `frame ${n}`, 'ed.h.branch': (/** @type {any} */ n) => `A branch: ${n} versions`, + 'ed.h.pen': 'pencil stroke', 'ed.h.eraser': 'erasing', 'ed.h.fill': 'fill', 'ed.h.line': 'line', 'ed.h.rect': 'rectangle', + 'ed.h.ellipse': 'ellipse', 'ed.h.clear': 'clearing the frame', 'ed.h.frame-add': 'adding a frame', 'ed.h.frame-remove': 'deleting a frame', + 'ed.h.frame-move': 'reordering frames', 'ed.h.size': 'resizing the canvas', 'ed.h.palette': 'changing the palette', 'ed.h.color': 'changing a colour', + 'ed.h.color-add': 'adding a colour', 'ed.h.title': 'renaming', 'ed.h.shade': 'changing the frame colour', 'ed.h.delay': 'changing the speed', + 'ed.h.new': 'replacing the canvas', 'ed.budget': (/** @type {any} */ n, /** @type {any} */ s) => `${n} QR · ~${s} s`, 'ed.budget.over': (/** @type {any} */ s) => `Slow · ~${s} s`, 'ed.budget.colors': 'Too many colours', - 'ed.budget.label': 'Passing on', - 'ed.budget.one.why': (/** @type {any} */ b) => `${b} bytes: one QR code, passed at once.`, - 'ed.budget.loop.why': (/** @type {any} */ b, /** @type {any} */ n, /** @type {any} */ s) => `${b} bytes: ${n} codes in a loop, about ${s} s to pass. Fewer frames or colours make it faster.`, - 'ed.budget.over.why': (/** @type {any} */ b) => `${b} bytes — passing it on would take over 10 seconds. Remove frames, colours or fine detail.`, - 'ed.budget.colors.why': 'With empty cells, 31 colours fit: remove one or fill the empty cells.', + 'ed.budget.label': 'Transfer', + 'ed.budget.one.why': (/** @type {any} */ b) => `${b} bytes: one QR code, transferred at once.`, + 'ed.budget.loop.why': (/** @type {any} */ b, /** @type {any} */ n, /** @type {any} */ s) => `${b} bytes: ${n} codes in a loop, about ${s} s to transfer. Fewer frames and colours make it faster.`, + 'ed.budget.over.why': (/** @type {any} */ b) => `${b} bytes — the transfer would take over 10 seconds. Remove some frames, colours or fine detail.`, + 'ed.budget.colors.why': 'With transparent cells, only 31 colours fit: remove one colour or fill the transparent cells.', }; /** @type {Record>} */ const DICTS = { ru, en }; +/** Both languages, for the tests that keep them in step. */ +export const dictionaries = DICTS; let lang = 'ru'; /** @param {string} l */ diff --git a/web/src/kit/strip.js b/web/src/kit/strip.js index 4e3bc3f..ac47c5c 100644 --- a/web/src/kit/strip.js +++ b/web/src/kit/strip.js @@ -3,9 +3,7 @@ // some place, newest first. A newcomer takes the first slot and the others // move one along; `room` makes space before it lands. -import { h } from '../dom.js'; - -const SLOT = 64; +import { h, cssNumber } from '../dom.js'; /** @typedef {{id: string, make: () => HTMLElement}} StripItem */ @@ -36,7 +34,8 @@ export function strip() { const room = o.room ?? 0; empty.textContent = o.empty; empty.hidden = !!(items.length || room); - const fit = Math.max(1, Math.floor((row.clientWidth + 8) / SLOT)); + const step = cssNumber('--slot', el), gap = step - cssNumber('--mini', el); + const fit = Math.max(1, Math.floor((row.clientWidth + gap) / step)); for (const [id, m] of minis) if (!items.some((i) => i.id === id)) { m.remove(); minis.delete(id); } items.forEach((it, k) => { let m = minis.get(it.id); @@ -46,17 +45,17 @@ export function strip() { minis.set(it.id, m); if (it.id === o.landed) { m.style.transition = 'none'; - m.style.transform = `translateX(${(k + room) * SLOT}px)`; + m.style.transform = `translateX(${(k + room) * step}px)`; void m.offsetWidth; m.style.transition = ''; } else { - m.style.transform = `translateX(${-SLOT}px)`; + m.style.transform = `translateX(${-step}px)`; m.style.opacity = '0'; void m.offsetWidth; } } const slot = k + room; - m.style.transform = `translateX(${slot * SLOT}px)`; + m.style.transform = `translateX(${slot * step}px)`; m.style.opacity = slot < fit ? '1' : '0'; }); } @@ -68,7 +67,7 @@ export function strip() { const r = row.getBoundingClientRect(); const bottom = /** @type {HTMLElement|null} */ (el.offsetParent)?.getBoundingClientRect().bottom ?? innerHeight; const shift = el.getBoundingClientRect().top - (bottom - el.offsetHeight); - return { x: r.left + k * SLOT, y: r.top - shift, w: 56 }; + return { x: r.left + k * cssNumber('--slot', el), y: r.top - shift, w: cssNumber('--mini', el) }; } reveal(0); diff --git a/web/src/ui/give.js b/web/src/ui/give.js index 22cba9e..8aa66af 100644 --- a/web/src/ui/give.js +++ b/web/src/ui/give.js @@ -20,8 +20,12 @@ const ownCards = () => [...app.cards.values()] .filter((e) => e.rec.mine && e.rec.state !== 'trash') .sort((a, b) => b.rec.received - a.rec.received); -/** The code's side here: as large as fits above the row of cards. */ -const sizeHere = () => Math.round(Math.max(180, Math.min(300, innerWidth - 96, innerHeight - 576))); +/** The code's side here: as large as the room above the row of cards + * allows, between these. Measured (fit() below), not worked out from the + * window: the room is whatever the CSS around it leaves. */ +const SIDE_MIN = 180, SIDE_MAX = 300; +/** Kept clear around the code's square inside the paper, and below the paper. */ +const MARGIN = 16; export function giveView() { const paper = h('div', { class: 'give-paper on-paper' }); @@ -36,6 +40,26 @@ export function giveView() { main, deckBox); let stop = () => {}; + let side = SIDE_MAX; + /** @type {import('../state.js').Entry|null} */ + let laid = null; + + /** + * The side the code takes: the one now, plus the room left over (or + * minus what is missing) across and down. One step lands on it — the + * square grows as much as the room does — and it holds for every card, + * since the paper is laid out the same way for each. + */ + function fit() { + const box = /** @type {HTMLElement|null} */ (body.querySelector('.qr-box')); + const area = /** @type {HTMLElement|null} */ (body.querySelector('.share-code')); + if (!box || !area || !main.clientHeight) return; + const across = area.clientWidth - 2 * MARGIN - box.offsetWidth; + const down = main.getBoundingClientRect().bottom - paper.getBoundingClientRect().bottom - MARGIN; + const next = Math.round(Math.max(SIDE_MIN, Math.min(SIDE_MAX, side + Math.min(across, down)))); + if (next !== side && laid) { side = next; lay(laid); } + } + new ResizeObserver(() => fit()).observe(main); /** Lays the chosen card on the paper. @param {import('../state.js').Entry} e */ function lay(e) { @@ -45,12 +69,14 @@ export function giveView() { paper.style.setProperty('--shade', s.paper); paper.style.setProperty('--shade-ink', s.ink); paper.style.setProperty('--shade-dim', s.dim); - stop = fillShare(body, e, { size: sizeHere(), hint: t('give.hint') }); + laid = e; + stop = fillShare(body, e, { size: side, hint: t('give.hint') }); for (const b of deck.querySelectorAll('.give-mini')) { const on = /** @type {HTMLElement} */ (b).dataset.id === chosen; b.setAttribute('aria-pressed', String(on)); if (on) reveal(deck, b); } + fit(); } function show() { diff --git a/web/src/ui/qr.js b/web/src/ui/qr.js index f749835..3d06648 100644 --- a/web/src/ui/qr.js +++ b/web/src/ui/qr.js @@ -7,14 +7,19 @@ import { app } from '../state.js'; const QUIET = 4; const FRAME_MS = 220; -/** Draws text as a QR symbol with whole pixels per module. @param {HTMLCanvasElement} cv @param {string} text @param {number} maxPx */ +/** + * Draws text as a QR symbol with whole pixels of the screen per module — + * not CSS pixels: a phone has two or three of those to each, so the step + * is fine, and a code of any size nearly fills its square. + * @param {HTMLCanvasElement} cv @param {string} text @param {number} maxPx + */ function draw(cv, text, maxPx) { const m = app.core.qrMatrix(text); - const side = m.size + QUIET * 2; - const scale = Math.max(1, Math.floor(maxPx / side)); + const side = m.size + QUIET * 2, dpr = devicePixelRatio || 1; + const scale = Math.max(1, Math.floor((maxPx * dpr) / side)); // device pixels a module cv.width = side; cv.height = side; - cv.style.width = cv.style.height = `${side * scale}px`; + cv.style.width = cv.style.height = `${(side * scale) / dpr}px`; const g = /** @type {CanvasRenderingContext2D} */ (cv.getContext('2d')); const img = g.createImageData(side, side); img.data.fill(255); @@ -52,7 +57,19 @@ export function qrFor(bytes, maxPx) { next(); timer = window.setInterval(next, FRAME_MS); } - return { el: h('div', { class: 'qr-box' }, cv), plan, stop: () => clearInterval(timer) }; + return { el: box(cv, maxPx), plan, stop: () => clearInterval(timer) }; +} + +/** + * The white square a code lies on: always `side` across, whatever the code. + * Each module takes whole pixels of the screen, so a code may come out a + * hair smaller than the square; the square itself does not, and nothing around it moves when + * one code gives way to another. @param {HTMLCanvasElement} cv @param {number} side + */ +function box(cv, side) { + const el = h('div', { class: 'qr-box' }, cv); + el.style.width = el.style.height = `${side}px`; + return el; } /** A QR display for text that is already in the QR alphabet (a paper key, @@ -60,7 +77,7 @@ export function qrFor(bytes, maxPx) { export function qrText(text, maxPx) { const cv = /** @type {HTMLCanvasElement} */ (h('canvas', { 'aria-label': 'QR' })); draw(cv, text, maxPx); - return h('div', { class: 'qr-box' }, cv); + return box(cv, maxPx); } /** Keeps the screen on while a code is shown, where the browser allows it. */