48e30a1897
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей» и пер-серверные стили) (AGENT.md 7.2, 7.4) - store: CRUD галереи, личные стили и вычисление оформления поэлементно (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль больше не выдаёт элемент - API: галерея сервера под MANAGE_ROLES, оформление роли в create/update, GET/PUT/DELETE /users/@me/styles с доступными элементами по областям - клиент: редактор личного стиля с предпросмотром, галерея оформления в настройках сервера, оформление роли в разделе «Роли» - рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты ника (CSS, при reduced-motion статично) в ленте и списке участников
546 lines
14 KiB
CSS
546 lines
14 KiB
CSS
@import 'tailwindcss';
|
|
|
|
/*
|
|
* Дизайн-токены (docs/client-web.md §3). Тёмная тема по умолчанию,
|
|
* светлая — тот же набор токенов. Акцент сервера переопределяет --color-accent
|
|
* в скоупе сервера.
|
|
*/
|
|
@theme {
|
|
--color-bg: oklch(0.19 0.02 265);
|
|
--color-surface-1: oklch(0.23 0.02 265);
|
|
--color-surface-2: oklch(0.27 0.02 265);
|
|
--color-surface-3: oklch(0.32 0.02 265);
|
|
--color-fg: oklch(0.96 0.005 265);
|
|
--color-fg-muted: oklch(0.72 0.01 265);
|
|
--color-accent: oklch(0.66 0.16 265);
|
|
--color-accent-fg: oklch(0.99 0.005 265);
|
|
--color-danger: oklch(0.62 0.19 25);
|
|
--color-success: oklch(0.72 0.15 150);
|
|
--color-warning: oklch(0.79 0.14 85);
|
|
/* Тёмно-голубой: точка статуса «невидимый» в разделе друзей. */
|
|
--color-info: oklch(0.45 0.09 240);
|
|
--color-border: oklch(0.35 0.02 265);
|
|
|
|
--radius-sm: 6px;
|
|
--radius-md: 10px;
|
|
--radius-lg: 14px;
|
|
|
|
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.3);
|
|
--shadow-2: 0 4px 12px rgb(0 0 0 / 0.35);
|
|
--shadow-3: 0 12px 32px rgb(0 0 0 / 0.4);
|
|
|
|
--duration-fast: 120ms;
|
|
--duration-base: 200ms;
|
|
/* Шаг каскадного появления элементов списка (см. .gl-stagger). */
|
|
--stagger-step: 28ms;
|
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
:root[data-theme='light'] {
|
|
--color-bg: oklch(0.98 0.003 265);
|
|
--color-surface-1: oklch(1 0 0);
|
|
--color-surface-2: oklch(0.96 0.004 265);
|
|
--color-surface-3: oklch(0.92 0.006 265);
|
|
--color-fg: oklch(0.2 0.02 265);
|
|
--color-fg-muted: oklch(0.48 0.015 265);
|
|
--color-border: oklch(0.87 0.008 265);
|
|
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.08);
|
|
--shadow-2: 0 4px 12px rgb(0 0 0 / 0.1);
|
|
--shadow-3: 0 12px 32px rgb(0 0 0 / 0.12);
|
|
}
|
|
|
|
html,
|
|
body,
|
|
#root {
|
|
height: 100%;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
background-color: var(--color-bg);
|
|
color: var(--color-fg);
|
|
font-family:
|
|
system-ui,
|
|
-apple-system,
|
|
'Segoe UI',
|
|
Inter,
|
|
sans-serif;
|
|
font-size: 14px;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
:focus-visible {
|
|
outline: 2px solid var(--color-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/*
|
|
* Анимации клиента (docs/client-web.md §3): сдержанные, быстрые, без
|
|
* зависимостей. Ключевые кадры и классы живут здесь, а не в inline-стилях,
|
|
* чтобы их можно было отключить одним media-запросом ниже.
|
|
*
|
|
* Все входные анимации используют fill-mode `backwards`: до старта элемент
|
|
* уже в начальном кадре (это нужно каскаду с задержками), а после финиша
|
|
* отдаёт управление обычным стилям — hover-трансформации продолжают работать.
|
|
*/
|
|
|
|
/* Появление страницы при смене маршрута: fade + сдвиг на 6px. */
|
|
@keyframes gl-page-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
.gl-page {
|
|
animation: gl-page-in var(--duration-base) var(--ease-out) backwards;
|
|
}
|
|
|
|
/* Затемнение подложки модального окна. */
|
|
@keyframes gl-backdrop-in {
|
|
from {
|
|
background-color: rgb(0 0 0 / 0);
|
|
}
|
|
to {
|
|
background-color: rgb(0 0 0 / 0.6);
|
|
}
|
|
}
|
|
|
|
@keyframes gl-backdrop-out {
|
|
from {
|
|
background-color: rgb(0 0 0 / 0.6);
|
|
}
|
|
to {
|
|
background-color: rgb(0 0 0 / 0);
|
|
}
|
|
}
|
|
|
|
.gl-backdrop-in {
|
|
animation: gl-backdrop-in var(--duration-fast) var(--ease-out) backwards;
|
|
}
|
|
|
|
.gl-backdrop-out {
|
|
animation: gl-backdrop-out var(--duration-fast) var(--ease-out) forwards;
|
|
}
|
|
|
|
/* Панель модального окна: scale(0.96 → 1) с небольшим подъёмом. */
|
|
@keyframes gl-pop-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: scale(0.96) translateY(8px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
@keyframes gl-pop-out {
|
|
from {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
to {
|
|
opacity: 0;
|
|
transform: scale(0.96) translateY(8px);
|
|
}
|
|
}
|
|
|
|
.gl-pop-in {
|
|
animation: gl-pop-in var(--duration-base) var(--ease-out) backwards;
|
|
}
|
|
|
|
.gl-pop-out {
|
|
animation: gl-pop-out var(--duration-fast) var(--ease-out) forwards;
|
|
}
|
|
|
|
/* Всплывающее меню/поповер: fade + сдвиг от якоря (меню растёт вверх). */
|
|
@keyframes gl-popover-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(6px) scale(0.98);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
.gl-popover {
|
|
transform-origin: bottom;
|
|
animation: gl-popover-in var(--duration-fast) var(--ease-out) backwards;
|
|
}
|
|
|
|
/*
|
|
* Панели, которые позиционирует `useAnchoredPosition`: до замера они скрыты,
|
|
* после — прижаты к рабочей области по left/top/max-height (задаёт хук).
|
|
* position: fixed нужен, чтобы панель не уезжала за пределы окна.
|
|
*/
|
|
.gl-anchored-panel {
|
|
position: fixed;
|
|
z-index: 40;
|
|
visibility: hidden;
|
|
}
|
|
|
|
.gl-anchored-panel[data-positioned='true'] {
|
|
visibility: visible;
|
|
}
|
|
|
|
/* Направление анимации берём из data-placement: панель может развернуться. */
|
|
.gl-anchored-panel[data-placement='top'] {
|
|
transform-origin: bottom;
|
|
animation: gl-popover-in var(--duration-fast) var(--ease-out) backwards;
|
|
}
|
|
|
|
.gl-anchored-panel[data-placement='bottom'] {
|
|
transform-origin: top;
|
|
animation: gl-popover-down-in var(--duration-fast) var(--ease-out) backwards;
|
|
}
|
|
|
|
/* То же меню, но раскрывающееся вниз (действия у сообщения). */
|
|
@keyframes gl-popover-down-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-6px) scale(0.98);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
.gl-popover-down {
|
|
transform-origin: top;
|
|
animation: gl-popover-down-in var(--duration-fast) var(--ease-out) backwards;
|
|
}
|
|
|
|
/* Подсветка сообщения, к которому перешли из поиска или от ответа. */
|
|
@keyframes gl-highlight-fade {
|
|
from {
|
|
background-color: color-mix(in oklch, var(--color-accent) 28%, transparent);
|
|
}
|
|
to {
|
|
background-color: transparent;
|
|
}
|
|
}
|
|
|
|
.gl-highlight {
|
|
animation: gl-highlight-fade 1.6s var(--ease-out) backwards;
|
|
}
|
|
|
|
/*
|
|
* Каскадное появление списка. Задержка растёт только у первых восьми
|
|
* элементов — дальше список появляется без ступенек, чтобы длинные
|
|
* списки не «догоняли» пользователя.
|
|
*/
|
|
.gl-stagger > * {
|
|
animation: gl-page-in var(--duration-base) var(--ease-out) backwards;
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(1) {
|
|
animation-delay: 0ms;
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(2) {
|
|
animation-delay: calc(var(--stagger-step) * 1);
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(3) {
|
|
animation-delay: calc(var(--stagger-step) * 2);
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(4) {
|
|
animation-delay: calc(var(--stagger-step) * 3);
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(5) {
|
|
animation-delay: calc(var(--stagger-step) * 4);
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(6) {
|
|
animation-delay: calc(var(--stagger-step) * 5);
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(7) {
|
|
animation-delay: calc(var(--stagger-step) * 6);
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(8) {
|
|
animation-delay: calc(var(--stagger-step) * 7);
|
|
}
|
|
|
|
.gl-stagger > *:nth-child(n + 9) {
|
|
animation-delay: 0ms;
|
|
}
|
|
|
|
/* Индикатор активной комнаты: полоска слева плавно «вырастает». */
|
|
@keyframes gl-indicator-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-50%) scaleY(0.3);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(-50%) scaleY(1);
|
|
}
|
|
}
|
|
|
|
.gl-indicator::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 50%;
|
|
width: 3px;
|
|
height: 60%;
|
|
border-radius: 999px;
|
|
background-color: var(--color-accent);
|
|
transform: translateY(-50%);
|
|
animation: gl-indicator-in var(--duration-base) var(--ease-out) backwards;
|
|
}
|
|
|
|
/* Индикатор активного сервера в рейке: кольцо, проявляющееся из центра. */
|
|
@keyframes gl-ring-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: scale(0.88);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
.gl-ring::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: -3px;
|
|
border: 2px solid var(--color-accent);
|
|
border-radius: var(--radius-lg);
|
|
pointer-events: none;
|
|
animation: gl-ring-in var(--duration-base) var(--ease-out) backwards;
|
|
}
|
|
|
|
/* Скелетон загрузки: полоса с бегущим блеском. */
|
|
@keyframes gl-shimmer {
|
|
from {
|
|
background-position: 200% 0;
|
|
}
|
|
to {
|
|
background-position: -200% 0;
|
|
}
|
|
}
|
|
|
|
.gl-skeleton {
|
|
display: block;
|
|
border-radius: var(--radius-sm);
|
|
background-color: var(--color-surface-2);
|
|
background-image: linear-gradient(
|
|
90deg,
|
|
var(--color-surface-2) 25%,
|
|
var(--color-surface-3) 50%,
|
|
var(--color-surface-2) 75%
|
|
);
|
|
background-size: 200% 100%;
|
|
animation: gl-shimmer 1.4s linear infinite;
|
|
}
|
|
|
|
/*
|
|
* Список друзей раскрывается снизу вверх: высота контейнера растёт от нуля
|
|
* (grid 0fr → 1fr), а содержимое въезжает снизу. Свёрнутый список не занимает
|
|
* места и не доступен скринридеру (`aria-hidden` ставит компонент).
|
|
*/
|
|
@keyframes gl-reveal-up {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
.gl-reveal-up {
|
|
display: grid;
|
|
grid-template-rows: 0fr;
|
|
transition: grid-template-rows var(--duration-base) var(--ease-out);
|
|
}
|
|
|
|
.gl-reveal-up > * {
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.gl-reveal-up[data-state='open'] {
|
|
grid-template-rows: 1fr;
|
|
}
|
|
|
|
.gl-reveal-up[data-state='open'] > * {
|
|
animation: gl-reveal-up var(--duration-base) var(--ease-out) backwards;
|
|
}
|
|
|
|
/* Стрелка-раскрывашка: развёрнутое состояние поворачивает её вниз. */
|
|
.gl-caret {
|
|
display: inline-block;
|
|
transform: rotate(-90deg);
|
|
transition: transform var(--duration-fast) var(--ease-out);
|
|
}
|
|
|
|
.gl-caret-open {
|
|
display: inline-block;
|
|
transform: rotate(0deg);
|
|
transition: transform var(--duration-fast) var(--ease-out);
|
|
}
|
|
|
|
/*
|
|
* Композер: поле ввода и зеркало подсветки должны переносить строки одинаково.
|
|
* Полосу прокрутки резервируем всегда — иначе при переполнении textarea её
|
|
* ширина сузила бы текстовую область только у поля, и подсветка разъехалась бы.
|
|
*/
|
|
.gl-composer-scroll {
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
/*
|
|
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся
|
|
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
|
|
* появлялись бы по очереди без анимации.
|
|
*/
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-delay: 0ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
transition-delay: 0ms !important;
|
|
scroll-behavior: auto !important;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Эффекты ника (AGENT.md 7.4): встроенный набор CSS-анимаций. При
|
|
* `prefers-reduced-motion: reduce` анимации гасятся общим правилом выше,
|
|
* поэтому эффект остаётся статичным градиентом или свечением.
|
|
*/
|
|
.gl-nick {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.gl-nick-gradient {
|
|
background-image: linear-gradient(90deg, #5865f2, #eb459e, #faa61a, #5865f2);
|
|
background-size: 300% 100%;
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
color: transparent;
|
|
animation: gl-nick-gradient 6s linear infinite;
|
|
}
|
|
|
|
@keyframes gl-nick-gradient {
|
|
from {
|
|
background-position: 0% 50%;
|
|
}
|
|
to {
|
|
background-position: 300% 50%;
|
|
}
|
|
}
|
|
|
|
.gl-nick-glow {
|
|
animation: gl-nick-glow 2.4s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes gl-nick-glow {
|
|
0%,
|
|
100% {
|
|
text-shadow: 0 0 4px currentColor;
|
|
}
|
|
50% {
|
|
text-shadow:
|
|
0 0 10px currentColor,
|
|
0 0 18px currentColor;
|
|
}
|
|
}
|
|
|
|
.gl-nick-fire {
|
|
background-image: linear-gradient(180deg, #ffd166, #ff7b00, #d62828);
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
color: transparent;
|
|
animation: gl-nick-fire 1.6s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes gl-nick-fire {
|
|
0%,
|
|
100% {
|
|
filter: brightness(1);
|
|
}
|
|
50% {
|
|
filter: brightness(1.25);
|
|
}
|
|
}
|
|
|
|
.gl-nick-shimmer {
|
|
background-image: linear-gradient(100deg, currentColor 20%, #ffffff 40%, currentColor 60%);
|
|
background-size: 250% 100%;
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
animation: gl-nick-shimmer 3.2s linear infinite;
|
|
}
|
|
|
|
@keyframes gl-nick-shimmer {
|
|
from {
|
|
background-position: 150% 50%;
|
|
}
|
|
to {
|
|
background-position: -50% 50%;
|
|
}
|
|
}
|
|
|
|
.gl-nick-glitch {
|
|
animation: gl-nick-glitch 2.2s steps(2, end) infinite;
|
|
}
|
|
|
|
@keyframes gl-nick-glitch {
|
|
0%,
|
|
92%,
|
|
100% {
|
|
text-shadow: none;
|
|
}
|
|
93% {
|
|
text-shadow: -2px 0 #eb459e;
|
|
}
|
|
95% {
|
|
text-shadow: 2px 0 #00b0f4;
|
|
}
|
|
97% {
|
|
text-shadow: -1px 0 #faa61a;
|
|
}
|
|
}
|
|
|
|
/* Рамка вокруг аватара: подложка под аватаром, как в AGENT.md 7.4. */
|
|
.gl-frame {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.gl-frame-image {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.gl-frame-inner {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|