diff --git a/docs/decisions/065-message-side-alignment.md b/docs/decisions/065-message-side-alignment.md new file mode 100644 index 0000000..211da2f --- /dev/null +++ b/docs/decisions/065-message-side-alignment.md @@ -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`). +- Комната с тремя и более участниками: у чужих сообщений сторона всех, кроме себя, одна и та же (левая) — различает их по-прежнему только ник и цвет, сторона тут ничего не добавляет и не мешает. diff --git a/docs/identity/brief.md b/docs/identity/brief.md index c42d934..7f1fc74 100644 --- a/docs/identity/brief.md +++ b/docs/identity/brief.md @@ -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. diff --git a/docs/identity/screens.html b/docs/identity/screens.html index a903ed0..54ccbda 100644 --- a/docs/identity/screens.html +++ b/docs/identity/screens.html @@ -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 @@
#general
вторник, 18 августа
-
-
marta 11:52
-
выкатила статику на bare.xmatic.team, кэш чистится сам
-
-
вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит
-
lev 11:58
-
смотрю network: один html, один css, ноль js до первого сообщения. красиво
-
kir 12:03
-
это и есть план. если фича требует бандлер — фича не нужна
-
-
доки пишу прямо в readme, отдельного сайта не будет
-
новые
-
marta 12:41
-
кто-то с hn спрашивает, где мобильное приложение
-
lev 12:42
-
ответил: браузер и есть приложение
-
+
marta 11:52

выкатила статику на bare.xmatic.team, кэш чистится сам

вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит

+
lev 11:58

смотрю network: один html, один css, ноль js до первого сообщения. красиво

+
kir 12:03

это и есть план. если фича требует бандлер — фича не нужна

доки пишу прямо в readme, отдельного сайта не будет

+
новые
+
marta 12:41

кто-то с hn спрашивает, где мобильное приложение

+
lev 12:42

ответил: браузер и есть приложение

>сообщение в #generalenter — отправить
@@ -125,7 +114,7 @@
18 авг
marta 11:52

выкатила статику на bare.xmatic.team, кэш чистится сам

вес страницы — 14 кб

lev 11:58

один html, один css, ноль js до первого сообщения. красиво

-
kir 12:03

это и есть план. если фича требует бандлер — фича не нужна

+
kir 12:03

это и есть план. если фича требует бандлер — фича не нужна

новые
marta 12:41

кто-то с hn спрашивает, где мобильное приложение

lev 12:42

ответил: браузер и есть приложение

diff --git a/docs/ui.md b/docs/ui.md index e8f7e15..f0019d2 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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). diff --git a/web/app.css b/web/app.css index 48ca5d0..d37e0d9 100644 --- a/web/app.css +++ b/web/app.css @@ -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; - } } diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index 2a2ed56..25c96c1 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -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) { diff --git a/web/sw.js b/web/sw.js index 6b98fa8..1f8f0c0 100644 --- a/web/sw.js +++ b/web/sw.js @@ -7,7 +7,7 @@ // Имя кэша содержит версию; версия — константа, она меняется при релизе, // и старые кэши уходят в activate. -const VERSION = "v4"; +const VERSION = "v5"; const CACHE = `bare-${VERSION}`; // Оболочка — всё, из чего клиент поднимается без сети. Список явный: