ADR-065: сообщения по сторонам — свои вправо, чужие влево

Заменяет десктопную сетку «автор 132 px + текст» (часть ADR-024) единым
для десктопа и мобильного потоком: блок сообщения прижат к своему краю
ленты, ширина ограничена, текст внутри блока по-прежнему слева. Разделители
дат и «новые» — во всю ширину. CSS-класс grid переименован в feed-list —
сетки в разметке больше нет.

Проверено вживую (два аккаунта, DM, скриншоты 390 и 1280 px через
Playwright/Chromium) — своя и чужая сторона видны на обоих брейкпоинтах,
перенос длинного текста и разделители работают.

Обновлены ui.md, identity/brief.md и эталонный мок screens.html.
sw.js VERSION → v5 (менялись app.css и js/ui/chat.js, накопительно
с композером из ADR-064 — ни то ни другое ещё не в проде).
This commit is contained in:
Claude
2026-08-24 06:37:57 +00:00
parent 334ce95935
commit 9935acb86d
7 changed files with 84 additions and 73 deletions
@@ -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`).
- Комната с тремя и более участниками: у чужих сообщений сторона всех, кроме себя, одна и та же (левая) — различает их по-прежнему только ник и цвет, сторона тут ничего не добавляет и не мешает.
+2 -1
View File
@@ -39,8 +39,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.
- Сообщения — поток на обеих ширинах, автор и время над текстом; свои — у правого края ленты, чужие — у левого, ширина блока не больше `min(640px, 85%)` (ADR-065).
- Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева.
- Активный элемент списка — инверсия: фон ink, текст bone.
- Разделители — 1 px `line`; разделитель «новые» — 1 px mark.
+15 -26
View File
@@ -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">&gt;</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>
+1 -1
View File
@@ -32,7 +32,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
View File
@@ -650,19 +650,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;
}
@@ -671,8 +679,9 @@ input[type="password"] {
margin-top: 14px;
}
.grid > :first-child {
margin-top: 0;
.line--me {
align-self: flex-end;
align-items: flex-end;
}
.author {
@@ -684,6 +693,10 @@ input[type="password"] {
overflow-wrap: anywhere;
}
.author:empty {
display: none;
}
.author--me {
color: var(--mark);
}
@@ -734,11 +747,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);
@@ -908,39 +924,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 28px;
}
@@ -992,11 +979,4 @@ input[type="password"] {
.enter {
display: none;
}
/* автор стоит над группой, поэтому пустая ячейка автора не занимает
места; на десктопе она держит колонку сетки и остаётся */
.author:empty {
display: none;
}
}
+15 -2
View File
@@ -79,7 +79,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).
@@ -524,12 +524,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) {
+1 -1
View File
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
const VERSION = "v4";
const VERSION = "v5";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный: