`, чего в блоке быть не должно. Там же держится `
`-заполнитель последней строки: перенос в самом конце браузер не рисует, и без заполнителя курсор оставался бы на прежней строке.
- Автозаполнение и менеджеры паролей блока не касаются — в чате им нечего заполнять.
- Записано в `docs/ui.md`, «Чат» и «Доступность».
diff --git a/docs/identity/brief.md b/docs/identity/brief.md
index 89a670a..40d8a22 100644
--- a/docs/identity/brief.md
+++ b/docs/identity/brief.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.
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 @@
>сообщение в #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/open-questions.md b/docs/open-questions.md
index 8b3e929..ace6161 100644
--- a/docs/open-questions.md
+++ b/docs/open-questions.md
@@ -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, строка ввода, запрет масштабирования, код причины отказа пушей.
diff --git a/docs/ui.md b/docs/ui.md
index 8fdc740..61ead22 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -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).
diff --git a/web/app.css b/web/app.css
index 749cae4..b5d6e30 100644
--- a/web/app.css
+++ b/web/app.css
@@ -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;
- }
}
diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js
index c5b24d1..1816492 100644
--- a/web/js/ui/chat.js
+++ b/web/js/ui/chat.js
@@ -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) {
diff --git a/web/sw.js b/web/sw.js
index d9e8161..ca5076d 100644
--- a/web/sw.js
+++ b/web/sw.js
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
-const VERSION = "v5";
+const VERSION = "v6";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный: