Слить main: стороны сообщений поверх строки ввода iOS
Обе ветки правили строку ввода чата ради одной и той же полосы помощника форм над клавиатурой iOS. ADR-064 из main считал триггером элемент form и вынес поле наружу; на устройстве не помогло — полоса бывает у input и textarea всегда. ADR-069 отсюда заменяет поле редактируемым блоком, это и есть «следующий шаг» из следствий ADR-064. Что взято: - строка ввода — редактируемый блок (ADR-069/071), контейнер div с role="form" и кнопка type="button" (ADR-064); - лента — поток со сторонами, feed-list (ADR-065), не сетка; - brief.md: safe-area из ADR-075 и стороны из ADR-065 вместе; - app.css: правило 44 px для блока ввода; .author:empty из медиазапроса убран — main вынес его на верхний уровень, сетки больше нет. Убран слушатель submit: контейнер стал div, событие не случается. ADR-064 и ADR-069 связаны ссылками в обе стороны. sw.js VERSION → v6: клиент отличается от обеих версий, где стоял v5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QR8uS3zGkybzWRW4GEY1oz
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# ADR-064: строка ввода — div, а не form
|
||||
|
||||
Уточняет `docs/ui.md`, «Доступность»: `form` в списке семантики держит `role="form"`, а не элемент `<form>`, — для строки ввода чата.
|
||||
|
||||
Пересмотрен [ADR-069](069-message-input-is-editable-block.md). Диагноз здесь неверен: полосу помощника форм iOS рисует не из-за `<form>`, а для любых `input` и `textarea`, — вынос поля из формы её не убрал. Контейнер `div` с `role="form"` и кнопка `type="button"` остаются, `textarea` заменён редактируемым блоком — тем самым «следующим шагом», который записан ниже в «Следствиях».
|
||||
|
||||
## Контекст
|
||||
|
||||
Тест с реальными пользователями (iPhone, Safari и Chrome): над клавиатурой при фокусе в строке ввода всплывает системная панель навигации между полями — «‹ ›» и «готово», около 50 px. Полю переходить некуда — оно на экране одно, — а место съедено.
|
||||
|
||||
Панель — не наша вёрстка, это поведение WebKit для полей, лежащих внутри `<form>`. Строка ввода была оформлена именно так: `<form class="compose">` с `<textarea>` и кнопкой `type="submit"`.
|
||||
|
||||
Дорогой вариант — `contenteditable` вместо `textarea` — снял бы панель гарантированно, но потребовал бы вручную переписать то, что сейчас даёт браузер бесплатно: placeholder, обрезку на 4000 символов, автовысоту, и обязательно — очистку `paste` до текста, потому что `contenteditable` иначе вставляет форматированный HTML, а `innerHTML` в клиенте запрещён (CLAUDE.md).
|
||||
|
||||
Дешёвый вариант — вынести поле из `<form>` — не трогает ничего из этого: `textarea` остаётся `textarea`.
|
||||
|
||||
## Решение
|
||||
|
||||
- `<form class="compose">` → `<div class="compose" role="form">`. `role="form"` — тот же ориентир для скринридера, что и элемент `<form>`, без вызывающих панель heuristics WebKit.
|
||||
- Кнопка отправки — `type="button"` с явным `click`, вместо `type="submit"` и события `submit` на форме.
|
||||
- `autocomplete="off"` на самом поле — на случай, если панель зависит ещё и от него, а не только от `<form>`.
|
||||
- `textarea`, `maxLength`, `field-sizing: content`, placeholder — без изменений.
|
||||
|
||||
Остальные формы (вход, `#/new`, участники, настройки) не тронуты: там несколько полей в форме, панель навигации между ними — по крайней мере, не чистый минус, и незачем менять то, что не жаловались.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Проверить можно только на реальном устройстве — эмуляции панели WebKit нет; headless-Chromium её и не показывал никогда, так что автотест здесь не поставить.
|
||||
- Если панель не уйдёт (вдруг триггер — не `<form>`, а что-то ещё в эвристике Safari), следующий шаг — `contenteditable`, со всей его ценой из «Контекста».
|
||||
- `Enter` на десктопе и кнопка «>» на мобильном по-прежнему отправляют — обработчики `keydown` и `click` делают то же, что раньше `submit`.
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR-065: сообщения выравниваются по стороне — свои вправо, чужие влево
|
||||
|
||||
Пересматривает часть [ADR-024](024-identity-and-ui.md): десктопную сетку ленты «автор 132 px + текст» из `docs/identity/brief.md`, «Компоновка». Уточняет `docs/ui.md`, «Чат».
|
||||
|
||||
## Контекст
|
||||
|
||||
Тест с реальными пользователями: единый список с колонкой автора — десктоп сеткой `132px 1fr`, мобильный автором над группой — читается медленно. Чтобы понять, кто автор строки, нужно каждый раз смотреть в колонку слева; с чужого сообщения на своё и обратно глаз идёт через всю ширину.
|
||||
|
||||
Разделение по стороне, как в обычных мессенджерах, снимает это одним взглядом: своё — у своего края, чужое — у чужого. Айдентика «Скобы» этому не мешает и не помогает — про стороны сообщений там ничего нет, — но прямо запрещает пузыри, скругления и тени (`identity/brief.md`, «Цвет»). Значит выравнивание, а не bubble-чат: то же плоское сообщение, что и сейчас, просто у другого края.
|
||||
|
||||
Десктопная сетка `132px 1fr` для этого не годится: колонка автора у неё одна на весь список, слева, общая для всех строк. Разделить строки по сторонам в фиксированной сетке нечем — под это нужен один и тот же поток и на десктопе, и на мобильном, каким мобильный уже пользуется («автор над группой»). Решение — не расширять сетку, а убрать её: десктоп переходит на тот же поток, что мобильный.
|
||||
|
||||
## Решение
|
||||
|
||||
- Лента — везде (десктоп и мобильный) один поток блоков сверху вниз: автор и время строкой, текст под ними, без сетки.
|
||||
- Блок своего сообщения выравнивается по правому краю ленты, чужого — по левому. Свой ник в колонке автора остаётся цветом `mark` (ADR-024) — сторона и цвет вместе, не одно вместо другого.
|
||||
- Ширина блока ограничена (`min(640px, 85%)`), иначе выравнивать нечего: абзац во всю ширину ленты выглядит одинаково с любого края. Текст внутри блока остаётся выровнен по левому краю независимо от стороны — многострочное сообщение с рваным правым краем читается быстрее, чем с рваным левым.
|
||||
- Разделители дат и «новые» — во всю ширину ленты, не по ширине сообщения: они не принадлежат стороне.
|
||||
- Никаких фонов, рамок, скруглений и теней вокруг сообщения — это не бабл, а положение блока.
|
||||
- CSS-класс `feed-list` вместо `grid`: сетки в разметке больше нет, имя не должно об этом врать.
|
||||
|
||||
## Следствия
|
||||
|
||||
- `docs/identity/brief.md`, «Компоновка»: строка про сетку `132px 1fr` заменена на «поток, свои сообщения — у правого края, чужие — у левого, ширина блока ограничена».
|
||||
- `docs/ui.md`, «Чат»: абзац про ленту переписан под общий для обеих ширин экрана поток.
|
||||
- `docs/identity/screens.html` (эталон 2a/2b) обновлён: десктопный мок 2a больше не показывает сетку, оба мока показывают сообщения по сторонам.
|
||||
- Десктопная и мобильная лента визуально сближаются: раньше это были заметно разные раскладки, теперь — одна, с разной шириной блока и отступов. Разница экранов остаётся в сайдбаре, шапке и размере поля ввода (ADR-021, `identity/brief.md`).
|
||||
- Комната с тремя и более участниками: у чужих сообщений сторона всех, кроме себя, одна и та же (левая) — различает их по-прежнему только ник и цвет, сторона тут ничего не добавляет и не мешает.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-066: серверного «перца» для ключевого блоба нет
|
||||
|
||||
Закрывает последний открытый вопрос из `docs/open-questions.md`, поставленный при [ADR-006](006-e2ee-webcrypto.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
Идея: шифровать ключевой блоб ещё раз серверным ключом, который лежит вне базы — например в `/etc/bare/env` рядом с VAPID. Тогда украденный дамп или бэкап сам по себе не даёт материала для оффлайн-перебора паролей. Против оператора это не помогает: у него есть и база, и файл с ключом.
|
||||
|
||||
Вопрос висел открытым с самого начала и мешает считать модель угроз закрытой: пока решения нет, непонятно, чего стоит ждать от следующей версии хранения.
|
||||
|
||||
## Решение
|
||||
|
||||
Перца нет. Блоб хранится так, как описано в [ADR-006](006-e2ee-webcrypto.md) и [ADR-013](013-password-policy-kdf.md): один слой шифрования, ключ выведен из пароля.
|
||||
|
||||
Аргументы:
|
||||
|
||||
- Рядом с базой появляется второй критичный артефакт. [ADR-003](003-sqlite.md) обещает «бэкап — копия одного файла»; перец это обещание ломает. Потеря файла с перцем — невозможность входа с новых устройств сразу для всех, а восстановления паролей в Bare нет by design. Для маленького самохостного инструмента риск потерять ключ при переезде выше риска, который перец закрывает.
|
||||
- Сценарий «утёк бэкап, но не сервер» уже закрыт [ADR-013](013-password-policy-kdf.md): PBKDF2-HMAC-SHA256 от 600 000 итераций и пароль от 12 символов делают перебор дорогим. Словарный пароль перец тоже не спасёт — при компрометации сервера он утекает вместе с базой.
|
||||
- Каждый дополнительный слой — код, который нужно аудировать, и оперативная процедура, которую нужно помнить. Простота важнее.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Стойкость блоба в любом сценарии равна стойкости пароля, как и записано в `docs/threat-model.md`, «Слабый пароль». Новых оговорок в модели угроз не появляется.
|
||||
- Развёртывание остаётся «бинарь плюс файл базы»: ничего, что нельзя потерять, кроме самой базы, у оператора нет. `/etc/bare/env` восстанавливается по `docs/deploy.md`, VAPID-пара перевыпускается.
|
||||
- Решение обратимо: перец можно добавить позже отдельным ADR — он не меняет формат блоба, только оборачивает его.
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Уточнён [ADR-071](071-input-limit-cuts-what-arrives.md): предел держится до вставки и режет приходящее, а не хвост блока.
|
||||
|
||||
Пересматривает [ADR-064](064-composer-not-form.md): там ту же полосу пробовали убрать выносом поля из `<form>`, считая триггером форму. Не помогло — полоса бывает у `input` и `textarea` всегда. Контейнер `div` с `role="form"` и кнопка `type="button"` из ADR-064 сохраняются: событие `submit` в строке ввода больше не участвует.
|
||||
|
||||
## Контекст
|
||||
|
||||
На iPhone при фокусе в строке сообщения над клавиатурой висит системная полоса помощника форм: две стрелки перехода между полями и «готово». Стрелки неактивны — поле на экране одно, переходить некуда, — а полоса занимает место и ломает ощущение приложения, ради которого принят ADR-075.
|
||||
@@ -30,7 +32,7 @@
|
||||
|
||||
- Полоса помощника форм в чате исчезает. На экранах входа и «нового чата» она остаётся — там она и не мешает.
|
||||
- Проверить это можно только на iPhone: Chrome такой полосы не рисует вовсе, и никакой прогон её не увидит.
|
||||
- Строка ввода перестала быть элементом формы. Отправку это не меняет: Enter и раньше обрабатывал клиент, а кнопка «>» остаётся кнопкой отправки формы.
|
||||
- Строка ввода перестала быть элементом формы. Отправку это не меняет: Enter и раньше обрабатывал клиент, а кнопка «>» отправляет по `click` — `type="button"` и обработчик из [ADR-064](064-composer-not-form.md).
|
||||
- В запасном пути (браузер без `plaintext-only`) отмена набранного (cmd+z) не помнит нашей вставки: она делается руками, а не `execCommand` — тот в Chrome разбирает перенос строки в `<div>`, чего в блоке быть не должно. Там же держится `<br>`-заполнитель последней строки: перенос в самом конце браузер не рисует, и без заполнителя курсор оставался бы на прежней строке.
|
||||
- Автозаполнение и менеджеры паролей блока не касаются — в чате им нечего заполнять.
|
||||
- Записано в `docs/ui.md`, «Чат» и «Доступность».
|
||||
|
||||
@@ -41,8 +41,9 @@ font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monos
|
||||
|
||||
## Компоновка
|
||||
|
||||
- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px; сообщения — сетка `132px 1fr`, column-gap 20, row-gap 6.
|
||||
- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px.
|
||||
- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят (ADR-075).
|
||||
- Сообщения — поток на обеих ширинах, автор и время над текстом; свои — у правого края ленты, чужие — у левого, ширина блока не больше `min(640px, 85%)` (ADR-065).
|
||||
- Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева.
|
||||
- Активный элемент списка — инверсия: фон ink, текст bone.
|
||||
- Разделители — 1 px `line`; разделитель «новые» — 1 px mark.
|
||||
|
||||
+15
-26
@@ -23,13 +23,17 @@
|
||||
.item { font-size:13px; padding:6px 8px; color:var(--text2); display:flex; }
|
||||
.item.active { background:var(--ink); color:var(--bone); }
|
||||
.item .n { margin-left:auto; color:var(--mark); }
|
||||
.divider { display:flex; align-items:center; gap:14px; font-size:11px; color:var(--stone); }
|
||||
.divider { display:flex; align-items:center; align-self:stretch; gap:14px; font-size:11px; color:var(--stone); }
|
||||
.divider::before, .divider::after { content:""; flex:1; height:1px; background:var(--line); }
|
||||
.divider.new { color:var(--mark); }
|
||||
.divider.new::before, .divider.new::after { background:var(--mark); }
|
||||
.author { color:var(--mute); font-size:12px; padding-top:2px; }
|
||||
.author { color:var(--mute); font-size:12px; }
|
||||
.author.me { color:var(--mark); }
|
||||
.author .t { color:var(--stone); }
|
||||
/* поток сообщений: свои — у правого края, чужие — у левого (ADR-065) */
|
||||
.msg { display:flex; flex-direction:column; gap:4px; max-width:min(640px, 85%); }
|
||||
.msg.me { align-self:flex-end; align-items:flex-end; }
|
||||
.msg p { margin:0; }
|
||||
.input { display:flex; align-items:center; gap:12px; border:1px solid var(--ink); padding:14px 16px; font-size:14px; }
|
||||
.input .p { color:var(--mark); }
|
||||
.input .caret { width:8px; height:17px; background:var(--ink); }
|
||||
@@ -46,21 +50,17 @@
|
||||
.me i { width:6px; height:6px; background:var(--ink); }
|
||||
.main { display:flex; flex-direction:column; min-width:0; }
|
||||
.head { height:64px; display:flex; align-items:center; gap:14px; padding:0 32px; border-bottom:1px solid var(--line); font-size:15px; }
|
||||
.feed { flex:1; padding:28px 32px; display:flex; flex-direction:column; justify-content:flex-end; overflow:hidden; }
|
||||
.feed { flex:1; padding:28px 32px; display:flex; flex-direction:column; align-items:flex-start; justify-content:flex-end; gap:14px; overflow:hidden; font-size:14px; line-height:1.55; }
|
||||
.feed .divider { padding:10px 0 22px; }
|
||||
.grid { display:grid; grid-template-columns:132px 1fr; column-gap:20px; row-gap:6px; font-size:14px; line-height:1.55; }
|
||||
.grid .divider { grid-column:1 / -1; padding:16px 0; }
|
||||
.compose { padding:0 32px 28px; }
|
||||
|
||||
/* 2b — мобильный */
|
||||
.mobile { width:390px; height:700px; display:flex; flex-direction:column; }
|
||||
.mobile .head { height:56px; padding:0 20px; gap:12px; font-size:14px; }
|
||||
.mobile .mark { width:16px; height:16px; stroke-width:8; }
|
||||
.mobile .feed { padding:20px; gap:14px; }
|
||||
.mobile .feed { padding:20px; font-size:14px; line-height:1.5; }
|
||||
.mobile .feed .divider { padding:0; font-size:10px; }
|
||||
.msg { display:flex; flex-direction:column; gap:4px; }
|
||||
.msg .author { font-size:11px; }
|
||||
.msg p { margin:0; font-size:14px; line-height:1.5; }
|
||||
.mobile .compose { padding:0 16px 20px; }
|
||||
.mobile .input { padding:13px 14px; min-height:44px; gap:10px; }
|
||||
.mobile .input .caret { width:7px; height:16px; }
|
||||
@@ -92,23 +92,12 @@
|
||||
<div class="head">#general</div>
|
||||
<div class="feed">
|
||||
<div class="divider">вторник, 18 августа</div>
|
||||
<div class="grid">
|
||||
<div class="author">marta <span class="t">11:52</span></div>
|
||||
<div>выкатила статику на bare.xmatic.team, кэш чистится сам</div>
|
||||
<div></div>
|
||||
<div>вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит</div>
|
||||
<div class="author">lev <span class="t">11:58</span></div>
|
||||
<div>смотрю network: один html, один css, ноль js до первого сообщения. красиво</div>
|
||||
<div class="author me">kir <span class="t">12:03</span></div>
|
||||
<div>это и есть план. если фича требует бандлер — фича не нужна</div>
|
||||
<div></div>
|
||||
<div>доки пишу прямо в readme, отдельного сайта не будет</div>
|
||||
<div class="divider new">новые</div>
|
||||
<div class="author">marta <span class="t">12:41</span></div>
|
||||
<div>кто-то с hn спрашивает, где мобильное приложение</div>
|
||||
<div class="author">lev <span class="t">12:42</span></div>
|
||||
<div>ответил: браузер и есть приложение</div>
|
||||
</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">11:52</span></div><p>выкатила статику на bare.xmatic.team, кэш чистится сам</p><p>вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">11:58</span></div><p>смотрю network: один html, один css, ноль js до первого сообщения. красиво</p></div>
|
||||
<div class="msg me"><div class="author me">kir <span class="t">12:03</span></div><p>это и есть план. если фича требует бандлер — фича не нужна</p><p>доки пишу прямо в readme, отдельного сайта не будет</p></div>
|
||||
<div class="divider new">новые</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">12:41</span></div><p>кто-то с hn спрашивает, где мобильное приложение</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">12:42</span></div><p>ответил: браузер и есть приложение</p></div>
|
||||
</div>
|
||||
<div class="compose">
|
||||
<div class="input"><span class="p">></span><span>сообщение в #general</span><span class="caret"></span><span class="hint">enter — отправить</span></div>
|
||||
@@ -125,7 +114,7 @@
|
||||
<div class="divider">18 авг</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">11:52</span></div><p>выкатила статику на bare.xmatic.team, кэш чистится сам</p><p>вес страницы — 14 кб</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">11:58</span></div><p>один html, один css, ноль js до первого сообщения. красиво</p></div>
|
||||
<div class="msg"><div class="author me">kir <span class="t">12:03</span></div><p>это и есть план. если фича требует бандлер — фича не нужна</p></div>
|
||||
<div class="msg me"><div class="author me">kir <span class="t">12:03</span></div><p>это и есть план. если фича требует бандлер — фича не нужна</p></div>
|
||||
<div class="divider new">новые</div>
|
||||
<div class="msg"><div class="author">marta <span class="t">12:41</span></div><p>кто-то с hn спрашивает, где мобильное приложение</p></div>
|
||||
<div class="msg"><div class="author">lev <span class="t">12:42</span></div><p>ответил: браузер и есть приложение</p></div>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
Решения по этим пунктам ещё не приняты. Каждое принятое решение уходит в ADR и вычёркивается отсюда.
|
||||
|
||||
- Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено.
|
||||
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
|
||||
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
|
||||
|
||||
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей.
|
||||
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — полоса помощника форм над клавиатурой iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…072 и ADR-073…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей.
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
|
||||
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
|
||||
|
||||
Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
|
||||
Лента: поток блоков сверху вниз, одинаковый на десктопе и мобильном; подряд идущие сообщения одного автора — без повтора автора над группой. Свои сообщения выровнены по правому краю ленты, чужие — по левому (ADR-065); свой ник в колонке автора при этом остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока выровнен по левому краю независимо от стороны. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
|
||||
|
||||
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
|
||||
|
||||
|
||||
+22
-42
@@ -739,19 +739,27 @@ input[type="password"] {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid {
|
||||
.feed-list {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* мобильный: автор над группой сообщений */
|
||||
.feed-list > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* строка сообщения: автор над текстом, блок целиком к своему краю —
|
||||
свои вправо, чужие влево (ADR-065). Ширина блока ограничена, иначе
|
||||
выравнивать нечего — текст в один абзац занял бы всю ленту. */
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: min(640px, 85%);
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -760,8 +768,9 @@ input[type="password"] {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.grid > :first-child {
|
||||
margin-top: 0;
|
||||
.line--me {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.author {
|
||||
@@ -773,6 +782,10 @@ input[type="password"] {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.author:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.author--me {
|
||||
color: var(--mark);
|
||||
}
|
||||
@@ -823,11 +836,14 @@ input[type="password"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* разделители: дата — линией line, «новые» — линией mark */
|
||||
/* разделители: дата — линией line, «новые» — линией mark; во всю ширину
|
||||
ленты, а не по ширине сообщения (feed-list выравнивает по краю только
|
||||
.line, ADR-065) */
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 14px;
|
||||
margin: 14px 0;
|
||||
color: var(--stone);
|
||||
@@ -1012,39 +1028,10 @@ input[type="password"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* сетка «автор 132 px + текст»: строка сообщения раскрывается
|
||||
в две ячейки сетки, поэтому display: contents */
|
||||
|
||||
.feed {
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
column-gap: 20px;
|
||||
row-gap: 6px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.line,
|
||||
.line.is-head {
|
||||
display: contents;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.author {
|
||||
padding-top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.compose {
|
||||
padding: 0 32px calc(28px + env(safe-area-inset-bottom));
|
||||
}
|
||||
@@ -1103,11 +1090,4 @@ input[type="password"] {
|
||||
.input .input__field--text {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* автор стоит над группой, поэтому пустая ячейка автора не занимает
|
||||
места; на десктопе она держит колонку сетки и остаётся */
|
||||
|
||||
.author:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
+19
-10
@@ -83,7 +83,7 @@ export function renderChat(root, ctx, chatId) {
|
||||
root.append(head(view));
|
||||
|
||||
view.feed = el("div", "feed");
|
||||
view.body = el("div", "grid");
|
||||
view.body = el("div", "feed-list");
|
||||
view.body.setAttribute("aria-live", "polite");
|
||||
view.feed.append(view.body);
|
||||
// Прокрутка к верхнему краю берёт следующую страницу (ADR-053).
|
||||
@@ -206,8 +206,8 @@ async function refreshRoom(view, known) {
|
||||
// у input и textarea. Со страницы её не убрать, а в чате она занимает место
|
||||
// и ничего не делает: поле на экране одно, переходить стрелками некуда.
|
||||
function composer(view) {
|
||||
const form = el("form", "compose");
|
||||
form.noValidate = true;
|
||||
const form = el("div", "compose");
|
||||
form.setAttribute("role", "form");
|
||||
|
||||
view.bar = el("p", "bar");
|
||||
view.bar.hidden = true;
|
||||
@@ -238,7 +238,8 @@ function composer(view) {
|
||||
view.counter.hidden = true;
|
||||
|
||||
view.send = el("button", "input__send", ">");
|
||||
view.send.type = "submit";
|
||||
view.send.type = "button";
|
||||
view.send.addEventListener("click", () => submit(view));
|
||||
|
||||
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send);
|
||||
form.append(view.bar, row);
|
||||
@@ -300,11 +301,6 @@ function composer(view) {
|
||||
}
|
||||
});
|
||||
}
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
submit(view);
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
@@ -802,12 +798,25 @@ function line(view, record, previous, parent = view.body) {
|
||||
}
|
||||
// Подряд идущие сообщения одного автора — без повтора автора.
|
||||
const first = marks.length > 0 || !previous || previous.from !== record.from;
|
||||
const node = el("div", first ? "line is-head" : "line");
|
||||
const node = el("div", lineClass(record, view.me, first));
|
||||
node.append(author(view, record, first), text(view, record));
|
||||
parent.append(...marks, node);
|
||||
view.nodes.set(record.id, { node, marks });
|
||||
}
|
||||
|
||||
// lineClass — своя сторона ленты: свои сообщения вправо, чужие влево
|
||||
// (ADR-065), классом на самой строке — выравнивает feed-list.
|
||||
function lineClass(record, me, first) {
|
||||
const classes = ["line"];
|
||||
if (record.from === me) {
|
||||
classes.push("line--me");
|
||||
}
|
||||
if (first) {
|
||||
classes.push("is-head");
|
||||
}
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
// redraw обновляет одну строку на месте: автор и группировка от состояния
|
||||
// сообщения не зависят.
|
||||
function redraw(view, record) {
|
||||
|
||||
Reference in New Issue
Block a user