Фронтенд¶
Интерфейс — серверный рендеринг Django-шаблонами плюс SCSS и vanilla JS. Ассеты
npm-пакетов копирует scripts/copy-assets.mjs, линтеры вызываются своими CLI
через npm scripts, сжатием на стороне Django занимается django-compressor.
Структура¶
Путь |
Содержимое |
|---|---|
|
Django-шаблоны |
|
Собственные скрипты |
|
Исходники SCSS |
|
Ассеты из npm-пакетов, копируются |
|
Результат |
Слои SCSS¶
base.html подключает три файла в этом порядке, и порядок значим — каждый
следующий рассчитывает на объявления предыдущего:
Файл |
Что в нём |
|---|---|
|
переменные Bootstrap, слой семантических токенов и те компоненты Bootstrap, которые подключены |
|
оформление проекта: кнопки, таблицы, типографика, модалки, компоненты страниц |
|
шапка, подвал, «тело», движение; подключает |
Страничные css/pages/*.scss подключаются своими шаблонами в блоке
{% block css %}, то есть после общих трёх.
Внутри bootstrap.scss три яруса:
Пигменты —
css/variables.scss:$orange-400,$blue-400,$dark-300и остальная рампа. Отображаются на$primary,$info,$success,$dangerдо того, как Bootstrap прочитает свои умолчания.Токены —
--surface,--surface-raised,--border,--text,--accent,--interactive,--status-*. Это роли, а не цвета: значение меняется вместе с[data-theme], поэтому правилу, написанному через токен, не нужен парный блок в светлой теме.--bs-*перенаправлены на них, чтобы вендорный CSS (темаbootstrap5у Tom Select) читал палитру проекта.Компоненты Bootstrap — подключаются по одному, каждый со своим визуальным разбором:
reboot,grid,transitions,forms/{labels,form-text,form-control,form-select,form-check,validation},button-group,nav,card,pagination,close,tooltip,modal,utilitiesиutilities/api.
Кнопки, таблицы и типографика Bootstrap не подключены сознательно: это оформление проекта, а не недоделанный Bootstrap. Подключить их значило бы подложить чужие правила под свои и переопределять обратно.
Новое правило пишется через токен. Пигмент по имени ($dark-600) требует
второго правила в блоке [data-theme='light'], и именно эти пары постепенно
схлопываются по мере перевода страничных файлов.
Фокус с клавиатуры¶
Кольцо фокуса — одно правило в theme.scss и токен --focus-ring в обеих
темах. Два обстоятельства объясняют его вид:
кольцо рисуется
box-shadow, а неoutline:outlineу вариантовbtn-outline-*работает рамкой, и десяток страничных правил его снимает;кольцо двухслойное — 2 px
--surface, затем 2 px--focus-ring. Один цвет не может пройти 3:1 и к фону страницы, и к заливке кнопки: почти белое кольцо даёт 16,9:1 на фоне и 2,6:1 на синей заливке. Внутренний слой цвета фона даёт 6,5:1 к этой же заливке, так что граница видна с любой стороны.
Правило перечисляет четыре селектора, потому что трижды фокус берёт не тот
элемент, который виден: обёртка Tom Select (:has(:focus-visible)), label
вокруг спрятанного радио и .btn-check рядом с таким же label.
Контрол, который не должен быть виден, прячется миксином
visually-hidden-control из variables.scss, не display: none:
display: none выкидывает его и из последовательности табуляции — так фильтры
статуса четырёх каталогов, оба расписания станции и форма нового наблюдения
были недоступны с клавиатуры вообще. Запрет проверяется тестом
test_no_stylesheet_hides_a_control_outright.
Почему bootstrap.scss сохраняет @import¶
Остальное дерево на @use. Исходники Bootstrap 5.3 написаны через @import, а
Sass запрещает @use файла, который сам импортирует, — то есть
@use 'bootstrap' with (...) для настройки переменных недоступен. Отсюда три
следствия:
.stylelintrcзапрещает@importправиломat-rule-disallowed-listи освобождает от него один этот файл по имени;в
COMPRESS_PRECOMPILERSстоит--silence-deprecation=import,color-functions,if-function,global-builtin— все четыре категории приходят из вендорных исходников, настоящие ошибки при этом видны;пути ведут в
../lib/bootstrap/scss/, куда файлы кладётscripts/copy-assets.mjs, а не вnode_modules: в образеnode_modulesнет, из него копируется только бинарь dart-sass.
Когда Bootstrap перейдёт на модули, это станет @use 'bootstrap' with (...), а
исключение в .stylelintrc уйдёт.
Визуальная проверка¶
Скриншот-диффового харнесса в проекте нет. Вместо него — список страниц, который прогоняется руками (Playwright MCP годится) на 375, 768 и 1440 px, в обеих темах, до и после правки, меняющей вид.
Примечание
Компрессор читает файлы через staticfiles storage, то есть из staticfiles/, а
не из исходников. После правки JS или CSS: rm -rf staticfiles,
manage.py collectstatic --noinput и перезапуск сервера — манифест
кэшируется в процессе.
Раздел |
Страницы |
|---|---|
Витрина |
|
Наблюдения |
|
Станции |
|
Спутники |
|
Данные |
|
Статистика |
|
Аккаунт |
|
Ошибки |
|
Отдельно, потому что разметку рисует JS и на скриншоте страницы её нет:
модалка спутника (клик по спутнику в любом списке), модалка станции на
/satellites/<norad>/, модалки предложения спутника и передатчика на
/transmitters/.
Страница спутника переключает первый экран по состоянию аппарата, поэтому одним
скриншотом не проверяется. Нужны три: аппарат, который сеть наблюдала за
последние 30 дней (карта плюс живая сводка), аппарат на орбите без свежих
наблюдений (карта плюс паспортная сводка) и сошедший с орбиты (вместо карты —
фотография). Вкладки открываются и по адресу: /satellites/<norad>/#tab-orbit.
Страница запуска устроена так же и тоже требует трёх скриншотов: до старта
(фотография и отсчёт), живой режим — у отслеживаемого запуска от T−1 ч до
T+72 ч, для проверки без пуска /launches/<id>/?live=1 под is_staff, — и
архив. /launches/ проверяется в каждом из трёх сегментов; герой-афиша и
плитки при переключении остаются на месте.
Предупреждение
Видимость столбцов DataTables хранится в cookie T_<столбец> и переживает
перезагрузку. Пустая или урезанная таблица после отладки — состояние браузера,
а не регрессия.
Команды¶
npm ci
npm run assets # скопировать ассеты пакетов в network/static/lib
npm run lint:js # eslint по network/static/js и scripts/
npm run lint:css # stylelint по network/static/css/**/*.{scss,css}
npm run lint # оба линтера
npm test # jest, tests/js/**/*.test.js
Список копируемых файлов — массив assets в package.json. Путь, которому
ничего не соответствует, роняет npm run assets с кодом возврата 1, а не
пропускается молча.
Обновление фронтенд-зависимостей — ./soniks.sh update.
satellite.js намеренно остаётся на 6: седьмая версия выходит только как ES-модуль
("type": "module", без UMD-сборки и dist/satellite.min.js). Страницы грузят её
обычным <script> и ждут глобальный satellite, компрессор склеивает её в бандл, а
jest берёт через require(). Переход возможен вместе с бандлером, не раньше.
В CI линтеры, копирование ассетов и jest выполняет джоба js_css_linting на
образе node:24-bookworm; её артефакт network/static/lib передаётся дальше в
джобы build и test.
Сборка статики в контейнере¶
djangoctl.sh prepare (вызывается при старте web) выполняет:
collectstatic --noinput --clear— собирает статику вSTATIC_ROOT;compress --force— офлайн-сборка блоковdjango-compressor, только приCOMPRESS_OFFLINE=True;migrate --noinput.
Предупреждение
--clear очищает STATIC_ROOT целиком при каждом старте. Файлы, положенные туда в
обход collectstatic, не переживут перезапуск.
Сжатие управляется настройками COMPRESS_ENABLED, COMPRESS_OFFLINE,
COMPRESS_CACHE_BACKEND. На сервере первые две включаются парой: без офлайн-сборки
имена бандлов живут в кэше дольше, чем файлы, которые стирает --clear. По умолчанию
обе выключены — так работают разработка и тесты.
Предупреждение
В {% block javascript %} и {% block css %} нет ни {% if %}, ни {{ }} — только
{% static %} и {% include %}. base.html оборачивает эти блоки в {% compress %}, а
офлайн-манифест ключуется тем, во что блок отрендерился: compress рендерит его
один раз с пустым контекстом, и запрос, у которого вышло иначе, получает
OfflineGenerationError — 500 вместо страницы. Условие переносится в сам скрипт
(map-satellite.js выходит, если на странице нет #sat-map). Сторожат
test_a_bundled_block_renders_the_same_for_every_request и OfflineCompressionTest.
Тег <script> вне этих блоков — отдельный запрос на каждой загрузке; все такие теги
перечислены с причиной в tests/js/frontend_perf.test.js.
SCSS компилируется dart-sass: бинарь приезжает из пакета
sass-embedded и кладётся образом на PATH; SASS_BINARY переопределяет путь.
Карты¶
Карты станций и наблюдений рендерятся через Mapbox: нужны MAPBOX_TOKEN и
MAPBOX_MAP_ID. Без токена страницы работают, но карты не отображаются.
Локализация¶
Интерфейс на трёх языках: русский, английский, китайский (упрощённый). Весь текст,
видимый пользователю, в шаблонах и моделях оборачивается в gettext_lazy:
from django.utils.translation import gettext_lazy as _
name = _("Наблюдения")
Каталоги переводов лежат в locale/ (LOCALE_PATHS): ru, en, zh_Hans.
Исходные строки (msgid) — русские, поэтому ru заполнен только там, где msgid
написан по-английски (приложение analytics, страницы админки).
Язык выбирается по поддомену: sonik.space — русский, en.sonik.space —
английский, zh.sonik.space — китайский. Это делает
SubdomainLanguageMiddleware (network/middleware.py); она стоит в MIDDLEWARE
последней и перекрывает выбор LocaleMiddleware, так что /i18n/setlang/ на язык
не влияет. Новый язык — строка в SubdomainLanguageMiddleware.LANGUAGES, строка в
settings.LANGUAGES, ссылка в шапке (templates/base.html) и каталог в locale/;
поддомен заводится в DNS и nginx отдельно, вне этого репозитория.
JS-каталог отдаётся по /jsi18n/.
.po и скомпилированные .mo оба лежат в репозитории, CI их не пересобирает.
Каталоги не правятся руками: после добавления или удаления строк в шаблонах и
коде из корня репозитория запускается
make messages
— цель прогоняет makemessages по обоим доменам и следом compilemessages,
остаётся закоммитить .po и .mo. Смысл цели — список --ignore: без него
makemessages обходит node_modules, вендоренный JS в network/static/lib и
сгенерированный API-клиент, и djangojs-каталог распухает на порядок. Правка .po
в обход этого один раз уже развела каталоги в обе стороны сразу: 31 перевод удалённой
разметки остался лежать мёртвым грузом, а две живые строки шаблонов в каталог так и не
попали. Новый перевод после make messages дописывается в msgstr — и цель гоняется
ещё раз, чтобы пересобрать .mo.
Что .mo не отстал от .po, что перевод не потерял плейсхолдеры, что в .mo не
уехала fuzzy-догадка и что китайский каталог покрывает все msgid английского —
стережёт network/base/test_locale_catalogs.py.
make messages запускается в том же коммите, что и правка строк. Функции, пропустившие
его, к сентябрю 2026 года оставили форму конфигурации станции без единого перевода —
114 строк Django и 28 JS показывались по-русски на en. и zh.. Строка, которую
makemessages не видит вовсе, не ловится и прогоном: кириллицу вне {% trans %} в
шаблонах ищет test_no_russian_text_outside_a_translation_tag
(network/base/test_template_markup.py), а gettext() над переменной вместо литерала —
tests/js/autoschedule.test.js.
Комментарии и docstring’и в коде пишутся на английском, пользовательские строки — через перевод.
Политика безопасности контента¶
CSP задаётся настройками CSP_DEFAULT_SRC, CSP_SCRIPT_SRC, CSP_IMG_SRC,
CSP_STYLE_SRC, CSP_WORKER_SRC, CSP_FRAME_SRC, CSP_CHILD_SRC,
CSP_FRAME_ANCESTORS. При добавлении внешнего ресурса (карты, шрифты, аналитика) его
источник нужно внести в соответствующую директиву, иначе браузер заблокирует загрузку.
Каждая переменная заменяет значение по умолчанию целиком, а не дополняет его.
Практическое следствие: вкладка «Телеметрия» на странице спутника встраивает
дашборд из Satellite.dashboard_url, и https://dashboard.sonik.space есть в
умолчании CSP_FRAME_SRC — но окружение, которое эту переменную переопределяет,
обязано перечислить хост само. Иначе вкладка покажет пустую рамку, и в логе
сервера не будет ничего.
В script-src и style-src нет ни 'unsafe-inline', ни 'unsafe-eval', поэтому:
Никакого
style="…"в разметке — ни в шаблоне, ни в строке, которую JS отдаёт вinnerHTML/insertAdjacentHTML. Браузер такой атрибут отбрасывает молча, и nonce его не спасёт: nonce пропускает только элементы<style>. Гейт —test_no_inline_style_attribute(network/base/test_template_markup.py).постоянное значение — класс в SCSS;
начальное скрытие — атрибут
hidden(Bootstrap делает егоdisplay: none !important, поэтому показывать элемент надо снятиемhidden, а неstyle.display);вычисленная ширина или высота в процентах —
data-width/data-heightиapply_sizes(root)изapp.jsпосле вставки разметки. Для шаблонов его один раз вызывает самapp.js, для опций Tom Select — рендер вinit_selects;всё остальное — через
el.style.x = …после вставки: CSSOM политика не ограничивает.
eval,new Function, строковыйsetTimeoutне работают. mapbox-gl 2 без них обходится, его воркеру нужен толькоblob:вworker-src.nonce выдаётся только wavesurfer. Плеер пишет свой
<style>в shadow root;observation_view.htmlиvet_observation_container.htmlкладут nonce в<meta name="csp-nonce">в блокеmeta,audio.jsпередаёт его вcspNonce. В блокjavascriptего ставить нельзя: блок сжимается, аCOMPRESS_OFFLINEне собирает разметку, которая меняется от запроса к запросу. Читать надо свойство.nonce, атрибут браузер очищает.