+@@ -71,2 +71,3 @@
+ const textEl = modal.querySelector('.profile-help-text');
++ const dialogEl = modal.querySelector('.profile-help-dialog');
+
+@@ -83,2 +84,3 @@
+ modal.hidden = false;
++ dialogEl.focus();
+ }
+apply_patch(auto_approved=true) exited 0 in 32ms:
+Success. Updated the following files:
+M /home/player/docker/shine-UI/js/pages/profile-view.js
+file update:
+diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js
+index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0458f140b40b0f2ee7388566def145ce1575e428
+--- a//home/player/docker/shine-UI/js/pages/profile-view.js
++++ b//home/player/docker/shine-UI/js/pages/profile-view.js
+@@ -4,6 +4,17 @@
+ export const pageMeta = { id: 'profile-view', title: 'Профиль' };
+
+ export function render({ navigate }) {
++ const badgeHelp = {
++ official: {
++ title: 'Официальный аккаунт',
++ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
++ },
++ shine: {
++ title: 'Сияющий',
++ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
++ },
++ };
++
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+@@ -23,8 +34,8 @@
+
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++
++
+
+
+
+@@ -39,6 +50,58 @@
+
+ `;
+
+- screen.append(card);
++ const modal = document.createElement('div');
++ modal.className = 'profile-help-modal';
++ modal.hidden = true;
++ modal.innerHTML = `
++
++
++
++
++
Управление функцией
++
++
++
++
++
++
++ `;
++
++ const titleEl = modal.querySelector('#profile-help-title');
++ const textEl = modal.querySelector('.profile-help-text');
++ const dialogEl = modal.querySelector('.profile-help-dialog');
++
++ function closeModal() {
++ modal.hidden = true;
++ }
++
++ function openModal(type) {
++ const content = badgeHelp[type];
++ if (!content) return;
++
++ titleEl.textContent = content.title;
++ textEl.textContent = content.text;
++ modal.hidden = false;
++ dialogEl.focus();
++ }
++
++ card.querySelectorAll('.profile-badge-trigger').forEach((button) => {
++ button.addEventListener('click', () => openModal(button.dataset.badge));
++ });
++
++ modal.addEventListener('click', (event) => {
++ const target = event.target;
++ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
++ closeModal();
++ }
++ });
++
++ modal.addEventListener('keydown', (event) => {
++ if (event.key === 'Escape') {
++ closeModal();
++ }
++ });
++
++ screen.append(card, modal);
+ return screen;
+ }
+diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css
+index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f
+--- a//home/player/docker/shine-UI/styles/components.css
++++ b//home/player/docker/shine-UI/styles/components.css
+@@ -87,12 +87,50 @@
+ font-weight: 600;
+ }
+
++.badge.profile-badge-trigger {
++ cursor: pointer;
++}
++
+ .badge.alt {
+ border-color: rgba(83, 216, 251, 0.35);
+ color: #dff8ff;
+ background: rgba(83, 216, 251, 0.11);
+ }
+
++.profile-help-modal[hidden] {
++ display: none;
++}
++
++.profile-help-modal {
++ position: fixed;
++ inset: 0;
++ z-index: 20;
++}
++
++.profile-help-backdrop {
++ position: absolute;
++ inset: 0;
++ background: rgba(5, 9, 16, 0.72);
++ backdrop-filter: blur(4px);
++}
++
++.profile-help-dialog {
++ position: absolute;
++ left: 16px;
++ right: 16px;
++ bottom: 24px;
++ display: grid;
++ gap: 12px;
++ padding: 16px;
++ box-shadow: var(--shadow);
++}
++
++.profile-help-text {
++ color: #d8e3ff;
++ line-height: 1.45;
++ font-size: 14px;
++}
++
+ .list-item {
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+
+file update:
+diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js
+index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0458f140b40b0f2ee7388566def145ce1575e428
+--- a//home/player/docker/shine-UI/js/pages/profile-view.js
++++ b//home/player/docker/shine-UI/js/pages/profile-view.js
+@@ -4,6 +4,17 @@
+ export const pageMeta = { id: 'profile-view', title: 'Профиль' };
+
+ export function render({ navigate }) {
++ const badgeHelp = {
++ official: {
++ title: 'Официальный аккаунт',
++ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
++ },
++ shine: {
++ title: 'Сияющий',
++ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
++ },
++ };
++
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+@@ -23,8 +34,8 @@
+
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++
++
+
+
+
+@@ -39,6 +50,58 @@
+
+ `;
+
+- screen.append(card);
++ const modal = document.createElement('div');
++ modal.className = 'profile-help-modal';
++ modal.hidden = true;
++ modal.innerHTML = `
++
++
++
++
++
Управление функцией
++
++
++
++
++
++
++ `;
++
++ const titleEl = modal.querySelector('#profile-help-title');
++ const textEl = modal.querySelector('.profile-help-text');
++ const dialogEl = modal.querySelector('.profile-help-dialog');
++
++ function closeModal() {
++ modal.hidden = true;
++ }
++
++ function openModal(type) {
++ const content = badgeHelp[type];
++ if (!content) return;
++
++ titleEl.textContent = content.title;
++ textEl.textContent = content.text;
++ modal.hidden = false;
++ dialogEl.focus();
++ }
++
++ card.querySelectorAll('.profile-badge-trigger').forEach((button) => {
++ button.addEventListener('click', () => openModal(button.dataset.badge));
++ });
++
++ modal.addEventListener('click', (event) => {
++ const target = event.target;
++ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
++ closeModal();
++ }
++ });
++
++ modal.addEventListener('keydown', (event) => {
++ if (event.key === 'Escape') {
++ closeModal();
++ }
++ });
++
++ screen.append(card, modal);
+ return screen;
+ }
+diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css
+index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f
+--- a//home/player/docker/shine-UI/styles/components.css
++++ b//home/player/docker/shine-UI/styles/components.css
+@@ -87,12 +87,50 @@
+ font-weight: 600;
+ }
+
++.badge.profile-badge-trigger {
++ cursor: pointer;
++}
++
+ .badge.alt {
+ border-color: rgba(83, 216, 251, 0.35);
+ color: #dff8ff;
+ background: rgba(83, 216, 251, 0.11);
+ }
+
++.profile-help-modal[hidden] {
++ display: none;
++}
++
++.profile-help-modal {
++ position: fixed;
++ inset: 0;
++ z-index: 20;
++}
++
++.profile-help-backdrop {
++ position: absolute;
++ inset: 0;
++ background: rgba(5, 9, 16, 0.72);
++ backdrop-filter: blur(4px);
++}
++
++.profile-help-dialog {
++ position: absolute;
++ left: 16px;
++ right: 16px;
++ bottom: 24px;
++ display: grid;
++ gap: 12px;
++ padding: 16px;
++ box-shadow: var(--shadow);
++}
++
++.profile-help-text {
++ color: #d8e3ff;
++ line-height: 1.45;
++ font-size: 14px;
++}
++
+ .list-item {
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+
+exec
+/bin/bash -lc "sed -n '1,260p' js/pages/profile-view.js" in /home/player/docker/shine-UI succeeded in 0ms:
+import { renderHeader } from '../components/header.js';
+import { profile } from '../mock-data.js';
+
+export const pageMeta = { id: 'profile-view', title: 'Профиль' };
+
+export function render({ navigate }) {
+ const badgeHelp = {
+ official: {
+ title: 'Официальный аккаунт',
+ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
+ },
+ shine: {
+ title: 'Сияющий',
+ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
+ },
+ };
+
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+exec
+/bin/bash -lc "sed -n '1,240p' styles/components.css" in /home/player/docker/shine-UI succeeded in 0ms:
+.page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+ gap: 8px;
+}
+
+.page-title {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: 0.2px;
+}
+
+.header-actions,
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 74px;
+exec
+/bin/bash -lc 'git diff -- js/pages/profile-view.js styles/components.css' in /home/player/docker/shine-UI exited 1 in 0ms:
+diff --git a/js/pages/profile-view.js b/styles/components.css
+index 0458f14..1c33cf1 100644
+--- a/js/pages/profile-view.js
++++ b/styles/components.css
+@@ -1,107 +1,351 @@
+-import { renderHeader } from '../components/header.js';
+-import { profile } from '../mock-data.js';
+-
+-export const pageMeta = { id: 'profile-view', title: 'Профиль' };
+-
+-export function render({ navigate }) {
+- const badgeHelp = {
+- official: {
+- title: 'Официальный аккаунт',
+- text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
+- },
+- shine: {
+- title: 'Сияющий',
+- text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
+- },
+file update:
+diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js
+index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0458f140b40b0f2ee7388566def145ce1575e428
+--- a//home/player/docker/shine-UI/js/pages/profile-view.js
++++ b//home/player/docker/shine-UI/js/pages/profile-view.js
+@@ -4,6 +4,17 @@
+ export const pageMeta = { id: 'profile-view', title: 'Профиль' };
+
+ export function render({ navigate }) {
++ const badgeHelp = {
++ official: {
++ title: 'Официальный аккаунт',
++ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
++ },
++ shine: {
++ title: 'Сияющий',
++ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
++ },
++ };
++
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+@@ -23,8 +34,8 @@
+
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++
++
+
+
+
+@@ -39,6 +50,58 @@
+
+ `;
+
+- screen.append(card);
++ const modal = document.createElement('div');
++ modal.className = 'profile-help-modal';
++ modal.hidden = true;
++ modal.innerHTML = `
++
++
++
++
++
Управление функцией
++
++
++
++
++
++
++ `;
++
++ const titleEl = modal.querySelector('#profile-help-title');
++ const textEl = modal.querySelector('.profile-help-text');
++ const dialogEl = modal.querySelector('.profile-help-dialog');
++
++ function closeModal() {
++ modal.hidden = true;
++ }
++
++ function openModal(type) {
++ const content = badgeHelp[type];
++ if (!content) return;
++
++ titleEl.textContent = content.title;
++ textEl.textContent = content.text;
++ modal.hidden = false;
++ dialogEl.focus();
++ }
++
++ card.querySelectorAll('.profile-badge-trigger').forEach((button) => {
++ button.addEventListener('click', () => openModal(button.dataset.badge));
++ });
++
++ modal.addEventListener('click', (event) => {
++ const target = event.target;
++ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
++ closeModal();
++ }
++ });
++
++ modal.addEventListener('keydown', (event) => {
++ if (event.key === 'Escape') {
++ closeModal();
++ }
++ });
++
++ screen.append(card, modal);
+ return screen;
+ }
+diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css
+index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f
+--- a//home/player/docker/shine-UI/styles/components.css
++++ b//home/player/docker/shine-UI/styles/components.css
+@@ -87,12 +87,50 @@
+ font-weight: 600;
+ }
+
++.badge.profile-badge-trigger {
++ cursor: pointer;
++}
++
+ .badge.alt {
+ border-color: rgba(83, 216, 251, 0.35);
+ color: #dff8ff;
+ background: rgba(83, 216, 251, 0.11);
+ }
+
++.profile-help-modal[hidden] {
++ display: none;
++}
++
++.profile-help-modal {
++ position: fixed;
++ inset: 0;
++ z-index: 20;
++}
++
++.profile-help-backdrop {
++ position: absolute;
++ inset: 0;
++ background: rgba(5, 9, 16, 0.72);
++ backdrop-filter: blur(4px);
++}
++
++.profile-help-dialog {
++ position: absolute;
++ left: 16px;
++ right: 16px;
++ bottom: 24px;
++ display: grid;
++ gap: 12px;
++ padding: 16px;
++ box-shadow: var(--shadow);
++}
++
++.profile-help-text {
++ color: #d8e3ff;
++ line-height: 1.45;
++ font-size: 14px;
++}
++
+ .list-item {
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+
+codex
+Изменил профильную страницу так, чтобы два уже существующих бейджа работали как триггеры подсказок, без добавления дублирующих элементов. По клику на `Официальный аккаунт` и `Сияющий` теперь открывается лёгкий pop-up с естественным русским текстом о том, что эти элементы используются для включения и отключения соответствующих функций. Закрытие работает по кнопке, по клику на фон и по `Esc`.
+
+Правки внесены в [js/pages/profile-view.js](/home/player/docker/shine-UI/js/pages/profile-view.js) и [styles/components.css](/home/player/docker/shine-UI/styles/components.css). Глобальную систему модалок не добавлял, сделал локальную и простую реализацию под текущий статический SPA-стек. В браузере дополнительный бэкенд не нужен.
+
+Краткая сводка файлов:
+- [js/pages/profile-view.js](/home/player/docker/shine-UI/js/pages/profile-view.js)
+- [styles/components.css](/home/player/docker/shine-UI/styles/components.css)
+
+Проверку в реальном браузере здесь не запускал.
+file update:
+diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js
+index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0458f140b40b0f2ee7388566def145ce1575e428
+--- a//home/player/docker/shine-UI/js/pages/profile-view.js
++++ b//home/player/docker/shine-UI/js/pages/profile-view.js
+@@ -4,6 +4,17 @@
+ export const pageMeta = { id: 'profile-view', title: 'Профиль' };
+
+ export function render({ navigate }) {
++ const badgeHelp = {
++ official: {
++ title: 'Официальный аккаунт',
++ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
++ },
++ shine: {
++ title: 'Сияющий',
++ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
++ },
++ };
++
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+@@ -23,8 +34,8 @@
+
+
${profile.avatarInitials}
+
+-
✔ ${profile.badges[0]}
+-
✨ ${profile.badges[1]}
++
++
+
+
+
+@@ -39,6 +50,58 @@
+
+ `;
+
+- screen.append(card);
++ const modal = document.createElement('div');
++ modal.className = 'profile-help-modal';
++ modal.hidden = true;
++ modal.innerHTML = `
++
++
++
++
++
Управление функцией
++
++
++
++
++
++
++ `;
++
++ const titleEl = modal.querySelector('#profile-help-title');
++ const textEl = modal.querySelector('.profile-help-text');
++ const dialogEl = modal.querySelector('.profile-help-dialog');
++
++ function closeModal() {
++ modal.hidden = true;
++ }
++
++ function openModal(type) {
++ const content = badgeHelp[type];
++ if (!content) return;
++
++ titleEl.textContent = content.title;
++ textEl.textContent = content.text;
++ modal.hidden = false;
++ dialogEl.focus();
++ }
++
++ card.querySelectorAll('.profile-badge-trigger').forEach((button) => {
++ button.addEventListener('click', () => openModal(button.dataset.badge));
++ });
++
++ modal.addEventListener('click', (event) => {
++ const target = event.target;
++ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
++ closeModal();
++ }
++ });
++
++ modal.addEventListener('keydown', (event) => {
++ if (event.key === 'Escape') {
++ closeModal();
++ }
++ });
++
++ screen.append(card, modal);
+ return screen;
+ }
+diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css
+index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f
+--- a//home/player/docker/shine-UI/styles/components.css
++++ b//home/player/docker/shine-UI/styles/components.css
+@@ -87,12 +87,50 @@
+ font-weight: 600;
+ }
+
++.badge.profile-badge-trigger {
++ cursor: pointer;
++}
++
+ .badge.alt {
+ border-color: rgba(83, 216, 251, 0.35);
+ color: #dff8ff;
+ background: rgba(83, 216, 251, 0.11);
+ }
+
++.profile-help-modal[hidden] {
++ display: none;
++}
++
++.profile-help-modal {
++ position: fixed;
++ inset: 0;
++ z-index: 20;
++}
++
++.profile-help-backdrop {
++ position: absolute;
++ inset: 0;
++ background: rgba(5, 9, 16, 0.72);
++ backdrop-filter: blur(4px);
++}
++
++.profile-help-dialog {
++ position: absolute;
++ left: 16px;
++ right: 16px;
++ bottom: 24px;
++ display: grid;
++ gap: 12px;
++ padding: 16px;
++ box-shadow: var(--shadow);
++}
++
++.profile-help-text {
++ color: #d8e3ff;
++ line-height: 1.45;
++ font-size: 14px;
++}
++
+ .list-item {
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+
+tokens used
+28 379
+Изменил профильную страницу так, чтобы два уже существующих бейджа работали как триггеры подсказок, без добавления дублирующих элементов. По клику на `Официальный аккаунт` и `Сияющий` теперь открывается лёгкий pop-up с естественным русским текстом о том, что эти элементы используются для включения и отключения соответствующих функций. Закрытие работает по кнопке, по клику на фон и по `Esc`.
+
+Правки внесены в [js/pages/profile-view.js](/home/player/docker/shine-UI/js/pages/profile-view.js) и [styles/components.css](/home/player/docker/shine-UI/styles/components.css). Глобальную систему модалок не добавлял, сделал локальную и простую реализацию под текущий статический SPA-стек. В браузере дополнительный бэкенд не нужен.
+
+Краткая сводка файлов:
+- [js/pages/profile-view.js](/home/player/docker/shine-UI/js/pages/profile-view.js)
+- [styles/components.css](/home/player/docker/shine-UI/styles/components.css)
+
+Проверку в реальном браузере здесь не запускал.
diff --git a/shine-UI/AGENTS.md b/shine-UI/AGENTS.md
new file mode 100644
index 0000000..3848596
--- /dev/null
+++ b/shine-UI/AGENTS.md
@@ -0,0 +1,40 @@
+# AGENTS
+
+## Назначение проекта
+Это демо-прототип мобильного веб-приложения в формате статического сайта.
+
+## Технические ограничения
+- Проект сделан без бэкенда, без базы данных и без реальных API.
+- Все данные моковые и хранятся в `js/mock-data.js`.
+- Навигация между экранами идет без полной перезагрузки страницы (SPA-подход на hash-router).
+
+## Обязательные требования к каждому экрану
+- У каждого экрана есть явный верхний заголовок на русском языке.
+- У каждого экрана есть нижняя служебная подпись над toolbar в формате:
+ `[Русское название] ([english-page-id])`.
+- `page-id` должен совпадать с именем JS-файла страницы или быть максимально близким к нему.
+
+## Архитектурные правила
+- Структура проекта должна оставаться понятной и модульной.
+- Новые доработки нужно вносить аккуратно, не ломая существующую навигацию.
+- Стиль проекта: темная тема, mobile-first, интерфейс на русском языке.
+
+## Экраны и файлы
+- Профиль: `js/pages/profile-view.js`
+- Кошелёк: `js/pages/wallet-view.js`
+- Настройки: `js/pages/settings-view.js`
+- Личные сообщения: `js/pages/messages-list.js`
+- Чат: `js/pages/chat-view.js`
+- Каналы: `js/pages/channels-list.js`
+- Канал: `js/pages/channel-view.js`
+- Связи: `js/pages/network-view.js`
+- Уведомления: `js/pages/notifications-view.js`
+
+## Ключевые файлы приложения
+- Точка входа: `index.html`
+- Инициализация приложения: `js/app.js`
+- Роутинг: `js/router.js`
+- Состояние клиента: `js/state.js`
+- Моки: `js/mock-data.js`
+- Компоненты: `js/components/*`
+- Стили: `styles/*`
diff --git a/shine-UI/img/device-qr-64.svg b/shine-UI/img/device-qr-64.svg
new file mode 100644
index 0000000..1f42ae7
--- /dev/null
+++ b/shine-UI/img/device-qr-64.svg
@@ -0,0 +1,83 @@
+
diff --git a/shine-UI/img/logo.jpg b/shine-UI/img/logo.jpg
new file mode 100644
index 0000000..6554410
Binary files /dev/null and b/shine-UI/img/logo.jpg differ
diff --git a/shine-UI/index.html b/shine-UI/index.html
new file mode 100644
index 0000000..02c30d0
--- /dev/null
+++ b/shine-UI/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
Shine UI Demo
+
+
+
+
+
+
+
+
+
+
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
new file mode 100644
index 0000000..9d2ca6c
--- /dev/null
+++ b/shine-UI/js/app.js
@@ -0,0 +1,122 @@
+import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260327192619';
+import { renderToolbar } from './components/toolbar.js?v=20260327192619';
+import { renderPageLabel } from './components/page-label.js?v=20260327192619';
+import { state, togglePageLabel } from './state.js?v=20260327192619';
+
+import * as startView from './pages/start-view.js?v=20260327192619';
+import * as entrySettingsView from './pages/entry-settings-view.js?v=20260327192619';
+import * as registerView from './pages/register-view.js?v=20260327192619';
+import * as registrationPaymentView from './pages/registration-payment-view.js?v=20260327192619';
+import * as registrationKeysView from './pages/registration-keys-view.js?v=20260327192619';
+import * as topupView from './pages/topup-view.js?v=20260327192619';
+import * as loginView from './pages/login-view.js?v=20260327192619';
+import * as loginCameraView from './pages/login-camera-view.js?v=20260327192619';
+import * as loginPasswordView from './pages/login-password-view.js?v=20260327192619';
+import * as keyStorageView from './pages/key-storage-view.js?v=20260327192619';
+
+import * as profileView from './pages/profile-view.js?v=20260327192619';
+import * as walletView from './pages/wallet-view.js?v=20260327192619';
+import * as settingsView from './pages/settings-view.js?v=20260327192619';
+import * as serverSettingsView from './pages/server-settings-view.js?v=20260327192619';
+import * as deviceView from './pages/device-view.js?v=20260327192619';
+import * as connectDeviceView from './pages/connect-device-view.js?v=20260327192619';
+import * as deviceQrView from './pages/device-qr-view.js?v=20260327192619';
+import * as deviceCameraView from './pages/device-camera-view.js?v=20260327192619';
+import * as showKeysView from './pages/show-keys-view.js?v=20260327192619';
+import * as deviceSessionView from './pages/device-session-view.js?v=20260327192619';
+import * as languageView from './pages/language-view.js?v=20260327192619';
+import * as messagesList from './pages/messages-list.js?v=20260327192619';
+import * as contactSearchView from './pages/contact-search-view.js?v=20260327192619';
+import * as chatView from './pages/chat-view.js?v=20260327192619';
+import * as channelsList from './pages/channels-list.js?v=20260327192619';
+import * as channelView from './pages/channel-view.js?v=20260327192619';
+import * as networkView from './pages/network-view.js?v=20260327192619';
+import * as notificationsView from './pages/notifications-view.js?v=20260327192619';
+
+const routes = {
+ 'start-view': startView,
+ 'entry-settings-view': entrySettingsView,
+ 'register-view': registerView,
+ 'registration-payment-view': registrationPaymentView,
+ 'registration-keys-view': registrationKeysView,
+ 'topup-view': topupView,
+ 'login-view': loginView,
+ 'login-camera-view': loginCameraView,
+ 'login-password-view': loginPasswordView,
+ 'key-storage-view': keyStorageView,
+ 'profile-view': profileView,
+ 'wallet-view': walletView,
+ 'settings-view': settingsView,
+ 'server-settings-view': serverSettingsView,
+ 'device-view': deviceView,
+ 'connect-device-view': connectDeviceView,
+ 'device-qr-view': deviceQrView,
+ 'device-camera-view': deviceCameraView,
+ 'show-keys-view': showKeysView,
+ 'device-session-view': deviceSessionView,
+ 'language-view': languageView,
+ 'messages-list': messagesList,
+ 'contact-search-view': contactSearchView,
+ 'chat-view': chatView,
+ 'channels-list': channelsList,
+ 'channel-view': channelView,
+ 'network-view': networkView,
+ 'notifications-view': notificationsView,
+};
+
+const screenEl = document.getElementById('app-screen');
+const labelEl = document.getElementById('page-label-slot');
+const toolbarEl = document.getElementById('toolbar-slot');
+
+let currentCleanup = null;
+
+function renderApp() {
+ const route = getRoute();
+ const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : 'start-view');
+
+ if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) {
+ navigate('start-view');
+ return;
+ }
+
+ if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
+ navigate('profile-view');
+ return;
+ }
+
+ const page = routes[pageId] || routes['start-view'];
+
+ if (typeof currentCleanup === 'function') {
+ currentCleanup();
+ currentCleanup = null;
+ }
+
+ screenEl.innerHTML = '';
+ const screen = page.render({ route, navigate });
+ screenEl.append(screen);
+ currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
+
+ const showAppChrome = page.pageMeta?.showAppChrome !== false;
+ screenEl.classList.toggle('no-app-chrome', !showAppChrome);
+
+ labelEl.innerHTML = '';
+ toolbarEl.innerHTML = '';
+
+ if (showAppChrome) {
+ labelEl.append(
+ renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => {
+ togglePageLabel();
+ renderApp();
+ }),
+ );
+ toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
+ }
+}
+
+if (!window.location.hash) {
+ navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
+} else {
+ renderApp();
+}
+
+window.addEventListener('hashchange', renderApp);
diff --git a/shine-UI/js/components/header.js b/shine-UI/js/components/header.js
new file mode 100644
index 0000000..7f5766c
--- /dev/null
+++ b/shine-UI/js/components/header.js
@@ -0,0 +1,31 @@
+export function renderHeader({ title, leftAction, rightActions = [] }) {
+ const wrap = document.createElement('header');
+ wrap.className = 'page-header';
+
+ const left = document.createElement('div');
+ left.className = 'header-left';
+ if (leftAction) {
+ const btn = document.createElement('button');
+ btn.className = 'icon-btn';
+ btn.textContent = leftAction.label;
+ btn.addEventListener('click', leftAction.onClick);
+ left.append(btn);
+ }
+
+ const h1 = document.createElement('h1');
+ h1.className = 'page-title';
+ h1.textContent = title;
+
+ const right = document.createElement('div');
+ right.className = 'header-actions';
+ rightActions.forEach((action) => {
+ const btn = document.createElement('button');
+ btn.className = 'icon-btn';
+ btn.textContent = action.label;
+ btn.addEventListener('click', action.onClick);
+ right.append(btn);
+ });
+
+ wrap.append(left, h1, right);
+ return wrap;
+}
diff --git a/shine-UI/js/components/page-label.js b/shine-UI/js/components/page-label.js
new file mode 100644
index 0000000..7116674
--- /dev/null
+++ b/shine-UI/js/components/page-label.js
@@ -0,0 +1,36 @@
+export function renderPageLabel(titleRu, pageId, collapsed, onToggle) {
+ const label = document.createElement('div');
+ label.className = `page-label${collapsed ? ' is-collapsed' : ''}`;
+
+ const toggle = document.createElement('button');
+ toggle.type = 'button';
+ toggle.className = 'page-label-toggle';
+ toggle.title = collapsed ? 'Показать подпись' : 'Скрыть подпись';
+ toggle.setAttribute(
+ 'aria-label',
+ collapsed ? 'Показать подпись страницы для разработки' : 'Скрыть подпись страницы для разработки',
+ );
+ toggle.addEventListener('click', onToggle);
+
+ if (!collapsed) {
+ label.append(toggle);
+
+ const content = document.createElement('div');
+ content.className = 'page-label-content';
+
+ const hint = document.createElement('div');
+ hint.className = 'page-label-hint';
+ hint.textContent = 'Для разработки';
+
+ const caption = document.createElement('div');
+ caption.className = 'page-label-caption';
+ caption.textContent = `${titleRu} (${pageId})`;
+
+ content.append(hint, caption);
+ label.append(content);
+ } else {
+ label.append(toggle);
+ }
+
+ return label;
+}
diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js
new file mode 100644
index 0000000..ec04845
--- /dev/null
+++ b/shine-UI/js/components/toolbar.js
@@ -0,0 +1,25 @@
+import { resolveToolbarActive } from '../router.js?v=20260327192619';
+
+const ITEMS = [
+ { pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
+ { pageId: 'channels-list', label: 'Каналы', icon: '📢' },
+ { pageId: 'network-view', label: 'Связи', icon: '🕸' },
+ { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
+ { pageId: 'profile-view', label: 'Профиль', icon: '👤' },
+];
+
+export function renderToolbar(currentPageId, navigate) {
+ const root = document.createElement('nav');
+ root.className = 'toolbar';
+ const active = resolveToolbarActive(currentPageId);
+
+ ITEMS.forEach((item) => {
+ const btn = document.createElement('button');
+ btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}`;
+ btn.innerHTML = `
${item.icon}${item.label}`;
+ btn.addEventListener('click', () => navigate(item.pageId));
+ root.append(btn);
+ });
+
+ return root;
+}
diff --git a/shine-UI/js/mock-data.js b/shine-UI/js/mock-data.js
new file mode 100644
index 0000000..82dc24b
--- /dev/null
+++ b/shine-UI/js/mock-data.js
@@ -0,0 +1,237 @@
+export const profile = {
+ login: '@shine.alex',
+ name: 'Алексей сияющий',
+ avatarInitials: 'АС',
+ phone: '+7 (916) 221-45-88',
+ address: 'Москва, Пресненская наб., 12',
+ email: 'alex.shine@demo.local',
+ socials: '@alexshine / t.me/alexshine',
+ badges: ['Официальный аккаунт', 'Сияющий'],
+};
+
+export const wallet = {
+ balanceSOL: '182.4571',
+ publicAddress: '9sVAXJ2CqP3BrtC6AFeQHhcuWjN1kUyhY7L8pkQJxMZe',
+ updatedAt: 'сегодня, 14:42',
+};
+
+export const deviceSessions = [
+ {
+ sessionId: 'sess_7c5e5c4b',
+ clientInfoFromClient: 'Android 15; Pixel 9',
+ clientInfoFromRequest: 'UA=Java-http-client/17.0.18; remote=127.0.0.1',
+ geo: 'RU/Moscow',
+ lastAuthenticatedAtMs: 1774600010500,
+ },
+ {
+ sessionId: 'sess_90ab11de',
+ clientInfoFromClient: 'iOS 19; iPhone 17',
+ clientInfoFromRequest: 'UA=ShineMobile/2.4; remote=10.0.2.12',
+ geo: 'RU/Moscow',
+ lastAuthenticatedAtMs: 1774553310000,
+ },
+ {
+ sessionId: 'sess_3ea4f11c',
+ clientInfoFromClient: 'Windows 11; Chrome 124',
+ clientInfoFromRequest: 'UA=Mozilla/5.0; remote=192.168.1.21',
+ geo: 'RU/Kazan',
+ lastAuthenticatedAtMs: 1774499010000,
+ },
+];
+
+export const directMessages = [
+ {
+ id: 'u1',
+ name: 'Марина К.',
+ initials: 'МК',
+ lastMessage: 'Вечером скину обновления по макетам.',
+ time: '15:08',
+ unread: 2,
+ },
+ {
+ id: 'u2',
+ name: 'Илья П.',
+ initials: 'ИП',
+ lastMessage: 'Спасибо, уже проверяю!',
+ time: '14:31',
+ unread: 0,
+ },
+ {
+ id: 'u3',
+ name: 'Елена Д.',
+ initials: 'ЕД',
+ lastMessage: 'Тестовый стенд снова доступен.',
+ time: '13:02',
+ unread: 5,
+ },
+ {
+ id: 'u4',
+ name: 'Никита О.',
+ initials: 'НО',
+ lastMessage: 'Отлично, давай так и сделаем.',
+ time: 'вчера',
+ unread: 0,
+ },
+];
+
+export const contactDirectory = [
+ {
+ id: 'u5',
+ name: 'Марк С.',
+ initials: 'МС',
+ about: 'Продуктовый аналитик, любит короткие созвоны и длинные отчёты.',
+ },
+ {
+ id: 'u6',
+ name: 'Мария Л.',
+ initials: 'МЛ',
+ about: 'UI-дизайнер, собирает референсы и следит за визуальным стилем.',
+ },
+ {
+ id: 'u7',
+ name: 'Марина Р.',
+ initials: 'МР',
+ about: 'Контент-менеджер, ведёт каналы и готовит анонсы.',
+ },
+ {
+ id: 'u8',
+ name: 'Максим В.',
+ initials: 'МВ',
+ about: 'Frontend-разработчик, отвечает за анимации и адаптивность.',
+ },
+ {
+ id: 'u9',
+ name: 'Мадина А.',
+ initials: 'МА',
+ about: 'Комьюнити-менеджер, быстро находит нужных людей.',
+ },
+ {
+ id: 'u10',
+ name: 'Ирина П.',
+ initials: 'ИП',
+ about: 'Редактор новостей, помогает с текстами и публикациями.',
+ },
+ {
+ id: 'u11',
+ name: 'Николай Д.',
+ initials: 'НД',
+ about: 'Технический писатель, структурирует знания по продукту.',
+ },
+ {
+ id: 'u12',
+ name: 'Егор Т.',
+ initials: 'ЕТ',
+ about: 'QA-инженер, любит проверять сложные сценарии вручную.',
+ },
+];
+
+export const chatMessages = {
+ u1: [
+ { from: 'in', text: 'Привет! Видел новые карточки?' },
+ { from: 'out', text: 'Да, смотрятся сильно. Нужен финальный текст.' },
+ { from: 'in', text: 'Вечером скину обновления по макетам.' },
+ ],
+ u2: [
+ { from: 'out', text: 'Скинул доступы в чат команды.' },
+ { from: 'in', text: 'Спасибо, уже проверяю!' },
+ ],
+ u3: [
+ { from: 'in', text: 'Тестовый стенд снова доступен.' },
+ { from: 'out', text: 'Отлично, запускаю прогон сценариев.' },
+ ],
+ u4: [
+ { from: 'in', text: 'Подтверждаю план на завтра.' },
+ { from: 'out', text: 'Отлично, давай так и сделаем.' },
+ ],
+};
+
+export const channels = [
+ {
+ id: 'ch1',
+ name: 'Новости продукта',
+ initials: 'НП',
+ description: 'Официальный канал команды Shine с релизами и обновлениями.',
+ lastMessage: 'Опубликовали обзор нового демо-прототипа мобильного интерфейса.',
+ time: '16:05',
+ unread: 14,
+ },
+ {
+ id: 'ch2',
+ name: 'Анекдоты дня',
+ initials: 'АД',
+ description: 'Лёгкий развлекательный канал с короткими шутками и мемами.',
+ lastMessage: 'Новый пост: как дизайнер, разработчик и дедлайн зашли в бар.',
+ time: '15:20',
+ unread: 3,
+ },
+ {
+ id: 'ch3',
+ name: 'Новости рынка',
+ initials: 'НР',
+ description: 'Короткие ежедневные сводки по рынку, технологиям и сообществам.',
+ lastMessage: 'В ленте свежая подборка новостей и главных событий дня.',
+ time: 'вчера',
+ unread: 0,
+ },
+];
+
+export const channelPosts = {
+ ch1: [
+ {
+ id: 'p1',
+ title: 'Новый экран профиля',
+ body: 'Добавлены бейджи статуса, переработан верхний блок и улучшены быстрые переходы.',
+ },
+ {
+ id: 'p2',
+ title: 'Навигация без перезагрузки',
+ body: 'Переходы между экранами теперь стабильнее работают в SPA-режиме через hash-router.',
+ },
+ ],
+ ch2: [
+ {
+ id: 'p3',
+ title: 'Анекдот утра',
+ body: 'Разработчик говорит: "Я починил один баг". Баги в ответ: "Нас было трое".',
+ },
+ {
+ id: 'p4',
+ title: 'Анекдот про дедлайн',
+ body: 'Дедлайн был настолько близко, что команда начала здороваться с ним по имени.',
+ },
+ ],
+ ch3: [
+ {
+ id: 'p5',
+ title: 'Утренний дайджест',
+ body: 'Собрали ключевые новости дня: обновления продуктов, движения рынка и заметные релизы.',
+ },
+ {
+ id: 'p6',
+ title: 'Что обсуждают сегодня',
+ body: 'В фокусе дня: рост интереса к мобильным dApp-интерфейсам и новые анонсы сообществ.',
+ },
+ ],
+};
+
+export const notifications = {
+ replies: [
+ { id: 'r1', title: 'Марина К. ответила на ваш комментарий', text: 'Согласна, такую структуру и оставим.', time: '12 минут назад' },
+ { id: 'r2', title: 'Илья П. ответил в обсуждении', text: 'Добавил примеры экранов для onboarding.', time: '48 минут назад' },
+ ],
+ events: [
+ { id: 'e1', title: 'Елена Д. добавила вас в друзья', text: 'Теперь вы в связях первого уровня.', time: 'сегодня' },
+ { id: 'e2', title: 'Никита О. удалил из друзей', text: 'Связь перенесена в архив событий.', time: 'вчера' },
+ { id: 'e3', title: 'Марина К. поставила лайк', text: 'Оценен ваш пост о прототипе.', time: '2 дня назад' },
+ ],
+};
+
+export const networkGraph = {
+ center: { id: 'me', name: 'Вы', initials: 'ВЫ', x: 50, y: 50 },
+ peers: [
+ { id: 'p1', name: 'Марина', initials: 'МК', x: 20, y: 24 },
+ { id: 'p2', name: 'Илья', initials: 'ИП', x: 80, y: 22 },
+ { id: 'p3', name: 'Елена', initials: 'ЕД', x: 18, y: 78 },
+ { id: 'p4', name: 'Никита', initials: 'НО', x: 82, y: 76 },
+ ],
+};
diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js
new file mode 100644
index 0000000..1235efb
--- /dev/null
+++ b/shine-UI/js/pages/channel-view.js
@@ -0,0 +1,41 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { channelPosts, channels } from '../mock-data.js?v=20260327192619';
+
+export const pageMeta = { id: 'channel-view', title: 'Канал' };
+
+export function render({ navigate, route }) {
+ const channelId = route.params.channelId || 'ch1';
+ const channel = channels.find((c) => c.id === channelId) || channels[0];
+ const posts = channelPosts[channelId] || [];
+
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: `Канал: ${channel.name}`,
+ leftAction: { label: '←', onClick: () => navigate('channels-list') },
+ })
+ );
+
+ const head = document.createElement('div');
+ head.className = 'card';
+ head.innerHTML = `
+
# ${channel.name}
+
${channel.description}
+
Публичный канал, режим только чтение
+ `;
+
+ const feed = document.createElement('div');
+ feed.className = 'stack';
+
+ posts.forEach((post) => {
+ const card = document.createElement('article');
+ card.className = 'card stack';
+ card.innerHTML = `
${post.title}${post.body}
`;
+ feed.append(card);
+ });
+
+ screen.append(head, feed);
+ return screen;
+}
diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js
new file mode 100644
index 0000000..5f440b5
--- /dev/null
+++ b/shine-UI/js/pages/channels-list.js
@@ -0,0 +1,42 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { channels } from '../mock-data.js?v=20260327192619';
+
+export const pageMeta = { id: 'channels-list', title: 'Каналы' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(renderHeader({ title: 'Каналы' }));
+
+ const search = document.createElement('div');
+ search.className = 'card';
+ search.textContent = 'Найти канал';
+ search.style.color = 'var(--text-muted)';
+
+ const list = document.createElement('div');
+ list.className = 'stack';
+
+ channels.forEach((channel) => {
+ const row = document.createElement('article');
+ row.className = 'list-item';
+ row.innerHTML = `
+
${channel.initials}
+
+
# ${channel.name}
+
${channel.description}
+
${channel.lastMessage}
+
+
+ Канал
+ ${channel.time}
+ ${channel.unread ? `${channel.unread}` : ''}
+
+ `;
+ row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
+ list.append(row);
+ });
+
+ screen.append(search, list);
+ return screen;
+}
diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js
new file mode 100644
index 0000000..ed55b16
--- /dev/null
+++ b/shine-UI/js/pages/chat-view.js
@@ -0,0 +1,58 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { directMessages } from '../mock-data.js?v=20260327192619';
+import { addChatMessage, getChatMessages } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'chat-view', title: 'Чат' };
+
+function renderLog(list, chatId) {
+ list.innerHTML = '';
+ const messages = getChatMessages(chatId);
+ messages.forEach((msg) => {
+ const bubble = document.createElement('div');
+ bubble.className = `bubble ${msg.from}`;
+ bubble.textContent = msg.text;
+ list.append(bubble);
+ });
+ list.scrollTop = list.scrollHeight;
+}
+
+export function render({ navigate, route }) {
+ const chatId = route.params.chatId || 'u1';
+ const contact = directMessages.find((d) => d.id === chatId) || directMessages[0];
+
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: `Чат: ${contact.name}`,
+ leftAction: { label: '←', onClick: () => navigate('messages-list') },
+ })
+ );
+
+ const wrap = document.createElement('div');
+ wrap.className = 'chat-wrap';
+
+ const log = document.createElement('div');
+ log.className = 'messages-log';
+
+ const form = document.createElement('form');
+ form.className = 'chat-input';
+ form.innerHTML = `
+
+
+ `;
+
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ const input = form.elements.message;
+ addChatMessage(chatId, input.value);
+ input.value = '';
+ renderLog(log, chatId);
+ });
+
+ renderLog(log, chatId);
+ wrap.append(log, form);
+ screen.append(wrap);
+ return screen;
+}
diff --git a/shine-UI/js/pages/connect-device-view.js b/shine-UI/js/pages/connect-device-view.js
new file mode 100644
index 0000000..4096559
--- /dev/null
+++ b/shine-UI/js/pages/connect-device-view.js
@@ -0,0 +1,103 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Подключить устройство',
+ leftAction: { label: '←', onClick: () => navigate('device-view') },
+ }),
+ );
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+ card.innerHTML = `
+
Выберите, какие ключи передать на подключаемое устройство
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const rootToggle = card.querySelector('#connect-root');
+ const blockchainToggle = card.querySelector('#connect-blockchain');
+ const deviceToggle = card.querySelector('#connect-device');
+ deviceToggle.checked = true;
+
+ rootToggle.addEventListener('change', () => {
+ state.deviceConnect.root = rootToggle.checked;
+ });
+ blockchainToggle.addEventListener('change', () => {
+ state.deviceConnect.blockchain = blockchainToggle.checked;
+ });
+ deviceToggle.addEventListener('change', () => {
+ state.deviceConnect.device = true;
+ deviceToggle.checked = true;
+ });
+
+ const helpModal = document.createElement('div');
+ helpModal.className = 'modal-shell';
+ helpModal.hidden = true;
+ helpModal.innerHTML = `
+
+
+
+
Техсправка
+
+
+
+
пользователь выбирает ключи для передачи
+
передать можно только существующие ключи
+
если ключа нет — он недоступен
+
blockchain key — можно передать или нет
+
root key — только если существует
+
device key передаётся всегда
+
подключение происходит напрямую через QR
+
сервер не используется
+
текущая логика: устройство 1 показывает QR, устройство 2 сканирует
+
обратный сценарий пока не реализован
+
+
+
+ `;
+
+ const openHelp = () => {
+ helpModal.hidden = false;
+ helpModal.querySelector('.modal-dialog').focus();
+ };
+
+ const closeHelp = () => {
+ helpModal.hidden = true;
+ };
+
+ card.querySelector('#tech-help').addEventListener('click', openHelp);
+ card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
+ card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
+
+ helpModal.addEventListener('click', (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.dataset.close === 'true') {
+ closeHelp();
+ }
+ });
+
+ helpModal.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ closeHelp();
+ }
+ });
+
+ screen.append(card, helpModal);
+ return screen;
+}
diff --git a/shine-UI/js/pages/contact-search-view.js b/shine-UI/js/pages/contact-search-view.js
new file mode 100644
index 0000000..e15f07f
--- /dev/null
+++ b/shine-UI/js/pages/contact-search-view.js
@@ -0,0 +1,129 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { contactDirectory, directMessages } from '../mock-data.js?v=20260327192619';
+import { ensureChat } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
+
+function getMatches(query) {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return [];
+
+ return contactDirectory
+ .filter((contact) => contact.name.toLowerCase().startsWith(normalized))
+ .slice(0, 5);
+}
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const input = document.createElement('input');
+ input.className = 'input';
+ input.type = 'text';
+ input.name = 'contact';
+ input.placeholder = 'Введите имя контакта';
+ input.autocomplete = 'off';
+ input.maxLength = 80;
+
+ const resultsCard = document.createElement('section');
+ resultsCard.className = 'card stack';
+ resultsCard.hidden = true;
+
+ const status = document.createElement('p');
+ status.className = 'meta-muted';
+
+ const resultsList = document.createElement('div');
+ resultsList.className = 'stack';
+
+ let latestMatches = [];
+
+ const renderResults = (matches, query) => {
+ latestMatches = matches;
+ resultsList.innerHTML = '';
+ resultsCard.hidden = false;
+
+ if (!query.trim()) {
+ status.textContent = 'Введите первые буквы имени, чтобы найти контакт.';
+ return;
+ }
+
+ if (!matches.length) {
+ status.textContent = 'Совпадений не найдено.';
+ return;
+ }
+
+ status.textContent = `Найдено пользователей: ${matches.length}`;
+
+ matches.forEach((contact) => {
+ const row = document.createElement('article');
+ row.className = 'list-item';
+ row.innerHTML = `
+
${contact.initials}
+
+
${contact.name}
+
${contact.about}
+
+
Контакт
+ `;
+ resultsList.append(row);
+ });
+ };
+
+ const searchButton = document.createElement('button');
+ searchButton.className = 'primary-btn';
+ searchButton.type = 'button';
+ searchButton.textContent = 'Найти';
+ searchButton.addEventListener('click', () => {
+ renderResults(getMatches(input.value), input.value);
+ });
+
+ const addButton = document.createElement('button');
+ addButton.className = 'ghost-btn';
+ addButton.type = 'button';
+ addButton.textContent = 'Добавить';
+ addButton.addEventListener('click', () => {
+ if (!latestMatches.length) {
+ status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.';
+ resultsCard.hidden = false;
+ return;
+ }
+
+ const contact = latestMatches[0];
+ const exists = directMessages.some((item) => item.id === contact.id);
+
+ if (!exists) {
+ directMessages.unshift({
+ id: contact.id,
+ name: contact.name,
+ initials: contact.initials,
+ lastMessage: 'Новый контакт добавлен. Можно начинать диалог.',
+ time: 'сейчас',
+ unread: 0,
+ });
+ }
+
+ ensureChat(contact.id);
+ navigate(`chat-view/${contact.id}`);
+ });
+
+ const controls = document.createElement('div');
+ controls.className = 'contact-search-actions';
+ controls.append(searchButton, addButton);
+
+ const formCard = document.createElement('section');
+ formCard.className = 'card stack';
+ formCard.append(input, controls);
+
+ resultsCard.append(status, resultsList);
+
+ screen.append(
+ renderHeader({
+ title: 'Поиск контактов',
+ leftAction: { label: '←', onClick: () => navigate('messages-list') },
+ }),
+ formCard,
+ resultsCard,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/device-camera-view.js b/shine-UI/js/pages/device-camera-view.js
new file mode 100644
index 0000000..0fbb87e
--- /dev/null
+++ b/shine-UI/js/pages/device-camera-view.js
@@ -0,0 +1,26 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+
+export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Подключить через камеру',
+ leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
+ }),
+ );
+
+ const frame = document.createElement('div');
+ frame.className = 'camera-shell';
+ frame.innerHTML = `
+
Область камеры (демо-заглушка)
+
+
Логика сканирования пока не реализована
+ `;
+
+ screen.append(frame);
+ return screen;
+}
diff --git a/shine-UI/js/pages/device-qr-view.js b/shine-UI/js/pages/device-qr-view.js
new file mode 100644
index 0000000..0d71173
--- /dev/null
+++ b/shine-UI/js/pages/device-qr-view.js
@@ -0,0 +1,36 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { profile } from '../mock-data.js?v=20260327192619';
+import { state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const selectedKeys = [];
+ if (state.deviceConnect.root) selectedKeys.push('root key');
+ if (state.deviceConnect.blockchain) selectedKeys.push('blockchain key');
+ if (state.deviceConnect.device) selectedKeys.push('device key');
+
+ screen.append(
+ renderHeader({
+ title: 'Показать QR-код',
+ leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
+ }),
+ );
+
+ const card = document.createElement('div');
+ card.className = 'card stack qr-card';
+ card.innerHTML = `
+

+
Логин пользователя: ${profile.login}
+
Передаваемые ключи: ${selectedKeys.join(', ')}
+
+ `;
+
+ card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view'));
+
+ screen.append(card);
+ return screen;
+}
diff --git a/shine-UI/js/pages/device-session-view.js b/shine-UI/js/pages/device-session-view.js
new file mode 100644
index 0000000..cc58476
--- /dev/null
+++ b/shine-UI/js/pages/device-session-view.js
@@ -0,0 +1,87 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { deviceSessions } from '../mock-data.js?v=20260327192619';
+
+export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' };
+
+function formatSessionTime(ms) {
+ return new Date(ms).toLocaleString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+export function render({ navigate, route }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const sessionId = route?.params?.sessionId || '';
+ const session = deviceSessions.find((item) => item.sessionId === sessionId) || deviceSessions[0];
+
+ screen.append(
+ renderHeader({
+ title: 'Сеанс устройства',
+ leftAction: { label: '←', onClick: () => navigate('device-view') },
+ }),
+ );
+
+ const details = document.createElement('div');
+ details.className = 'card stack';
+ details.innerHTML = `
+
sessionId
${session.sessionId}
+
clientInfoFromClient
${session.clientInfoFromClient}
+
clientInfoFromRequest
${session.clientInfoFromRequest}
+
+
дата/время
${formatSessionTime(session.lastAuthenticatedAtMs)}
+ `;
+
+ const actionBtn = document.createElement('button');
+ actionBtn.className = 'text-btn';
+ actionBtn.type = 'button';
+ actionBtn.textContent = 'Завершить сеанс';
+
+ const confirmModal = document.createElement('div');
+ confirmModal.className = 'modal-shell';
+ confirmModal.hidden = true;
+ confirmModal.innerHTML = `
+
+
+
Вы уверены, что хотите завершить этот сеанс?
+
+
+ `;
+
+ const openModal = () => {
+ confirmModal.hidden = false;
+ confirmModal.querySelector('.modal-dialog').focus();
+ };
+
+ const closeModal = () => {
+ confirmModal.hidden = true;
+ };
+
+ actionBtn.addEventListener('click', openModal);
+
+ confirmModal.querySelector('#confirm-session-ok').addEventListener('click', closeModal);
+
+ confirmModal.addEventListener('click', (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.dataset.close === 'true') {
+ closeModal();
+ }
+ });
+
+ confirmModal.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ closeModal();
+ }
+ });
+
+ screen.append(details, actionBtn, confirmModal);
+ return screen;
+}
diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js
new file mode 100644
index 0000000..1dec785
--- /dev/null
+++ b/shine-UI/js/pages/device-view.js
@@ -0,0 +1,138 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { deviceSessions } from '../mock-data.js?v=20260327192619';
+import { terminateCurrentSession } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'device-view', title: 'Устройства' };
+
+function formatSessionTime(ms) {
+ return new Date(ms).toLocaleString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Устройства',
+ leftAction: { label: '←', onClick: () => navigate('settings-view') },
+ }),
+ );
+
+ const actions = document.createElement('div');
+ actions.className = 'card stack';
+ actions.innerHTML = `
+
+
+ `;
+
+ actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
+ actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
+
+ const sessionsBlock = document.createElement('div');
+ sessionsBlock.className = 'card stack';
+
+ const currentSession = deviceSessions[0];
+ const otherSessions = deviceSessions.slice(1);
+
+ const createSessionItem = (session, isCurrent) => {
+ const item = document.createElement('button');
+ item.className = 'session-item';
+ item.type = 'button';
+ item.innerHTML = `
+
+
+ ${session.clientInfoFromClient}
+ ${session.geo}
+
+
${formatSessionTime(session.lastAuthenticatedAtMs)}
+
+ ${
+ isCurrent
+ ? '
Текущий сеанс
'
+ : ''
+ }
+ `;
+ item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
+ return item;
+ };
+
+ const currentMenu = document.createElement('div');
+ currentMenu.className = 'stack';
+ currentMenu.innerHTML = '
Текущий сеанс
';
+ currentMenu.append(createSessionItem(currentSession, true));
+
+ const endCurrentSessionBtn = document.createElement('button');
+ endCurrentSessionBtn.className = 'text-btn';
+ endCurrentSessionBtn.type = 'button';
+ endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
+ currentMenu.append(endCurrentSessionBtn);
+
+ const othersMenu = document.createElement('div');
+ othersMenu.className = 'stack';
+ othersMenu.innerHTML = '
Остальные активные сеансы
';
+
+ if (otherSessions.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'meta-muted';
+ empty.textContent = 'Других активных сеансов нет.';
+ othersMenu.append(empty);
+ } else {
+ otherSessions.forEach((session) => {
+ othersMenu.append(createSessionItem(session, false));
+ });
+ }
+
+ const confirmModal = document.createElement('div');
+ confirmModal.className = 'modal-shell';
+ confirmModal.hidden = true;
+ confirmModal.innerHTML = `
+
+
+
Завершить текущую сессию?
+
+
+ `;
+
+ const openModal = () => {
+ confirmModal.hidden = false;
+ confirmModal.querySelector('.modal-dialog').focus();
+ };
+
+ const closeModal = () => {
+ confirmModal.hidden = true;
+ };
+
+ endCurrentSessionBtn.addEventListener('click', openModal);
+
+ confirmModal.querySelector('#confirm-end-yes').addEventListener('click', () => {
+ terminateCurrentSession();
+ navigate('start-view');
+ });
+
+ confirmModal.addEventListener('click', (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.dataset.close === 'true') {
+ closeModal();
+ }
+ });
+
+ confirmModal.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ closeModal();
+ }
+ });
+
+ sessionsBlock.append(currentMenu, othersMenu);
+ screen.append(actions, sessionsBlock, confirmModal);
+ return screen;
+}
diff --git a/shine-UI/js/pages/entry-settings-view.js b/shine-UI/js/pages/entry-settings-view.js
new file mode 100644
index 0000000..6942022
--- /dev/null
+++ b/shine-UI/js/pages/entry-settings-view.js
@@ -0,0 +1,161 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
+
+const SERVER_FIELDS = [
+ { key: 'solanaServer', label: 'Адрес Solana сервера' },
+ { key: 'shineServer', label: 'Адрес сервера Сияние' },
+ { key: 'arweaveServer', label: 'Адрес сервера Arweave' },
+];
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const draft = {
+ language: state.entrySettings.language,
+ solanaServer: state.entrySettings.solanaServer,
+ shineServer: state.entrySettings.shineServer,
+ arweaveServer: state.entrySettings.arweaveServer,
+ statuses: { ...state.entrySettings.statuses },
+ };
+
+ const timers = new Map();
+
+ const body = document.createElement('div');
+ body.className = 'card stack';
+
+ const languageLabel = document.createElement('label');
+ languageLabel.className = 'stack';
+ languageLabel.innerHTML = `
Язык`;
+
+ const languageSelect = document.createElement('select');
+ languageSelect.className = 'select';
+ languageSelect.innerHTML = `
+
+
+ `;
+ languageSelect.value = draft.language;
+ languageSelect.addEventListener('change', () => {
+ draft.language = languageSelect.value;
+ });
+ languageLabel.append(languageSelect);
+
+ body.append(languageLabel);
+
+ SERVER_FIELDS.forEach((field) => {
+ const block = document.createElement('div');
+ block.className = 'stack';
+
+ const title = document.createElement('label');
+ title.className = 'field-label';
+ title.textContent = field.label;
+
+ const input = document.createElement('input');
+ input.className = 'input';
+ input.type = 'text';
+ input.value = draft[field.key];
+
+ const controls = document.createElement('div');
+ controls.className = 'row wrap-row';
+
+ const checkButton = document.createElement('button');
+ checkButton.className = 'ghost-btn server-check-btn';
+ checkButton.type = 'button';
+ checkButton.textContent = 'Проверить';
+
+ const status = document.createElement('span');
+ status.className = 'status-line';
+
+ const applyStatus = (value) => {
+ draft.statuses[field.key] = value;
+ checkButton.classList.remove('is-available', 'is-unavailable');
+ status.classList.remove('is-available', 'is-unavailable');
+
+ if (value === 'available') {
+ status.textContent = 'Доступен';
+ checkButton.classList.add('is-available');
+ status.classList.add('is-available');
+ } else if (value === 'unavailable') {
+ status.textContent = 'Недоступен';
+ checkButton.classList.add('is-unavailable');
+ status.classList.add('is-unavailable');
+ } else {
+ status.textContent = 'Статус не проверен';
+ }
+ };
+
+ const runCheck = () => {
+ draft[field.key] = input.value.trim();
+ applyStatus(checkServerAvailability(input.value));
+ };
+
+ applyStatus(draft.statuses[field.key]);
+
+ checkButton.addEventListener('click', runCheck);
+ input.addEventListener('input', () => {
+ draft[field.key] = input.value;
+ applyStatus('idle');
+ window.clearTimeout(timers.get(field.key));
+ timers.set(field.key, window.setTimeout(runCheck, 3000));
+ });
+ input.addEventListener('blur', runCheck);
+ input.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ runCheck();
+ }
+ });
+
+ controls.append(checkButton, status);
+ block.append(title, input, controls);
+ body.append(block);
+ });
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+
+ const cancelButton = document.createElement('button');
+ cancelButton.className = 'ghost-btn';
+ cancelButton.type = 'button';
+ cancelButton.textContent = 'Отмена';
+ cancelButton.addEventListener('click', () => navigate('start-view'));
+
+ const saveButton = document.createElement('button');
+ saveButton.className = 'primary-btn';
+ saveButton.type = 'button';
+ saveButton.textContent = 'Сохранить';
+ saveButton.addEventListener('click', () => {
+ saveEntrySettings(draft);
+ navigate('start-view');
+ });
+
+ actions.append(cancelButton, saveButton);
+
+ const help = document.createElement('button');
+ help.className = 'help-fab';
+ help.type = 'button';
+ help.textContent = '?';
+ help.addEventListener('click', () => {
+ window.alert(
+ 'Текст для разработчиков: после ввода адреса любого сервера автопроверка запускается по кнопке "Проверить", после перехода в другое поле или если подождать больше 3 секунд. Зелёный статус означает доступность, красный — недоступность.',
+ );
+ });
+
+ screen.append(
+ renderHeader({
+ title: 'Настройки входа',
+ leftAction: { label: '←', onClick: () => navigate('start-view') },
+ }),
+ body,
+ actions,
+ help,
+ );
+
+ screen.cleanup = () => {
+ timers.forEach((timerId) => window.clearTimeout(timerId));
+ };
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/key-storage-view.js b/shine-UI/js/pages/key-storage-view.js
new file mode 100644
index 0000000..6c5e8a7
--- /dev/null
+++ b/shine-UI/js/pages/key-storage-view.js
@@ -0,0 +1,99 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { authorizeSession, state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+
+ const rootToggle = document.createElement('input');
+ rootToggle.type = 'checkbox';
+ rootToggle.checked = state.keyStorage.saveRoot;
+ rootToggle.addEventListener('change', () => {
+ state.keyStorage.saveRoot = rootToggle.checked;
+ if (rootToggle.checked) {
+ window.alert('Мы советуем не сохранять главный ключ на устройстве, он используется только для смены паролей и основных настроек.');
+ }
+ });
+
+ const blockchainToggle = document.createElement('input');
+ blockchainToggle.type = 'checkbox';
+ blockchainToggle.checked = state.keyStorage.saveBlockchain;
+ blockchainToggle.addEventListener('change', () => {
+ state.keyStorage.saveBlockchain = blockchainToggle.checked;
+ });
+
+ const deviceToggle = document.createElement('input');
+ deviceToggle.type = 'checkbox';
+ deviceToggle.checked = true;
+ deviceToggle.disabled = true;
+
+ card.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ card.children[0].querySelector('label').prepend(rootToggle);
+ card.children[1].querySelector('label').prepend(blockchainToggle);
+ card.children[2].querySelector('label').prepend(deviceToggle);
+
+ const rootInput = card.children[0].querySelector('.input');
+ rootInput.addEventListener('input', () => {
+ state.keyStorage.rootKey = rootInput.value;
+ });
+
+ const blockchainInput = card.children[1].querySelector('.input');
+ blockchainInput.addEventListener('input', () => {
+ state.keyStorage.blockchainKey = blockchainInput.value;
+ });
+
+ const deviceInput = card.children[2].querySelector('.input');
+ deviceInput.addEventListener('input', () => {
+ state.keyStorage.deviceKey = deviceInput.value;
+ });
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+
+ const cancelButton = document.createElement('button');
+ cancelButton.className = 'ghost-btn';
+ cancelButton.type = 'button';
+ cancelButton.textContent = 'Отмена';
+ cancelButton.addEventListener('click', () => navigate('login-password-view'));
+
+ const okButton = document.createElement('button');
+ okButton.className = 'primary-btn';
+ okButton.type = 'button';
+ okButton.textContent = 'OK';
+ okButton.addEventListener('click', () => {
+ authorizeSession();
+ navigate('profile-view');
+ });
+
+ actions.append(cancelButton, okButton);
+
+ screen.append(
+ renderHeader({
+ title: 'Какие ключи сохранить',
+ leftAction: { label: '←', onClick: () => navigate('login-password-view') },
+ }),
+ card,
+ actions,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/language-view.js b/shine-UI/js/pages/language-view.js
new file mode 100644
index 0000000..8aa2c8a
--- /dev/null
+++ b/shine-UI/js/pages/language-view.js
@@ -0,0 +1,43 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'language-view', title: 'Язык' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Язык',
+ leftAction: { label: '←', onClick: () => navigate('settings-view') },
+ }),
+ );
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+ card.innerHTML = `
+
+
+ `;
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+ actions.innerHTML = `
+
+
+ `;
+
+ actions.querySelector('#language-ok').addEventListener('click', () => {
+ const selected = card.querySelector('input[name="language"]:checked');
+ if (selected) {
+ state.entrySettings.language = selected.value;
+ }
+ navigate('settings-view');
+ });
+
+ actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view'));
+
+ screen.append(card, actions);
+ return screen;
+}
diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js
new file mode 100644
index 0000000..043a747
--- /dev/null
+++ b/shine-UI/js/pages/login-camera-view.js
@@ -0,0 +1,67 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+
+export const pageMeta = { id: 'login-camera-view', title: 'Войти по камере', showAppChrome: false };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const frame = document.createElement('div');
+ frame.className = 'camera-shell';
+ frame.innerHTML = `
+
+
+
Наведите QR-код в рамку
+ `;
+
+ const video = frame.querySelector('video');
+ let stream = null;
+
+ const stopCamera = () => {
+ if (stream) {
+ stream.getTracks().forEach((track) => track.stop());
+ stream = null;
+ }
+ };
+
+ if (navigator.mediaDevices?.getUserMedia) {
+ navigator.mediaDevices
+ .getUserMedia({ video: { facingMode: 'environment' }, audio: false })
+ .then((nextStream) => {
+ stream = nextStream;
+ video.srcObject = nextStream;
+ })
+ .catch(() => {
+ frame.insertAdjacentHTML('beforeend', '
Не удалось открыть камеру. Проверьте разрешения браузера.
');
+ });
+ } else {
+ frame.insertAdjacentHTML('beforeend', '
Камера не поддерживается в этом браузере.
');
+ }
+
+ const backButton = document.createElement('button');
+ backButton.className = 'ghost-btn';
+ backButton.type = 'button';
+ backButton.textContent = 'Назад';
+ backButton.addEventListener('click', () => {
+ stopCamera();
+ navigate('login-view');
+ });
+
+ screen.append(
+ renderHeader({
+ title: 'Войти по камере',
+ leftAction: {
+ label: '←',
+ onClick: () => {
+ stopCamera();
+ navigate('login-view');
+ },
+ },
+ }),
+ frame,
+ backButton,
+ );
+
+ screen.cleanup = stopCamera;
+ return screen;
+}
diff --git a/shine-UI/js/pages/login-password-view.js b/shine-UI/js/pages/login-password-view.js
new file mode 100644
index 0000000..dcaf910
--- /dev/null
+++ b/shine-UI/js/pages/login-password-view.js
@@ -0,0 +1,75 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const form = document.createElement('div');
+ form.className = 'card stack';
+
+ const loginInput = document.createElement('input');
+ loginInput.className = 'input';
+ loginInput.type = 'text';
+ loginInput.value = state.loginDraft.login;
+ loginInput.placeholder = 'Введите логин';
+
+ const passwordInput = document.createElement('input');
+ passwordInput.className = 'input';
+ passwordInput.type = 'password';
+ passwordInput.value = state.loginDraft.password;
+ passwordInput.placeholder = 'Введите пароль';
+
+ const advanced = document.createElement('label');
+ advanced.className = 'checkbox-row';
+ advanced.innerHTML = `
Расширенные настройки`;
+ const advancedInput = advanced.querySelector('input');
+ advancedInput.addEventListener('change', () => {
+ if (advancedInput.checked) {
+ window.alert('Расширенные настройки в стартовой версии приложения пока не используются.');
+ advancedInput.checked = false;
+ }
+ });
+
+ form.innerHTML = `
+
+
+ `;
+ form.children[0].append(loginInput);
+ form.children[1].append(passwordInput);
+ form.append(advanced);
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+
+ const backButton = document.createElement('button');
+ backButton.className = 'ghost-btn';
+ backButton.type = 'button';
+ backButton.textContent = 'Назад';
+ backButton.addEventListener('click', () => navigate('login-view'));
+
+ const enterButton = document.createElement('button');
+ enterButton.className = 'primary-btn';
+ enterButton.type = 'button';
+ enterButton.textContent = 'Войти';
+ enterButton.addEventListener('click', () => {
+ state.loginDraft.login = loginInput.value;
+ state.loginDraft.password = passwordInput.value;
+ navigate('key-storage-view');
+ });
+
+ actions.append(backButton, enterButton);
+
+ screen.append(
+ renderHeader({
+ title: 'Войти по логину',
+ leftAction: { label: '←', onClick: () => navigate('login-view') },
+ }),
+ form,
+ actions,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/login-view.js b/shine-UI/js/pages/login-view.js
new file mode 100644
index 0000000..cb87553
--- /dev/null
+++ b/shine-UI/js/pages/login-view.js
@@ -0,0 +1,72 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+
+export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false };
+
+function createQrCode() {
+ const svgNS = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(svgNS, 'svg');
+ svg.setAttribute('viewBox', '0 0 100 100');
+ svg.classList.add('qr-code');
+
+ const cells = [
+ [6, 6, 22, 22], [72, 6, 22, 22], [6, 72, 22, 22], [14, 14, 6, 6], [80, 14, 6, 6], [14, 80, 6, 6],
+ [38, 12, 8, 8], [52, 12, 8, 8], [38, 26, 8, 8], [52, 26, 8, 8], [32, 40, 10, 10], [48, 40, 10, 10],
+ [64, 40, 10, 10], [40, 56, 8, 8], [56, 56, 8, 8], [72, 56, 8, 8], [32, 72, 8, 8], [48, 72, 8, 8],
+ [64, 72, 8, 8], [48, 86, 8, 8],
+ ];
+
+ cells.forEach(([x, y, width, height]) => {
+ const rect = document.createElementNS(svgNS, 'rect');
+ rect.setAttribute('x', x);
+ rect.setAttribute('y', y);
+ rect.setAttribute('width', width);
+ rect.setAttribute('height', height);
+ rect.setAttribute('rx', '2');
+ svg.append(rect);
+ });
+
+ return svg;
+}
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const qrCard = document.createElement('div');
+ qrCard.className = 'card stack qr-card';
+ qrCard.append(createQrCode());
+
+ const cameraButton = document.createElement('button');
+ cameraButton.className = 'primary-btn';
+ cameraButton.type = 'button';
+ cameraButton.textContent = 'Войти по камере';
+ cameraButton.addEventListener('click', () => navigate('login-camera-view'));
+
+ const loginButton = document.createElement('button');
+ loginButton.className = 'ghost-btn';
+ loginButton.type = 'button';
+ loginButton.textContent = 'Войти по логину';
+ loginButton.addEventListener('click', () => navigate('login-password-view'));
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-actions';
+ actions.append(cameraButton, loginButton);
+
+ const backButton = document.createElement('button');
+ backButton.className = 'ghost-btn';
+ backButton.type = 'button';
+ backButton.textContent = 'Назад';
+ backButton.addEventListener('click', () => navigate('start-view'));
+
+ screen.append(
+ renderHeader({
+ title: 'Войти',
+ leftAction: { label: '←', onClick: () => navigate('start-view') },
+ }),
+ qrCard,
+ actions,
+ backButton,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js
new file mode 100644
index 0000000..c19fb33
--- /dev/null
+++ b/shine-UI/js/pages/messages-list.js
@@ -0,0 +1,42 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { directMessages } from '../mock-data.js?v=20260327192619';
+
+export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Личные сообщения',
+ rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
+ }),
+ );
+
+ const list = document.createElement('div');
+ list.className = 'stack';
+
+ directMessages.forEach((item) => {
+ const row = document.createElement('article');
+ row.className = 'list-item';
+ row.innerHTML = `
+
${item.initials}
+
+
+ ${item.name}
+
+
${item.lastMessage}
+
+
+ ${item.time}
+ ${item.unread ? `${item.unread}` : ''}
+
+ `;
+ row.addEventListener('click', () => navigate(`chat-view/${item.id}`));
+ list.append(row);
+ });
+
+ screen.append(list);
+ return screen;
+}
diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js
new file mode 100644
index 0000000..d26e90a
--- /dev/null
+++ b/shine-UI/js/pages/network-view.js
@@ -0,0 +1,77 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { networkGraph } from '../mock-data.js?v=20260327192619';
+
+export const pageMeta = { id: 'network-view', title: 'Связи' };
+
+function toPoint(v) {
+ return `${v.x}%`;
+}
+
+function showHelpModal() {
+ const root = document.getElementById('modal-root');
+ root.innerHTML = `
+
+
+
Справка по схеме связей
+
В центре находишься ты.
+
Рядом показаны друзья первого уровня.
+
Далее могут существовать друзья второго уровня.
+
При одном нажатии на узел можно показать его связи.
+
При двойном нажатии узел может переместиться в центр.
+
При долгом удержании может открываться меню действий.
+
Логика схемы строится на одном запросе связей пользователя, дальше дерево достраивается на его основе.
+
+
+
+ `;
+
+ root.querySelector('#close-network-help').addEventListener('click', () => {
+ root.innerHTML = '';
+ });
+}
+
+export function render() {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const header = renderHeader({
+ title: 'Связи',
+ rightActions: [{ label: 'Справка', onClick: showHelpModal }],
+ });
+
+ const board = document.createElement('div');
+ board.className = 'network-board';
+
+ const lines = networkGraph.peers
+ .map(
+ (peer) =>
+ `
`
+ )
+ .join('');
+
+ board.innerHTML = `
`;
+
+ const centerNode = document.createElement('div');
+ centerNode.className = 'node center';
+ centerNode.style.left = `${networkGraph.center.x}%`;
+ centerNode.style.top = `${networkGraph.center.y}%`;
+ centerNode.innerHTML = `
${networkGraph.center.initials}
${networkGraph.center.name}
`;
+
+ board.append(centerNode);
+
+ networkGraph.peers.forEach((peer) => {
+ const node = document.createElement('div');
+ node.className = 'node';
+ node.style.left = `${peer.x}%`;
+ node.style.top = `${peer.y}%`;
+ node.innerHTML = `
${peer.initials}
${peer.name}
`;
+ board.append(node);
+ });
+
+ const note = document.createElement('p');
+ note.className = 'meta-muted';
+ note.textContent = 'Схема статичная для демо, архитектура подготовлена под дальнейшую интерактивность.';
+
+ screen.append(header, board, note);
+ return screen;
+}
diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js
new file mode 100644
index 0000000..6c24159
--- /dev/null
+++ b/shine-UI/js/pages/notifications-view.js
@@ -0,0 +1,48 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { notifications } from '../mock-data.js?v=20260327192619';
+import { state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
+
+function renderList(container) {
+ const active = state.notificationsTab;
+ const items = notifications[active] || [];
+ container.innerHTML = '';
+
+ items.forEach((item) => {
+ const card = document.createElement('article');
+ card.className = 'card stack';
+ card.innerHTML = `
${item.title}${item.text}
${item.time}
`;
+ container.append(card);
+ });
+}
+
+export function render() {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(renderHeader({ title: 'Уведомления' }));
+
+ const tabs = document.createElement('div');
+ tabs.className = 'tabs';
+ tabs.innerHTML = `
+
+
+ `;
+
+ const list = document.createElement('div');
+ list.className = 'stack';
+ renderList(list);
+
+ tabs.querySelectorAll('.tab-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ state.notificationsTab = btn.dataset.tab;
+ tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active'));
+ btn.classList.add('active');
+ renderList(list);
+ });
+ });
+
+ screen.append(tabs, list);
+ return screen;
+}
diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js
new file mode 100644
index 0000000..b50f613
--- /dev/null
+++ b/shine-UI/js/pages/profile-view.js
@@ -0,0 +1,107 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { profile } from '../mock-data.js?v=20260327192619';
+
+export const pageMeta = { id: 'profile-view', title: 'Профиль' };
+
+export function render({ navigate }) {
+ const badgeHelp = {
+ official: {
+ title: 'Официальный аккаунт',
+ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.',
+ },
+ shine: {
+ title: 'Сияющий',
+ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.',
+ },
+ };
+
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Профиль',
+ rightActions: [
+ { label: 'Кошелёк', onClick: () => navigate('wallet-view') },
+ { label: 'Настройки', onClick: () => navigate('settings-view') },
+ ],
+ })
+ );
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+ card.innerHTML = `
+
+
${profile.avatarInitials}
+
+
+
+
+
+
+
${profile.name}
+
${profile.login}
+
+
+
Телефон: ${profile.phone}
+
Адрес: ${profile.address}
+
Email: ${profile.email}
+
Соцсети: ${profile.socials}
+
+ `;
+
+ const modal = document.createElement('div');
+ modal.className = 'profile-help-modal';
+ modal.hidden = true;
+ modal.innerHTML = `
+
+
+
+
+
Управление функцией
+
+
+
+
+
+
+ `;
+
+ const titleEl = modal.querySelector('#profile-help-title');
+ const textEl = modal.querySelector('.profile-help-text');
+ const dialogEl = modal.querySelector('.profile-help-dialog');
+
+ function closeModal() {
+ modal.hidden = true;
+ }
+
+ function openModal(type) {
+ const content = badgeHelp[type];
+ if (!content) return;
+
+ titleEl.textContent = content.title;
+ textEl.textContent = content.text;
+ modal.hidden = false;
+ dialogEl.focus();
+ }
+
+ card.querySelectorAll('.profile-badge-trigger').forEach((button) => {
+ button.addEventListener('click', () => openModal(button.dataset.badge));
+ });
+
+ modal.addEventListener('click', (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) {
+ closeModal();
+ }
+ });
+
+ modal.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ closeModal();
+ }
+ });
+
+ screen.append(card, modal);
+ return screen;
+}
diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js
new file mode 100644
index 0000000..4aa0be7
--- /dev/null
+++ b/shine-UI/js/pages/register-view.js
@@ -0,0 +1,75 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const form = document.createElement('div');
+ form.className = 'card stack';
+
+ const loginInput = document.createElement('input');
+ loginInput.className = 'input';
+ loginInput.type = 'text';
+ loginInput.value = state.registrationDraft.login;
+ loginInput.placeholder = 'Введите логин';
+
+ const passwordInput = document.createElement('input');
+ passwordInput.className = 'input';
+ passwordInput.type = 'password';
+ passwordInput.value = state.registrationDraft.password;
+ passwordInput.placeholder = 'Введите пароль';
+
+ const advanced = document.createElement('label');
+ advanced.className = 'checkbox-row';
+ advanced.innerHTML = `
Расширенные настройки`;
+ const advancedInput = advanced.querySelector('input');
+ advancedInput.addEventListener('change', () => {
+ if (advancedInput.checked) {
+ window.alert('Расширенные настройки в стартовой версии не работают и не будут работать.');
+ advancedInput.checked = false;
+ }
+ });
+
+ form.innerHTML = `
+
+
+ `;
+ form.children[0].append(loginInput);
+ form.children[1].append(passwordInput);
+ form.append(advanced);
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+
+ const backButton = document.createElement('button');
+ backButton.className = 'ghost-btn';
+ backButton.type = 'button';
+ backButton.textContent = 'Назад';
+ backButton.addEventListener('click', () => navigate('start-view'));
+
+ const nextButton = document.createElement('button');
+ nextButton.className = 'primary-btn';
+ nextButton.type = 'button';
+ nextButton.textContent = 'Далее';
+ nextButton.addEventListener('click', () => {
+ state.registrationDraft.login = loginInput.value;
+ state.registrationDraft.password = passwordInput.value;
+ navigate('registration-payment-view');
+ });
+
+ actions.append(backButton, nextButton);
+
+ screen.append(
+ renderHeader({
+ title: 'Зарегистрироваться',
+ leftAction: { label: '←', onClick: () => navigate('start-view') },
+ }),
+ form,
+ actions,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js
new file mode 100644
index 0000000..fd6480a
--- /dev/null
+++ b/shine-UI/js/pages/registration-keys-view.js
@@ -0,0 +1,92 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { authorizeSession, state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const normalizedLogin = (state.registrationDraft.login || '').trim();
+ const displayLogin = normalizedLogin || '@new.user';
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+
+ const title = document.createElement('p');
+ title.className = 'auth-copy';
+ title.textContent = `Поздравляю, ваш логин ${displayLogin} зарегистрирован.`;
+
+ const question = document.createElement('p');
+ question.className = 'auth-copy';
+ question.textContent = 'Какие ключи вы хотите сохранить на этом устройстве?';
+
+ const rootToggle = document.createElement('input');
+ rootToggle.type = 'checkbox';
+ rootToggle.checked = state.keyStorage.saveRoot;
+
+ const blockchainToggle = document.createElement('input');
+ blockchainToggle.type = 'checkbox';
+ blockchainToggle.checked = state.keyStorage.saveBlockchain;
+
+ const deviceToggle = document.createElement('input');
+ deviceToggle.type = 'checkbox';
+ deviceToggle.checked = state.keyStorage.saveDevice;
+
+ rootToggle.addEventListener('change', () => {
+ state.keyStorage.saveRoot = rootToggle.checked;
+ });
+
+ blockchainToggle.addEventListener('change', () => {
+ state.keyStorage.saveBlockchain = blockchainToggle.checked;
+ });
+
+ deviceToggle.addEventListener('change', () => {
+ state.keyStorage.saveDevice = deviceToggle.checked;
+ });
+
+ const rootRow = document.createElement('label');
+ rootRow.className = 'checkbox-row';
+ rootRow.append(rootToggle, document.createTextNode('root key'));
+
+ const blockchainRow = document.createElement('label');
+ blockchainRow.className = 'checkbox-row';
+ blockchainRow.append(blockchainToggle, document.createTextNode('blockchain key'));
+
+ const deviceRow = document.createElement('label');
+ deviceRow.className = 'checkbox-row';
+ deviceRow.append(deviceToggle, document.createTextNode('device key'));
+
+ card.append(title, question, rootRow, deviceRow, blockchainRow);
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+
+ const cancelButton = document.createElement('button');
+ cancelButton.className = 'ghost-btn';
+ cancelButton.type = 'button';
+ cancelButton.textContent = 'Отмена';
+ cancelButton.addEventListener('click', () => navigate('start-view'));
+
+ const okButton = document.createElement('button');
+ okButton.className = 'primary-btn';
+ okButton.type = 'button';
+ okButton.textContent = 'OK';
+ okButton.addEventListener('click', () => {
+ authorizeSession();
+ navigate('profile-view');
+ });
+
+ actions.append(cancelButton, okButton);
+
+ screen.append(
+ renderHeader({
+ title: 'Сохранение ключей',
+ leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
+ }),
+ card,
+ actions,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js
new file mode 100644
index 0000000..fce0b1c
--- /dev/null
+++ b/shine-UI/js/pages/registration-payment-view.js
@@ -0,0 +1,93 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { refreshRegistrationBalance, state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+
+ const walletValue = document.createElement('input');
+ walletValue.className = 'input';
+ walletValue.type = 'text';
+ walletValue.value = state.registrationPayment.walletAddress;
+ walletValue.addEventListener('input', () => {
+ state.registrationPayment.walletAddress = walletValue.value;
+ });
+
+ const walletRow = document.createElement('div');
+ walletRow.className = 'inline-input-row';
+
+ const copyButton = document.createElement('button');
+ copyButton.className = 'ghost-btn';
+ copyButton.type = 'button';
+ copyButton.textContent = 'Скопировать номер';
+ copyButton.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(walletValue.value);
+ copyButton.textContent = 'Скопировано';
+ window.setTimeout(() => {
+ copyButton.textContent = 'Скопировать номер';
+ }, 1500);
+ } catch {
+ window.alert('Не удалось скопировать номер кошелька.');
+ }
+ });
+
+ walletRow.append(walletValue, copyButton);
+
+ const balanceRow = document.createElement('div');
+ balanceRow.className = 'row wrap-row';
+
+ const balanceValue = document.createElement('strong');
+ balanceValue.textContent = `${state.registrationPayment.balanceSOL} SOL`;
+
+ const refreshButton = document.createElement('button');
+ refreshButton.className = 'square-btn';
+ refreshButton.type = 'button';
+ refreshButton.textContent = '↻';
+ refreshButton.title = 'Обновить';
+ refreshButton.addEventListener('click', () => {
+ balanceValue.textContent = `${refreshRegistrationBalance()} SOL`;
+ });
+
+ balanceRow.append(balanceValue, refreshButton);
+
+ const topupButton = document.createElement('button');
+ topupButton.className = 'ghost-btn';
+ topupButton.type = 'button';
+ topupButton.textContent = 'Пополнить счет';
+ topupButton.addEventListener('click', () => navigate('topup-view'));
+
+ const submitButton = document.createElement('button');
+ submitButton.className = 'primary-btn';
+ submitButton.type = 'button';
+ submitButton.textContent = 'Зарегистрироваться';
+ submitButton.addEventListener('click', () => {
+ navigate('registration-keys-view');
+ });
+
+ card.innerHTML = `
+
Для регистрации в Solana нужно заплатить 0,01 SOL (примерно 1 доллар).
+
+
+ Баланс
+
+ `;
+ card.children[1].append(walletRow);
+ card.children[2].append(balanceRow);
+ card.append(topupButton, submitButton);
+
+ screen.append(
+ renderHeader({
+ title: 'Оплата регистрации',
+ leftAction: { label: '←', onClick: () => navigate('register-view') },
+ }),
+ card,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/server-settings-view.js b/shine-UI/js/pages/server-settings-view.js
new file mode 100644
index 0000000..3bc5070
--- /dev/null
+++ b/shine-UI/js/pages/server-settings-view.js
@@ -0,0 +1,143 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { checkServerAvailability, saveEntrySettings, state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'server-settings-view', title: 'Настройки серверов' };
+
+const SERVER_FIELDS = [
+ { key: 'solanaServer', label: 'Адрес Solana сервера' },
+ { key: 'shineServer', label: 'Адрес сервера Сияние' },
+ { key: 'arweaveServer', label: 'Адрес сервера Arweave' },
+];
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const draft = {
+ language: state.entrySettings.language,
+ solanaServer: state.entrySettings.solanaServer,
+ shineServer: state.entrySettings.shineServer,
+ arweaveServer: state.entrySettings.arweaveServer,
+ statuses: { ...state.entrySettings.statuses },
+ };
+
+ const timers = new Map();
+
+ const body = document.createElement('div');
+ body.className = 'card stack';
+
+ SERVER_FIELDS.forEach((field) => {
+ const block = document.createElement('div');
+ block.className = 'stack';
+
+ const title = document.createElement('label');
+ title.className = 'field-label';
+ title.textContent = field.label;
+
+ const input = document.createElement('input');
+ input.className = 'input';
+ input.type = 'text';
+ input.value = draft[field.key];
+
+ const controls = document.createElement('div');
+ controls.className = 'row wrap-row';
+
+ const checkButton = document.createElement('button');
+ checkButton.className = 'ghost-btn server-check-btn';
+ checkButton.type = 'button';
+ checkButton.textContent = 'Проверить';
+
+ const status = document.createElement('span');
+ status.className = 'status-line';
+
+ const applyStatus = (value) => {
+ draft.statuses[field.key] = value;
+ checkButton.classList.remove('is-available', 'is-unavailable');
+ status.classList.remove('is-available', 'is-unavailable');
+
+ if (value === 'available') {
+ status.textContent = 'Доступен';
+ checkButton.classList.add('is-available');
+ status.classList.add('is-available');
+ } else if (value === 'unavailable') {
+ status.textContent = 'Недоступен';
+ checkButton.classList.add('is-unavailable');
+ status.classList.add('is-unavailable');
+ } else {
+ status.textContent = 'Статус не проверен';
+ }
+ };
+
+ const runCheck = () => {
+ draft[field.key] = input.value.trim();
+ applyStatus(checkServerAvailability(input.value));
+ };
+
+ applyStatus(draft.statuses[field.key]);
+
+ checkButton.addEventListener('click', runCheck);
+ input.addEventListener('input', () => {
+ draft[field.key] = input.value;
+ applyStatus('idle');
+ window.clearTimeout(timers.get(field.key));
+ timers.set(field.key, window.setTimeout(runCheck, 3000));
+ });
+ input.addEventListener('blur', runCheck);
+ input.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ runCheck();
+ }
+ });
+
+ controls.append(checkButton, status);
+ block.append(title, input, controls);
+ body.append(block);
+ });
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+
+ const cancelButton = document.createElement('button');
+ cancelButton.className = 'ghost-btn';
+ cancelButton.type = 'button';
+ cancelButton.textContent = 'Отмена';
+ cancelButton.addEventListener('click', () => navigate('settings-view'));
+
+ const saveButton = document.createElement('button');
+ saveButton.className = 'primary-btn';
+ saveButton.type = 'button';
+ saveButton.textContent = 'Сохранить';
+ saveButton.addEventListener('click', () => {
+ saveEntrySettings(draft);
+ navigate('settings-view');
+ });
+
+ actions.append(cancelButton, saveButton);
+
+ const help = document.createElement('button');
+ help.className = 'help-fab';
+ help.type = 'button';
+ help.textContent = '?';
+ help.addEventListener('click', () => {
+ window.alert(
+ 'Текст для разработчиков: после ввода адреса любого сервера автопроверка запускается по кнопке "Проверить", после перехода в другое поле или если подождать больше 3 секунд. Зелёный статус означает доступность, красный — недоступность.',
+ );
+ });
+
+ screen.append(
+ renderHeader({
+ title: 'Настройки серверов',
+ leftAction: { label: '←', onClick: () => navigate('settings-view') },
+ }),
+ body,
+ actions,
+ help,
+ );
+
+ screen.cleanup = () => {
+ timers.forEach((timerId) => window.clearTimeout(timerId));
+ };
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js
new file mode 100644
index 0000000..700475b
--- /dev/null
+++ b/shine-UI/js/pages/settings-view.js
@@ -0,0 +1,30 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+
+export const pageMeta = { id: 'settings-view', title: 'Настройки' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Настройки',
+ leftAction: { label: '←', onClick: () => navigate('profile-view') },
+ }),
+ );
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+ card.innerHTML = `
+
+
+
+ `;
+
+ card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
+ card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
+ card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
+
+ screen.append(card);
+ return screen;
+}
diff --git a/shine-UI/js/pages/show-keys-view.js b/shine-UI/js/pages/show-keys-view.js
new file mode 100644
index 0000000..3b1e5fc
--- /dev/null
+++ b/shine-UI/js/pages/show-keys-view.js
@@ -0,0 +1,128 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+
+export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
+
+function randomKey(length = 44) {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz123456789';
+ let result = '';
+ for (let i = 0; i < length; i += 1) {
+ result += chars[Math.floor(Math.random() * chars.length)];
+ }
+ return result;
+}
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const keys = {
+ root: randomKey(),
+ blockchain: randomKey(),
+ device: randomKey(),
+ };
+
+ const visible = {
+ root: false,
+ blockchain: false,
+ device: false,
+ };
+
+ screen.append(
+ renderHeader({
+ title: 'Показать ключи',
+ leftAction: { label: '←', onClick: () => navigate('device-view') },
+ }),
+ );
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+
+ const renderField = (id, label) => {
+ const row = document.createElement('div');
+ row.className = 'key-card stack';
+ row.innerHTML = `
+
+ ${label}
+
+
+
*****
+ `;
+ return row;
+ };
+
+ card.append(renderField('root', 'root key'), renderField('blockchain', 'blockchain key'), renderField('device', 'device key'));
+
+ const updateField = (id) => {
+ const valueEl = card.querySelector(`[data-value="${id}"]`);
+ const btnEl = card.querySelector(`[data-toggle="${id}"]`);
+ valueEl.textContent = visible[id] ? keys[id] : '*****';
+ btnEl.textContent = visible[id] ? 'Скрыть' : 'Показать';
+ };
+
+ card.querySelectorAll('[data-toggle]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const { toggle } = button.dataset;
+ visible[toggle] = !visible[toggle];
+ updateField(toggle);
+ });
+ });
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+ actions.innerHTML = `
+
+
+ `;
+
+ const confirmModal = document.createElement('div');
+ confirmModal.className = 'modal-shell';
+ confirmModal.hidden = true;
+ confirmModal.innerHTML = `
+
+
+
Вы уверены, что хотите изменить ключи?
+
+
+ `;
+
+ const openModal = () => {
+ confirmModal.hidden = false;
+ confirmModal.querySelector('.modal-dialog').focus();
+ };
+
+ const closeModal = () => {
+ confirmModal.hidden = true;
+ };
+
+ actions.querySelector('#save-keys').addEventListener('click', openModal);
+ actions.querySelector('#cancel-keys').addEventListener('click', () => navigate('device-view'));
+
+ confirmModal.querySelector('#confirm-keys-ok').addEventListener('click', () => {
+ keys.root = randomKey();
+ keys.blockchain = randomKey();
+ keys.device = randomKey();
+ updateField('root');
+ updateField('blockchain');
+ updateField('device');
+ closeModal();
+ });
+
+ confirmModal.addEventListener('click', (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.dataset.close === 'true') {
+ closeModal();
+ }
+ });
+
+ confirmModal.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ closeModal();
+ }
+ });
+
+ screen.append(card, actions, confirmModal);
+ return screen;
+}
diff --git a/shine-UI/js/pages/start-view.js b/shine-UI/js/pages/start-view.js
new file mode 100644
index 0000000..eb2c0c4
--- /dev/null
+++ b/shine-UI/js/pages/start-view.js
@@ -0,0 +1,53 @@
+import { clearStartHint, state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'auth-screen stack';
+
+ const logo = document.createElement('img');
+ logo.className = 'auth-logo';
+ logo.src = './img/logo.jpg';
+ logo.alt = 'Логотип Сияние';
+
+ const title = document.createElement('h1');
+ title.className = 'auth-brand';
+ title.textContent = 'Сияние';
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-actions';
+
+ const loginButton = document.createElement('button');
+ loginButton.className = 'primary-btn';
+ loginButton.type = 'button';
+ loginButton.textContent = 'Войти';
+ loginButton.addEventListener('click', () => navigate('login-view'));
+
+ const registerButton = document.createElement('button');
+ registerButton.className = 'ghost-btn';
+ registerButton.type = 'button';
+ registerButton.textContent = 'Зарегистрироваться';
+ registerButton.addEventListener('click', () => navigate('register-view'));
+
+ const settingsButton = document.createElement('button');
+ settingsButton.className = 'ghost-btn';
+ settingsButton.type = 'button';
+ settingsButton.textContent = 'Настройки';
+ settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
+
+ actions.append(loginButton, registerButton, settingsButton);
+
+ screen.append(logo, title);
+
+ if (state.startHint) {
+ const notice = document.createElement('div');
+ notice.className = 'card auth-status-card';
+ notice.textContent = state.startHint;
+ screen.append(notice);
+ clearStartHint();
+ }
+
+ screen.append(actions);
+ return screen;
+}
diff --git a/shine-UI/js/pages/topup-view.js b/shine-UI/js/pages/topup-view.js
new file mode 100644
index 0000000..3ae049d
--- /dev/null
+++ b/shine-UI/js/pages/topup-view.js
@@ -0,0 +1,84 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { state } from '../state.js?v=20260327192619';
+
+export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
+
+const BUY_LINK = 'https://www.moonpay.com/buy/sol';
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const walletValue = document.createElement('input');
+ walletValue.className = 'input';
+ walletValue.type = 'text';
+ walletValue.value = state.registrationPayment.walletAddress;
+ walletValue.readOnly = true;
+ walletValue.style.fontSize = '13px';
+
+ const copyButton = document.createElement('button');
+ copyButton.className = 'ghost-btn';
+ copyButton.type = 'button';
+ copyButton.textContent = 'Скопировать';
+ copyButton.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(walletValue.value);
+ copyButton.textContent = 'Скопировано';
+ window.setTimeout(() => {
+ copyButton.textContent = 'Скопировать';
+ }, 1500);
+ } catch {
+ window.alert('Не удалось скопировать номер кошелька.');
+ }
+ });
+
+ const walletRow = document.createElement('div');
+ walletRow.className = 'inline-input-row';
+ walletRow.append(walletValue, copyButton);
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+ card.innerHTML = `
+
Для пополнения счета скопируйте номер кошелька.
+
+
1. Пополните через любое свое приложение, используя этот кошелек в сети Solana.
+
2. Либо откройте страницу для покупки SOL.
+
3. Либо используйте кнопку «Тестовое пополнение» (работает в тестовой Solana).
+
+
Открыть страницу покупки SOL
+
+
Кошелёк для пополнения
+
+ `;
+ card.children[3].append(walletRow);
+
+ const testButton = document.createElement('button');
+ testButton.className = 'ghost-btn';
+ testButton.type = 'button';
+ testButton.textContent = 'Тестовое пополнение';
+ testButton.addEventListener('click', () => {
+ state.registrationPayment.balanceSOL = '0.0250';
+ window.alert('Тестовое пополнение выполнено. Баланс обновлён.');
+ });
+
+ const backButton = document.createElement('button');
+ backButton.className = 'primary-btn';
+ backButton.type = 'button';
+ backButton.textContent = 'Назад';
+ backButton.addEventListener('click', () => navigate('registration-payment-view'));
+
+ const actions = document.createElement('div');
+ actions.className = 'auth-footer-actions';
+ actions.append(testButton, backButton);
+
+ screen.append(
+ renderHeader({
+ title: 'Пополнение счета',
+ leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
+ }),
+ card,
+ actions,
+ );
+
+ return screen;
+}
diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js
new file mode 100644
index 0000000..ba1bad6
--- /dev/null
+++ b/shine-UI/js/pages/wallet-view.js
@@ -0,0 +1,78 @@
+import { renderHeader } from '../components/header.js?v=20260327192619';
+import { wallet } from '../mock-data.js?v=20260327192619';
+
+export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+ let statusText = 'Данные демонстрационные';
+
+ const status = document.createElement('p');
+ status.className = 'meta-muted';
+
+ const updateStatus = (text) => {
+ statusText = text;
+ status.textContent = statusText;
+ };
+
+ screen.append(
+ renderHeader({
+ title: 'Кошелёк',
+ leftAction: { label: '←', onClick: () => navigate('profile-view') },
+ })
+ );
+
+ const card = document.createElement('div');
+ card.className = 'card stack';
+ card.innerHTML = `
+
+
Баланс
+
${wallet.balanceSOL} SOL
+
Обновлено: ${wallet.updatedAt}
+
+
+
Публичный адрес
+
${wallet.publicAddress}
+
+ `;
+
+ const actions = document.createElement('div');
+ actions.className = 'stack';
+ actions.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+
+ actions.querySelector('#copy-address').addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(wallet.publicAddress);
+ updateStatus('Адрес скопирован в буфер обмена');
+ } catch {
+ updateStatus('Не удалось скопировать в этом браузере');
+ }
+ });
+
+ actions.querySelector('#refresh-balance').addEventListener('click', () => {
+ updateStatus(`Баланс обновлен: ${wallet.balanceSOL} SOL`);
+ });
+
+ actions.querySelector('#send-sol').addEventListener('click', () => {
+ updateStatus('Демо-функция: перевод будет добавлен позже');
+ });
+
+ actions.querySelector('#topup-sol').addEventListener('click', () => {
+ updateStatus('Демо-функция: пополнение будет добавлено позже');
+ });
+
+ updateStatus(statusText);
+
+ screen.append(card, actions, status);
+ return screen;
+}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
new file mode 100644
index 0000000..6569141
--- /dev/null
+++ b/shine-UI/js/router.js
@@ -0,0 +1,62 @@
+const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
+
+export const PRE_AUTH_PAGES = [
+ 'start-view',
+ 'entry-settings-view',
+ 'register-view',
+ 'registration-payment-view',
+ 'registration-keys-view',
+ 'topup-view',
+ 'login-view',
+ 'login-camera-view',
+ 'login-password-view',
+ 'key-storage-view',
+];
+
+export function getRoute() {
+ const raw = window.location.hash.replace(/^#\/?/, '');
+ if (!raw) {
+ return { pageId: '', params: {} };
+ }
+
+ const [pageId, dynamicId] = raw.split('/');
+
+ if (pageId === 'chat-view') {
+ return { pageId, params: { chatId: dynamicId || '' } };
+ }
+
+ if (pageId === 'channel-view') {
+ return { pageId, params: { channelId: dynamicId || '' } };
+ }
+
+ if (pageId === 'device-session-view') {
+ return { pageId, params: { sessionId: dynamicId || '' } };
+ }
+
+ return { pageId, params: {} };
+}
+
+export function navigate(path) {
+ window.location.hash = `#/${path}`;
+}
+
+export function resolveToolbarActive(pageId) {
+ if (ROOT_PAGES.includes(pageId)) return pageId;
+ if (
+ pageId === 'wallet-view' ||
+ pageId === 'settings-view' ||
+ pageId === 'server-settings-view' ||
+ pageId === 'device-view' ||
+ pageId === 'connect-device-view' ||
+ pageId === 'device-qr-view' ||
+ pageId === 'device-camera-view' ||
+ pageId === 'show-keys-view' ||
+ pageId === 'device-session-view' ||
+ pageId === 'language-view'
+ ) {
+ return 'profile-view';
+ }
+ if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
+ if (pageId === 'channel-view') return 'channels-list';
+ return 'profile-view';
+}
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
new file mode 100644
index 0000000..c37c57c
--- /dev/null
+++ b/shine-UI/js/state.js
@@ -0,0 +1,111 @@
+import { chatMessages, wallet } from './mock-data.js?v=20260327192619';
+
+const clone = (value) => JSON.parse(JSON.stringify(value));
+
+export const state = {
+ chats: clone(chatMessages),
+ notificationsTab: 'replies',
+ pageLabelCollapsed: false,
+ session: {
+ isAuthorized: false,
+ },
+ startHint: '',
+ entrySettings: {
+ language: 'ru',
+ solanaServer: 'https://api.mainnet-beta.solana.com',
+ shineServer: 'https://demo.shine.local',
+ arweaveServer: 'https://arweave.net',
+ statuses: {
+ solanaServer: 'idle',
+ shineServer: 'idle',
+ arweaveServer: 'idle',
+ },
+ },
+ registrationDraft: {
+ login: '',
+ password: '',
+ },
+ loginDraft: {
+ login: '',
+ password: '',
+ },
+ registrationPayment: {
+ walletAddress: wallet.publicAddress,
+ balanceSOL: '0.0068',
+ },
+ keyStorage: {
+ rootKey: 'RK-4Q8N-1SZP-71LM-AUTH-ROOT',
+ blockchainKey: 'BK-SOL-19F2-CHAIN-ACCESS',
+ deviceKey: 'DK-LOCAL-82XA-DEVICE-SIGN',
+ saveRoot: false,
+ saveBlockchain: true,
+ saveDevice: true,
+ },
+ deviceConnect: {
+ root: true,
+ blockchain: true,
+ device: true,
+ },
+};
+
+export function getChatMessages(chatId) {
+ if (!state.chats[chatId]) {
+ state.chats[chatId] = [];
+ }
+ return state.chats[chatId];
+}
+
+export function addChatMessage(chatId, text) {
+ const message = text.trim();
+ if (!message) return;
+ getChatMessages(chatId).push({ from: 'out', text: message });
+}
+
+export function togglePageLabel() {
+ state.pageLabelCollapsed = !state.pageLabelCollapsed;
+}
+
+export function ensureChat(chatId) {
+ return getChatMessages(chatId);
+}
+
+export function checkServerAvailability(address) {
+ const normalized = address.trim().toLowerCase();
+ if (!normalized) return 'unavailable';
+
+ const looksLikeUrl = /^https?:\/\/[a-z0-9.-]+/i.test(normalized);
+ const blockedWord = /(offline|down|fail|bad|broken|invalid)/i.test(normalized);
+ return looksLikeUrl && !blockedWord ? 'available' : 'unavailable';
+}
+
+export function saveEntrySettings(nextSettings) {
+ state.entrySettings = {
+ ...state.entrySettings,
+ ...nextSettings,
+ statuses: {
+ ...state.entrySettings.statuses,
+ ...(nextSettings.statuses || {}),
+ },
+ };
+ state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.';
+}
+
+export function clearStartHint() {
+ state.startHint = '';
+}
+
+export function authorizeSession() {
+ state.session.isAuthorized = true;
+ state.startHint = '';
+}
+
+export function terminateCurrentSession() {
+ state.session.isAuthorized = false;
+ state.startHint = '';
+}
+
+export function refreshRegistrationBalance() {
+ const next = (0.005 + Math.random() * 0.03).toFixed(4);
+ state.registrationPayment.balanceSOL = next;
+ return next;
+}
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
new file mode 100644
index 0000000..03c64a3
--- /dev/null
+++ b/shine-UI/styles/components.css
@@ -0,0 +1,730 @@
+.page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+ gap: 8px;
+}
+
+.page-title {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: 0.2px;
+}
+
+.header-actions,
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 74px;
+}
+
+.header-left {
+ justify-content: flex-start;
+}
+
+.header-actions {
+ justify-content: flex-end;
+}
+
+.icon-btn,
+.text-btn,
+.primary-btn,
+.ghost-btn {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--card-soft);
+ padding: 8px 10px;
+ cursor: pointer;
+ transition: 0.2s ease;
+}
+
+.icon-btn:hover,
+.text-btn:hover,
+.primary-btn:hover,
+.ghost-btn:hover {
+ border-color: var(--accent);
+}
+
+.primary-btn {
+ background: linear-gradient(120deg, var(--accent-soft), rgba(82, 120, 240, 0.22));
+}
+
+.ghost-btn {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.card {
+ background: linear-gradient(180deg, rgba(31, 44, 67, 0.62), rgba(21, 30, 48, 0.9));
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: var(--radius-lg);
+ padding: 14px;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.stack {
+ display: grid;
+ gap: 10px;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(132, 244, 161, 0.35);
+ color: #d7ffe3;
+ background: rgba(132, 244, 161, 0.09);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.badge.profile-badge-trigger {
+ cursor: pointer;
+}
+
+.badge.alt {
+ border-color: rgba(83, 216, 251, 0.35);
+ color: #dff8ff;
+ background: rgba(83, 216, 251, 0.11);
+}
+
+.profile-help-modal[hidden] {
+ display: none;
+}
+
+.profile-help-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+}
+
+.profile-help-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(5, 9, 16, 0.72);
+ backdrop-filter: blur(4px);
+}
+
+.profile-help-dialog {
+ position: absolute;
+ left: 16px;
+ right: 16px;
+ bottom: 24px;
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+ box-shadow: var(--shadow);
+}
+
+.profile-help-text {
+ color: #d8e3ff;
+ line-height: 1.45;
+ font-size: 14px;
+}
+
+.auth-screen {
+ min-height: calc(100dvh - 48px - env(safe-area-inset-bottom));
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ gap: 18px;
+ text-align: center;
+}
+
+.auth-logo {
+ width: 126px;
+ height: 126px;
+ border-radius: 28px;
+ object-fit: cover;
+ box-shadow: var(--shadow);
+}
+
+.auth-brand {
+ font-size: 32px;
+ letter-spacing: 0.04em;
+}
+
+.auth-actions,
+.auth-footer-actions {
+ display: grid;
+ gap: 10px;
+}
+
+.auth-actions {
+ width: min(100%, 320px);
+}
+
+.auth-footer-actions {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.auth-copy {
+ line-height: 1.45;
+ color: #d8e3ff;
+}
+
+.auth-status-card {
+ width: min(100%, 320px);
+ color: #d8e3ff;
+}
+
+.field-label {
+ color: #b2c2e6;
+ font-size: 13px;
+}
+
+.select {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+ min-height: 44px;
+ padding: 0 12px;
+ color: var(--text);
+}
+
+.select:focus {
+ outline: none;
+ border-color: rgba(83, 216, 251, 0.55);
+ box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
+}
+
+.wrap-row {
+ flex-wrap: wrap;
+}
+
+.status-line {
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.status-line.is-available {
+ color: #8ef0a8;
+}
+
+.status-line.is-unavailable {
+ color: #ff8d97;
+}
+
+.server-check-btn.is-available {
+ border-color: rgba(132, 244, 161, 0.42);
+ background: rgba(132, 244, 161, 0.12);
+ color: #d7ffe3;
+}
+
+.server-check-btn.is-unavailable {
+ border-color: rgba(255, 113, 143, 0.42);
+ background: rgba(255, 113, 143, 0.12);
+ color: #ffd7df;
+}
+
+.help-fab,
+.square-btn {
+ width: 42px;
+ height: 42px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--card-soft);
+ color: var(--text);
+ cursor: pointer;
+}
+
+.help-fab {
+ position: fixed;
+ right: max(20px, calc((100vw - min(100vw, 430px)) / 2 + 20px));
+ bottom: calc(20px + env(safe-area-inset-bottom));
+ z-index: 12;
+}
+
+.inline-input-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+}
+
+.link-card {
+ display: block;
+ padding: 14px;
+ border-radius: var(--radius-md);
+ background: rgba(83, 216, 251, 0.08);
+ border: 1px solid rgba(83, 216, 251, 0.22);
+ color: #d9f8ff;
+}
+
+.qr-card {
+ justify-items: center;
+}
+
+.qr-code {
+ width: min(220px, 100%);
+ aspect-ratio: 1;
+ fill: #eff5ff;
+ background: #111723;
+ border-radius: 22px;
+ padding: 18px;
+}
+
+.camera-shell {
+ position: relative;
+ min-height: 380px;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background: #09101a;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.camera-video {
+ width: 100%;
+ height: 100%;
+ min-height: 380px;
+ object-fit: cover;
+ display: block;
+}
+
+.camera-frame {
+ position: absolute;
+ inset: 70px 40px 110px;
+ border: 3px solid rgba(83, 216, 251, 0.85);
+ border-radius: 24px;
+ box-shadow: 0 0 0 999px rgba(5, 9, 16, 0.38);
+}
+
+.camera-hint,
+.camera-error {
+ position: absolute;
+ left: 16px;
+ right: 16px;
+ text-align: center;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: rgba(10, 14, 23, 0.78);
+}
+
+.camera-hint {
+ bottom: 18px;
+}
+
+.camera-error {
+ top: 18px;
+ color: #ffd7df;
+}
+
+.camera-placeholder {
+ width: 100%;
+ min-height: 380px;
+ display: grid;
+ place-items: center;
+ color: #c8d6f9;
+ background:
+ radial-gradient(circle at 20% 10%, rgba(83, 216, 251, 0.16), transparent 48%),
+ linear-gradient(180deg, #0a1220, #070d17);
+}
+
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.key-card {
+ padding: 12px;
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.list-item {
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 11px;
+ border-radius: var(--radius-md);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ cursor: pointer;
+}
+
+.avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: linear-gradient(130deg, #3c4f73, #243352);
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ color: #e5ebff;
+}
+
+.avatar.large {
+ width: 82px;
+ height: 82px;
+ font-size: 26px;
+}
+
+.meta-muted {
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+.unread {
+ min-width: 20px;
+ height: 20px;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ background: var(--accent);
+ color: #08212a;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 0 6px;
+}
+
+.toolbar {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 8px;
+ padding: 8px;
+ background: rgba(20, 28, 44, 0.95);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 16px;
+ backdrop-filter: blur(8px);
+}
+
+.toolbar-btn {
+ border: 0;
+ border-radius: 12px;
+ background: transparent;
+ color: var(--text-muted);
+ padding: 8px 4px;
+ cursor: pointer;
+ display: grid;
+ justify-items: center;
+ gap: 4px;
+ font-size: 11px;
+}
+
+.toolbar-btn.active {
+ background: rgba(83, 216, 251, 0.14);
+ color: var(--text);
+}
+
+.page-label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ border-radius: 10px;
+ padding: 8px 10px;
+ color: #c5d2f4;
+ background: rgba(17, 24, 39, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.page-label.is-collapsed {
+ width: fit-content;
+ padding: 6px;
+}
+
+.page-label-content {
+ min-width: 0;
+}
+
+.page-label-hint {
+ margin-bottom: 3px;
+ color: #8ea2cd;
+ font-size: 10px;
+ line-height: 1;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.page-label-caption {
+ font-size: 12px;
+ line-height: 1.2;
+}
+
+.page-label-toggle {
+ width: 16px;
+ height: 16px;
+ flex: 0 0 16px;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ cursor: pointer;
+}
+
+.page-label-toggle:hover {
+ border-color: rgba(83, 216, 251, 0.5);
+ background: rgba(83, 216, 251, 0.16);
+}
+
+.chat-wrap {
+ display: grid;
+ grid-template-rows: 1fr auto;
+ gap: 10px;
+ min-height: calc(100dvh - 210px);
+}
+
+.messages-log {
+ display: grid;
+ gap: 8px;
+ align-content: start;
+}
+
+.bubble {
+ max-width: 76%;
+ padding: 10px 12px;
+ border-radius: 14px;
+ font-size: 14px;
+ line-height: 1.35;
+}
+
+.bubble.in {
+ justify-self: start;
+ background: #1f2c46;
+ border-top-left-radius: 6px;
+}
+
+.bubble.out {
+ justify-self: end;
+ background: #273f63;
+ border-top-right-radius: 6px;
+}
+
+.chat-input {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+}
+
+.contact-search-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.input {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+ min-height: 40px;
+ padding: 0 12px;
+ width: 100%;
+ outline: none;
+}
+
+.input:focus {
+ border-color: rgba(83, 216, 251, 0.55);
+ box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
+}
+
+.small-btn {
+ padding: 6px 10px;
+ font-size: 13px;
+}
+
+.session-item {
+ width: 100%;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.03);
+ color: inherit;
+ padding: 10px;
+ cursor: pointer;
+}
+
+.session-item:hover {
+ border-color: rgba(83, 216, 251, 0.4);
+}
+
+.session-tabs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.session-tab {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text-muted);
+ min-height: 36px;
+ padding: 6px 8px;
+ cursor: pointer;
+}
+
+.session-tab.is-active {
+ color: var(--text);
+ border-color: rgba(83, 216, 251, 0.45);
+ background: rgba(83, 216, 251, 0.15);
+}
+
+.session-current-badge {
+ display: inline-flex;
+ margin-top: 8px;
+ padding: 4px 9px;
+ border-radius: 999px;
+ font-size: 12px;
+ color: #d7ffe3;
+ border: 1px solid rgba(132, 244, 161, 0.36);
+ background: rgba(132, 244, 161, 0.1);
+}
+
+.key-value {
+ font-family: "IBM Plex Mono", "Fira Code", monospace;
+ font-size: 13px;
+ line-height: 1.35;
+ word-break: break-all;
+ color: #dce7ff;
+}
+
+.qr-demo {
+ width: 64px;
+ height: 64px;
+ border-radius: 8px;
+ border: 2px solid rgba(255, 255, 255, 0.85);
+ background:
+ linear-gradient(
+ 90deg,
+ #f6fbff 0 8px,
+ #0f1524 8px 16px,
+ #f6fbff 16px 24px,
+ #0f1524 24px 32px,
+ #f6fbff 32px 40px,
+ #0f1524 40px 48px,
+ #f6fbff 48px 56px,
+ #0f1524 56px 64px
+ );
+}
+
+.qr-image {
+ width: 64px;
+ height: 64px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ background: #fff;
+}
+
+.modal-shell[hidden] {
+ display: none;
+}
+
+.modal-shell {
+ position: fixed;
+ inset: 0;
+ z-index: 24;
+}
+
+.modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(5, 9, 16, 0.74);
+ backdrop-filter: blur(4px);
+}
+
+.modal-dialog {
+ position: absolute;
+ left: 16px;
+ right: 16px;
+ bottom: 24px;
+ display: grid;
+ gap: 12px;
+ box-shadow: var(--shadow);
+}
+
+.network-board {
+ position: relative;
+ height: 290px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-lg);
+ background: radial-gradient(circle at center, rgba(83, 216, 251, 0.08), rgba(255, 255, 255, 0.01));
+ overflow: hidden;
+}
+
+.network-svg {
+ position: absolute;
+ inset: 0;
+}
+
+.node {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ width: 74px;
+ text-align: center;
+}
+
+.node-dot {
+ width: 52px;
+ height: 52px;
+ margin: 0 auto 4px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ background: #2f4265;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.node.center .node-dot {
+ width: 60px;
+ height: 60px;
+ background: linear-gradient(130deg, #3a5f8e, #3dc4df);
+ color: #061119;
+}
+
+.node-label {
+ font-size: 11px;
+ color: #d6e2ff;
+}
+
+.tabs {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ padding: 4px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.tab-btn {
+ border: 0;
+ background: transparent;
+ border-radius: 9px;
+ padding: 8px;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.tab-btn.active {
+ background: rgba(83, 216, 251, 0.16);
+ color: var(--text);
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.62);
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ z-index: 20;
+}
+
+.modal-card {
+ width: min(100%, 390px);
+ background: #172238;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 16px;
+ padding: 14px;
+}
diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css
new file mode 100644
index 0000000..e8184b5
--- /dev/null
+++ b/shine-UI/styles/layout.css
@@ -0,0 +1,55 @@
+body {
+ display: flex;
+ justify-content: center;
+}
+
+.app-shell {
+ width: min(100vw, 430px);
+ height: 100dvh;
+ position: relative;
+ background: linear-gradient(165deg, rgba(16, 22, 36, 0.96), rgba(11, 16, 27, 0.99));
+ border-left: 1px solid rgba(255, 255, 255, 0.05);
+ border-right: 1px solid rgba(255, 255, 255, 0.05);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+}
+
+.screen-content {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 118px;
+ overflow-y: auto;
+ padding: 14px 14px 24px;
+}
+
+.screen-content.no-app-chrome {
+ bottom: 0;
+ padding-bottom: calc(24px + env(safe-area-inset-bottom));
+}
+
+.page-label-slot {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 99px;
+ padding: 0 14px;
+}
+
+.toolbar-slot {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
+ background: linear-gradient(180deg, rgba(10, 14, 23, 0) 0%, rgba(10, 14, 23, 0.95) 42%);
+}
+
+@media (min-width: 900px) {
+ .app-shell {
+ margin: 16px 0;
+ height: calc(100dvh - 32px);
+ border-radius: 24px;
+ }
+}
diff --git a/shine-UI/styles/main.css b/shine-UI/styles/main.css
new file mode 100644
index 0000000..2d63a8a
--- /dev/null
+++ b/shine-UI/styles/main.css
@@ -0,0 +1,51 @@
+:root {
+ --bg-0: #080b12;
+ --bg-1: #101624;
+ --bg-2: #171f32;
+ --card: #1a2436;
+ --card-soft: #202d45;
+ --line: #2a3854;
+ --text: #ebf1ff;
+ --text-muted: #99a8cb;
+ --accent: #53d8fb;
+ --accent-soft: rgba(83, 216, 251, 0.17);
+ --danger: #ff718f;
+ --ok: #84f4a1;
+ --radius-lg: 18px;
+ --radius-md: 12px;
+ --radius-sm: 9px;
+ --shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
+ --font-main: "Manrope", "IBM Plex Sans", "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ min-height: 100%;
+ background: radial-gradient(circle at 22% -10%, #1f355e 0%, var(--bg-0) 45%) fixed;
+ color: var(--text);
+ font-family: var(--font-main);
+}
+
+button,
+input {
+ font: inherit;
+ color: inherit;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}