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-bg1.623.42
полоса сплиттера при наведенииcolor-border-hovered на color-bg1.826.76
полоса сплиттера в фокусеcolor-bg-primary на color-bg5.382.88
грип внутри полосы (исходная строка Д7)color-content-subtle на color-border62.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

КомпонентРоль
diagramcontent-primary
ganttborder-primary
schedulerborder

Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.

Д9 +
Одно понятие, разные роли: accent content rest

Семейства: bg / border / content

КомпонентРоль
chatbg-primary
pivotGridborder-primary
schedulercontent-primary

Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.

Д10 +
Одно понятие, разные роли: base bg rest

Семейства: border / bg

КомпонентРоль
listborder-subtle
stepperbg

Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.

Д11 +
Одно понятие, разные роли: base content rest

Семейства: content / bg

КомпонентРоль
diagramcontent-subtle
schedulerbg-primary-subtle
steppercontent

Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.

Д12 +
Одно понятие, разные роли: invalid content rest

Семейства: content / bg

КомпонентРоль
commoncontent-static-dark
fieldsetcontent-danger
fileUploaderbg-danger
steppercontent-danger

Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.

Д13 +
Одно понятие, разные роли: menu bg rest

Семейства: bg / border

КомпонентРоль
listbg
menubg
menuborder-subtle

Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.

+ +

Б. Команда пакета токенов — 2 заявки

+

Роли, которая нужна, в пакете нет — обменять не на что.

П1 +
load-indicator-segment-inner-border

Внутренняя рамка лоад-индикатора красится bg-primary-subtle, потому что border-роли с этой насыщенностью в пакете не существует. Менять не на что — нужна роль border-primary-subtle.

П2 +
.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 на своей же наведённой поверхности в тёмном.

+ +

В. Унификация, правка бесплатна — 3

+

Компоненты кладут один и тот же цвет и пишут его ролями из разных семейств. Пока каноническая +запись не выбрана, следующая перепривязка палитры разведёт их молча.

У1 +
invalid bg focused — один цвет записан 3 ролями

bg-danger-shared-active · content-danger-shared-active · border-danger-shared-active

Правка ничего не двигает. Нужно назвать каноническую запись.

У2 +
invalid bg hovered — один цвет записан 4 ролями

bg-danger-shared-hovered · content-danger-shared-hovered · border-danger-shared-hovered · border-danger

Правка ничего не двигает. Нужно назвать каноническую запись.

У3 +
invalid bg rest — один цвет записан 6 ролями

bg-danger-shared · content-danger-shared · bg-danger-subtler · bg-danger · border-danger-shared · border-danger

Правка ничего не двигает. Нужно назвать каноническую запись.

+ +

Г. Переименование компонентного тира — 21 имён

+

Роль верна, врёт слово в имени.

+

Окно открыто: компонентный тир fluent-next ещё не отгружен, поэтому переименования и +удаления сейчас бесплатны — ни ченджлога, ни цикла устаревания. После релиза каждое такое имя +становится контрактом с приложениями, и та же правка будет стоить депрекации. Это довод сделать +волну до выпуска, а не после.

+

Не путать с легаси-38: --dx-toolbar-height, +--dx-font-size-heading-* и ещё пятнадцать имён отгружены с 25.2 и решением 27.08.2026 +заморожены — их это окно не касается.

+ + +
ИмяСлот обещаетКрасит
color-view-handle-contentcontentbackground, box-shadow
date-view-roller-bottom-shadowshadowbackground-image
date-view-roller-top-shadowshadowbackground-image
filter-builder-group-operation-contentcontentbackground-color
filter-builder-group-operation-content-disabledcontentbackground-color
filter-builder-group-operation-content-focusedcontentbackground-color
filter-builder-group-operation-content-hoveredcontentbackground-color
filter-builder-item-field-contentcontentbackground-color
filter-builder-item-field-content-disabledcontentbackground-color
filter-builder-item-field-content-focusedcontentbackground-color
filter-builder-item-field-content-hoveredcontentbackground-color
filter-builder-item-operator-contentcontentbackground-color
filter-builder-item-operator-content-focusedcontentbackground-color
filter-builder-item-operator-content-hoveredcontentbackground-color
filter-builder-item-value-contentcontentbackground-color
filter-builder-item-value-content-focusedcontentbackground-color
filter-builder-item-value-content-hoveredcontentbackground-color
pivot-grid-grand-total-contentcontentbackground-color
pivot-grid-total-contentcontentbackground-color
scheduler-appointment-content-activecontentbackground-color
scheduler-workspace-cell-content-focusedcontentbackground-color
+

Четырнадцать из 21 — filterBuilder: его чипы уходят в базовый +button-color(), который ставит фон, а названы они -content.

+ +

Д. Возможности пакета, которыми тема не пользуется — 83 ролей

+

Счёт от пакета внутрь, а не от наших объявлений наружу: целое семейство может отсутствовать, и при +этом ни одно объявление не выглядит неверным. Из 175 ролей, которые +назначают четыре набора, тема читает 82.

+
Н1 +
Индикатор фокуса — четыре роли пакета не читаются нигде

focus · focus-inverted · focus-static · focus-static-inverted

Тема красит фокус ролью границы 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) не используется

content-on-color · content-on-color-shared · content-on-color-subtler · bg-on-color · bg-on-color-alpha · border-on-color-shared

Пакет описывает ими элементы, лежащие на залитой акцентом поверхности — например вариант on-surface чекбокса. У темы такого варианта нет.

Н4 +
Статические роли (static-dark / static-light) не используются

Роли, не меняющиеся между режимами. Тема вместо них берёт content-static-dark в отдельных местах, а поверхностные и границы — нет.

+
Полный список 83 ролей + + +
РольНазначают
bg-highcore, vnext
bg-infoblazor, core, vnext
bg-info-activeblazor, core, vnext
bg-info-hoveredblazor, core, vnext
bg-info-selectedblazor
bg-info-subtlerblazor, wpf
bg-info-subtler-activewpf
bg-info-subtler-hoveredwpf
bg-info-subtler-selectedwpf
bg-inverted-activeblazor
bg-inverted-disabledblazor
bg-inverted-hoveredblazor
bg-inverted-selectedblazor
bg-low-activecore, vnext
bg-on-colorcore, wpf
bg-on-color-alphacore, vnext
bg-on-color-alpha-activecore, vnext
bg-on-color-alpha-disabledcore, vnext
bg-on-color-alpha-hoveredcore, vnext
bg-primary-shared-hoveredcore, vnext
bg-static-darkblazor, core, vnext
bg-static-dark-activeblazor
bg-static-dark-disabledblazor
bg-static-dark-hoveredblazor
bg-static-dark-selectedblazor
bg-static-light-activeblazor
bg-static-light-disabledblazor
bg-static-light-hoveredblazor
bg-static-light-selectedblazor
bg-success-sharedblazor
bg-success-shared-activeblazor
bg-success-shared-hoveredblazor
bg-warning-activeblazor, core, vnext
bg-warning-hoveredblazor, core, vnext
bg-warning-selectedblazor
bg-warning-subtlerblazor, vnext, wpf
bg-warning-subtler-activewpf
bg-warning-subtler-hoveredwpf
bg-warning-subtler-selectedwpf
border-infoblazor
border-invertedblazor
border-inverted-activeblazor
border-inverted-disabledblazor
border-inverted-hoveredblazor
border-on-color-sharedcore, vnext
border-on-color-shared-disabledcore
border-static-darkblazor, core, vnext, wpf
border-static-dark-activeblazor
border-static-dark-disabledblazor
border-static-dark-hoveredblazor
border-static-lightblazor
border-static-light-activeblazor
border-static-light-disabledblazor
border-static-light-hoveredblazor
border-success-sharedblazor
border-success-shared-activeblazor
border-success-shared-hoveredblazor
border-warningblazor, vnext
content-infoblazor, wpf
content-info-activeblazor, core, vnext
content-info-hoveredblazor, core, vnext
content-inverted-disabledblazor
content-on-colorblazor, core, vnext
content-on-color-disabledcore, vnext
content-on-color-sharedwpf
content-on-color-shared-disabledcore
content-on-color-subtlercore, vnext
content-on-subtle-primarywpf
content-primary-sharedcore, vnext
content-primary-shared-activecore, vnext
content-primary-shared-hoveredcore, vnext
content-static-dark-disabledblazor
content-static-light-disabledblazor, wpf
content-success-sharedblazor
content-success-shared-activeblazor
content-success-shared-hoveredblazor
content-warningblazor, vnext, wpf
content-warning-activeblazor, core, vnext
content-warning-hoveredblazor, core, vnext
focuscore, vnext
focus-invertedcore, vnext
focus-staticcore, vnext
focus-static-invertedcore, vnext
+ +

Е. Решения не требуется — записано, чтобы не переоткрывали

+ + +
КлассСколькоПочему закрыто
confirmed5пакет назначает ровно эту роль — расходится только слово слота
rule-512одно значение в двух свойствах, названо по доминирующей роли; принято ревью-раундом 2
hairline5волосяная линия фоном сохраняет border-роль; подтверждено core (у tabs это слот selector)
no-rung22состояния схлопнуты там же, где их схлопывает сама система
shade15то же семейство, другой оттенок — расхождение, которое компоненты вправе иметь
graphic-ok2глиф, порог 3:1 взят
bridge / known2уже инвентаризовано в BRIDGES.md и DIVERGENCES.md
stale в наборах соседей10blazor и wpf ссылаются на имена, которых семантический слой не объявляет — их дрейф, не наш
+ +

Ж. Чего эта проверка не видела, и как это сократить

+ +

226 цветовых объявлений из 709 — 31.9% темы — сравнивать не с чем. +Это компоненты, которых нет ни у core, ни у vnext, ни у blazor, ни у wpf в пакете токенов. По ним +работали только проверки темы против себя самой: семейство, слот против свойства, лестницы состояний, +контраст и согласованность понятий между компонентами.

+ + +
ПапкаОбъявлений
scheduler43
filterBuilder28
stepper25
diagram20
fileManager20
pivotGrid19
gantt14
htmlEditor13
slider13
fileUploader10
tileView7
dateView5
sortable3
splitterBar3
dropDownEditor2
widget1
+ +

Чем это сокращается — измерено 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 как пятый источник−110116 (16.4%)
Р3Fluent UI React−13103 (14.5%)
Р4WPF — только на уровне семейства−4756 (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 ↔ vnext579100%
blazor ↔ core11577%
blazor ↔ vnext11676%
core ↔ wpf3370%
blazor ↔ wpf30%
+

core и vnext — практически один набор (vnext = core плюс field), поэтому их +100% ничего не доказывают. Значимы пары с blazor и wpf. У пары blazor ↔ wpf всего три общих слота — +это не выборка.

+ +

Но одними словами они почти ничего не описывают. Пересечение анатомии, а не ролей, — +вот что расходится:

+ + +
КомпонентНаборыПутей всегоОбщих для всех
buttoncore+vnext+blazor+wpf4010
checkboxcore+vnext+blazor1260
toastcore+vnext+blazor920
gridcore+vnext+wpf668
switchcore+vnext+blazor+wpf570
ribboncore+vnext+wpf468
radio-buttoncore+vnext+blazor360
text-inputcore+vnext+blazor+wpf350
+

У 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 сформулирован как вопрос, а не как вердикт.

+ + + +
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_TYPOGRAPHY.html b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_TYPOGRAPHY.html new file mode 100644 index 000000000000..55631f67baa6 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_TYPOGRAPHY.html @@ -0,0 +1,75 @@ + + + +Fluent-next: типографика вне ролевой сетки +
+

Fluent-next: типографика вне ролевой сетки

+

Материал к design#1555. +37 мест вне сетки и ещё 31, где роль есть, а тема читает ступень.
+Типографика не зависит от режима — у каждого места одно значение, светлая и тёмная темы одинаковы.

+ +

Главное: карточка сформулирована в обратную сторону

+

design#1555 спрашивает, «каким ступеням нужны семантические роли». Сверка с Fluent 2 показывает, +что вопрос не к пакету:

+ +

Ни 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, просто непредсказуемо по платформам.

Почти всё это заголовки групп, метки и подписи, то есть элементы с усилением. Колонка «предложение» — наша рекомендация, не решение.

ГдеПеременнаяПредложение
accordion/_sizes.scss:35accordion-title-font-weight600
button/_sizes.scss:12button-text-font-weight600
calendar/_sizes.scss:35calendar-week-day-header-font-weight600
cardView/_sizes.scss:249card-view-card-content-field-value-highlighted-font-weight600
cardView/_sizes.scss:331card-view-card-content-field-value-highlighted-font-weight600
chat/_sizes.scss:159chat-editing-preview-caption-font-weight600
common/_sizes.scss:41invalid-badge-font-weight600
dataGrid/_sizes.scss:24data-grid-group-row-font-weight600
fileUploader/_sizes.scss:35file-uploader-file-name-font-weight400 — имя файла это данные, не заголовок
list/_sizes.scss:40list-group-header-font-weight600
pagination/_sizes.scss:29pagination-font-weight-selected600
pivotGrid/_sizes.scss:48pivot-grid-header-font-weight600
scheduler/_sizes.scss:183scheduler-group-header-font-weight600
speedDialAction/_sizes.scss:24speed-dial-action-fa-button-label-font-weight600
tabs/variables/_sizes.scss:13tabs-tab-accent-font-weight600
toolbar/_sizes.scss:26toolbar-label-font-weight600
typography/_sizes.scss:69typography-heading-6-font-weight600
Т2 +
Рампа заголовков — 4 места, следствие на все приложения

Самое дорогое решение: --dx-font-size-heading-1…6 — публичные переменные, их читают приложения.

h1h2h3h4h5h6
сейчас, default40 ✓3632 ✓262222
сейчас, compact32 ✓28 ✓24 ✓20 ✓16 ✓16 ✓
Fluent 2 / сетка ролей403228242016

✓ — значение уже лежит на роли.

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:102chat-message-list-empty-message-font-size18px16px base-lg20px title-md
fileManager/_sizes.scss:67file-manager-progress-title-font-size18px16px base-lg20px title-md
scheduler/_sizes.scss:229scheduler-appointment-icon-font-size18px16px base-lg20px title-md
scheduler/_sizes.scss:288scheduler-group-header-agenda-font-size18px16px base-lg20px title-md
scheduler/_sizes.scss:146scheduler-appointment-tooltip-date-font-size11px10px caption-sm12px caption-md
scheduler/_sizes.scss:266scheduler-appointment-content-details-font-size11px10px caption-sm12px caption-md
scheduler/_sizes.scss:308scheduler-time-indicator-font-size30px28px headline-md32px headline-lg

Большая часть — scheduler. Если решение по нему будет одно («округляем вниз» или «вверх»), оно закроет список почти целиком.

Т4 +
line-height вне сетки — 8 мест

Оговорка: шкала межстрочного у пакета не полностью повторяет Fluent 2 — у Fluent 2 есть 22 и 26, у пакета вместо них 24 и 28. Наши значения 12 и 18 не встречаются ни там, ни там.

ГдеПеременнаяСейчасБлижайшая роль
diagram/_sizes.scss:94diagram-popup-close-icon-line-height12px14px caption-sm · 14px base-xs
pagination/_sizes.scss:9pagination-page-line-height12px14px caption-sm · 14px base-xs
pivotGrid/_sizes.scss:17pivot-grid-indicators-line-height12px14px caption-sm · 14px base-xs
scheduler/_sizes.scss:44scheduler-appointment-10min-title-line-height12px14px caption-sm · 14px base-xs
scheduler/_sizes.scss:175scheduler-appointment-15min-title-line-height12px14px caption-sm · 14px base-xs
validation/_sizes.scss:25validation-message-line-height12px14px caption-sm · 14px base-xs
gridBase/_sizes.scss:32grid-header-line-height18px16px caption-md · 16px base-sm
scheduler/_sizes.scss:127scheduler-appointment-15min-title-line-height18px16px caption-md · 16px base-sm

У большинства межстрочное меньше самой низкой роли. Три из них — аппойнтменты scheduler на 10 и 15 минут, где высота строки прижата к высоте ячейки: там 12→14 может не поместиться, это надо смотреть на макете, а не решать по таблице.

Т5 +
Роль существует, а тема читает ступень — 31 мест

Здесь значение не меняется: роль, называющая эту ступень, резолвится в неё же. Перевод равнозначен по построению. Решить нужно только, какая роль — на одной ступени их бывает несколько (caption / base / title), и это выбор смысла, а не значения.

11 из них не несут даже маркера: гейт px-audit смотрит только на литералы, а чтение ступени — не литерал, поэтому они не доезжали ни до SCALES.md, ни до дизайна.

ГдеПеременнаяЧитаетРоли с этой ступеньюМаркер
chat/_sizes.scss:112chat-file-icon-font-size20pxtitle-mdicon-glyph-size
chat/_sizes.scss:138chat-message-list-empty-message-font-size14pxcaption-lg · base-md · title-xsno-semantic-role
chat/_sizes.scss:148chat-file-icon-font-size16pxbase-lg · title-smicon-glyph-size
checkBox/_sizes.scss:5check-box-icon-font-size16pxbase-lg · title-smicon-glyph-size
diagram/_sizes.scss:93diagram-popup-close-icon-font-size12pxcaption-md · base-smicon-glyph-size
fileUploader/_sizes.scss:22file-uploader-file-icon-font-size20pxtitle-mdicon-glyph-size
fileUploader/_sizes.scss:31file-uploader-file-icon-font-size16pxbase-lg · title-smicon-glyph-size
gallery/_sizes.scss:8gallery-nav-icon-font-size32pxheadline-lgicon-glyph-size
gridBase/_sizes.scss:166grid-column-chooser-title-font-size16pxbase-lg · title-smнет
scheduler/_sizes.scss:45scheduler-appointment-10min-icon-font-size12pxcaption-md · base-smicon-glyph-size
scheduler/_sizes.scss:50scheduler-appointment-15min-icon-font-size12pxcaption-md · base-smicon-glyph-size
scheduler/_sizes.scss:98scheduler-appointment-tooltip-date-font-size14pxcaption-lg · base-md · title-xsno-semantic-role
scheduler/_sizes.scss:302scheduler-appointment-month-text-font-size12pxcaption-md · base-smнет
scheduler/_sizes.scss:310scheduler-small-font-size14pxcaption-lg · base-md · title-xsнет
scheduler/_sizes.scss:312scheduler-month-date-text-font-size16pxbase-lg · title-smнет
scrollView/_sizes.scss:4scroll-view-load-indicator-font-size24pxtitle-lg · headline-smicon-glyph-size
scrollView/_sizes.scss:12scroll-view-pull-down-icon-font-size24pxtitle-lg · headline-smicon-glyph-size
tabs/variables/_sizes.scss:39tabs-tab-nav-button-icon-font-size20pxtitle-mdicon-glyph-size
tabs/variables/_sizes.scss:48tabs-tab-nav-button-icon-font-size16pxbase-lg · title-smicon-glyph-size
typography/_sizes.scss:8typography-xs-font-size12pxcaption-md · base-smнет
typography/_sizes.scss:23typography-l-font-size28pxheadline-mdнет
typography/_sizes.scss:24typography-m-font-size20pxtitle-mdнет
typography/_sizes.scss:36typography-xl-font-size24pxtitle-lg · headline-smнет
typography/_sizes.scss:37typography-l-font-size20pxtitle-mdнет
typography/_sizes.scss:38typography-m-font-size16pxbase-lg · title-smнет
typography/_sizes.scss:39typography-s-font-size14pxcaption-lg · base-md · title-xsнет
typography/_sizes.scss:42typography-heading-2-font-size28pxheadline-mdno-semantic-role
typography/_sizes.scss:44typography-heading-4-font-size20pxtitle-mdno-semantic-role
typography/_sizes.scss:45typography-heading-5-font-size16pxbase-lg · title-smno-semantic-role
typography/_sizes.scss:46typography-heading-6-font-size16pxbase-lg · title-smno-semantic-role
validation/_sizes.scss:18validation-message-line-height16pxcaption-md · base-smno-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} +
Сгенерировано node tools/review/roles-pages.mjs · +пакет @devexpress/design-tokens-internal@${data.summary.tokensVersion} · +данные: tools/review/roles.mjs + tests/roles.baseline.json · +править руками не нужно, перегенерируйте.
+
+`; + +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-*, и только disabledcontent-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)}`, + `

${ruText(x.stem)}

`)); + +const contrastQs = base.contrast.filter((x) => x.decision === 'design').map((x) => q(idOf(`contrast:${x.selector}`), + `Контраст: ${code(x.selector)}`, + `

${code(x.fgRole.replace(/^color-/, ''))} на ${code(x.bgRole.replace(/^color-/, ''))} — ` + + `светлый ${x.contrast.light}, тёмный ${x.contrast.dark}

` + + `

${ruText(x.selector)}

`)); + +const slotQs = base.slotLies.filter((x) => x.decision === 'design').map((x) => q(idOf(`slot:${x.name}`), + `${code(x.name)} — слот обещает ${code(x.slotSays)}, красит ${x.paints.map(code).join(', ')}`, + `

${ruText(x.name)}

`)); + +const conceptRows = base.concepts.filter((x) => x.decision === 'design').map((c) => q(idOf(`concept:${c.concept}`), + `Одно понятие, разные роли: ${esc(c.concept)}`, + `

Семейства: ${c.families.join(' / ')}

` + + c.members.map((m) => ``).join('') + + '
КомпонентРоль
${esc(m.folder)}${code(m.role.replace(/^color-/, ''))}

Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.

')); + +// --- B. заявки в пакет +n = 0; +const pkgQs = [...base.open, ...base.contrast].filter((x) => x.decision === 'package-gap') + .map((x) => q(num('П'), code(x.name ?? x.selector), `

${ruText(x.name ?? x.selector)}

`)); + +// --- C. унификация +n = 0; +const spellQs = base.concepts.filter((x) => x.decision === 'spelling').map((c) => q(num('У'), + `${esc(c.concept)} — один цвет записан ${c.roles.length} ролями`, + `

${roleList(c.roles)}

Правка ничего не двигает. Нужно назвать каноническую запись.

`)); + +// --- D. переименование +const renames = base.slotLies.filter((x) => x.decision === 'naming'); + +// --- E. неиспользуемые роли +n = 0; +const famOf = (r) => r.replace(/-(hovered|active|selected|disabled|read-only)$/, ''); +const groups = new Map(); +for (const u of data.unusedRoles.capability) { + const k = famOf(u.role); + if (!groups.has(k)) groups.set(k, { roles: [], sets: new Set() }); + groups.get(k).roles.push(u.role); + u.sets.forEach((x) => groups.get(k).sets.add(x)); +} +const notable = [...groups].filter(([k]) => /focus|on-color|-info|static/.test(k)); +const unusedQs = [ + q(num('Н'), 'Индикатор фокуса — четыре роли пакета не читаются нигде', + `

${roleList(['color-focus', 'color-focus-inverted', 'color-focus-static', 'color-focus-static-inverted'])}

` + + '

Тема красит фокус ролью границы 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) не используется', + `

${roleList(['color-content-on-color', 'color-content-on-color-shared', 'color-content-on-color-subtler', 'color-bg-on-color', 'color-bg-on-color-alpha', 'color-border-on-color-shared'])}

` + + '

Пакет описывает ими элементы, лежащие на залитой акцентом поверхности — например вариант ' + + 'on-surface чекбокса. У темы такого варианта нет.

'), + q(num('Н'), 'Статические роли (static-dark / static-light) не используются', + '

Роли, не меняющиеся между режимами. Тема вместо них берёт content-static-dark ' + + 'в отдельных местах, а поверхностные и границы — нет.

'), +]; + +const noAction = [ + ['confirmed', base.open.filter((x) => x.decision === 'confirmed').length, 'пакет назначает ровно эту роль — расходится только слово слота'], + ['rule-5', base.open.filter((x) => x.decision === 'rule-5').length + base.slotLies.filter((x) => x.decision === 'rule-5').length, 'одно значение в двух свойствах, названо по доминирующей роли; принято ревью-раундом 2'], + ['hairline', base.slotLies.filter((x) => x.decision === 'hairline').length, 'волосяная линия фоном сохраняет border-роль; подтверждено core (у tabs это слот selector)'], + ['no-rung', base.ladders.filter((x) => x.decision === 'no-rung').length, 'состояния схлопнуты там же, где их схлопывает сама система'], + ['shade', base.concepts.filter((x) => x.decision === 'shade').length, 'то же семейство, другой оттенок — расхождение, которое компоненты вправе иметь'], + ['graphic-ok', base.contrast.filter((x) => x.decision === 'graphic-ok').length, 'глиф, порог 3:1 взят'], + ['bridge / known', 2, 'уже инвентаризовано в BRIDGES.md и DIVERGENCES.md'], + ['stale в наборах соседей', data.unusedRoles.stale.length, 'blazor и wpf ссылаются на имена, которых семантический слой не объявляет — их дрейф, не наш'], +]; + + +// --- Ж. покрытие и как его поднять +const ncCount = data.summary.byVerdict['no-counterpart']; +const ncPct = (ncCount / data.summary.declarations * 100).toFixed(1); +const ncByFolder = {}; +for (const f of data.findings) { + if (f.package?.verdict !== 'no-counterpart') continue; + ncByFolder[f.folder] = (ncByFolder[f.folder] ?? 0) + 1; +} +const cov = base.coverage; +let running = ncCount; +const ladder = cov.levers.filter((l) => l.status !== 'сделано').map((l) => { + running -= l.covers; + return `${esc(l.id)}${esc(l.lever)}−${l.covers}` + + `${running} (${(running / data.summary.declarations * 100).toFixed(1)}%)`; +}).join(''); + +const coverageSection = ` +

${ncCount} цветовых объявлений из ${data.summary.declarations} — ${ncPct}% темы — сравнивать не с чем. +Это компоненты, которых нет ни у core, ни у vnext, ни у blazor, ни у wpf в пакете токенов. По ним +работали только проверки темы против себя самой: семейство, слот против свойства, лестницы состояний, +контраст и согласованность понятий между компонентами.

+ +${Object.entries(ncByFolder).sort((a, b2) => b2[1] - a[1]) + .map(([f, c]) => ``).join('')} +
ПапкаОбъявлений
${esc(f)}${c}
+ +

Чем это сокращается — измерено ${esc(cov.measuredOn)}

+

Числа сняты по репозиториям за пределами этого, поэтому инструмент их не пересчитывает: они +забанкованы вместе с источником и протухнут заметно, если прочитать их рядом со свежим счётчиком выше.

+${cov.levers.map((l) => q(l.id, `${esc(l.lever)} — ${l.status === 'сделано' ? 'сделано' : `покрывает ${l.covers}`}`, + `

${esc(l.detail)}

` + + (l.status === 'сделано' ? '' : `

Что нужно: ${esc(l.needs)}

`))).join('')} + +

Куда это приводит

+ + +${ladder}
РычагПокрываетОстанется
сейчас${ncCount} (${ncPct}%)
+

Ниже этого не опускается: ${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) => ``).join('')} +
ПараОбщих слотовСовпадает
${esc(x.pair)}${x.shared}${x.agree}%
+

core и vnext — практически один набор (vnext = core плюс field), поэтому их +100% ничего не доказывают. Значимы пары с blazor и wpf. У пары blazor ↔ wpf всего три общих слота — +это не выборка.

+ +

Но одними словами они почти ничего не описывают. Пересечение анатомии, а не ролей, — +вот что расходится:

+ +${na.anatomyOverlap.map((x) => `` + + ``).join('')} +
КомпонентНаборыПутей всегоОбщих для всех
${esc(x.component)}${esc(x.sets)}${x.paths}${x.sharedByAll === 0 ? '0' : x.sharedByAll}
+

У 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, а не предположены:

+${wc.reasons.map((r) => `

${esc(r.reason)}. ${esc(r.detail)}
` + + `Касается: ${esc(r.covers)}

`).join('')} +

${esc(wc.consequence)}

+`; + + +// --- нетекстовый контраст (WCAG 1.4.11) +const nt = base.nonTextContrast; +const nonTextSection = ` +
нетекстовый контраст · WCAG 1.4.11 +
Порог 3:1 для границ элементов управления и графики
+

Его не реализует ни одно правило axe и не видит ни один скриншот — в CI его не меряет никто. +Замер ${esc(nt.measuredOn)}, и он появился как проверка того, стоит ли действовать по строке грипа выше.

+ +${nt.findings.map((f) => { + const bad = (v) => (v < 3 ? `${v}` : v); + return ``; + }).join('')} +
ЧтоПараСветлыйТёмный
${esc(f.what)}${code(f.pair)}${bad(f.light)}${bad(f.dark)}
+${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. Всё на этой странице двигает пиксель, +меняет публичное имя или требует расширения пакета.

+ +

А. Дизайн — ${roleQs.length + ladderQs.length + contrastQs.length + slotQs.length + conceptRows.length} вопросов

+

Роль выбрана спорно

${roleQs.join('')} +

Состояние неотличимо от соседнего

${ladderQs.join('')} +

Контраст ниже порога

${contrastQs.join('')}${nonTextSection}${whyContrast} +

Имя обещает одно, красит другое

${slotQs.join('')} +

Одно понятие покрашено по-разному в разных компонентах

${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) => ``).join('')} +
ИмяСлот обещаетКрасит
${code(x.name)}${esc(x.slotSays)}${x.paints.map(esc).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) => ``).join('')} +
РольНазначают
${code(u.role.replace(/^color-/, ''))}${u.sets.join(', ')}
+ +

Е. Решения не требуется — записано, чтобы не переоткрывали

+ +${noAction.map(([k, c, why]) => ``).join('')} +
КлассСколькоПочему закрыто
${code(k)}${c}${esc(why)}
+ +

Ж. Чего эта проверка не видела, и как это сократить

+${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, ' + + 'просто непредсказуемо по платформам.

' + + '

Почти всё это заголовки групп, метки и подписи, то есть элементы с усилением. Колонка ' + + '«предложение» — наша рекомендация, не решение.

' + + '' + + weightPlaces.map((t) => `` + + ``).join('') + + '
ГдеПеременнаяПредложение
${code(t.where.replace('scss/widgets/fluent-next/', ''))}${code(t.variable)}${WEIGHT_400.has(t.variable) ? '400 — имя файла это данные, не заголовок' : '600'}
'); + +const headingQ = q(num('Т'), 'Рампа заголовков — 4 места, следствие на все приложения', + '

Самое дорогое решение: --dx-font-size-heading-1…6 — публичные переменные, их читают приложения.

' + + '' + + '' + + '' + + '' + + '' + + '
h1h2h3h4h5h6
сейчас, default40 ✓3632 ✓262222
сейчас, compact32 ✓28 ✓24 ✓20 ✓16 ✓16 ✓
Fluent 2 / сетка ролей403228242016

✓ — значение уже лежит на роли.

' + + '

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 ' + + 'намеренно сидит на базовой шкале. Трогать не предлагаем.

'); + +const sizeGroups = stepGroups.filter(([k]) => k.startsWith('font-size') && !k.endsWith('-500')); +const sizeRows = sizeGroups.flatMap(([, places]) => places) + .filter((t) => !t.where.includes('typography/_sizes.scss')); +const sizeQ = q(num('Т'), `Одиночные размеры компонентов — ${sizeRows.length} мест`, + '' + + sizeRows.map((t) => { + const below = t.nearest.filter((x) => x.step < t.step).sort((a, b) => b.step - a.step)[0]; + const above = t.nearest.filter((x) => x.step > t.step).sort((a, b) => a.step - b.step)[0]; + const fmt = (r) => (r ? `${r.step / 10}px ${r.role}` : '—'); + return `` + + ``; + }).join('') + + '
ГдеПеременнаяСейчасВнизВверх
${code(t.where.replace('scss/widgets/fluent-next/', ''))}${code(t.variable)}${t.step / 10}px${fmt(below)}${fmt(above)}

Большая часть — scheduler. Если решение по нему будет одно («округляем вниз» или «вверх»), ' + + 'оно закроет список почти целиком.

'); + +const lhRows = stepGroups.filter(([k]) => k.startsWith('line-height')).flatMap(([, v]) => v); +const lhQ = q(num('Т'), `line-height вне сетки — ${lhRows.length} мест`, + '

Оговорка: шкала межстрочного у пакета не полностью повторяет Fluent 2 — у Fluent 2 есть 22 и 26, ' + + 'у пакета вместо них 24 и 28. Наши значения 12 и 18 не встречаются ни там, ни там.

' + + '' + + lhRows.map((t) => { + const near = t.nearest.slice(0, 2).map((r) => `${r.step / 10}px ${r.role}`).join(' · '); + return `` + + ``; + }).join('') + + '
ГдеПеременнаяСейчасБлижайшая роль
${code(t.where.replace('scss/widgets/fluent-next/', ''))}${code(t.variable)}${t.step / 10}px${near}

У большинства межстрочное меньше самой низкой роли. Три из них — аппойнтменты scheduler ' + + 'на 10 и 15 минут, где высота строки прижата к высоте ячейки: там 12→14 может не поместиться, это надо ' + + 'смотреть на макете, а не решать по таблице.

'); + +const unmarked = onGrid.filter((t) => !t.marker); +const onGridQ = q(num('Т'), `Роль существует, а тема читает ступень — ${onGrid.length} мест`, + '

Здесь значение не меняется: роль, называющая эту ступень, резолвится в неё же. Перевод ' + + 'равнозначен по построению. Решить нужно только, какая роль — на одной ступени их бывает ' + + 'несколько (caption / base / title), и это выбор смысла, а не значения.

' + + `

${unmarked.length} из них не несут даже маркера: гейт px-audit смотрит только ` + + 'на литералы, а чтение ступени — не литерал, поэтому они не доезжали ни до SCALES.md, ни до дизайна.

' + + '' + + onGrid.map((t) => `` + + `` + + `` + + ``).join('') + + '
ГдеПеременнаяЧитаетРоли с этой ступеньюМаркер
${code(t.where.replace('scss/widgets/fluent-next/', ''))}${code(t.variable)}${t.step / 10}px${t.roles.map(code).join(' · ')}${t.marker ? code(t.marker) : 'нет'}
'); + +const typoPage = page('Fluent-next: типографика вне ролевой сетки', ` +

Fluent-next: типографика вне ролевой сетки

+

Материал к 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.

+ +

Решения

+${weightQ}${headingQ}${sizeQ}${lhQ}${onGridQ} + +

Если решения приняты

+

Правки механические, значения меняются ровно в перечисленных строках, режимы не расходятся. +После них потребуется пересъёмка эталонов скриншотов затронутых компонентов — отдельный проход, +а не «заодно».

+`); + +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'))}`); +}