feat(web): анимации, меню пользователя и раздельные настройки аккаунта и серверов

- анимации на чистом CSS: появление страниц (gl-page), модалки с обратным
  закрытием, поповеры, каскад списков (gl-stagger), индикаторы активной комнаты
  и сервера, скелетоны с shimmer; prefers-reduced-motion отключает всё;
- нижне-левое меню пользователя: аватар и шестерёнка открывают меню вверх с
  пунктами «Настройки аккаунта», «Настройки серверов», сменой статуса и
  выходом (role=menu, стрелки, Escape, возврат фокуса);
- настройки разделены на два соседних раздела с общим внешним видом:
  /settings/account/* (профиль, безопасность, внешний вид, инстанс) и
  /settings/servers/:guildId (общее, свой ник, участники, роли, опасная зона)
  с панелью выбора сервера — иконка и имя, выбор сохраняется в URL;
- старые пути /settings/{profile,security,appearance,instance} работают
  редиректами; шестерёнка настроек сервера — в шапке сервера рядом с «+»
  и только для владельца/ADMINISTRATOR/MANAGE_GUILD;
- 100 тестов Vitest (было 66), check/lint/test/build зелёные.
This commit is contained in:
2026-09-19 23:42:56 +03:00
parent 8619f37cd5
commit 53b7b77eed
4 changed files with 48 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+1
View File
@@ -0,0 +1 @@
вложение: проверка загрузки
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { motionDelay, prefersReducedMotion } from '@/lib/motion';
/**
* Доступность: при `prefers-reduced-motion: reduce` анимации мгновенные,
* поэтому и задержка удаления узлов из DOM должна быть нулевой.
*/
describe('prefers-reduced-motion', () => {
it('без поддержки matchMedia считает, что анимации разрешены', () => {
vi.stubGlobal('matchMedia', undefined);
expect(prefersReducedMotion()).toBe(false);
expect(motionDelay(140)).toBe(140);
});
it('при reduce анимации не ждут', () => {
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: true })),
);
expect(prefersReducedMotion()).toBe(true);
expect(motionDelay(140)).toBe(0);
});
it('при обычных настройках возвращает исходную задержку', () => {
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: false })),
);
expect(prefersReducedMotion()).toBe(false);
expect(motionDelay(140)).toBe(140);
});
});
+10 -3
View File
@@ -127,10 +127,17 @@ describe('настройки серверов', () => {
expect(router.state.location.pathname).toBe('/settings/servers/g-1'); expect(router.state.location.pathname).toBe('/settings/servers/g-1');
}); });
// После выбора первого сервера страница перерисовывается — берём список заново. // Первый сервер подсвечен. Список запрашиваем заново: после выбора
const picker = await screen.findByTestId('guild-picker'); // страница перерисовывается, и прежний узел может быть уже отсоединён.
await waitFor(() => {
const picker = screen.getByTestId('guild-picker');
expect(
within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }),
).toHaveAttribute('aria-current', 'page');
});
const picker = screen.getByTestId('guild-picker');
const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }); const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' });
expect(first).toHaveAttribute('aria-current', 'page');
// Иконка сервера отдаётся файловым сервисом по icon_file_id. // Иконка сервера отдаётся файловым сервисом по icon_file_id.
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1'); expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
expect( expect(