Дизайн Артёма

This commit is contained in:
AidarKC
2026-09-22 14:09:02 +03:00
parent cae64639bd
commit 8b12760dde
40 changed files with 3114 additions and 5008 deletions
+290
View File
@@ -0,0 +1,290 @@
# SHiNE — дизайн приложения
Версия 0.2 · 22 сентября 2026.
Статус: первый перенос списка каналов, ленты, ветки и общего редактора выполнен в рабочем дереве. Состав изменений, автоматические проверки и оставшаяся визуальная приёмка описаны в [IMPLEMENTATION.md](IMPLEMENTATION.md). Точные размеры и поведение на устройствах остаются целями приёмки, а не утверждением о выполненном тестировании на телефонах.
Этот файл — актуальный источник решений по дизайну. Галерея `channels-v1` остаётся историей четырёх исходных вариантов: она ещё не показывает итоговое сочетание двух тем, компактной шапки и полноэкранного ответа. При расхождении руководствоваться этим документом.
## 1. Задача и границы
Сделать общение понятным с первого открытия. Начинаем со списка каналов, ленты канала, ветки обсуждения и мобильного редактора ответа. Затем распространяем выбранную систему на личные сообщения, связи, уведомления, профиль, поиск, создание каналов и настройки.
Основа — предоставленные пользователем скриншоты Threads и SHiNE. Заимствуем знакомые расположения и сценарии, сохраняя особенности продукта. Этот документ не утверждает новые возможности API и не меняет правила записи, реакции или блокчейна.
Исходные направления представлены в [HTML-галерее](channels-v1/index.html). Выбрана одна система интерфейса с двумя темами: композиция Ночного SHiNE, ночная палитра из него же, дневная палитра из Тёплого SHiNE. Шапка канала компактная по образцу Компактного клуба; редактор ответа полноэкранный, как у Компактного клуба / Чистого монохрома.
Тема меняет цвета, но не геометрию, навигацию, типографику или порядок действий. Из Тёплого SHiNE берём палитру, а не карточную компоновку, увеличенные заголовки Georgia и редактор-лист.
## 2. Принципы для всего приложения
1. Содержимое важнее оформления: текст без свечения, минимум декоративных рамок, спокойные поверхности.
2. Один акцентный цвет обозначает активный раздел, главное действие и выбранное состояние.
3. Главное действие доступно без прокрутки до конца. Его содержание зависит от роли и текущего экрана.
4. Всегда видно, где пользователь находится и кому отвечает. Возврат сохраняет позицию списка или ленты.
5. Редкие и технические действия помещаются в меню, но остаются доступными.
6. Пустота, загрузка и ошибка — полноценные состояния. Ошибка не уничтожает введённый текст.
7. Метки, иконки и расположение действий повторяются во всех разделах.
8. Не выдавать отсутствующую функцию за существующую. В частности, не добавлять репост, GIF-поиск или сортировку по популярности только потому, что они есть в референсе.
## 3. Принятые решения
| Решение пользователя | Правило для реализации |
| --- | --- |
| Основа — Ночной SHiNE | Плоская широкая лента, сдержанные поверхности, единые линейные иконки |
| Дневной и ночной режим сразу | Оба режима обязательны для каждого переносимого компонента и состояния |
| Дневные цвета — из Тёплого | Кремовые поверхности, тёмный тёплый текст, янтарно-коричневый акцент |
| Шапка сообщений — компактнее, как в Клубе | Панель 56 px, без большого описания и декоративного баннера перед лентой |
| Ответ — как у Клуба / Монохрома | Полноэкранный мобильный редактор, короткий контекст, основное место отдано вводу |
| Лайки — общее количество | Один счётчик. Типы лайков и их статистика не входят в первый перенос |
| Статистика позднее, возможно долгим тапом | Возможное будущее взаимодействие, не обязательство реализовать жест сейчас |
| Оглавление используется редко | Только в меню канала, без отдельной кнопки в шапке или под описанием |
| Плотность мокапов подходит | Сохраняем ритм Ночного SHiNE; уплотняем шапку, не всю ленту до варианта Клуба |
Эти решения приняты 22.09.2026 в обсуждении с пользователем. Повторно выбирать направление не требуется.
## 4. Базовые параметры
Ниже единые размеры для обеих тем. Референс плотности — Ночной SHiNE. Не переносить мелкие служебные подписи галереи буквально: текст остаётся читаемым на телефоне. Размеры задавать через общие токены с поддержкой системного увеличения шрифта; фиксированные высоты ниже являются минимальными, если тексту требуется больше места.
| Параметр | Целевое значение |
| --- | --- |
| Основной текст публикации / ответа | 15 px / 23 px; редактор ввода 16 px / 24 px |
| Имя автора | 14 px / 20 px, вес 600 |
| Вторичная информация | 12 px / 18 px |
| Заголовок корневого экрана | 24 px / 30 px, вес 600 |
| Название канала в компактной панели | 17 px / 22 px, вес 600 |
| Заголовок обычной модальной панели / редактора | 16 px / 24 px, вес 600 |
| Шкала отступов | 4 / 8 / 12 / 16 / 24 / 32 px |
| Горизонтальные поля телефона | 16 px; между аватаром и текстом 10–12 px |
| Вертикальный ритм | Публикация: 16 px сверху, 8 px снизу; абзацы: 8–12 px; строка канала: 14 px сверху и снизу |
| Аватар | 36 px в ленте; 48 px в списке; 32 px у ответа; 40 px в редакторе |
| Область нажатия | Не менее 44 × 44 px для всех действий, включая меню, закрытие, лайки и вкладки |
| Иконки | Единая линейная система, 20–24 px, одинаковая толщина штриха |
| Скругления | Поле/файл/кнопка 12 px; медиа 14 px; меню-лист 20 px; полноэкранный ответ без скругления внешнего края |
| Контраст текста | Цель WCAG AA; проверить утверждённую палитру |
| Анимация | 150–200 мс, отключение при reduced motion |
### 4.1. Семантические цвета двух тем
Использовать роли, а не прямые цвета внутри страниц. Имена ниже описывают контракт дизайна; при реализации сопоставить их существующей системе переменных, не создавать параллельную систему токенов. Основная палитра взята из выбранных мокапов; статусные цвета — дополнение спецификации.
| Роль | Ночь | День | Где используется |
| --- | --- | --- | --- |
| `background` | `#101B20` | `#FAF7F0` | Основной фон, шапка и навигация |
| `surface` | `#17272D` | `#FFFDF8` | Поле поиска, меню, карточка файла |
| `surface-selected` | `#213B3A` | `#EFE6D6` | Выбранная вкладка, лёгкое выделение |
| `text-primary` | `#E8F2F1` | `#302C25` | Основной текст |
| `text-secondary` | `#98ADAF` | `#706658` | Время, авторские идентификаторы, подсказки |
| `border-subtle` | `#2A3B40` | `#E6DFD1` | Декоративное разделение строк |
| `border-control` | `#71888A` | `#978B79` | Граница поля, если нужна для распознавания контроля |
| `accent` | `#94E1CE` | `#93511E` | Главное действие, ссылки, активная навигация |
| `on-accent` | `#102C27` | `#FFFAF3` | Текст на залитой акцентом кнопке |
| `focus-ring` | `#94E1CE` | `#93511E` | Контур клавиатурного фокуса |
| `reaction-active` | `#F29AAB` | `#A43350` | Сердце выбранного лайка, не системная ошибка |
| `danger` | `#FFABA8` | `#B13135` | Ошибка, подтверждение удаления |
| `success` | `#94E1CE` | `#346A48` | Успешное действие, с текстовым пояснением |
| `warning` | `#E6C184` | `#845B14` | Предупреждение, с текстовым пояснением |
| `scrim` | `#00000099` | `#302C2566` | Затемнение под меню/диалогом |
Шрифт системный sans-serif в обеих темах: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`. Georgia из тёплого концепта не переносить. Не использовать свечение текста и цветные тени кнопок. Допустима лёгкая тень у всплывающего меню, но не у каждой публикации.
Небольшая коррекция исходной дневной палитры: вторичный текст затемнён с `#7B7264` до `#706658`. Расчёт контраста исходного цвета на основном фоне дал 4.43:1, на выбранной поверхности — 3.83:1. Новый цвет даёт соответственно 5.26:1 и 4.55:1, сохраняя тёплый характер. Это уточнение для читаемости, а не смена выбранной палитры.
`border-subtle` не служит единственным указателем поля или выбранного состояния. Контраст проверять для конкретных пар: обычный текст ≥ 4.5:1; значимые иконки/границы и фокус ≥ 3:1. Не снижать контраст вторичного текста общей прозрачностью контейнера. Disabled обозначать одновременно неактивностью и стилем; ожидаемое действие пояснять при необходимости.
### 4.2. Выбор темы
Рабочее решение для реализации: настройка «Оформление» с пунктами «Как на устройстве», «Дневное», «Ночное». По умолчанию — как на устройстве. Явный выбор сохраняется локально и имеет приоритет; изменение системной темы применяется только в режиме «Как на устройстве». Это проектное допущение, пользователь отдельно не выбирал способ переключения.
Обновление темы не перезагружает экран, не закрывает редактор и не сбрасывает черновик или прокрутку. Выбранный режим применяется до первой видимой отрисовки, без вспышки противоположного фона. Все модальные окна, меню, системные поля и состояния используют ту же тему. Фотографии и аватары не инвертируются. Не нужна постоянная кнопка солнца/луны в тесной шапке канала.
## 5. Список каналов
Верх: заголовок «Каналы», видимая кнопка создания, поиск. Фильтры «Все», «Подписки», «Мои» вынесены из выпадающего заголовка на поверхность.
Строка: аватар, название, автор/идентификатор при необходимости, однострочное превью, время, число непрочитанных. Длинное название сокращается в строке, полное доступно внутри канала. На карточке не нужно одновременно показывать описание, технический путь, общее число сообщений и длинное превью.
Поиск в прототипе фильтрует примеры по названию и автору. В текущем приложении серверный поиск начинается с логина/начала логина автора: полноценный глобальный поиск по названию здесь является предложением, а не обещанием текущего API. При реализации можно сохранить существующую семантику с подсказкой «Найти по @автору».
Нижняя навигация сохраняет пять существующих разделов: «Личные», «Каналы», «Связи», «Уведомления», «Профиль». Иконки приводятся к одной системе. Высота рабочей зоны 64 px плюс нижний safe area; иконка 22 px, подпись 11 px. Активный пункт отмечается акцентом и мягкой подложкой под иконкой. При крупном системном шрифте панель может увеличивать высоту; нельзя уменьшать шрифт, скрывать смысловые подписи или обрезать «Уведомления» ради фиксированной высоты.
Не выделять первую строку автоматически только потому, что она первая. Непрочитанное отмечается счётчиком и более заметным превью, выбранное — фактическим состоянием выбора. Просмотр канала и подписка — разные действия; открытие строки не подписывает пользователя автоматически.
## 6. Канал и сообщения
### 6.1. Компактная шапка
Верхняя панель высотой 56 px плюс верхний safe area: возврат 44 px, растягивающийся блок названия, уведомления 44 px, меню 44 px. Внутри блока — название 17/22 px и вторичная строка `@автор` 12/16 px. Название в одну строку с многоточием; полное название, идентификатор и описание доступны в «О канале». На очень узком экране и при крупном шрифте уведомления переносятся в меню, чтобы сохранить место названию. Название канала открывает информацию о канале.
Под панелью сразу начинается лента. Убрать большой блок описания, декоративный градиентный баннер и постоянно видимый статус «Вы подписаны». Описание и управление подпиской — в меню/информации о канале. Для неподписанного читателя допустима компактная строка «Подписаться» над лентой; после подписки она исчезает. Метка «Новые сообщения» занимает одну строку и появляется только при наличии непрочитанного.
Порядок меню канала: «О канале», «Оглавление», настройка уведомлений, действие подписки/отписки, затем доступные владельцу действия. Пункты показываются по реальным правам. Оглавление не дублируется в шапке, под описанием или в строке действий публикации. При пустом оглавлении показать «В этом канале пока нет оглавления»; создание доступно владельцу только через существующий сценарий.
### 6.2. Публикация
Публикация: автор, время, меню, текст, вложение и действия. Порядок действий — лайк, открыть обсуждение, поделиться; явное «Ответить» открывает редактор. Это разделяет чтение ветки и написание ответа, сейчас они завязаны на разные области карточки.
У лайка сейчас только общий счётчик `likesCount`: не суммировать его повторно с подкатегориями. Один тап ставит/снимает лайк; выбранное состояние — заполненное сердце и `reaction-active`. Повторное действие во время запроса блокируется, при ошибке состояние и число восстанавливаются с коротким пояснением. Нулевой счётчик можно показывать как `0`, одинаково во всех разделах. У кнопки есть доступное название и состояние, например «Нравится, 24» и `aria-pressed`.
Статистику типов не показывать ни на панели, ни в меню первого переноса. Долгий тап по лайкам — отложенная идея пользователя; порог жеста, содержимое и доступную альтернативу согласовать позднее. Если жест появится, ему понадобится явный пункт меню для клавиатуры и пользователей, которые не знают о долгом тапе. Сейчас не добавлять неработающую подсказку или заглушку для этого жеста.
«Данные блокчейна», доступная история, редактирование и удаление помещаются в меню. Редактирование и удаление доступны только для собственных сообщений; удаление требует понятного подтверждения с описанием реального эффекта существующего протокола. Отдельные оценки не возвращаются на панель действий: в коде они намеренно скрыты.
Кнопка публикации закреплена снизу у владельца. У читателя в ленте нет поля, которое обещало бы писать непосредственно в чужой канал. Ответ доступен под публикацией, а на экране ветки — также в нижнем поле. Для неподписанного пользователя нужна заметная подписка, для гостя — корректное приглашение войти перед действием.
Вложения: компактная карточка файла с названием, типом и размером; изображения — в ширину текста с ограничением высоты. У галереи нужен индикатор количества. Реальные превью, загрузка и ошибки файлов проектируются отдельно; изображение в мокапе — встроенная SVG-иллюстрация без внешних ресурсов.
## 7. Ветка обсуждения
Исходное сообщение остаётся наверху, ниже — счётчик и ответы в последовательном порядке. Не предлагать «Популярные» до подтверждения алгоритма и источника данных.
Для вложенных ответов показывать «В ответ …» и короткий контекст. Не уменьшать ширину текстовой колонки бесконечными отступами: после одного уровня показывать связь подписью или открывать подветку, используя имеющуюся маршрутизацию. Тонкая линия допустима только для обозначения реальной связи, не вокруг каждого комментария. Плотность ответов сохраняется близкой к Ночному SHiNE; выбор полноэкранного редактора из Клуба не означает переноса всей его плотности.
Возврат ведёт в тот же канал и на прежнюю позицию. После отправки новый ответ появляется в ветке; при ошибке остаются текст и вложения. Длинные ветки потребуют отдельной проверки пагинации, состояния загрузки и перехода к конкретному ответу.
## 8. Мобильный редактор ответа
Полноэкранный мобильный редактор в обеих темах — принятое решение. Он закрывает нижнюю навигацию и занимает доступную область приложения; не является маленькой центральной модалкой или частично открытым листом. Нет ручки перетаскивания, переключателя разворачивания и декоративного верхнего отступа.
Верхняя панель 56 px: закрытие 44 px слева, заголовок «Ответ» по центру; место справа не заполнять лишними действиями. Ниже — короткий контекст исходного сообщения, собственный аватар, поле и вложения. Нижняя панель: «Прикрепить», счётчик и кнопка «Ответить». Для нового сообщения тот же редактор с заголовком «Новое сообщение» и кнопкой «Опубликовать», без цитаты.
При первом открытии показать адресата и цитату максимум в две строки плюс «Показать целиком» при обрезке. Не повторять весь исходный пост с изображением в редакторе. Для ответа на файл без текста показать название/тип вложения. Раскрытая цитата находится в прокручиваемой области и не вытесняет навсегда поле. Адресат берётся из выбранного сообщения, а не всегда из автора канала.
Поле ввода занимает всё оставшееся свободное место, растёт вместе с текстом в общей прокручиваемой области. Шапка и нижняя панель остаются видимыми. При открытии по нажатию фокус переходит в поле; курсор при восстановлении черновика остаётся в тексте. Системная клавиатура не должна перекрывать отправку: учитывать visual viewport, поворот устройства и безопасные отступы. Не рисовать свою клавиатуру в приложении — иллюстрация есть только в галерее.
Крестик, системный возврат и Escape закрывают редактор с одинаковой политикой черновика. Возврат восстанавливает место в обсуждении и фокус инициирующей кнопки. На desktop редактор может быть центрированной панелью шириной до 640 px и высотой до доступного viewport с теми же тремя зонами; на телефоне всегда полный экран.
Enter создаёт новую строку. Отправка — по явной кнопке; Ctrl/Cmd+Enter можно поддержать как дополнительное desktop-действие с подсказкой. Не отправлять во время IME/composition. В текущем редакторе есть обработчик plain Enter, поэтому это намеренное изменение поведения, которое нужно отдельно учитывать при переносе.
Состояния отправки:
- Пустой текст без вложений: кнопка неактивна.
- Есть текст или вложение: отправка доступна; лимит текста 2000 символов сохранён из текущего редактора ответа.
- Загрузка вложения: прогресс, возможность отмены, запрет отправлять незавершённое вложение.
- Отправка: индикатор, блокировка повторного нажатия.
- Ошибка: понятная причина, повтор, сохранённый текст.
- Успех: новый ответ и восстановленный контекст обсуждения.
- Закрытие: черновик остаётся; очистка по отдельному явному действию, не по Escape.
В исходной HTML-галерее воспроизведены пустое/заполненное поле, лимит, выбранное вложение, закрытие с черновиком и локальная успешная отправка. Загрузка/ошибка сети и прогресс — требования будущей реализации. Счётчик 2000 относится к ответу согласно просмотренному коду; лимиты публикаций и вложений брать из соответствующих существующих валидаторов, не вводить общий лимит для всех сообщений только по этому документу.
Черновик в приложении изолировать ключом «аккаунт + канал + идентификатор исходного сообщения + режим редактора». Никогда не переносить текст ответа на другой пост молча. В первом переносе достаточно сохранения при закрытии/возврате в рамках текущей сессии интерфейса. Переживание перезагрузки/закрытия приложения не обещать, пока не выбраны хранение и политика очистки. При выходе из аккаунта его черновики не показываются другому пользователю.
## 9. Что подтверждено кодом
| Возможность | Где прочитано | Решение макета |
| --- | --- | --- |
| Все / свои / подписки; поиск; создание | `shine-UI/js/pages/channels-list.js`, `render`, `openChannelFinderModal` | Показать поиск и фильтры явно |
| Непрочитанные, превью, автор и время | Там же, построение строк списка | Сохранить иерархию без перегрузки |
| Публикация владельцем | `shine-UI/js/pages/channel-view.js`, ветка `isOwnChannel` | Закреплённое действие только для роли владельца |
| Подписка и оглавление | `channel-view.js` | Сделать доступными в информации о канале |
| Лайки трёх категорий, ответ, отправка ссылки | `channel-view.js`, построение действий сообщения | Только общий счётчик; детализация отложена; ветка отдельно от ответа |
| Технические данные и редактирование | `channel-view.js` | Перенести в меню, учитывать авторство |
| Оценки временно скрыты | Комментарий возле действий сообщения в `channel-view.js` | Не возвращать без решения продукта |
| Ответы и вложения; 2000 символов | `openReplyModal` в `channel-view.js` и `channel-thread-view.js` | Мобильный редактор с контекстом |
Просмотр кода был целевым, не полным аудитом. Права всех типов каналов, статусы пользователей, истории, специальные подтипы и поведение гостя нужно проверить перед реализацией. Переключатель роли в галерее предназначен для оценки размещения действий, не моделирует авторизацию.
## 10. Состояния для следующей итерации
| Область | Нужные дополнительные макеты |
| --- | --- |
| Список | Пустые подписки; первый запуск; загрузка; ошибка; нет результатов |
| Канал | Гость; неподписанный; владелец; пустой канал; недоступный канал; очень длинное название |
| Сообщение | Длинный текст; несколько изображений; файл без текста; удалённое; изменённое; специальный подтип |
| Ветка | Нет ответов; много ответов; вложенная ветка; переход по ссылке |
| Ответ | Длинная цитата; ошибка отправки; загрузка файла; черновик; клавиатура; возврат фокуса |
| Доступность | Клавиатура; screen reader; крупный шрифт; контраст; reduced motion |
## 11. Общие компоненты для единообразия
| Компонент | Единое правило |
| --- | --- |
| TopBar | Общие высота, поля, возврат и меню. Большой заголовок только у корневого экрана, компактный у вложенного |
| Primary button | Заливка `accent`, текст `on-accent`, высота 44 px, радиус 12 px; одно главное действие в области |
| Secondary button | Поверхность/контур, без конкуренции с primary; тот же размер текста и зона нажатия |
| Icon button | Иконка 22 px внутри зоны 44 px, доступное название; одинаковые pressed/focus/disabled |
| Field | Метка по смыслу, текст 16 px, вспомогательная подпись и ошибка под полем; placeholder не заменяет метку |
| Search | Небольшое поле с лупой; очистка видна при непустом запросе; отдельно загрузка, отсутствие результатов и ошибка |
| Tabs | Одинаковый selected-стиль, минимум 44 px по высоте; не создавать страницу со своей системой вкладок |
| Avatar | Круглый, устойчивый размер; fallback с инициалами; выбранная тема не перекрашивает фотографию |
| List row | Аватар/иконка → основной и вторичный текст → время/счётчик; вся строка открывает объект |
| Overflow menu | Те же отступы и строки 44 px; на телефоне допустим общий лист, на desktop привязка к кнопке |
| Attachment | Единая карточка имени/типа/размера, отдельные состояния загрузки и ошибки; медиа не растягивает страницу по ширине |
| Empty state | Короткий заголовок, причина/следующий шаг, одно уместное действие; без большого декоративного баннера |
| Inline error | Возле неудавшегося действия, понятный текст и повтор; технические подробности раскрываются отдельно |
| Toast | Короткое подтверждение без перехвата фокуса; не единственный способ сообщить о блокирующей ошибке |
| Confirmation | Называет действие и объект; основной акцент для обычного действия, `danger` для удаления |
| Fullscreen editor | Один визуальный контракт публикации/ответа/редактирования с вариантами заголовка и контекста |
Каждый компонент определяет normal, hover для указателя, pressed, focus-visible, disabled, loading и error, если они применимы. Selected и pressed различать: selected сохраняется после нажатия, pressed — краткий отклик. Выбор и ошибку не кодировать только цветом.
Для клавиатуры соблюдать логичный порядок фокуса; у диалогов — семантика dialog, фокус внутри и возврат инициатору. Screen reader получает названия и состояния переключателей, общий счётчик лайков и информацию об отправке. Обновление ленты не должно каждый раз перехватывать фокус или зачитывать весь экран.
### 11.1. Общая адаптация
Телефоны 320–430 CSS px: одна колонка, боковые поля 16 px, никакого горизонтального скролла страницы. Горизонтальная прокрутка допустима только внутри явно обозначенной галереи вложений. Длинные слова/ссылки переносятся; имена и заголовки сокращаются только там, где предусмотрен доступ к полному тексту.
При ширине 600 px и выше текстовая колонка не шире 640 px, центрируется; верхняя панель, лента и composer выровнены по её краям. Desktop-навигация использует существующий shell. Новую многоколоночную архитектуру приложения этот документ не требует.
Safe area учитывается ровно один раз владельцем оболочки. При открытом редакторе нет двойной нижней панели. При увеличении текста до 200% допускается рост высот; кнопки не накладываются, действия не исчезают. Анимация открытия 150–200 мс; при reduced motion переход без движения.
### 11.2. Применение к остальным разделам
| Раздел | Что наследует | Что требует отдельного проектирования |
| --- | --- | --- |
| Личные сообщения | Палитры, текст, аватары, строки списка, вложения, поля и меню | Геометрия диалога, входящие/исходящие, статусы доставки/прочтения, звонки |
| Связи | Строки людей, профильные аватары, кнопки, пустые состояния | Запросы, типы отношений и правила доступности |
| Уведомления | Строки, типографика, счётчики, непрочитанное | Группировка, переход к объекту, состояния удалённого объекта |
| Профиль | TopBar, разделители, поля, кнопки и обе темы | Состав профиля, редактирование, настройки оформления |
| Вход/регистрация | Цвета, поля, ошибки и focus-стили | Чувствительные сценарии, последовательность шагов, тексты восстановления |
| Создание/настройка канала | Общие формы, редакторы, меню и подтверждения | Права, типы канала и специфические настройки |
Наследование визуальной системы не означает изменения протоколов или применения ленты каналов к личному диалогу. Каждый следующий раздел добавляет свои сценарии в отдельный документ со ссылкой на эту основу, а не копирует собственные палитры и наборы кнопок.
## 12. Допущения и отложенные решения
Блокирующих вопросов для документа нет. Рабочие решения: тема по системе с ручным выбором, размеры из раздела 4, сохранение черновика внутри текущей сессии, единый редактор с адаптацией для desktop.
До переноса проверить по коду матрицу прав всех типов каналов, доступность ответа для гостя/неподписанного, ограничения вложений и существующие переменные темы. Если для предложения нужны новые серверные данные, явно отметить зависимость и сохранить доступный сценарий; не расширять API автоматически.
Позднее: статистика типов лайков и долгий тап; глобальный поиск по названию; постоянные черновики; подробные сценарии остальных разделов. Эти пункты не являются задачами текущего переноса.
## 13. Перенос в приложение и критерии готовности
На следующем этапе использовать существующие AppShell/TopBar/Dropdown/Avatar/Composer. Не копировать автономный JS из мокапа в production: он работает на демонстрационных данных и не реализует lifecycle приложения.
Последовательность: сопоставить текущие токены и владельцев компонентов → подготовить обе палитры → реализовать общие варианты компонентов → перенести список/канал/ветку/ответ → закрыть состояния из раздела 10 → проверить устройства и темы → расширять на другие разделы по отдельным задачам. Перед реализацией удобно обновить автономный мокап выбранного сочетания; исходную галерею сохранять как историю выбора.
Матрица приёмки для первого переноса:
| Проверка | Ожидаемый результат |
| --- | --- |
| День ↔ ночь на каждом экране | Геометрия совпадает; нет чужих тёмных/светлых элементов и вспышки при старте |
| Переключение темы с открытым ответом | Текст, вложения, адресат, фокус и позиция не сбрасываются |
| Шапка канала | Компактная; нет баннера описания и кнопки оглавления вне меню |
| Роль читателя/владельца/гостя | Только доступные действия; нет ложного обещания писать в чужой канал |
| Лайки | Один общий счётчик; постановка/снятие; защита от повторного запроса; восстановление после ошибки |
| Оглавление | Доступно через меню, включая пустое состояние |
| Ответ на пост и ответ на комментарий | Верный адресат и цитата; полноэкранный редактор; отправка видна над клавиатурой |
| Длинный ответ и Enter | Перенос строки без отправки; поле и курсор доступны при прокрутке |
| Черновик | Закрытие/возврат сохраняет текст в текущей сессии; другой адресат не получает чужой текст |
| Вложения | Текст или вложение; прогресс/ошибка; отправка незавершённого файла недоступна |
| Навигация | Возврат сохраняет позицию; отправленный ответ виден; обновление не пересоздаёт страницу целиком |
| Загрузка/ошибка/пустота | Понятные отдельные состояния; введённые данные не теряются |
| 320 / 390 / 430 px, большой экран, 200% текст | Нет наложений и горизонтальной прокрутки страницы; все действия достижимы |
| Android / iOS, экранная клавиатура, поворот | Поле и отправка доступны; safe area не дублируется |
| Клавиатура / screen reader / reduced motion | Фокус видим и возвращается; семантика доступна; движение отключается |
Все четыре экрана проверяются в обеих темах, включая меню, редактор, ошибку и пустое состояние. Тесты поведения не заменяют визуальную проверку на телефоне.
Документ служит общим справочником приложения. Новые разделы описывают сценарии, владельцев компонентов, состояния и принятые решения, сохраняя принципы разделов 2, 4 и 11. Обнаруженные при реализации ограничения записывать как явные отклонения с причиной, а не незаметно создавать новый стиль страницы.
## 14. Журнал решений
| Дата | Версия | Решение |
| --- | --- | --- |
| 22.09.2026 | 0.1 | Четыре самостоятельных HTML-направления для обсуждения |
| 22.09.2026 | 0.2 | Пользователь выбрал Ночной SHiNE, дневную тёплую палитру, компактную шапку, полноэкранный ответ; общее число лайков, оглавление в меню; плотность принята |
| 22.09.2026 | 0.2 | Специфицированы семантические цвета двух тем, общие компоненты, состояния, адаптация, правила расширения и матрица приёмки. Это документация будущего переноса, не выполненное изменение приложения |
+46
View File
@@ -0,0 +1,46 @@
# Первый перенос дизайна каналов
22 сентября 2026. Изменения в рабочем дереве; без коммита и деплоя. Основа — [DESIGN.md](DESIGN.md), версия 0.2.
## Реализовано
- Ночная и дневная семантические палитры; системный режим по умолчанию, ручной выбор в настройках, применение до первой отрисовки. Смена темы не перемонтирует страницу.
- Список каналов: плоские строки, превью, время и непрочитанное; видимые фильтры «Все / Подписки / Мои», создание и поиск. Поле фильтрует загруженный список; «По @автору» открывает существующий серверный поиск.
- Лента: компактная шапка, оглавление в меню, плоские публикации, общий лайк без промежуточного диалога, отдельные обсуждение и ответ. Технические данные, история и собственные редактирование/удаление — в меню. Удаление подтверждается с предупреждением о сохранении истории в блокчейне.
- Ветка: единый вид публикаций и ответов, контекст вложенного ответа без растущих горизонтальных отступов. Порядок данных и существующие пределы загрузки не менялись.
- Общий редактор публикации/ответа/редактирования: полный мобильный экран, desktop-панель до 640 px, три зоны, VisualViewport и safe area. Контекст ответа, растущее поле, вложения, счётчик, отправка, ошибка и очистка черновика. Enter — новая строка, Ctrl/Cmd+Enter — отправка, IME защищён.
- Черновики в памяти текущей вкладки, изолированные по аккаунту, цели и режиму. Для ответа цель — полная ссылка на сообщение (blockchain/номер/hash); один и тот же ответ восстанавливается из ленты и ветки. Тип новой публикации сохраняется вместе с текстом. После перезагрузки черновики не обещаются.
- Закреплённое действие публикации у владельца; закреплённый ответ в ветке. Гостевые действия обозначают необходимость входа. Позиции чтения сохраняются внутри текущей вкладки отдельно по аккаунту и экрану.
- Общие TopBar, Dropdown, Tabs, Avatar и Toolbar приведены к выбранному направлению. Стили общей галереи вложений перенесены из `features/chat.css` к владельцу `components/attachments.css`; логика DM и формат вложений не менялись.
## Проверено автоматически
Проверка: `shine-UI/channel-design-check.mjs`. Она исполняет настоящий DOM producer страниц и компонентов в JSDOM; транспорт/API изолированы, тест ничего не публикует.
- Редактор: пустое состояние, Enter и Ctrl+Enter, IME, фокус/Tab/возврат инициатору, восстановление черновика, разделение аккаунтов, очистка после успеха, сохранение после ошибки, вложение, системный возврат и гостевой вход.
- Карточки канала/ветки: текст, общий лайк, контекст ответа, меню по авторству, повторный cleanup.
- Список/канал/ветка: успешная пустая загрузка, сохранение корневого элемента при refresh, идемпотентный cleanup, отсутствие DOM-изменений от позднего ответа API после dispose.
- Темы, сохранение позиции чтения, навигация Dropdown клавишами и возврат фокуса.
- `node --check` изменённых production JS, разрешение относительных импортов всех 135 JS, парсинг 17 изменённых CSS, отсутствие новых повторов селекторов внутри одного CSS-контекста и роста `!important`, корректность CSS manifest, `git diff --check`.
- Расчёт контраста токенов: основной текст/фон 15.34:1 ночью и 12.98:1 днём; вторичный текст/фон 7.45:1 и 5.26:1; вторичный текст/поверхность 6.55:1 и 5.54:1; текст основной кнопки/акцент 9.86:1 и 5.88:1. Это расчёт палитры, не полный аудит итогового CSS-cascade.
Воспроизводимый запуск из корня продукта (Node 24):
```bash
check_dir=$(mktemp -d)
npm install --prefix "$check_dir" --no-audit --no-fund --ignore-scripts jsdom postcss
SHINE_UI_TEST_DEPS="$check_dir/node_modules" node --experimental-vm-modules shine-UI/channel-design-check.mjs
git diff --check
```
Тестовые зависимости устанавливаются вне репозитория, в runtime приложения не добавляются.
## Границы и оставшаяся приёмка
API, формат блоков и операции записи не изменены. Категории лайков не выведены в интерфейс. Глобальный поиск по названию и фиктивный переключатель уведомлений канала не добавлены: рабочий серверный контракт для них не введён.
Используется существующий менеджер загрузок Arweave с его статусами/ошибками. Пока менеджер открыт, редактор блокирует отправку незавершённого вложения. Закрытие редактора закрывает менеджер, но не гарантирует отмену уже переданного транспортному слою upload-запроса; новая транспортная отмена и процент прогресса не заявляются.
Подключённого браузера в данной сессии нет: встроенный browser-инструмент вернул пустой список. Поэтому **визуальная приёмка не выполнена**. DOM-тесты не подтверждают реальные размеры, CSS-cascade, экранную клавиатуру, screen reader или сетевую публикацию. До выпуска необходимо пройти матрицу раздела 13 DESIGN.md: обе темы, 320/390/430 px и desktop, 200% текст, длинные сообщения/имена, вложения, Android/iOS, поворот, safe area, ошибки реального сервера и восстановление фокуса после фактического обновления ленты. Также проверить соседние экраны, использующие изменённые общие компоненты.
Локальный запуск приложения по правилам проекта: `./gradlew startLocal`. Production не затрагивался.
+41
View File
@@ -0,0 +1,41 @@
# Дизайн SHiNE
Статус: направление выбрано, спецификация 0.2 от 22.09.2026. Основа — Ночной SHiNE; дневная тема — тёплая палитра; компактная шапка канала; полноэкранный ответ. У лайков только общий счётчик, оглавление в меню.
- [Общий дизайн-документ](DESIGN.md) — актуальная спецификация выбранного стиля, обе темы, общие компоненты, сценарии и критерии приёмки.
- [Открыть исходную HTML-галерею](channels-v1/index.html) — четыре варианта, четыре экрана в каждом; история выбора.
Галерея сохранена без изменений и ещё не отражает итоговое сочетание решений. Для последующего переноса источником истины служит `DESIGN.md`, а не детали одного старого варианта. Код приложения не менялся.
## Как посмотреть
Откройте `channels-v1/index.html` в обычном браузере. Сервер, сборка, вход в аккаунт и интернет не нужны. Стили и JavaScript лежат рядом, поэтому при передаче макетов сохраняйте всю папку `channels-v1`.
Вверху переключаются экраны всех вариантов одновременно. Стрелка возле названия открывает один вариант. На узком экране галерея выстраивается вертикально.
Отдельные варианты:
1. [Чистый монохром](channels-v1/index.html?variant=mono).
2. [Тёплый SHiNE](channels-v1/index.html?variant=warm).
3. [Ночной SHiNE](channels-v1/index.html?variant=night).
4. [Компактный клуб](channels-v1/index.html?variant=club).
## Что попробовать
1. Нажать на канал и открыть обсуждение через значок комментариев.
2. Ответить на исходное сообщение или конкретному собеседнику.
3. Набрать ответ, закрыть редактор, открыть его снова: черновик сохраняется в памяти страницы.
4. Прикрепить файл, удалить вложение, отправить текст или только вложение.
5. Включить «Макет клавиатуры», чтобы оценить оставшееся место. Это иллюстрация, настоящая клавиатура телефона работает отдельно.
6. Переключить роль на владельца: в канале появляется постоянная кнопка публикации.
7. Проверить поиск и фильтры списка; открыть оглавление и меню сообщения.
Все действия демонстрационные. Выбранный файл не читается и никуда не загружается: отображается только имя. После перезагрузки страницы демонстрационные сообщения, реакции и черновики исчезают. Уведомления, подписка и поиск не обращаются к API. Другие разделы нижней навигации показывают пояснение о границах макета.
## Границы
Макеты намеренно изолированы от `shine-UI`: они не импортируют приложение и не участвуют в его сборке. Здесь допускаются экспериментальные компоненты; при переносе утверждённого дизайна нужно использовать существующие владельцы AppShell, TopBar, Dropdown, Avatar и Composer.
## Проверка
Результаты проверки и ограничения приведены в [VALIDATION.md](VALIDATION.md). Визуальная проверка в браузере в текущей сессии недоступна: подключённых браузеров нет. Это рабочие HTML-макеты для обсуждения, не проверенная на устройствах реализация приложения.
+35
View File
@@ -0,0 +1,35 @@
# Проверка HTML-мокапов
Дата: 22.09.2026.
## Выполнено
- `node --check channels-v1/mockups.js` — синтаксис JavaScript корректен.
- CSS разобран PostCSS без ошибок синтаксиса.
- В изолированном jsdom проверены все четыре направления: начальная отрисовка, поиск и отсутствие результатов, фильтр «Мои», открытие канала, роли читателя/владельца, переключение лайка, открытие ветки, ответ конкретному участнику, неактивная пустая отправка, сохранение черновика при закрытии, успешная отправка, независимый контекст второй публикации.
- Проверено, что HTML-подобный текст ответа отображается как текст, без создания пользовательских HTML-элементов.
- Проверены выбор/удаление вложения и возможность отправить вложение без текста, переключение макета клавиатуры, Escape и снятие `inert` с основного содержимого.
- Проверены публикация владельца и её сохранение при переключении роли, отдельное открытие каждого из четырёх вариантов через `?variant=`.
- После последних изменений повторены синтаксическая проверка и проверки взаимодействий — успешно.
- Git показывает только новую папку `docs/UI-Design/`. Существующие файлы приложения не изменялись.
Инструменты DOM-проверки и временный сценарий установлены в `/tmp/shine-ui-review-gporxy`, зависимости приложения не менялись.
## Ограничения проверки
Подключённый Browser недоступен: runtime сообщил `No browser is available`, список браузеров пуст. Скриншоты не получены. jsdom проверяет структуру и поведение, но не рассчитывает реальную геометрию и не заменяет визуальную проверку браузером.
Перед переносом в приложение обязательна проверка в реальном браузере: ширины 320/390/430 px, увеличение текста, длинные названия, прокрутка и восстановление позиции, экранная клавиатура Android/iOS, фокус и screen reader, контраст, состояния загрузки и ошибки. Эмуляция клавиатуры в галерее показывает только занятое ею место.
Никакие серверные операции, публикации, загрузки файлов или изменения production не выполнялись.
## Уточнение документа 0.2
22.09.2026, после выбора пользователем направления:
- Обновлены `DESIGN.md` и `README.md`; HTML-галерея оставлена историческим референсом, это явно отмечено в обоих документах.
- Убраны устаревшие вопросы о выборе темы/редактора и указания показывать статистику лайков или оглавление вне меню.
- Добавлены единые компоненты, цвета двух тем, компактная шапка, полноэкранный редактор, состояния и матрица приёмки будущего переноса.
- Локально рассчитан контраст основных пар цветов. Основной текст/фон: ночь 15.34:1, день 12.98:1. Вторичный текст/фон: ночь 7.45:1, день после коррекции 5.26:1. Вторичный текст/выбранная поверхность: ночь 5.10:1, день 4.55:1. Текст акцентной кнопки: ночь 9.86:1, день 5.88:1. Граница контрола/поверхность: ночь 4.10:1, день 3.29:1.
- Эти числа относятся к непрозрачным цветам токенов. Проверка реальных CSS-состояний, прозрачностей, наложений, всех статусных цветов и геометрии потребуется при реализации; полного аудита доступности пока нет.
- Изменения документа не означают обновления приложения или визуальной проверки итогового сочетания.
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHiNE — четыре направления UI</title>
<link rel="stylesheet" href="mockups.css">
<script src="mockups.js" defer></script>
</head>
<body>
<header class="studio-header">
<a class="brand" href="index.html" aria-label="SHiNE — все варианты"><span class="brand-spark"></span> SHiNE<span class="brand-caption">DESIGN STUDY / 01</span></a>
<span class="draft-label">Концепции · сентябрь 2026</span>
</header>
<main>
<section class="intro">
<div><div class="eyebrow">КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ</div><h1>Ближе к разговору.</h1><p>Четыре характера. Один привычный путь от канала к ответу.<br>Выберите экран и сравните — или нажмите на элементы внутри макета.</p></div>
<div class="intro-note"><span>01—04</span><p>Меньше визуального шума.<br>Больше места людям и мыслям.</p></div>
</section>
<section class="review-bar" aria-label="Управление просмотром">
<div class="screen-switch" role="group" aria-label="Экран для всех вариантов">
<button class="review-button selected" data-screen="list" aria-pressed="true">01 Каналы</button>
<button class="review-button" data-screen="feed" aria-pressed="false">02 Сообщения</button>
<button class="review-button" data-screen="thread" aria-pressed="false">03 Ветка</button>
<button class="review-button" data-screen="reply" aria-pressed="false">04 Ответ</button>
</div>
<div class="review-options"><label>Роль <select id="role"><option value="reader">Читатель</option><option value="owner">Владелец</option></select></label><label class="keyboard-option"><input type="checkbox" id="keyboard"> Макет клавиатуры</label><a class="compare-link" href="index.html">Все варианты ↗</a></div>
</section>
<div id="gallery" class="gallery"></div>
<section class="decision-note"><span class="eyebrow">С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ</span><h2>Сначала удобство. Затем характер.</h2><div class="decision-grid"><p><b>01 / Читаемость</b>Где комфортнее читать длинный пост и замечать новый ответ?</p><p><b>02 / Плотность</b>Нужны просторные сообщения или больше содержимого на одном экране?</p><p><b>03 / Окно ответа</b>Полный экран или лист поверх ветки? Проверьте с макетом клавиатуры.</p></div><p class="disclaimer">Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. <a href="../DESIGN.md">Дизайн-документ ↗</a></p></section>
</main>
<footer class="studio-footer"><span>SHiNE / пространство для общения</span><span>Рабочий материал · не финальный дизайн</span></footer>
</body>
</html>
+212
View File
@@ -0,0 +1,212 @@
* { box-sizing: border-box; }
:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; }
body { margin: 0; }
button, input, textarea, select { font: inherit; }
button, a, select { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .4; }
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; }
a { color: inherit; }
.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; }
.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
.brand-spark { font-size: 36px; font-weight: 400; }
.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; }
.draft-label { font-size: 12px; color: #63675f; }
main { padding: 0 42px; max-width: 1900px; margin: auto; }
.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; }
.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; }
h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; }
.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; }
.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; }
.intro-note > span { font-size: 38px; letter-spacing: -2px; }
.intro-note p { font-size: 12px; margin-top: 10px; }
.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; }
.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; }
.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; }
.review-button.selected { background: #292d27; color: white; }
.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; }
.review-options label { display: flex; align-items: center; gap: 6px; }
.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; }
.compare-link { display: none; }
.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; }
.concept { min-width: 0; }
.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; }
.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; }
.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; }
.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; }
.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; }
.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; }
.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; }
.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; }
.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; }
.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; }
.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; }
.status-icons .icon { height: 16px; width: 16px; }
.phone button { color: inherit; }
.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; }
.icon-btn:hover { background: var(--soft); }
.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); }
.header-text { flex: 1; min-width: 0; }
.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header-text small { color: var(--muted); font-size: 10px; }
.list-header { padding-left: 22px; }
.list-header h3 { font-size: 25px; letter-spacing: -.9px; }
.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; }
.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; }
.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); }
.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; }
.search-field input::placeholder { color: var(--muted); }
.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; }
.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); }
.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); }
.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); }
.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; }
.channel-row:hover { background: var(--soft); }
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; }
.avatar.city { background: #ded3bb; color: #635039; }
.avatar.blue { background: #bac5d9; color: #354663; }
.avatar.rose { background: #d9bcbb; color: #704444; }
.avatar.green { background: #b9cdc0; color: #345747; }
.avatar.violet { background: #cbc2d9; color: #56476c; }
.avatar.self { background: var(--soft); color: var(--accent); }
.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; }
.channel-copy { min-width: 0; flex: 1; }
.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; }
.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; }
.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; }
.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; }
.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); }
.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; }
.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); }
.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); }
.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); }
.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; }
.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; }
.text-button .icon { width: 16px; height: 16px; }
.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; }
.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; }
.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); }
.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); }
.post-header { display: flex; align-items: center; gap: 9px; }
.post-header .avatar { width: 34px; height: 34px; font-size: 11px; }
.author { min-width: 0; flex: 1; font-size: 12px; }
.author strong { font-weight: 650; }
.author small { color: var(--muted); font-size: 10px; display: block; }
.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); }
.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; }
.post-title { font-weight: 600; }
.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; }
.landscape svg { width: 100%; height: 100%; display: block; }
.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; }
.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); }
.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); }
.action[aria-pressed="true"] { color: #dc7477; }
.action[aria-pressed="true"] .icon { fill: currentColor; }
.reply-link { margin-left: auto; font-size: 10px; }
.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; }
.attachment > .icon { color: var(--accent); }
.attachment strong { font-size: 11px; display: block; }
.attachment small { color: var(--muted); display: block; font-size: 10px; }
.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; }
.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; }
.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; }
.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); }
.thread-heading strong { color: var(--text); }
.reply { display: flex; padding: 16px 18px 0; gap: 10px; }
.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); }
.reply .avatar { width: 32px; height: 32px; font-size: 10px; }
.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; }
.reply small { color: var(--muted); font-size: 10px; }
.reply strong { font-size: 12px; }
.reply .post-actions { gap: 16px; }
.reply-context { color: var(--accent); font-size: 10px; }
.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; }
.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; }
.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; }
.sheet.full { height: 100%; border-radius: 0; }
.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; }
.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; }
.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; }
.sheet-header .expand { color: var(--muted); }
.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; }
.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; }
.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); }
.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; }
.source-preview strong { font-size: 12px; }
.source-preview small { color: var(--muted); font-size: 10px; }
.editor-row { display: flex; gap: 10px; }
.editor-main { flex: 1; min-width: 0; }
.editor-main strong { display: block; font-size: 12px; margin: 2px 0; }
.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; }
.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; }
.editor-input::placeholder { color: var(--muted); }
.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); }
.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); }
.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; }
.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; }
.phone .submit { color: var(--on-accent); }
.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; }
.editor-attachment span { flex: 1; overflow-wrap: anywhere; }
.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); }
.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; }
.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; }
.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; }
.keys .space { max-width: 150px; flex: 5; font-size: 10px; }
.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; }
.menu-sheet { min-height: 0; padding-bottom: 24px; }
.menu-content { padding: 10px 20px; }
.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); }
.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; }
.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; }
.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; }
.concept-notes strong { color: #33392f; font-weight: 600; }
.swatches { display: flex; gap: 5px; margin-bottom: 12px; }
.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; }
.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; }
.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; }
.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; }
.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; }
.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; }
.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; }
.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; }
.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; }
/* Варианты отличаются также плотностью, иерархией и формой редактора. */
.phone[data-theme="mono"] .post { padding-left: 18px; }
.phone[data-theme="mono"] .post-content { margin-left: 43px; }
.phone[data-theme="mono"] .landscape { height: 128px; }
.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); }
.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; }
.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; }
.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; }
.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; }
.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; }
.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; }
.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); }
.phone[data-theme="night"] .composer-trigger { border-radius: 13px; }
.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; }
.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; }
.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; }
.phone[data-theme="club"] .channel-copy small { display: none; }
.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; }
.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; }
.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); }
.phone[data-theme="club"] .post { padding: 12px 16px 5px; }
.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; }
.phone[data-theme="club"] .landscape { height: 108px; }
.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; }
.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; }
.phone[data-theme="club"] .reply { padding-top: 12px; }
.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; }
.phone[data-theme="club"] .composer-trigger { border-radius: 10px; }
.gallery.single { display: block; max-width: 410px; margin: auto; }
.single .concept-description { min-height: 0; }
body.focused .compare-link { display: inline; }
@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } }
@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } }
@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } }
@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } }
+246
View File
@@ -0,0 +1,246 @@
/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */
'use strict';
const icons = {
back: '<path d="m14 5-7 7 7 7M7 12h14"/>',
close: '<path d="m6 6 12 12M6 18 18 6"/>',
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
heart: '<path d="M20.8 4.8a5.5 5.5 0 0 0-7.8 0L12 6l-1.1-1.2a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.4a5.5 5.5 0 0 0 0-7.8Z"/>',
chat: '<path d="M21 11.5a9 9 0 0 1-9 9 9.7 9.7 0 0 1-4-.9L3 21l1.4-4.7A9 9 0 1 1 21 11.5Z"/>',
share: '<path d="m21 3-7 18-4-8-8-3 19-7ZM10 13 21 3"/>',
channels: '<rect x="3" y="4" width="18" height="13" rx="3"/><path d="m8 21 4-4 4 4M7 8h10M7 12h6"/>',
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 5"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
user: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
book: '<path d="M12 5v16M3 4c4-1 6-1 9 1 3-2 5-2 9-1v15c-4-1-6-1-9 2-3-3-5-3-9-2Z"/>',
file: '<path d="M14 2H5v20h14V7l-5-5ZM14 2v6h5M8 13h8M8 17h5"/>',
attach: '<path d="m9 17 9-9a3 3 0 0 0-4-4L4 14a5 5 0 0 0 7 7L21 11M7 14l8-8"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="8" cy="8" r="1"/><path d="m3 17 6-6 4 4 3-3 5 5"/>',
expand: '<path d="M14 3h7v7M21 3l-7 7M10 21H3v-7M3 21l7-7"/>',
check: '<path d="m5 12 4 4L19 6"/>',
edit: '<path d="m15 4 5 5M4 20l5-1L21 7a2 2 0 0 0-4-4L5 15l-1 5Z"/>',
wifi: '<path d="M3 8a15 15 0 0 1 18 0M6 12a10 10 0 0 1 12 0M9 16a5 5 0 0 1 6 0M12 20h.01"/>',
battery: '<rect x="2" y="6" width="18" height="12" rx="2"/><path d="M23 10v4M5 9h11v6H5Z"/>',
};
const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.chat}</svg>`;
const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;'}[c]));
const button = (action, label, name, extra = '') => `<button class="icon-btn" data-action="${action}" aria-label="${label}" title="${label}" ${extra}>${icon(name)}</button>`;
const concepts = [
{ id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. <strong>Самая нейтральная отправная точка.</strong>' },
{ id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается <strong>округлым листом снизу.</strong>' },
{ id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. <strong>Мой кандидат на развитие:</strong> узнаваемый, но сдержанный.' },
{ id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор <strong>на весь экран</strong>, без потери контекста.' },
];
const channels = [
{ title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true },
{ title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true },
{ title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true },
{ title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true },
{ title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false },
{ title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false },
];
let role = 'reader';
let showKeyboard = false;
const states = new Map();
const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?';
const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!';
const landscape = () => `<div class="landscape" role="img" aria-label="Иллюстрация: тихая река, холмы и солнце"><svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice"><rect width="400" height="180" fill="#cad8d0"/><circle cx="290" cy="46" r="24" fill="#f5e7b5"/><path d="M0 87Q65 35 136 79T270 70T400 61V180H0" fill="#8faaa0"/><path d="M0 125Q73 67 155 102T310 86T400 101V180H0" fill="#527b70"/><path d="M0 153Q98 104 184 130T400 110V180H0" fill="#31594f"/><path d="M216 100Q105 130 236 145T201 180H311Q347 151 239 137T242 101Z" fill="#c2d2ba"/><path d="M32 138V59m0 4-15 30h30ZM361 129V47m0 4-18 34h36Z" fill="#30534a" stroke="#30534a" stroke-width="4"/></svg><span class="media-caption">У реки / 18:42</span></div>`;
function header(s) {
if (s.screen === 'list') return `<header class="app-header list-header"><span class="mini-brand">✳</span><div class="header-text"><h3>Каналы</h3></div>${button('new-channel','Создать канал','plus')}</header>`;
return `<header class="app-header">${button('back','Назад','back')}<div class="header-text"><h3>${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}</h3><small>${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}</small></div>${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}</header>`;
}
function nav() {
return `<nav class="bottom-nav" aria-label="Разделы приложения">${[['chat','Личные'],['channels','Каналы'],['people','Связи'],['bell','Уведомления'],['user','Профиль']].map(([i,label])=>`<button class="nav-item" data-action="${i==='channels'?'list':'outside'}" ${i==='channels'?'aria-current="page"':''}>${icon(i)}<span>${label}</span></button>`).join('')}</nav>`;
}
function rows(s) {
const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase()));
return filtered.length ? filtered.map(c=>`<button class="channel-row" data-action="open-channel" data-index="${channels.indexOf(c)}"><span class="avatar ${c.color}">${c.symbol}</span><span class="channel-copy"><strong>${c.title}</strong><small>@${c.owner} / ${c.slug}</small><p>${c.preview}</p></span><span class="channel-tail"><span>${c.time}</span>${c.unread?`<span class="badge" aria-label="${c.unread} непрочитанных">${c.unread}</span>`:''}</span></button>`).join('') : '<p class="empty">Каналы не найдены.<br>Попробуйте другое название или логин.</p>';
}
function list(s) {
return `<label class="search-field">${icon('search')}<input data-search aria-label="Поиск каналов" placeholder="Название или @автор" value="${escapeText(s.query)}"></label><div class="filter-tabs" role="group" aria-label="Фильтр каналов">${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>`<button class="filter" data-action="filter" data-value="${value}" aria-pressed="${s.filter===value}">${label}</button>`).join('')}</div><div class="section-label"><span>${s.filter==='mine'?'Ваши каналы':'Ваше пространство'}</span><span>SHiNE</span></div><div class="channel-rows">${rows(s)}</div><p class="list-tip">Хорошие разговоры начинаются<br>с общих интересов.</p>`;
}
function actions(s, id, count, replies) {
const liked = s.likes.has(id);
replies = 2 + s.replies.filter(reply => reply.post === id).length;
return `<div class="post-actions"><button class="action" data-action="like" data-post="${id}" aria-label="Нравится" aria-pressed="${liked}">${icon('heart')}<span>${count+(liked?1:0)}</span></button><button class="action" data-action="thread" data-post="${id}" aria-label="Открыть ${replies} ответов">${icon('chat')}<span>${replies}</span></button><button class="action" data-action="share" aria-label="Поделиться">${icon('share')}</button><button class="action reply-link" data-action="reply" data-post="${id}">Ответить</button></div>`;
}
function post(s, second = false) {
return `<article class="post"><div class="post-header"><span class="avatar city">АК</span><div class="author"><strong>${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}</strong><small>${second?'вчера, 19:10':'12 минут назад'}</small></div><button class="icon-btn more" data-action="post-menu" aria-label="Действия с сообщением">${icon('more')}</button></div><div class="post-content">${second?'<p>Собрала наши любимые места в один список. Добавляйте свои находки в ответах!</p><div class="attachment">'+icon('file')+'<div><strong>Места для прогулок.pdf</strong><small>PDF · 552 КБ</small></div></div>':`<p class="post-title">Маленькие открытия</p><p>${mainText}</p>${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}</div></article>`;
}
function feed(s) {
return `<section class="channel-intro"><p>Замечаем красоту рядом. Делимся местами,<br>историями и маленькими открытиями.</p><div class="channel-tools"><button class="text-button" data-action="contents">${icon('book')} Оглавление</button><button class="pill" data-action="subscribe">${s.subscribed?'Вы подписаны':'Подписаться'}</button></div></section><div class="unread-line">Новые сообщения</div>${post(s)}${post(s,true)}`;
}
function replyRow(name, initials, color, text, target='') {
return `<article class="reply"><span class="avatar ${color}">${initials}</span><div class="reply-body">${target?`<div class="reply-context">В ответ ${escapeText(target)}</div>`:''}<strong>${escapeText(name)}</strong> <small>· сейчас</small><p>${escapeText(text)}</p><div class="post-actions"><button class="action" data-action="reply-to" data-author="${escapeText(name)}" data-text="${escapeText(text)}">${icon('chat')} Ответить</button></div></div></article>`;
}
function thread(s) {
const replies=s.replies.filter(r=>r.post===s.activePost);
const second=s.activePost==='second';
return `${post(s,second)}<div class="thread-heading"><strong>Ответы · ${2+replies.length}</strong><span>По порядку</span></div>${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`;
}
function composer(s) {
if (s.screen==='list') return '';
if (s.screen==='feed' && role==='reader') return '';
return `<div class="composer-bar"><button class="composer-trigger ${s.screen==='feed'?'primary':''}" data-action="${s.screen==='feed'?'new-post':'reply'}"><span>${s.screen==='feed'?'Написать в канал':'Ответить '+s.channel.owner+'…'}</span>${icon(s.screen==='feed'?'plus':'edit')}</button></div>`;
}
function render(s) {
const previousView = s.phone.querySelector('.view');
s.scrolls ||= {};
if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop;
s.phone.innerHTML = `<div class="status-bar"><span>9:41</span><span class="status-icons">${icon('wifi')}${icon('battery')}</span></div>${header(s)}<div class="view">${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}</div>${composer(s)}${nav()}`;
if(s.screen==='feed') for(const text of s.posts) {
const item=document.createElement('article'); item.className='post';
const author=document.createElement('strong'); author.textContent='Вы · сейчас';
const body=document.createElement('p'); body.textContent=text; item.append(author,body);
s.phone.querySelector('.view').append(item);
}
s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0;
s.renderedScreen = s.screen;
if (s.editor) mountEditor(s);
}
function keyboard() {
return `<div class="keyboard" aria-hidden="true"><div class="keyboard-label">Макет клавиатуры · не интерактивный</div>${['йцукенгшщзх','фывапролджэ','ячсмитьбю'].map(row=>`<div class="keys">${[...row].map(c=>`<span>${c}</span>`).join('')}</div>`).join('')}<div class="keys"><span class="wide">123</span><span class="space">Русский</span><span class="wide">↵</span></div></div>`;
}
function mountEditor(s) {
const mode = s.editor;
const full = s.id==='mono'||s.id==='club'||s.expanded;
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.innerHTML = `<section class="sheet ${full?'full':''}" role="dialog" aria-modal="true" aria-label="${mode==='post'?'Новое сообщение':'Ответ на сообщение'}" tabindex="-1">${full?'':'<div class="sheet-handle"></div>'}<div class="sheet-header">${button('close-editor','Закрыть и сохранить черновик','close')}<h4>${mode==='post'?'Новое сообщение':'Ответ'}</h4>${button('expand','Развернуть или свернуть редактор','expand','aria-pressed="'+full+'"')}</div><div class="editor-scroll">${mode==='post'?'':`<div class="source-preview"><span class="avatar city">${s.replyTo===s.channel.owner?'АК':escapeText(s.replyTo.slice(0,2).toUpperCase())}</span><div><strong>${escapeText(s.replyTo)}</strong><small> · ${escapeText(s.channel.title)}</small><p>${escapeText(s.replyText)}</p></div></div>`}<div class="editor-row"><span class="avatar self">ВЫ</span><div class="editor-main"><strong>Вы</strong><small>${mode==='post'?'Публикация в «'+escapeText(s.channel.title)+'»':'Ответ '+escapeText(s.replyTo)+' · виден в обсуждении'}</small><textarea class="editor-input" maxlength="2000" aria-label="Текст ${mode==='post'?'сообщения':'ответа'}" placeholder="${mode==='post'?'О чём хотите рассказать?':'Продолжите разговор…'}">${escapeText(s.drafts[mode])}</textarea><div class="selected-attachment">${attachmentMarkup(s)}</div></div></div></div><div class="editor-footer">${button('attach','Прикрепить файл','attach')}<input type="file" hidden data-file><span class="char-count">${s.drafts[mode].length} / 2000</span><button class="submit" data-action="send" ${!s.drafts[mode].trim()&&!s.attachments[mode]?'disabled':''}>${mode==='post'?'Опубликовать':'Ответить'}</button></div>${showKeyboard?keyboard():''}</section>`;
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true);
s.phone.append(overlay);
overlay.querySelector('.sheet').focus({preventScroll:true});
}
function attachmentMarkup(s) {
const name = s.attachments[s.editor];
return name?`<div class="editor-attachment">${icon('file')}<span>${escapeText(name)}</span>${button('remove-attachment','Удалить вложение','close')}</div>`:'';
}
function removeOverlay(s) {
s.phone.querySelector('.overlay')?.remove();
s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false);
}
function closeEditor(s) {
s.editor = null;
removeOverlay(s);
if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true});
else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true});
}
function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) {
if (s.editor) closeEditor(s);
s.editor=mode;
s.replyTo=author;
s.replyText=text;
s.returnFocus=trigger;
s.expanded=false;
mountEditor(s);
}
function toast(s, message) {
s.phone.querySelector('.toast')?.remove();
clearTimeout(s.toastTimer);
const el=document.createElement('div');
el.className='toast'; el.setAttribute('role','status'); el.textContent=message;
s.phone.append(el);
s.toastTimer=setTimeout(()=>el.remove(),3500);
}
function menu(s, title, contents, trigger) {
removeOverlay(s);
s.returnFocus=trigger;
const el=document.createElement('div'); el.className='overlay';
el.innerHTML=`<section class="sheet menu-sheet" role="dialog" aria-modal="true" aria-label="${escapeText(title)}" tabindex="-1"><div class="sheet-handle"></div><div class="sheet-header">${button('close-menu','Закрыть','close')}<h4>${escapeText(title)}</h4><span style="width:44px"></span></div><div class="menu-content">${contents}</div></section>`;
s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true);
s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true});
}
const menuAction=(action,label,i='file')=>`<button class="menu-action" data-action="${action}">${icon(i)}${label}</button>`;
function updateEditor(s) {
const modal=s.phone.querySelector('.overlay');
modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`;
modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor];
modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s);
}
function handle(s, event) {
const el=event.target.closest('[data-action]');
if (!el || el.disabled) return;
const action=el.dataset.action;
if (action==='filter') { s.filter=el.dataset.value; render(s); }
else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); }
else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); }
else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); }
else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); }
else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el);
else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); }
else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); }
else if (action==='attach') s.phone.querySelector('[data-file]').click();
else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); }
else if (action==='send') {
const mode=s.editor;
const text=s.drafts[mode].trim();
const attachment=s.attachments[mode];
if (!text&&!attachment) return;
if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost});
else s.posts.push(text+(attachment?'\n📎 '+attachment:''));
s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s);
const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight;
s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.');
}
else if (action==='like') {
const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key);
el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1));
}
else if (action==='share') menu(s,'Поделиться', '<p>В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.</p>'+menuAction('close-menu','Понятно','check'),el);
else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; }
else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); }
else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'<p>Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.</p>',el);
else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el);
else if (action==='contents') menu(s,'Оглавление','<p>С чего начать знакомство с каналом</p>'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el);
else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); }
else if (action==='reactions') menu(s,'Лайки','<p>Все: 24 · основные: 18 · сияющие: 6</p><p>Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.</p>',el);
else if (action==='details') menu(s,'Данные записи','<p>Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.</p>',el);
else if (action==='about') menu(s,'О канале','<p>Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.</p><p>Автор: @'+escapeText(s.channel.owner)+'</p>',el);
else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования');
else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап');
else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.');
}
function attachEvents(s) {
s.phone.addEventListener('click',event=>handle(s,event));
s.phone.addEventListener('input',event=>{
if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); }
if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); }
});
s.phone.addEventListener('change',event=>{
if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); }
});
s.phone.addEventListener('keydown',event=>{
const dialog=s.phone.querySelector('[role="dialog"]');
if(!dialog) return;
if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
if(event.key==='Tab') {
const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')];
const first=focusable[0], last=focusable.at(-1);
if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); }
else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); }
}
});
}
function init() {
const query=new URLSearchParams(location.search);
const chosen=concepts.find(c=>c.id===query.get('variant'));
const gallery=document.getElementById('gallery');
if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');}
for(const c of chosen?[chosen]:concepts) {
const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title);
article.innerHTML=`<div class="concept-heading"><span class="concept-number">${c.number}</span><h2>${c.title}</h2><a class="open-concept" href="?variant=${c.id}" aria-label="Открыть отдельно: ${c.title}">↗</a></div><p class="concept-description">${c.description}</p><div class="phone" data-theme="${c.id}" aria-label="Макет: ${c.title}"></div><div class="concept-notes"><div class="swatches" aria-hidden="true">${c.colors.map(color=>`<span style="background:${color}"></span>`).join('')}</div><span class="concept-tag">${c.tag}</span><div>${c.note}</div></div>`;
gallery.append(article);
const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null};
states.set(c.id,s);render(s);attachEvents(s);
}
document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{
document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));});
for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); }
el.focus({preventScroll:true});
}));
document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);});
document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});});
}
init();