diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
new file mode 100644
index 000000000000..8c0498729d43
--- /dev/null
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
@@ -0,0 +1,203 @@
+
+
+
+Fluent-next: открытые вопросы по ролям
+
+
Fluent-next: открытые вопросы по ролям
+
Всё, что аудит нашёл и не стал решать сам. Ответы можно давать номерами: «Д3 — второй вариант».
+Проверено 709 цветовых объявлений в 64 папках; применено девять правок —
+семь равнозначных по значению и две решённые порогом WCAG. Всё на этой странице двигает пиксель,
+меняет публичное имя или требует расширения пакета.
+
+
А. Дизайн — 12 вопросов
+
Роль выбрана спорно
Д1
+
gallery-nav-button-bg = content-subtle
Значение сейчас: #444444 / #cbcbcb
Content-роль использована как подложка под навигационными кнопками поверх картинок. Bg-роли с таким значением в пакете нет, а сам пакет красит этой ролью глиф стрелки, а не диск под ним. Вопрос: нужна ли отдельная роль затемняющей подложки, или диск берёт существующую bg-роль и меняет тон.
Ближайшие роли верного семейства: bg-active — двигает light и dark bg-hovered — двигает light и dark
Д2
+
gallery-nav-button-bg-disabled = content-disabled
Значение сейчас: #ababab / #767676
Тот же вопрос о подложке, неактивное состояние.
Ближайшие роли верного семейства: bg-active — двигает light и dark bg-hovered — двигает light и dark
Д3
+
switch-on-border-focused = bg-primary-active
Значение сейчас: #003c70 / #003c70
Два соседа по трио (покой и наведение) уже переведены на border-роли равнозначно. Третий не переведён: border-primary-shared-active совпадает в светлом и двигает тёмный — #003c70 → #005397. Вопрос: принимаем сдвиг ради однородности трио.
Ближайшие роли верного семейства: border-primary-shared-active — двигает dark border-contrast — двигает light и dark
+
Состояние неотличимо от соседнего
Д5
+
accordion-title-bg — состояния active = hovered дают одну роль bg-hovered
Наведение и нажатие читают одну роль: нажать на заголовок аккордеона выглядит ровно как навести. Fluent 2 здесь однозначен — colorSubtleBackgroundHover #f5f5f5 и colorSubtleBackgroundPressed #e0e0e0 у него разные токены, и пакет несёт оба значения как bg-hovered / bg-active. Не применили только потому, что приведение уводит пиксель от legacy-fluent, а это решение продукта (NFR-1).
Д6
+
tile-view-bg — состояния hovered = rest дают одну роль bg
Покой и наведение — одно значение, а нажатие отличается. Слот явно задуман с лестницей, плоская у него ступень наведения. Доказательство слабее, чем у аккордеона: плитка не обязана вести себя как «subtle surface» из Fluent 2.
+
Контраст ниже порога
Д7
+
Контраст: .dx-splitter .dx-resize-handle
content-subtle на border — светлый 6, тёмный 2.8
Грип ресайза даёт 2.8 в тёмном при пороге 3:1 для нетекстового элемента управления. Проходящая роль есть — content даёт 4.54, — но она же делает грип заметно темнее в светлом (6 → 11.15). Пол задаёт стандарт, выбор роли — нет.
+
нетекстовый контраст · WCAG 1.4.11
+
Порог 3:1 для границ элементов управления и графики
+
Его не реализует ни одно правило axe и не видит ни один скриншот — в CI его не меряет никто.
+Замер 2026-09-09, и он появился как проверка того, стоит ли действовать по строке грипа выше.
+
Что
Пара
Светлый
Тёмный
+
полоса сплиттера на фоне панели
color-border на color-bg
1.62
3.42
полоса сплиттера при наведении
color-border-hovered на color-bg
1.82
6.76
полоса сплиттера в фокусе
color-bg-primary на color-bg
5.38
2.88
грип внутри полосы (исходная строка Д7)
color-content-subtle на color-border
6
2.8
+
+
полоса сплиттера на фоне панели: провал в светлом, порог 3:1. Элемент управления не опознаётся контрастом.
полоса сплиттера при наведении: провал в светлом: наведение не делает полосу различимой.
полоса сплиттера в фокусе: провал в тёмном: индикация фокуса ниже 3:1.
грип внутри полосы (исходная строка Д7): ниже 3:1 в тёмном, но применимость 1.4.11 спорна: это аффорданс внутри уже опознанного элемента, а не то, что его опознаёт.
+
Вопрос Д7 переформулирован: не «какую роль дать точкам грипа», а «различима ли полоса сплиттера вообще». В светлом — нет, 1.62 при пороге 3:1, и это не видит ни axe (нет правила под 1.4.11), ни скриншоты (эталон совпадает сам с собой). Померен только сплиттер: сплошной проверки нетекстового контраста в теме нет.
+
Оговорка: This is a first sample, not a sweep: only the splitter was measured this way.
+
+
почему это не ловит CI
+
Тёмный a11y-прогон существует, и color-contrast в нём включён — но ни одна из
+строк выше его не роняет. Причины проверены по исходнику axe-core 4.12.1, а не предположены:
+
axe reads DOM text nodes, and an icon glyph is not one. visibleTextNodes() in axe-core collects children with nodeType === 3. DevExtreme icon fonts render through ::before { content: "\fXXX" }, which is generated content, not a child node - so an element whose only text is a glyph never enters the color-contrast rule. The splitter grip is exactly that: dx-icon-sizing sets a font-size on .dx-resize-handle-icon and the glyph arrives from the icon font. Касается: every chevron, checkmark, arrow, grip and spinner in the theme
WCAG 1.4.11 has no axe rule at all. Non-text contrast - 3:1 for control boundaries and graphics - is not implemented in axe. Borders, focus rings, indicators and switch tracks are outside the gate by construction, not by suppression. Касается: the whole non-text half of the contrast requirement
the state has to be reached. The delete item in the message-list context menu measures 3.05 in dark, and it is real text - but only while the menu is open and that item focused. A pair that needs a state the a11y suite never enters is invisible even when the rule applies to it. Касается: findings in transient states: focused, opened, invalid
+
Тёмный a11y-прогон идёт и color-contrast в нём включён - но он видит только настоящий текст в достижимых состояниях. Контраст глифов и нетекстовых элементов не проверяет никто, и таблица контраста в этом отчёте - единственное место, где он измерен.
+
+
Имя обещает одно, красит другое
+
Одно понятие покрашено по-разному в разных компонентах
Д8
+
Одно понятие, разные роли: accent border rest
Семейства: content / border
Компонент
Роль
diagram
content-primary
gantt
border-primary
scheduler
border
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д9
+
Одно понятие, разные роли: accent content rest
Семейства: bg / border / content
Компонент
Роль
chat
bg-primary
pivotGrid
border-primary
scheduler
content-primary
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д10
+
Одно понятие, разные роли: base bg rest
Семейства: border / bg
Компонент
Роль
list
border-subtle
stepper
bg
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д11
+
Одно понятие, разные роли: base content rest
Семейства: content / bg
Компонент
Роль
diagram
content-subtle
scheduler
bg-primary-subtle
stepper
content
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д12
+
Одно понятие, разные роли: invalid content rest
Семейства: content / bg
Компонент
Роль
common
content-static-dark
fieldset
content-danger
fileUploader
bg-danger
stepper
content-danger
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д13
+
Одно понятие, разные роли: menu bg rest
Семейства: bg / border
Компонент
Роль
list
bg
menu
bg
menu
border-subtle
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
+
+
Б. Команда пакета токенов — 2 заявки
+
Роли, которая нужна, в пакете нет — обменять не на что.
П1
+
load-indicator-segment-inner-border
Внутренняя рамка лоад-индикатора красится bg-primary-subtle, потому что border-роли с этой насыщенностью в пакете не существует. Менять не на что — нужна роль border-primary-subtle.
Пункт удаления в контекстном меню списка сообщений даёт 3.05 при пороге 4.5 для подписи. Проверены все роли семейства: самая сильная, content-danger-hovered, даёт на той же поверхности 3.87 — тоже провал. Нет danger-content роли, проходящей AA на своей же наведённой поверхности в тёмном.
+
+
В. Унификация, правка бесплатна — 3
+
Компоненты кладут один и тот же цвет и пишут его ролями из разных семейств. Пока каноническая
+запись не выбрана, следующая перепривязка палитры разведёт их молча.
Правка ничего не двигает. Нужно назвать каноническую запись.
+
+
Г. Переименование компонентного тира — 21 имён
+
Роль верна, врёт слово в имени.
+
Окно открыто: компонентный тир fluent-next ещё не отгружен, поэтому переименования и
+удаления сейчас бесплатны — ни ченджлога, ни цикла устаревания. После релиза каждое такое имя
+становится контрактом с приложениями, и та же правка будет стоить депрекации. Это довод сделать
+волну до выпуска, а не после.
+
Не путать с легаси-38: --dx-toolbar-height,
+--dx-font-size-heading-* и ещё пятнадцать имён отгружены с 25.2 и решением 27.08.2026
+заморожены — их это окно не касается.
+
Имя
Слот обещает
Красит
+
color-view-handle-content
content
background, box-shadow
date-view-roller-bottom-shadow
shadow
background-image
date-view-roller-top-shadow
shadow
background-image
filter-builder-group-operation-content
content
background-color
filter-builder-group-operation-content-disabled
content
background-color
filter-builder-group-operation-content-focused
content
background-color
filter-builder-group-operation-content-hovered
content
background-color
filter-builder-item-field-content
content
background-color
filter-builder-item-field-content-disabled
content
background-color
filter-builder-item-field-content-focused
content
background-color
filter-builder-item-field-content-hovered
content
background-color
filter-builder-item-operator-content
content
background-color
filter-builder-item-operator-content-focused
content
background-color
filter-builder-item-operator-content-hovered
content
background-color
filter-builder-item-value-content
content
background-color
filter-builder-item-value-content-focused
content
background-color
filter-builder-item-value-content-hovered
content
background-color
pivot-grid-grand-total-content
content
background-color
pivot-grid-total-content
content
background-color
scheduler-appointment-content-active
content
background-color
scheduler-workspace-cell-content-focused
content
background-color
+
+
Четырнадцать из 21 — filterBuilder: его чипы уходят в базовый
+button-color(), который ставит фон, а названы они -content.
+
+
Д. Возможности пакета, которыми тема не пользуется — 83 ролей
+
Счёт от пакета внутрь, а не от наших объявлений наружу: целое семейство может отсутствовать, и при
+этом ни одно объявление не выглядит неверным. Из 175 ролей, которые
+назначают четыре набора, тема читает 82.
+
Н1
+
Индикатор фокуса — четыре роли пакета не читаются нигде
Тема красит фокус ролью границы border-primary-shared. Fluent 2 рисует фокус двухтонной обводкой — colorStrokeFocus1 #ffffff внутри colorStrokeFocus2 #000000, — чтобы она выживала на любом фоне. Это не оттенок в сторону, а другой механизм. Вариантов inverted и static у темы нет вовсе.
Н2
+
Интент info не используется
Пакет назначает bg-info · content-info · border-info и их состояния. Тема не читает ни одной: компоненты с модификатором info (informer, toast, pagination) красят его нейтральными ролями.
Н3
+
Лестница «на цветной поверхности» (on-color) не используется
Пакет описывает ими элементы, лежащие на залитой акцентом поверхности — например вариант on-surface чекбокса. У темы такого варианта нет.
Н4
+
Статические роли (static-dark / static-light) не используются
Роли, не меняющиеся между режимами. Тема вместо них берёт content-static-dark в отдельных местах, а поверхностные и границы — нет.
+Полный список 83 ролей
+
Роль
Назначают
+
bg-high
core, vnext
bg-info
blazor, core, vnext
bg-info-active
blazor, core, vnext
bg-info-hovered
blazor, core, vnext
bg-info-selected
blazor
bg-info-subtler
blazor, wpf
bg-info-subtler-active
wpf
bg-info-subtler-hovered
wpf
bg-info-subtler-selected
wpf
bg-inverted-active
blazor
bg-inverted-disabled
blazor
bg-inverted-hovered
blazor
bg-inverted-selected
blazor
bg-low-active
core, vnext
bg-on-color
core, wpf
bg-on-color-alpha
core, vnext
bg-on-color-alpha-active
core, vnext
bg-on-color-alpha-disabled
core, vnext
bg-on-color-alpha-hovered
core, vnext
bg-primary-shared-hovered
core, vnext
bg-static-dark
blazor, core, vnext
bg-static-dark-active
blazor
bg-static-dark-disabled
blazor
bg-static-dark-hovered
blazor
bg-static-dark-selected
blazor
bg-static-light-active
blazor
bg-static-light-disabled
blazor
bg-static-light-hovered
blazor
bg-static-light-selected
blazor
bg-success-shared
blazor
bg-success-shared-active
blazor
bg-success-shared-hovered
blazor
bg-warning-active
blazor, core, vnext
bg-warning-hovered
blazor, core, vnext
bg-warning-selected
blazor
bg-warning-subtler
blazor, vnext, wpf
bg-warning-subtler-active
wpf
bg-warning-subtler-hovered
wpf
bg-warning-subtler-selected
wpf
border-info
blazor
border-inverted
blazor
border-inverted-active
blazor
border-inverted-disabled
blazor
border-inverted-hovered
blazor
border-on-color-shared
core, vnext
border-on-color-shared-disabled
core
border-static-dark
blazor, core, vnext, wpf
border-static-dark-active
blazor
border-static-dark-disabled
blazor
border-static-dark-hovered
blazor
border-static-light
blazor
border-static-light-active
blazor
border-static-light-disabled
blazor
border-static-light-hovered
blazor
border-success-shared
blazor
border-success-shared-active
blazor
border-success-shared-hovered
blazor
border-warning
blazor, vnext
content-info
blazor, wpf
content-info-active
blazor, core, vnext
content-info-hovered
blazor, core, vnext
content-inverted-disabled
blazor
content-on-color
blazor, core, vnext
content-on-color-disabled
core, vnext
content-on-color-shared
wpf
content-on-color-shared-disabled
core
content-on-color-subtler
core, vnext
content-on-subtle-primary
wpf
content-primary-shared
core, vnext
content-primary-shared-active
core, vnext
content-primary-shared-hovered
core, vnext
content-static-dark-disabled
blazor
content-static-light-disabled
blazor, wpf
content-success-shared
blazor
content-success-shared-active
blazor
content-success-shared-hovered
blazor
content-warning
blazor, vnext, wpf
content-warning-active
blazor, core, vnext
content-warning-hovered
blazor, core, vnext
focus
core, vnext
focus-inverted
core, vnext
focus-static
core, vnext
focus-static-inverted
core, vnext
+
+
+
Е. Решения не требуется — записано, чтобы не переоткрывали
+
Класс
Сколько
Почему закрыто
+
confirmed
5
пакет назначает ровно эту роль — расходится только слово слота
rule-5
12
одно значение в двух свойствах, названо по доминирующей роли; принято ревью-раундом 2
hairline
5
волосяная линия фоном сохраняет border-роль; подтверждено core (у tabs это слот selector)
no-rung
22
состояния схлопнуты там же, где их схлопывает сама система
shade
15
то же семейство, другой оттенок — расхождение, которое компоненты вправе иметь
graphic-ok
2
глиф, порог 3:1 взят
bridge / known
2
уже инвентаризовано в BRIDGES.md и DIVERGENCES.md
stale в наборах соседей
10
blazor и wpf ссылаются на имена, которых семантический слой не объявляет — их дрейф, не наш
+
+
+
Ж. Чего эта проверка не видела, и как это сократить
+
+
226 цветовых объявлений из 709 — 31.9% темы — сравнивать не с чем.
+Это компоненты, которых нет ни у core, ни у vnext, ни у blazor, ни у wpf в пакете токенов. По ним
+работали только проверки темы против себя самой: семейство, слот против свойства, лестницы состояний,
+контраст и согласованность понятий между компонентами.
+
Папка
Объявлений
+
scheduler
43
filterBuilder
28
stepper
25
diagram
20
fileManager
20
pivotGrid
19
gantt
14
htmlEditor
13
slider
13
fileUploader
10
tileView
7
dateView
5
sortable
3
splitterBar
3
dropDownEditor
2
widget
1
+
+
+
Чем это сокращается — измерено 2026-09-09
+
Числа сняты по репозиториям за пределами этого, поэтому инструмент их не пересчитывает: они
+забанкованы вместе с источником и протухнут заметно, если прочитать их рядом со свежим счётчиком выше.
+
Р1
+
Карта «наша папка → компонент пакета» — сделано
Карта сопоставляла по похожести имён. cardView — это пакетный grid (так его и мапит package-disabled.mjs), speedDialAction — button, lookup — listbox, actionSheet — popup, validation — field. Шесть строк, 277 → 226, и ни одного нового расхождения: все 51 попали в agrees.
Р2
+
Реализация Blazor как пятый источник — покрывает 110
В пакете у Blazor 20 компонентов, в реализации (dxvcs/.../ds-themes/components) — 57, и роли он читает через тот же мост ds.$, что и мы: карта имён ролей не нужна. Имена папок опять другие: rollers — это dateView, edit-dropdown — dropDownEditor. Покрывает scheduler 43 (у них 38 ролей), filterBuilder 28 (59), pivotGrid 19, fileUploader 10, dateView 5, splitterBar 3, dropDownEditor 2.
Что нужно: парсер ds.$ по папкам + карта из семи строк
Р3
+
Fluent UI React — покрывает 13
У Fluent есть Slider, и его стили называют девять цветовых токенов по частям. Но ни имена (colorCompoundBrandBackground против color-bg-primary-shared), ни значения (бренд #0078d4 против #0f6cbd) не совпадают — нужна разовая карта.
Что нужно: карта имён Fluent 2 → dxds, ~40-60 пар
Р4
+
WPF — только на уровне семейства — покрывает 47
Компоненты у WPF есть (Gantt 12 xaml, Diagram 14, RichEdit 8), но их темы НЕ затокенизированы: семантические ключи вида AccordionHeaderBackgroundRest получают только компоненты из components/wpf, а Gantt и Diagram красятся легаси-палитрой Lightweight, где Color.Foreground.Primary — это литерал #FF1A1A1A. Зато Lightweight/Common/PaletteSemantics.md описывает смысл 142 ключей словами («Color.Border — primary border brush for controls»). Это сигнал уровня семейства (border / bg / content), а не роли, и он покрывает ровно то, чего нет больше нигде: gantt 14, diagram 20, htmlEditor 13.
Что нужно: разбор PaletteSemantics.md, 142 ключа
+
+
Куда это приводит
+
Рычаг
Покрывает
Останется
+
—
сейчас
226 (31.9%)
+
Р2
Реализация Blazor как пятый источник
−110
116 (16.4%)
Р3
Fluent UI React
−13
103 (14.5%)
Р4
WPF — только на уровне семейства
−47
56 (7.9%)
+
Ниже этого не опускается: 56 объявлений в папках
+stepper, fileManager, tileView, sortable, widget. Виджеты, которых нет ни в одной дизайн-системе. Единственный путь — дизайн-ревью по скриншотам.
+
Померено и отброшено. Реализация vnext: 18 компонентов, все до одного уже описаны пакетом. Не добавляет ничего.
+
+
Оговорка о самой проверке
+
Правки инструмента шли в одну сторону — к меньшему числу находок: конфликтов семейств 11 → 3,
+cross-family 24 → 15, кнопочных лестниц 8 → 0. Каждое сокращение проверено вручную и описано в
+коммите, но направление у них одно: скорее недосчитал, чем перебрал.
+
+
+
З. Согласны ли соседи между собой
+
+
Весь аудит сравнивает наши роли с чужими, поэтому он стоит ровно столько, сколько стоит
+согласованность самих соседей. Замер 2026-09-09 по четырём наборам пакета: сравнивались
+только слоты, у которых полностью совпадает путь анатомии — компонент плюс всё после
+color..
+
Там, где они говорят об одном и том же одними словами, они согласны.
+583 сравнимых слотов, роль совпадает у 541
+(93%).
+
Пара
Общих слотов
Совпадает
+
core ↔ vnext
579
100%
blazor ↔ core
115
77%
blazor ↔ vnext
116
76%
core ↔ wpf
33
70%
blazor ↔ wpf
3
0%
+
+
core и vnext — практически один набор (vnext = core плюс field), поэтому их
+100% ничего не доказывают. Значимы пары с blazor и wpf. У пары blazor ↔ wpf всего три общих слота —
+это не выборка.
+
+
Но одними словами они почти ничего не описывают. Пересечение анатомии, а не ролей, —
+вот что расходится:
+
Компонент
Наборы
Путей всего
Общих для всех
+
button
core+vnext+blazor+wpf
401
0
checkbox
core+vnext+blazor
126
0
toast
core+vnext+blazor
92
0
grid
core+vnext+wpf
66
8
switch
core+vnext+blazor+wpf
57
0
ribbon
core+vnext+wpf
46
8
radio-button
core+vnext+blazor
36
0
text-input
core+vnext+blazor+wpf
35
0
+
+
У button четыреста один путь анатомии на четыре продукта и ноль общих для всех
+четырёх. Каждый моделирует свои варианты, суб-элементы и состояния.
+
+
Расхождений всего 42, и 9 из них — не расхождения.
+Blazor запинен на 262.9.1 и всё ещё пишет bg-none / border-none /
+content-none там, где в действующем слое одна роль none. Настоящих
+остаётся 33:
+
button, вариант outline. blazor кладёт фон на bg-none / bg-hovered / bg-active, core и vnext — на альфа-лестницу bg-alpha*. На button приходится 19 расхождений из 33.
ribbon. у wpf он акцентный: content-inverted вместо content, bg-primary-hovered вместо bg-hovered. У веба нейтральный. Похоже на осознанное десктопное решение, а не на разнобой.
grid. wpf сидит на bg-low и border, веб — на bg и border-subtle.
tabs. иконка при наведении: blazor content-primary-hovered, wpf content.
+
+
Что из этого следует для самой проверки
+
Там, где соседи описывают одно и то же одними словами, они согласны на 93%. Но одними словами они описывают почти ничего: у button 401 путь анатомии на четыре продукта и ноль общих для всех четырёх. Расходится не назначение ролей, а анатомия — каждый продукт моделирует свои варианты, суб-элементы и состояния.
+
Отсюда и устройство сравнения: оно на уровне слота, а не пути. Сравнение по полному пути
+нашло бы почти ничего — наша анатомия не совпадает с чужой ровно так же, как их анатомии не
+совпадают между собой. Цена этого выбора честная: инструмент отвечает на вопрос «использует ли пакет
+эту роль для слота такого рода в этом компоненте», а не «использует ли он её именно здесь». Поэтому
+cross-family сформулирован как вопрос, а не как вердикт.
Материал к design#1555.
+37 мест вне сетки и ещё 31, где роль есть, а тема читает ступень.
+Типографика не зависит от режима — у каждого места одно значение, светлая и тёмная темы одинаковы.
+
+
Главное: карточка сформулирована в обратную сторону
+
design#1555 спрашивает, «каким ступеням нужны семантические роли». Сверка с Fluent 2 показывает,
+что вопрос не к пакету:
+
+
веб-рампа Fluent 2 знает три начертания — Regular, Semibold, Bold. Medium (500) в ней нет;
+
её размеры — 10 / 12 / 14 / 16 / 20 / 24 / 28 / 32 / 40 / 68 px, и сетка ролей пакета
+повторяет их точно, кроме Display 68.
+
+
Ни font-weight: 500, ни размеры 11 / 18 / 22 / 26 / 30 / 36 px в Fluent 2 не существуют.
+Пакет верен источнику. Все эти места — значения, унаследованные от legacy-темы fluent, которые
+миграция сохранила по требованию «визуально это тот же fluent» (NFR-1).
+
Решать нужно не «расширять ли пакет», а что важнее в каждом месте: совпадение с legacy или
+соответствие Fluent 2.
+
+
Решения
+
Т1
+
font-weight: 500 — 17 мест
Ближайшие роли: 400 (Regular) и 600 (Semibold). Промежуточного веса у Fluent 2 нет, и у Segoe UI грани 500 тоже нет — на Windows эти места уже сегодня рендерятся как Regular 400, просто непредсказуемо по платформам.
Почти всё это заголовки групп, метки и подписи, то есть элементы с усилением. Колонка «предложение» — наша рекомендация, не решение.
Рампа заголовков — 4 места, следствие на все приложения
Самое дорогое решение: --dx-font-size-heading-1…6 — публичные переменные, их читают приложения.
h1
h2
h3
h4
h5
h6
сейчас, default
40 ✓
36
32 ✓
26
22
22
сейчас, compact
32 ✓
28 ✓
24 ✓
20 ✓
16 ✓
16 ✓
Fluent 2 / сетка ролей
40
32
28
24
20
16
✓ — значение уже лежит на роли.
Compact-ветка целиком на сетке, а default — нет. У default совпадают только h1 и h3. Если привести default к той же логике, что уже действует в compact, получится ровно ряд Fluent 2: 40 / 32 / 28 / 24 / 20 / 16. Цена: h2 36→32, h4 26→24, h5 22→20, h6 22→16 — последнее самое заметное.
Отдельно: $typography-s-font-size (18px) формально попадает в список, но это утилита.dx-font-sm, а не текстовая роль — в коде так и написано, что ряд xl/l/m/s/xs намеренно сидит на базовой шкале. Трогать не предлагаем.
Т3
+
Одиночные размеры компонентов — 7 мест
Где
Переменная
Сейчас
Вниз
Вверх
chat/_sizes.scss:102
chat-message-list-empty-message-font-size
18px
16px base-lg
20px title-md
fileManager/_sizes.scss:67
file-manager-progress-title-font-size
18px
16px base-lg
20px title-md
scheduler/_sizes.scss:229
scheduler-appointment-icon-font-size
18px
16px base-lg
20px title-md
scheduler/_sizes.scss:288
scheduler-group-header-agenda-font-size
18px
16px base-lg
20px title-md
scheduler/_sizes.scss:146
scheduler-appointment-tooltip-date-font-size
11px
10px caption-sm
12px caption-md
scheduler/_sizes.scss:266
scheduler-appointment-content-details-font-size
11px
10px caption-sm
12px caption-md
scheduler/_sizes.scss:308
scheduler-time-indicator-font-size
30px
28px headline-md
32px headline-lg
Большая часть — scheduler. Если решение по нему будет одно («округляем вниз» или «вверх»), оно закроет список почти целиком.
Т4
+
line-height вне сетки — 8 мест
Оговорка: шкала межстрочного у пакета не полностью повторяет Fluent 2 — у Fluent 2 есть 22 и 26, у пакета вместо них 24 и 28. Наши значения 12 и 18 не встречаются ни там, ни там.
Где
Переменная
Сейчас
Ближайшая роль
diagram/_sizes.scss:94
diagram-popup-close-icon-line-height
12px
14px caption-sm · 14px base-xs
pagination/_sizes.scss:9
pagination-page-line-height
12px
14px caption-sm · 14px base-xs
pivotGrid/_sizes.scss:17
pivot-grid-indicators-line-height
12px
14px caption-sm · 14px base-xs
scheduler/_sizes.scss:44
scheduler-appointment-10min-title-line-height
12px
14px caption-sm · 14px base-xs
scheduler/_sizes.scss:175
scheduler-appointment-15min-title-line-height
12px
14px caption-sm · 14px base-xs
validation/_sizes.scss:25
validation-message-line-height
12px
14px caption-sm · 14px base-xs
gridBase/_sizes.scss:32
grid-header-line-height
18px
16px caption-md · 16px base-sm
scheduler/_sizes.scss:127
scheduler-appointment-15min-title-line-height
18px
16px caption-md · 16px base-sm
У большинства межстрочное меньше самой низкой роли. Три из них — аппойнтменты scheduler на 10 и 15 минут, где высота строки прижата к высоте ячейки: там 12→14 может не поместиться, это надо смотреть на макете, а не решать по таблице.
Т5
+
Роль существует, а тема читает ступень — 31 мест
Здесь значение не меняется: роль, называющая эту ступень, резолвится в неё же. Перевод равнозначен по построению. Решить нужно только, какая роль — на одной ступени их бывает несколько (caption / base / title), и это выбор смысла, а не значения.
11 из них не несут даже маркера: гейт px-audit смотрит только на литералы, а чтение ступени — не литерал, поэтому они не доезжали ни до SCALES.md, ни до дизайна.
Где
Переменная
Читает
Роли с этой ступенью
Маркер
chat/_sizes.scss:112
chat-file-icon-font-size
20px
title-md
icon-glyph-size
chat/_sizes.scss:138
chat-message-list-empty-message-font-size
14px
caption-lg · base-md · title-xs
no-semantic-role
chat/_sizes.scss:148
chat-file-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
checkBox/_sizes.scss:5
check-box-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
diagram/_sizes.scss:93
diagram-popup-close-icon-font-size
12px
caption-md · base-sm
icon-glyph-size
fileUploader/_sizes.scss:22
file-uploader-file-icon-font-size
20px
title-md
icon-glyph-size
fileUploader/_sizes.scss:31
file-uploader-file-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
gallery/_sizes.scss:8
gallery-nav-icon-font-size
32px
headline-lg
icon-glyph-size
gridBase/_sizes.scss:166
grid-column-chooser-title-font-size
16px
base-lg · title-sm
нет
scheduler/_sizes.scss:45
scheduler-appointment-10min-icon-font-size
12px
caption-md · base-sm
icon-glyph-size
scheduler/_sizes.scss:50
scheduler-appointment-15min-icon-font-size
12px
caption-md · base-sm
icon-glyph-size
scheduler/_sizes.scss:98
scheduler-appointment-tooltip-date-font-size
14px
caption-lg · base-md · title-xs
no-semantic-role
scheduler/_sizes.scss:302
scheduler-appointment-month-text-font-size
12px
caption-md · base-sm
нет
scheduler/_sizes.scss:310
scheduler-small-font-size
14px
caption-lg · base-md · title-xs
нет
scheduler/_sizes.scss:312
scheduler-month-date-text-font-size
16px
base-lg · title-sm
нет
scrollView/_sizes.scss:4
scroll-view-load-indicator-font-size
24px
title-lg · headline-sm
icon-glyph-size
scrollView/_sizes.scss:12
scroll-view-pull-down-icon-font-size
24px
title-lg · headline-sm
icon-glyph-size
tabs/variables/_sizes.scss:39
tabs-tab-nav-button-icon-font-size
20px
title-md
icon-glyph-size
tabs/variables/_sizes.scss:48
tabs-tab-nav-button-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
typography/_sizes.scss:8
typography-xs-font-size
12px
caption-md · base-sm
нет
typography/_sizes.scss:23
typography-l-font-size
28px
headline-md
нет
typography/_sizes.scss:24
typography-m-font-size
20px
title-md
нет
typography/_sizes.scss:36
typography-xl-font-size
24px
title-lg · headline-sm
нет
typography/_sizes.scss:37
typography-l-font-size
20px
title-md
нет
typography/_sizes.scss:38
typography-m-font-size
16px
base-lg · title-sm
нет
typography/_sizes.scss:39
typography-s-font-size
14px
caption-lg · base-md · title-xs
нет
typography/_sizes.scss:42
typography-heading-2-font-size
28px
headline-md
no-semantic-role
typography/_sizes.scss:44
typography-heading-4-font-size
20px
title-md
no-semantic-role
typography/_sizes.scss:45
typography-heading-5-font-size
16px
base-lg · title-sm
no-semantic-role
typography/_sizes.scss:46
typography-heading-6-font-size
16px
base-lg · title-sm
no-semantic-role
validation/_sizes.scss:18
validation-message-line-height
16px
caption-md · base-sm
no-semantic-role
+
+
Если решения приняты
+
Правки механические, значения меняются ровно в перечисленных строках, режимы не расходятся.
+После них потребуется пересъёмка эталонов скриншотов затронутых компонентов — отдельный проход,
+а не «заодно».
+
+
+
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss
index 54e3c280c115..ca098d5634e1 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss
@@ -26,7 +26,7 @@ $palette-border: ds.$color-border !default;
$global-font-family: ds.$font-family-sans-serif !default;
$invalid-badge-bg: ds.$color-content-danger-shared !default;
$invalid-badge-content: ds.$color-content-static-dark !default;
-$valid-badge-content: ds.$color-bg-success !default;
+$valid-badge-content: ds.$color-content-success !default;
$palette-text: ds.$color-content !default;
$global-bg: ds.$color-bg !default;
$global-content: ds.$color-content !default;
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/fieldset/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/fieldset/_colors.scss
index 8728b1158dfb..b26e80e24bec 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/fieldset/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/fieldset/_colors.scss
@@ -1,6 +1,6 @@
@use "../../../_design-system/variables/ds" as ds;
$fieldset-field-label-content: ds.$color-content !default;
-$fieldset-field-value-invalid-content: ds.$color-border-danger !default;
+$fieldset-field-value-invalid-content: ds.$color-content-danger !default;
$fieldset-field-label-content-disabled: ds.$color-content-disabled !default;
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/list/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/list/_colors.scss
index e2c3fd21ef63..54af0582e56f 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/list/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/list/_colors.scss
@@ -33,7 +33,6 @@ $list-base-select-all-separator-bg: ds.$color-border-subtle !default;
$list-group-content: ds.$color-content !default;
-$list-border-disabled: ds.$color-border-disabled !default;
$list-content-disabled: ds.$color-content-disabled !default;
$list-inverted-content: ds.$color-content-static-dark !default;
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/list/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/list/_index.scss
index 388bc7593560..66059a0d0d2e 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/list/_index.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/list/_index.scss
@@ -22,10 +22,6 @@
.dx-state-disabled {
.dx-list-select-all-label {
color: var(--dx-list-content-disabled);
-
- &::after {
- color: var(--dx-list-border-disabled);
- }
}
.dx-list-item-chevron {
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/list/_public.scss b/packages/devextreme-scss/scss/widgets/fluent-next/list/_public.scss
index be70fe838c72..6d424f715929 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/list/_public.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/list/_public.scss
@@ -6,7 +6,6 @@
@mixin publish {
--dx-list-base-select-all-separator-bg: #{$list-base-select-all-separator-bg};
--dx-list-border: #{$list-border};
- --dx-list-border-disabled: #{$list-border-disabled};
--dx-list-border-width: #{$list-border-width};
--dx-list-bottom-padding: #{$list-bottom-padding};
--dx-list-content-disabled: #{$list-content-disabled};
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/switch/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/switch/_colors.scss
index 8d75389421a0..fac3b256a57f 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/switch/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/switch/_colors.scss
@@ -1,7 +1,7 @@
@use "../../../_design-system/variables/ds" as ds;
-$switch-on-border: ds.$color-bg-primary-shared !default;
-$switch-on-border-hovered: ds.$color-bg-primary-shared-hovered !default;
+$switch-on-border: ds.$color-border-primary-shared !default;
+$switch-on-border-hovered: ds.$color-border-primary-shared-hovered !default;
$switch-on-border-focused: ds.$color-bg-primary-active !default;
$switch-bg: ds.$color-none !default;
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/_public.scss b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/_public.scss
index 3ec738519f5b..c566178c08ba 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/_public.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/_public.scss
@@ -19,18 +19,12 @@
--dx-tabs-nav-button-bg: #{$tabs-nav-button-bg};
--dx-tabs-nav-button-content-disabled: #{$tabs-nav-button-content-disabled};
--dx-tabs-tab-accent-font-weight: #{$tabs-tab-accent-font-weight};
- --dx-tabs-tab-basic-border-selected: #{$tabs-tab-basic-border-selected};
--dx-tabs-tab-basic-font-weight: #{$tabs-tab-basic-font-weight};
+ --dx-tabs-tab-basic-selector-selected: #{$tabs-tab-basic-selector-selected};
--dx-tabs-tab-bg: #{$tabs-tab-bg};
--dx-tabs-tab-bg-active: #{$tabs-tab-bg-active};
--dx-tabs-tab-bg-focused: #{$tabs-tab-bg-focused};
--dx-tabs-tab-bg-hovered: #{$tabs-tab-bg-hovered};
- --dx-tabs-tab-border-active: #{$tabs-tab-border-active};
- --dx-tabs-tab-border-disabled: #{$tabs-tab-border-disabled};
- --dx-tabs-tab-border-hovered: #{$tabs-tab-border-hovered};
- --dx-tabs-tab-border-selected-active: #{$tabs-tab-border-selected-active};
- --dx-tabs-tab-border-selected-focused: #{$tabs-tab-border-selected-focused};
- --dx-tabs-tab-border-selected-hovered: #{$tabs-tab-border-selected-hovered};
--dx-tabs-tab-content: #{$tabs-tab-content};
--dx-tabs-tab-content-disabled: #{$tabs-tab-content-disabled};
--dx-tabs-tab-content-selected: #{$tabs-tab-content-selected};
@@ -44,6 +38,12 @@
--dx-tabs-tab-primary-bg-active: #{$tabs-tab-primary-bg-active};
--dx-tabs-tab-primary-bg-focused: #{$tabs-tab-primary-bg-focused};
--dx-tabs-tab-primary-bg-hovered: #{$tabs-tab-primary-bg-hovered};
+ --dx-tabs-tab-selector-active: #{$tabs-tab-selector-active};
+ --dx-tabs-tab-selector-disabled: #{$tabs-tab-selector-disabled};
+ --dx-tabs-tab-selector-hovered: #{$tabs-tab-selector-hovered};
+ --dx-tabs-tab-selector-selected-active: #{$tabs-tab-selector-selected-active};
+ --dx-tabs-tab-selector-selected-focused: #{$tabs-tab-selector-selected-focused};
+ --dx-tabs-tab-selector-selected-hovered: #{$tabs-tab-selector-selected-hovered};
--dx-tabs-tab-vertical-nav-button-content-padding: #{$tabs-tab-vertical-nav-button-content-padding};
--dx-tabs-text-column-gap: #{$tabs-text-column-gap};
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_primary.scss b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_primary.scss
index 39a1c802a5b8..dc4701225993 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_primary.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_primary.scss
@@ -37,36 +37,36 @@
&.dx-tab-indicator-position-bottom {
.dx-tab {
&.dx-state-hover {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-hovered));
}
&.dx-state-active {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-active));
}
}
.dx-tab-selected {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-border-selected));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-selector-selected));
&.dx-state-focused {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-focused));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-focused));
}
&.dx-state-hover {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-hovered));
}
&.dx-state-active {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-active));
}
&.dx-state-disabled {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
&.dx-state-disabled .dx-tab-selected {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
@@ -86,36 +86,36 @@
&.dx-tab-indicator-position-left {
.dx-tab {
&.dx-state-hover {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-hovered));
}
&.dx-state-active {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-active));
}
}
.dx-tab-selected {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-border-selected));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-selector-selected));
&.dx-state-focused {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-focused));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-focused));
}
&.dx-state-hover {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-hovered));
}
&.dx-state-active {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-active));
}
&.dx-state-disabled {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
&.dx-state-disabled .dx-tab-selected {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
}
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_secondary.scss b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_secondary.scss
index 3c1eced6ad83..972deecbdd44 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_secondary.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/layout/tab/styling-mode/_secondary.scss
@@ -29,38 +29,38 @@
&.dx-tab-indicator-position-bottom {
.dx-tab {
&.dx-state-hover .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-hovered));
}
&.dx-state-active .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-active));
}
&.dx-tab-selected {
.dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-border-selected));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-selector-selected));
}
&.dx-state-focused .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-focused));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-focused));
}
&.dx-state-hover .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-hovered));
}
&.dx-state-active .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-active));
}
&.dx-state-disabled .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
}
&.dx-state-disabled .dx-tab.dx-tab-selected .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
@@ -82,36 +82,36 @@
&.dx-tab-indicator-position-left {
.dx-tab {
&.dx-state-hover {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-hovered));
}
&.dx-state-active {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-active));
}
}
.dx-tab-selected {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-border-selected));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-basic-selector-selected));
&.dx-state-focused {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-focused));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-focused));
}
&.dx-state-hover {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-hovered));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-hovered));
}
&.dx-state-active {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-selected-active));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-selected-active));
}
&.dx-state-disabled {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
&.dx-state-disabled .dx-tab-selected .dx-tab-content {
- @include dx-tabs-indicator-background(var(--dx-tabs-tab-border-disabled));
+ @include dx-tabs-indicator-background(var(--dx-tabs-tab-selector-disabled));
}
}
}
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/variables/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/variables/_colors.scss
index 5bd021f394e4..a90ba4f0c0ba 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/tabs/variables/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/variables/_colors.scss
@@ -6,14 +6,14 @@ $tabs-tab-content-selected: ds.$color-content !default;
$tabs-tab-content-disabled: ds.$color-content-disabled !default;
-$tabs-tab-basic-border-selected: ds.$color-content-primary !default;
-$tabs-tab-border-selected-active: ds.$color-content-primary-active !default;
-$tabs-tab-border-selected-hovered: ds.$color-content-primary-hovered !default;
-$tabs-tab-border-selected-focused: ds.$color-content-primary !default;
-$tabs-tab-border-active: ds.$color-border-active !default;
-$tabs-tab-border-hovered: ds.$color-border-hovered !default;
-
-$tabs-tab-border-disabled: ds.$color-content-disabled !default;
+$tabs-tab-basic-selector-selected: ds.$color-border-primary-shared !default;
+$tabs-tab-selector-selected-active: ds.$color-border-primary-shared-active !default;
+$tabs-tab-selector-selected-hovered: ds.$color-border-primary-shared-hovered !default;
+$tabs-tab-selector-selected-focused: ds.$color-border-primary-shared !default;
+$tabs-tab-selector-active: ds.$color-border-active !default;
+$tabs-tab-selector-hovered: ds.$color-border-hovered !default;
+
+$tabs-tab-selector-disabled: ds.$color-content-disabled !default;
$tabs-tab-bg: transparent !default;
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss
index d8410e8df25f..b6770c8a6e5a 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss
@@ -38,4 +38,4 @@ $text-editor-label-content-focused: ds.$color-content-primary !default;
}
// Theme-layer values used by this widget's style files (phase 0.5 layer normalization)
-$text-editor-invalid-content: ds.$color-border-danger-shared !default;
+$text-editor-invalid-content: ds.$color-content-danger !default;
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_colors.scss
index 0bce3ea1ee0c..a3c6c8190bf0 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_colors.scss
@@ -15,10 +15,6 @@ $tree-view-spin-icon: ds.$color-content-subtle !default;
$tree-view-spin-icon-disabled: ds.$color-content-disabled !default;
$tree-view-checkbox-checked-border-focused: ds.$color-border-primary-shared-active !default;
-$tree-view-checkbox-border-disabled: ds.$color-content-disabled !default;
-$tree-view-checkbox-icon-disabled: ds.$color-content-disabled !default;
-$tree-view-checkbox-indeterminate-bg-disabled: ds.$color-content-disabled !default;
-$tree-view-checkbox-bg-disabled: ds.$color-bg-disabled !default;
$tree-view-item-content-disabled: ds.$color-content-disabled !default;
/* Was fed by a *-disabled token holding a neutral grey; 262.15.0 dropped it. The neutral border
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_index.scss
index 79961a4d3845..3b83e84a9ee6 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_index.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_index.scss
@@ -43,20 +43,6 @@
}
.dx-treeview-item-with-checkbox {
- .dx-treeview-item {
- .dx-checkbox.dx-state-disabled {
- .dx-checkbox-icon {
- border-color: var(--dx-tree-view-item-content-disabled);
- }
-
- &.dx-checkbox-indeterminate {
- .dx-checkbox-icon::before {
- background-color: var(--dx-tree-view-item-content-disabled);
- }
- }
- }
- }
-
&.dx-state-focused > .dx-treeview-item {
.dx-checkbox {
.dx-checkbox-icon {
@@ -81,25 +67,6 @@
}
}
}
-
- .dx-checkbox.dx-state-disabled {
- .dx-checkbox-icon {
- background-color: var(--dx-tree-view-checkbox-bg-disabled);
- border-color: var(--dx-tree-view-checkbox-border-disabled);
- }
-
- &.dx-checkbox-checked {
- .dx-checkbox-icon {
- color: var(--dx-tree-view-checkbox-icon-disabled);
- }
- }
-
- &.dx-checkbox-indeterminate {
- .dx-checkbox-icon::before {
- background-color: var(--dx-tree-view-checkbox-indeterminate-bg-disabled);
- }
- }
- }
}
}
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_public.scss b/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_public.scss
index 50b1e4366cd1..f4f1c31f3e80 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_public.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/treeView/_public.scss
@@ -10,14 +10,10 @@
--dx-tree-view-border-padding: #{$tree-view-border-padding};
--dx-tree-view-border-visible-border: #{$tree-view-border-visible-border};
--dx-tree-view-checkbox-accent-content-focused: #{$tree-view-checkbox-accent-content-focused};
- --dx-tree-view-checkbox-bg-disabled: #{$tree-view-checkbox-bg-disabled};
--dx-tree-view-checkbox-bg-focused: #{$tree-view-checkbox-bg-focused};
- --dx-tree-view-checkbox-border-disabled: #{$tree-view-checkbox-border-disabled};
--dx-tree-view-checkbox-border-focused: #{$tree-view-checkbox-border-focused};
--dx-tree-view-checkbox-checked-border-focused: #{$tree-view-checkbox-checked-border-focused};
--dx-tree-view-checkbox-container-size: #{$tree-view-checkbox-container-size};
- --dx-tree-view-checkbox-icon-disabled: #{$tree-view-checkbox-icon-disabled};
- --dx-tree-view-checkbox-indeterminate-bg-disabled: #{$tree-view-checkbox-indeterminate-bg-disabled};
--dx-tree-view-checkbox-offset: #{$tree-view-checkbox-offset};
--dx-tree-view-container-with-border-offset: #{$tree-view-container-with-border-offset};
--dx-tree-view-content: #{$tree-view-content};
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
new file mode 100644
index 000000000000..03d091a6c6b2
--- /dev/null
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -0,0 +1,2024 @@
+{
+ "comment": [
+ "Every role the package disagrees with, and what was decided about it.",
+ "",
+ "Exact equality, not a ratchet down: a new disagreement and a resolved one both fail, because both",
+ "are a decision somebody has to record. Re-bank deliberately with UPDATE_ROLES_BASELINE=1 pnpm test,",
+ "and fill in the decision - a banked entry without one fails too.",
+ "",
+ "verdict cross-family - the package uses this role, but only for a slot of another kind",
+ " family-conflict - the package paints this slot from another family entirely",
+ "decision confirmed - the package assigns this very role here; only our slot word differs",
+ " naming - the role is right for what the property paints; the slot word is not",
+ " rule-5 - one value in two roles, named after the dominant one (DIVERGENCES)",
+ " bridge - built with rgb(from role / a); the foundation names no role for it",
+ " package-gap - no role of the right family exists; a request, not a swap",
+ " design - a correction would move a pixel, so it is not ours to make",
+ "",
+ "typographyUnmarked - typography step reads that carry no marker at all. The px gate only looks at",
+ "literals, so a step read slipped past it; these are banked until each one is routed to a role or",
+ "marked with a reason.",
+ "",
+ "slotLies - the slot word contradicts the CSS property the bundle shows it painting. Same rules as",
+ "above: banked with a decision, exact equality. hairline and rule-5 are deliberate and stay; naming",
+ "is a rename wave waiting for a changelog, not a role defect.",
+ "",
+ "ladders - a slot whose states resolve to one role. `focused` reusing `hovered` is accepted",
+ "convention and never listed. decision no-rung means the system has no role for the second state",
+ "either; design means it does and we skip it.",
+ "",
+ "contrast - foreground and background the bundle puts in ONE rule, resolved per mode. Alpha-bridge",
+ "values and disabled selectors are excluded: the first renders as a tint over an unknown surface,",
+ "the second is exempt from WCAG 1.4.3 and gated by disabled-paint.test.ts. decision graphic-ok means",
+ "the pair is a glyph and clears 3:1; design means it misses the threshold that applies to it.",
+ "",
+ "concepts - one anatomical concept (modifiers + slot + state, sub-elements dropped) painted with",
+ "several roles across components. This is the only check that asks the question the task is named",
+ "after; every other one reads a single declaration. decision spelling means the roles resolve to one",
+ "colour and differ only in family, shade means same family different value, design means the",
+ "components disagree about the colour itself.",
+ "",
+ "judgmentCalls - where the decision was not read off the package or a standard. Kept because a",
+ "fixed item leaves the lists above and its reasoning would leave with it."
+ ],
+ "open": [
+ {
+ "name": "chat-message-editing-preview-accent-content",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-bg-primary"
+ ],
+ "slot": "content",
+ "decision": "rule-5",
+ "why": "one value paints the accent bar (background-color) and the caption (color) in base/chat/.../_mixins.scss:14,25 - dominant role recorded per rule 5. The text half wants content-primary in dark (#4b90d9 vs our #0f6cbd); same shape as the danger/success/warning fix of review round 1"
+ },
+ {
+ "name": "check-box-invalid-mark-bg",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-danger-shared"
+ ],
+ "slot": "bg",
+ "decision": "confirmed",
+ "why": "reverted 08.09 after checking Fluent 2: the mark is a foreground element there (the box is colorCompoundBrandBackground, the check is colorNeutralForegroundInverted) and the package models it the same way as an icon. NAMING.md decides the SLOT WORD from the property, not the role family - reading it as both was mine. Value-neutral either way"
+ },
+ {
+ "name": "check-box-invalid-mark-bg-focused",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-danger-shared-active"
+ ],
+ "slot": "bg",
+ "decision": "confirmed",
+ "why": "reverted 08.09 after checking Fluent 2: the mark is a foreground element there (the box is colorCompoundBrandBackground, the check is colorNeutralForegroundInverted) and the package models it the same way as an icon. NAMING.md decides the SLOT WORD from the property, not the role family - reading it as both was mine. Value-neutral either way"
+ },
+ {
+ "name": "check-box-invalid-mark-bg-hovered",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-danger-shared-hovered"
+ ],
+ "slot": "bg",
+ "decision": "confirmed",
+ "why": "reverted 08.09 after checking Fluent 2: the mark is a foreground element there (the box is colorCompoundBrandBackground, the check is colorNeutralForegroundInverted) and the package models it the same way as an icon. NAMING.md decides the SLOT WORD from the property, not the role family - reading it as both was mine. Value-neutral either way"
+ },
+ {
+ "name": "color-view-handle-content",
+ "verdict": "cross-family",
+ "roles": [
+ "color-bg"
+ ],
+ "slot": "content",
+ "decision": "naming",
+ "why": "goes to base as $handle-color and is a FILL - radial-gradient stops and inset box-shadows. color-bg is the right role; the slot word `content` is what disagrees"
+ },
+ {
+ "name": "gallery-nav-button-bg",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-subtle"
+ ],
+ "slot": "bg",
+ "decision": "design",
+ "why": "a content role used as the scrim behind nav buttons over images. No bg role carries this value; the package paints its nav glyph with it, not the disc. Needs a scrim decision, not a swap"
+ },
+ {
+ "name": "gallery-nav-button-bg-disabled",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-disabled"
+ ],
+ "slot": "bg",
+ "decision": "design",
+ "why": "same scrim question as gallery-nav-button-bg, disabled state"
+ },
+ {
+ "name": "load-indicator-segment-inner-border",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-bg-primary-subtle"
+ ],
+ "slot": "border",
+ "decision": "package-gap",
+ "why": "border-color: from color-bg-primary-subtle because color-border-primary-subtle does not exist. Nothing to swap to - a role request"
+ },
+ {
+ "name": "radio-button-invalid-bg",
+ "verdict": "cross-family",
+ "roles": [
+ "color-border-danger-shared"
+ ],
+ "slot": "bg",
+ "decision": "rule-5",
+ "why": "the ring's border-color and the dot's background-color travel as one value - recorded in DIVERGENCES as a rule-5 pair, accepted by review round 2"
+ },
+ {
+ "name": "radio-button-invalid-bg-focused",
+ "verdict": "cross-family",
+ "roles": [
+ "color-border-danger-shared-active"
+ ],
+ "slot": "bg",
+ "decision": "rule-5",
+ "why": "see radio-button-invalid-bg"
+ },
+ {
+ "name": "radio-button-invalid-bg-hovered",
+ "verdict": "cross-family",
+ "roles": [
+ "color-border-danger-shared-hovered"
+ ],
+ "slot": "bg",
+ "decision": "rule-5",
+ "why": "see radio-button-invalid-bg"
+ },
+ {
+ "name": "switch-handle-bg-disabled",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-disabled"
+ ],
+ "slot": "bg",
+ "decision": "confirmed",
+ "why": "see switch-handle-off-bg"
+ },
+ {
+ "name": "switch-handle-off-bg",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-subtle"
+ ],
+ "slot": "bg",
+ "decision": "confirmed",
+ "why": "the package paints the switch knob (its `trigger` slot) from exactly this role. Our role is right; only the slot word differs, because the property is background-color"
+ },
+ {
+ "name": "switch-handle-on-shadow",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-inverted"
+ ],
+ "slot": "shadow",
+ "decision": "bridge",
+ "why": "rgb(from color-content-inverted / .1) - the foundation names no switch shadow role, recorded in DIVERGENCES and inventoried in BRIDGES.md"
+ },
+ {
+ "name": "switch-on-border-focused",
+ "verdict": "cross-family",
+ "roles": [
+ "color-bg-primary-active"
+ ],
+ "slot": "border",
+ "decision": "design",
+ "why": "the only member of the on-border trio still on a bg role; its siblings were corrected. color-border-primary-shared-active matches in light and moves dark #003c70 -> #005397, so it is a pixel, not hygiene"
+ },
+ {
+ "name": "tree-view-checkbox-accent-content-focused",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-bg-primary-hovered"
+ ],
+ "slot": "content",
+ "decision": "rule-5",
+ "why": "paints background-color and border-color of the checkbox, never color: - a dual-role value whose name claims the content slot it does not use"
+ }
+ ],
+ "typographyUnmarked": [
+ {
+ "variable": "grid-column-chooser-title-font-size",
+ "reads": "font-size-160",
+ "roleExists": true
+ },
+ {
+ "variable": "scheduler-appointment-month-text-font-size",
+ "reads": "font-size-120",
+ "roleExists": true
+ },
+ {
+ "variable": "scheduler-month-date-text-font-size",
+ "reads": "font-size-160",
+ "roleExists": true
+ },
+ {
+ "variable": "scheduler-small-font-size",
+ "reads": "font-size-140",
+ "roleExists": true
+ },
+ {
+ "variable": "scheduler-time-indicator-font-size",
+ "reads": "font-size-300",
+ "roleExists": false
+ },
+ {
+ "variable": "typography-l-font-size",
+ "reads": "font-size-200",
+ "roleExists": true
+ },
+ {
+ "variable": "typography-l-font-size",
+ "reads": "font-size-280",
+ "roleExists": true
+ },
+ {
+ "variable": "typography-m-font-size",
+ "reads": "font-size-160",
+ "roleExists": true
+ },
+ {
+ "variable": "typography-m-font-size",
+ "reads": "font-size-200",
+ "roleExists": true
+ },
+ {
+ "variable": "typography-s-font-size",
+ "reads": "font-size-140",
+ "roleExists": true
+ },
+ {
+ "variable": "typography-s-font-size",
+ "reads": "font-size-180",
+ "roleExists": false
+ },
+ {
+ "variable": "typography-xl-font-size",
+ "reads": "font-size-240",
+ "roleExists": true
+ },
+ {
+ "variable": "typography-xs-font-size",
+ "reads": "font-size-120",
+ "roleExists": true
+ }
+ ],
+ "slotLies": [
+ {
+ "name": "color-view-border",
+ "slot": "border",
+ "slotSays": "border",
+ "paints": [
+ "box-shadow"
+ ],
+ "decision": "hairline",
+ "why": "a 1px line drawn with background-color keeps its border role - the property is how it is drawn, not what it is. Consistent across tabs, splitterBar and gridBase"
+ },
+ {
+ "name": "color-view-border-disabled",
+ "slot": "border",
+ "slotSays": "border",
+ "paints": [
+ "box-shadow"
+ ],
+ "decision": "hairline",
+ "why": "a 1px line drawn with background-color keeps its border role - the property is how it is drawn, not what it is. Consistent across tabs, splitterBar and gridBase"
+ },
+ {
+ "name": "color-view-handle-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background",
+ "box-shadow"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "date-view-roller-bottom-shadow",
+ "slot": "shadow",
+ "slotSays": "shadow",
+ "paints": [
+ "background-image"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "date-view-roller-top-shadow",
+ "slot": "shadow",
+ "slotSays": "shadow",
+ "paints": [
+ "background-image"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "diagram-selection-bg",
+ "slot": "bg",
+ "slotSays": "bg",
+ "paints": [
+ "fill"
+ ],
+ "decision": "known",
+ "why": "already inventoried as a divergence - the foundation names no role for it"
+ },
+ {
+ "name": "filter-builder-group-operation-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-group-operation-content-disabled",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-group-operation-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-group-operation-content-hovered",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-field-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-field-content-disabled",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-field-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-field-content-hovered",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-operator-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-operator-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-operator-content-hovered",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-value-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-value-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "filter-builder-item-value-content-hovered",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "gantt-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color",
+ "border-color",
+ "border-left-color",
+ "border-top-color"
+ ],
+ "decision": "rule-5",
+ "why": "one value paints two properties; named after the dominant role, second recorded (DIVERGENCES)"
+ },
+ {
+ "name": "grid-border-focused",
+ "slot": "border",
+ "slotSays": "border",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "hairline",
+ "why": "a 1px line drawn with background-color keeps its border role - the property is how it is drawn, not what it is. Consistent across tabs, splitterBar and gridBase"
+ },
+ {
+ "name": "pivot-grid-accent-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "border-color",
+ "outline-color"
+ ],
+ "decision": "rule-5",
+ "why": "one value paints two properties; named after the dominant role, second recorded (DIVERGENCES)"
+ },
+ {
+ "name": "pivot-grid-grand-total-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "pivot-grid-total-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "radio-button-content-read-only",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color",
+ "border-color"
+ ],
+ "decision": "rule-5",
+ "why": "one value paints two properties; named after the dominant role, second recorded (DIVERGENCES)"
+ },
+ {
+ "name": "scheduler-appointment-content-active",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "scheduler-appointment-start-content",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color",
+ "box-shadow"
+ ],
+ "decision": "rule-5",
+ "why": "one value paints two properties; named after the dominant role, second recorded (DIVERGENCES)"
+ },
+ {
+ "name": "scheduler-workspace-cell-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "naming",
+ "why": "the role matches the property the bundle shows; the slot word in the name does not"
+ },
+ {
+ "name": "slider-accent-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background",
+ "border-color"
+ ],
+ "decision": "rule-5",
+ "why": "one value paints two properties; named after the dominant role, second recorded (DIVERGENCES)"
+ },
+ {
+ "name": "slider-invalid-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color",
+ "border-color"
+ ],
+ "decision": "rule-5",
+ "why": "one value paints two properties; named after the dominant role, second recorded (DIVERGENCES)"
+ },
+ {
+ "name": "splitter-bar-border",
+ "slot": "border",
+ "slotSays": "border",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "hairline",
+ "why": "a 1px line drawn with background-color keeps its border role - the property is how it is drawn, not what it is. Consistent across tabs, splitterBar and gridBase"
+ },
+ {
+ "name": "splitter-bar-border-disabled",
+ "slot": "border",
+ "slotSays": "border",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "hairline",
+ "why": "a 1px line drawn with background-color keeps its border role - the property is how it is drawn, not what it is. Consistent across tabs, splitterBar and gridBase"
+ },
+ {
+ "name": "tree-view-checkbox-accent-content-focused",
+ "slot": "content",
+ "slotSays": "content",
+ "paints": [
+ "background-color",
+ "border-color"
+ ],
+ "decision": "rule-5",
+ "why": "one value paints two properties; named after the dominant role, second recorded (DIVERGENCES)"
+ }
+ ],
+ "ladders": [
+ {
+ "stem": "accordion-title-bg",
+ "states": [
+ "active",
+ "hovered"
+ ],
+ "role": [
+ "color-bg-hovered"
+ ],
+ "decision": "design",
+ "why": "pressing the header looks exactly like hovering it: both read color-bg-hovered while the system ships color-bg-active (#e1e1e1 light / #1d1d1d dark), which the theme already uses elsewhere. Fluent 2 settles the principle: colorSubtleBackgroundHover #f5f5f5 and colorSubtleBackgroundPressed #e0e0e0 are separate tokens there, and the package mirrors those values as color-bg-hovered / color-bg-active. Still not applied here - conforming would move a pixel away from legacy fluent, and NFR-1 is the product's call, not the audit's"
+ },
+ {
+ "stem": "button-danger-contained-content",
+ "states": [
+ "rest",
+ "selected"
+ ],
+ "role": [
+ "color-content-static-dark"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "button-danger-outlined-content",
+ "states": [
+ "hovered",
+ "selected"
+ ],
+ "role": [
+ "color-content-danger-hovered"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "button-default-contained-content",
+ "states": [
+ "rest",
+ "selected"
+ ],
+ "role": [
+ "color-content-static-dark"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "button-default-outlined-content",
+ "states": [
+ "hovered",
+ "selected"
+ ],
+ "role": [
+ "color-content-primary-hovered"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "button-default-text-content",
+ "states": [
+ "hovered",
+ "selected"
+ ],
+ "role": [
+ "color-content-primary-hovered"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "button-normal-contained-content",
+ "states": [
+ "rest",
+ "selected"
+ ],
+ "role": [
+ "color-content"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "button-success-contained-content",
+ "states": [
+ "rest",
+ "selected"
+ ],
+ "role": [
+ "color-content-static-dark"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "button-success-outlined-content",
+ "states": [
+ "hovered",
+ "selected"
+ ],
+ "role": [
+ "color-content-success-hovered"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "calendar-invalid-border",
+ "states": [
+ "focused",
+ "rest"
+ ],
+ "role": [
+ "color-border-danger"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "chat-message-list-context-menu-delete-button-content",
+ "states": [
+ "focused",
+ "rest"
+ ],
+ "role": [
+ "color-content-danger"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "check-box-border",
+ "states": [
+ "disabled",
+ "read-only"
+ ],
+ "role": [
+ "color-border-disabled"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "file-manager-file-item-select-bg",
+ "states": [
+ "focused",
+ "rest"
+ ],
+ "role": [
+ "color-bg-primary"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "file-manager-file-item-select-content",
+ "states": [
+ "focused",
+ "rest"
+ ],
+ "role": [
+ "color-content-inverted"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "list-item-bg",
+ "states": [
+ "active",
+ "selected-focused"
+ ],
+ "role": [
+ "color-bg-active"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "lookup-icon",
+ "states": [
+ "active",
+ "rest"
+ ],
+ "role": [
+ "color-content-subtle"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "radio-button-content",
+ "states": [
+ "disabled",
+ "read-only"
+ ],
+ "role": [
+ "color-content-disabled"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "slider-invalid-bg",
+ "states": [
+ "hovered",
+ "rest"
+ ],
+ "role": [
+ "color-border-danger"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "switch-invalid-border",
+ "states": [
+ "focused",
+ "hovered",
+ "rest"
+ ],
+ "role": [
+ "color-border-danger"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "tabs-tab-content",
+ "states": [
+ "rest",
+ "selected"
+ ],
+ "role": [
+ "color-content"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "tag-box-tag-content",
+ "states": [
+ "active",
+ "rest"
+ ],
+ "role": [
+ "color-content"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "text-editor-border",
+ "states": [
+ "disabled",
+ "read-only"
+ ],
+ "role": [
+ "color-border-disabled"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ },
+ {
+ "stem": "tile-view-bg",
+ "states": [
+ "hovered",
+ "rest"
+ ],
+ "role": [
+ "color-bg"
+ ],
+ "decision": "design",
+ "why": "rest and hovered are one value while active is not - the slot clearly means to ladder and the hovered rung is flat. color-bg-hovered exists. Fluent 2 ladders a subtle surface from transparent through colorSubtleBackgroundHover, though a tile is not obviously that control - weaker evidence than the accordion"
+ },
+ {
+ "stem": "tree-view-content",
+ "states": [
+ "focused",
+ "rest"
+ ],
+ "role": [
+ "color-content"
+ ],
+ "decision": "no-rung",
+ "why": "the design system ships no role for the second state either, so the collapse mirrors it rather than skipping a rung"
+ }
+ ],
+ "contrast": [
+ {
+ "selector": ".dx-checkbox-checked .dx-checkbox-icon",
+ "fgRole": "color-content-inverted",
+ "bgRole": "color-bg-primary",
+ "contrast": {
+ "light": 5.38,
+ "dark": 3.36
+ },
+ "decision": "graphic-ok",
+ "why": "the checkmark is a glyph: 3.36 clears the 3:1 threshold. Recorded so it is not rediscovered, and so a drop below 3 is caught"
+ },
+ {
+ "selector": ".dx-field-value.dx-attention::before",
+ "fgRole": "color-content-static-dark",
+ "bgRole": "color-content-danger-shared",
+ "contrast": {
+ "light": 6.07,
+ "dark": 3.68
+ },
+ "decision": "graphic-ok",
+ "why": "the attention mark is a glyph on the invalid disc: 3.68 clears 3:1. Same reason to keep it listed"
+ },
+ {
+ "selector": ".dx-messagelist-context-menu-content .dx-menu-item:has(.dx-icon-trash).dx-state-focused",
+ "fgRole": "color-content-danger",
+ "bgRole": "color-bg-hovered",
+ "contrast": {
+ "light": 5.56,
+ "dark": 3.05
+ },
+ "decision": "package-gap",
+ "why": "3.05 in dark against 4.5 for a label - and no role in the family clears it: content-danger-hovered, the strongest, reaches 3.87 on the same surface. The design system ships no danger content role that meets AA on its own hovered surface in dark. A request, not a swap"
+ },
+ {
+ "selector": ".dx-splitter .dx-resize-handle",
+ "fgRole": "color-content-subtle",
+ "bgRole": "color-border",
+ "contrast": {
+ "light": 6,
+ "dark": 2.8
+ },
+ "decision": "design",
+ "why": "Measured further 09.09 and the question moved. WCAG 1.4.11 asks about what identifies the component, and that is the bar against the panes, not the dots inside it - so the grip at 2.8 may not be in scope at all. The bar is: color-border on color-bg is 1.62 in LIGHT against the same 3:1, and its hovered state 1.82. The splitter is not identifiable by contrast in light at all, which is worse than what this row originally reported and in the mode that has screenshots. The focused state fails the other way: bg-primary on color-bg is 2.88 in dark."
+ }
+ ],
+ "concepts": [
+ {
+ "concept": "accent border rest",
+ "roles": [
+ "color-content-primary",
+ "color-border-primary",
+ "color-border"
+ ],
+ "families": [
+ "content",
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "diagram",
+ "role": "color-content-primary"
+ },
+ {
+ "folder": "gantt",
+ "role": "color-border-primary"
+ },
+ {
+ "folder": "scheduler",
+ "role": "color-border"
+ }
+ ],
+ "decision": "design",
+ "why": "the components disagree on the colour, not only on the name: one concept painted from different families with different values. A design call"
+ },
+ {
+ "concept": "accent content focused",
+ "roles": [
+ "color-bg-primary-active",
+ "color-bg-primary-hovered"
+ ],
+ "families": [
+ "bg"
+ ],
+ "members": [
+ {
+ "folder": "slider",
+ "role": "color-bg-primary-active"
+ },
+ {
+ "folder": "treeView",
+ "role": "color-bg-primary-hovered"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "accent content rest",
+ "roles": [
+ "color-bg-primary",
+ "color-border-primary",
+ "color-content-primary"
+ ],
+ "families": [
+ "bg",
+ "border",
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "chat",
+ "role": "color-bg-primary"
+ },
+ {
+ "folder": "pivotGrid",
+ "role": "color-border-primary"
+ },
+ {
+ "folder": "scheduler",
+ "role": "color-content-primary"
+ }
+ ],
+ "decision": "design",
+ "why": "the components disagree on the colour, not only on the name: one concept painted from different families with different values. A design call"
+ },
+ {
+ "concept": "base bg rest",
+ "roles": [
+ "color-border-subtle",
+ "color-bg"
+ ],
+ "families": [
+ "border",
+ "bg"
+ ],
+ "members": [
+ {
+ "folder": "list",
+ "role": "color-border-subtle"
+ },
+ {
+ "folder": "stepper",
+ "role": "color-bg"
+ }
+ ],
+ "decision": "design",
+ "why": "the components disagree on the colour, not only on the name: one concept painted from different families with different values. A design call"
+ },
+ {
+ "concept": "base content rest",
+ "roles": [
+ "color-content-subtle",
+ "color-bg-primary-subtle",
+ "color-content"
+ ],
+ "families": [
+ "content",
+ "bg"
+ ],
+ "members": [
+ {
+ "folder": "diagram",
+ "role": "color-content-subtle"
+ },
+ {
+ "folder": "scheduler",
+ "role": "color-bg-primary-subtle"
+ },
+ {
+ "folder": "stepper",
+ "role": "color-content"
+ }
+ ],
+ "decision": "design",
+ "why": "the components disagree on the colour, not only on the name: one concept painted from different families with different values. A design call"
+ },
+ {
+ "concept": "empty content rest",
+ "roles": [
+ "color-content-subtle",
+ "color-content"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "cardView",
+ "role": "color-content-subtle"
+ },
+ {
+ "folder": "chat",
+ "role": "color-content"
+ },
+ {
+ "folder": "chat",
+ "role": "color-content-subtle"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "error bg rest",
+ "roles": [
+ "color-bg-danger",
+ "color-bg-danger-subtler"
+ ],
+ "families": [
+ "bg"
+ ],
+ "members": [
+ {
+ "folder": "gridBase",
+ "role": "color-bg-danger"
+ },
+ {
+ "folder": "informer",
+ "role": "color-bg-danger-subtler"
+ },
+ {
+ "folder": "toast",
+ "role": "color-bg-danger"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "error content rest",
+ "roles": [
+ "color-content-static-dark",
+ "color-content-danger"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "gridBase",
+ "role": "color-content-static-dark"
+ },
+ {
+ "folder": "gridBase",
+ "role": "color-content-danger"
+ },
+ {
+ "folder": "informer",
+ "role": "color-content-danger"
+ },
+ {
+ "folder": "toast",
+ "role": "color-content-static-dark"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "highlighted bg rest",
+ "roles": [
+ "color-bg-highlight",
+ "color-bg-primary-alpha-active"
+ ],
+ "families": [
+ "bg"
+ ],
+ "members": [
+ {
+ "folder": "cardView",
+ "role": "color-bg-highlight"
+ },
+ {
+ "folder": "htmlEditor",
+ "role": "color-bg-primary-alpha-active"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "info content rest",
+ "roles": [
+ "color-content",
+ "color-content-subtle"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "informer",
+ "role": "color-content"
+ },
+ {
+ "folder": "pagination",
+ "role": "color-content-subtle"
+ },
+ {
+ "folder": "toast",
+ "role": "color-content"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "invalid bg focused",
+ "roles": [
+ "color-bg-danger-shared-active",
+ "color-content-danger-shared-active",
+ "color-border-danger-shared-active"
+ ],
+ "families": [
+ "bg",
+ "content",
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "checkBox",
+ "role": "color-bg-danger-shared-active"
+ },
+ {
+ "folder": "checkBox",
+ "role": "color-content-danger-shared-active"
+ },
+ {
+ "folder": "radioButton",
+ "role": "color-border-danger-shared-active"
+ }
+ ],
+ "decision": "spelling",
+ "why": "the components paint the identical colour and spell it from different families - free to unify, and until then the next palette change moves some of them and not the others"
+ },
+ {
+ "concept": "invalid bg hovered",
+ "roles": [
+ "color-bg-danger-shared-hovered",
+ "color-content-danger-shared-hovered",
+ "color-border-danger-shared-hovered",
+ "color-border-danger"
+ ],
+ "families": [
+ "bg",
+ "content",
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "checkBox",
+ "role": "color-bg-danger-shared-hovered"
+ },
+ {
+ "folder": "checkBox",
+ "role": "color-content-danger-shared-hovered"
+ },
+ {
+ "folder": "radioButton",
+ "role": "color-border-danger-shared-hovered"
+ },
+ {
+ "folder": "slider",
+ "role": "color-border-danger"
+ }
+ ],
+ "decision": "spelling",
+ "why": "the components paint the identical colour and spell it from different families - free to unify, and until then the next palette change moves some of them and not the others"
+ },
+ {
+ "concept": "invalid bg rest",
+ "roles": [
+ "color-bg-danger-shared",
+ "color-content-danger-shared",
+ "color-bg-danger-subtler",
+ "color-bg-danger",
+ "color-border-danger-shared",
+ "color-border-danger"
+ ],
+ "families": [
+ "bg",
+ "content",
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "checkBox",
+ "role": "color-bg-danger-shared"
+ },
+ {
+ "folder": "checkBox",
+ "role": "color-content-danger-shared"
+ },
+ {
+ "folder": "common",
+ "role": "color-content-danger-shared"
+ },
+ {
+ "folder": "gridBase",
+ "role": "color-bg-danger-subtler"
+ },
+ {
+ "folder": "progressBar",
+ "role": "color-bg-danger"
+ },
+ {
+ "folder": "radioButton",
+ "role": "color-border-danger-shared"
+ },
+ {
+ "folder": "slider",
+ "role": "color-border-danger"
+ }
+ ],
+ "decision": "spelling",
+ "why": "the components paint the identical colour and spell it from different families - free to unify, and until then the next palette change moves some of them and not the others"
+ },
+ {
+ "concept": "invalid border focused",
+ "roles": [
+ "color-border-danger",
+ "color-border-danger-shared-active"
+ ],
+ "families": [
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "calendar",
+ "role": "color-border-danger"
+ },
+ {
+ "folder": "checkBox",
+ "role": "color-border-danger-shared-active"
+ },
+ {
+ "folder": "switch",
+ "role": "color-border-danger"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "invalid border hovered",
+ "roles": [
+ "color-border-danger-shared-hovered",
+ "color-border-danger"
+ ],
+ "families": [
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "checkBox",
+ "role": "color-border-danger-shared-hovered"
+ },
+ {
+ "folder": "switch",
+ "role": "color-border-danger"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "invalid border rest",
+ "roles": [
+ "color-border-danger",
+ "color-border-danger-shared"
+ ],
+ "families": [
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "calendar",
+ "role": "color-border-danger"
+ },
+ {
+ "folder": "checkBox",
+ "role": "color-border-danger-shared"
+ },
+ {
+ "folder": "switch",
+ "role": "color-border-danger"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "invalid content rest",
+ "roles": [
+ "color-content-static-dark",
+ "color-content-danger",
+ "color-bg-danger"
+ ],
+ "families": [
+ "content",
+ "bg"
+ ],
+ "members": [
+ {
+ "folder": "common",
+ "role": "color-content-static-dark"
+ },
+ {
+ "folder": "fieldset",
+ "role": "color-content-danger"
+ },
+ {
+ "folder": "fileUploader",
+ "role": "color-bg-danger"
+ },
+ {
+ "folder": "stepper",
+ "role": "color-content-danger"
+ }
+ ],
+ "decision": "design",
+ "why": "the components disagree on the colour, not only on the name: one concept painted from different families with different values. A design call"
+ },
+ {
+ "concept": "inverted content rest",
+ "roles": [
+ "color-content-static-dark",
+ "color-content-inverted"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "fileManager",
+ "role": "color-content-static-dark"
+ },
+ {
+ "folder": "list",
+ "role": "color-content-static-dark"
+ },
+ {
+ "folder": "scheduler",
+ "role": "color-content-inverted"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "menu bg rest",
+ "roles": [
+ "color-bg",
+ "color-border-subtle"
+ ],
+ "families": [
+ "bg",
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "list",
+ "role": "color-bg"
+ },
+ {
+ "folder": "menu",
+ "role": "color-bg"
+ },
+ {
+ "folder": "menu",
+ "role": "color-border-subtle"
+ }
+ ],
+ "decision": "design",
+ "why": "the components disagree on the colour, not only on the name: one concept painted from different families with different values. A design call"
+ },
+ {
+ "concept": "menu icon rest",
+ "roles": [
+ "color-content-subtle",
+ "color-content"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "contextMenu",
+ "role": "color-content-subtle"
+ },
+ {
+ "folder": "filterBuilder",
+ "role": "color-content"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "menu shadow rest",
+ "roles": [
+ "box-shadow-md",
+ "color-shadow-ambient"
+ ],
+ "families": [
+ "shadow"
+ ],
+ "members": [
+ {
+ "folder": "contextMenu",
+ "role": "box-shadow-md"
+ },
+ {
+ "folder": "list",
+ "role": "color-shadow-ambient"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "success content rest",
+ "roles": [
+ "color-content-success",
+ "color-content-static-dark"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "gridBase",
+ "role": "color-content-success"
+ },
+ {
+ "folder": "toast",
+ "role": "color-content-static-dark"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "text content focused",
+ "roles": [
+ "color-content-static-dark",
+ "color-content-primary"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "filterBuilder",
+ "role": "color-content-static-dark"
+ },
+ {
+ "folder": "textEditor",
+ "role": "color-content-primary"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ },
+ {
+ "concept": "text content rest",
+ "roles": [
+ "color-content",
+ "color-content-subtle"
+ ],
+ "families": [
+ "content"
+ ],
+ "members": [
+ {
+ "folder": "cardView",
+ "role": "color-content"
+ },
+ {
+ "folder": "diagram",
+ "role": "color-content-subtle"
+ },
+ {
+ "folder": "textEditor",
+ "role": "color-content"
+ }
+ ],
+ "decision": "shade",
+ "why": "the same family, a different shade - a difference two components can honestly have; listed so it is a choice rather than an accident"
+ }
+ ],
+ "judgmentCalls": [
+ {
+ "what": "the hairline idiom: a border role drawn with background-color keeps the border role (11 declarations left as they are)",
+ "against": "nothing - but no guideline states it either",
+ "basis": "three places in the theme agree (tabs, splitterBar, gridBase), and core/tabs confirms it independently: its selected-tab bar is a `selector` slot painted from border-primary-shared",
+ "cost": "none - nothing was changed on this basis, it only kept 11 names off the defect list",
+ "open": false
+ },
+ {
+ "what": "the classifiers themselves: the slot->family table, the kinship table, the folder->component map, the shared cross-cutting list",
+ "against": "nothing - they encode NAMING.md, but the specific 30-slot mapping and which slots count as ambiguous are mine",
+ "basis": "seeded from registries.json and the ROOTS map in package-disabled.mjs; every finding they produce carries its own evidence, so a wrong classifier shows up as a wrong finding rather than a hidden bias",
+ "cost": "three revisions during the audit already removed seven false conflicts and eight false ladders",
+ "open": false
+ },
+ {
+ "what": "Fluent 2 was consulted only for four questions (typography ramp, focus stroke, checkbox anatomy, subtle hover vs pressed)",
+ "against": "nothing - but it is a spot check, not a sweep",
+ "basis": "those were the places where an external answer could change a decision; the rest of the report still rests on the package and the three sibling products",
+ "cost": "the 277 declarations no product describes remain without external authority",
+ "open": true
+ }
+ ],
+ "fluent2": {
+ "comment": [
+ "Checked against fluent2.microsoft.design and microsoft/fluentui packages/tokens on 08.09.2026.",
+ "Only the places where an external answer changes something are recorded."
+ ],
+ "findings": [
+ {
+ "about": "typography off the role grid (37 step reads, 17 of them font-weight 500)",
+ "fluent2": "the web type ramp has three weights - Regular, Semibold, Bold - and no Medium at all. Its sizes are 10/12/14/16/20/24/28/32/40/68px, which the package grid mirrors exactly bar Display",
+ "answer": "font-weight 500 and sizes 11/18/22/26/36px are legacy fluent values with no home in Fluent 2. The package grid is faithful, not short of steps, so design#1555 asked the wrong way round: the question is whether to keep the legacy values, not which steps deserve roles"
+ },
+ {
+ "about": "the focus ring reading color-border-primary-shared while the package ships four focus roles unread",
+ "fluent2": "focus is a two-tone stroke - colorStrokeFocus1 #ffffff inner, colorStrokeFocus2 #000000 outer - specifically so it survives any background. It is not an accent-coloured single stroke",
+ "answer": "the four unread roles (focus, -inverted, -static, -static-inverted) are the package mirroring Fluent 2. The theme paints focus in a way Fluent 2 does not"
+ },
+ {
+ "about": "the checkBox invalid mark, swapped to a bg role earlier in this audit",
+ "fluent2": "the checkbox box is colorCompoundBrandBackground and the checkmark is colorNeutralForegroundInverted - the mark is foreground",
+ "answer": "reverted. Two external sources against one reading of an internal rule"
+ },
+ {
+ "about": "accordion hover and pressed resolving to one role",
+ "fluent2": "colorSubtleBackgroundHover #f5f5f5 and colorSubtleBackgroundPressed #e0e0e0 are distinct tokens",
+ "answer": "the collapse deviates from Fluent 2, and the package already carries both values. Left for the product because conforming moves away from legacy fluent"
+ }
+ ]
+ },
+ "unusedRoles": {
+ "comment": [
+ "Counted from the package inward: roles the four component sets assign that the theme reads",
+ "nowhere. capability - the role exists in the semantic layer and we simply never reached for it;",
+ "stale - the neighbours reference a name no layer declares, which is their drift, not our gap.",
+ "Exact equality: a role leaving this list means the theme started using it, which is a decision."
+ ],
+ "capability": [
+ "color-bg-high",
+ "color-bg-info",
+ "color-bg-info-active",
+ "color-bg-info-hovered",
+ "color-bg-info-selected",
+ "color-bg-info-subtler",
+ "color-bg-info-subtler-active",
+ "color-bg-info-subtler-hovered",
+ "color-bg-info-subtler-selected",
+ "color-bg-inverted-active",
+ "color-bg-inverted-disabled",
+ "color-bg-inverted-hovered",
+ "color-bg-inverted-selected",
+ "color-bg-low-active",
+ "color-bg-on-color",
+ "color-bg-on-color-alpha",
+ "color-bg-on-color-alpha-active",
+ "color-bg-on-color-alpha-disabled",
+ "color-bg-on-color-alpha-hovered",
+ "color-bg-primary-shared-hovered",
+ "color-bg-static-dark",
+ "color-bg-static-dark-active",
+ "color-bg-static-dark-disabled",
+ "color-bg-static-dark-hovered",
+ "color-bg-static-dark-selected",
+ "color-bg-static-light-active",
+ "color-bg-static-light-disabled",
+ "color-bg-static-light-hovered",
+ "color-bg-static-light-selected",
+ "color-bg-success-shared",
+ "color-bg-success-shared-active",
+ "color-bg-success-shared-hovered",
+ "color-bg-warning-active",
+ "color-bg-warning-hovered",
+ "color-bg-warning-selected",
+ "color-bg-warning-subtler",
+ "color-bg-warning-subtler-active",
+ "color-bg-warning-subtler-hovered",
+ "color-bg-warning-subtler-selected",
+ "color-border-info",
+ "color-border-inverted",
+ "color-border-inverted-active",
+ "color-border-inverted-disabled",
+ "color-border-inverted-hovered",
+ "color-border-on-color-shared",
+ "color-border-on-color-shared-disabled",
+ "color-border-static-dark",
+ "color-border-static-dark-active",
+ "color-border-static-dark-disabled",
+ "color-border-static-dark-hovered",
+ "color-border-static-light",
+ "color-border-static-light-active",
+ "color-border-static-light-disabled",
+ "color-border-static-light-hovered",
+ "color-border-success-shared",
+ "color-border-success-shared-active",
+ "color-border-success-shared-hovered",
+ "color-border-warning",
+ "color-content-info",
+ "color-content-info-active",
+ "color-content-info-hovered",
+ "color-content-inverted-disabled",
+ "color-content-on-color",
+ "color-content-on-color-disabled",
+ "color-content-on-color-shared",
+ "color-content-on-color-shared-disabled",
+ "color-content-on-color-subtler",
+ "color-content-on-subtle-primary",
+ "color-content-primary-shared",
+ "color-content-primary-shared-active",
+ "color-content-primary-shared-hovered",
+ "color-content-static-dark-disabled",
+ "color-content-static-light-disabled",
+ "color-content-success-shared",
+ "color-content-success-shared-active",
+ "color-content-success-shared-hovered",
+ "color-content-warning",
+ "color-content-warning-active",
+ "color-content-warning-hovered",
+ "color-focus",
+ "color-focus-inverted",
+ "color-focus-static",
+ "color-focus-static-inverted"
+ ],
+ "stale": [
+ "color-bg-none",
+ "color-border-contrast-disabled",
+ "color-border-none",
+ "color-content-active",
+ "color-content-none",
+ "color-content-static-dark-active",
+ "color-content-subtle-active",
+ "color-content-subtle-hovered",
+ "color-content-subtle-selected",
+ "color-content-subtler-disabled"
+ ]
+ },
+ "coverage": {
+ "comment": [
+ "How much of the theme has an external counterpart to compare against, and what it would cost to",
+ "raise that. Measured 09.09.2026 against repositories outside this one, so the tool cannot",
+ "recompute it - the numbers are banked with their source and go stale on purpose, loudly, when",
+ "someone reads them next to a fresh no-counterpart count."
+ ],
+ "measuredOn": "2026-09-09",
+ "levers": [
+ {
+ "id": "Р1",
+ "lever": "Карта «наша папка → компонент пакета»",
+ "status": "сделано",
+ "covers": 51,
+ "needs": "ничего — источник тот же",
+ "detail": "Карта сопоставляла по похожести имён. cardView — это пакетный grid (так его и мапит package-disabled.mjs), speedDialAction — button, lookup — listbox, actionSheet — popup, validation — field. Шесть строк, 277 → 226, и ни одного нового расхождения: все 51 попали в agrees."
+ },
+ {
+ "id": "Р2",
+ "lever": "Реализация Blazor как пятый источник",
+ "status": "предложение",
+ "covers": 110,
+ "needs": "парсер ds.$ по папкам + карта из семи строк",
+ "detail": "В пакете у Blazor 20 компонентов, в реализации (dxvcs/.../ds-themes/components) — 57, и роли он читает через тот же мост ds.$, что и мы: карта имён ролей не нужна. Имена папок опять другие: rollers — это dateView, edit-dropdown — dropDownEditor. Покрывает scheduler 43 (у них 38 ролей), filterBuilder 28 (59), pivotGrid 19, fileUploader 10, dateView 5, splitterBar 3, dropDownEditor 2."
+ },
+ {
+ "id": "Р3",
+ "lever": "Fluent UI React",
+ "status": "предложение",
+ "covers": 13,
+ "needs": "карта имён Fluent 2 → dxds, ~40-60 пар",
+ "detail": "У Fluent есть Slider, и его стили называют девять цветовых токенов по частям. Но ни имена (colorCompoundBrandBackground против color-bg-primary-shared), ни значения (бренд #0078d4 против #0f6cbd) не совпадают — нужна разовая карта."
+ },
+ {
+ "id": "Р4",
+ "lever": "WPF — только на уровне семейства",
+ "status": "предложение",
+ "covers": 47,
+ "needs": "разбор PaletteSemantics.md, 142 ключа",
+ "detail": "Компоненты у WPF есть (Gantt 12 xaml, Diagram 14, RichEdit 8), но их темы НЕ затокенизированы: семантические ключи вида AccordionHeaderBackgroundRest получают только компоненты из components/wpf, а Gantt и Diagram красятся легаси-палитрой Lightweight, где Color.Foreground.Primary — это литерал #FF1A1A1A. Зато Lightweight/Common/PaletteSemantics.md описывает смысл 142 ключей словами («Color.Border — primary border brush for controls»). Это сигнал уровня семейства (border / bg / content), а не роли, и он покрывает ровно то, чего нет больше нигде: gantt 14, diagram 20, htmlEditor 13."
+ }
+ ],
+ "rejected": [
+ {
+ "lever": "Реализация vnext",
+ "why": "18 компонентов, все до одного уже описаны пакетом. Не добавляет ничего."
+ }
+ ],
+ "floor": {
+ "declarations": 56,
+ "folders": [
+ "stepper",
+ "fileManager",
+ "tileView",
+ "sortable",
+ "widget"
+ ],
+ "why": "Виджеты, которых нет ни в одной дизайн-системе. Единственный путь — дизайн-ревью по скриншотам."
+ }
+ },
+ "neighbourAgreement": {
+ "comment": [
+ "Do the neighbours agree with each other? Measured 09.09.2026 over the four component sets,",
+ "comparing only where the anatomy path matches exactly (component + everything after `color.`).",
+ "Banked because it qualifies the whole method: this audit compares our roles against theirs, and",
+ "that is worth what their own consistency is worth."
+ ],
+ "measuredOn": "2026-09-09",
+ "comparableSlots": 583,
+ "agreeing": 541,
+ "disagreeing": 42,
+ "staleNoneSpelling": 9,
+ "realDisagreements": 33,
+ "pairs": [
+ {
+ "pair": "core ↔ vnext",
+ "shared": 579,
+ "agree": 100
+ },
+ {
+ "pair": "blazor ↔ core",
+ "shared": 115,
+ "agree": 77
+ },
+ {
+ "pair": "blazor ↔ vnext",
+ "shared": 116,
+ "agree": 76
+ },
+ {
+ "pair": "core ↔ wpf",
+ "shared": 33,
+ "agree": 70
+ },
+ {
+ "pair": "blazor ↔ wpf",
+ "shared": 3,
+ "agree": 0
+ }
+ ],
+ "anatomyOverlap": [
+ {
+ "component": "button",
+ "paths": 401,
+ "sharedByAll": 0,
+ "sets": "core+vnext+blazor+wpf"
+ },
+ {
+ "component": "checkbox",
+ "paths": 126,
+ "sharedByAll": 0,
+ "sets": "core+vnext+blazor"
+ },
+ {
+ "component": "toast",
+ "paths": 92,
+ "sharedByAll": 0,
+ "sets": "core+vnext+blazor"
+ },
+ {
+ "component": "grid",
+ "paths": 66,
+ "sharedByAll": 8,
+ "sets": "core+vnext+wpf"
+ },
+ {
+ "component": "switch",
+ "paths": 57,
+ "sharedByAll": 0,
+ "sets": "core+vnext+blazor+wpf"
+ },
+ {
+ "component": "ribbon",
+ "paths": 46,
+ "sharedByAll": 8,
+ "sets": "core+vnext+wpf"
+ },
+ {
+ "component": "radio-button",
+ "paths": 36,
+ "sharedByAll": 0,
+ "sets": "core+vnext+blazor"
+ },
+ {
+ "component": "text-input",
+ "paths": 35,
+ "sharedByAll": 0,
+ "sets": "core+vnext+blazor+wpf"
+ }
+ ],
+ "realExamples": [
+ {
+ "what": "button, вариант outline",
+ "detail": "blazor кладёт фон на bg-none / bg-hovered / bg-active, core и vnext — на альфа-лестницу bg-alpha*. На button приходится 19 расхождений из 33."
+ },
+ {
+ "what": "ribbon",
+ "detail": "у wpf он акцентный: content-inverted вместо content, bg-primary-hovered вместо bg-hovered. У веба нейтральный. Похоже на осознанное десктопное решение, а не на разнобой."
+ },
+ {
+ "what": "grid",
+ "detail": "wpf сидит на bg-low и border, веб — на bg и border-subtle."
+ },
+ {
+ "what": "tabs",
+ "detail": "иконка при наведении: blazor content-primary-hovered, wpf content."
+ }
+ ],
+ "conclusion": "Там, где соседи описывают одно и то же одними словами, они согласны на 93%. Но одними словами они описывают почти ничего: у button 401 путь анатомии на четыре продукта и ноль общих для всех четырёх. Расходится не назначение ролей, а анатомия — каждый продукт моделирует свои варианты, суб-элементы и состояния."
+ },
+ "whyContrastDoesNotFailCi": {
+ "comment": [
+ "Why a contrast pair below AA does not turn the dark a11y job red. Asked 09.09.2026 and grounded",
+ "in axe-core 4.12.1 source rather than assumed - the answer is structural, so it applies to every",
+ "future finding of the same shape."
+ ],
+ "reasons": [
+ {
+ "reason": "axe reads DOM text nodes, and an icon glyph is not one",
+ "detail": "visibleTextNodes() in axe-core collects children with nodeType === 3. DevExtreme icon fonts render through ::before { content: \"\\fXXX\" }, which is generated content, not a child node - so an element whose only text is a glyph never enters the color-contrast rule. The splitter grip is exactly that: dx-icon-sizing sets a font-size on .dx-resize-handle-icon and the glyph arrives from the icon font.",
+ "covers": "every chevron, checkmark, arrow, grip and spinner in the theme"
+ },
+ {
+ "reason": "WCAG 1.4.11 has no axe rule at all",
+ "detail": "Non-text contrast - 3:1 for control boundaries and graphics - is not implemented in axe. Borders, focus rings, indicators and switch tracks are outside the gate by construction, not by suppression.",
+ "covers": "the whole non-text half of the contrast requirement"
+ },
+ {
+ "reason": "the state has to be reached",
+ "detail": "The delete item in the message-list context menu measures 3.05 in dark, and it is real text - but only while the menu is open and that item focused. A pair that needs a state the a11y suite never enters is invisible even when the rule applies to it.",
+ "covers": "findings in transient states: focused, opened, invalid"
+ }
+ ],
+ "consequence": "Тёмный a11y-прогон идёт и color-contrast в нём включён - но он видит только настоящий текст в достижимых состояниях. Контраст глифов и нетекстовых элементов не проверяет никто, и таблица контраста в этом отчёте - единственное место, где он измерен."
+ },
+ "nonTextContrast": {
+ "comment": [
+ "WCAG 1.4.11, the 3:1 floor for control boundaries and graphics. No axe rule implements it and no",
+ "screenshot can see it, so nothing in CI measures it. Recorded 09.09.2026 after asking whether the",
+ "splitter grip row was worth acting on - it turned out to be the wrong element to measure.",
+ "This is a first sample, not a sweep: only the splitter was measured this way."
+ ],
+ "measuredOn": "2026-09-09",
+ "findings": [
+ {
+ "what": "полоса сплиттера на фоне панели",
+ "pair": "color-border на color-bg",
+ "light": 1.62,
+ "dark": 3.42,
+ "verdict": "провал в светлом, порог 3:1. Элемент управления не опознаётся контрастом."
+ },
+ {
+ "what": "полоса сплиттера при наведении",
+ "pair": "color-border-hovered на color-bg",
+ "light": 1.82,
+ "dark": 6.76,
+ "verdict": "провал в светлом: наведение не делает полосу различимой."
+ },
+ {
+ "what": "полоса сплиттера в фокусе",
+ "pair": "color-bg-primary на color-bg",
+ "light": 5.38,
+ "dark": 2.88,
+ "verdict": "провал в тёмном: индикация фокуса ниже 3:1."
+ },
+ {
+ "what": "грип внутри полосы (исходная строка Д7)",
+ "pair": "color-content-subtle на color-border",
+ "light": 6,
+ "dark": 2.8,
+ "verdict": "ниже 3:1 в тёмном, но применимость 1.4.11 спорна: это аффорданс внутри уже опознанного элемента, а не то, что его опознаёт."
+ }
+ ],
+ "consequence": "Вопрос Д7 переформулирован: не «какую роль дать точкам грипа», а «различима ли полоса сплиттера вообще». В светлом — нет, 1.62 при пороге 3:1, и это не видит ни axe (нет правила под 1.4.11), ни скриншоты (эталон совпадает сам с собой). Померен только сплиттер: сплошной проверки нетекстового контраста в теме нет."
+ },
+ "questionIds": {
+ "comment": [
+ "Постоянные номера вопросов. Присваиваются один раз и не переиспользуются: закрытая запись",
+ "уносит свой номер с собой, новая получает следующий свободный. Раньше номер вычислялся от",
+ "позиции в списке, и закрытие одной записи сдвигало все следующие - ссылка «Д8 — вариант 1»",
+ "через день указывала на другой вопрос.",
+ "Закрытые: Д-list-border-disabled (удалён), Д-tabs-tab-border-disabled (переименован в selector)."
+ ],
+ "next": 14,
+ "map": {
+ "open:gallery-nav-button-bg": "Д1",
+ "open:gallery-nav-button-bg-disabled": "Д2",
+ "open:switch-on-border-focused": "Д3",
+ "open:tree-view-checkbox-border-disabled": "Д4",
+ "ladder:accordion-title-bg": "Д5",
+ "ladder:tile-view-bg": "Д6",
+ "contrast:.dx-splitter .dx-resize-handle": "Д7",
+ "concept:accent border rest": "Д8",
+ "concept:accent content rest": "Д9",
+ "concept:base bg rest": "Д10",
+ "concept:base content rest": "Д11",
+ "concept:invalid content rest": "Д12",
+ "concept:menu bg rest": "Д13"
+ }
+ }
+}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
new file mode 100644
index 000000000000..79bcaa1192d8
--- /dev/null
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -0,0 +1,317 @@
+/*
+ * The role a colour slot reads has to be the role the design system names for that slot.
+ *
+ * Nothing else checks this. The naming enforcer checks the shape of the name, the resolve diff
+ * checks that a value did not move, the reachability audit checks delivery and the screenshots
+ * check the cascade - a role that is wrong but plausible passes all four, and surfaces only when
+ * the palette is re-anchored or in dark mode, where two roles that share a primitive in light
+ * diverge and no etalon exists to notice.
+ *
+ * The comparison lives in tools/review/roles.mjs - the same module ROLES.md is built from, so the
+ * gate and the report cannot disagree about what counts as a disagreement. Driven as a child
+ * process because the tool is ESM and jest transforms TypeScript only.
+ */
+
+import { execFileSync } from 'child_process';
+import {
+ mkdirSync, mkdtempSync, readFileSync, writeFileSync,
+} from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+const packageRoot = process.cwd();
+const tool = join(packageRoot, 'tools', 'review', 'roles.mjs');
+const baselinePath = join(packageRoot, 'tests', 'roles.baseline.json');
+
+type Finding = {
+ name: string;
+ slot: string | null;
+ roles: string[];
+ package?: { verdict: string };
+};
+type Open = {
+ name: string; verdict: string; roles: string[]; slot: string | null;
+ decision?: string; why?: string;
+};
+
+const DECISIONS = ['confirmed', 'naming', 'rule-5', 'bridge', 'package-gap', 'design'];
+const SLOT_DECISIONS = ['naming', 'hairline', 'rule-5', 'known', 'design'];
+const LADDER_DECISIONS = ['no-rung', 'design'];
+const CONTRAST_DECISIONS = ['graphic-ok', 'package-gap', 'design'];
+const CONCEPT_DECISIONS = ['spelling', 'shade', 'design'];
+
+type Concept = {
+ concept: string; roles: string[]; families: string[];
+ members: { folder: string; role: string }[]; decision?: string; why?: string;
+};
+
+type ContrastPair = {
+ selector: string; fgRole: string; bgRole: string; contrast: Record;
+ decision?: string; why?: string;
+};
+
+type Ladder = { stem: string; states: string[]; role: string[]; decision?: string; why?: string };
+
+type SlotLie = {
+ name: string; slot: string | null; slotSays: string; paints: string[];
+ decision?: string; why?: string;
+};
+
+type Typography = { variable: string; family: string; step: number; marker: string | null; roles: string[] };
+
+const run = (theme?: string): {
+ summary: Record;
+ findings: (Finding & { slot?: string | null; slotLies?: { slotSays: string }; paints?: { properties: string[] } })[];
+ typography: Typography[];
+ ladders: (Ladder & { unusedRungs: unknown[] })[];
+ lowContrast: ContrastPair[];
+ concepts: (Concept & { clusters: unknown[]; oneColour: boolean })[];
+ unusedRoles: { capability: { role: string }[]; stale: { role: string }[] };
+} => JSON.parse(
+ execFileSync('node', [tool, '--json', ...(theme ? [`--theme=${theme}`] : [])], {
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ }),
+);
+
+const disagreements = (findings: Finding[]): Open[] => findings
+ .filter((f) => f.package && ['cross-family', 'family-conflict'].includes(f.package.verdict))
+ .map((f) => ({
+ name: f.name, verdict: f.package!.verdict, roles: f.roles, slot: f.slot,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+const actual = run();
+const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
+
+const unmarked = (typography: Typography[]) => typography
+ .filter((t) => !t.marker)
+ .map((t) => ({ variable: t.variable, reads: `${t.family}-${t.step}`, roleExists: t.roles.length > 0 }))
+ .sort((a, b) => (a.variable + a.reads).localeCompare(b.variable + b.reads));
+
+if (process.env.UPDATE_ROLES_BASELINE) {
+ writeFileSync(baselinePath, `${JSON.stringify({
+ ...baseline,
+ open: disagreements(actual.findings).map((entry) => {
+ const previous = baseline.open.find((o: Open) => o.name === entry.name);
+ return { ...entry, decision: previous?.decision, why: previous?.why };
+ }),
+ typographyUnmarked: unmarked(actual.typography),
+ }, null, 2)}\n`);
+}
+
+test('every colour declaration reaches a verdict', () => {
+ const unclassified = actual.findings.filter((f) => !f.package?.verdict);
+ expect(unclassified.map((f) => f.name)).toEqual([]);
+});
+
+/*
+ * Exact equality, not a ratchet down. A new disagreement is a role nobody has looked at; a resolved
+ * one is a decision that belongs in the commit that made it. Both have to be banked on purpose.
+ */
+test('the roles the package disagrees with are the reviewed ones', () => {
+ const banked = baseline.open.map(({ decision, why, ...rest }: Open) => rest);
+ expect(disagreements(actual.findings)).toEqual(banked);
+});
+
+/*
+ * A banked disagreement with no decision is the failure mode this whole report exists to prevent:
+ * a role nobody chose, sitting in a list nobody reads. The list is the record, so it carries the
+ * reasoning, not just the names.
+ */
+test('every banked disagreement carries a decision and a reason', () => {
+ const undecided = baseline.open
+ .filter((o: Open) => !o.decision || !DECISIONS.includes(o.decision) || !o.why?.trim())
+ .map((o: Open) => o.name);
+ expect(undecided).toEqual([]);
+});
+
+// A green gate has to mean "nothing to find", not "the scan matched nothing".
+test('a role from the wrong family is caught', () => {
+ const theme = mkdtempSync(join(tmpdir(), 'roles-'));
+ mkdirSync(join(theme, 'switch'));
+ writeFileSync(join(theme, 'switch', '_colors.scss'), [
+ '@use "../../../_design-system/variables/ds" as ds;',
+ '',
+ '$switch-off-border: ds.$color-content-subtle !default;',
+ '',
+ ].join('\n'));
+
+ const planted = run(theme).findings.find((f) => f.name === 'switch-off-border');
+ expect(planted?.package?.verdict).toBe('cross-family');
+});
+
+test('a role the package names for the slot passes', () => {
+ const theme = mkdtempSync(join(tmpdir(), 'roles-'));
+ mkdirSync(join(theme, 'switch'));
+ writeFileSync(join(theme, 'switch', '_colors.scss'), [
+ '@use "../../../_design-system/variables/ds" as ds;',
+ '',
+ '$switch-off-border: ds.$color-border-contrast !default;',
+ '',
+ ].join('\n'));
+
+ const planted = run(theme).findings.find((f) => f.name === 'switch-off-border');
+ expect(planted?.package?.verdict).toBe('agrees');
+});
+
+/*
+ * A typography step read is not a literal, so tools/review/px-audit.mjs never saw it: these slipped
+ * past the marker discipline entirely. Banked rather than ratcheted, for the same reason as above -
+ * routing one onto a role is a decision (caption or base or title, at the same step), and it should
+ * arrive with the commit that made it.
+ */
+test('typography step reads with no marker are the known ones', () => {
+ expect(unmarked(actual.typography)).toEqual(baseline.typographyUnmarked);
+});
+
+/*
+ * The slot is the one claim in a name that can be checked against ground truth: NAMING.md says the
+ * CSS property decides it, and the built bundle says which property the value reaches. Where the two
+ * disagree the name misdescribes the code - sometimes deliberately (a hairline drawn with
+ * background-color is still a border), sometimes not (fourteen filterBuilder `-content` variables
+ * that have never painted text). Banked with the reason either way.
+ *
+ * Needs the built bundle; with none there is nothing to read and the case would pass vacuously, so
+ * it asserts the scan found something first.
+ */
+test('names whose slot contradicts the painted property are the reviewed ones', () => {
+ const lies = actual.findings
+ .filter((f) => f.slotLies)
+ .map((f) => ({
+ name: f.name,
+ slot: f.slot ?? null,
+ slotSays: f.slotLies!.slotSays,
+ paints: f.paints!.properties,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ expect(lies.length).toBeGreaterThan(0);
+ expect(lies).toEqual(baseline.slotLies.map(({ decision, why, ...rest }: SlotLie) => rest));
+});
+
+test('every banked slot mismatch carries a decision and a reason', () => {
+ const undecided = baseline.slotLies
+ .filter((o: SlotLie) => !o.decision || !SLOT_DECISIONS.includes(o.decision) || !o.why?.trim())
+ .map((o: SlotLie) => o.name);
+ expect(undecided).toEqual([]);
+});
+
+/*
+ * A state in the name that the eye cannot find. Read from the theme alone, so it answers for the
+ * 22 folders the package has never heard of as well: the question is whether the design system
+ * ships a role for the second state, not whether some other product models the widget.
+ */
+test('slots whose states resolve to one role are the reviewed ones', () => {
+ const seen = actual.ladders
+ .map((l) => ({ stem: l.stem, states: l.states, role: l.role }))
+ .sort((a, b) => a.stem.localeCompare(b.stem));
+ expect(seen).toEqual(baseline.ladders.map(({ decision, why, ...rest }: Ladder) => rest));
+});
+
+test('every banked ladder carries a decision and a reason', () => {
+ const undecided = baseline.ladders
+ .filter((l: Ladder) => !l.decision || !LADDER_DECISIONS.includes(l.decision) || !l.why?.trim())
+ .map((l: Ladder) => l.stem);
+ expect(undecided).toEqual([]);
+});
+
+/*
+ * Dark mode has no screenshot etalon and axe reads text only, so a role that is fine in light and
+ * wrong in dark has nothing watching it. This measures only pairs the bundle puts in one rule -
+ * no guess about which surface a text sits on - and skips alpha bridges and disabled selectors,
+ * which would each invent a number nobody sees.
+ */
+test('text on its own background below AA is the reviewed set', () => {
+ const measured = actual.lowContrast
+ .map(({ selector, fgRole, bgRole, contrast }) => ({ selector, fgRole, bgRole, contrast }))
+ .sort((a, b) => a.selector.localeCompare(b.selector));
+ expect(measured).toEqual(baseline.contrast.map(({ decision, why, ...rest }: ContrastPair) => rest));
+});
+
+test('every banked contrast pair carries a decision and a reason', () => {
+ const undecided = baseline.contrast
+ .filter((c: ContrastPair) => !c.decision || !CONTRAST_DECISIONS.includes(c.decision) || !c.why?.trim())
+ .map((c: ContrastPair) => c.selector);
+ expect(undecided).toEqual([]);
+});
+
+/*
+ * The only check that asks about the theme as a whole rather than one declaration: does the same
+ * concept get the same role everywhere? Six components paint an invalid background six ways, three
+ * of them the identical colour spelled from three different families - nothing that reads one
+ * declaration at a time can see that.
+ */
+test('concepts painted with several roles are the reviewed ones', () => {
+ const seen = actual.concepts
+ .map(({ concept, roles, families, members }) => ({
+ concept,
+ roles,
+ families,
+ members: members.map(({ folder, role }) => ({ folder, role })),
+ }))
+ .sort((a, b) => a.concept.localeCompare(b.concept));
+ expect(seen).toEqual(baseline.concepts.map(({ decision, why, ...rest }: Concept) => rest));
+});
+
+test('every banked concept split carries a decision and a reason', () => {
+ const undecided = baseline.concepts
+ .filter((c: Concept) => !c.decision || !CONCEPT_DECISIONS.includes(c.decision) || !c.why?.trim())
+ .map((c: Concept) => c.concept);
+ expect(undecided).toEqual([]);
+});
+
+/*
+ * Provenance. Most decisions above are read off the package or off a threshold; a few are not, and
+ * a fixed item leaves its list taking the reasoning with it. This keeps those visible - notably the
+ * checkBox mark, where the repo rule (the property decides the slot) and the package's modelling
+ * (the mark is an icon) point opposite ways and the colour is identical either way.
+ */
+test('judgment calls stay recorded with what they went against', () => {
+ expect(baseline.judgmentCalls.length).toBeGreaterThan(0);
+ const incomplete = baseline.judgmentCalls
+ .filter((c: Record) => !c.what?.trim() || !c.basis?.trim() || !c.against?.trim())
+ .map((c: Record) => c.what);
+ expect(incomplete).toEqual([]);
+});
+
+/*
+ * The one check that starts from the package rather than from our declarations. A whole family can
+ * be missing without any single declaration looking wrong - that is how the four focus roles stayed
+ * invisible until the component holding them was parsed at all. Exact equality both ways: a role
+ * leaving the list means the theme started using it, and that is a decision worth a diff.
+ */
+test('roles the package assigns and the theme never reads are the known ones', () => {
+ expect(actual.unusedRoles.capability.map((r) => r.role)).toEqual(baseline.unusedRoles.capability);
+ expect(actual.unusedRoles.stale.map((r) => r.role)).toEqual(baseline.unusedRoles.stale);
+});
+
+/*
+ * The coverage numbers come from repositories outside this one, so the tool cannot recompute them.
+ * They are banked with their measurement date instead, and this only holds their shape - the point
+ * is that a lever without a measured size or a stated cost is a suggestion, not a plan.
+ */
+test('every coverage lever is measured and costed', () => {
+ const bad = baseline.coverage.levers
+ .filter((l: Record) => !l.id || !l.lever || typeof l.covers !== 'number' || !l.detail || !l.needs)
+ .map((l: Record) => l.id ?? '(без id)');
+ expect(bad).toEqual([]);
+ expect(baseline.coverage.floor.declarations).toBeGreaterThan(0);
+});
+
+/*
+ * The pages are the deliverable, and a generated file regenerated only when somebody remembers will
+ * eventually disagree with the data it claims to show - which already happened, with a hardcoded
+ * "39%" next to a computed 226 of 714. So staleness is a red test rather than a habit.
+ */
+test('the decision pages match the data they are generated from', () => {
+ const pages = join(packageRoot, 'tools', 'review', 'roles-pages.mjs');
+ let output = '';
+ try {
+ output = execFileSync('node', [pages, '--check'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
+ } catch (error) {
+ const details = `${(error as { stderr?: string }).stderr ?? ''}`.trim();
+ throw new Error(details || 'roles-pages.mjs --check failed');
+ }
+ expect(output).toContain('совпадают');
+});
diff --git a/packages/devextreme-scss/tools/review/roles-pages.mjs b/packages/devextreme-scss/tools/review/roles-pages.mjs
new file mode 100644
index 000000000000..64684445b713
--- /dev/null
+++ b/packages/devextreme-scss/tools/review/roles-pages.mjs
@@ -0,0 +1,549 @@
+/*
+ * The decision pages: every question the roles audit leaves for a person, as standalone HTML.
+ *
+ * node tools/review/roles-pages.mjs # → scss/widgets/fluent-next/ROLES_*.html
+ *
+ * Three audiences, three pages, one source - `node tools/review/roles.mjs --json` plus the decisions
+ * banked in tests/roles.baseline.json, so a page cannot drift from the gate that holds the list.
+ * Every question carries a number so an answer can be given as "Д3 - вариант 2" without quoting it
+ * back. Nothing is filtered out: what needs no decision is listed too, with the reason, so the set
+ * is closed rather than curated.
+ */
+
+import { readFileSync, writeFileSync, existsSync } from 'fs';
+import { dirname, join, relative } from 'path';
+import { fileURLToPath } from 'url';
+import { execSync } from 'child_process';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const packageRoot = join(here, '..', '..');
+const themeDir = join(packageRoot, 'scss', 'widgets', 'fluent-next');
+const base = JSON.parse(readFileSync(join(packageRoot, 'tests', 'roles.baseline.json'), 'utf8'));
+const data = JSON.parse(execSync('node tools/review/roles.mjs --json', {
+ cwd: packageRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
+}));
+
+/*
+ * --check makes a stale page a red test instead of something to remember. The pages are the
+ * deliverable, and a generated file that is only regenerated when somebody thinks of it will
+ * eventually disagree with the data it claims to show - which already happened once, when a
+ * hardcoded "39%" sat next to a computed 226 of 714.
+ */
+const checkOnly = process.argv.includes('--check');
+const stale = [];
+const emit = (name, html) => {
+ const path = join(themeDir, name);
+ if (!checkOnly) { writeFileSync(path, html); console.log(name); return; }
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
+ if (current !== html) stale.push(name);
+};
+
+const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
+const code = (s) => `${esc(s)}`;
+const roleList = (rs) => rs.map((r) => code(r.replace(/^color-/, ''))).join(' · ');
+
+const CSS = `
+:root { color-scheme: light dark; --fg:#161616; --bg:#fff; --muted:#616161; --line:#e1e1e1;
+ --accent:#0f6cbd; --warn:#c50f1f; --panel:#f8f8f8; }
+@media (prefers-color-scheme: dark) { :root { --fg:#f5f5f5; --bg:#242424; --muted:#a1a1a1;
+ --line:#4c4c4c; --accent:#4b90d9; --warn:#e4554f; --panel:#1d1d1d; } }
+* { box-sizing: border-box; }
+body { margin:0; padding:2.5rem 1.5rem 6rem; background:var(--bg); color:var(--fg);
+ font:15px/1.6 "Segoe UI", system-ui, sans-serif; }
+main { max-width: 62rem; margin: 0 auto; }
+h1 { font-size:1.9rem; font-weight:600; margin:0 0 .3rem; letter-spacing:-.01em; }
+h2 { font-size:1.3rem; font-weight:600; margin:3rem 0 .6rem; padding-top:1.2rem;
+ border-top:2px solid var(--line); }
+h3 { font-size:1.05rem; font-weight:600; margin:2rem 0 .5rem; }
+p, li { margin:.5rem 0; }
+.lede { color:var(--muted); margin-bottom:2rem; }
+table { border-collapse:collapse; width:100%; margin:.9rem 0; font-size:.92em; }
+th, td { border:1px solid var(--line); padding:.45rem .6rem; text-align:left; vertical-align:top; }
+th { background:var(--panel); font-weight:600; }
+code { font:.88em ui-monospace, "Cascadia Code", Menlo, monospace;
+ background:var(--panel); padding:.1em .35em; border-radius:3px; }
+.q { border:1px solid var(--line); border-left:3px solid var(--accent); border-radius:4px;
+ padding:.9rem 1.1rem; margin:1.1rem 0; background:var(--panel); }
+.q > .id { font-weight:600; color:var(--accent); font-size:.85em; letter-spacing:.06em;
+ text-transform:uppercase; display:block; margin-bottom:.25rem; }
+.q > .t { font-weight:600; margin-bottom:.4rem; }
+.q .opt { margin:.5rem 0 0 0; padding-left:1.2rem; }
+.warn { color:var(--warn); font-weight:600; }
+.swatch { display:inline-block; width:.85em; height:.85em; border:1px solid var(--line);
+ border-radius:2px; vertical-align:-.1em; margin-right:.3em; }
+.meta { color:var(--muted); font-size:.88em; }
+.none { color:var(--muted); font-style:italic; }
+footer { margin-top:4rem; padding-top:1rem; border-top:1px solid var(--line);
+ color:var(--muted); font-size:.85em; }
+`;
+
+const page = (title, bodyHtml) => `
+
+
+${esc(title)}
+${bodyHtml}
+
+
+`;
+
+const q = (id, title, bodyHtml) => `
${esc(id)}
+
${title}
${bodyHtml}
`;
+
+const swatch = (hex) => (/^#[0-9a-f]{3,8}$/i.test(hex ?? '') ? `` : '');
+const pair = (light, dark) => `${swatch(light)}${code(light ?? '?')} / ${swatch(dark)}${code(dark ?? '?')}`;
+
+
+// ---------------------------------------------------------------------------------------------
+// страница 1 — вопросы, которые решает человек
+// ---------------------------------------------------------------------------------------------
+
+const find = (name) => data.findings.find((f) => f.name === name);
+const values = (name) => {
+ const f = find(name);
+ return f?.swap?.ours ? pair(f.swap.ours.light, f.swap.ours.dark) : '';
+};
+
+
+/*
+ * Русский текст вопросов. Базлайн остаётся английским - он читается гейтом и живёт рядом с кодом,
+ * - а страница уходит дизайну, поэтому текст здесь. Дублирование удерживается проверкой ниже:
+ * запись без перевода роняет генерацию, а не выходит на страницу по-английски.
+ */
+const RU = {
+ 'gallery-nav-button-bg': 'Content-роль использована как подложка под навигационными кнопками поверх картинок. Bg-роли с таким значением в пакете нет, а сам пакет красит этой ролью глиф стрелки, а не диск под ним. Вопрос: нужна ли отдельная роль затемняющей подложки, или диск берёт существующую bg-роль и меняет тон.',
+ 'gallery-nav-button-bg-disabled': 'Тот же вопрос о подложке, неактивное состояние.',
+ 'switch-on-border-focused': 'Два соседа по трио (покой и наведение) уже переведены на border-роли равнозначно. Третий не переведён: border-primary-shared-active совпадает в светлом и двигает тёмный — #003c70 → #005397. Вопрос: принимаем сдвиг ради однородности трио.',
+ 'tree-view-checkbox-border-disabled': 'Content-роль красит border-color. Пакет для чекбокса разводит их: рамке неактивного состояния он даёт border-disabled (#d7d7d7 / #4c4c4c), заметно светлее нашего #ababab / #767676. Это прямой ответ на вопрос из журнала: 06.08 одно значение разложили на три роли и записали, что совпадение «видно в коде» — вот чем оно должно было разойтись.',
+ 'accordion-title-bg': 'Наведение и нажатие читают одну роль: нажать на заголовок аккордеона выглядит ровно как навести. Fluent 2 здесь однозначен — colorSubtleBackgroundHover #f5f5f5 и colorSubtleBackgroundPressed #e0e0e0 у него разные токены, и пакет несёт оба значения как bg-hovered / bg-active. Не применили только потому, что приведение уводит пиксель от legacy-fluent, а это решение продукта (NFR-1).',
+ 'tile-view-bg': 'Покой и наведение — одно значение, а нажатие отличается. Слот явно задуман с лестницей, плоская у него ступень наведения. Доказательство слабее, чем у аккордеона: плитка не обязана вести себя как «subtle surface» из Fluent 2.',
+ '.dx-splitter .dx-resize-handle': 'Грип ресайза даёт 2.8 в тёмном при пороге 3:1 для нетекстового элемента управления. Проходящая роль есть — content даёт 4.54, — но она же делает грип заметно темнее в светлом (6 → 11.15). Пол задаёт стандарт, выбор роли — нет.',
+ 'tabs-tab-border-disabled': 'Единственный член собственной лестницы не на border-роли: selected-active, selected-hovered, selected-focused, active и hovered читают border-*, и только disabled — content-disabled. Варианты: ① border-disabled — лестница становится согласованной, индикатор бледнеет с #ababab / #767676 до #d7d7d7 / #4c4c4c, что для неактивного состояния и ожидается; ② оставить и записать как осознанное исключение. Рекомендация — ①: это следование собственной лестнице, а не смена вкуса.',
+ 'load-indicator-segment-inner-border': 'Внутренняя рамка лоад-индикатора красится bg-primary-subtle, потому что border-роли с этой насыщенностью в пакете не существует. Менять не на что — нужна роль border-primary-subtle.',
+ '.dx-messagelist-context-menu-content .dx-menu-item:has(.dx-icon-trash).dx-state-focused': 'Пункт удаления в контекстном меню списка сообщений даёт 3.05 при пороге 4.5 для подписи. Проверены все роли семейства: самая сильная, content-danger-hovered, даёт на той же поверхности 3.87 — тоже провал. Нет danger-content роли, проходящей AA на своей же наведённой поверхности в тёмном.',
+};
+const ruText = (key) => {
+ const text = RU[key];
+ if (!text) throw new Error(`нет русского текста для "${key}" - добавьте в RU в tools/review/roles-pages.mjs`);
+ return text;
+};
+
+/*
+ * Постоянные номера из базлайна. Считать их от позиции нельзя: закрытая запись сдвигала все
+ * следующие, и ссылка «Д8 — вариант 1» через день указывала на другой вопрос. Новая запись без
+ * номера роняет генерацию, а не получает чужой.
+ */
+const idOf = (key) => {
+ const id = base.questionIds?.map?.[key];
+ if (!id) {
+ throw new Error(`нет постоянного номера для "${key}" - перевыдайте номера в tests/roles.baseline.json `
+ + `(questionIds.map, следующий свободный ${base.questionIds?.next ?? '?'})`);
+ }
+ return id;
+};
+let n = 0;
+const num = (prefix) => `${prefix}${++n}`;
+
+// --- A. дизайн: роль выбрана спорно
+n = 0;
+const design = base.open.filter((x) => x.decision === 'design');
+const roleQs = design.map((x) => {
+ const f = find(x.name);
+ const near = (f?.near ?? []).slice(0, 2)
+ .map((c) => `${code(c.role.replace(/^color-/, ''))} — двигает ${c.moves.join(' и ')}`).join(' ');
+ return q(idOf(`open:${x.name}`), `${code(x.name)} = ${roleList(x.roles)}`,
+ `
Значение сейчас: ${values(x.name) || '—'}
`
+ + `
${ruText(x.name)}
`
+ + (near ? `
Ближайшие роли верного семейства: ${near}
` : ''));
+});
+
+const ladderQs = base.ladders.filter((x) => x.decision === 'design').map((x) => q(idOf(`ladder:${x.stem}`),
+ `${code(x.stem)} — состояния ${x.states.map((s2) => code(s2)).join(' = ')} дают одну роль ${roleList(x.role)}`,
+ `
Тема красит фокус ролью границы border-primary-shared. Fluent 2 рисует фокус '
+ + 'двухтонной обводкой — colorStrokeFocus1 #ffffff внутри '
+ + 'colorStrokeFocus2 #000000, — чтобы она выживала на любом фоне. Это не оттенок в '
+ + 'сторону, а другой механизм. Вариантов inverted и static у темы нет вовсе.
'),
+ q(num('Н'), 'Интент info не используется',
+ `
Пакет назначает ${roleList(['color-bg-info', 'color-content-info', 'color-border-info'])} и их состояния. `
+ + 'Тема не читает ни одной: компоненты с модификатором info (informer, toast, pagination) '
+ + 'красят его нейтральными ролями.
'),
+ q(num('Н'), 'Лестница «на цветной поверхности» (on-color) не используется',
+ `
${ncCount} цветовых объявлений из ${data.summary.declarations} — ${ncPct}% темы — сравнивать не с чем.
+Это компоненты, которых нет ни у core, ни у vnext, ни у blazor, ни у wpf в пакете токенов. По ним
+работали только проверки темы против себя самой: семейство, слот против свойства, лестницы состояний,
+контраст и согласованность понятий между компонентами.
Чем это сокращается — измерено ${esc(cov.measuredOn)}
+
Числа сняты по репозиториям за пределами этого, поэтому инструмент их не пересчитывает: они
+забанкованы вместе с источником и протухнут заметно, если прочитать их рядом со свежим счётчиком выше.
Ниже этого не опускается: ${cov.floor.declarations} объявлений в папках
+${cov.floor.folders.map((f) => `${esc(f)}`).join(', ')}. ${esc(cov.floor.why)}
+${cov.rejected.map((r) => `
Померено и отброшено. ${esc(r.lever)}: ${esc(r.why)}
`).join('')}
+
+
Оговорка о самой проверке
+
Правки инструмента шли в одну сторону — к меньшему числу находок: конфликтов семейств 11 → 3,
+cross-family 24 → 15, кнопочных лестниц 8 → 0. Каждое сокращение проверено вручную и описано в
+коммите, но направление у них одно: скорее недосчитал, чем перебрал.
+`;
+
+
+// --- З. согласны ли соседи между собой
+const na = base.neighbourAgreement;
+const agreementSection = `
+
Весь аудит сравнивает наши роли с чужими, поэтому он стоит ровно столько, сколько стоит
+согласованность самих соседей. Замер ${esc(na.measuredOn)} по четырём наборам пакета: сравнивались
+только слоты, у которых полностью совпадает путь анатомии — компонент плюс всё после
+color..
+
Там, где они говорят об одном и том же одними словами, они согласны.
+${na.comparableSlots} сравнимых слотов, роль совпадает у ${na.agreeing}
+(${(na.agreeing / na.comparableSlots * 100).toFixed(0)}%).
+
Пара
Общих слотов
Совпадает
+${na.pairs.map((x) => `
${esc(x.pair)}
${x.shared}
${x.agree}%
`).join('')}
+
+
core и vnext — практически один набор (vnext = core плюс field), поэтому их
+100% ничего не доказывают. Значимы пары с blazor и wpf. У пары blazor ↔ wpf всего три общих слота —
+это не выборка.
+
+
Но одними словами они почти ничего не описывают. Пересечение анатомии, а не ролей, —
+вот что расходится:
+
Компонент
Наборы
Путей всего
Общих для всех
+${na.anatomyOverlap.map((x) => `
${esc(x.component)}
${esc(x.sets)}
`
+ + `
${x.paths}
${x.sharedByAll === 0 ? '0' : x.sharedByAll}
`).join('')}
+
+
У button четыреста один путь анатомии на четыре продукта и ноль общих для всех
+четырёх. Каждый моделирует свои варианты, суб-элементы и состояния.
+
+
Расхождений всего ${na.disagreeing}, и ${na.staleNoneSpelling} из них — не расхождения.
+Blazor запинен на 262.9.1 и всё ещё пишет bg-none / border-none /
+content-none там, где в действующем слое одна роль none. Настоящих
+остаётся ${na.realDisagreements}:
+${na.realExamples.map((x) => `
${esc(x.what)}. ${esc(x.detail)}
`).join('')}
+
+
Что из этого следует для самой проверки
+
${esc(na.conclusion)}
+
Отсюда и устройство сравнения: оно на уровне слота, а не пути. Сравнение по полному пути
+нашло бы почти ничего — наша анатомия не совпадает с чужой ровно так же, как их анатомии не
+совпадают между собой. Цена этого выбора честная: инструмент отвечает на вопрос «использует ли пакет
+эту роль для слота такого рода в этом компоненте», а не «использует ли он её именно здесь». Поэтому
+cross-family сформулирован как вопрос, а не как вердикт.
+`;
+
+
+// --- почему контраст не роняет CI
+const wc = base.whyContrastDoesNotFailCi;
+const whyContrast = `
+
почему это не ловит CI
+
Тёмный a11y-прогон существует, и color-contrast в нём включён — но ни одна из
+строк выше его не роняет. Причины проверены по исходнику axe-core 4.12.1, а не предположены:
Порог 3:1 для границ элементов управления и графики
+
Его не реализует ни одно правило axe и не видит ни один скриншот — в CI его не меряет никто.
+Замер ${esc(nt.measuredOn)}, и он появился как проверка того, стоит ли действовать по строке грипа выше.
+
Что
Пара
Светлый
Тёмный
+${nt.findings.map((f) => {
+ const bad = (v) => (v < 3 ? `${v}` : v);
+ return `
${esc(f.what)}
${code(f.pair)}
${bad(f.light)}
${bad(f.dark)}
`;
+ }).join('')}
+
+${nt.findings.map((f) => `
${esc(f.what)}: ${esc(f.verdict)}
`).join('')}
+
${esc(nt.consequence)}
+
Оговорка: ${esc(nt.comment[3])}
+`;
+
+const questionsPage = page('Fluent-next: открытые вопросы по ролям', `
+
Fluent-next: открытые вопросы по ролям
+
Всё, что аудит нашёл и не стал решать сам. Ответы можно давать номерами: «Д3 — второй вариант».
+Проверено ${data.summary.declarations} цветовых объявлений в 64 папках; применено девять правок —
+семь равнозначных по значению и две решённые порогом WCAG. Всё на этой странице двигает пиксель,
+меняет публичное имя или требует расширения пакета.
Одно понятие покрашено по-разному в разных компонентах
${conceptRows.join('')}
+
+
Б. Команда пакета токенов — ${pkgQs.length} заявки
+
Роли, которая нужна, в пакете нет — обменять не на что.
${pkgQs.join('')}
+
+
В. Унификация, правка бесплатна — ${spellQs.length}
+
Компоненты кладут один и тот же цвет и пишут его ролями из разных семейств. Пока каноническая
+запись не выбрана, следующая перепривязка палитры разведёт их молча.
${spellQs.join('')}
+
+
Г. Переименование компонентного тира — ${renames.length} имён
+
Роль верна, врёт слово в имени.
+
Окно открыто: компонентный тир fluent-next ещё не отгружен, поэтому переименования и
+удаления сейчас бесплатны — ни ченджлога, ни цикла устаревания. После релиза каждое такое имя
+становится контрактом с приложениями, и та же правка будет стоить депрекации. Это довод сделать
+волну до выпуска, а не после.
+
Не путать с легаси-38: --dx-toolbar-height,
+--dx-font-size-heading-* и ещё пятнадцать имён отгружены с 25.2 и решением 27.08.2026
+заморожены — их это окно не касается.
+
Имя
Слот обещает
Красит
+${renames.map((x) => `
${code(x.name)}
${esc(x.slotSays)}
${x.paints.map(esc).join(', ')}
`).join('')}
+
+
Четырнадцать из ${renames.length} — filterBuilder: его чипы уходят в базовый
+button-color(), который ставит фон, а названы они -content.
+
+
Д. Возможности пакета, которыми тема не пользуется — ${data.unusedRoles.capability.length} ролей
+
Счёт от пакета внутрь, а не от наших объявлений наружу: целое семейство может отсутствовать, и при
+этом ни одно объявление не выглядит неверным. Из ${data.summary.rolesOffered} ролей, которые
+назначают четыре набора, тема читает ${data.summary.rolesRead}.
+${unusedQs.join('')}
+Полный список ${data.unusedRoles.capability.length} ролей
+
Роль
Назначают
+${data.unusedRoles.capability.map((u) => `
${code(u.role.replace(/^color-/, ''))}
${u.sets.join(', ')}
`).join('')}
+
+
+
Е. Решения не требуется — записано, чтобы не переоткрывали
+
Класс
Сколько
Почему закрыто
+${noAction.map(([k, c, why]) => `
${code(k)}
${c}
${esc(why)}
`).join('')}
+
+
+
Ж. Чего эта проверка не видела, и как это сократить
+${coverageSection}
+
+
З. Согласны ли соседи между собой
+${agreementSection}
+`);
+
+emit('ROLES_QUESTIONS.html', questionsPage);
+
+// ---------------------------------------------------------------------------------------------
+// страница 2 — типографика
+// ---------------------------------------------------------------------------------------------
+
+const px = (rem) => (typeof rem === 'string' && rem.endsWith('rem') ? `${parseFloat(rem) * 16}px` : String(rem));
+const offGrid = data.typography.filter((t) => !t.roles.length);
+const onGrid = data.typography.filter((t) => t.roles.length);
+
+const byStep = new Map();
+for (const t of offGrid) {
+ const k = `${t.family}-${t.step}`;
+ if (!byStep.has(k)) byStep.set(k, []);
+ byStep.get(k).push(t);
+}
+const order = ['font-weight', 'font-size', 'line-height'];
+const stepGroups = [...byStep.entries()].sort((a, b) => order.indexOf(a[1][0].family) - order.indexOf(b[1][0].family)
+ || b[1].length - a[1].length);
+
+// одно исключение: имя файла это данные, а не заголовок
+const WEIGHT_400 = new Set(['file-uploader-file-name-font-weight']);
+
+n = 0;
+const weightPlaces = byStep.get('font-weight-500') ?? [];
+const weightQ = q(num('Т'), `font-weight: 500 — ${weightPlaces.length} мест`,
+ '
Ближайшие роли: 400 (Regular) и 600 (Semibold). Промежуточного веса у Fluent 2 нет, '
+ + 'и у Segoe UI грани 500 тоже нет — на Windows эти места уже сегодня рендерятся как Regular 400, '
+ + 'просто непредсказуемо по платформам.
'
+ + '
Почти всё это заголовки групп, метки и подписи, то есть элементы с усилением. Колонка '
+ + '«предложение» — наша рекомендация, не решение.
${WEIGHT_400.has(t.variable) ? '400 — имя файла это данные, не заголовок' : '600'}
`).join('')
+ + '
');
+
+const headingQ = q(num('Т'), 'Рампа заголовков — 4 места, следствие на все приложения',
+ '
Самое дорогое решение: --dx-font-size-heading-1…6 — публичные переменные, их читают приложения.
'
+ + '
h1
h2
h3
h4
h5
h6
'
+ + '
сейчас, default
40 ✓
36
32 ✓
'
+ + '
26
22
22
'
+ + '
сейчас, compact
32 ✓
28 ✓
24 ✓
20 ✓
16 ✓
16 ✓
'
+ + '
Fluent 2 / сетка ролей
40
32
28
24
20
16
'
+ + '
✓ — значение уже лежит на роли.
'
+ + '
Compact-ветка целиком на сетке, а default — нет. У default совпадают только h1 и h3. '
+ + 'Если привести default к той же логике, что уже действует в compact, получится ровно ряд Fluent 2: '
+ + '40 / 32 / 28 / 24 / 20 / 16. Цена: h2 36→32, h4 26→24, h5 22→20, h6 22→16 — последнее самое заметное.
'
+ + '
Отдельно: $typography-s-font-size (18px) формально попадает в список, но это '
+ + 'утилита.dx-font-sm, а не текстовая роль — в коде так и написано, что ряд xl/l/m/s/xs '
+ + 'намеренно сидит на базовой шкале. Трогать не предлагаем.
Оговорка: шкала межстрочного у пакета не полностью повторяет Fluent 2 — у Fluent 2 есть 22 и 26, '
+ + 'у пакета вместо них 24 и 28. Наши значения 12 и 18 не встречаются ни там, ни там.
У большинства межстрочное меньше самой низкой роли. Три из них — аппойнтменты scheduler '
+ + 'на 10 и 15 минут, где высота строки прижата к высоте ячейки: там 12→14 может не поместиться, это надо '
+ + 'смотреть на макете, а не решать по таблице.
Здесь значение не меняется: роль, называющая эту ступень, резолвится в неё же. Перевод '
+ + 'равнозначен по построению. Решить нужно только, какая роль — на одной ступени их бывает '
+ + 'несколько (caption / base / title), и это выбор смысла, а не значения.
'
+ + `
${unmarked.length} из них не несут даже маркера: гейт px-audit смотрит только `
+ + 'на литералы, а чтение ступени — не литерал, поэтому они не доезжали ни до SCALES.md, ни до дизайна.
Материал к design#1555.
+${offGrid.length} мест вне сетки и ещё ${onGrid.length}, где роль есть, а тема читает ступень.
+Типографика не зависит от режима — у каждого места одно значение, светлая и тёмная темы одинаковы.
+
+
Главное: карточка сформулирована в обратную сторону
+
design#1555 спрашивает, «каким ступеням нужны семантические роли». Сверка с Fluent 2 показывает,
+что вопрос не к пакету:
+
+
веб-рампа Fluent 2 знает три начертания — Regular, Semibold, Bold. Medium (500) в ней нет;
+
её размеры — 10 / 12 / 14 / 16 / 20 / 24 / 28 / 32 / 40 / 68 px, и сетка ролей пакета
+повторяет их точно, кроме Display 68.
+
+
Ни font-weight: 500, ни размеры 11 / 18 / 22 / 26 / 30 / 36 px в Fluent 2 не существуют.
+Пакет верен источнику. Все эти места — значения, унаследованные от legacy-темы fluent, которые
+миграция сохранила по требованию «визуально это тот же fluent» (NFR-1).
+
Решать нужно не «расширять ли пакет», а что важнее в каждом месте: совпадение с legacy или
+соответствие Fluent 2.
Правки механические, значения меняются ровно в перечисленных строках, режимы не расходятся.
+После них потребуется пересъёмка эталонов скриншотов затронутых компонентов — отдельный проход,
+а не «заодно».
+`);
+
+emit('ROLES_TYPOGRAPHY.html', typoPage);
+
+if (checkOnly) {
+ if (!stale.length) console.log('страницы совпадают с данными');
+ else {
+ console.error(`страницы устарели: ${stale.join(', ')}`);
+ console.error('перегенерируйте: node tools/review/roles-pages.mjs');
+ process.exit(1);
+ }
+}
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
new file mode 100644
index 000000000000..83f420f949b4
--- /dev/null
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -0,0 +1,1009 @@
+/*
+ * The roles report: which semantic role every colour slot of the theme reads, and what the token
+ * package's component tier says the same slot should read.
+ *
+ * node tools/review/roles.mjs # → scss/widgets/fluent-next/ROLES.md
+ * node tools/review/roles.mjs --md # markdown to stdout
+ * node tools/review/roles.mjs --json # machine-readable, for the gate
+ *
+ * Nothing else checks the CHOICE of role. The naming enforcer checks the name, the resolve diff
+ * checks that a value did not move, the reachability audit checks delivery, the screenshots check
+ * the cascade. A role that is wrong but plausible passes all five and surfaces only when the
+ * palette is re-anchored or in dark mode, where two roles that share a primitive in light diverge.
+ *
+ * Two independent signals, because neither alone is enough:
+ *
+ * 1. family — a `-bg` slot must read a color-bg-* role, `-content` a color-content-* one, and so
+ * on. Needs no package. This is the class that produced the danger/success/warning
+ * fix and the gallery nav-disc defect (bg-disabled on a content slot: white on white).
+ * 2. package — `@devexpress/design-tokens-internal/tokens/components/{core,vnext,blazor,wpf}` IS
+ * the role assignment design made, for four products. The theme does not consume it
+ * (decision 06.08.2026), and that is exactly why it reads as a reference: measured on
+ * 262.16.0 its colour leaves are ~99% plain references to the semantic roles, so it
+ * carries the mapping and no value of its own.
+ *
+ * The package comparison is deliberately slot-level, not path-level. Our anatomy and the package's
+ * do not line up segment by segment - 86 folders against 44 components, different sub-element trees
+ * - and a table that pretended they did would be wrong more often than the roles it checks. So for
+ * each component we cross-tabulate role -> slots as the package uses them, and ask one question:
+ * is our role one the package uses in our slot? A "no" is a question, not a verdict; the report
+ * names the slots the package does use it for, which is usually the answer.
+ */
+
+import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
+import { join, dirname, relative } from 'path';
+import { fileURLToPath } from 'url';
+import { createRequire } from 'module';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const packageRoot = join(here, '..', '..');
+// --theme= lets the gate run the same code over a synthetic tree, so a green gate means "nothing
+// to find" rather than "the scan matched nothing".
+const themeArg = process.argv.find((a) => a.startsWith('--theme='));
+const themeDir = themeArg ? themeArg.slice('--theme='.length) : join(packageRoot, 'scss', 'widgets', 'fluent-next');
+const registries = JSON.parse(readFileSync(join(packageRoot, 'tools', 'naming', 'registries.json'), 'utf8'));
+
+const require = createRequire(import.meta.url);
+const tokensRoot = dirname(require.resolve('@devexpress/design-tokens-internal/package.json'));
+const tokensVersion = JSON.parse(readFileSync(join(tokensRoot, 'package.json'), 'utf8')).version;
+
+const SETS = ['core', 'vnext', 'blazor', 'wpf'];
+const MODES = ['light', 'dark'];
+
+const leavesOf = (node, trail = []) => Object.entries(node ?? {}).flatMap(([key, value]) => {
+ if (key.startsWith('$') || !value || typeof value !== 'object') return [];
+ if ('$value' in value) return [[[...trail, key].join('.'), value.$value]];
+ return leavesOf(value, [...trail, key]);
+});
+
+/*
+ * Theme folder -> the package components that describe the same thing, most authoritative first.
+ * A judgment call per line, so the list is explicit rather than derived: `chat` is the package's
+ * `ai-chat`, all three grids are its single `grid`, and our chassis folders (textEditor, gridBase)
+ * map to the component the package models, not to a widget name. A folder that is absent here has
+ * no counterpart worth comparing - the report says so instead of guessing.
+ *
+ * Six were added 09.09 after asking whether the gap was real or just my spelling: cardView is the
+ * package's `grid`, the way tools/review/package-disabled.mjs has always mapped it; a speed-dial
+ * action is a button; a lookup is a listbox; an action sheet is a popup. Name similarity was doing
+ * the matching before, and it does not survive contact with another product's vocabulary.
+ */
+const COMPONENT = {
+ accordion: ['accordion'],
+ actionSheet: ['popup', 'menu-list'],
+ buttonGroup: ['button-group', 'button'],
+ cardView: ['grid', 'container-card'],
+ lookup: ['listbox', 'text-input'],
+ speedDialAction: ['button'],
+ validation: ['field', 'message-bar'],
+ badge: ['badge'],
+ button: ['button', 'custom-button'],
+ calendar: ['calendar'],
+ card: ['container-card', 'popover-card'],
+ chat: ['ai-chat'],
+ checkBox: ['checkbox'],
+ colorBox: ['color-palette'],
+ colorView: ['color-palette'],
+ common: ['focus-rect', 'backdrop', 'separator', 'skeleton', 'empty-item'],
+ contextMenu: ['menu-list'],
+ dataGrid: ['grid'],
+ dateBox: ['text-input', 'button-edit'],
+ drawer: ['drawer'],
+ dropDownButton: ['split-button'],
+ fieldset: ['field'],
+ form: ['form'],
+ gallery: ['gallery'],
+ gridBase: ['grid'],
+ informer: ['message-bar'],
+ list: ['listbox'],
+ loadIndicator: ['spinner', 'loading-indicator', 'waitIndicator'],
+ loadPanel: ['loading-panel'],
+ menu: ['menu'],
+ menuBase: ['menu', 'menu-list'],
+ numberBox: ['text-input'],
+ pagination: ['pagination', 'pager'],
+ popover: ['popover', 'popover-card', 'popover-arrow'],
+ popup: ['popup', 'message-box'],
+ progressBar: ['progress-bar'],
+ radioButton: ['radio-button', 'radio'],
+ radioGroup: ['radio-button', 'radio'],
+ scrollable: ['scroll-bar'],
+ scrollView: ['scroll-view', 'scroll-bar'],
+ selectBox: ['text-input'],
+ splitter: ['splitter'],
+ switch: ['switch', 'switcher', 'toggle-switch'],
+ tabPanel: ['tabs', 'tab-control'],
+ tabs: ['tabs', 'tab-control'],
+ tagBox: ['tagbox', 'tag'],
+ textArea: ['text-area', 'memo'],
+ textBox: ['text-input', 'text-edit'],
+ textEditor: ['text-input', 'text-edit'],
+ toast: ['toast'],
+ toolbar: ['toolbar', 'ribbon', 'bars'],
+ tooltip: ['tooltip'],
+ treeList: ['grid'],
+ treeView: ['treeview', 'tree-view'],
+ typography: ['text-content', 'button-text'],
+};
+
+// slot -> the --dxds- colour family it has to read. null = the slot is genuinely two-sided (a thumb
+// can be filled or outlined), so the family signal says nothing and only the package can answer.
+/*
+ * Cross-cutting nodes: the package models a separator, a focus rect, a backdrop and a skeleton as
+ * components of their own, the way our system tier publishes them on :root rather than inside a
+ * widget. Every component is compared against these too, after its own, so `$menu-separator-bg`
+ * finds `separator.color` instead of reading as a menu background that borrowed a border role.
+ */
+/* A package component whose name IS the slot but is not spelled the way our grammar spells it.
+ * Until 09.09 the package's whole `focus-rect` component - four roles for the focus indicator -
+ * never entered the comparison, because neither `focus-rect.color.default` nor the name
+ * `focus-rect` matches any of our parts, so it was dropped as an unknown slot. */
+const COMPONENT_AS_SLOT = { 'focus-rect': 'outline', skeleton: 'bg', 'empty-item': 'content' };
+
+const SHARED = ['separator', 'focus-rect', 'backdrop', 'skeleton', 'empty-item', 'text-content', 'link'];
+
+const FAMILY = {
+ backdrop: 'bg', bg: 'bg', highlight: 'bg', scrim: 'bg', veil: 'bg',
+ caption: 'content', chevron: 'content', content: 'content', 'end-icon': 'content',
+ icon: 'content', placeholder: 'content', shortcut: 'content', 'start-icon': 'content',
+ subtitle: 'content', text: 'content', title: 'content',
+ border: 'border', line: 'border', outline: 'border', separator: 'border',
+ shadow: 'shadow', 'shadow-ambient': 'shadow', 'shadow-key': 'shadow',
+ grip: null, indicator: null, opacity: null, selector: null, thumb: null, track: null, trigger: null,
+};
+
+/*
+ * Slot kinship. The package splits what we deliberately keep together: `content` is our umbrella
+ * slot and `text` is reserved for elements that tokenise text and icon separately (NAMING.md), so
+ * the package painting our `content` as `text` is the documented naming divergence, not a finding.
+ * Comparison therefore runs twice - exact slot first, then kin - and only a role that crosses a
+ * family boundary is reported. `ambiguous` matches anything: a thumb or a track is legitimately
+ * either filled or outlined, so its family carries no claim.
+ */
+const KIN = {
+ bg: 'bg', backdrop: 'bg', scrim: 'bg', veil: 'bg', highlight: 'bg',
+ content: 'content', text: 'content', icon: 'content', 'start-icon': 'content',
+ 'end-icon': 'content', title: 'content', subtitle: 'content', caption: 'content',
+ placeholder: 'content', chevron: 'content', shortcut: 'content', trigger: 'content',
+ border: 'border', outline: 'border', separator: 'border', line: 'border',
+ shadow: 'shadow', 'shadow-ambient': 'shadow', 'shadow-key': 'shadow',
+ grip: 'ambiguous', indicator: 'ambiguous', opacity: 'ambiguous',
+ selector: 'ambiguous', thumb: 'ambiguous', track: 'ambiguous',
+};
+const kinOf = (slot) => KIN[slot] ?? 'ambiguous';
+const kindred = (a, b) => a === b || kinOf(a) === 'ambiguous' || kinOf(b) === 'ambiguous'
+ || kinOf(a) === kinOf(b);
+
+const PARTS = [...registries.parts].sort((a, b) => b.length - a.length);
+const STATES = [...registries.states].sort((a, b) => b.length - a.length);
+// `rest` is absence of a suffix in the theme and an explicit segment in the package; `disable` is a
+// vendor typo that ships in switch.color.checked.bg.disable.
+const PACKAGE_STATES = new Set([...registries.states, 'rest', 'disable']);
+
+const familyOf = (role) => {
+ if (/^(box-shadow|color-shadow)-/.test(role)) return 'shadow';
+ if (role === 'color-none' || role === 'none') return 'none';
+ return /^color-(bg|content|border)\b/.exec(role)?.[1] ?? 'other';
+};
+
+const trailing = (name, vocabulary) => {
+ for (const word of vocabulary) if (name === word || name.endsWith(`-${word}`)) return word;
+ return null;
+};
+
+/*
+ * Resolved values, per mode, read from the package rather than from a built bundle: the report has
+ * to answer "would this swap move a pixel" without waiting for a theme build, and the answer lives
+ * in the tokens. A swap that resolves identically in BOTH modes is free - a declaration fix with no
+ * etalon to re-shoot. One that moves only in dark is the case this whole report exists for: every
+ * screenshot etalon is .light, so nothing in CI can see it.
+ */
+const valueIndex = {};
+for (const mode of MODES) {
+ const map = new Map();
+ const collect = (dir) => {
+ for (const entry of readdirSync(dir)) {
+ const absolute = join(dir, entry);
+ if (statSync(absolute).isDirectory()) { collect(absolute); continue; }
+ if (!entry.endsWith('.json')) continue;
+ if (/material/.test(absolute)) continue;
+ // the mode files sit at semantic/colors//.json - keep only this mode's
+ if (/[\\/]colors[\\/]/.test(absolute) && /^(light|dark)\.json$/.test(entry) && entry !== `${mode}.json`) continue;
+ for (const [name, value] of leavesOf(JSON.parse(readFileSync(absolute, 'utf8')))) {
+ if (!map.has(name)) map.set(name, value);
+ }
+ }
+ };
+ for (const sub of ['base', 'global', 'semantic']) collect(join(tokensRoot, 'tokens', sub));
+ valueIndex[mode] = map;
+}
+const resolveRole = (role, mode, depth = 0) => {
+ const key = role.replace(/^color-/, 'color.');
+ const raw = valueIndex[mode].get(key) ?? valueIndex[mode].get(role);
+ if (raw === undefined) return null;
+ if (typeof raw !== 'string' || !raw.startsWith('{') || depth > 12) return String(raw).toLowerCase();
+ return resolveRole(raw.replace(/[{}]/g, ''), mode, depth + 1);
+};
+const sameValue = (a, b) => MODES.every((mode) => {
+ const va = resolveRole(a, mode);
+ const vb = resolveRole(b, mode);
+ return va !== null && vb !== null && va === vb;
+});
+
+// --- the theme side -----------------------------------------------------------------------------
+
+/*
+ * Block comments are blanked, not removed: dropping them shifts every line number after the first
+ * one in a file, and this whole report is line references. typography/_sizes.scss:69 was printed
+ * as :65 because of a four-line comment above it.
+ */
+const colourFiles = (dir) => readdirSync(dir).flatMap((entry) => {
+ const absolute = join(dir, entry);
+ if (statSync(absolute).isDirectory()) return colourFiles(absolute);
+ return entry === '_colors.scss' ? [absolute] : [];
+});
+
+const declarations = [];
+for (const file of colourFiles(themeDir)) {
+ const folder = relative(themeDir, file).split('/')[0];
+ const source = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ''));
+ source.split('\n').forEach((line, index) => {
+ if (/^\s*\/\//.test(line)) return;
+ const match = /^\s*\$([a-z0-9-]+)\s*:\s*(.+?)(?:\s*!default)?\s*;/.exec(line);
+ if (!match) return;
+ const [, name, value] = match;
+ const roles = [...value.matchAll(/ds\.\$([a-z0-9-]+)/g)].map((r) => r[1]);
+ if (!roles.length) return;
+ const state = trailing(name, STATES);
+ const bare = state ? name.slice(0, -state.length - 1) : name;
+ const slot = trailing(bare, PARTS);
+ /*
+ * A part word can also sit in the middle as a sub-element: `$menu-separator-bg` is slot `bg` on
+ * sub-element `separator`, and the package models exactly that as its own `separator` slot. So
+ * the comparison looks for the package's word among ours, not only at our last position -
+ * otherwise every `-bg` reads as a bg that borrowed a border role.
+ */
+ const middle = slot ? bare.slice(0, -slot.length).replace(/-$/, '') : bare;
+ const subElementSlots = PARTS.filter((part) => middle === part || middle.endsWith(`-${part}`)
+ || middle.startsWith(`${part}-`) || middle.includes(`-${part}-`));
+ declarations.push({
+ folder,
+ where: `${relative(packageRoot, file)}:${index + 1}`,
+ name,
+ slot,
+ subElementSlots,
+ state: state ?? 'rest',
+ roles,
+ bridged: /rgb\(\s*from/.test(value),
+ value: value.trim(),
+ });
+ });
+}
+
+// --- the package side ---------------------------------------------------------------------------
+
+const leaves = leavesOf;
+const _unusedLeaves = (node, trail = []) => Object.entries(node ?? {}).flatMap(([key, value]) => {
+ if (key.startsWith('$') || !value || typeof value !== 'object') return [];
+ if ('$value' in value) return [[[...trail, key].join('.'), value.$value]];
+ return leaves(value, [...trail, key]);
+});
+
+/* A package path is `..color...`, and only the part
+ * after `color.` describes the paint - `progress-bar.progress-line.color.indicator.…` would
+ * otherwise match `line` in the sub-element. The slot is the rightmost segment that is one of our
+ * parts, so a package slot we have no word for is reported rather than silently mapped. */
+const dissect = (path) => {
+ const segments = path.split('.');
+ const colourAt = segments.indexOf('color');
+ if (colourAt === -1) return null;
+ const tail = segments.slice(colourAt + 1);
+ const state = PACKAGE_STATES.has(tail.at(-1)) ? tail.pop() : 'rest';
+ for (let i = tail.length - 1; i >= 0; i -= 1) {
+ const slot = trailing(tail[i], PARTS);
+ if (slot) return { slot, state, variant: tail.slice(0, i).join('.') };
+ }
+ /* `separator.color` and `backdrop.color` carry no slot segment because the component IS the slot:
+ * the package models them the way our system tier publishes them, as a thing rather than a part
+ * of a thing. Without this they fall out of the comparison entirely, and every `-separator-border`
+ * in the theme reads as a border nobody named. */
+ const asSlot = COMPONENT_AS_SLOT[segments[0]] ?? trailing(segments[0], PARTS);
+ if (asSlot) return { slot: asSlot, state, variant: tail.join('.') };
+ return { slot: null, state, variant: tail.join('.') };
+};
+
+const packageTier = {}; // set -> component -> { bySlot, byRole, unknownSlots }
+for (const set of SETS) {
+ const file = join(tokensRoot, 'tokens', 'components', set, 'theme', 'fluent.json');
+ const components = JSON.parse(readFileSync(file, 'utf8'));
+ packageTier[set] = {};
+ for (const [component, tree] of Object.entries(components)) {
+ const bySlot = new Map();
+ const byState = new Map(); // slot -> state -> Set(role)
+ const byRole = new Map();
+ const unknownSlots = new Set();
+ for (const [path, raw] of leaves(tree)) {
+ if (typeof raw !== 'string' || !raw.startsWith('{')) continue;
+ const role = raw.replace(/[{}]/g, '').replace(/^(color|global\.color)\./, 'color-');
+ if (!role.startsWith('color-')) continue;
+ const anatomy = dissect(`${component}.${path}`);
+ if (!anatomy) continue;
+ if (!anatomy.slot) { unknownSlots.add(path); continue; }
+ if (!bySlot.has(anatomy.slot)) bySlot.set(anatomy.slot, new Set());
+ bySlot.get(anatomy.slot).add(role);
+ if (!byState.has(anatomy.slot)) byState.set(anatomy.slot, new Map());
+ const states = byState.get(anatomy.slot);
+ if (!states.has(anatomy.state)) states.set(anatomy.state, new Set());
+ states.get(anatomy.state).add(role);
+ if (!byRole.has(role)) byRole.set(role, new Set());
+ byRole.get(role).add(anatomy.slot);
+ }
+ packageTier[set][component] = { bySlot, byState, byRole, unknownSlots };
+ }
+}
+
+/*
+ * Typography: the same question, asked of the size files.
+ *
+ * The colour layer reads roles everywhere (0 direct palette reads). Typography does not: most of
+ * its step reads go straight to a base scale, because the value came from legacy fluent and the
+ * role grid has no step with that value - font-weight 500, font-size 110/180/220/260/360,
+ * line-height 120/180. Most carry `dx-no-semantic-role`; the rest carry no marker at all, because
+ * the px gate only looks at literals and a step read is not a literal.
+ *
+ * None of the four neighbours has this: their component sets reference the typography ROLES and a
+ * bare step three times in total. So a place here is not "the package is missing a role" by
+ * default - it is a choice between the legacy value and the design system's grid, and the report
+ * has to put both in front of whoever decides.
+ */
+const TYPOGRAPHY = ['font-size', 'font-weight', 'line-height'];
+
+const typographyGrid = {}; // family -> [{ role, step }], the steps the role grid actually names
+for (const family of TYPOGRAPHY) {
+ const roles = [];
+ for (const [name, raw] of valueIndex.light) {
+ if (!name.startsWith(`${family}.`)) continue;
+ const step = /^\{?([a-z-]+)\.(\d+)\}?$/.exec(String(raw));
+ if (!step) continue; // a role points at a step; a step points at a number
+ roles.push({ role: name.split('.')[1], step: Number(step[2]) });
+ }
+ typographyGrid[family] = roles.sort((a, b) => a.step - b.step);
+}
+
+const MARKERS = /dx-(no-semantic-role|icon-glyph-size|offscale|relative|px-nudge|literal-required|fixed-size|line-width|shadow-geometry)/;
+
+const typography = [];
+const sizeFiles = (dir) => readdirSync(dir).flatMap((entry) => {
+ const absolute = join(dir, entry);
+ if (statSync(absolute).isDirectory()) return sizeFiles(absolute);
+ return entry === '_sizes.scss' ? [absolute] : [];
+});
+for (const file of sizeFiles(themeDir)) {
+ const folder = relative(themeDir, file).split('/')[0];
+ readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, '')).split('\n').forEach((line, index) => {
+ if (/^\s*\/\//.test(line)) return;
+ const read = /ds\.\$(font-size|font-weight|line-height)-(\d+)/.exec(line);
+ if (!read) return;
+ const [, family, step] = read;
+ const grid = typographyGrid[family] ?? [];
+ const onGrid = grid.filter((r) => r.step === Number(step));
+ const nearest = [...grid]
+ .sort((a, b) => Math.abs(a.step - Number(step)) - Math.abs(b.step - Number(step)))
+ .slice(0, 3);
+ typography.push({
+ folder,
+ where: `${relative(packageRoot, file)}:${index + 1}`,
+ variable: /\$([a-z0-9-]+)\s*:/.exec(line)?.[1] ?? '(inline)',
+ family,
+ step: Number(step),
+ marker: MARKERS.exec(line)?.[1] ?? null,
+ roles: onGrid.map((r) => r.role),
+ nearest: onGrid.length ? [] : nearest.map((r) => ({ role: r.role, step: r.step })),
+ });
+ });
+}
+
+/*
+ * What each tier name actually paints, read out of the built bundle.
+ *
+ * The slot is supposed to encode the CSS property (NAMING.md: assigned in `color:` -> content, in
+ * `background-color` -> bg, in `border-color` -> border), and that is the one claim in the whole
+ * name that can be checked against ground truth instead of read. filterBuilder is why it is worth
+ * checking: fourteen `-content` variables reach base as `button-color($color, ...)`, which sets
+ * `background-color` - the roles were right all along and the names were not.
+ *
+ * Needs a built bundle; without one this half of the report is simply absent, the way the calc
+ * inventory in SCALES.md is.
+ */
+const PROPERTY_FAMILY = [
+ [/^(background|background-color|background-image)$/, 'bg'],
+ [/^(color|fill|caret-color|-webkit-text-fill-color)$/, 'content'],
+ [/(^|-)border(-|$)|^outline(-|$)|^stroke$|^border-color$/, 'border'],
+ [/shadow$/, 'shadow'],
+];
+const bundlePath = join(packageRoot, '..', 'devextreme', 'artifacts', 'css', 'dx.fluent-next.blue.light.css');
+const paints = new Map(); // --dx-name -> Set(css property)
+if (existsSync(bundlePath)) {
+ const css = readFileSync(bundlePath, 'utf8');
+ for (const [, property, value] of css.matchAll(/([a-z-]+)\s*:\s*([^;{}]*var\(--dx-[^;{}]*)/g)) {
+ for (const [, name] of value.matchAll(/var\(\s*(--dx-[a-z0-9-]+)/g)) {
+ if (!paints.has(name)) paints.set(name, new Set());
+ paints.get(name).add(property);
+ }
+ }
+}
+const familyOfProperty = (property) => PROPERTY_FAMILY.find(([re]) => re.test(property))?.[1] ?? null;
+
+// --- the comparison -------------------------------------------------------------------------------
+
+const findings = [];
+for (const declaration of declarations) {
+ const { slot, subElementSlots, roles, folder } = declaration;
+ const ourSlots = [slot, ...subElementSlots].filter(Boolean);
+ const record = { ...declaration, family: null, package: null };
+
+ // The family signal follows the CSS property, which is what the slot encodes (NAMING.md): a
+ // separator drawn with background-color is still painted by `bg`. Sub-elements steer the package
+ // comparison, not this one.
+ if (slot && FAMILY[slot]) {
+ const want = FAMILY[slot];
+ const got = [...new Set(roles.map(familyOf))].filter((f) => f !== 'none');
+ if (got.length && !got.includes(want)) record.family = { want, got, slot };
+ }
+
+ const candidates = COMPONENT[folder] ?? [];
+ if (!candidates.length) record.package = { verdict: 'no-counterpart' };
+ else if (!slot) record.package = { verdict: 'slot-unparsed' };
+ else {
+ const seen = [];
+ for (const set of SETS) {
+ for (const candidate of [...candidates, ...SHARED]) {
+ const tier = packageTier[set][candidate];
+ if (!tier) continue;
+ seen.push({ set, component: candidate, tier, own: candidates.includes(candidate) });
+ }
+ }
+ if (!seen.length) record.package = { verdict: 'no-counterpart' };
+ else {
+ const exact = [];
+ const kin = [];
+ /* Whether the package uses our role for another part OF THE SAME widget or only somewhere
+ * else entirely. The first is a word disagreement - the package calls the switch knob a
+ * `trigger` and paints it from a content role, exactly as we do, and only our slot says `bg`.
+ * The second is the one worth a second look. */
+ const sameComponent = new Set();
+ const crossFamily = new Map();
+ const slotRoles = new Set(); // roles the package uses for our slot, or a kin slot
+ for (const { set, component, tier, own } of seen) {
+ /*
+ * What the package offers HERE is gathered strictly: same family as our own slot, no
+ * sub-elements and no wildcard. `$popup-content-shadow-ambient` is a shadow that happens to
+ * live on the content area, and a scroll bar's thumb is ambiguous by design - letting
+ * either widen the candidate set turns a correct role into a conflict with roles that were
+ * never on offer. The lenient reading stays where it belongs: deciding whether our role
+ * already agrees with the package somewhere.
+ */
+ for (const [pkgSlot, pkgRoles] of tier.bySlot) {
+ if (kinOf(pkgSlot) !== kinOf(slot) || kinOf(slot) === 'ambiguous') continue;
+ for (const role of pkgRoles) slotRoles.add(role);
+ }
+ for (const role of roles) {
+ const usedIn = [...(tier.byRole.get(role) ?? [])];
+ if (!usedIn.length) continue;
+ if (usedIn.some((pkgSlot) => ourSlots.includes(pkgSlot))) exact.push(`${set}/${component}`);
+ else if (usedIn.some((pkgSlot) => ourSlots.some((ours) => kindred(pkgSlot, ours)))) kin.push(`${set}/${component}:${usedIn.join(',')}`);
+ else {
+ const key = usedIn.sort().join('|');
+ if (!crossFamily.has(key)) crossFamily.set(key, []);
+ crossFamily.get(key).push(`${set}/${component}`);
+ if (own) sameComponent.add(`${set}/${component}:${usedIn.join(',')}`);
+ }
+ }
+ }
+ const here = [...slotRoles].sort();
+ /* A role of the slot's own family used elsewhere for a different part is not a crossing - the
+ * package simply has not needed it here. Reserve `cross-family` for the case the name
+ * promises: the role belongs to another family than the slot paints with. */
+ const crosses = FAMILY[slot] && roles.some((role) => {
+ const family = familyOf(role);
+ return family !== 'none' && family !== FAMILY[slot];
+ });
+ if (exact.length) record.package = { verdict: 'agrees', where: [...new Set(exact)] };
+ else if (kin.length) record.package = { verdict: 'agrees-kin', where: [...new Set(kin)] };
+ else if (crossFamily.size && crosses) {
+ record.package = {
+ verdict: 'cross-family',
+ usedFor: [...crossFamily].map(([slots, where]) => ({ slots: slots.split('|'), where: [...new Set(where)] })),
+ sameComponent: [...sameComponent],
+ packageUsesHere: here,
+ };
+ } else if (crossFamily.size) {
+ record.package = { verdict: 'role-new', packageUsesHere: here };
+ } else if (here.length) {
+ const ourFamilies = new Set(roles.map(familyOf).filter((f) => f !== 'none'));
+ const theirFamilies = new Set(here.map(familyOf).filter((f) => f !== 'none'));
+ // Only `color-none` on offer is not a family to conflict with - the package simply paints
+ // nothing here, which says nothing about our role.
+ if (!theirFamilies.size) record.package = { verdict: 'slot-absent' };
+ else {
+ const shared = [...ourFamilies].some((f) => theirFamilies.has(f));
+ record.package = { verdict: shared ? 'role-new' : 'family-conflict', packageUsesHere: here };
+ }
+ } else record.package = { verdict: 'slot-absent' };
+ }
+ }
+ const painted = [...(paints.get(`--dx-${declaration.name}`) ?? [])].sort();
+ if (painted.length) {
+ const families = [...new Set(painted.map(familyOfProperty).filter(Boolean))];
+ record.paints = { properties: painted, families };
+ // The slot claims a family; the bundle says which one the property actually belongs to.
+ if (FAMILY[slot] && families.length && !families.includes(FAMILY[slot])) {
+ record.slotLies = { slotSays: FAMILY[slot], propertySays: families };
+ }
+ }
+
+ const here = record.package?.packageUsesHere ?? [];
+ if (here.length && roles.length === 1) {
+ const free = here.filter((candidate) => candidate !== roles[0] && sameValue(candidate, roles[0]));
+ const drift = MODES.filter((mode) => {
+ const ours = resolveRole(roles[0], mode);
+ return here.some((candidate) => resolveRole(candidate, mode) !== ours);
+ });
+ record.swap = { free, ours: Object.fromEntries(MODES.map((m) => [m, resolveRole(roles[0], m)])), drift };
+ if (!free.length) {
+ /* The role the package would have us use is often one step away and differs in a single mode.
+ * Naming it turns a diagnosis into a decision - and a candidate that moves dark only is the
+ * signature case of this report: no etalon can see it. */
+ const wanted = FAMILY[record.slot];
+ record.near = here
+ .filter((candidate) => !wanted || familyOf(candidate) === wanted)
+ .map((candidate) => ({
+ role: candidate,
+ moves: MODES.filter((mode) => resolveRole(candidate, mode) !== resolveRole(roles[0], mode)),
+ }))
+ .filter((candidate) => candidate.moves.length)
+ .sort((a, b) => a.moves.length - b.moves.length);
+ }
+ }
+ findings.push(record);
+}
+
+/*
+ * State ladders: does a state actually change the paint?
+ *
+ * Needs neither the package nor a bundle - it reads the theme against itself. A slot whose hovered
+ * and active resolve to one role has a state in the name that the eye cannot find, and the ladder
+ * the design system ships for that role is going unused. Two collapses are accepted convention and
+ * are named here rather than discovered every run: `focused` reuses `hovered` because the
+ * foundation has no focused state (DIVERGENCES), and a state that deliberately resets to the rest
+ * value is a reset, not a gap.
+ */
+const ACCEPTED_COLLAPSE = [['focused', 'hovered'], ['focused', 'active'], ['selected-focused', 'selected-hovered']];
+const acceptedPair = (a, b) => ACCEPTED_COLLAPSE.some(([x, y]) => (a === x && b === y) || (a === y && b === x));
+
+const ladders = [];
+{
+ const groups = new Map();
+ for (const declaration of declarations) {
+ if (!declaration.slot) continue;
+ const stem = declaration.state === 'rest'
+ ? declaration.name
+ : declaration.name.slice(0, -declaration.state.length - 1);
+ if (!groups.has(stem)) groups.set(stem, []);
+ groups.get(stem).push(declaration);
+ }
+ for (const [stem, members] of groups) {
+ if (members.length < 2) continue;
+ const byRole = new Map();
+ for (const member of members) {
+ const key = member.roles.join('+');
+ if (!byRole.has(key)) byRole.set(key, []);
+ byRole.get(key).push(member.state);
+ }
+ for (const [role, states] of byRole) {
+ if (states.length < 2) continue;
+ const pairs = states.flatMap((a, i) => states.slice(i + 1).map((b) => [a, b]));
+ if (pairs.every(([a, b]) => acceptedPair(a, b))) continue;
+ /* The precise question is about OUR role, not the package's anatomy: we paint two states
+ * from role R, so does the design system ship R for the second state? `bg-alpha-hovered`
+ * shared by hovered and active is a gap exactly when `bg-alpha-active` exists. This needs no
+ * component mapping, so it answers for all 86 folders, including the 22 the package has
+ * never heard of. */
+ const unusedRungs = states
+ .filter((state) => state !== 'rest')
+ .flatMap((state) => role.split('+').map((r) => {
+ const rung = `${r.replace(/-(hovered|active|selected|focused|disabled|read-only)$/, '')}-${state}`;
+ return resolveRole(rung, 'light') !== null && rung !== r ? { state, rung } : null;
+ }))
+ .filter(Boolean);
+ ladders.push({
+ stem,
+ folder: members[0].folder,
+ where: members.find((m) => states.includes(m.state)).where,
+ role: role.split('+'),
+ states: states.sort(),
+ unusedRungs,
+ });
+ }
+ }
+ ladders.sort((a, b) => a.stem.localeCompare(b.stem));
+}
+
+/*
+ * Contrast, measured only where the bundle itself puts a foreground and a background in ONE rule.
+ *
+ * This is the blind spot the whole report circles: every screenshot etalon is .light, and axe's
+ * colour-contrast rule looks at text only, so a role that is fine in light and wrong in dark has
+ * nothing watching it. Guessing which surface a text sits on would produce noise; a rule that sets
+ * both is ground truth and needs no assumption. It covers a subset - most backgrounds live on an
+ * ancestor - but every pair it reports is real.
+ */
+const hexOf = (value) => {
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(value).trim());
+ if (!hex) return null;
+ const body = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join('') : hex[1];
+ return [0, 2, 4].map((i) => parseInt(body.slice(i, i + 2), 16));
+};
+const luminance = (rgb) => {
+ const [r, g, b] = rgb.map((channel) => {
+ const c = channel / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+const contrast = (a, b) => {
+ const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
+ return (x + 0.05) / (y + 0.05);
+};
+
+/* A value built through the alpha bridge renders as a tint over whatever is behind it, not as the
+ * role's opaque hex - measuring it against the role would invent a contrast nobody sees. The
+ * html editor's code block, `rgb(from color-content-subtle r g b / .15)`, is why this is here. */
+const roleOfTierName = new Map(declarations
+ .filter((d) => !d.bridged && d.roles.length === 1)
+ .map((d) => [`--dx-${d.name}`, d.roles[0]]));
+const pairs = [];
+if (existsSync(bundlePath)) {
+ const css = readFileSync(bundlePath, 'utf8');
+ for (const [, selector, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
+ if (selector.trim().startsWith('@')) continue;
+ // WCAG 1.4.3 exempts inactive controls, and the theme's disabled policy is gated separately
+ // (tests/disabled-paint.test.ts). Measuring them here would bury the live pairs under them.
+ if (/dx-state-disabled|dx-state-readonly|dx-button-disable/.test(selector)) continue;
+ const grab = (property) => new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*var\\(\\s*(--dx-[a-z0-9-]+)`).exec(body)?.[1];
+ const fg = grab('color');
+ const bg = grab('background-color') ?? grab('background');
+ if (!fg || !bg) continue;
+ const fgRole = roleOfTierName.get(fg);
+ const bgRole = roleOfTierName.get(bg);
+ if (!fgRole || !bgRole) continue;
+ const measured = {};
+ for (const mode of MODES) {
+ const a = hexOf(resolveRole(fgRole, mode));
+ const b = hexOf(resolveRole(bgRole, mode));
+ if (a && b) measured[mode] = Math.round(contrast(a, b) * 100) / 100;
+ }
+ if (!Object.keys(measured).length) continue;
+ pairs.push({ selector: selector.trim().replace(/\s+/g, ' ').slice(0, 90), fg, bg, fgRole, bgRole, contrast: measured });
+ }
+}
+const AA = 4.5;
+const lowContrast = pairs
+ .filter((pair) => MODES.some((mode) => pair.contrast[mode] !== undefined && pair.contrast[mode] < AA))
+ .filter((pair, index, all) => all.findIndex((other) => other.fg === pair.fg && other.bg === pair.bg) === index)
+ .sort((a, b) => Math.min(...Object.values(a.contrast)) - Math.min(...Object.values(b.contrast)));
+
+/*
+ * The same concept across components.
+ *
+ * Every check above asks about one declaration. This one asks the question the task is actually
+ * named after: does the theme paint the same thing the same way everywhere? Group by what the name
+ * says the thing IS - its modifiers plus slot plus state, with the sub-elements dropped - and a
+ * concept that resolves to several roles is either a considered difference or nobody comparing.
+ *
+ * Ranked by how many FAMILIES disagree, not how many roles: `border-danger` against
+ * `border-danger-shared` is a shade, and two components can honestly differ on it. bg against
+ * border against content for one concept cannot be explained by the element being different.
+ */
+const MODIFIER_WORDS = new Set(Object.values(registries.modifiers).flat());
+const concepts = [];
+{
+ const groups = new Map();
+ for (const declaration of declarations) {
+ if (!declaration.slot || declaration.roles.length !== 1) continue;
+ const bare = declaration.state === 'rest'
+ ? declaration.name
+ : declaration.name.slice(0, -declaration.state.length - 1);
+ const middle = bare.slice(0, -declaration.slot.length).replace(/-$/, '').split('-');
+ const modifiers = [...new Set(middle.filter((word) => MODIFIER_WORDS.has(word)))].sort();
+ if (!modifiers.length) continue; // without a modifier the concept is too generic to compare
+ const key = `${modifiers.join('+')} ${declaration.slot} ${declaration.state}`;
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(declaration);
+ }
+ for (const [concept, members] of groups) {
+ const folders = [...new Set(members.map((m) => m.folder))];
+ const roles = [...new Set(members.map((m) => m.roles[0]))];
+ if (folders.length < 2 || roles.length < 2) continue;
+ const families = [...new Set(roles.map(familyOf).filter((f) => f !== 'none'))];
+ /* Roles that resolve to one colour in both modes are the same paint under different names, and
+ * unifying them costs nothing. That is a different problem from components that genuinely
+ * disagree about the colour, and mixing the two would hide both. */
+ const valueOf = (role) => MODES.map((mode) => resolveRole(role, mode)).join(' / ');
+ const values = new Set(roles.map(valueOf));
+ const oneColour = values.size === 1;
+ /* Inside a split concept, the interesting part is the cluster: components that paint the same
+ * colour while spelling it from different families. Those cost nothing to unify, and until they
+ * are unified the next palette change moves some of them and not the others. */
+ const clusters = [...values].map((value) => ({
+ value,
+ roles: roles.filter((role) => valueOf(role) === value),
+ })).filter((cluster) => cluster.roles.length > 1);
+ const seen = new Set();
+ concepts.push({
+ concept,
+ families,
+ roles,
+ oneColour,
+ clusters,
+ members: members.filter((m) => {
+ const key = `${m.folder}|${m.roles[0]}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ }).map((m) => ({ folder: m.folder, name: m.name, role: m.roles[0], where: m.where })),
+ });
+ }
+ concepts.sort((a, b) => b.families.length - a.families.length
+ || b.roles.length - a.roles.length || a.concept.localeCompare(b.concept));
+}
+
+/*
+ * What the package offers and the theme never takes.
+ *
+ * Every other check starts from a declaration we wrote and asks whether its role is right. This one
+ * starts from the package and asks what we never reached for at all - a whole family can be missing
+ * without a single declaration looking wrong, which is how the four focus roles stayed invisible
+ * until the component holding them was finally parsed.
+ *
+ * Split in two, because the two halves mean opposite things: a role that exists in the semantic
+ * layer and goes unread is capability we are not using, while a role the neighbours reference that
+ * does not exist at all is a stale name in their set.
+ */
+const declaredRoles = new Set();
+for (const [name] of valueIndex.light) declaredRoles.add(name.replace(/^(color|global\.color)\./, 'color-'));
+
+const offeredRoles = new Map();
+for (const set of SETS) {
+ const file = join(tokensRoot, 'tokens', 'components', set, 'theme', 'fluent.json');
+ for (const [, raw] of leavesOf(JSON.parse(readFileSync(file, 'utf8')))) {
+ if (typeof raw !== 'string' || !raw.startsWith('{')) continue;
+ const role = raw.replace(/[{}]/g, '').replace(/^(color|global\.color)\./, 'color-');
+ if (!role.startsWith('color-') || role === 'color-none') continue;
+ if (!offeredRoles.has(role)) offeredRoles.set(role, new Set());
+ offeredRoles.get(role).add(set);
+ }
+}
+const readRoles = new Set(declarations.flatMap((d) => d.roles));
+const unusedRoles = { capability: [], stale: [] };
+for (const [role, sets] of [...offeredRoles].sort()) {
+ if (readRoles.has(role)) continue;
+ unusedRoles[declaredRoles.has(role) ? 'capability' : 'stale'].push({ role, sets: [...sets].sort() });
+}
+
+// --- output ---------------------------------------------------------------------------------------
+
+const count = (predicate) => findings.filter(predicate).length;
+const verdicts = ['agrees', 'agrees-kin', 'cross-family', 'family-conflict', 'role-new', 'slot-absent', 'no-counterpart', 'slot-unparsed'];
+const summary = {
+ tokensVersion,
+ declarations: findings.length,
+ typographyStepReads: typography.length,
+ typographyOffGrid: typography.filter((t) => !t.roles.length).length,
+ typographyUnmarked: typography.filter((t) => !t.marker).length,
+ collapsedLadders: ladders.length,
+ conceptsSplit: concepts.length,
+ conceptsSplitAcrossFamilies: concepts.filter((c) => c.families.length > 1).length,
+ conceptsSameColour: concepts.filter((c) => c.oneColour).length,
+ conceptsWithSpellingClusters: concepts.filter((c) => c.clusters.length).length,
+ rolesOffered: offeredRoles.size,
+ rolesRead: offeredRoles.size - unusedRoles.capability.length - unusedRoles.stale.length,
+ rolesUnusedCapability: unusedRoles.capability.length,
+ rolesStaleInNeighbours: unusedRoles.stale.length,
+ contrastPairsMeasured: pairs.length,
+ contrastBelowAA: lowContrast.length,
+ contrastDarkOnly: lowContrast.filter((p) => p.contrast.light >= AA && p.contrast.dark < AA).length,
+ familyMismatch: count((f) => f.family),
+ slotLies: count((f) => f.slotLies),
+ familyMismatchExplainedByProperty: count((f) => f.family && f.slotLies
+ && f.slotLies.propertySays.some((fam) => f.family.got.includes(fam))),
+ byVerdict: Object.fromEntries(verdicts.map((v) => [v, count((f) => f.package?.verdict === v)])),
+};
+
+const roleList = (roles) => roles.map((r) => `ds.$${r}`).join(' + ');
+
+const swapLine = (f) => {
+ if (!f.swap) return null;
+ const { free, ours, drift } = f.swap;
+ const value = MODES.map((m) => `${m} ${ours[m] ?? '?'}`).join(' / ');
+ if (free.length) {
+ return ` - **free swap**: \`${free[0]}\` resolves identically in both modes (${value})`
+ + (free.length > 1 ? `; also ${free.slice(1).map((r) => `\`${r}\``).join(', ')}` : '');
+ }
+ const near = (f.near ?? []).slice(0, 3)
+ .map((n) => `\`${n.role}\` (moves ${n.moves.join(' and ')})`)
+ .join(', ');
+ return ` - ours resolves ${value}; no role of the right family shares it`
+ + (near ? `. Nearest of the right family: ${near}` : '');
+};
+
+const md = () => {
+ const out = [];
+ out.push('');
+ out.push('# Roles - what the theme assigns, what the package assigns\n');
+ out.push(`Package \`@devexpress/design-tokens-internal@${tokensVersion}\`, sets: ${SETS.join(', ')}.\n`);
+ out.push(`Colour declarations reading a role: **${summary.declarations}**.\n`);
+ out.push('| Signal | Count |');
+ out.push('|---|---|');
+ out.push(`| family mismatch (slot wants another \`--dxds-\` family) | **${summary.familyMismatch}** |`);
+ out.push(`| slot contradicts the painted property | **${summary.slotLies}** |`);
+ out.push(`| states that resolve to one role | **${summary.collapsedLadders}** |`);
+ out.push(`| one concept painted with several roles | **${summary.conceptsSplit}** (${summary.conceptsSplitAcrossFamilies} across families) |`);
+ out.push(`| text/background pairs below AA | **${summary.contrastBelowAA}** of ${summary.contrastPairsMeasured} measured (${summary.contrastDarkOnly} dark only) |`);
+ for (const verdict of verdicts) out.push(`| package: ${verdict} | ${summary.byVerdict[verdict]} |`);
+ out.push('');
+
+ const section = (title, rows, render) => {
+ if (!rows.length) return;
+ out.push(`## ${title} - ${rows.length}\n`);
+ for (const row of rows) out.push(render(row));
+ out.push('');
+ };
+
+ section('Cross-family - the package uses this role, but only for a slot of another kind',
+ findings.filter((f) => f.package?.verdict === 'cross-family'),
+ (f) => [
+ `- \`${f.name}\` = ${roleList(f.roles)}${f.bridged ? ' *(alpha bridge - see BRIDGES.md)*' : ''} (${f.where})`,
+ ...f.package.usedFor.map((u) => ` - package paints it as **${u.slots.join(', ')}** in ${u.where.join(', ')}`),
+ f.package.sameComponent?.length
+ ? ' - **same widget, different word**: the package uses this very role on another part of it'
+ : null,
+ f.package.packageUsesHere.length
+ ? ` - for our slot \`${f.slot}\` the package uses: ${f.package.packageUsesHere.map((r) => `\`${r}\``).join(', ')}`
+ : ` - the package names no role for slot \`${f.slot}\` here`,
+ swapLine(f),
+ ].filter(Boolean).join('\n'));
+
+ section('Family conflict - the package paints this slot from another family entirely',
+ findings.filter((f) => f.package?.verdict === 'family-conflict'),
+ (f) => [
+ `- \`${f.name}\` = ${roleList(f.roles)} (${f.where})`,
+ ` - for slot \`${f.slot}\` the package uses: ${f.package.packageUsesHere.map((r) => `\`${r}\``).join(', ')}`,
+ swapLine(f),
+ ].filter(Boolean).join('\n'));
+
+ section('Family mismatch',
+ findings.filter((f) => f.family),
+ (f) => `- \`${f.name}\` = ${roleList(f.roles)} (${f.where})\n`
+ + ` - slot \`${f.family.slot}\` wants \`color-${f.family.want}-*\`, reads a \`${f.family.got.join('/')}\` role`
+ + (f.package ? `; package verdict: ${f.package.verdict}` : ''));
+
+ out.push(`## Roles the package assigns and the theme never reads - ${unusedRoles.capability.length}\n`);
+ out.push('Counted from the package inward rather than from our declarations outward, because a whole');
+ out.push('family can be missing without any single declaration looking wrong.\n');
+ out.push(`Of the ${offeredRoles.size} roles the four sets assign, the theme reads ${offeredRoles.size - unusedRoles.capability.length - unusedRoles.stale.length}.`);
+ out.push(`${unusedRoles.capability.length} exist in the semantic layer and go unread; ${unusedRoles.stale.length} are names no layer declares -`);
+ out.push('stale references inside the neighbours\' own sets.\n');
+ out.push('| Role | Assigned by |', '|---|---|');
+ for (const u of unusedRoles.capability) out.push(`| \`${u.role}\` | ${u.sets.join(', ')} |`);
+ out.push('');
+
+ out.push(`## One concept, several roles - ${concepts.length} (${summary.conceptsSplitAcrossFamilies} across families)\n`);
+ out.push('Grouped by what the name says the thing is - modifiers, slot, state - with sub-elements');
+ out.push('dropped. A shade apart is a difference two components can honestly have; a family apart is');
+ out.push('one concept painted as a fill in one widget and as a border in the next. Listed first are');
+ out.push('the ones where every role resolves to the SAME colour in both modes - the same paint under');
+ out.push('several names, free to unify and, until then, repainted differently by the next redesign.\n');
+ for (const c of concepts) {
+ out.push(`- **${c.concept}** - ${c.roles.length} roles, ${c.families.length} famil${c.families.length > 1 ? 'ies' : 'y'}`
+ + (c.oneColour ? ', **one colour under several names**' : ''));
+ for (const cluster of c.clusters) {
+ out.push(` - **one colour, ${cluster.roles.length} names** (${cluster.value}): `
+ + cluster.roles.map((r) => `\`${r}\``).join(', '));
+ }
+ for (const m of c.members) out.push(` - ${m.folder}: \`${m.role}\` (${m.where})`);
+ }
+ out.push('');
+
+ out.push(`## Text on its own background, below AA - ${lowContrast.length} of ${pairs.length} measured pairs\n`);
+ out.push('Only pairs the bundle puts in one rule, so no assumption about which surface a text sits');
+ out.push('on. A row that passes in light and fails in dark is the case nothing else can see: the');
+ out.push('etalons are all .light and the axe rule reads text only.\n');
+ out.push('Both thresholds matter and the report does not pick for you: 4.5:1 for text, 3:1 for a');
+ out.push('glyph or a control boundary. A checkmark at 3.36 passes as a graphic; the same number under');
+ out.push('a menu label does not.\n');
+ out.push('| Selector | Text | On | Light | Dark |');
+ out.push('|---|---|---|---|---|');
+ for (const pair of lowContrast) {
+ const mark = (value) => (value === undefined ? '-' : `${value}${value < AA ? ' ⚠' : ''}`);
+ out.push(`| \`${pair.selector}\` | \`${pair.fgRole}\` | \`${pair.bgRole}\` `
+ + `| ${mark(pair.contrast.light)} | ${mark(pair.contrast.dark)} |`);
+ }
+ out.push('');
+
+ out.push(`## States that resolve to one role - ${ladders.length}\n`);
+ out.push('A state in the name that the eye cannot find. `focused` reusing `hovered` is accepted -');
+ out.push('the foundation has no focused state - and is not listed; everything below is a ladder the');
+ out.push('design system ships and the theme does not climb.\n');
+ out.push('| Where | Slot | Role | States sharing it | Rung the system ships and we skip |');
+ out.push('|---|---|---|---|---|');
+ for (const l of ladders) {
+ const rungs = l.unusedRungs.length
+ ? l.unusedRungs.map((r) => `\`${r.rung}\` (${r.state})`).join(', ')
+ : 'none - the system has no role for the second state either';
+ out.push(`| ${l.where} | \`${l.stem}\` | ${roleList(l.role)} | ${l.states.map((x) => `\`${x}\``).join(', ')} | ${rungs} |`);
+ }
+ out.push('');
+
+ const lies = findings.filter((f) => f.slotLies);
+ out.push(`## The slot does not match the property it paints - ${lies.length}\n`);
+ out.push('Read out of the built bundle, so this is what the browser gets, not what the name claims.');
+ out.push('Most are the name and not the role: fourteen filterBuilder `-content` variables reach base as');
+ out.push('`button-color()`, which sets `background-color`, and the bg roles they carry were right all');
+ out.push('along. Two idioms are deliberate and stay - a hairline drawn with `background-color` keeps its');
+ out.push('border role, and a value that paints two properties is named after the dominant one (rule 5).\n');
+ out.push('| Where | Variable | Reads | Slot says | Actually paints |');
+ out.push('|---|---|---|---|---|');
+ for (const f of lies) {
+ out.push(`| ${f.where} | \`${f.name}\` | ${roleList(f.roles)} | \`${f.slot}\` (${f.slotLies.slotSays}) `
+ + `| ${f.paints.properties.map((x) => `\`${x}\``).join(', ')} |`);
+ }
+ out.push('');
+
+ const offGrid = typography.filter((t) => !t.roles.length);
+ const onGridUnrouted = typography.filter((t) => t.roles.length);
+ out.push(`## Typography off the role grid - ${offGrid.length} of ${typography.length} step reads\n`);
+ out.push('The role grid names no step with this value, so the theme reads the base scale directly.');
+ out.push('Each line is a choice: move onto the nearest role (the value changes, etalons follow), ask');
+ out.push('the package for a role at this step, or record the value as a deliberate divergence.\n');
+ out.push('| Where | Variable | Reads | Marker | Nearest roles |');
+ out.push('|---|---|---|---|---|');
+ for (const t of offGrid) {
+ out.push(`| ${t.where} | \`${t.variable}\` | \`${t.family}-${t.step}\` | ${t.marker ? `\`${t.marker}\`` : '**none**'}`
+ + ` | ${t.nearest.map((n) => `\`${n.role}\` (${n.step})`).join(', ')} |`);
+ }
+ out.push('');
+ if (onGridUnrouted.length) {
+ out.push(`### A role names this step and the theme reads the step anyway - ${onGridUnrouted.length}\n`);
+ out.push('| Where | Variable | Reads | Marker | Role with this step |');
+ out.push('|---|---|---|---|---|');
+ for (const t of onGridUnrouted) {
+ out.push(`| ${t.where} | \`${t.variable}\` | \`${t.family}-${t.step}\` | ${t.marker ? `\`${t.marker}\`` : '**none**'}`
+ + ` | ${t.roles.map((r) => `\`${r}\``).join(', ')} |`);
+ }
+ out.push('');
+ }
+
+ const orphans = [...new Set(findings.filter((f) => f.package?.verdict === 'no-counterpart').map((f) => f.folder))].sort();
+ out.push(`## No package counterpart - ${orphans.length} folders\n`);
+ out.push(`${orphans.join(', ')}\n`);
+ out.push('These are the manual layer: no set describes them, so the role can only be judged by eye');
+ out.push('against the light/dark pair, and a gap goes to design as a card.\n');
+ return out.join('\n');
+};
+
+if (process.argv.includes('--json')) {
+ console.log(JSON.stringify({
+ summary, findings, typography, ladders, lowContrast, concepts, unusedRoles,
+ }, null, 2));
+} else if (process.argv.includes('--md')) {
+ console.log(md());
+} else if (themeArg) {
+ console.error('--theme= is for the gate; pass --json with it');
+ process.exit(2);
+} else {
+ writeFileSync(join(themeDir, 'ROLES.md'), `${md()}\n`);
+ console.log(`declarations ${summary.declarations} | family mismatch ${summary.familyMismatch}`);
+ for (const verdict of verdicts) console.log(` ${verdict.padEnd(16)} ${summary.byVerdict[verdict]}`);
+ console.log(`\n→ ${relative(process.cwd(), join(themeDir, 'ROLES.md'))}`);
+}