Поправил шрифты и некоторые визуальные косяки

This commit is contained in:
dipatrik10
2026-09-27 17:47:36 +03:00
parent e3b6797713
commit c6083b35e0
37 changed files with 644 additions and 63 deletions

View File

@@ -463,6 +463,17 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
### Темизация
Общий `ui-foundation.css` подключается последним во всех пяти оболочках.
Текст, заголовки и элементы управления используют прежний стек `Inter`,
`-apple-system`, `BlinkMacSystemFont`, `Segoe UI`, `sans-serif` без внешнего CDN.
Если Inter не установлен, браузер выбирает системный шрифт без засечек.
Базовые стили содержат запасной стек на случай недоступности общего файла.
Список свободных аудиторий на дашборде имеет высоту 180 px и внутреннюю
вертикальную прокрутку, доступную с клавиатуры. В запросах преподавателей
статус, комментарий и кнопки размещаются под содержимым заявки; кнопки
переносятся целиком при нехватке ширины, статусы не растягиваются по высоте.
CSS-переменные позволяют поддерживать светлую/тёмную тему:
```css

View File

@@ -107,7 +107,9 @@ COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
tag и manifest digest. Первый этап frontend собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
файлы, без `node_modules`, тестов и build-исходников. В корень образа также копируются
`ui-foundation.css`, `schedule-overview.css` и `schedule-overview.js`: они необходимы
для общего шрифта, компонентов и обзора занятий в личных кабинетах. Apache подключает `mod_headers`,
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
маршрутизировался в tenant `default`. Перед копированием runtime-файлов устанавливаются
доступные исправления Alpine через `apk upgrade --no-cache`. Сервер

View File

@@ -18,6 +18,7 @@ COPY edu-office/ /usr/local/apache2/htdocs/edu-office/
COPY student/ /usr/local/apache2/htdocs/student/
COPY teacher/ /usr/local/apache2/htdocs/teacher/
COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
COPY ui-foundation.css schedule-overview.css schedule-overview.js /usr/local/apache2/htdocs/
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf

View File

@@ -219,6 +219,13 @@
align-content: flex-start;
gap: 0.5rem;
margin-top: 0.25rem;
height: 180px;
min-height: 180px;
max-height: 180px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-right: 0.25rem;
}
.dashboard-actions {

View File

@@ -67,7 +67,7 @@ html {
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;

View File

@@ -66,7 +66,7 @@
.quality-hero h2 {
max-width: 790px;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: clamp(2rem, 4.5vw, 4.4rem);
font-weight: 500;
letter-spacing: -0.045em;
@@ -229,7 +229,7 @@
.quality-score-inner strong {
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: 3.2rem;
font-weight: 500;
line-height: 0.95;
@@ -271,7 +271,7 @@
.quality-score-copy h3 {
margin: 0.55rem 0 0;
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: clamp(1.6rem, 3vw, 2.5rem);
font-weight: 500;
}
@@ -338,7 +338,7 @@
.quality-metric-index {
color: color-mix(in srgb, var(--text-secondary) 65%, transparent);
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: 0.78rem;
}
@@ -346,7 +346,7 @@
display: block;
margin-top: 1.25rem;
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: 2rem;
font-weight: 500;
}
@@ -402,7 +402,7 @@
.quality-recommendations h3 {
margin: 0;
color: var(--text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: 1.65rem;
font-weight: 500;
}
@@ -537,7 +537,7 @@
.quality-penalty {
color: var(--quality-red);
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: 1.15rem;
}
@@ -583,7 +583,7 @@
background: color-mix(in srgb, var(--quality-cyan) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--quality-cyan) 35%, transparent);
border-radius: 50%;
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: 1.6rem;
}

View File

@@ -30,7 +30,7 @@
right: -1.5rem;
bottom: 1.2rem;
color: rgba(255, 255, 255, .055);
font-family: Georgia, serif;
font-family: var(--ui-font-display);
font-size: clamp(4rem, 10vw, 9rem);
letter-spacing: -.08em;
pointer-events: none;
@@ -53,7 +53,7 @@
.version-hero h2 {
max-width: 760px;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-family: var(--ui-font-display);
font-size: clamp(2rem, 4.5vw, 4.5rem);
font-weight: 500;
letter-spacing: -.05em;
@@ -136,7 +136,7 @@
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border));
}
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.55rem; }
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
.version-constructor-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; align-items: end; }
.version-constructor-field { min-width: 0; }
@@ -219,7 +219,7 @@
margin-bottom: 1rem;
}
.version-section-head > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 2rem; }
.version-section-head > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 2rem; }
.version-draft-list { display: grid; gap: .8rem; }
.version-card {
@@ -237,7 +237,7 @@
right: .5rem;
top: -.8rem;
color: color-mix(in srgb, var(--version-copper) 12%, transparent);
font-family: Georgia, serif;
font-family: var(--ui-font-display);
font-size: 5rem;
}
@@ -252,7 +252,7 @@
place-content: center;
text-align: center;
}
.version-empty > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 4rem; }
.version-empty > span { color: var(--version-copper); font-family: var(--ui-font-display); font-size: 4rem; }
.version-empty p { max-width: 360px; }
.version-diff-score {
@@ -262,7 +262,7 @@
margin: 1rem 0;
}
.version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; }
.version-diff-score strong { display: block; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.75rem; }
.version-diff-score strong { display: block; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 1.75rem; }
.version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; }
.version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; }
@@ -292,7 +292,7 @@
}
.version-modal[hidden] { display: none; }
.version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 2rem; }
.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: var(--ui-font-display); font-size: 2rem; }
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; }
.version-modal-card form { display: grid; gap: .65rem; }
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }

View File

@@ -187,6 +187,7 @@
}
.absence-registry-title {
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
}
@@ -220,21 +221,31 @@
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 0.65rem;
padding: 0.25rem 0.65rem;
flex: 0 0 auto;
align-self: flex-start;
max-width: 100%;
line-height: 1.4;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.69rem;
font-weight: 800;
letter-spacing: 0.03em;
white-space: nowrap;
white-space: normal;
}
.absence-status.is-pending { color: #f59e0b; }
.absence-status.is-approved { color: #fb7185; }
.absence-status.is-approved { color: #34d399; }
.absence-status.is-resolved { color: #34d399; }
.absence-status.is-rejected,
.absence-status.is-cancelled { color: var(--text-secondary); }
[data-theme="light"] .absence-status.is-pending { color: #92400e; }
[data-theme="light"] .absence-status.is-approved,
[data-theme="light"] .absence-status.is-resolved { color: #047857; }
.absence-status.is-rejected { color: #fb7185; }
[data-theme="light"] .absence-status.is-rejected { color: #be123c; }
.absence-registry-buttons {
justify-content: flex-end;
flex-wrap: wrap;
@@ -481,7 +492,7 @@ body.absence-wizard-open {
.teacher-dialogue-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
@@ -604,7 +615,7 @@ body.absence-wizard-open {
.teacher-dialogue-item {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) minmax(180px, .42fr);
grid-template-columns: 7px minmax(0, 1fr);
gap: .85rem;
padding: .85rem;
border: 1px solid var(--bg-card-border);
@@ -613,6 +624,7 @@ body.absence-wizard-open {
}
.teacher-dialogue-mark {
grid-row: 1 / 3;
border-radius: 99px;
background: #94a3b8;
}
@@ -631,6 +643,11 @@ body.absence-wizard-open {
font-size: .68rem;
}
.teacher-dialogue-copy {
min-width: 0;
overflow-wrap: anywhere;
}
.teacher-dialogue-copy h3 {
margin: .22rem 0;
font-size: .92rem;
@@ -668,17 +685,32 @@ body.absence-wizard-open {
.teacher-dialogue-copy ol { display: grid; gap: .3rem; padding-left: 1.15rem; }
.teacher-dialogue-action {
grid-column: 2;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: stretch;
gap: .55rem;
}
.teacher-dialogue-action > div {
display: flex;
justify-content: flex-end;
justify-content: flex-start;
flex-wrap: wrap;
gap: .35rem;
gap: .5rem;
}
.teacher-dialogue-action > div > .btn {
flex: 1 1 120px;
min-width: 0;
white-space: normal;
line-height: 1.35;
padding-block: 0.4rem;
}
.teacher-dialogue-toolbar > .btn {
align-self: center;
min-height: 44px;
}
.teacher-dialogue-comment {
@@ -801,6 +833,6 @@ body.absence-wizard-open {
.teacher-dialogue-action {
grid-column: 2;
align-items: flex-start;
align-items: stretch;
}
}

View File

@@ -15,6 +15,7 @@
<link rel="stylesheet" href="css/teacher-absences.css">
<link rel="stylesheet" href="css/schedule-quality.css">
<link rel="stylesheet" href="css/schedule-versions.css">
<link rel="stylesheet" href="../ui-foundation.css">
</head>
<body>
@@ -78,7 +79,7 @@
<!-- Раздел: Расписание -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div>
<div class="sidebar-section-title">Расписание</div>
<a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -149,7 +150,7 @@
<!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div>
<div class="sidebar-section-title">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -184,7 +185,7 @@
<!-- Раздел: Система -->
<div class="sidebar-section-divider"></div>
<div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div>
<div class="sidebar-section-title">Система</div>
<a href="#" class="nav-item" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"

View File

@@ -935,7 +935,7 @@ export async function initAcademicCalendar() {
<span class="calendar-subject-chip">
<span>${escapeHtml(subjectRowLabel(row))}</span>
<button type="button"
class="calendar-subject-remove"
class="btn btn-icon-sm btn-danger-subtle calendar-subject-remove"
data-semester="${escapeHtml(String(row.semesterNumber))}"
data-subject="${escapeHtml(String(row.subjectId))}"
aria-label="Убрать ${escapeHtml(subjectRowLabel(row))}">×</button>

View File

@@ -94,11 +94,13 @@ export async function initClassrooms() {
</button>
</div>
</td>
<td style="text-align: right;">
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
<td class="table-actions-cell">
<div class="table-actions">
<button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
${c.status === 'ARCHIVED'
? `<button class="btn btn-sm btn-secondary btn-restore-classroom" data-id="${c.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}">Архивировать</button>`}
</div>
</td>
</tr>`;
}).join('');

View File

@@ -333,7 +333,7 @@ export async function initGroups() {
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
${group.status === 'ARCHIVED'
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
? `<button class="btn btn-sm btn-secondary btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
</div>
</td>

View File

@@ -865,7 +865,7 @@ export async function initScheduleView() {
const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate
? `
<button type="button"
class="schedule-override-edit"
class="btn btn-sm btn-secondary schedule-override-edit"
data-schedule-override-edit
data-base-rule-slot-id="${escapeHtml(lesson.scheduleRuleSlotId)}"
data-lesson-date="${escapeHtml(sourceDate)}">

View File

@@ -990,7 +990,7 @@ export async function initSchedule() {
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
<button type="button"
class="schedule-visual-edit-rule"
class="btn btn-icon-sm btn-ghost schedule-visual-edit-rule"
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"

View File

@@ -86,7 +86,7 @@ export async function initUsers() {
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td>
${u.status === 'ARCHIVED'
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
? `<button class="btn btn-sm btn-secondary btn-restore-user" data-id="${u.id}">Восстановить</button>`
: `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
</td>
</tr>

View File

@@ -61,7 +61,7 @@ html {
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="../css/components.css">
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="../../ui-foundation.css">
</head>
<body>

View File

@@ -108,7 +108,7 @@
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
</div>
<button type="button" class="btn btn-icon-sm btn-secondary settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть">
<button type="button" class="btn btn-icon-md btn-ghost settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть окно">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>

View File

@@ -273,7 +273,7 @@
<div class="modal-overlay" id="calendar-day-dialog">
<div class="modal-content card calendar-day-dialog-card">
<h2>Ячейка графика</h2>
<button class="modal-close" id="calendar-day-dialog-close">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="calendar-day-dialog-close" aria-label="Закрыть окно">&times;</button>
<div class="calendar-day-dialog-grid">
<div>
<span>Дата</span>

View File

@@ -70,7 +70,7 @@
<div class="modal-overlay" id="modal-edit-classroom">
<div class="modal-content card">
<h2>Редактировать аудиторию</h2>
<button class="modal-close" id="modal-edit-classroom-close">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-classroom-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-classroom-form">
<input type="hidden" id="edit-classroom-id">
<div class="form-row" style="margin-top: 1rem;">
@@ -116,7 +116,7 @@
<div class="modal-content card" style="max-width: 600px; width: 90%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Справочник оборудования</h2>
<button class="modal-close" id="modal-manage-equipments-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-equipments-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма добавления -->

View File

@@ -91,7 +91,7 @@
<!-- Свободные аудитории -->
<div class="card dashboard-compact-card">
<h3 class="dashboard-card-title">Свободные аудитории (ближайшие)</h3>
<div id="free-classrooms-list" class="dashboard-badge-list">
<div id="free-classrooms-list" class="dashboard-badge-list" tabindex="0" role="region" aria-label="Свободные аудитории">
<div style="color: var(--text-secondary); font-size: 0.9rem;">Загрузка...</div>
</div>
</div>

View File

@@ -118,7 +118,7 @@
<div class="modal-overlay" id="modal-edit-group">
<div class="modal-content card edit-group-modal">
<h2>Редактировать группу</h2>
<button class="modal-close" id="modal-edit-group-close">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-group-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<div class="form-row edit-group-form-row edit-group-form-row-main">
@@ -177,7 +177,7 @@
<h2>Подгруппы группы</h2>
<div id="subgroups-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button class="modal-close" id="modal-manage-subgroups-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-subgroups-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма -->
@@ -233,7 +233,7 @@
<h2>Календарные графики группы</h2>
<div id="calendar-group-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button class="modal-close" id="modal-manage-calendar-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-calendar-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма -->

View File

@@ -71,7 +71,7 @@
<div class="version-modal" id="version-modal" hidden>
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button type="button" class="version-modal-close" id="version-modal-close" aria-label="Закрыть">×</button>
<button type="button" class="btn btn-icon-md btn-ghost version-modal-close" id="version-modal-close" aria-label="Закрыть окно">×</button>
<span class="version-eyebrow" id="version-modal-kicker">Новая операция</span>
<h3 id="version-modal-title">Версия расписания</h3>
<p id="version-modal-copy"></p>

View File

@@ -38,7 +38,7 @@
<div class="version-modal schedule-draft-modal" id="schedule-draft-modal" hidden>
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="schedule-draft-title">
<button type="button" class="version-modal-close" id="schedule-draft-close" aria-label="Закрыть">×</button>
<button type="button" class="btn btn-icon-md btn-ghost version-modal-close" id="schedule-draft-close" aria-label="Закрыть окно">×</button>
<span class="version-eyebrow">Новая рабочая копия</span>
<h3 id="schedule-draft-title">Создать черновик</h3>
<p id="schedule-draft-copy">Все правила выбранного расписания будут скопированы.</p>
@@ -152,7 +152,7 @@
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-conflict-card">
<button type="button" class="modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Конфликт слота</span>
<h2>Освободите место для нового правила</h2>
@@ -190,7 +190,7 @@
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-move-slot-card">
<button type="button" class="modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Перенос слота</span>
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>

View File

@@ -75,7 +75,7 @@
<h2>Преподаватели дисциплины</h2>
<div id="subject-teachers-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button class="modal-close" id="modal-manage-teachers-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-teachers-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма -->

View File

@@ -169,7 +169,7 @@
<div class="modal-overlay" id="modal-edit-department">
<div class="modal-content card">
<h2>Редактирование кафедры</h2>
<button class="modal-close" id="modal-edit-department-close">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-department-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-department-form">
<input type="hidden" id="edit-dept-id">
<div class="form-row">
@@ -192,7 +192,7 @@
<div class="modal-overlay" id="modal-edit-specialty">
<div class="modal-content card">
<h2>Редактирование специальности</h2>
<button class="modal-close" id="modal-edit-specialty-close">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close" id="modal-edit-specialty-close" aria-label="Закрыть окно">&times;</button>
<form id="edit-specialty-form">
<input type="hidden" id="edit-spec-id">
<div class="form-row">
@@ -219,7 +219,7 @@
<h2>Профили обучения</h2>
<div id="profiles-spec-context" style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem;"></div>
</div>
<button class="modal-close" id="modal-manage-profiles-close" style="position: static; font-size: 2rem; background: none; border: none; cursor: pointer; color: var(--text-secondary);">&times;</button>
<button type="button" class="btn btn-icon-md btn-ghost modal-close modal-close--static" id="modal-manage-profiles-close" aria-label="Закрыть окно">&times;</button>
</div>
<!-- Форма создания/редактирования профиля -->

View File

@@ -6,6 +6,7 @@
<meta name="description" content="Страница авторизации">
<title>Авторизация</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="ui-foundation.css">
</head>
<body>
<div class="background">

View File

@@ -1,4 +1,4 @@
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-Ap89pw6Ru+bI6/fvSZrLRErTB0Vlrkjhr+GBUApiIWk=' 'sha256-SIXM20kwgT/LFCXoUieNCZ2zVEtDEJLBMFkzuqTDiEs=' 'sha256-Gg5P79IEFo2naj0u+NnV/XRNhPpmG+qszzSTXOPUj1A=' 'sha256-TnG5YZ63fruUpLo7bQSZmwEy7iaTHxpbBhwz5vu9uSg=' 'sha256-XzNZtBQ5RVU4zPLtfvXv2K6GCBeoCM2HUIIQSlEaULA=' 'sha256-nULJId45lkSYkp9G7rs5Ucrzkl/RvOYcmolE2LAWGy0=' 'sha256-zxhlsg7XWHprQXI4sW8mCF1bkpJgB7ybVJwWOPVLGRE=' 'sha256-K0Xrmwt6pAGUrGVS8MwRZJExIhykcVQ8SI1byCheVwc=' 'sha256-4YmfPYnVNL8IzZiGb/0iwhc6yUgCF19Y33KOfCPVmCw=' 'sha256-iNZSE5e3vOhMyMQkK5rpl8610z7E4ZtyqgP2Svv49PU=' 'sha256-MhqAPfMbUlw1OEgW5/QfCxo+BaJmIHx/8235W2X3bVI=' 'sha256-foNl8DYrtM07r6llOCuEQkuDC3P+Z9AhhM6r+IJDuvE=' 'sha256-TZ/59DFnpMwCdrSuMVhcjfKS2+Uktb1ikcGdbop+LrY=' 'sha256-bouNTXMWL7OeoflvcP2VmNuyQ00CJIpU091rlJcNh0o=' 'sha256-SWPOf/xgJrjLW+pINXE1lz0iy53MqwWqXeVJ6eC/feI=' 'sha256-1N/gWxc/W4gFlj8zf/p3KcADNes7ROePFaTOWiqwF4E=' 'sha256-biLFinpqYMtWHmXfkA1BPeCY0/fNt46SAZ+BBk5YUog=' 'sha256-/dAGmjflKS5r+hQF1hmagGz5QABPHf+YFadZo1ne2rc=' 'sha256-Xrwk3bvnw8h9hvxxkkKe/tGHcDa4FiyIkZgQh7zZ5OM=' 'sha256-yg9xEhg50yeViXuIpHVfY4ORsctQahuBNAF5elcPo2o=' 'sha256-wJR14aiwE2jYijrg7Fjqg/N0F1aAZ5CIcUtwN05LcGg=' 'sha256-aYnI2FBeHDuscjjfM9Mw136ryNwuYO8SA40Zevg1KoY=' 'sha256-y6K4epLEZv6QKwcoJSp/0xGIN8fOl7DSHSPNd5QT6Xo=' 'sha256-tdUysXRHnX2Z4LXERihNGxflbZuvaer3/ZEbjN2vQYE=' 'sha256-scdHk/Xhx12Q7ITYKMEP661eR9qxmkAEEIEGSoSvhnk=' 'sha256-SQjLChNmhyZMwxbpXjOF6LYFFUEHnV6PvzvTEmWb7eA=' 'sha256-50MALp7OIkg8n1p4VILXcnKO8ypKpLNL/QYLOgWPOCk=' 'sha256-clWb9y4Y/RHEr3K6egWYXbH1r5Hi2fgpzDHlBdskEeo=' 'sha256-chNIGV8ioVlRP9Ypn/XhEA2EhWFAeWCuUGJL5S+vzz0=' 'sha256-W998Pje85WadRdREdYtdQtKylzNM5QOtfq5HJWoaeTc=' 'sha256-s6IpehL1Dcfvbn/QY1MA4otfjj+O7fzMbbfi7Qc6pmA=' 'sha256-AC3CbEeLPJf2W6daVbkfUpWh5e6gLviBovIHzqhb7I4=' 'sha256-+j7xWNheSULp9DWeiEHZrhTxGekhyQ9qIjUgAiPhJ50=' 'sha256-moRhvd2U0JZLbFHJNyPJuevxlkfGXw5XWMByVNv9Vig=' 'sha256-203TZuzrYa786NHRsoV1tXZ8L/9lEl+/UL46E0VAiGI=' 'sha256-032ZxPG0oQ3+/U6NXKrgm5LnBreb9401xJrjrsb+p+U=' 'sha256-JamKOmpIVQSPv+gDrYtzbxnVclnrvhrOMLmA+WaGPIE=' 'sha256-mnRyqew64Lcv5/Qgb/4HP8LfJi5MjjQCGLPt+/cJZis=' 'sha256-Fx6gwPzP46ZjBgLjBZ6QLksJxr+0rXElPelXtSQMnxE=' 'sha256-fug/5+Ut6nV0WD+m4YV23l7GiD/oMKUnUvZdMiDCZmw=' 'sha256-AkGc/9SiOd74zk72UnCdLs+k10sM4iy2uKmgoXkaHe0=' 'sha256-Ar6JjQB5XpqI8eiPMCdTp/UuHaqgHiv6I+MV5V0AqD0=' 'sha256-q3nqK4VzeI/SowYCYQ/tCfo056B/JMutuSpzbJbHczk=' 'sha256-FHr5kWe1pqEckj58767LzvY6a0Jefvryb6NDWnX+9iA=' 'sha256-3bpbcQ42x+1MqmADGJ3zikAZX1tMXcOSJO5HaQgBLd0=' 'sha256-JZXmJpz7JH1a0DyiOr/syUiirSXF8caWHSEs55ZeuvM=' 'sha256-5z4wfnbsEag4kdsuEHpgdTrXYzlAY7n+RR18FQrPaEk=' 'sha256-97KgMmG+SNbCjjLJMC4FFguhozddOvNKLVC6hCmCMsg=' 'sha256-K1k356n2KePxr1iToCyZJNdGCm9eq1GSLPiovYYcGJw=' 'sha256-Wbi+cQ7sBtwXXZqWfFgLJRH4d6W8PogiptWNj9KIw8c=' 'sha256-ZGygktZXqOYjv8SF3rIcpWd19YFA1qMbC3LhD9PPSIk=' 'sha256-C1diVwUHC99dr4LcsUyLWfgQa8cu0K/7SBK+bd24TWg=' 'sha256-gKWvnkygeYkz1lyPSLpgVWSsmFaaDT2lY+hqijL4Gd8=' 'sha256-XhqU3am1TiOnFUKLIcPS5JCePjRUcUoGqQvjnfL92ik=' 'sha256-COR0sjBXRz2BkYmsZyQELjCUGLEbVii9YMmpBGwfFhM=' 'sha256-N90MKmRow2DpYEVeqcc3uc8pOUsS4Rg4sNmkau1k0xQ=' 'sha256-qcFcRsF+ljnThVJj80IfggPdbDjDPk/ZEeeiFCw/f4s=' 'sha256-niVdTxYNBhpZEFDz7d87VgN56BaVHxkE1mKzrSiTEa8=' 'sha256-N6tSydZ64AHCaOWfwKbUhxXx2fRFDxHOaL3e3CO7GPI=' 'sha256-V3Yf08rYNHRd1lYyQK1mcEb3duonX88OA6nfz8qDvuY=' 'sha256-7VXlcg/uSZugHSa6UtIG2/44ju460LiO4M0CyQfraX8='; upgrade-insecure-requests"
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-Ap89pw6Ru+bI6/fvSZrLRErTB0Vlrkjhr+GBUApiIWk=' 'sha256-SIXM20kwgT/LFCXoUieNCZ2zVEtDEJLBMFkzuqTDiEs=' 'sha256-Gg5P79IEFo2naj0u+NnV/XRNhPpmG+qszzSTXOPUj1A=' 'sha256-TnG5YZ63fruUpLo7bQSZmwEy7iaTHxpbBhwz5vu9uSg=' 'sha256-XzNZtBQ5RVU4zPLtfvXv2K6GCBeoCM2HUIIQSlEaULA=' 'sha256-nULJId45lkSYkp9G7rs5Ucrzkl/RvOYcmolE2LAWGy0=' 'sha256-zxhlsg7XWHprQXI4sW8mCF1bkpJgB7ybVJwWOPVLGRE=' 'sha256-K0Xrmwt6pAGUrGVS8MwRZJExIhykcVQ8SI1byCheVwc=' 'sha256-4YmfPYnVNL8IzZiGb/0iwhc6yUgCF19Y33KOfCPVmCw=' 'sha256-iNZSE5e3vOhMyMQkK5rpl8610z7E4ZtyqgP2Svv49PU=' 'sha256-MhqAPfMbUlw1OEgW5/QfCxo+BaJmIHx/8235W2X3bVI=' 'sha256-foNl8DYrtM07r6llOCuEQkuDC3P+Z9AhhM6r+IJDuvE=' 'sha256-TZ/59DFnpMwCdrSuMVhcjfKS2+Uktb1ikcGdbop+LrY=' 'sha256-bouNTXMWL7OeoflvcP2VmNuyQ00CJIpU091rlJcNh0o=' 'sha256-SWPOf/xgJrjLW+pINXE1lz0iy53MqwWqXeVJ6eC/feI=' 'sha256-1N/gWxc/W4gFlj8zf/p3KcADNes7ROePFaTOWiqwF4E=' 'sha256-biLFinpqYMtWHmXfkA1BPeCY0/fNt46SAZ+BBk5YUog=' 'sha256-/dAGmjflKS5r+hQF1hmagGz5QABPHf+YFadZo1ne2rc=' 'sha256-Xrwk3bvnw8h9hvxxkkKe/tGHcDa4FiyIkZgQh7zZ5OM=' 'sha256-yg9xEhg50yeViXuIpHVfY4ORsctQahuBNAF5elcPo2o=' 'sha256-wJR14aiwE2jYijrg7Fjqg/N0F1aAZ5CIcUtwN05LcGg=' 'sha256-aYnI2FBeHDuscjjfM9Mw136ryNwuYO8SA40Zevg1KoY=' 'sha256-y6K4epLEZv6QKwcoJSp/0xGIN8fOl7DSHSPNd5QT6Xo=' 'sha256-scdHk/Xhx12Q7ITYKMEP661eR9qxmkAEEIEGSoSvhnk=' 'sha256-SQjLChNmhyZMwxbpXjOF6LYFFUEHnV6PvzvTEmWb7eA=' 'sha256-50MALp7OIkg8n1p4VILXcnKO8ypKpLNL/QYLOgWPOCk=' 'sha256-clWb9y4Y/RHEr3K6egWYXbH1r5Hi2fgpzDHlBdskEeo=' 'sha256-chNIGV8ioVlRP9Ypn/XhEA2EhWFAeWCuUGJL5S+vzz0=' 'sha256-W998Pje85WadRdREdYtdQtKylzNM5QOtfq5HJWoaeTc=' 'sha256-s6IpehL1Dcfvbn/QY1MA4otfjj+O7fzMbbfi7Qc6pmA=' 'sha256-AC3CbEeLPJf2W6daVbkfUpWh5e6gLviBovIHzqhb7I4=' 'sha256-+j7xWNheSULp9DWeiEHZrhTxGekhyQ9qIjUgAiPhJ50=' 'sha256-moRhvd2U0JZLbFHJNyPJuevxlkfGXw5XWMByVNv9Vig=' 'sha256-032ZxPG0oQ3+/U6NXKrgm5LnBreb9401xJrjrsb+p+U=' 'sha256-JamKOmpIVQSPv+gDrYtzbxnVclnrvhrOMLmA+WaGPIE=' 'sha256-mnRyqew64Lcv5/Qgb/4HP8LfJi5MjjQCGLPt+/cJZis=' 'sha256-Fx6gwPzP46ZjBgLjBZ6QLksJxr+0rXElPelXtSQMnxE=' 'sha256-fug/5+Ut6nV0WD+m4YV23l7GiD/oMKUnUvZdMiDCZmw=' 'sha256-AkGc/9SiOd74zk72UnCdLs+k10sM4iy2uKmgoXkaHe0=' 'sha256-Ar6JjQB5XpqI8eiPMCdTp/UuHaqgHiv6I+MV5V0AqD0=' 'sha256-q3nqK4VzeI/SowYCYQ/tCfo056B/JMutuSpzbJbHczk=' 'sha256-FHr5kWe1pqEckj58767LzvY6a0Jefvryb6NDWnX+9iA=' 'sha256-3bpbcQ42x+1MqmADGJ3zikAZX1tMXcOSJO5HaQgBLd0=' 'sha256-JZXmJpz7JH1a0DyiOr/syUiirSXF8caWHSEs55ZeuvM=' 'sha256-5z4wfnbsEag4kdsuEHpgdTrXYzlAY7n+RR18FQrPaEk=' 'sha256-97KgMmG+SNbCjjLJMC4FFguhozddOvNKLVC6hCmCMsg=' 'sha256-K1k356n2KePxr1iToCyZJNdGCm9eq1GSLPiovYYcGJw=' 'sha256-Wbi+cQ7sBtwXXZqWfFgLJRH4d6W8PogiptWNj9KIw8c=' 'sha256-ZGygktZXqOYjv8SF3rIcpWd19YFA1qMbC3LhD9PPSIk=' 'sha256-C1diVwUHC99dr4LcsUyLWfgQa8cu0K/7SBK+bd24TWg=' 'sha256-XhqU3am1TiOnFUKLIcPS5JCePjRUcUoGqQvjnfL92ik=' 'sha256-COR0sjBXRz2BkYmsZyQELjCUGLEbVii9YMmpBGwfFhM=' 'sha256-N90MKmRow2DpYEVeqcc3uc8pOUsS4Rg4sNmkau1k0xQ=' 'sha256-qcFcRsF+ljnThVJj80IfggPdbDjDPk/ZEeeiFCw/f4s=' 'sha256-niVdTxYNBhpZEFDz7d87VgN56BaVHxkE1mKzrSiTEa8=' 'sha256-N6tSydZ64AHCaOWfwKbUhxXx2fRFDxHOaL3e3CO7GPI=' 'sha256-V3Yf08rYNHRd1lYyQK1mcEb3duonX88OA6nfz8qDvuY=' 'sha256-7VXlcg/uSZugHSa6UtIG2/44ju460LiO4M0CyQfraX8='; upgrade-insecure-requests"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"

View File

@@ -6,6 +6,7 @@
<title>Расписание студента</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="../schedule-overview.css">
<link rel="stylesheet" href="../ui-foundation.css">
</head>
<body>
<main class="shell">

View File

@@ -33,7 +33,7 @@ body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
}
.shell {

View File

@@ -110,7 +110,7 @@ html {
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;

View File

@@ -220,7 +220,7 @@ import { trackUxEvent } from '../telemetry.js';
: 'Добавить отметку';
return `<button type="button" class="preference-cell ${typeClass}${pendingClass}"
data-day="${index + 1}" data-slot="${slot.id}" data-preference-id="${item?.id || ''}"
data-preference-status="${item?.status || ''}" title="${escapeHtml(title)}">
data-preference-status="${item?.status || ''}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}">
<span>${item ? preferenceTypeSymbol(item.preferenceType) : '+'}</span>
</button>`;
})
@@ -681,7 +681,7 @@ import { trackUxEvent } from '../telemetry.js';
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
${lesson.scheduleRuleSlotId && !lesson.scheduleOverrideId ? `
<button class="lesson-request-button" type="button" data-request-lesson="${escapeHtml(lessonKey(lesson))}">
<button class="btn btn-sm btn-secondary lesson-request-button" type="button" data-request-lesson="${escapeHtml(lessonKey(lesson))}">
Запросить изменение
</button>
` : '<span class="lesson-change-note">Занятие уже изменено учебным отделом</span>'}

View File

@@ -6,6 +6,7 @@
<title>Кабинет преподавателя</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="../schedule-overview.css">
<link rel="stylesheet" href="../ui-foundation.css">
</head>
<body>
<main class="shell">

View File

@@ -207,7 +207,7 @@ body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
font-family: var(--ui-font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
}
.shell {

View File

@@ -81,6 +81,12 @@ test('Docker-образ включает локальный telemetry bundle и
assert.match(dockerfile, /scripts\/build-vendor\.mjs/);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/vendor\//);
assert.match(dockerfile, /magistr-security\.conf/);
for (const asset of ['ui-foundation.css', 'schedule-overview.css', 'schedule-overview.js']) {
const copiesAsset = dockerfile.split('\n').some(line =>
line.startsWith('COPY ') && line.includes(asset) && line.includes('/usr/local/apache2/htdocs/')
);
assert.ok(copiesAsset, `Общий ресурс отсутствует в Docker-образе: ${asset}`);
}
assert.match(telemetry, /import\('\/vendor\/otel\.js'\)/);
assert.doesNotMatch(telemetry, /https?:\/\//);
});

View File

@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const readFrontendFile = relativePath => readFile(
new URL(`../${relativePath}`, import.meta.url),
'utf8'
);
test('все пользовательские оболочки завершают каскад единой визуальной основой', async () => {
const entrypoints = [
['index.html', 'ui-foundation.css'],
['admin/index.html', '../ui-foundation.css'],
['admin/settings/index.html', '../../ui-foundation.css'],
['teacher/index.html', '../ui-foundation.css'],
['student/index.html', '../ui-foundation.css']
];
for (const [file, expectedHref] of entrypoints) {
const html = await readFrontendFile(file);
const stylesheets = Array.from(
html.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/g),
match => match[1]
);
assert.equal(stylesheets.at(-1), expectedHref, file);
}
});
test('единая визуальная основа фиксирует типографику, размеры и доступный фокус', async () => {
const foundation = await readFrontendFile('ui-foundation.css');
assert.match(foundation, /--ui-font-sans:/);
assert.match(foundation, /--ui-control-sm:\s*32px/);
assert.match(foundation, /--ui-control-md:\s*40px/);
assert.match(foundation, /--ui-control-lg:\s*48px/);
assert.match(foundation, /\.btn-sm\s*\{[^}]*min-height:\s*var\(--ui-control-sm\)/s);
assert.match(foundation, /\.btn-md\s*\{[^}]*min-height:\s*var\(--ui-control-md\)/s);
assert.match(foundation, /\.btn-lg\s*\{[^}]*min-height:\s*var\(--ui-control-lg\)/s);
assert.match(foundation, /:focus-visible\s*\{[^}]*outline:/s);
assert.match(foundation, /prefers-reduced-motion:\s*reduce/);
});
test('локальные стили используют общие шрифтовые токены', async () => {
const files = [
'style.css',
'student/style.css',
'teacher/style.css',
'admin/css/main.css',
'admin/settings/css/main.css',
'admin/css/schedule-quality.css',
'admin/css/schedule-versions.css'
];
for (const file of files) {
const css = await readFrontendFile(file);
assert.doesNotMatch(css, /font-family:\s*(?:['"]?Inter|['"]?Segoe UI|Georgia|['"]Times New Roman)/, file);
}
});
test('архивные действия и кнопки закрытия используют системные размеры', async () => {
const actionSources = await Promise.all([
readFrontendFile('admin/js/views/classrooms.js'),
readFrontendFile('admin/js/views/groups.js'),
readFrontendFile('admin/js/views/users.js')
]);
actionSources.forEach(source => {
assert.match(source, /class="btn btn-sm btn-secondary btn-restore-/);
});
const modalViews = await Promise.all([
'admin/views/academic-calendar.html',
'admin/views/classrooms.html',
'admin/views/groups.html',
'admin/views/schedule.html',
'admin/views/schedule-versions.html',
'admin/views/subjects.html',
'admin/views/university-structure.html',
'admin/settings/views/time-slots.html'
].map(readFrontendFile));
const closeButtons = modalViews.flatMap(html => Array.from(
html.matchAll(/<button\b[^>]*class="[^"]*(?:modal-close|version-modal-close|settings-modal-close)[^"]*"[^>]*>/g),
match => match[0]
));
assert.ok(closeButtons.length > 0);
closeButtons.forEach(button => {
assert.match(button, /type="button"/);
assert.match(button, /class="[^"]*\bbtn\b[^"]*\bbtn-icon-md\b/);
assert.match(button, /aria-label="Закрыть окно"/);
});
});

421
frontend/ui-foundation.css Normal file
View File

@@ -0,0 +1,421 @@
/*
* Единая визуальная основа Magistr.
*
* Файл подключается последним во всех пользовательских оболочках и задаёт
* общие инварианты: типографику, размеры элементов управления, фокус,
* радиусы, ритм и поведение на узких экранах. Локальные CSS отвечают только
* за компоновку и предметные компоненты конкретного раздела.
*/
:root {
--ui-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--ui-font-display: var(--ui-font-sans);
--ui-font-mono: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
--ui-control-sm: 32px;
--ui-control-md: 40px;
--ui-control-lg: 48px;
--ui-radius-control: 8px;
--ui-radius-field: 10px;
--ui-radius-panel: 16px;
--ui-radius-large: 24px;
--ui-space-1: 4px;
--ui-space-2: 8px;
--ui-space-3: 12px;
--ui-space-4: 16px;
--ui-space-5: 20px;
--ui-space-6: 24px;
--ui-space-8: 32px;
--ui-shadow-panel: 0 12px 32px rgba(15, 23, 42, 0.08);
--ui-shadow-floating: 0 20px 56px rgba(15, 23, 42, 0.18);
--ui-transition: 160ms cubic-bezier(0.2, 0, 0, 1);
--ui-focus-ring: var(--button-focus-ring, rgba(79, 70, 229, 0.35));
/* Совместимость с уже существующими токенами разделов. */
--button-radius: var(--ui-radius-control);
}
html {
color-scheme: dark;
font-size: 16px;
text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html[data-theme="light"] {
color-scheme: light;
}
body {
font-family: var(--ui-font-sans);
font-size: 1rem;
line-height: 1.5;
text-rendering: optimizeLegibility;
}
button,
input,
select,
textarea {
font: inherit;
letter-spacing: inherit;
}
button,
[role="button"],
a {
-webkit-tap-highlight-color: transparent;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--ui-font-display);
font-weight: 700;
line-height: 1.18;
letter-spacing: -0.025em;
text-wrap: balance;
}
p,
li,
dd {
text-wrap: pretty;
}
time,
input[type="date"],
input[type="time"],
.schedule-overview-next-time,
.academic-year-code,
.version-number,
.metric-value {
font-variant-numeric: tabular-nums;
}
::selection {
background: color-mix(in srgb, var(--accent, #4f46e5) 28%, transparent);
}
/* ===== Доступный фокус ===== */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--accent, #4f46e5);
outline-offset: 2px;
}
:where(input, select, textarea):focus-visible {
border-color: var(--accent, #4f46e5);
box-shadow: 0 0 0 3px var(--ui-focus-ring);
}
/* ===== Кнопки ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--ui-space-2);
min-width: 0;
min-height: var(--ui-control-md);
padding: 0 var(--ui-space-4);
border: 1px solid var(--button-secondary-border, var(--panel-border, var(--bg-card-border)));
border-radius: var(--ui-radius-control);
font-family: var(--ui-font-sans);
font-size: 0.875rem;
font-weight: 650;
line-height: 1;
text-align: center;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background-color var(--ui-transition),
border-color var(--ui-transition),
color var(--ui-transition),
box-shadow var(--ui-transition),
transform var(--ui-transition);
}
.btn:hover:not(:disabled):not([aria-disabled="true"]) {
transform: translateY(-1px);
}
.btn:active:not(:disabled):not([aria-disabled="true"]) {
transform: translateY(0);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--ui-focus-ring);
}
.btn:disabled,
.btn[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.62;
transform: none;
}
.btn-sm {
min-height: var(--ui-control-sm);
padding-inline: var(--ui-space-3);
font-size: 0.8125rem;
}
.btn-md {
min-height: var(--ui-control-md);
padding-inline: var(--ui-space-4);
font-size: 0.875rem;
}
.btn-lg {
min-height: var(--ui-control-lg);
padding-inline: var(--ui-space-5);
font-size: 0.9375rem;
font-weight: 700;
}
.btn-icon-sm,
.btn-icon-md {
flex: 0 0 auto;
padding: 0;
border-radius: var(--ui-radius-control);
}
.btn-icon-sm {
width: var(--ui-control-sm);
height: var(--ui-control-sm);
min-height: var(--ui-control-sm);
}
.btn-icon-md {
width: var(--ui-control-md);
height: var(--ui-control-md);
min-height: var(--ui-control-md);
}
.btn-primary {
border-color: var(--button-primary-border, var(--accent, #4f46e5));
background: var(--button-primary-bg, var(--accent, #4f46e5));
color: var(--button-primary-text, #fff);
}
.btn-primary:hover:not(:disabled) {
border-color: var(--button-primary-border, var(--accent-strong, var(--accent-hover, #4338ca)));
background: var(--button-primary-bg-hover, var(--accent-strong, var(--accent-hover, #4338ca)));
color: var(--button-primary-text, #fff);
}
.btn-secondary {
border-color: var(--button-secondary-border, var(--panel-border, var(--bg-card-border)));
background: var(--button-secondary-bg, var(--panel, var(--bg-card)));
color: var(--button-secondary-text, var(--text, var(--text-primary)));
}
.btn-secondary:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent, #4f46e5) 38%, var(--button-secondary-border, var(--panel-border, var(--bg-card-border))));
background: var(--button-secondary-bg-hover, var(--bg-hover, color-mix(in srgb, var(--accent, #4f46e5) 7%, var(--panel, var(--bg-card)))));
color: var(--button-secondary-text, var(--text, var(--text-primary)));
}
.btn-ghost {
border-color: var(--button-ghost-border, transparent);
background: transparent;
color: var(--text-secondary, var(--muted));
}
.btn-ghost:hover:not(:disabled) {
border-color: var(--button-ghost-border, var(--panel-border, var(--bg-card-border)));
background: var(--button-ghost-bg-hover, var(--bg-hover, rgba(148, 163, 184, 0.1)));
color: var(--text-primary, var(--text));
}
.btn-danger {
border-color: var(--button-danger-border, #b91c1c);
background: var(--button-danger-bg, #dc2626);
color: #fff;
}
.btn-danger-subtle {
border-color: var(--button-danger-subtle-border, rgba(239, 68, 68, 0.28));
background: var(--button-danger-subtle-bg, rgba(239, 68, 68, 0.12));
color: var(--button-danger-subtle-text, #b91c1c);
}
.btn-danger-subtle:hover:not(:disabled) {
border-color: var(--button-danger-subtle-border, rgba(239, 68, 68, 0.38));
background: var(--button-danger-subtle-bg-hover, rgba(239, 68, 68, 0.18));
color: var(--button-danger-subtle-text, #b91c1c);
}
.input-wrapper .toggle-password {
border-color: transparent;
background: transparent;
color: var(--text-placeholder, var(--muted));
}
/* ===== Поля форм ===== */
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
select,
textarea {
border-radius: var(--ui-radius-field);
}
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
select {
min-height: var(--ui-control-md);
}
textarea {
min-height: 96px;
line-height: 1.5;
resize: vertical;
}
input::placeholder,
textarea::placeholder {
color: var(--text-placeholder, var(--muted));
opacity: 1;
}
label {
line-height: 1.35;
}
/* ===== Поверхности и ритм ===== */
.card,
.settings-card,
.toolbar,
.schedule-overview,
.teacher-absence-panel,
.preference-board,
.preference-card,
.request-ledger-head,
.request-dialog {
border-radius: var(--ui-radius-panel);
}
.card,
.settings-card,
.toolbar,
.schedule-overview {
box-shadow: var(--ui-shadow-panel);
}
.card:hover {
transform: none;
box-shadow: var(--ui-shadow-panel);
}
.card-header-row,
.settings-header-row,
.dashboard-card-header,
.preference-board-head {
gap: var(--ui-space-4);
}
.form-row,
.filter-row,
.actions,
.week-nav {
column-gap: var(--ui-space-3);
}
table {
font-variant-numeric: tabular-nums;
}
th {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.035em;
}
.table-actions-cell {
text-align: right;
}
.table-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--ui-space-2);
}
.schedule-visual-lesson {
padding-right: 2.55rem;
}
/* ===== Унифицированные кнопки закрытия ===== */
.modal-close,
.version-modal-close,
.settings-modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--ui-control-md);
height: var(--ui-control-md);
min-height: var(--ui-control-md);
padding: 0;
border: 1px solid var(--button-ghost-border, var(--panel-border, var(--bg-card-border)));
border-radius: var(--ui-radius-control);
background: transparent;
color: var(--text-secondary, var(--muted));
font-family: var(--ui-font-sans);
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
transition: background-color var(--ui-transition), border-color var(--ui-transition), color var(--ui-transition);
}
.modal-close:hover,
.version-modal-close:hover,
.settings-modal-close:hover {
border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent);
background: color-mix(in srgb, var(--error, #ef4444) 10%, transparent);
color: var(--error, #ef4444);
}
.modal-close--static {
position: static;
flex: 0 0 auto;
}
/* ===== Скролл и движение ===== */
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--text-secondary, var(--muted)) 42%, transparent) transparent;
}
@media (max-width: 640px) {
.shell {
padding: var(--ui-space-4);
}
.actions .btn:not(.btn-icon-sm):not(.btn-icon-md) {
flex: 1 1 auto;
}
.modal-content,
.request-dialog,
.version-modal-card {
border-radius: var(--ui-radius-panel);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}