Files
sparkantiplagiat-frontend/CONVENTIONS.md
Микаэл Оганесян 2016d9160c
Some checks failed
CI / checks (push) Failing after 9m18s
- Add PlagiarismGraphComponent (force-graph) with legend, abbreviated names,
risk-derived node colors, and particle animation; integrated into work, event,
  group, and student detail pages
- Extract domain API services (students, events, groups, reference-sets, users,
  analysis-runs, audit) from WorksApiService
- Add RiskLevelPipe for translating risk level values to Russian
- Replace raw IDs with entity names across all detail page overview sections
- Dashboard: remove Works tab, reorder tabs (Students, Events, Groups, Ref-sets),
  hide list cards when empty or on error
- Hide secondary blocks (runs, matches, graph, analytics) on error instead of
  showing error text; keep top-level entity load errors visible
- Refset detail: split Ingestions tab into separate list and upload cards;
  hide list card when empty or on error
- Convert analysis runs list to table; fix kv-grid vertical alignment
- Style native select[tuiSelect] to match other form fields
- Add favicon (yellow star) from sparkguardian
2026-04-18 15:31:09 +03:00

46 KiB
Raw Permalink Blame History

SparkAntiplagiat — Конвенции и правила разработки

Документ описывает архитектурные, стилевые и технические решения фронтенда SparkAntiplagiat. Базируется на конвенциях родственного проекта SparkGuardian и наследует общие правила (Angular 21, signals, Taiga UI, T-Bank tokens). Передавайте этот файл AI-ассистенту вместе с задачей — описанные правила должны соблюдаться при любых изменениях.


1. Технологический стек

Область Решение Версия
Фреймворк Angular 21+
Язык TypeScript strict mode
UI-библиотека Taiga UI v5
Формы / маски Maskito 5.x
Web API wrappers @ng-web-apis 5.x
Стили Vanilla CSS + CSS Custom Properties
Линтинг ESLint + angular-eslint
Тесты Vitest + jsdom
Анимации @angular/animations
Шрифт Tinkoff Sans (фирменный; системный fallback: system-ui, sans-serif)

Категорически НЕ используется: TailwindCSS, SCSS/SASS/LESS, NgModules, RxJS-only подход к состоянию (signals предпочтительны).


2. Архитектура: слоевая модель

Проект строго разделён на три слоя. Ни один вышестоящий слой не импортирует из нижестоящего (feature не экспортирует наружу, core не импортирует feature).

src/app/
├── core/               ← синглтоны, DI-токены, interceptors, сервисы, модели, pipes
│   ├── config/         ← InjectionToken'ы (API_BASE_URL, API_ORIGIN, DEFAULT_PAGE_LIMIT)
│   ├── devtools/       ← DevLogService (ring-buffer 300 записей)
│   ├── guards/         ← authGuard
│   ├── http/           ← interceptors (apiBaseUrl, auth, devLog), error-classification, http-error utils
│   ├── models/         ← api.types.ts — ВСЕ TypeScript-интерфейсы API
│   ├── notifications/  ← UserErrorNotifyService, user-error-messages config
│   ├── services/       ← доменные API-клиенты: WorksApi, AnalysisRunsApi,
│   │                     StudentsApi, GroupsApi, EventsApi, ReferenceSetsApi,
│   │                     UsersApi, AuditApi + AuthService
│   ├── monitoring/     ← pipes для audit log
│   └── works/          ← pipes: analysis-run-status, chip-classes
│
├── features/           ← lazy-loaded smart-компоненты (по одному на маршрут)
│   ├── landing/        ← LandingComponent (маркетинговая страница)
│   ├── login/          ← LoginComponent
│   ├── dashboard/      ← DashboardComponent (сводка доменов + CRUD-формы)
│   ├── works/          ← WorksList, WorkDetail (загрузка архива, запуск check,
│   │                     отчёты, adoptions по прогонам)
│   ├── groups/         ← GroupDetail (участники, привязка студентов/юзеров)
│   ├── students/       ← StudentDetail
│   ├── events/         ← EventDetail (CRUD + works события)
│   ├── reference-sets/ ← RefsetDetail (ingestions, CRUD)
│   ├── monitoring/     ← MonitoringComponent (audit logs + фильтры)
│   └── devtools/       ← DevConsoleComponent (overlay, только isDevMode())
│
└── shared/             ← чистые pure-функции, НИКАКИХ Angular-зависимостей
    └── utils/          ← date-time, duration, json, math, number

Правила файловой организации

  • Один компонент = одна директория: feature-name/feature-name.component.ts, feature-name.html, feature-name.css.
  • Имена файлов: исключительно kebab-case.
  • Селекторы компонентов: app-<name> (e.g., app-session-detail).
  • Классы компонентов: PascalCase + суффикс Component (e.g., SessionDetailComponent).
  • Pipes: суффикс Pipe (e.g., SessionStatusPipe).
  • Сервисы: суффикс Service (e.g., WorksApiService).
  • Утилиты: суффикс .util.ts (e.g., date-time.util.ts).
  • Конфиги: суффикс .config.ts (e.g., user-error-messages.config.ts).
  • Типы: суффикс .types.ts (e.g., api.types.ts).

3. Angular-паттерны

3.1 Компоненты

Каждый компонент обязан соответствовать:

@Component({
  selector: 'app-my-component',
  imports: [/* только то, что используется в шаблоне */],
  templateUrl: './my-component.html',  // отдельный файл, НЕ inline template
  styleUrl: './my-component.css',       // singular (Angular 21 syntax), НЕ styleUrls
  changeDetection: ChangeDetectionStrategy.OnPush, // ОБЯЗАТЕЛЬНО
})
export class MyComponent {
  // DI через inject(), НЕ constructor injection
  private readonly api = inject(WorksApiService);
  private readonly userErrors = inject(UserErrorNotifyService);

  // Inputs/Outputs через signal-based API (Angular 17+)
  readonly sessionId = input.required<number>();
  readonly excludeMouseMoves = model(true);
  readonly telemetryToMsChange = output<number>();

  // Локальный state через signal()
  protected readonly isLoading = signal(false);

  // Вычисляемые значения через computed()
  protected readonly totalPages = computed(() =>
    Math.ceil(this.total() / this.limit),
  );
}

Запрещено:

  • @Input() / @Output() декораторы — используй input() / output() / model().
  • ChangeDetectionStrategy.Default — всегда OnPush.
  • constructor injection — всегда inject().
  • styleUrls: [...] (plural) — всегда styleUrl: '...' (singular, Angular 21+).
  • Inline template: и styles: (кроме крошечных компонентов < 15 строк шаблона).

3.2 Маршрутизация

Все feature-компоненты загружаются лениво через loadComponent:

export const routes: Routes = [
  {
    path: 'sessions',
    loadComponent: () =>
      import('./features/sessions/sessions-list/sessions-list.component')
        .then((m) => m.SessionsListComponent),
  },
  { path: '**', redirectTo: '' },
];

3.3 Тяжёлые вложенные компоненты: @defer

Если одновременно видна только одна вкладка/секция — оборачивай в @defer:

@case (1) {
  @defer {
    <app-session-interactive-tab [detail]="state.detail" />
  } @placeholder {
    <div class="loading-wrap">
      <tui-loader [loading]="true" size="l" />
    </div>
  }
}

3.4 Signals vs RxJS

Задача Инструмент
Локальный UI state (boolean, number, string) signal()
Derived/вычисляемые данные computed()
Two-way binding model()
HTTP-запросы RxJS Observableasync pipe
Подписки в компоненте toObservable() + switchMap/combineLatest
Lifecycle-cleanup effect(onCleanup)

Паттерн загрузки данных — единая цепочка с тремя статусами:

protected readonly data$ = toObservable(this.sessionId).pipe(
  switchMap((id) =>
    this.api.getData(id).pipe(
      map((data) => ({ status: 'ok' as const, data })),
      catchError((e: HttpErrorResponse) => {
        this.userErrors.notifyError(e, 'Контекст ошибки');
        return of({ status: 'error' as const });
      }),
      startWith({ status: 'loading' as const }),
    ),
  ),
);

В шаблоне:

@if (data$ | async; as state) {
  @switch (state.status) {
    @case ('loading') { <tui-loader [loading]="true" /> }
    @case ('error')   { <p class="muted">Не удалось загрузить данные.</p> }
    @case ('ok')      { /* контент --> }
  }
}

3.5 DI-токены

Конфигурации выносятся в InjectionToken с factory, а НЕ читаются напрямую из environment:

// core/config/api.tokens.ts
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
  factory: () => {
    const doc = inject(DOCUMENT);
    const loc = doc.defaultView?.location;
    if (loc?.protocol === 'file:') {
      return `${environment.apiFallbackOrigin}${environment.apiBasePath}`;
    }
    return environment.apiBasePath;
  },
});

Это обеспечивает тестируемость (можно подменить токен без мока environment) и поддержку file:// протокола.

3.6 Interceptors

Два функциональных interceptor'а (Angular 21 style):

  1. apiBaseUrlInterceptor — добавляет API_BASE_URL ко всем запросам, кроме:

    • абсолютных URL (https://...)
    • статических ассетов (/svg/, /fonts/, /images/)
  2. devLogInterceptor — логирует все HTTP-запросы в DevLogService (только isDevMode()).

Регистрация в app.config.ts:

provideHttpClient(withInterceptors([apiBaseUrlInterceptor, devLogInterceptor])),

3.7 Обработка ошибок

Полный pipeline:

HttpErrorResponse
  └→ classifyUserError(err): UserErrorKind
       └→ friendlyMessageForUserError(kind): string для пользователя
            └→ UserErrorNotifyService.notifyError() → TuiNotificationService

Классификация по HTTP-кодам и паттернам:

Код/паттерн UserErrorKind
status === 0 network
status === 401 unauthorized
status === 403 forbidden
status === 404 not_found
status >= 500 server_error
TimeoutError timeout
'Http failure during parsing' parse_error

Дружелюбные сообщения определяются в user-error-messages.config.ts.

3.8 Pipes

Standalone pipes для трансформации данных в шаблонах:

@Pipe({
  name: 'sessionStatus',
  standalone: true,
})
export class SessionStatusPipe implements PipeTransform {
  private readonly devLog = inject(DevLogService);

  transform(value: string | null | undefined): string {
    // ... маппинг + warn в DevLog для неизвестных значений
  }
}

Pipes логируют неизвестные значения в DevLogService (только devMode), чтобы разработчик заметил незнакомые статусы/типы.


4. Дизайн-система и CSS

4.1 Принцип: CSS Custom Properties + Taiga UI overrides

Все цвета, отступы и параметры анимаций централизованы в глобальном файле src/styles/color-tokens.css.

:root {
  /* SparkGuardian проектные токены */
  --sg-color-accent: #ffdb00;
  --sg-color-bg: #f6f7f8;
  --sg-color-text: #383839;
  --sg-color-subtitle: #313132;
  --sg-color-form-bg: #e8edf1;
  --sg-color-placeholder: #6b6d6f;

  /* Семантические алиасы */
  --sg-color-card-bg: #ffffff;
  --sg-color-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent);
  --sg-color-danger: #d92d20;

  /* Layout */
  --sg-content-max-width: 1104px;
  --sg-page-padding-inline: 1rem;

  /* Taiga UI overrides  переопределяем цвета дизайн-системы */
  --tui-background-accent-1: var(--sg-color-accent);
  --tui-background-base: var(--sg-color-bg);
  --tui-background-elevation-1: var(--sg-color-card-bg);
  --tui-text-primary: var(--sg-color-text);
  --tui-border-normal: var(--sg-color-border);
  --tui-focus: var(--sg-color-accent);
}

4.2 Naming convention для токенов

Prefix Назначение Пример
--sg-color-* Базовые цвета проекта --sg-color-accent
--sg-filter-chip-* Чипы фильтров --sg-filter-chip-active-bg
--sg-session-status-* Раскраска статусов сессий --sg-session-status-active-fg
--sg-keyboard-* Визуализация клавиатуры --sg-keyboard-key-pressed-fill
--sg-input-highlight-* Анимации подсветки --sg-input-highlight-duration
--sg-textfield-* Текстовые поля --sg-textfield-radius
--sg-native-input-* Нативные <input> --sg-native-input-min-height
--tui-* Переопределения Taiga UI --tui-background-accent-1

4.3 Правила работы со стилями

  1. Никогда не используй захардкоженных цветов в компонентных CSS. Всё через var(--sg-*) или var(--tui-*).
  2. Не дублируй глобальные классы в компонентных стилях. Глобальные .muted, .small, .mono, .section-title, .card, .loading-wrap определены в page-common.css и доступны везде.
  3. color-mix() — основной инструмент для производных цветов (hover, faded, border).
  4. Кнопки Taiga UI кастомизируются через CSS-селекторы с [tuiButton][tuiAppearance][data-appearance='secondary'].
  5. Текстовые поля стилизуются через два паттерна:
    • Taiga tui-textfield → класс .sg-tui-textfield
    • Нативный <input> → класс .sg-native-input
  6. Focus-visible vs focus — при клике снимаем outline/box-shadow; ring только для keyboard navigation.

4.4 Глобальные CSS-файлы

Файл Назначение
color-tokens.css Все CSS-переменные и Taiga overrides
page-common.css .card, .section-title, .loading-wrap, .muted, .small, .mono, .page
sg-input-fields.css Стилизация tui-textfield и нативных <input>
filter-chips.css Стилизация toggle-кнопок / чипов фильтрации
session-status-chips.css Раскраска [tuiChip] по статусам сессии

4.5 Layout

/* Используется для ограничения ширины контента */
.sg-content-column {
  max-width: var(--sg-content-max-width);  /* 1104px */
  margin-inline: auto;
  padding-inline: var(--sg-page-padding-inline);  /* 1rem / 48px на мобильных */
}

Все page-уровневые контейнеры добавляют класс .page .sg-content-column.

4.6 Шрифт

Шрифт Tinkoff Sans подключается глобально. В CSS используется font-family: 'Tinkoff Sans', system-ui, sans-serif. Для кода/моноширинных блоков: font-family: ui-monospace, monospace.


5. Taiga UI: правила интеграции

5.1 Инициализация

// app.config.ts
providers: [
  provideAnimations(),
  provideTaiga(),
  tuiNotificationOptionsProvider(() => ({
    block: 'start',
    inline: 'end',
  })),
]

Корневой шаблон оборачивается в <tui-root>:

<tui-root>
  <div class="shell">
    <header>...</header>
    <main><router-outlet /></main>
  </div>
  @if (isDev) { <app-dev-console /> }
</tui-root>

5.2 Используемые компоненты Taiga UI

Компонент Импорт Назначение
TuiButton @taiga-ui/core/components/button Primary/secondary кнопки
TuiLoader @taiga-ui/core/components/loader Индикаторы загрузки
TuiLink @taiga-ui/core/components/link Навигационные ссылки
TuiTitle @taiga-ui/core/components/title Заголовки с семантикой
TuiTabs @taiga-ui/core/directives/tabs Вкладки
TuiIcon @taiga-ui/core/components/icon Иконки
TuiInput @taiga-ui/core/components/input Текстовые поля (spread: ...TuiInput)
TuiChip @taiga-ui/kit/components/chip Чипы/бейджи
TuiPagination @taiga-ui/kit/components/pagination Пагинация
TuiAccordion @taiga-ui/kit Аккордеоны
TuiNotificationService @taiga-ui/core/components/notification Всплывающие уведомления

5.3 Кастомизация Taiga-компонентов

Не меняй глобальные стили Taiga напрямую. Вместо этого:

  1. Переопредели --tui-* переменные в color-tokens.css.
  2. Используй CSS-селекторы с [tuiAppearance][data-appearance='...'] для точечных кастомизаций.
  3. Добавляй собственные CSS-классы (.sg-tui-textfield, .stream-active) для стилевых модификаторов.

6. TypeScript: строгость и конвенции

6.1 tsconfig

{
  "compilerOptions": {
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "isolatedModules": true,
    "target": "ES2022",
    "module": "preserve"
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

6.2 ESLint ключевые правила

  • no-console: ['warn', { allow: ['warn', 'error'] }] — нет console.log в продакшне.
  • no-alert: 'error', no-debugger: 'error' — безопасность.
  • Компоненты: prefix app-, kebab-case.
  • Директивы: prefix app, camelCase.
  • @angular-eslint/template/no-call-expression: 'off' — мы вызываем методы в шаблонах (computed → formatDate и т.д.).

6.3 Правила кода

  • @ts-ignore запрещён. Используй keyof, Record<string, unknown>, type assertions.
  • Неиспользуемые импорты удаляются немедленно.
  • Falsy checks (!value) — НЕ использовать для числовых значений, которые могут быть 0. Используй value == null.
  • Track expressions в @for: использовать стабильные ключи (track item.id или track item.timestamp + '_' + item.type), а НЕ track $index.
  • Pure utility функции (без Angular-зависимости) → shared/utils/. Никогда не дублировать: вынести в shared.

7. Паттерны проектирования

7.1 Rule-based Engine (расширяемая обработка)

Вместо switch/case используется массив правил:

// types
export interface TelemetrySummaryRule {
  readonly id: string;
  readonly match: (o: Record<string, unknown>) => boolean;
  readonly summarize: (o: Record<string, unknown>) => string;
}

// config — просто массив, легко расширить добавлением одного элемента
export const TELEMETRY_SUMMARY_RULES: readonly TelemetrySummaryRule[] = [
  mouseClickRule,
  mouseMoveRule,
  keyboardKeyRule,
];

// engine — итерирует и применяет первое совпавшее правило
export function summarizeTelemetryData(data: unknown): string {
  const raw = unwrapJsonPayload(data);
  const o = raw as Record<string, unknown>;
  for (const rule of TELEMETRY_SUMMARY_RULES) {
    if (rule.match(o)) {
      return rule.summarize(o);
    }
  }
  return fallbackCompactJson(o);
}

7.2 Classification Pipeline (обработка ошибок)

Raw Error → classify(err) → UserErrorKind → friendlyMessage(kind) → UI Notification

Разделяет три ответственности:

  1. Классификатор (error-classification.util.ts) — чистая функция, тестируемая.
  2. Словарь сообщений (user-error-messages.config.ts) — Record<Kind, string>.
  3. Сервис уведомления (UserErrorNotifyService) — injectable, использует Taiga UI.

7.3 DevTools с ring-buffer

@Injectable({ providedIn: 'root' })
export class DevLogService {
  private seq = 0;
  private readonly max = 300;
  readonly entries = signal<DevLogEntry[]>([]);

  add(entry: Omit<DevLogEntry, 'id' | 'time'>): void {
    const withMeta: DevLogEntry = {
      id: ++this.seq,
      time: new Date().toISOString(),
      ...entry,
    };
    this.entries.update((curr) => [...curr.slice(-(this.max - 1)), withMeta]);
  }
}

Всё логирование за isDevMode() → нулевой overhead в продакшне.

7.4 SVG-инъекция (работа с графикой)

Для динамической подсветки элементов SVG:

  1. Загружаем SVG через HttpClient (responseType: text).
  2. Кэшируем через shareReplay({ bufferSize: 1, refCount: false }).
  3. Инжектируем <style> блок перед </svg> с CSS-правилами для нужных ID.
  4. Передаём в шаблон через DomSanitizer.bypassSecurityTrustHtml().

⚠️ SECURITY: bypassSecurityTrustHtml только для SVG из собственного public/. Никогда для пользовательского контента!

7.5 HLS-плеер: effect + onCleanup

constructor() {
  effect((onCleanup) => {
    const url = this.src();
    const video = this.videoRef()?.nativeElement;
    if (!video) return;

    let hls: Hls | null = null;
    if (Hls.isSupported()) {
      hls = new Hls({ enableWorker: true });
      hls.loadSource(url);
      hls.attachMedia(video);
    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
      video.src = url;  // Safari native
    }

    video.addEventListener('timeupdate', emitCurrentTime);
    // ...

    onCleanup(() => {
      video.removeEventListener('timeupdate', emitCurrentTime);
      hls?.destroy();
      video.removeAttribute('src');
      video.load();
    });
  });
}

Ключевое: onCleanup гарантирует очистку event listeners и уничтожение HLS при смене источника.


8. Окружение и конфигурация

8.1 Файлы окружений

src/environments/
  environment.ts       ← генерируется из .env скриптом sync-env.cjs
  environment.prod.ts  ← статический, для production

Оба файла ОБЯЗАНЫ содержать одинаковый набор полей:

export const environment = {
  production: boolean,
  apiFallbackOrigin: string,
  apiBasePath: string,
  interactivePrerollMs: number,
  defaultPageLimit: number,
} as const;

8.2 Dev-прокси

proxy.conf.cjs проксирует /api/** на бэкенд из SG_DEV_PROXY_TARGET в .env.


9. Язык интерфейса

  • Весь пользовательский интерфейс — на русском языке.
  • Это включает: заголовки, кнопки, уведомления об ошибках, подписи, плейсхолдеры, dev-console.
  • Технические термины (JSON, HTTP, API) — на английском, когда это общепринято.
  • Комментарии в коде — русский для JSDoc/описаний логики, английский допустим для однострочных TODO.

10. Контрольный чеклист для нового компонента

  • ChangeDetectionStrategy.OnPush
  • inject() вместо constructor injection
  • input() / output() / model() вместо декораторов
  • standalone: true (в Angular 21 по умолчанию)
  • Отдельные .html и .css файлы
  • styleUrl (singular)
  • Селектор app-*
  • Класс *Component
  • Все цвета через CSS-переменные
  • Ошибки HTTP через UserErrorNotifyService.notifyError()
  • Данные из API загружаются через паттерн status: loading → ok → error
  • Lazy-load через loadComponent в роутах
  • @defer для тяжёлых вложенных компонентов
  • Утилиты вынесены в shared/utils/, не дублируются
  • track в @for использует стабильный ключ
  • Неиспользуемые импорты удалены

11. Руководство по стилям: полный справочник

Цель этого раздела — чтобы любой новый проект, прочитавший этот файл, выглядел идентично SparkGuardian без дополнительных договорённостей.


11.1 Цветовая палитра

Вся цветовая система SparkGuardian основана на T-Bank / Tinkoff Design System. Основной акцент — жёлтый #ffdb00. Фон — светло-серый #f6f7f8. Текст — почти чёрный #383839.

Полный список CSS-переменных цветов

/* === БАЗОВЫЕ ЦВЕТА (src/styles/color-tokens.css) === */

/* Главные */
--sg-color-accent:      #ffdb00;   /* Жёлтый акцент — кнопки, focus, активные состояния */
--sg-color-bg:          #f6f7f8;   /* Фон страницы */
--sg-color-text:        #383839;   /* Основной текст */
--sg-color-subtitle:    #313132;   /* Чуть темнее — подзаголовки, nav-ссылки */
--sg-color-form-bg:     #e8edf1;   /* Фон форм, поверхностей инпутов */
--sg-color-placeholder: #6b6d6f;   /* Placeholder текст */

/* Карточки и границы */
--sg-color-card-bg:  #ffffff;
--sg-color-border:   color-mix(in srgb, var(--sg-color-text) 12%, transparent);
--sg-color-danger:   #d92d20;      /* Ошибки, деструктивные действия */

/* Текстовые поля */
--sg-color-textfield-bg:           #f3f4f7;   /* Состояние idle */
--sg-color-textfield-hover-bg:     #eaeff3;   /* Состояние hover */
--sg-color-textfield-focus-bg:     #ffffff;   /* Состояние focus */
--sg-color-textfield-focus-border: #333333;   /* Rim при фокусе */
--sg-color-textfield-focus-label:  #333333;   /* Label при фокусе */

/* Чипы-фильтры */
--sg-filter-chip-bg:            #f3f4f7;   /* Неактивный */
--sg-filter-chip-bg-hover:      #eaeff3;
--sg-filter-chip-fg:            #313131;
--sg-filter-chip-active-bg:     #158eff;   /* Активный — синий */
--sg-filter-chip-active-bg-hover: #0070ff;
--sg-filter-chip-active-fg:     #ffffff;

/* Статусы сессий */
--sg-session-status-active-bg:     color-mix(in srgb, #16a34a 18%, var(--sg-color-card-bg));
--sg-session-status-active-fg:     #166534;
--sg-session-status-active-border: color-mix(in srgb, #16a34a 38%, transparent);

--sg-session-status-pending-bg:     color-mix(in srgb, #eab308 18%, var(--sg-color-card-bg));
--sg-session-status-pending-fg:     #713f12;
--sg-session-status-pending-border: color-mix(in srgb, #eab308 34%, transparent);

--sg-session-status-unknown-bg:     color-mix(in srgb, var(--sg-color-text) 10%, var(--sg-color-card-bg));
--sg-session-status-unknown-fg:     var(--sg-color-text);
--sg-session-status-unknown-border: var(--sg-color-border);

Производные цвета через color-mix()

Никогда не добавляй захардкоженный полупрозрачный или затемнённый цвет. Всегда используй:

/* Затемнение */
color-mix(in srgb, var(--sg-color-accent) 85%, black)

/* Осветление / приглушение */
color-mix(in srgb, var(--sg-color-text) 60%, white)

/* Прозрачная граница */
color-mix(in srgb, var(--sg-color-text) 12%, transparent)

/* Цветной фон статуса */
color-mix(in srgb, #16a34a 18%, var(--sg-color-card-bg))

Переопределения Taiga UI

/* Всё это переопределяется один раз в color-tokens.css */
--tui-background-accent-1:         var(--sg-color-accent);
--tui-background-accent-1-hover:   color-mix(in srgb, var(--sg-color-accent) 92%, black);
--tui-background-accent-1-pressed: color-mix(in srgb, var(--sg-color-accent) 84%, black);
--tui-text-primary-on-accent-1:    var(--sg-color-text);   /* чёрный текст на жёлтом */

--tui-background-base:        var(--sg-color-bg);
--tui-background-elevation-1: var(--sg-color-card-bg);
--tui-text-primary:           var(--sg-color-text);
--tui-text-tertiary:          color-mix(in srgb, var(--sg-color-text) 70%, white);
--tui-text-action:            color-mix(in srgb, var(--sg-color-text) 80%, black);
--tui-border-normal:          var(--sg-color-border);
--tui-focus:                  var(--sg-color-accent);
--tui-status-negative:        var(--sg-color-danger);

11.2 Типографика

Шрифты

/* Основной шрифт — Tinkoff Sans (подключается в index.html или styles.css) */
font-family: 'Tinkoff Sans', system-ui, sans-serif;

/* Моноширинный — для кода, JSON, технических строк */
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;

Размеры: используй Taiga-токены, а не конкретные значения

font: var(--tui-font-heading-1);   /* ~2rem, bold — H1 экранов */
font: var(--tui-font-heading-2);   /* ~1.75rem — H2 */
font: var(--tui-font-heading-3);   /* ~1.5rem  — H3 */
font: var(--tui-font-heading-6);   /* ~1.1rem  — подзаголовки секций (.section-title) */
font: var(--tui-font-text-m);      /* ~1rem    — основной текст */
font: var(--tui-font-text-s);      /* ~0.875rem — вспомогательный (.small) */
font: var(--tui-font-text-xs);     /* ~0.75rem  — метки, подписи */

Готовые классы типографики (page-common.css, глобальные)

.muted  { color: var(--tui-text-tertiary); }                          /* Приглушённый текст */
.small  { font: var(--tui-font-text-s); margin-top: 0.35rem; }        /* Вспомогательный */
.mono   { font-family: ui-monospace, monospace; font-size: 0.92em; }  /* Код/JSON */

Правило: никогда не переопределяй .muted, .small, .mono в компонентном CSS — они доступны глобально.


11.3 Отступы и layout

Переменные

--sg-content-max-width:   1104px;  /* Ограничение ширины контента */
--sg-page-padding-inline: 1rem;    /* Горизонтальные поля (48px на экранах < 1000px) */

Базовые значения отступов (без переменных)

Контекст Значение
Внутри карточки (padding) 1.25rem 1.5rem
Между карточками (margin-bottom) 1.5rem
Внутри секций (gap у flex) 1rem1.5rem
Мелкие элементы (gap кнопок, чипов) 0.5rem
Отступ страницы сверху 1.5rem (padding-top у .page)
Отступ страницы снизу 3rem
Центрирование загрузчика padding: 3rem в .loading-wrap

Использование

/* Каждая страница — обёртка из двух классов */
<div class="page sg-content-column">
  ...
</div>

/* .page (из page-common.css) */
.page {
  padding-top: 1.5rem;
  padding-bottom: 3rem;
}

/* .sg-content-column (из app.css или глобально) */
.sg-content-column {
  max-width: var(--sg-content-max-width);  /* 1104px */
  margin-inline: auto;
  padding-inline: var(--sg-page-padding-inline);
}

11.4 Рецепты: типичные UI-задачи


◉ Шапка приложения (Header / Shell)

<!-- app.html -->
<tui-root>
  <div class="shell">
    <header class="shell-header">
      <div class="shell-header__inner sg-content-column">
        <a class="brand" routerLink="/">
          <img class="brand-logo" src="/images/logo.svg" alt="Логотип" />
          НазваниеПродукта
        </a>
        <nav class="shell-nav">
          <a class="shell-nav-link" routerLink="/section">Раздел</a>
        </nav>
      </div>
    </header>
    <main class="shell-main">
      <router-outlet />
    </main>
  </div>
</tui-root>
/* app.css */
.shell { min-height: 100dvh; display: flex; flex-direction: column; }
.shell-header {
  border-bottom: 1px solid var(--tui-border-normal);
  background: var(--tui-background-elevation-1);   /* белый */
}
.shell-header__inner {
  display: flex; align-items: center; gap: 1.75rem; padding-block: 1rem;
}
.brand {
  font-size: clamp(1.15rem, 2vw, 1.4rem);
  font-weight: 500;
  color: var(--tui-text-primary);
  text-decoration: none;
}
.brand:hover { color: var(--tui-text-action); }
.brand-logo  { height: 2rem; width: auto; }
.shell-nav   { display: flex; gap: 1.5rem; margin-left: 1.5rem; }
.shell-nav-link {
  text-decoration: none; font-weight: 400;
  color: var(--sg-color-subtitle);
  transition: opacity 0.2s;
}
.shell-nav-link:hover { opacity: 0.7; }
.shell-main { flex: 1; }

◉ Карточка (Card)

<div class="card">
  <h3 class="section-title">Заголовок секции</h3>
  <!-- контент -->
</div>
/* page-common.css (уже глобально) */
.card {
  padding: 1.25rem 1.5rem;
  border-radius: var(--tui-radius-l);
  background: var(--tui-background-elevation-1);  /* #ffffff */
  margin-bottom: 1.5rem;
}
.section-title {
  margin: 0 0 1rem;
  font: var(--tui-font-heading-6);
  color: var(--sg-color-subtitle);
}

◉ Вкладки (Tabs)

Используется TuiTabs из Taiga UI. Никаких кастомных tab-компонентов.

// component.ts
import { TuiTabs } from '@taiga-ui/core/directives/tabs';

protected readonly activeTabIndex = signal(0);
<!-- component.html -->
<tui-tabs [(activeItemIndex)]="activeTabIndex" size="m">
  <button tuiTab type="button">Вкладка 1</button>
  <button tuiTab type="button">Вкладка 2</button>
  <button tuiTab type="button">Вкладка 3</button>
</tui-tabs>

@switch (activeTabIndex()) {
  @case (0) { <компонент-вкладки-1 /> }
  @case (1) {
    @defer {
      <компонент-вкладки-2 />
    } @placeholder {
      <div class="loading-wrap"><tui-loader [loading]="true" size="l" /></div>
    }
  }
}

Правило: первая (главная) вкладка — без @defer, все остальные — обязательно @defer.


◉ Кнопки

<!-- Primary — жёлтый акцент -->
<button tuiButton type="button" appearance="primary">
  Создать
</button>

<!-- Secondary — нейтральный -->
<button tuiButton type="button" appearance="secondary">
  Отмена
</button>

<!-- Деструктивный -->
<button tuiButton type="button" appearance="destructive">
  Удалить
</button>

<!-- Ссылка-кнопка -->
<a tuiButton appearance="secondary" routerLink="/sessions">
  К списку
</a>

Минимальная ширина основной кнопки действия задаётся через CSS-переменную:

--sg-primary-action-min-inline-size: 11rem;

button.my-create-btn {
  min-inline-size: var(--sg-primary-action-min-inline-size);
}

◉ Чипы-фильтры (Filter Chips / Toggle Tabs)

Паттерн: горизонтальный список кнопок appearance="secondary", одна активна (класс .stream-active).

<div class="stream-tabs">
  @for (type of streamTypes(); track type) {
    <button
      tuiButton
      type="button"
      appearance="secondary"
      size="s"
      [class.stream-active]="activeStreamType() === type"
      (click)="pickStream(type)"
    >
      {{ type }}
    </button>
  }
</div>

Стили уже определены в filter-chips.css — импортировать глобально в styles.css. Повторно в компоненте не определять.

При необходимости создать аналогичный набор переключателей — добавь новые имена в селекторы filter-chips.css:

/* Добавить новый контейнер к существующим правилам */
.my-new-tabs button[tuiButton]... { /* те же правила */ }

◉ Бейджи и статусные чипы

Используется TuiChip из Taiga UI. Состояние передаётся через CSS-классы на атрибут [tuiChip].

<span tuiChip class="status-chip status-chip--active">Активна</span>
<span tuiChip class="status-chip status-chip--pending">Ожидание</span>
<span tuiChip class="status-chip">Завершена</span>  <!-- без доп. класса -->
/* session-status-chips.css (глобально) */
[tuiChip].status-chip.status-chip--active {
  background: var(--sg-session-status-active-bg);
  color:      var(--sg-session-status-active-fg);
  border-color: var(--sg-session-status-active-border);
}

Для нового типа статуса:

  1. Добавь токены в color-tokens.css:
    --sg-session-status-НОВЫЙ-bg:     color-mix(in srgb, #цвет 18%, var(--sg-color-card-bg));
    --sg-session-status-НОВЫЙ-fg:     #тёмный-цвет;
    --sg-session-status-НОВЫЙ-border: color-mix(in srgb, #цвет 38%, transparent);
    
  2. Добавь CSS-правило в session-status-chips.css.
  3. Добавь маппинг в Pipe (SessionStatusChipClassesPipe).

◉ Текстовые поля

Вариант 1 — Taiga tui-textfield (предпочтительный):

<tui-textfield class="sg-tui-textfield">
  <label tuiLabel>Поиск</label>
  <input tuiInput type="text" [(ngModel)]="query" />
</tui-textfield>

Добавь класс .sg-tui-textfield — стили применяются автоматически из sg-input-fields.css.

Вариант 2 — нативный <input> (для дат, времени и т.д.):

<input
  class="sg-native-input"
  type="datetime-local"
  [value]="dateValue()"
  (change)="onDateChange($event)"
/>

Классы .sg-native-input и .sg-tui-textfield — глобальные, не треубют импорта в отдельный компонент.


◉ Состояния загрузки

<!-- Центральный loader на всю секцию -->
<div class="loading-wrap">
  <tui-loader [loading]="true" size="xl" />
</div>

<!-- Маленький loader внутри блока -->
<div class="loading-wrap loading-wrap_small">
  <tui-loader [loading]="true" size="m" />
</div>

<!-- Inline в кнопке -->
<button tuiButton [loading]="isSubmitting()">
  Сохранить
</button>
/* page-common.css (уже глобально) */
.loading-wrap       { display: flex; justify-content: center; padding: 3rem; }
.loading-wrap_small { padding: 1rem 0; }

<nav class="back">
  <a tuiLink routerLink="/sessions">К списку сессий</a>
</nav>
/* В компонентном CSS или page-common.css */
.back {
  margin-bottom: 1rem;
}

◉ Заголовки страниц

<!-- Главный заголовок страницы (H1/H2) -->
<h2 tuiTitle="m" class="heading">Сессия #{{ id() }}</h2>

<!-- Заголовок секции в карточке -->
<h3 class="section-title">Аномалии</h3>

<!-- Подзаголовок / subtitle -->
<p class="small muted">Последнее обновление: {{ updatedAt }}</p>

Правила именования заголовков:

  • h1 — только один на странице, используется для заголовка маршрута.
  • h2 c tuiTitle="m" — заголовок страницы (с Taiga-семантикой).
  • h3 c .section-title — заголовок логической секции (внутри карточки).
  • Никаких font-size / font-weight в компонентном CSS для заголовков — только через Taiga-токены или глобальные классы.

◉ Уведомления и ошибки

// Всегда через UserErrorNotifyService, НИКОГДА напрямую через TuiNotificationService
private readonly userErrors = inject(UserErrorNotifyService);

// При ошибке HTTP
this.userErrors.notifyError(httpError, 'Контекст: что пытались сделать');

// Если нужно уведомление без ошибки — через TuiAlerts напрямую допустимо только в исключительных случаях

В шаблоне для inline-ошибок:

@case ('error') {
  <p class="muted">Не удалось загрузить данные.</p>
}

◉ Пустые состояния (Empty State)

<div class="loading-wrap">
  <p class="muted">Данных нет.</p>
</div>

Или с иконкой:

<div style="display: flex; flex-direction: column; align-items: center; gap: 0.75rem; padding: 3rem;">
  <tui-icon icon="@tui.inbox" style="font-size: 2.5rem; color: var(--tui-text-tertiary);" />
  <p class="muted">Список пуст</p>
</div>

◉ Таблицы данных

<table style="width: 100%; border-collapse: collapse;">
  <thead>
    <tr>
      <th style="text-align: left; padding: 0.5rem; font: var(--tui-font-text-s); color: var(--tui-text-tertiary);">
        Колонка
      </th>
    </tr>
  </thead>
  <tbody>
    @for (row of rows(); track row.id) {
      <tr style="border-top: 1px solid var(--tui-border-normal);">
        <td style="padding: 0.5rem 0.5rem;">{{ row.value }}</td>
      </tr>
    }
  </tbody>
</table>

Правила:

  • track — обязательно стабильный ключ (track item.id), никогда track $index.
  • Цвет границ: var(--tui-border-normal).
  • Фон строк hover: color-mix(in srgb, var(--sg-color-text) 4%, transparent).

11.5 Анимации и переходы

/* Стандартный transition для интерактивных элементов */
transition:
  background-color 0.15s ease,
  color 0.15s ease,
  border-color 0.15s ease,
  box-shadow 0.15s ease;

/* Плавное появление блоков */
transition: opacity 0.2s ease;

/* Анимация подсветки ввода (клавиатура/мышь) — централизована */
animation-duration: var(--sg-input-highlight-duration);   /* 140ms */
animation-timing-function: var(--sg-input-highlight-easing); /* cubic-bezier(0.33,1,0.68,1) */

Правило: кастомные @keyframes — только через CSS-инъекцию в SVG или глобально. В компонентных стилях анимации — только через transition. Для сложных анимаций — @angular/animations.


11.6 Focus-стили и доступность

/* Убираем ring при клике мышью */
:focus:not(:focus-visible) {
  outline: none !important;
  box-shadow: none !important;
}

/* Оставляем ring при навигации с клавиатуры */
:focus-visible {
  outline: 2px solid var(--sg-filter-chip-active-bg);
  outline-offset: 2px;
}

Это правило уже применяется в filter-chips.css для чипов. Для своих интерактивных элементов добавляй аналогично.


11.7 Добавление новой страницы: CSS-чеклист

Когда создаёшь новый маршрут/страницу:

  • Корневой div — <div class="page sg-content-column">
  • Заголовок страницы — <h2 tuiTitle="m"> или <h1 tuiTitle="l">
  • Состояния загрузки — loading-wrap + TuiLoader
  • Ошибки — через UserErrorNotifyService + inline class="muted"
  • Карточки — class="card", секции — class="section-title"
  • Все цвета — через var(--sg-*) или var(--tui-*)
  • Никаких px-цветов, никаких rgba(0,0,0,...) — только color-mix()
  • Шрифты — через var(--tui-font-*) или font: inherit
  • Анимации — transition: ... 0.15s ease для hover/active
  • Мобильные отступы — проверь поведение при max-width: 999px