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

ADR 0074 — Достижимая тема и загрузка под CSP

Статус: принят · 2026-08-14

Контекст​

В design/tokens.css две темы с первого дня. Тёмная — основная и находится в :root; светлая живёт в [data-theme='light'], измеряется тестом паритета (каждый семантический токен цвета должен быть в ней переобъявлен) и упоминается в design/README.md так, словно это экран продукта.

Экраном она никогда не была. Ничто в apps/web нигде не записывало data-theme — ни index.html, ни main.tsx, ни один компонент. Селектор существовал, значения существовали, и единственный способ увидеть светлую тему был вписать атрибут в DevTools. Тест apps/web/test/design-contraste.test.ts знал об этом и говорил прямо: "[data-theme='light'] existe nos tokens e nada o define em nenhum lugar da app" — и шёл дальше, утверждая через expect, что три пары не проходят AA в светлой теме, намеренно, "como registro do que se herda, não como garantia do que se renderiza".

С другой стороны, apps/web/src/lib/contraste.test.ts измерял палитру подсветки синтаксиса только в тёмной теме, и комментарий объяснял почему: --accent, --warning и --success на светлом --code-bg уже оказывались ниже 4,5:1. Пока светлая тема была недостижима, оба файла были правы — измерять экран, который никто не может открыть, значит измерять намерение.

Этот расчёт меняет то, что программа 28 требует кнопку темы. Как только кнопка включена, половина видимой поверхности продукта становится экраном, который никогда не измерялся, а три зафиксированных провала перестают быть задокументированным долгом и становятся отдаваемым дефектом.

Есть ещё деталь поставки, и она не второстепенна. Handoff (design_handoff_brabo/tokens.css) предписывает: "aplique o atributo o mais cedo possível (script inline no <head>)". Образ web в продакшене отдаётся под script-src 'self' (docker/web/nginx.conf), без 'unsafe-inline' и без nonce. Встроенный <script> в head работал бы в pnpm dev:web и был бы заблокирован в опубликованном образе — снова отказ из ADR 0036, только с другим субъектом: там handoff требовал <link> на Google Fonts, CSP его блокировал, и симптом (вся типографика проваливалась в системный шрифт) проявлялся только в продакшене.

Решение​

1. Тема становится достижимой, а загрузка — это ФАЙЛ.

apps/web/public/theme-boot.js читает localStorage['brabo.theme'], принимает только 'dark' или 'light' и записывает data-theme в <html>. По умолчанию — dark. Он входит в index.html как <script src="/theme-boot.js"></script>, синхронно и до бандла, рядом с /config.js, который уже есть там по той же причине.

Файл, а не встроенный скрипт, потому что CSP — это script-src 'self'. Синхронно и до бандла, потому что data-theme определяет цвета всего tokens.css: если применить его после гидратации, пользователь светлой темы видит тёмную вспышку при каждой загрузке. Это чистый ES5 без import — public/ у vite копируется как есть, без сборки, — и тест читает файл и проваливается, если он перестанет быть таким.

apps/web/src/lib/tema.ts — «продуктовая» половина: temaAtual, aplicarTema, alternarTema, observarTema, плюс экспортированные ключ и значение по умолчанию. КНОПКА там не живёт — она принадлежит shell. Оба файла повторяют ключ и значение по умолчанию, потому что загрузочный не может ничего импортировать, и тест в tema.test.ts читает загрузочный файл и проваливается, если они разойдутся: иначе продукт мог бы писать в один ключ и читать из другого.

Ничто на этом пути не бросает исключений. localStorage может бросить (приватный режим, хранилище заблокировано в iframe), а тема — это предпочтение, а не функция: отказ здесь не может уронить загрузку. Неизвестное значение — ключ, записанный вручную, остаток старой версии — сводится к значению по умолчанию, а не превращается в data-theme, которого CSS не знает.

2. Светлая тема начинает ИЗМЕРЯТЬСЯ, и значения исправлены до прохождения.

Пары теперь измеряются в обеих темах, в трёх файлах, которые измеряют контраст. Шесть токенов светлой темы изменили значение. Числа получены razaoDeContraste по разрешённым токенам, а самый требовательный фон светлой темы — --code-bg (бумага, #efe4d2, в шаге от поверхностей): что проходит против него, проходит против всего остального:

токен (светлая тема)допослепара, которая вынуждаладо → после
--accent#c4552d#a5451f (--terracota-500)keyword на --code-bg3,56 → 4,81
--accent-hover#a5451f#7e3316 (--terracota-600)следует за accent, на ступень ниже4,81 → 7,04
--warning#b5701c#8a5410string на --code-bg3,15 → 4,98
--success#217e73#136a60тип на --code-bg3,89 → 5,12
--violet#7b56c9#6b4fb0 (значение из handoff)число на --code-bg4,16 → 4,95
--text-muted#80939a#526670метаданные на --surface-02,76 → 5,17

Два побочных эффекта стоит зафиксировать. Первый: пять пар, которые в тёмной теме являются известным долгом, после этого проходят 4,5:1 в светлой — --text-muted/--surface-1 3,02 → 5,68, --text-muted/--surface-2 2,40 → 4,50, --accent/--surface-1 4,23 → 5,72, --danger/--surface-1 уже 5,59, --success/--surface-2 3,66 → 4,83. У светлой темы больше нет долга, и это утверждается тестом, чтобы он не вернулся незамеченным. Второй: «известное исключение дизайн-системы» — --on-accent на --accent при 3,20:1 в основной кнопке — исчезает в светлой теме, потому что исправление, которое описывал комментарий теста («затемнить accent до --terracota-500, 5,27:1»), это ровно то, что теперь делает светлая тема. В тёмной исключение остаётся: там трогать accent — значит трогать цвет бренда без того, что бы к этому вынуждало.

--text-muted светлой темы заслуживает отдельного упоминания. При 2,40:1 на --surface-2 он был не долгом, а дефектом: не проходил даже порог элемента интерфейса (3:1), самый низкий из существующих. Метаданные и метки — это текст, и светлой теме незачем быть хуже основной — новое значение даёт 5,17 на --surface-0, выше 4,81, которые уже давала тёмная.

3. Восемь ролей подсветки синтаксиса получают собственный токен, а значение из handoff входит только тогда, когда измерение его одобряет.

Палитра подсветки состояла из трёх токенов (--syntax-function, --syntax-comment, --syntax-operator) и пяти повторных использований семантических. Теперь это восемь ролей из handoff, с префиксом --syntax-*, который репозиторий уже использует, и с собственным значением для каждой темы. Назвать все восемь — это то, что позволяет подсветке разойтись с семантическими в тот день, когда это понадобится, — а именно так и делает handoff.

Пять из восьми значений handoff отвергнуты измерением, и это самый важный пункт этого ADR. Против собственного --code-bg из handoff: --syn-cm даёт 4,09:1 в тёмной и 2,32:1 в светлой; --syn-kw 4,34:1 в светлой; --syn-str 4,20:1; --syn-fn 4,14:1; --syn-op 4,00:1. Все ниже 4,5:1, которых требует текст кода. Где handoff не проходит, действует измеренное значение — та же мерка, что в ADR 0036: намерение handoff действует, число, которое не проходит, — нет. Все восемь проходят 4,5:1 против --code-bg в ОБЕИХ темах, а пять семантических, которые по-прежнему реально красят (SyntaxTokens.module.css в этом изменении не тронут), измеряются рядом с ними — пока пиксель принадлежит им, порог спрашивают с них.

4. Недостающие имена входят как АЛИАСЫ, никогда как переименование.

--font-display и --shadow-modal — имена из handoff для --font-heading и --shadow-lg. Они входят, указывая на них. Переименование было бы слепой заменой по синониму в десятках файлов, и семейство --r-* получает то же обращение: --r-xs (5px) и --r-sm (7px) новые, --r-md/--r-lg/ --r-pill — алиасы уже существующих --radius-*, а --radius-* остаются. Внимание к ступени, которая не совпадает: --r-sm — это 7px, а --radius-sm — 4px; это не синонимы, и ни одно место вызова здесь не перенесено.

Вместе с ними входят шкала --fs-* (восемь ступеней) и метрики shell (--sidebar-w, --sidebar-w-collapsed, --header-h, --tabs-h), которые были разбросаны по каждому CSS-модулю, рисующему боковую панель и линейку вкладок.

Последствия​

Вспышка темы становится невозможной, а цена — один лишний запрос. theme-boot.js — это файл, отдаваемый с того же origin, кэшируемый, размером в несколько сотен байт. В HTTP/2 он едет вместе с /config.js, который уже был там. Это цена отсутствия 'unsafe-inline' в CSP, и эту цену ADR 0058 решил платить в целом.

Тёмная тема не изменила ни одного значения. Ни один токен :root не изменён — пять пар известного долга остаются на 3,89 / 3,10 / 3,88 / 3,88 / 4,41, зафиксированные теми же числами, что и раньше. Кто пользуется продуктом сегодня, не видит никакой разницы; кто включит кнопку, видит тему, которая проходит AA.

Светлая тема стала темнее, чем нарисовал handoff. Акценты светлой темы на ступень ниже hex, которые указывает handoff, и тот, кто сравнит экран с .dc.html, это заметит. Расхождение намеренное и имеет ту же форму, что и в случае шрифтов: handoff задаёт намерение (семейство, роль, ступень), измерение задаёт число. Красивый светлый акцент, делающий const нечитаемым на экране кода, — это не верность, а верность прототипу, который ни разу не открыл вкладку Code.

У пяти новых токенов --syntax-* пока нет потребителя. SyntaxTokens.module.css по-прежнему направляет keyword/string/число/тип/текст на семантические. Это выбор: переподключить проводку — значит трогать подсветку во вкладке Code и в Markdown чата, которые относятся к другим направлениям, а тест сегодня покрывает оба набора. В тот день, когда проводка изменится, значения уже будут измерены — и в светлой теме у каждой роли подсветки сегодня ТО ЖЕ число, что у семантического токена, который её красит, намеренно: два источника с разными числами для одного пикселя разошлись бы при первом исправлении, сделанном только с одной стороны.

Три цвета агентов остаются голым hex. #B9A5E8 (Psicólogo, лёгкий), #5EBEB1 (Dev Frontend) и #8AA6AE (SecOps) в apps/web/src/lib/agents.ts не имеют семантического аналога в tokens.css. Дубликат --violet (#9C7BE0, у двух агентов) заменён токеном; три оставшихся остались, объявленные в самом файле, потому что создавать три новых цвета в дизайн-системе мимоходом — это продуктовое решение, а не исправление по пути. Последствие известно: эти три не меняются с темой — они выбраны под тёмный фон и в светлой выглядят бледнее остальных.

Предпочтение хранится по браузеру, а не по учётной записи. Оно лежит в localStorage, поэтому не следует за пользователем на другую машину и не появляется в Настройках. Следование операционной системе (prefers-color-scheme) тоже осталось за рамками: lerTemaSalvo() возвращает null — а не значение по умолчанию — именно для того, чтобы тот, кто решит это позже, знал, что человек никогда не выбирал. Сегодня null сводится к dark.

Одно утверждение теста было инвертировано, и такое изменение заслуживает прочтения. apps/web/test/design-contraste.test.ts утверждал через expect, что три пары светлой темы НЕ ПРОХОДЯТ. Это не был слабый тест: это был долг, записанный на единственном языке, который читает CI. С достижимой темой утверждение стало противоположным — те же пары auth проверяются в обеих темах, с тем же порогом. Кто прочитает историю этого файла, увидит, как toHaveLength(3) превращается в батарею toBeGreaterThanOrEqual, и причина изложена здесь.

Ссылки​

  • 0036 — самостоятельно размещённые шрифты: тот же отказ (handoff требует ресурс, который блокирует CSP), тот же исход (намерение — да, механизм — нет).
  • 0058 — политика CSP, которая делает встроенный скрипт невозможным.
  • RN-182, RN-183, RN-184, RN-185.