ADR 0036 — Экраны аутентификации, верные дизайну, и три шрифта, которые не загружались
Контекст
Четыре экрана аутентификации — /login, /registrar, /esqueci-senha,
/definir-senha — появились в ADR 0032,
вместе с отказом от Keycloak. Они появились функциональными: аутентифицируют, обрабатывают ошибки,
навигируют, и все свойства защиты от перечисления из
ADR 0031 на
месте. Чего никогда не было — так это дизайна.
Причина записана в самом коде, который они заменили: эту поверхность обслуживал Keycloak,
поэтому её никогда не проектировали. design/SCREENS.md, кураторский
набор экранов дизайн-системы, не имеет раздела входа — пробел старше
экранов. Они были собраны из базовых спецификаций design/COMPONENTS.md
и семантических токенов, что было лучшим возможным без макета, и
результат — серая карточка без бренда, с предупреждением о миграции как свободной прозой в
подвале и без подвала страницы.
Два открытия изменили рамки этой сессии.
1. Макет существует, но не в репозитории
Макет входа был создан во внешнем дизайн-проекте после кураторской работы
2026-07-23, породившей design/tokens.css и design/COMPONENTS.md:
- проект
1c960ca8-5e00-4558-8ced-80dfbdf01027, файлBrabo Login.dc.html.
Это авторитетная спецификация этой работы, и она живёт вне системы контроля
версий. Тот, кто будет это ревьюить позже, не сможет перепроверить без доступа к дизайн-
проекту — отсюда обязанность, зафиксированная в решении 9, внести экран в
design/SCREENS.md.
2. Три шрифта дизайн-системы не загружались в production
apps/web/index.html подтягивал Space Grotesk, Archivo и IBM Plex Mono из Google
Fonts через <link>. CSP production-образа в docker/web/nginx.conf:
style-src 'self' 'unsafe-inline'; font-src 'self' data:
Она блокирует таблицу стилей и файлы шрифтов. Ни один .woff2 не был
под версионным контролем. Поэтому в контейнере nginx три семейства падали на системный
шрифт — а поскольку --font-heading и --font-body разделяют fallback
sans-serif, типографское различие между заголовком и текстом просто
исчезало. В index.css есть font-synthesis: none, поэтому браузер не
синтезировал даже вес 700.
Это было невидимо в pnpm dev, где нет ни nginx, ни CSP. А комментарий в начале
design/tokens.css утверждал, что загрузка происходит «via <link> no <head> do
HTML que consome este arquivo» — точно описывая механизм, который
сам production-образ запрещал.
Это не гипотеза и не будущий долг: это был дефект в production, в типографике, которую дизайн-система специфицирует в первую очередь.
Решение
1. Самостоятельно размещать три семейства
Восемь файлов .woff2 в apps/web/public/fonts/, с @font-face в
apps/web/src/index.css и font-display: swap. <link> Google убран.
Удовлетворяет текущей CSP без её ослабления — альтернативой было добавить
fonts.googleapis.com и fonts.gstatic.com в style-src и font-src, что
меняет один дефект на две сторонние зависимости на критическом пути рендеринга.
@font-face находится в index.css, а не в design/tokens.css: загрузка —
ответственность того, кто потребляет токены, и комментарий того файла теперь
говорит это, а не то, что говорил раньше.
Space Grotesk и Archivo — вариативные шрифты и входят с диапазоном веса
(font-weight: 500 700 и 400 600); IBM Plex Mono статичен и входит с
одним @font-face на вес. Различие проверено декодированием каталога
таблиц каждого .woff2 и поиском таблицы fvar — объявление диапазона для
статичного шрифта заставляет браузер синтезировать недостающий вес, а это ровно то,
для предотвращения чего существует font-synthesis: none.
Лицензионное обязательство. Все три под OFL 1.1, которая требует распространять уведомление об
авторском праве вместе с бинарником. apps/web/public/fonts/LICENSE.txt содержит лицензию и
три уведомления, а THIRD_PARTY_NOTICES.md перестал говорить «загружаются через CDN,
не встроены» — потому что теперь они встроены, и обязательство возникло.
2. Проверка в два слоя, потому что одного не хватает
- Тест (
apps/web/test/fontes.test.ts): каждыйurl()из@font-faceвindex.cssразрешается в существующий файл с сигнатуройwOF2; каждый блок имеетfont-display: swapиunicode-range; диапазон веса соответствует вариативному-или-статичному; иindex.htmlне упоминаетfonts.googleapis.com. - Gate в
docker/web/Dockerfile.prod: каталог существует, содержит восемь файлов, а опубликованныйindex.htmlне ссылается на CDN.
Ни один из двух не доказывает отрендеренный шрифт. jsdom не применяет CSS Module и не
разрешает var() из @import, а getComputedStyle возвращает написанное, а не
разрешённое. Доказательство рендеринга — ручная проверка в контейнере, и
оно зафиксировано как таковое.
3. AuthLayout становится всей рамкой
Это была карточка и ничего больше. Макет добавляет две детали, которые одинаково относятся ко всем четырём
экранам, — заголовок бренда над карточкой и подвал страницы под ней, — и
они живут в рамке, а не на экране. Каждый экран заполняет четыре точки: titulo,
subtitulo, rodapeDoCartao и abaixoDoCartao.
Заголовок карточки — единственный <h1>; «Brabo» — это <span>. Повышение бренда до
заголовка дало бы два <h1> и заставило бы список заголовков скринридера
начинаться с бренда на каждом экране, вместо того чтобы говорить, что здесь делается.
4. Четыре компонента дизайн-системы и критерий того, где что живёт
| деталь | где оказалась | почему |
|---|---|---|
Alert | новый компонент в components/ui/ | нужен в четырёх тонах и на четырёх экранах; раньше у каждого экрана был собственный класс .aviso/.banner, скопированный с немного разными отступами |
loading в Button | prop в DS | каждый экран менял метку вручную; для пользователя скринридера кнопка лишь становилась неактивной, не сообщая, что идёт работа |
| заполненное поле | prop preenchido в Input, opt-in | Input используется пятью экранами вне аутентификации; смена значения по умолчанию молча перестилизовала бы все пять |
| показ пароля | prop revelavel в Input | это анатомия поля, а не экрана: кнопка располагается внутри поля и переключает type. Два экрана с паролем наследуют |
| заголовок бренда | локально, в AuthLayout | это рамка экрана, а не библиотечный компонент |
LogoMark | новая иконка | существующий BrandIcon — другой рисунок (изометрический куб); в макете — полоса + два шеврона |
role у Alert — это prop, и он не выводится из тона. role="alert" — это ассертивная
live region: скринридер прерывает то, что говорит. Это правильно для
результата действия, которое пользователь только что запустил, и неправильно для текста, который уже
был на экране при его открытии. Если бы тон решал роль, предупреждение о миграции
(тон warning) попало бы в ту же live region, что и ошибка учётных данных, — и объявление
«e-mail ou senha incorretos» стало бы включать «a senha antiga não foi
migrada», а это ровно тот намёк на учётную запись, ради избежания которого существует единообразный 401 из ADR
0031.
fullWidth у Button был сломан. Правило было flex: 1, которое действует только
если родитель — flex или grid, — а ни один из контейнеров, использующих prop, им не является. Он
передавался в семи местах, все на экранах аутентификации, и ничего не растягивал: «кнопка
на всю ширину» фигурировала как требование дизайна, тогда как ширина кнопки была шириной
текста. Стало width: 100%.
5. Где появляется каждая ошибка
Ошибка учётных данных уходит из поля Senha и становится алертом вверху карточки. Ошибка формы и ошибка поля становятся разными вещами на всех четырёх экранах:
- ошибка поля (короткий пароль, несовпадающее подтверждение): под полем, с
aria-invalid, потому что исправлять нужно там; - ошибка формы (отклонённые учётные данные, неверная ссылка, сбой сети): в верхнем алерте, потому что она не указывает ни на одно поле.
Отклонённые учётные данные не получают aria-invalid ни в одном поле: ни e-mail, ни
пароль по отдельности не искажены, и api не говорит, какое из двух ошибочно.
Пометить оба значило бы утверждать больше, чем известно.
Текст меняется с E-mail ou senha inválidos. на E-mail ou senha incorretos..
Случай 403 (Confirme seu e-mail…) остаётся отдельным, как требуют ADR 0032 и
RN-032: единообразие — между «не существует»,
«неверный пароль» и «учётная запись заблокирована» — а не с «e-mail не подтверждён», который достижим
только после того, как пароль доказан.
6. Реальная версия в подвале и цепочка, которой не существовало
Подвал показывает версию артефакта. При её подключении обнаружилось, что
BRABO_VERSION никогда нигде в репозитории не задавалась: api читала
её для service.version ресурса OpenTelemetry и всегда получала fallback
dev, поэтому каждый span каждого окружения говорил dev, и атрибут был бесполезен
ровно для того, для чего он нужен. А docs/reference/configuration.md утверждал, что
«образ релиза внедряет тег» и что он «появляется в /health» — оба утверждения
ложны.
Теперь цепочка существует, и она одна для обоих сервисов: release.yml вычисляет
versao=${TAG#v} → передаёт VERSION в docker buildx bake → bakefile
преобразует её в BRABO_VERSION (цель api) и VITE_BRABO_VERSION (цель web) →
каждый Dockerfile.prod объявляет её как ARG со значением по умолчанию dev → Vite встраивает →
runtime-config.ts читает → AuthLayout показывает.
На этапе сборки, хотя ADR 0024
выбрал runtime для URL. Причиной там было продвижение ОДНОГО И ТОГО ЖЕ образа между
окружениями, а URL — свойство окружения. Версия — свойство артефакта:
brabo-web:1.1.2 не должен иметь возможности сообщать что-то иное, иначе подвал становится
редактируемым полем, а не идентичностью.
VERSION отделена от TAG в bakefile, потому что ci.yml использует TAG=prod, а
«prod» — это не версия чего-либо.
7. /status перестаёт требовать сессию
Подвал указывает на /status, а он был за guard сессии: клик по
«Status» на экране входа перенаправлял обратно на экран входа — единственное
место назначения, которым он быть не может. Перенесён в новый publicLayout, брат layout
аутентификации. Это безопасно: страница лишь запрашивает /health api и engine, которые уже были
публичными, потому что их вызывает kubelet до появления какого-либо токена.
8. Контраст вычислен из токенов, а не измерен axe
Axe работает на всех четырёх экранах, в пустом состоянии, состоянии ошибки и успеха, — но с
правилом color-contrast, явно выключенным. Ему нужны разметка и разрешённый
цвет, а у jsdom нет ни того, ни другого: его запуск там дал бы «прошло»,
ни на что не посмотрев, что хуже отсутствующего теста, потому что выглядит как покрытие.
Контраст проверяется прямым вычислением над design/tokens.css по
формуле яркости WCAG 2.1 для пар, которые экраны реально используют.
Четыре не проходили 4.5:1. Три исправлены уже существовавшими токенами:
| пара | было | стало | причина |
|---|---|---|---|
.hint у Input | --text-muted (3.89) | --text-secondary (8.00) | действовало и для пяти экранов вне аутентификации |
.link аутентификации | --accent (3.88) | --accent-hover (4.90) | тот же оттенок, на ступень светлее |
| placeholder заполненного поля | --text-muted (3.10) | --text-secondary (6.37) | placeholder — это текст |
Четвёртая не исправлена, и она самая заметная: --on-accent на --accent
даёт 3.20:1 на основной кнопке, которой требуется 4.5 (текст 14px/600). Это
терракотовая пара дизайн-системы, используемая на каждой основной кнопке приложения; исправление
требует затемнить --accent до --terracota-500 (5.27:1), что меняет цвет
бренда во всём UI. Это решение дизайна, а не реализации, и оно
зафиксировано как незакрытый вопрос с числом, зафиксированным в тесте, чтобы оно не могло ухудшиться
незаметно.
9. Экран входит в design/
design/ — источник истины UI, и в нём не было спецификации алерта, загрузки
кнопки или заполненного поля — трёх вещей, которые создаёт эта работа. Три
анатомии входят в design/COMPONENTS.md, а design/SCREENS.md получает раздел
входа, которого никогда не было. Это закрывает пробел, породивший проблему, и это
единственный способ сделать соответствие проверяемым для того, у кого нет макета.
Последствия
Намеренные расхождения с макетом
| # | расхождение | причина |
|---|---|---|
| 1 | без «Continuar com GitHub» | социальный вход — осознанный backlog Фазы 7, уже зафиксированный в ADR 0031 и подтверждённый в 0032. Ничего нового — лишь констатация того, что макет нарисовал то, чего фаза решила не иметь |
| 2 | без «N agentes online» | динамические данные до аутентификации без необходимости расширяют поверхность: потребовался бы публичный маршрут, подсчитывающий агентов. Это кандидат для внутренней панели, а не для экрана входа |
| 3 | поле в --surface-2, а не --code-bg | явный выбор: приподнятое поле вместо утопленного. На карточке --surface-1 фон поля по умолчанию ТОТ ЖЕ, что у карточки |
| 4 | .link в --accent-hover | контраст — см. решение 8 |
| 5 | --shadow из токенов, а не переопределение из макета | макет — отдельный файл, и он перезаписал токен в собственном :root. Источник истины токена — дизайн-система |
| 6 | «Esqueci minha senha» — брат <label>, а не потомок | клик в любом месте <label> активирует связанное поле; внутри метки клик по ссылке тоже фокусировал бы поле пароля |
Производное последствие № 1: разделитель «ou» в макете существует только для разделения двух кнопок. Без второй он тоже уходит — оставлять «ou», указывающий в никуда, было бы хуже асимметрии.
Последствие № 2: в подвале карточки остаётся один элемент, поэтому
justify-content: space-between из макета становится явным выравниванием по левому краю.
Что остаётся незакрытым и почему
- Контраст основной кнопки (3.20:1). Требуется решение о цвете бренда. Это единственный оставшийся на экранах провал AA.
- Та же проблема поля на остальных пяти экранах:
Inputпо умолчанию —--surface-1на карточке--surface-1, разделённые границей в 1px. Вариант opt-in, поэтому там ничего не изменилось — и ничего не исправлено. data-theme="light"по-прежнему не проверяется. Он существует в токенах, и ничто его не задаёт. Экраны используют только семантические токены, поэтому унаследуют светлую тему, если кто-то её включит, но три её пары не проходят AA — это утверждается как запись в тесте контраста, а не как гарантия.- Соответствие проверяется по неверсионируемому файлу. Смягчено решением 9, не устранено.
- Ни один тест не доказывает отрендеренный шрифт. См. решение 2.
Две новые зависимости, обе для разработки
@testing-library/user-event (порядок фокуса) и axe-core (структурная a11y).
Ни одной runtime — бандл не меняется.
axe-core напрямую, а не обёртка vitest-axe, которая была бы очевидным путём:
обёртка добавляет matcher и зависимость, чтобы сэкономить шесть строк, а при
прямом вызове список выключенных правил виден в файле теста,
где тот, кто выключит следующее, должен будет написать почему.
Harness был проверен против реального нарушения — input без <label> и кнопки без
доступного имени — прежде чем доверять зелёному. Тест a11y, проходящий потому, что
не смотрит, — стандартный режим отказа этого инструмента в jsdom.