- 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 - Add CLAUDE.md, .env.example, proxy config, CI workflow, and Makefile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
46 KiB
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 Observable → async 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):
-
apiBaseUrlInterceptor— добавляетAPI_BASE_URLко всем запросам, кроме:- абсолютных URL (
https://...) - статических ассетов (
/svg/,/fonts/,/images/)
- абсолютных URL (
-
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 Правила работы со стилями
- Никогда не используй захардкоженных цветов в компонентных CSS. Всё через
var(--sg-*)илиvar(--tui-*). - Не дублируй глобальные классы в компонентных стилях. Глобальные
.muted,.small,.mono,.section-title,.card,.loading-wrapопределены вpage-common.cssи доступны везде. color-mix()— основной инструмент для производных цветов (hover, faded, border).- Кнопки Taiga UI кастомизируются через CSS-селекторы с
[tuiButton][tuiAppearance][data-appearance='secondary']. - Текстовые поля стилизуются через два паттерна:
- Taiga
tui-textfield→ класс.sg-tui-textfield - Нативный
<input>→ класс.sg-native-input
- Taiga
- 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 напрямую. Вместо этого:
- Переопредели
--tui-*переменные вcolor-tokens.css. - Используй CSS-селекторы с
[tuiAppearance][data-appearance='...']для точечных кастомизаций. - Добавляй собственные 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
Разделяет три ответственности:
- Классификатор (
error-classification.util.ts) — чистая функция, тестируемая. - Словарь сообщений (
user-error-messages.config.ts) —Record<Kind, string>. - Сервис уведомления (
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:
- Загружаем SVG через
HttpClient(responseType:text). - Кэшируем через
shareReplay({ bufferSize: 1, refCount: false }). - Инжектируем
<style>блок перед</svg>с CSS-правилами для нужных ID. - Передаём в шаблон через
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.OnPushinject()вместо constructor injectioninput()/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) | 1rem — 1.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);
}
Для нового типа статуса:
- Добавь токены в
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); - Добавь CSS-правило в
session-status-chips.css. - Добавь маппинг в 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; }
◉ Навигационные ссылки (back-link)
<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— только один на странице, используется для заголовка маршрута.h2ctuiTitle="m"— заголовок страницы (с Taiga-семантикой).h3c.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+ inlineclass="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