Отзыв с iPhone после первого живого прогона. Масштабирование запрещено (ADR-066): viewport как в соседнем sixlines плюс viewport-fit=cover, мета-теги установленного приложения, touch-action против двойного тапа и страховка на JS — гашение жестов iOS, потому что мета-строку Safari вправе проигнорировать. Размеры шрифтов не тронуты: 14 px из брифа остаются, поднимать поля до 16 px владелец отклонил. Цена — доступность для тех, кому зум нужен — записана в ADR и в ui.md честно. Версия в подвале (ADR-065, 067): GET /api/config отдаёт короткую ревизию и время коммита. Именно коммита, а не сборки: штамп компиляции в бинаре means каждая пересборка даёт новый хеш, и обещание ADR-022 «бинарь собирается из тега байт в байт» перестаёт проверяться. Самообновление PWA (ADR-068, 070): в режиме приложения на iPhone перезагрузки нет, и старая версия жила бы вечно. Проверка при возврате в приложение, включение ждущей оболочки, перезагрузка — но не тогда, когда в строке набран текст. Замок от петли сравнивает версию оболочки, а не считает перезагрузки: счётчик отменял саму фичу, долгоживущая вкладка переставала обновляться. Замерено: было 30, 60, 120, 240 секунд между релизами, стало ровно 30. Строка ввода — редактируемый блок (ADR-069): iOS рисует над клавиатурой полосу со стрелками и «готово» для полей формы, и убрать её со страницы нельзя. Для contenteditable не рисует. Режим plaintext-only с проверкой, что он применился, и запасным путём с разбором вставки: невалидное значение атрибута в части браузеров делает блок нередактируемым вовсе. Чтение и запись только через textContent, innerHTML в клиенте по-прежнему нет. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
98 lines
4.0 KiB
JavaScript
98 lines
4.0 KiB
JavaScript
// Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас»
|
||
// и «Список чатов».
|
||
|
||
import * as pwa from "../pwa.js";
|
||
import { INSTALL_IOS, clear, el, mark } from "./dom.js";
|
||
import { mount } from "./chats.js";
|
||
|
||
// Время коммита — в местной зоне и коротко: день с месяцем и часы
|
||
// с минутами (ADR-067). Год не показывается: версия отвечает на вопрос
|
||
// «что сейчас работает», а не ведёт летопись.
|
||
const BUILD_DAY = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit" });
|
||
const BUILD_TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||
|
||
// frame отдаёт корень, место под экран и отписку списка чатов.
|
||
// screen — что показывать на мобильном, где виден один экран за раз:
|
||
// "list" или "screen". active — чат, который сейчас открыт.
|
||
export function frame(ctx, screen, active = null) {
|
||
const root = el("div", "shell");
|
||
root.dataset.screen = screen;
|
||
const main = el("main", "main");
|
||
const { nav, dispose } = side(ctx, active);
|
||
root.append(nav, main);
|
||
return { root, main, dispose };
|
||
}
|
||
|
||
function side(ctx, active) {
|
||
const nav = el("nav", "side");
|
||
|
||
const brand = el("div", "brand");
|
||
brand.append(mark(), el("span", null, "bare"));
|
||
nav.append(brand);
|
||
|
||
// Баннер установки — над списком чатов (docs/ui.md, «Баннер установки»).
|
||
// Место под него занимается сразу, содержимое приезжает из meta.
|
||
const place = el("div", "banner-slot");
|
||
nav.append(place);
|
||
banner(place);
|
||
|
||
const list = el("div", "list");
|
||
const add = el("button", "item item--new", "+ новый чат");
|
||
add.type = "button";
|
||
add.addEventListener("click", () => ctx.go("#/new"));
|
||
const items = el("div");
|
||
list.append(add, items);
|
||
nav.append(list);
|
||
|
||
const foot = el("div", "foot");
|
||
const me = el("button", "me");
|
||
me.type = "button";
|
||
const dot = el("i");
|
||
dot.setAttribute("aria-hidden", "true");
|
||
me.append(dot, el("span", "me__nick", `ты: @${ctx.me.nick}`));
|
||
me.addEventListener("click", () => ctx.go("#/settings"));
|
||
foot.append(me);
|
||
const version = build(ctx.config);
|
||
if (version !== null) {
|
||
foot.append(el("span", "build", version));
|
||
}
|
||
nav.append(foot);
|
||
|
||
return { nav, dispose: mount(items, ctx, active) };
|
||
}
|
||
|
||
// build — версия и время коммита из GET /api/config (ADR-065, ADR-067).
|
||
// Конфигурации нет — офлайн-старт до первого ответа сервера — значит,
|
||
// и строки нет: выдумывать версию не из чего. Время без версии не бывает:
|
||
// её сервер отдаёт всегда, хотя бы как «unknown».
|
||
function build(config) {
|
||
const version = config?.version;
|
||
if (typeof version !== "string" || version === "") {
|
||
return null;
|
||
}
|
||
const at = config?.commitAt;
|
||
if (!Number.isFinite(at) || at <= 0) {
|
||
return version;
|
||
}
|
||
return `${version} · ${BUILD_DAY.format(at)} ${BUILD_TIME.format(at)}`;
|
||
}
|
||
|
||
// banner — баннер установки на iOS: пуши там работают только
|
||
// у установленного приложения (ADR-011). Крестик закрывает его насовсем.
|
||
async function banner(place) {
|
||
if (!pwa.iosBrowser() || await pwa.bannerHidden()) {
|
||
return;
|
||
}
|
||
const box = el("div", "banner");
|
||
box.append(el("p", "banner__text", INSTALL_IOS));
|
||
const close = el("button", "banner__close", "×");
|
||
close.type = "button";
|
||
close.setAttribute("aria-label", "закрыть");
|
||
close.addEventListener("click", () => {
|
||
clear(place);
|
||
pwa.hideBanner();
|
||
});
|
||
box.append(close);
|
||
place.append(box);
|
||
}
|