UI v4: новый стиль по макету V3/V4 и доводка экранов

Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий
с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки.
Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа
со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги),
сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов –
переключатель; уведомления – список как в чатах; фото – просмотр на весь экран.
Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода,
второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов),
иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса.

Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
q
2026-09-29 01:36:52 +03:00
co-authored by Claude Opus 5.5
parent 13693a0a53
commit 2e62616b46
84 changed files with 2507 additions and 1583 deletions
+31 -46
View File
@@ -1,4 +1,5 @@
import { createTopBar } from '../components/topbar.js';
import { iconHtml } from '../components/ui-icon.js';
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@@ -31,19 +32,8 @@ function normalizeLoginForTemporaryUiGuard(login) {
function makePasswordToggleIcons() {
return {
eye: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`,
eyeOff: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`,
eye: iconHtml('eye', false, 'key-toggle-icon'),
eyeOff: iconHtml('eyeOff', false, 'key-toggle-icon'),
};
}
@@ -97,9 +87,10 @@ export function render({ navigate }) {
passwordInputRow.className = 'inline-input-row';
passwordInputRow.append(passwordInput, passwordToggleButton);
const statusText = document.createElement('p');
// Строка под логином всегда занимает место — сообщения не сдвигают форму.
const statusText = document.createElement('span');
statusText.className = 'status-line registration-login-status';
statusText.style.display = 'none';
statusText.setAttribute('aria-live', 'polite');
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
@@ -135,10 +126,24 @@ export function render({ navigate }) {
passwordLengthText.textContent = `Символов в пароле: ${getCurrentPassword().length}`;
}
function setStatusMessage(message, kind = '') {
function setStatusMessage(message, kind = '', details = '') {
statusText.textContent = message;
statusText.title = details || message;
statusText.className = kind ? `status-line registration-login-status ${kind}` : 'status-line registration-login-status';
statusText.style.display = message ? '' : 'none';
}
function showCheckedLoginStatus(isFree, className) {
if (!isFree) {
setStatusMessage('Логин уже занят', 'is-unavailable');
} else if (className === 'free') {
setStatusMessage('Логин свободен', 'is-available');
} else if (className === 'premium') {
setStatusMessage('Премиум-логин – покупка через DAO', 'is-unavailable');
} else if (className === 'company') {
setStatusMessage('Логин бренда – нужно согласование', 'is-unavailable');
} else {
setStatusMessage('Не подходит для обычной регистрации', 'is-unavailable');
}
}
function resetLoginCheckState() {
@@ -186,34 +191,24 @@ export function render({ navigate }) {
const normalizedLogin = normalizeLoginForTemporaryUiGuard(login);
if (!normalizedLogin) {
setStatusMessage('Логин содержит недопустимые символы или имеет неверную длину ❌', 'is-unavailable');
setStatusMessage('Только латиница, цифры и _, до 20 символов', 'is-unavailable');
formError.style.display = 'none';
return false;
}
if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) {
setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, 'is-unavailable');
setStatusMessage(`Не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
if (login === lastCheckedLogin) {
if (!lastCheckedFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (lastCheckedClassName === 'premium') {
setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'company') {
setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable');
} else {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
showCheckedLoginStatus(lastCheckedFree, lastCheckedClassName);
formError.style.display = 'none';
return lastCheckedFree && lastCheckedClassName === 'free';
}
setStatusMessage('Проверяем логин...');
setStatusMessage('Проверяем логин…', 'is-checking');
try {
const check = await checkLoginExistsOnSolana({
login,
@@ -237,28 +232,18 @@ export function render({ navigate }) {
lastCheckedLogin = login;
lastCheckedFree = isFree;
lastCheckedClassName = className;
if (!isFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (className === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (className === 'premium') {
setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable');
} else if (className === 'company') {
setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable');
} else {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
showCheckedLoginStatus(isFree, className);
formError.style.display = 'none';
return isFree && className === 'free';
} catch (error) {
if (runId !== loginCheckRunId) return false;
if (isSolanaRpcUnavailableError(error)) {
setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable');
setStatusMessage('Нет связи с Solana – попробуйте позже', 'is-unavailable');
return false;
}
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable');
setStatusMessage(base, 'is-unavailable', `${base}. Детали: ${details}`);
return false;
}
}
@@ -313,8 +298,8 @@ export function render({ navigate }) {
const passwordLabel = document.createElement('label');
passwordLabel.className = 'stack registration-password-single';
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
form.append(loginField, statusText, passwordLabel);
loginField.append(loginInput);
form.append(loginField, passwordLabel);
loginField.append(loginInput, statusText);
passwordLabel.append(passwordInputRow);
form.append(passwordLengthText, formError, faqButton);
actions.innerHTML = '';