Перейти к основному содержимому

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 в Buttonprop в DSкаждый экран менял метку вручную; для пользователя скринридера кнопка лишь становилась неактивной, не сообщая, что идёт работа
заполненное полеprop preenchido в Input, opt-inInput используется пятью экранами вне аутентификации; смена значения по умолчанию молча перестилизовала бы все пять
показ пароля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.