From 79557e4dbf0fd6ceee2e55ea426f95d121899075 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 13:20:00 +0400
Subject: [PATCH 01/23] Fluent-next: check role assignment against the package
component tier (tools/review/roles.mjs)
Nothing checked the CHOICE of role: the 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 shows up only in dark mode,
where every etalon is .light.
The report cross-tabulates role -> slot per component against
components/{core,vnext,blazor,wpf} of the token package (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), and resolves both sides per mode to say whether a correction would move
a pixel.
714 colour declarations: 247 agree with the package, 39 agree on a kindred slot, 24 read a
role the package uses only for another kind of slot, 11 sit in a family the package never
uses there, 277 have no counterpart to compare against.
Ten of them are corrected here - all value-neutral in both modes, all moving a role into the
family its CSS property demands, with the package agreeing:
checkBox invalid mark background: content-danger-shared* -> bg-danger-shared*
switch on border, rest+hovered: bg-primary-shared* -> border-primary-shared*
tabs selected tab border x4: content-primary* -> border-primary-shared*
textEditor invalid text: border-danger-shared -> content-danger
switch's on-border-focused is left alone on purpose: the border role of the right family
differs in dark (#003c70 -> #005397), so it is a design decision, not hygiene.
Resolve diff 0 on 6428 declarations across both modes; jest 170/170, naming:check 0,
stylelint 0.
---
.../widgets/fluent-next/checkBox/_colors.scss | 6 +-
.../widgets/fluent-next/switch/_colors.scss | 4 +-
.../fluent-next/tabs/variables/_colors.scss | 8 +-
.../fluent-next/textEditor/_colors.scss | 2 +-
.../devextreme-scss/tools/review/roles.mjs | 492 ++++++++++++++++++
5 files changed, 502 insertions(+), 10 deletions(-)
create mode 100644 packages/devextreme-scss/tools/review/roles.mjs
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss
index 37c0716d6940..5989969cf857 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss
@@ -18,9 +18,9 @@ $check-box-invalid-bg: ds.$color-bg-danger-shared !default;
$check-box-invalid-bg-hovered: ds.$color-bg-danger-shared-hovered !default;
$check-box-invalid-bg-focused: ds.$color-bg-danger-shared-active !default;
-$check-box-invalid-mark-bg: ds.$color-content-danger-shared !default;
-$check-box-invalid-mark-bg-hovered: ds.$color-content-danger-shared-hovered !default;
-$check-box-invalid-mark-bg-focused: ds.$color-content-danger-shared-active !default;
+$check-box-invalid-mark-bg: ds.$color-bg-danger-shared !default;
+$check-box-invalid-mark-bg-hovered: ds.$color-bg-danger-shared-hovered !default;
+$check-box-invalid-mark-bg-focused: ds.$color-bg-danger-shared-active !default;
$check-box-border-disabled: ds.$color-border-disabled !default;
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/variables/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/tabs/variables/_colors.scss
index 5bd021f394e4..9a40680c4b29 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,10 +6,10 @@ $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-basic-border-selected: ds.$color-border-primary-shared !default;
+$tabs-tab-border-selected-active: ds.$color-border-primary-shared-active !default;
+$tabs-tab-border-selected-hovered: ds.$color-border-primary-shared-hovered !default;
+$tabs-tab-border-selected-focused: ds.$color-border-primary-shared !default;
$tabs-tab-border-active: ds.$color-border-active !default;
$tabs-tab-border-hovered: ds.$color-border-hovered !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/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
new file mode 100644
index 000000000000..39f29ca7238d
--- /dev/null
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -0,0 +1,492 @@
+/*
+ * 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 } 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, '..', '..');
+const themeDir = 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.
+ */
+const COMPONENT = {
+ accordion: ['accordion'],
+ 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.
+ */
+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 -----------------------------------------------------------------------------
+
+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, '');
+ 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,
+ 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('.') };
+ }
+ 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 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 (!byRole.has(role)) byRole.set(role, new Set());
+ byRole.get(role).add(anatomy.slot);
+ }
+ packageTier[set][component] = { bySlot, byRole, unknownSlots };
+ }
+}
+
+// --- 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 });
+ }
+ }
+ if (!seen.length) record.package = { verdict: 'no-counterpart' };
+ else {
+ const exact = [];
+ const kin = [];
+ const crossFamily = new Map();
+ const slotRoles = new Set(); // roles the package uses for our slot, or a kin slot
+ for (const { set, component, tier } of seen) {
+ for (const [pkgSlot, pkgRoles] of tier.bySlot) {
+ if (!ourSlots.some((ours) => kindred(pkgSlot, ours))) 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}`);
+ }
+ }
+ }
+ const here = [...slotRoles].sort();
+ 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) {
+ record.package = {
+ verdict: 'cross-family',
+ usedFor: [...crossFamily].map(([slots, where]) => ({ slots: slots.split('|'), where: [...new Set(where)] })),
+ 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'));
+ 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 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);
+}
+
+// --- 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,
+ familyMismatch: count((f) => f.family),
+ 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}** |`);
+ 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.where})`,
+ ...f.package.usedFor.map((u) => ` - package paints it as **${u.slots.join(', ')}** in ${u.where.join(', ')}`),
+ 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}` : ''));
+
+ 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 }, null, 2));
+} else if (process.argv.includes('--md')) {
+ console.log(md());
+} 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'))}`);
+}
From 2bd667ce0776bd4ab9bfbea9648029245a308269 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 13:21:47 +0400
Subject: [PATCH 02/23] Fluent-next: gate the role comparison so the 25 open
disagreements cannot go stale
The report alone rots: a role nobody has looked at joins the list silently, and a role somebody
fixed leaves it without a trace of the decision. So the list is banked and compared for exact
equality - a new disagreement and a resolved one both fail, and both are re-banked on purpose
with UPDATE_ROLES_BASELINE=1 pnpm test.
Two self-checks keep a green gate meaningful: the tool takes --theme= and is driven over a
synthetic tree that plants a wrong-family role (must be caught) and a right one (must pass).
Without them a scan that matched nothing would read as a clean bill.
jest 174/174.
---
.../devextreme-scss/tests/roles.baseline.json | 211 ++++++++++++++++++
packages/devextreme-scss/tests/roles.test.ts | 95 ++++++++
.../devextreme-scss/tools/review/roles.mjs | 8 +-
3 files changed, 313 insertions(+), 1 deletion(-)
create mode 100644 packages/devextreme-scss/tests/roles.baseline.json
create mode 100644 packages/devextreme-scss/tests/roles.test.ts
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
new file mode 100644
index 000000000000..716616f9fd42
--- /dev/null
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -0,0 +1,211 @@
+{
+ "comment": [
+ "Declarations whose role the package disagrees with, as reviewed today.",
+ "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.",
+ "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."
+ ],
+ "open": [
+ {
+ "name": "chat-message-editing-preview-accent-content",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-bg-primary"
+ ],
+ "slot": "content"
+ },
+ {
+ "name": "color-view-handle-content",
+ "verdict": "cross-family",
+ "roles": [
+ "color-bg"
+ ],
+ "slot": "content"
+ },
+ {
+ "name": "fieldset-field-value-invalid-content",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-border-danger"
+ ],
+ "slot": "content"
+ },
+ {
+ "name": "gallery-nav-button-bg",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-subtle"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "gallery-nav-button-bg-disabled",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-disabled"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "grid-content-focused",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-inverted"
+ ],
+ "slot": "content"
+ },
+ {
+ "name": "popup-content-shadow-ambient",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-shadow-ambient"
+ ],
+ "slot": "shadow-ambient"
+ },
+ {
+ "name": "popup-content-shadow-focused",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-shadow-key"
+ ],
+ "slot": "shadow"
+ },
+ {
+ "name": "popup-content-shadow-key",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-shadow-key"
+ ],
+ "slot": "shadow-key"
+ },
+ {
+ "name": "radio-button-invalid-bg",
+ "verdict": "cross-family",
+ "roles": [
+ "color-border-danger-shared"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "radio-button-invalid-bg-focused",
+ "verdict": "cross-family",
+ "roles": [
+ "color-border-danger-shared-active"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "radio-button-invalid-bg-hovered",
+ "verdict": "cross-family",
+ "roles": [
+ "color-border-danger-shared-hovered"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "scroll-view-shadow",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-shadow-key"
+ ],
+ "slot": "shadow"
+ },
+ {
+ "name": "switch-handle-bg-disabled",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-disabled"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "switch-handle-off-bg",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-subtle"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "switch-handle-on-shadow",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-inverted"
+ ],
+ "slot": "shadow"
+ },
+ {
+ "name": "switch-on-border-focused",
+ "verdict": "cross-family",
+ "roles": [
+ "color-bg-primary-active"
+ ],
+ "slot": "border"
+ },
+ {
+ "name": "toolbar-separator-border",
+ "verdict": "cross-family",
+ "roles": [
+ "color-border-subtle"
+ ],
+ "slot": "border"
+ },
+ {
+ "name": "tree-view-border",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-border"
+ ],
+ "slot": "border"
+ },
+ {
+ "name": "tree-view-checkbox-accent-content-focused",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-bg-primary-hovered"
+ ],
+ "slot": "content"
+ },
+ {
+ "name": "tree-view-checkbox-border-disabled",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-disabled"
+ ],
+ "slot": "border"
+ },
+ {
+ "name": "tree-view-checkbox-border-focused",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-border"
+ ],
+ "slot": "border"
+ },
+ {
+ "name": "tree-view-checkbox-checked-border-focused",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-border-primary-shared-active"
+ ],
+ "slot": "border"
+ },
+ {
+ "name": "tree-view-checkbox-indeterminate-bg-disabled",
+ "verdict": "cross-family",
+ "roles": [
+ "color-content-disabled"
+ ],
+ "slot": "bg"
+ },
+ {
+ "name": "valid-badge-content",
+ "verdict": "family-conflict",
+ "roles": [
+ "color-bg-success"
+ ],
+ "slot": "content"
+ }
+ ]
+}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
new file mode 100644
index 000000000000..4687c98b1eb2
--- /dev/null
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -0,0 +1,95 @@
+/*
+ * 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 };
+
+const run = (theme?: string): { summary: Record; findings: Finding[] } => 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'));
+
+if (process.env.UPDATE_ROLES_BASELINE) {
+ writeFileSync(baselinePath, `${JSON.stringify({ ...baseline, open: disagreements(actual.findings) }, 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', () => {
+ expect(disagreements(actual.findings)).toEqual(baseline.open);
+});
+
+// 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');
+});
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index 39f29ca7238d..759ac0e5886a 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -37,7 +37,10 @@ import { createRequire } from 'module';
const here = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(here, '..', '..');
-const themeDir = join(packageRoot, 'scss', 'widgets', 'fluent-next');
+// --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);
@@ -484,6 +487,9 @@ if (process.argv.includes('--json')) {
console.log(JSON.stringify({ summary, findings }, 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}`);
From c0347c9b3fef87b8296a74914ffa24a6a61a61ec Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 13:24:49 +0400
Subject: [PATCH 03/23] Fluent-next: turn dx-no-semantic-role into a decision
table and gate the step reads
The marker says "the package has no role for this case" and leaves it there. Measured against the
role grid, the claim splits in two, and only one half is what the marker describes:
37 of 68 typography step reads are genuinely off the grid - font-weight 500, font-size
110/180/220/260/360, line-height 120/180. The report now names the nearest roles and their
steps, so each is a choice between the legacy value and the grid rather than an open question.
31 read a step the grid DOES name. Routing them is value-neutral by construction - a role that
names step X resolves to step X - but which role (caption or base or title at the same step)
is a decision, so none is applied here.
None of the four neighbours has this class: their component sets reference the typography roles and
a bare step three times in total, all on {font-weight.400}, which has a role. So the default reading
is not "the package is missing a role" - it is legacy fluent's value meeting the design system's
grid, and 13 hard font-weight: 500 in the legacy theme are where most of it comes from.
13 of the 68 carry no marker at all: px-audit only looks at literals and a step read is not a
literal, so they never reached SCALES.md or design. Banked so the next one cannot arrive silently.
jest 175/175, stylelint 0.
---
.../devextreme-scss/tests/roles.baseline.json | 72 ++++++++++++++-
packages/devextreme-scss/tests/roles.test.ts | 27 +++++-
.../devextreme-scss/tools/review/roles.mjs | 90 ++++++++++++++++++-
3 files changed, 185 insertions(+), 4 deletions(-)
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index 716616f9fd42..b710888c767b 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -4,7 +4,10 @@
"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.",
"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."
+ "family-conflict - the package paints this slot from another family entirely.",
+ "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."
],
"open": [
{
@@ -207,5 +210,72 @@
],
"slot": "content"
}
+ ],
+ "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
+ }
]
}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index 4687c98b1eb2..cbb8824698a0 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -31,7 +31,11 @@ type Finding = {
};
type Open = { name: string; verdict: string; roles: string[]; slot: string | null };
-const run = (theme?: string): { summary: Record; findings: Finding[] } => JSON.parse(
+type Typography = { variable: string; family: string; step: number; marker: string | null; roles: string[] };
+
+const run = (theme?: string): {
+ summary: Record; findings: Finding[]; typography: Typography[];
+} => JSON.parse(
execFileSync('node', [tool, '--json', ...(theme ? [`--theme=${theme}`] : [])], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
@@ -48,8 +52,17 @@ const disagreements = (findings: Finding[]): Open[] => findings
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) }, null, 2)}\n`);
+ writeFileSync(baselinePath, `${JSON.stringify({
+ ...baseline,
+ open: disagreements(actual.findings),
+ typographyUnmarked: unmarked(actual.typography),
+ }, null, 2)}\n`);
}
test('every colour declaration reaches a verdict', () => {
@@ -93,3 +106,13 @@ test('a role the package names for the slot passes', () => {
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);
+});
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index 759ac0e5886a..0465f17f3c06 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -308,6 +308,67 @@ for (const set of SETS) {
}
}
+/*
+ * 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, '').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 })),
+ });
+ });
+}
+
// --- the comparison -------------------------------------------------------------------------------
const findings = [];
@@ -410,6 +471,9 @@ const verdicts = ['agrees', 'agrees-kin', 'cross-family', 'family-conflict', 'ro
const summary = {
tokensVersion,
declarations: findings.length,
+ typographyStepReads: typography.length,
+ typographyOffGrid: typography.filter((t) => !t.roles.length).length,
+ typographyUnmarked: typography.filter((t) => !t.marker).length,
familyMismatch: count((f) => f.family),
byVerdict: Object.fromEntries(verdicts.map((v) => [v, count((f) => f.package?.verdict === v)])),
};
@@ -475,6 +539,30 @@ const md = () => {
+ ` - slot \`${f.family.slot}\` wants \`color-${f.family.want}-*\`, reads a \`${f.family.got.join('/')}\` role`
+ (f.package ? `; package verdict: ${f.package.verdict}` : ''));
+ 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`);
@@ -484,7 +572,7 @@ const md = () => {
};
if (process.argv.includes('--json')) {
- console.log(JSON.stringify({ summary, findings }, null, 2));
+ console.log(JSON.stringify({ summary, findings, typography }, null, 2));
} else if (process.argv.includes('--md')) {
console.log(md());
} else if (themeArg) {
From 0d1a86c05d1272b484329aa33b20d3793f29315c Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 15:39:14 +0400
Subject: [PATCH 04/23] Fluent-next: decide all 59 role disagreements and read
the slot against the built CSS
Adjudicating the first list turned up more instrument than finding: seven of the eleven family
conflicts were the tool's own noise. A shadow that lives on a content area was compared against
content roles, a scroll bar's thumb matched everything because it is ambiguous by design, an empty
candidate set read as a conflict, and `separator.color` - the package naming a component that IS
the slot - fell out of the comparison entirely, so every -separator-border in the theme looked like
a border nobody named. Fixed, and `cross-family` now means what it says: the families differ.
17 disagreements survive, each with a decision in the baseline:
confirmed 3 the package assigns this very role here - the switch knob is its `trigger`, painted
from content roles exactly as we do, and only our slot word says bg
rule-5 5 one value in two properties, named after the dominant role
naming 1 colorView's handle is a fill; color-bg is right and `content` is not
bridge 1 switch handle ring - rgb(from ... / .1), no shadow role exists
package-gap 1 loadIndicator's inner border wants color-border-primary-subtle, which does not exist
design 6 a correction moves a pixel. Two are defects in one mode only: valid-badge text is
#107c10 in dark where content-success gives #509f4b, and fieldset invalid text is
#ee726a in light where content-danger gives #c50f1f
Then the slot itself became checkable. NAMING.md says the CSS property decides it, and the built
bundle says which property each tier name reaches: 42 names disagree with their own code. 21 are
the name and not the role - fourteen filterBuilder `-content` variables reach base as
button-color(), which sets background-color, and their bg roles were right all along. 11 are a
deliberate idiom worth keeping: a hairline drawn with background-color stays a border role, the
same way in tabs, splitterBar and gridBase. 7 are rule-5 pairs, 1 is an inventoried divergence, and
2 want a call - notably list's disabled text, painted from a border role two steps paler than the
content one.
Both lists are banked with the reasoning and gated on exact equality; a banked entry with no
decision fails too. jest 178/178.
---
.../devextreme-scss/tests/roles.baseline.json | 594 +++++++++++++++---
packages/devextreme-scss/tests/roles.test.ts | 69 +-
.../devextreme-scss/tools/review/roles.mjs | 115 +++-
3 files changed, 677 insertions(+), 101 deletions(-)
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index b710888c767b..761322468e2a 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -1,13 +1,27 @@
{
"comment": [
- "Declarations whose role the package disagrees with, as reviewed today.",
- "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.",
- "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.",
+ "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."
+ "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."
],
"open": [
{
@@ -16,7 +30,9 @@
"roles": [
"color-bg-primary"
],
- "slot": "content"
+ "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": "color-view-handle-content",
@@ -24,7 +40,9 @@
"roles": [
"color-bg"
],
- "slot": "content"
+ "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": "fieldset-field-value-invalid-content",
@@ -32,7 +50,9 @@
"roles": [
"color-border-danger"
],
- "slot": "content"
+ "slot": "content",
+ "decision": "design",
+ "why": "reads color: in fieldset/_index.scss:33 from a border role. Dark matches content-danger, light does not: ours #ee726a against content-danger #c50f1f. A light-only defect"
},
{
"name": "gallery-nav-button-bg",
@@ -40,7 +60,9 @@
"roles": [
"color-content-subtle"
],
- "slot": "bg"
+ "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",
@@ -48,39 +70,19 @@
"roles": [
"color-content-disabled"
],
- "slot": "bg"
+ "slot": "bg",
+ "decision": "design",
+ "why": "same scrim question as gallery-nav-button-bg, disabled state"
},
{
- "name": "grid-content-focused",
- "verdict": "cross-family",
- "roles": [
- "color-content-inverted"
- ],
- "slot": "content"
- },
- {
- "name": "popup-content-shadow-ambient",
- "verdict": "family-conflict",
- "roles": [
- "color-shadow-ambient"
- ],
- "slot": "shadow-ambient"
- },
- {
- "name": "popup-content-shadow-focused",
+ "name": "load-indicator-segment-inner-border",
"verdict": "family-conflict",
"roles": [
- "color-shadow-key"
+ "color-bg-primary-subtle"
],
- "slot": "shadow"
- },
- {
- "name": "popup-content-shadow-key",
- "verdict": "family-conflict",
- "roles": [
- "color-shadow-key"
- ],
- "slot": "shadow-key"
+ "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",
@@ -88,7 +90,9 @@
"roles": [
"color-border-danger-shared"
],
- "slot": "bg"
+ "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",
@@ -96,7 +100,9 @@
"roles": [
"color-border-danger-shared-active"
],
- "slot": "bg"
+ "slot": "bg",
+ "decision": "rule-5",
+ "why": "see radio-button-invalid-bg"
},
{
"name": "radio-button-invalid-bg-hovered",
@@ -104,15 +110,9 @@
"roles": [
"color-border-danger-shared-hovered"
],
- "slot": "bg"
- },
- {
- "name": "scroll-view-shadow",
- "verdict": "family-conflict",
- "roles": [
- "color-shadow-key"
- ],
- "slot": "shadow"
+ "slot": "bg",
+ "decision": "rule-5",
+ "why": "see radio-button-invalid-bg"
},
{
"name": "switch-handle-bg-disabled",
@@ -120,7 +120,9 @@
"roles": [
"color-content-disabled"
],
- "slot": "bg"
+ "slot": "bg",
+ "decision": "confirmed",
+ "why": "see switch-handle-off-bg"
},
{
"name": "switch-handle-off-bg",
@@ -128,7 +130,9 @@
"roles": [
"color-content-subtle"
],
- "slot": "bg"
+ "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",
@@ -136,7 +140,9 @@
"roles": [
"color-content-inverted"
],
- "slot": "shadow"
+ "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",
@@ -144,23 +150,9 @@
"roles": [
"color-bg-primary-active"
],
- "slot": "border"
- },
- {
- "name": "toolbar-separator-border",
- "verdict": "cross-family",
- "roles": [
- "color-border-subtle"
- ],
- "slot": "border"
- },
- {
- "name": "tree-view-border",
- "verdict": "family-conflict",
- "roles": [
- "color-border"
- ],
- "slot": "border"
+ "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",
@@ -168,7 +160,9 @@
"roles": [
"color-bg-primary-hovered"
],
- "slot": "content"
+ "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"
},
{
"name": "tree-view-checkbox-border-disabled",
@@ -176,23 +170,9 @@
"roles": [
"color-content-disabled"
],
- "slot": "border"
- },
- {
- "name": "tree-view-checkbox-border-focused",
- "verdict": "family-conflict",
- "roles": [
- "color-border"
- ],
- "slot": "border"
- },
- {
- "name": "tree-view-checkbox-checked-border-focused",
- "verdict": "family-conflict",
- "roles": [
- "color-border-primary-shared-active"
- ],
- "slot": "border"
+ "slot": "border",
+ "decision": "design",
+ "why": "border-color: from color-content-disabled. The package's checkbox separates them - border-disabled #d7d7d7/#4c4c4c against our #ababab/#767676. This is the answer to the journal's open question about the three roles split 06.08 out of one value"
},
{
"name": "tree-view-checkbox-indeterminate-bg-disabled",
@@ -200,7 +180,9 @@
"roles": [
"color-content-disabled"
],
- "slot": "bg"
+ "slot": "bg",
+ "decision": "confirmed",
+ "why": "the package assigns content-disabled to indeterminate.icon.disabled - the dash is content. Our role is right; the slot word says bg because the property is background-color"
},
{
"name": "valid-badge-content",
@@ -208,7 +190,9 @@
"roles": [
"color-bg-success"
],
- "slot": "content"
+ "slot": "content",
+ "decision": "design",
+ "why": "reads color: in common/_mixins.scss:52 from a bg role. Light matches content-success exactly, dark does not: ours #107c10 against a #242424 surface where the content role gives #509f4b. A dark-only defect, invisible to every etalon"
}
],
"typographyUnmarked": [
@@ -277,5 +261,437 @@
"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": "list-border-disabled",
+ "slot": "border",
+ "slotSays": "border",
+ "paints": [
+ "color"
+ ],
+ "decision": "design",
+ "why": "a border role painting text: disabled list text sits at #d7d7d7 light / #4c4c4c dark where the content role gives #ababab / #767676. A contrast call, not hygiene"
+ },
+ {
+ "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": "tabs-tab-basic-border-selected",
+ "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": "tabs-tab-border-active",
+ "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": "tabs-tab-border-disabled",
+ "slot": "border",
+ "slotSays": "border",
+ "paints": [
+ "background-color"
+ ],
+ "decision": "design",
+ "why": "content-disabled painting the disabled tab indicator background; no bg role carries this value"
+ },
+ {
+ "name": "tabs-tab-border-hovered",
+ "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": "tabs-tab-border-selected-active",
+ "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": "tabs-tab-border-selected-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": "tabs-tab-border-selected-hovered",
+ "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)"
+ }
]
}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index cbb8824698a0..a8f751f7379e 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -29,12 +29,25 @@ type Finding = {
roles: string[];
package?: { verdict: string };
};
-type Open = { name: string; verdict: string; roles: string[]; slot: string | null };
+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'];
+
+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[]; typography: Typography[];
+ summary: Record;
+ findings: (Finding & { slot?: string | null; slotLies?: { slotSays: string }; paints?: { properties: string[] } })[];
+ typography: Typography[];
} => JSON.parse(
execFileSync('node', [tool, '--json', ...(theme ? [`--theme=${theme}`] : [])], {
encoding: 'utf8',
@@ -60,7 +73,10 @@ const unmarked = (typography: Typography[]) => typography
if (process.env.UPDATE_ROLES_BASELINE) {
writeFileSync(baselinePath, `${JSON.stringify({
...baseline,
- open: disagreements(actual.findings),
+ 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`);
}
@@ -75,7 +91,20 @@ test('every colour declaration reaches a verdict', () => {
* 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', () => {
- expect(disagreements(actual.findings)).toEqual(baseline.open);
+ 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".
@@ -116,3 +145,35 @@ test('a role the package names for the slot passes', () => {
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([]);
+});
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index 0465f17f3c06..42fc74263fcf 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -30,7 +30,7 @@
* names the slots the package does use it for, which is usually the answer.
*/
-import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
+import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join, dirname, relative } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
@@ -252,6 +252,7 @@ for (const file of colourFiles(themeDir)) {
subElementSlots,
state: state ?? 'rest',
roles,
+ bridged: /rgb\(\s*from/.test(value),
value: value.trim(),
});
});
@@ -280,6 +281,12 @@ const dissect = (path) => {
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 = trailing(segments[0], PARTS);
+ if (asSlot) return { slot: asSlot, state, variant: tail.join('.') };
return { slot: null, state, variant: tail.join('.') };
};
@@ -369,6 +376,37 @@ for (const file of sizeFiles(themeDir)) {
});
}
+/*
+ * 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 = [];
@@ -395,18 +433,31 @@ for (const declaration of declarations) {
for (const candidate of [...candidates, ...SHARED]) {
const tier = packageTier[set][candidate];
if (!tier) continue;
- seen.push({ set, component: candidate, tier });
+ 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 } of seen) {
+ 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 (!ourSlots.some((ours) => kindred(pkgSlot, ours))) continue;
+ if (kinOf(pkgSlot) !== kinOf(slot) || kinOf(slot) === 'ambiguous') continue;
for (const role of pkgRoles) slotRoles.add(role);
}
for (const role of roles) {
@@ -418,26 +469,52 @@ for (const declaration of declarations) {
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) {
+ 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'));
- const shared = [...ourFamilies].some((f) => theirFamilies.has(f));
- record.package = { verdict: shared ? 'role-new' : 'family-conflict', packageUsesHere: here };
+ // 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]));
@@ -475,6 +552,9 @@ const summary = {
typographyOffGrid: typography.filter((t) => !t.roles.length).length,
typographyUnmarked: typography.filter((t) => !t.marker).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)])),
};
@@ -504,6 +584,7 @@ const md = () => {
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}** |`);
for (const verdict of verdicts) out.push(`| package: ${verdict} | ${summary.byVerdict[verdict]} |`);
out.push('');
@@ -517,8 +598,11 @@ const md = () => {
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.where})`,
+ `- \`${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`,
@@ -539,6 +623,21 @@ const md = () => {
+ ` - slot \`${f.family.slot}\` wants \`color-${f.family.want}-*\`, reads a \`${f.family.got.join('/')}\` role`
+ (f.package ? `; package verdict: ${f.package.verdict}` : ''));
+ 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`);
From f80043df2a502ec5086057a0fe82f788d4ee9807 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 16:50:58 +0400
Subject: [PATCH 05/23] Fluent-next: measure the state ladders the theme
declares but does not climb
The 105 role-new findings looked like a pile until they were split: 18 are a state the package does
not model, 87 are a different shade of the same family. Reading those turned up a class no package
comparison can see, because it is the theme against itself - a slot whose states resolve to one
role has a state in its name that the eye cannot find.
24 such slots. The honest question is not whether the package ladders that slot: its per-state role
sets are a union over every variant of a component, so a button's rest and selected differ merely
because different variants live in each, and the first two attempts at this check reported eight
buttons that were nothing. The question is about OUR role - we paint two states from role R, does
the design system ship R for the second state? That needs no component mapping, so it answers for
all 86 folders including the 22 the package has never heard of.
Two do:
accordion-title-bg hovered and active are both color-bg-hovered; color-bg-active exists
(#e1e1e1 light / #1d1d1d dark) and the theme uses it elsewhere. Pressing an
accordion header looks exactly like hovering it
tile-view-bg rest and hovered are one value while active is not - the slot means to
ladder and the hovered rung is flat
The other 22 collapse where the system collapses too, which is a decision rather than a gap:
disabled and read-only share a colour in checkBox, textEditor and radioButton; a selected tab keeps
its text colour because the indicator carries the selection. A focused state reusing hovered is
accepted convention and never listed at all.
jest 180/180, stylelint 0.
---
.../devextreme-scss/tests/roles.baseline.json | 297 +++++++++++++++++-
packages/devextreme-scss/tests/roles.test.ts | 23 ++
.../devextreme-scss/tools/review/roles.mjs | 86 ++++-
3 files changed, 403 insertions(+), 3 deletions(-)
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index 761322468e2a..618f04ddb676 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -21,7 +21,11 @@
"",
"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."
+ "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."
],
"open": [
{
@@ -693,5 +697,296 @@
"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"
+ },
+ {
+ "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"
+ },
+ {
+ "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"
+ }
]
}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index a8f751f7379e..3b0c48040977 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -36,6 +36,9 @@ type Open = {
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'];
+
+type Ladder = { stem: string; states: string[]; role: string[]; decision?: string; why?: string };
type SlotLie = {
name: string; slot: string | null; slotSays: string; paints: string[];
@@ -48,6 +51,7 @@ const run = (theme?: string): {
summary: Record;
findings: (Finding & { slot?: string | null; slotLies?: { slotSays: string }; paints?: { properties: string[] } })[];
typography: Typography[];
+ ladders: (Ladder & { unusedRungs: unknown[] })[];
} => JSON.parse(
execFileSync('node', [tool, '--json', ...(theme ? [`--theme=${theme}`] : [])], {
encoding: 'utf8',
@@ -177,3 +181,22 @@ test('every banked slot mismatch carries a decision and a reason', () => {
.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([]);
+});
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index 42fc74263fcf..b03452743c46 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -297,6 +297,7 @@ for (const set of SETS) {
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)) {
@@ -308,10 +309,14 @@ for (const set of SETS) {
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, byRole, unknownSlots };
+ packageTier[set][component] = { bySlot, byState, byRole, unknownSlots };
}
}
@@ -541,6 +546,67 @@ for (const declaration of declarations) {
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));
+}
+
// --- output ---------------------------------------------------------------------------------------
const count = (predicate) => findings.filter(predicate).length;
@@ -551,6 +617,7 @@ const summary = {
typographyStepReads: typography.length,
typographyOffGrid: typography.filter((t) => !t.roles.length).length,
typographyUnmarked: typography.filter((t) => !t.marker).length,
+ collapsedLadders: ladders.length,
familyMismatch: count((f) => f.family),
slotLies: count((f) => f.slotLies),
familyMismatchExplainedByProperty: count((f) => f.family && f.slotLies
@@ -585,6 +652,7 @@ const md = () => {
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}** |`);
for (const verdict of verdicts) out.push(`| package: ${verdict} | ${summary.byVerdict[verdict]} |`);
out.push('');
@@ -623,6 +691,20 @@ const md = () => {
+ ` - 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(`## 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.');
@@ -671,7 +753,7 @@ const md = () => {
};
if (process.argv.includes('--json')) {
- console.log(JSON.stringify({ summary, findings, typography }, null, 2));
+ console.log(JSON.stringify({ summary, findings, typography, ladders }, null, 2));
} else if (process.argv.includes('--md')) {
console.log(md());
} else if (themeArg) {
From 84637175723ac84e71f5314e8be0566017ac1a5b Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 16:54:30 +0400
Subject: [PATCH 06/23] Fluent-next: measure contrast where the bundle itself
pairs a text with its background
The gap this report keeps circling is dark mode: every screenshot etalon is .light and the axe rule
reads text only, so a role that is right in light and wrong in dark has nothing watching it. Two
earlier findings were exactly that shape - valid-badge text and the fieldset invalid message - and
both were found by eye. This measures the class.
No guessing which surface a text sits on: only pairs the bundle puts in ONE rule, resolved per mode
from the tokens. 59 such pairs; 4 fall below AA and all four pass comfortably in light.
Two exclusions, each because including them invents a number nobody sees. An alpha-bridge value
renders as a tint over whatever is behind it, not as its role's opaque hex - the html editor's code
block, rgb(from color-content-subtle r g b / .15), read as 1.62 against its own text before this was
fixed, and it is the reason the rule exists. Disabled selectors are exempt from WCAG 1.4.3 and gated
by disabled-paint.test.ts; eight of them were burying the live pairs.
Both thresholds are reported and neither is chosen for you - 4.5:1 for text, 3:1 for a glyph:
.dx-splitter .dx-resize-handle 2.8 dark - a grip, so 3:1 applies. Misses
...context-menu .dx-icon-trash item, focused 3.05 dark - a menu label, so 4.5:1. Misses
.dx-checkbox-checked .dx-checkbox-icon 3.36 dark - a checkmark, clears 3:1
.dx-field-value.dx-attention::before 3.68 dark - a glyph, clears 3:1
The two that clear are banked too, so a drop below the threshold is caught rather than rediscovered.
jest 182/182, naming:check 0.
---
.../devextreme-scss/tests/roles.baseline.json | 53 +++++++++++-
packages/devextreme-scss/tests/roles.test.ts | 27 ++++++
.../devextreme-scss/tools/review/roles.mjs | 86 ++++++++++++++++++-
3 files changed, 164 insertions(+), 2 deletions(-)
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index 618f04ddb676..1fbd9df279fe 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -25,7 +25,12 @@
"",
"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."
+ "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."
],
"open": [
{
@@ -988,5 +993,51 @@
"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": "design",
+ "why": "a menu label, so 4.5:1 applies and 3.05 misses it clearly. Delete-item danger text on the hovered surface, dark only"
+ },
+ {
+ "selector": ".dx-splitter .dx-resize-handle",
+ "fgRole": "color-content-subtle",
+ "bgRole": "color-border",
+ "contrast": {
+ "light": 6,
+ "dark": 2.8
+ },
+ "decision": "design",
+ "why": "the grip is a glyph, so 3:1 applies and 2.8 misses it. content-subtle on a border role, dark only"
+ }
]
}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index 3b0c48040977..334f1451cb7f 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -37,6 +37,12 @@ type Open = {
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', 'design'];
+
+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 };
@@ -52,6 +58,7 @@ const run = (theme?: string): {
findings: (Finding & { slot?: string | null; slotLies?: { slotSays: string }; paints?: { properties: string[] } })[];
typography: Typography[];
ladders: (Ladder & { unusedRungs: unknown[] })[];
+ lowContrast: ContrastPair[];
} => JSON.parse(
execFileSync('node', [tool, '--json', ...(theme ? [`--theme=${theme}`] : [])], {
encoding: 'utf8',
@@ -200,3 +207,23 @@ test('every banked ladder carries a decision and a reason', () => {
.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([]);
+});
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index b03452743c46..f30e4579270c 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -607,6 +607,70 @@ const ladders = [];
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)));
+
// --- output ---------------------------------------------------------------------------------------
const count = (predicate) => findings.filter(predicate).length;
@@ -618,6 +682,9 @@ const summary = {
typographyOffGrid: typography.filter((t) => !t.roles.length).length,
typographyUnmarked: typography.filter((t) => !t.marker).length,
collapsedLadders: ladders.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
@@ -653,6 +720,7 @@ const md = () => {
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(`| 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('');
@@ -691,6 +759,22 @@ const md = () => {
+ ` - 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(`## 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');
@@ -753,7 +837,7 @@ const md = () => {
};
if (process.argv.includes('--json')) {
- console.log(JSON.stringify({ summary, findings, typography, ladders }, null, 2));
+ console.log(JSON.stringify({ summary, findings, typography, ladders, lowContrast }, null, 2));
} else if (process.argv.includes('--md')) {
console.log(md());
} else if (themeArg) {
From 4639389fcd4e86ce3598f68488866308be2d3ec5 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 17:07:16 +0400
Subject: [PATCH 07/23] Fluent-next: ask whether one concept gets one role
across all components
Every check so far reads a single declaration. This one asks what the task is named after - does the
theme paint the same thing the same way everywhere? Group by what the name says the thing IS
(modifiers + slot + state, sub-elements dropped) and compare across components.
24 concepts get more than one role, 9 of them across families. The headline:
invalid bg rest - six components, six roles, three families
checkBox bg-danger-shared #c50f1f / #e4554f
common content-danger-shared #c50f1f / #e4554f the invalid badge
radioButton border-danger-shared #c50f1f / #e4554f
slider border-danger #ee726a / #e4554f pale in light only
progressBar bg-danger #c50f1f / #c50f1f does not lighten in dark
gridBase bg-danger-subtler #fceae7 / #470004 a cell tint, legitimately other
Three of those six paint the identical colour and spell it from three different families. That costs
nothing to unify and, until it is unified, the next palette change moves some of them and not the
others - which is the failure the 262.10.1 remap already described and could not act on. Two more
concepts have the same cluster, both in the invalid ladder.
The distinction is the point and took two passes to get right: a concept whose roles resolve to one
colour is a spelling problem, one whose roles differ by a shade is a difference two components can
honestly have, and one that differs across families with different values is a design call. Banked
as spelling 3, shade 15, design 6.
Worth noting what this catches that the per-declaration checks cannot: radioButton's border role on
a bg slot is banked as an accepted rule-5 pair, and common's content role on the invalid badge sits
in another bucket entirely - only grouping by concept shows they are the same paint.
jest 184/184, stylelint 0.
---
.../devextreme-scss/tests/roles.baseline.json | 640 +++++++++++++++++-
packages/devextreme-scss/tests/roles.test.ts | 32 +
.../devextreme-scss/tools/review/roles.mjs | 89 ++-
3 files changed, 759 insertions(+), 2 deletions(-)
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index 1fbd9df279fe..061464c14244 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -30,7 +30,13 @@
"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."
+ "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."
],
"open": [
{
@@ -1039,5 +1045,637 @@
"decision": "design",
"why": "the grip is a glyph, so 3:1 applies and 2.8 misses it. content-subtle on a border role, dark only"
}
+ ],
+ "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-border-danger-shared-active"
+ ],
+ "families": [
+ "bg",
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "checkBox",
+ "role": "color-bg-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-border-danger-shared-hovered",
+ "color-border-danger"
+ ],
+ "families": [
+ "bg",
+ "border"
+ ],
+ "members": [
+ {
+ "folder": "checkBox",
+ "role": "color-bg-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": "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-border-danger",
+ "color-bg-danger",
+ "color-content-danger"
+ ],
+ "families": [
+ "content",
+ "border",
+ "bg"
+ ],
+ "members": [
+ {
+ "folder": "common",
+ "role": "color-content-static-dark"
+ },
+ {
+ "folder": "fieldset",
+ "role": "color-border-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"
+ }
]
}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index 334f1451cb7f..2a4fd573a2fc 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -38,6 +38,12 @@ const DECISIONS = ['confirmed', 'naming', 'rule-5', 'bridge', 'package-gap', 'de
const SLOT_DECISIONS = ['naming', 'hairline', 'rule-5', 'known', 'design'];
const LADDER_DECISIONS = ['no-rung', 'design'];
const CONTRAST_DECISIONS = ['graphic-ok', '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;
@@ -59,6 +65,7 @@ const run = (theme?: string): {
typography: Typography[];
ladders: (Ladder & { unusedRungs: unknown[] })[];
lowContrast: ContrastPair[];
+ concepts: (Concept & { clusters: unknown[]; oneColour: boolean })[];
} => JSON.parse(
execFileSync('node', [tool, '--json', ...(theme ? [`--theme=${theme}`] : [])], {
encoding: 'utf8',
@@ -227,3 +234,28 @@ test('every banked contrast pair carries a decision and a reason', () => {
.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([]);
+});
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index f30e4579270c..0c2a79a37252 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -671,6 +671,71 @@ const lowContrast = pairs
.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));
+}
+
// --- output ---------------------------------------------------------------------------------------
const count = (predicate) => findings.filter(predicate).length;
@@ -682,6 +747,10 @@ const summary = {
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,
contrastPairsMeasured: pairs.length,
contrastBelowAA: lowContrast.length,
contrastDarkOnly: lowContrast.filter((p) => p.contrast.light >= AA && p.contrast.dark < AA).length,
@@ -720,6 +789,7 @@ const md = () => {
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('');
@@ -759,6 +829,23 @@ const md = () => {
+ ` - 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(`## 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');
@@ -837,7 +924,7 @@ const md = () => {
};
if (process.argv.includes('--json')) {
- console.log(JSON.stringify({ summary, findings, typography, ladders, lowContrast }, null, 2));
+ console.log(JSON.stringify({ summary, findings, typography, ladders, lowContrast, concepts }, null, 2));
} else if (process.argv.includes('--md')) {
console.log(md());
} else if (themeArg) {
From 5ab72161bd5b9bc4177ceb98a4d4a9c4c1ef18c4 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 22:58:48 +0400
Subject: [PATCH 08/23] Fluent-next: fix the two role defects a standard
decides, not a person
I had banked six items as "design" on the grounds that a correction moves a pixel. That conflated
two different things. Where the current role fails an objective threshold and the role the design
system names for that property passes it, the decision belongs to the standard.
Two are exactly that, and each changes one mode only - the other is already identical:
valid-badge-content color: from a bg role. light 5.37 either way; dark 2.89 -> 4.73
(#107c10 on #242424 against content-success #509f4b). Below 3:1, so it
failed even the graphic floor, as green text on the dark surface
fieldset-...-invalid color: from a border role. dark 4.22 either way; light 2.9 -> 6.07
(#ee726a on white against content-danger #c50f1f)
Resolve diff over both bundles: exactly one value moved per mode, each the failing one. Nothing
else in 6428 declarations.
Measuring the rest changed two more records without changing code:
the delete item in the message-list menu is not our choice to fix - 3.05 in dark against 4.5, and
content-danger-hovered, the strongest role in the family, reaches only 3.87 on the same surface.
No danger content role meets AA on its own hovered surface in dark. Reclassified to package-gap
the splitter grip keeps its design flag but now carries the floor and the option: 2.8 against 3:1,
color-content clears it at 4.54, and choosing it also darkens the grip in light from 6 to 11.15
One measurement I withdrew rather than used: I read the switch's focused border against the page and
got 1.39 in dark, which would be damning for a boundary - but an ON switch is filled, so its border
sits on its own fill and the number answers a question nobody asked.
Side effect worth having: the concept "invalid content rest" drops from three families to two, and fieldset now
agrees with stepper on content-danger.
jest 184/184, stylelint 0.
---
.../widgets/fluent-next/common/_colors.scss | 2 +-
.../widgets/fluent-next/fieldset/_colors.scss | 2 +-
.../devextreme-scss/tests/roles.baseline.json | 34 ++++---------------
packages/devextreme-scss/tests/roles.test.ts | 2 +-
4 files changed, 9 insertions(+), 31 deletions(-)
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/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index 061464c14244..c793d0ad09cc 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -59,16 +59,6 @@
"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": "fieldset-field-value-invalid-content",
- "verdict": "family-conflict",
- "roles": [
- "color-border-danger"
- ],
- "slot": "content",
- "decision": "design",
- "why": "reads color: in fieldset/_index.scss:33 from a border role. Dark matches content-danger, light does not: ours #ee726a against content-danger #c50f1f. A light-only defect"
- },
{
"name": "gallery-nav-button-bg",
"verdict": "cross-family",
@@ -198,16 +188,6 @@
"slot": "bg",
"decision": "confirmed",
"why": "the package assigns content-disabled to indeterminate.icon.disabled - the dash is content. Our role is right; the slot word says bg because the property is background-color"
- },
- {
- "name": "valid-badge-content",
- "verdict": "family-conflict",
- "roles": [
- "color-bg-success"
- ],
- "slot": "content",
- "decision": "design",
- "why": "reads color: in common/_mixins.scss:52 from a bg role. Light matches content-success exactly, dark does not: ours #107c10 against a #242424 surface where the content role gives #509f4b. A dark-only defect, invisible to every etalon"
}
],
"typographyUnmarked": [
@@ -1031,8 +1011,8 @@
"light": 5.56,
"dark": 3.05
},
- "decision": "design",
- "why": "a menu label, so 4.5:1 applies and 3.05 misses it clearly. Delete-item danger text on the hovered surface, dark only"
+ "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",
@@ -1043,7 +1023,7 @@
"dark": 2.8
},
"decision": "design",
- "why": "the grip is a glyph, so 3:1 applies and 2.8 misses it. content-subtle on a border role, dark only"
+ "why": "2.8 in dark against 3:1 for a grip. A passing role exists - color-content reaches 4.54 - so the floor is set by the standard, but choosing which role also changes the grip in light (6 -> 11.15), and that part is a design call"
}
],
"concepts": [
@@ -1480,13 +1460,11 @@
"concept": "invalid content rest",
"roles": [
"color-content-static-dark",
- "color-border-danger",
- "color-bg-danger",
- "color-content-danger"
+ "color-content-danger",
+ "color-bg-danger"
],
"families": [
"content",
- "border",
"bg"
],
"members": [
@@ -1496,7 +1474,7 @@
},
{
"folder": "fieldset",
- "role": "color-border-danger"
+ "role": "color-content-danger"
},
{
"folder": "fileUploader",
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index 2a4fd573a2fc..8e1b9fb2493e 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -37,7 +37,7 @@ type Open = {
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', 'design'];
+const CONTRAST_DECISIONS = ['graphic-ok', 'package-gap', 'design'];
const CONCEPT_DECISIONS = ['spelling', 'shade', 'design'];
type Concept = {
From 69ecb91828255725375a6d22e6f987b7e63d7723 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 23:39:24 +0400
Subject: [PATCH 09/23] Fluent-next: record where a role decision was mine
rather than read off a source
Asked where the roles came from, the answer turned out to be three different answers, and one of
them needed saying out loud.
Seven of the twelve corrections match what another product already assigns, exactly:
switch on-border, rest + hovered core AND vnext: switch.color.checked.border.* =
border-primary-shared / -hovered
tabs selected bar, four core: tabs.item.color.selector.selected* =
border-primary-shared / -hovered / -active. The package calls
the bar a `selector`, we call it a border - the role is the
same one, and our slot word is the part that differs
textEditor invalid text core AND vnext: text-input.color.invalid.content.rest =
content-danger, with border-danger-shared kept for the border
Two are decided by a threshold, not by anyone's taste: the valid badge and the fieldset invalid
message, each failing AA in one mode and identical in the other.
Three are mine, and they go AGAINST the package. The checkBox invalid mark reads background-color,
so NAMING.md's rule puts it in the bg family - but core and blazor both model that element as an
icon and paint it from content roles. Both spellings resolve to the same colour in both modes, so
nothing renders differently; what differs is which name moves at the next palette change. Recorded
as open rather than settled.
Also recorded, because a fixed item leaves the lists above and its reasoning leaves with it: the
hairline idiom (independently confirmed afterwards by core's `selector` slot), the fact that the
classifiers are my construction and have already been revised three times to remove false findings,
and the bound on the whole report - no Microsoft Fluent documentation was consulted at any point.
Everything Fluent here arrives through the DevExpress token package and the three sibling products.
Where all four are silent - 277 declarations across 22 folders - the report has no external
authority at all, only the theme against itself.
jest 185/185.
---
.../devextreme-scss/tests/roles.baseline.json | 35 ++++++++++++++++++-
packages/devextreme-scss/tests/roles.test.ts | 14 ++++++++
2 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index c793d0ad09cc..57c77d5fd10c 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -36,7 +36,10 @@
"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."
+ "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": [
{
@@ -1655,5 +1658,35 @@
"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": "checkBox invalid mark: content-danger-shared* -> bg-danger-shared* (3 declarations)",
+ "against": "the package models this element as an icon and paints it from content roles (core/checkbox.color.checked.default.icon.rest = content-inverted; blazor paints the invalid mark as icon)",
+ "basis": "the repo's own rule, NAMING.md: the CSS property decides the slot, and the bundle shows background-color",
+ "cost": "none rendered - the two spellings resolve to the same colour in both modes. What differs is which name a future palette change moves",
+ "open": true
+ },
+ {
+ "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": "no Microsoft Fluent 2 documentation was consulted at any point",
+ "against": "nothing claimed otherwise, but it bounds the whole report",
+ "basis": "everything Fluent here arrives through @devexpress/design-tokens-internal - a Figma export of DevExpress's own Fluent-derived system - and the three sibling products' component tiers",
+ "cost": "where all four sets are silent (277 declarations, 22 folders) there is no external authority in this report at all, only the theme against itself",
+ "open": true
+ }
]
}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index 8e1b9fb2493e..bbaee01806a5 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -259,3 +259,17 @@ test('every banked concept split carries a decision and a reason', () => {
.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([]);
+});
From 6604d74c78ff58d470551e810ba2726faa5c1c8d Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Tue, 8 Sep 2026 23:46:29 +0400
Subject: [PATCH 10/23] Fluent-next: check four questions against Fluent 2
itself, and revert what it contradicts
Everything in this audit so far leaned on the DevExpress token package and the three sibling
products. Fluent 2's own sources answer four of the open questions, and one answer is against me.
Typography, the largest open cluster. The Fluent 2 web 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. So font-weight 500 and sizes 11/18/22/26/36px are legacy fluent values
with no home in Fluent 2, and the package is faithful rather than short of steps. design#1555 asks
which steps deserve roles; the question is the other way round - whether to keep the legacy values.
37 places, 17 of them the 500 weight.
Focus. Fluent 2 draws it as a two-tone stroke, colorStrokeFocus1 #ffffff inside colorStrokeFocus2
#000000, precisely so it survives any background. The four focus roles the package ships and the
theme reads nowhere are that, mirrored. Painting focus from an accent border role is not a shade
away from Fluent 2, it is a different mechanism.
The checkBox invalid mark - reverted. Fluent 2 builds a checkbox from colorCompoundBrandBackground
for the box and colorNeutralForegroundInverted for the check: the mark is foreground. The package
models it the same way, as an icon. I had moved it to a bg role on the strength of NAMING.md, but
that rule decides the SLOT WORD from the CSS property and says nothing about the role family -
reading it as both was mine. Value-neutral either way, and reverting also restores the checkbox's
own consistency, since its icon fill was left on a content role. Resolve diff 0 on both bundles.
Accordion hover and pressed. colorSubtleBackgroundHover #f5f5f5 and colorSubtleBackgroundPressed
#e0e0e0 are separate tokens in Fluent 2, and the package carries both values already. Not applied:
conforming moves a pixel away from legacy fluent, and NFR-1 is the product's call.
Recorded in the baseline as a fluent2 section, with the bound stated - four spot checks, not a
sweep, and the 277 declarations no product describes are still without external authority.
jest 185/185, stylelint 0.
---
.../widgets/fluent-next/checkBox/_colors.scss | 6 +-
.../devextreme-scss/tests/roles.baseline.json | 95 ++++++++++++++++---
2 files changed, 84 insertions(+), 17 deletions(-)
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss
index 5989969cf857..37c0716d6940 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/checkBox/_colors.scss
@@ -18,9 +18,9 @@ $check-box-invalid-bg: ds.$color-bg-danger-shared !default;
$check-box-invalid-bg-hovered: ds.$color-bg-danger-shared-hovered !default;
$check-box-invalid-bg-focused: ds.$color-bg-danger-shared-active !default;
-$check-box-invalid-mark-bg: ds.$color-bg-danger-shared !default;
-$check-box-invalid-mark-bg-hovered: ds.$color-bg-danger-shared-hovered !default;
-$check-box-invalid-mark-bg-focused: ds.$color-bg-danger-shared-active !default;
+$check-box-invalid-mark-bg: ds.$color-content-danger-shared !default;
+$check-box-invalid-mark-bg-hovered: ds.$color-content-danger-shared-hovered !default;
+$check-box-invalid-mark-bg-focused: ds.$color-content-danger-shared-active !default;
$check-box-border-disabled: ds.$color-border-disabled !default;
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index 57c77d5fd10c..df7d7f79a430 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -52,6 +52,36 @@
"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",
@@ -703,7 +733,7 @@
"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"
+ "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",
@@ -968,7 +998,7 @@
"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"
+ "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",
@@ -1294,10 +1324,12 @@
"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": [
@@ -1305,6 +1337,10 @@
"folder": "checkBox",
"role": "color-bg-danger-shared-active"
},
+ {
+ "folder": "checkBox",
+ "role": "color-content-danger-shared-active"
+ },
{
"folder": "radioButton",
"role": "color-border-danger-shared-active"
@@ -1317,11 +1353,13 @@
"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": [
@@ -1329,6 +1367,10 @@
"folder": "checkBox",
"role": "color-bg-danger-shared-hovered"
},
+ {
+ "folder": "checkBox",
+ "role": "color-content-danger-shared-hovered"
+ },
{
"folder": "radioButton",
"role": "color-border-danger-shared-hovered"
@@ -1361,6 +1403,10 @@
"folder": "checkBox",
"role": "color-bg-danger-shared"
},
+ {
+ "folder": "checkBox",
+ "role": "color-content-danger-shared"
+ },
{
"folder": "common",
"role": "color-content-danger-shared"
@@ -1660,13 +1706,6 @@
}
],
"judgmentCalls": [
- {
- "what": "checkBox invalid mark: content-danger-shared* -> bg-danger-shared* (3 declarations)",
- "against": "the package models this element as an icon and paints it from content roles (core/checkbox.color.checked.default.icon.rest = content-inverted; blazor paints the invalid mark as icon)",
- "basis": "the repo's own rule, NAMING.md: the CSS property decides the slot, and the bundle shows background-color",
- "cost": "none rendered - the two spellings resolve to the same colour in both modes. What differs is which name a future palette change moves",
- "open": true
- },
{
"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",
@@ -1682,11 +1721,39 @@
"open": false
},
{
- "what": "no Microsoft Fluent 2 documentation was consulted at any point",
- "against": "nothing claimed otherwise, but it bounds the whole report",
- "basis": "everything Fluent here arrives through @devexpress/design-tokens-internal - a Figma export of DevExpress's own Fluent-derived system - and the three sibling products' component tiers",
- "cost": "where all four sets are silent (277 declarations, 22 folders) there is no external authority in this report at all, only the theme against itself",
+ "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"
+ }
+ ]
+ }
}
From bc1e5261ac99e967ddc47139d85b8add6395a0b6 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Wed, 9 Sep 2026 01:39:40 +0400
Subject: [PATCH 11/23] Fluent-next: the package's focus-rect component was
invisible to the comparison
Asked whether the two handoff documents covered everything, they did not, and the reason was a bug
rather than an omission.
A package token path is ..color..., and the slot is
the rightmost segment matching one of our parts. `focus-rect.color.default` has no such segment, and
the fallback - treat the component name as the slot - only fires when that name is itself one of our
parts. `focus-rect` is not. So the entire component, four roles for the focus indicator, was dropped
as an unknown slot and never entered the comparison. The finding that opened this whole audit was
one I had made by hand; the tool could not have produced it.
Fixed with an explicit alias for the package components whose name IS the slot but is spelled
differently: focus-rect -> outline, skeleton -> bg, empty-item -> content.
That surfaced a class no check had: 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; a whole family can be
missing without any single declaration looking wrong. Counted from the package inward:
175 roles the four sets assign
82 the theme reads
83 exist in the semantic layer and go unread - capability we never reached for
10 no layer declares at all - stale names inside the neighbours' own sets
Two of the unread families are worth naming. All four focus roles - focus, -inverted, -static,
-static-inverted - are unread, which is the same finding as before, now produced by the tool instead
of by hand. And the whole info intent is unread: bg-info, content-info, border-info all exist, while
the components with an `info` modifier paint it from neutral roles.
Banked on exact equality both ways - a role leaving the list means the theme started using it, and
that is a diff worth seeing.
jest 186/186.
---
.../devextreme-scss/tests/roles.baseline.json | 105 ++++++++++++++++++
packages/devextreme-scss/tests/roles.test.ts | 12 ++
.../devextreme-scss/tools/review/roles.mjs | 59 +++++++++-
3 files changed, 174 insertions(+), 2 deletions(-)
diff --git a/packages/devextreme-scss/tests/roles.baseline.json b/packages/devextreme-scss/tests/roles.baseline.json
index df7d7f79a430..d74330a59801 100644
--- a/packages/devextreme-scss/tests/roles.baseline.json
+++ b/packages/devextreme-scss/tests/roles.baseline.json
@@ -1755,5 +1755,110 @@
"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"
+ ]
}
}
diff --git a/packages/devextreme-scss/tests/roles.test.ts b/packages/devextreme-scss/tests/roles.test.ts
index bbaee01806a5..9175c413f261 100644
--- a/packages/devextreme-scss/tests/roles.test.ts
+++ b/packages/devextreme-scss/tests/roles.test.ts
@@ -66,6 +66,7 @@ const run = (theme?: string): {
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',
@@ -273,3 +274,14 @@ test('judgment calls stay recorded with what they went against', () => {
.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);
+});
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index 0c2a79a37252..7d04bd4ee67c 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -123,6 +123,12 @@ const COMPONENT = {
* 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 = {
@@ -285,7 +291,7 @@ const dissect = (path) => {
* 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 = trailing(segments[0], PARTS);
+ 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('.') };
};
@@ -736,6 +742,39 @@ const concepts = [];
|| 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;
@@ -751,6 +790,10 @@ const summary = {
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,
@@ -829,6 +872,16 @@ const md = () => {
+ ` - 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');
@@ -924,7 +977,9 @@ const md = () => {
};
if (process.argv.includes('--json')) {
- console.log(JSON.stringify({ summary, findings, typography, ladders, lowContrast, concepts }, null, 2));
+ 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) {
From 9c03a6323a1ccdc8f0f263da73dc8c0103cf5207 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Wed, 9 Sep 2026 01:45:54 +0400
Subject: [PATCH 12/23] Fluent-next: generate the decision pages, and fix the
line numbers they print
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two pages, one generator, same source as the gate: tools/review/roles-pages.mjs reads
roles.mjs --json plus tests/roles.baseline.json and writes ROLES_QUESTIONS.html and
ROLES_TYPOGRAPHY.html next to SCALES.html and BRIDGES.html. Every question carries a number so an
answer can arrive as "Д3 - second option" without quoting it back, and nothing is filtered out -
what needs no decision is listed too, with the reason, so the set is closed rather than curated.
The pages are Russian while the baseline stays English: the baseline is read by the gate and lives
beside the code, the pages go to design. The duplication is held by a check - a banked item with no
Russian text throws during generation instead of shipping in English.
Two bugs found while building them.
The report was printing wrong line numbers. Block comments were stripped with a plain replace, which
removes their newlines too, so every reference after the first comment in a file was short by its
length - typography/_sizes.scss:69 came out as :65. Blanked instead of removed now. This affected
every line reference the audit has produced, including the two documents already handed over.
And the note explaining that fix contained a literal comment terminator inside a block comment,
which closed it early and broke the module. Rewritten without one.
Both pages regenerated and read end to end; jest 186/186, stylelint 0.
---
.../widgets/fluent-next/ROLES_QUESTIONS.html | 120 ++++++
.../widgets/fluent-next/ROLES_TYPOGRAPHY.html | 75 ++++
.../tools/review/roles-pages.mjs | 391 ++++++++++++++++++
.../devextreme-scss/tools/review/roles.mjs | 9 +-
4 files changed, 593 insertions(+), 2 deletions(-)
create mode 100644 packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
create mode 100644 packages/devextreme-scss/scss/widgets/fluent-next/ROLES_TYPOGRAPHY.html
create mode 100644 packages/devextreme-scss/tools/review/roles-pages.mjs
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..30e30a219b88
--- /dev/null
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
@@ -0,0 +1,120 @@
+
+
+
+Fluent-next: открытые вопросы по ролям
+
+
Fluent-next: открытые вопросы по ролям
+
Всё, что аудит нашёл и не стал решать сам. Ответы можно давать номерами: «Д3 — второй вариант».
+Проверено 714 цветовых объявлений в 64 папках; применено девять правок —
+семь равнозначных по значению и две решённые порогом WCAG. Всё на этой странице двигает пиксель,
+меняет публичное имя или требует расширения пакета.
+
+
А. Дизайн — 15 вопросов
+
Роль выбрана спорно
Д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
Content-роль красит border-color. Пакет для чекбокса разводит их: рамке неактивного состояния он даёт border-disabled (#d7d7d7 / #4c4c4c), заметно светлее нашего #ababab / #767676. Это прямой ответ на вопрос из журнала: 06.08 одно значение разложили на три роли и записали, что совпадение «видно в коде» — вот чем оно должно было разойтись.
Ближайшие роли верного семейства: border-subtle — двигает 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). Пол задаёт стандарт, выбор роли — нет.
+
Имя обещает одно, красит другое
Д8
+
list-border-disabled — слот обещает border, красит color
Border-роль красит текст. Неактивный текст списка сидит на #d7d7d7 / #4c4c4c, тогда как content-роль дала бы #ababab / #767676 — заметно контрастнее. Вопрос контраста, а не гигиены имён.
content-disabled красит фон индикатора неактивной вкладки. Bg-роли с таким значением нет.
+
Одно понятие покрашено по-разному в разных компонентах
Д10
+
Одно понятие, разные роли: accent border rest
Семейства: content / border
Компонент
Роль
diagram
content-primary
gantt
border-primary
scheduler
border
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д11
+
Одно понятие, разные роли: accent content rest
Семейства: bg / border / content
Компонент
Роль
chat
bg-primary
pivotGrid
border-primary
scheduler
content-primary
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д12
+
Одно понятие, разные роли: base bg rest
Семейства: border / bg
Компонент
Роль
list
border-subtle
stepper
bg
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д13
+
Одно понятие, разные роли: base content rest
Семейства: content / bg
Компонент
Роль
diagram
content-subtle
scheduler
bg-primary-subtle
stepper
content
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д14
+
Одно понятие, разные роли: invalid content rest
Семейства: content / bg
Компонент
Роль
common
content-static-dark
fieldset
content-danger
fileUploader
bg-danger
stepper
content-danger
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
Д15
+
Одно понятие, разные роли: menu bg rest
Семейства: bg / border
Компонент
Роль
list
bg
menu
bg
menu
border-subtle
Вопрос: должны ли эти компоненты красить одно и то же одинаково, и если да — какой ролью.
+
+
Б. Команда пакета токенов — 2 заявки
+
Роли, которая нужна, в пакете нет — обменять не на что.
П1
+
load-indicator-segment-inner-border
Внутренняя рамка лоад-индикатора красится bg-primary-subtle, потому что border-роли с этой насыщенностью в пакете не существует. Менять не на что — нужна роль border-primary-subtle.
Пункт удаления в контекстном меню списка сообщений даёт 3.05 при пороге 4.5 для подписи. Проверены все роли семейства: самая сильная, content-danger-hovered, даёт на той же поверхности 3.87 — тоже провал. Нет danger-content роли, проходящей AA на своей же наведённой поверхности в тёмном.
+
+
В. Унификация, правка бесплатна — 3
+
Компоненты кладут один и тот же цвет и пишут его ролями из разных семейств. Пока каноническая
+запись не выбрана, следующая перепривязка палитры разведёт их молча.
Правка ничего не двигает. Нужно назвать каноническую запись.
+
+
Г. Переименование компонентного тира — 21 имён
+
Роль верна, врёт слово в имени. Публичные имена --dx-*: тир эволюционирующий,
+переименование допустимо, но с ченджлогом и отдельной волной.
+
Имя
Слот обещает
Красит
+
color-view-handle-content
content
background, box-shadow
date-view-roller-bottom-shadow
shadow
background-image
date-view-roller-top-shadow
shadow
background-image
filter-builder-group-operation-content
content
background-color
filter-builder-group-operation-content-disabled
content
background-color
filter-builder-group-operation-content-focused
content
background-color
filter-builder-group-operation-content-hovered
content
background-color
filter-builder-item-field-content
content
background-color
filter-builder-item-field-content-disabled
content
background-color
filter-builder-item-field-content-focused
content
background-color
filter-builder-item-field-content-hovered
content
background-color
filter-builder-item-operator-content
content
background-color
filter-builder-item-operator-content-focused
content
background-color
filter-builder-item-operator-content-hovered
content
background-color
filter-builder-item-value-content
content
background-color
filter-builder-item-value-content-focused
content
background-color
filter-builder-item-value-content-hovered
content
background-color
pivot-grid-grand-total-content
content
background-color
pivot-grid-total-content
content
background-color
scheduler-appointment-content-active
content
background-color
scheduler-workspace-cell-content-focused
content
background-color
+
+
Четырнадцать из 21 — filterBuilder: его чипы уходят в базовый
+button-color(), который ставит фон, а названы они -content.
+
+
Д. Возможности пакета, которыми тема не пользуется — 83 ролей
+
Счёт от пакета внутрь, а не от наших объявлений наружу: целое семейство может отсутствовать, и при
+этом ни одно объявление не выглядит неверным. Из 175 ролей, которые
+назначают четыре набора, тема читает 82.
+
Н1
+
Индикатор фокуса — четыре роли пакета не читаются нигде
Тема красит фокус ролью границы border-primary-shared. Fluent 2 рисует фокус двухтонной обводкой — colorStrokeFocus1 #ffffff внутри colorStrokeFocus2 #000000, — чтобы она выживала на любом фоне. Это не оттенок в сторону, а другой механизм. Вариантов inverted и static у темы нет вовсе.
Н2
+
Интент info не используется
Пакет назначает bg-info · content-info · border-info и их состояния. Тема не читает ни одной: компоненты с модификатором info (informer, toast, pagination) красят его нейтральными ролями.
Н3
+
Лестница «на цветной поверхности» (on-color) не используется
Пакет описывает ими элементы, лежащие на залитой акцентом поверхности — например вариант on-surface чекбокса. У темы такого варианта нет.
Н4
+
Статические роли (static-dark / static-light) не используются
Роли, не меняющиеся между режимами. Тема вместо них берёт content-static-dark в отдельных местах, а поверхностные и границы — нет.
+Полный список 83 ролей
+
Роль
Назначают
+
bg-high
core, vnext
bg-info
blazor, core, vnext
bg-info-active
blazor, core, vnext
bg-info-hovered
blazor, core, vnext
bg-info-selected
blazor
bg-info-subtler
blazor, wpf
bg-info-subtler-active
wpf
bg-info-subtler-hovered
wpf
bg-info-subtler-selected
wpf
bg-inverted-active
blazor
bg-inverted-disabled
blazor
bg-inverted-hovered
blazor
bg-inverted-selected
blazor
bg-low-active
core, vnext
bg-on-color
core, wpf
bg-on-color-alpha
core, vnext
bg-on-color-alpha-active
core, vnext
bg-on-color-alpha-disabled
core, vnext
bg-on-color-alpha-hovered
core, vnext
bg-primary-shared-hovered
core, vnext
bg-static-dark
blazor, core, vnext
bg-static-dark-active
blazor
bg-static-dark-disabled
blazor
bg-static-dark-hovered
blazor
bg-static-dark-selected
blazor
bg-static-light-active
blazor
bg-static-light-disabled
blazor
bg-static-light-hovered
blazor
bg-static-light-selected
blazor
bg-success-shared
blazor
bg-success-shared-active
blazor
bg-success-shared-hovered
blazor
bg-warning-active
blazor, core, vnext
bg-warning-hovered
blazor, core, vnext
bg-warning-selected
blazor
bg-warning-subtler
blazor, vnext, wpf
bg-warning-subtler-active
wpf
bg-warning-subtler-hovered
wpf
bg-warning-subtler-selected
wpf
border-info
blazor
border-inverted
blazor
border-inverted-active
blazor
border-inverted-disabled
blazor
border-inverted-hovered
blazor
border-on-color-shared
core, vnext
border-on-color-shared-disabled
core
border-static-dark
blazor, core, vnext, wpf
border-static-dark-active
blazor
border-static-dark-disabled
blazor
border-static-dark-hovered
blazor
border-static-light
blazor
border-static-light-active
blazor
border-static-light-disabled
blazor
border-static-light-hovered
blazor
border-success-shared
blazor
border-success-shared-active
blazor
border-success-shared-hovered
blazor
border-warning
blazor, vnext
content-info
blazor, wpf
content-info-active
blazor, core, vnext
content-info-hovered
blazor, core, vnext
content-inverted-disabled
blazor
content-on-color
blazor, core, vnext
content-on-color-disabled
core, vnext
content-on-color-shared
wpf
content-on-color-shared-disabled
core
content-on-color-subtler
core, vnext
content-on-subtle-primary
wpf
content-primary-shared
core, vnext
content-primary-shared-active
core, vnext
content-primary-shared-hovered
core, vnext
content-static-dark-disabled
blazor
content-static-light-disabled
blazor, wpf
content-success-shared
blazor
content-success-shared-active
blazor
content-success-shared-hovered
blazor
content-warning
blazor, vnext, wpf
content-warning-active
blazor, core, vnext
content-warning-hovered
blazor, core, vnext
focus
core, vnext
focus-inverted
core, vnext
focus-static
core, vnext
focus-static-inverted
core, vnext
+
+
+
Е. Решения не требуется — записано, чтобы не переоткрывали
+
Класс
Сколько
Почему закрыто
+
confirmed
6
пакет назначает ровно эту роль — расходится только слово слота
rule-5
12
одно значение в двух свойствах, названо по доминирующей роли; принято ревью-раундом 2
hairline
11
волосяная линия фоном сохраняет border-роль; подтверждено core (у tabs это слот selector)
no-rung
22
состояния схлопнуты там же, где их схлопывает сама система
shade
15
то же семейство, другой оттенок — расхождение, которое компоненты вправе иметь
graphic-ok
2
глиф, порог 3:1 взят
bridge / known
2
уже инвентаризовано в BRIDGES.md и DIVERGENCES.md
stale в наборах соседей
10
blazor и wpf ссылаются на имена, которых семантический слой не объявляет — их дрейф, не наш
+
+
+
Ж. Чего эта проверка не видела
+
39% темы сравнить не с чем. 277 цветовых объявлений
+из 714 — в компонентах, которых нет ни у core, ни у vnext, ни у blazor, ни у
+wpf: filterBuilder, gantt, scheduler, diagram, pivotGrid и других. По ним работали только проверки
+темы против себя самой; внешнего авторитета нет ни у пакета, ни у Fluent 2.
+
Правки инструмента шли в одну сторону. Конфликтов семейств 11 → 3, cross-family 24 → 15,
+кнопочных лестниц 8 → 0. Каждое сокращение проверено вручную и описано в коммите, но направление у
+них одно: скорее недосчитал, чем перебрал.
Материал к design#1555.
+37 мест вне сетки и ещё 31, где роль есть, а тема читает ступень.
+Типографика не зависит от режима — у каждого места одно значение, светлая и тёмная темы одинаковы.
+
+
Главное: карточка сформулирована в обратную сторону
+
design#1555 спрашивает, «каким ступеням нужны семантические роли». Сверка с Fluent 2 показывает,
+что вопрос не к пакету:
+
+
веб-рампа Fluent 2 знает три начертания — Regular, Semibold, Bold. Medium (500) в ней нет;
+
её размеры — 10 / 12 / 14 / 16 / 20 / 24 / 28 / 32 / 40 / 68 px, и сетка ролей пакета
+повторяет их точно, кроме Display 68.
+
+
Ни font-weight: 500, ни размеры 11 / 18 / 22 / 26 / 30 / 36 px в Fluent 2 не существуют.
+Пакет верен источнику. Все эти места — значения, унаследованные от legacy-темы fluent, которые
+миграция сохранила по требованию «визуально это тот же fluent» (NFR-1).
+
Решать нужно не «расширять ли пакет», а что важнее в каждом месте: совпадение с legacy или
+соответствие Fluent 2.
+
+
Решения
+
Т1
+
font-weight: 500 — 17 мест
Ближайшие роли: 400 (Regular) и 600 (Semibold). Промежуточного веса у Fluent 2 нет, и у Segoe UI грани 500 тоже нет — на Windows эти места уже сегодня рендерятся как Regular 400, просто непредсказуемо по платформам.
Почти всё это заголовки групп, метки и подписи, то есть элементы с усилением. Колонка «предложение» — наша рекомендация, не решение.
Рампа заголовков — 4 места, следствие на все приложения
Самое дорогое решение: --dx-font-size-heading-1…6 — публичные переменные, их читают приложения.
h1
h2
h3
h4
h5
h6
сейчас, default
40 ✓
36
32 ✓
26
22
22
сейчас, compact
32 ✓
28 ✓
24 ✓
20 ✓
16 ✓
16 ✓
Fluent 2 / сетка ролей
40
32
28
24
20
16
✓ — значение уже лежит на роли.
Compact-ветка целиком на сетке, а default — нет. У default совпадают только h1 и h3. Если привести default к той же логике, что уже действует в compact, получится ровно ряд Fluent 2: 40 / 32 / 28 / 24 / 20 / 16. Цена: h2 36→32, h4 26→24, h5 22→20, h6 22→16 — последнее самое заметное.
Отдельно: $typography-s-font-size (18px) формально попадает в список, но это утилита.dx-font-sm, а не текстовая роль — в коде так и написано, что ряд xl/l/m/s/xs намеренно сидит на базовой шкале. Трогать не предлагаем.
Т3
+
Одиночные размеры компонентов — 7 мест
Где
Переменная
Сейчас
Вниз
Вверх
chat/_sizes.scss:102
chat-message-list-empty-message-font-size
18px
16px base-lg
20px title-md
fileManager/_sizes.scss:67
file-manager-progress-title-font-size
18px
16px base-lg
20px title-md
scheduler/_sizes.scss:229
scheduler-appointment-icon-font-size
18px
16px base-lg
20px title-md
scheduler/_sizes.scss:288
scheduler-group-header-agenda-font-size
18px
16px base-lg
20px title-md
scheduler/_sizes.scss:146
scheduler-appointment-tooltip-date-font-size
11px
10px caption-sm
12px caption-md
scheduler/_sizes.scss:266
scheduler-appointment-content-details-font-size
11px
10px caption-sm
12px caption-md
scheduler/_sizes.scss:308
scheduler-time-indicator-font-size
30px
28px headline-md
32px headline-lg
Большая часть — scheduler. Если решение по нему будет одно («округляем вниз» или «вверх»), оно закроет список почти целиком.
Т4
+
line-height вне сетки — 8 мест
Оговорка: шкала межстрочного у пакета не полностью повторяет Fluent 2 — у Fluent 2 есть 22 и 26, у пакета вместо них 24 и 28. Наши значения 12 и 18 не встречаются ни там, ни там.
Где
Переменная
Сейчас
Ближайшая роль
diagram/_sizes.scss:94
diagram-popup-close-icon-line-height
12px
14px caption-sm · 14px base-xs
pagination/_sizes.scss:9
pagination-page-line-height
12px
14px caption-sm · 14px base-xs
pivotGrid/_sizes.scss:17
pivot-grid-indicators-line-height
12px
14px caption-sm · 14px base-xs
scheduler/_sizes.scss:44
scheduler-appointment-10min-title-line-height
12px
14px caption-sm · 14px base-xs
scheduler/_sizes.scss:175
scheduler-appointment-15min-title-line-height
12px
14px caption-sm · 14px base-xs
validation/_sizes.scss:25
validation-message-line-height
12px
14px caption-sm · 14px base-xs
gridBase/_sizes.scss:32
grid-header-line-height
18px
16px caption-md · 16px base-sm
scheduler/_sizes.scss:127
scheduler-appointment-15min-title-line-height
18px
16px caption-md · 16px base-sm
У большинства межстрочное меньше самой низкой роли. Три из них — аппойнтменты scheduler на 10 и 15 минут, где высота строки прижата к высоте ячейки: там 12→14 может не поместиться, это надо смотреть на макете, а не решать по таблице.
Т5
+
Роль существует, а тема читает ступень — 31 мест
Здесь значение не меняется: роль, называющая эту ступень, резолвится в неё же. Перевод равнозначен по построению. Решить нужно только, какая роль — на одной ступени их бывает несколько (caption / base / title), и это выбор смысла, а не значения.
11 из них не несут даже маркера: гейт px-audit смотрит только на литералы, а чтение ступени — не литерал, поэтому они не доезжали ни до SCALES.md, ни до дизайна.
Где
Переменная
Читает
Роли с этой ступенью
Маркер
chat/_sizes.scss:112
chat-file-icon-font-size
20px
title-md
icon-glyph-size
chat/_sizes.scss:138
chat-message-list-empty-message-font-size
14px
caption-lg · base-md · title-xs
no-semantic-role
chat/_sizes.scss:148
chat-file-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
checkBox/_sizes.scss:5
check-box-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
diagram/_sizes.scss:93
diagram-popup-close-icon-font-size
12px
caption-md · base-sm
icon-glyph-size
fileUploader/_sizes.scss:22
file-uploader-file-icon-font-size
20px
title-md
icon-glyph-size
fileUploader/_sizes.scss:31
file-uploader-file-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
gallery/_sizes.scss:8
gallery-nav-icon-font-size
32px
headline-lg
icon-glyph-size
gridBase/_sizes.scss:166
grid-column-chooser-title-font-size
16px
base-lg · title-sm
нет
scheduler/_sizes.scss:45
scheduler-appointment-10min-icon-font-size
12px
caption-md · base-sm
icon-glyph-size
scheduler/_sizes.scss:50
scheduler-appointment-15min-icon-font-size
12px
caption-md · base-sm
icon-glyph-size
scheduler/_sizes.scss:98
scheduler-appointment-tooltip-date-font-size
14px
caption-lg · base-md · title-xs
no-semantic-role
scheduler/_sizes.scss:302
scheduler-appointment-month-text-font-size
12px
caption-md · base-sm
нет
scheduler/_sizes.scss:310
scheduler-small-font-size
14px
caption-lg · base-md · title-xs
нет
scheduler/_sizes.scss:312
scheduler-month-date-text-font-size
16px
base-lg · title-sm
нет
scrollView/_sizes.scss:4
scroll-view-load-indicator-font-size
24px
title-lg · headline-sm
icon-glyph-size
scrollView/_sizes.scss:12
scroll-view-pull-down-icon-font-size
24px
title-lg · headline-sm
icon-glyph-size
tabs/variables/_sizes.scss:39
tabs-tab-nav-button-icon-font-size
20px
title-md
icon-glyph-size
tabs/variables/_sizes.scss:48
tabs-tab-nav-button-icon-font-size
16px
base-lg · title-sm
icon-glyph-size
typography/_sizes.scss:8
typography-xs-font-size
12px
caption-md · base-sm
нет
typography/_sizes.scss:23
typography-l-font-size
28px
headline-md
нет
typography/_sizes.scss:24
typography-m-font-size
20px
title-md
нет
typography/_sizes.scss:36
typography-xl-font-size
24px
title-lg · headline-sm
нет
typography/_sizes.scss:37
typography-l-font-size
20px
title-md
нет
typography/_sizes.scss:38
typography-m-font-size
16px
base-lg · title-sm
нет
typography/_sizes.scss:39
typography-s-font-size
14px
caption-lg · base-md · title-xs
нет
typography/_sizes.scss:42
typography-heading-2-font-size
28px
headline-md
no-semantic-role
typography/_sizes.scss:44
typography-heading-4-font-size
20px
title-md
no-semantic-role
typography/_sizes.scss:45
typography-heading-5-font-size
16px
base-lg · title-sm
no-semantic-role
typography/_sizes.scss:46
typography-heading-6-font-size
16px
base-lg · title-sm
no-semantic-role
validation/_sizes.scss:18
validation-message-line-height
16px
caption-md · base-sm
no-semantic-role
+
+
Если решения приняты
+
Правки механические, значения меняются ровно в перечисленных строках, режимы не расходятся.
+После них потребуется пересъёмка эталонов скриншотов затронутых компонентов — отдельный проход,
+а не «заодно».
+
+
+
diff --git a/packages/devextreme-scss/tools/review/roles-pages.mjs b/packages/devextreme-scss/tools/review/roles-pages.mjs
new file mode 100644
index 000000000000..a91a8cf4af88
--- /dev/null
+++ b/packages/devextreme-scss/tools/review/roles-pages.mjs
@@ -0,0 +1,391 @@
+/*
+ * 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 } 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,
+}));
+
+const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
+const code = (s) => `${esc(s)}`;
+const roleList = (rs) => rs.map((r) => code(r.replace(/^color-/, ''))).join(' · ');
+
+const CSS = `
+:root { color-scheme: light dark; --fg:#161616; --bg:#fff; --muted:#616161; --line:#e1e1e1;
+ --accent:#0f6cbd; --warn:#c50f1f; --panel:#f8f8f8; }
+@media (prefers-color-scheme: dark) { :root { --fg:#f5f5f5; --bg:#242424; --muted:#a1a1a1;
+ --line:#4c4c4c; --accent:#4b90d9; --warn:#e4554f; --panel:#1d1d1d; } }
+* { box-sizing: border-box; }
+body { margin:0; padding:2.5rem 1.5rem 6rem; background:var(--bg); color:var(--fg);
+ font:15px/1.6 "Segoe UI", system-ui, sans-serif; }
+main { max-width: 62rem; margin: 0 auto; }
+h1 { font-size:1.9rem; font-weight:600; margin:0 0 .3rem; letter-spacing:-.01em; }
+h2 { font-size:1.3rem; font-weight:600; margin:3rem 0 .6rem; padding-top:1.2rem;
+ border-top:2px solid var(--line); }
+h3 { font-size:1.05rem; font-weight:600; margin:2rem 0 .5rem; }
+p, li { margin:.5rem 0; }
+.lede { color:var(--muted); margin-bottom:2rem; }
+table { border-collapse:collapse; width:100%; margin:.9rem 0; font-size:.92em; }
+th, td { border:1px solid var(--line); padding:.45rem .6rem; text-align:left; vertical-align:top; }
+th { background:var(--panel); font-weight:600; }
+code { font:.88em ui-monospace, "Cascadia Code", Menlo, monospace;
+ background:var(--panel); padding:.1em .35em; border-radius:3px; }
+.q { border:1px solid var(--line); border-left:3px solid var(--accent); border-radius:4px;
+ padding:.9rem 1.1rem; margin:1.1rem 0; background:var(--panel); }
+.q > .id { font-weight:600; color:var(--accent); font-size:.85em; letter-spacing:.06em;
+ text-transform:uppercase; display:block; margin-bottom:.25rem; }
+.q > .t { font-weight:600; margin-bottom:.4rem; }
+.q .opt { margin:.5rem 0 0 0; padding-left:1.2rem; }
+.warn { color:var(--warn); font-weight:600; }
+.swatch { display:inline-block; width:.85em; height:.85em; border:1px solid var(--line);
+ border-radius:2px; vertical-align:-.1em; margin-right:.3em; }
+.meta { color:var(--muted); font-size:.88em; }
+.none { color:var(--muted); font-style:italic; }
+footer { margin-top:4rem; padding-top:1rem; border-top:1px solid var(--line);
+ color:var(--muted); font-size:.85em; }
+`;
+
+const page = (title, bodyHtml) => `
+
+
+${esc(title)}
+${bodyHtml}
+
+
+`;
+
+const q = (id, title, bodyHtml) => `
${esc(id)}
+
${title}
${bodyHtml}
`;
+
+const swatch = (hex) => (/^#[0-9a-f]{3,8}$/i.test(hex ?? '') ? `` : '');
+const pair = (light, dark) => `${swatch(light)}${code(light ?? '?')} / ${swatch(dark)}${code(dark ?? '?')}`;
+
+
+// ---------------------------------------------------------------------------------------------
+// страница 1 — вопросы, которые решает человек
+// ---------------------------------------------------------------------------------------------
+
+const find = (name) => data.findings.find((f) => f.name === name);
+const values = (name) => {
+ const f = find(name);
+ return f?.swap?.ours ? pair(f.swap.ours.light, f.swap.ours.dark) : '';
+};
+
+
+/*
+ * Русский текст вопросов. Базлайн остаётся английским - он читается гейтом и живёт рядом с кодом,
+ * - а страница уходит дизайну, поэтому текст здесь. Дублирование удерживается проверкой ниже:
+ * запись без перевода роняет генерацию, а не выходит на страницу по-английски.
+ */
+const RU = {
+ 'gallery-nav-button-bg': 'Content-роль использована как подложка под навигационными кнопками поверх картинок. Bg-роли с таким значением в пакете нет, а сам пакет красит этой ролью глиф стрелки, а не диск под ним. Вопрос: нужна ли отдельная роль затемняющей подложки, или диск берёт существующую bg-роль и меняет тон.',
+ 'gallery-nav-button-bg-disabled': 'Тот же вопрос о подложке, неактивное состояние.',
+ 'switch-on-border-focused': 'Два соседа по трио (покой и наведение) уже переведены на border-роли равнозначно. Третий не переведён: border-primary-shared-active совпадает в светлом и двигает тёмный — #003c70 → #005397. Вопрос: принимаем сдвиг ради однородности трио.',
+ 'tree-view-checkbox-border-disabled': 'Content-роль красит border-color. Пакет для чекбокса разводит их: рамке неактивного состояния он даёт border-disabled (#d7d7d7 / #4c4c4c), заметно светлее нашего #ababab / #767676. Это прямой ответ на вопрос из журнала: 06.08 одно значение разложили на три роли и записали, что совпадение «видно в коде» — вот чем оно должно было разойтись.',
+ 'accordion-title-bg': 'Наведение и нажатие читают одну роль: нажать на заголовок аккордеона выглядит ровно как навести. Fluent 2 здесь однозначен — colorSubtleBackgroundHover #f5f5f5 и colorSubtleBackgroundPressed #e0e0e0 у него разные токены, и пакет несёт оба значения как bg-hovered / bg-active. Не применили только потому, что приведение уводит пиксель от legacy-fluent, а это решение продукта (NFR-1).',
+ 'tile-view-bg': 'Покой и наведение — одно значение, а нажатие отличается. Слот явно задуман с лестницей, плоская у него ступень наведения. Доказательство слабее, чем у аккордеона: плитка не обязана вести себя как «subtle surface» из Fluent 2.',
+ '.dx-splitter .dx-resize-handle': 'Грип ресайза даёт 2.8 в тёмном при пороге 3:1 для нетекстового элемента управления. Проходящая роль есть — content даёт 4.54, — но она же делает грип заметно темнее в светлом (6 → 11.15). Пол задаёт стандарт, выбор роли — нет.',
+ 'list-border-disabled': 'Border-роль красит текст. Неактивный текст списка сидит на #d7d7d7 / #4c4c4c, тогда как content-роль дала бы #ababab / #767676 — заметно контрастнее. Вопрос контраста, а не гигиены имён.',
+ 'tabs-tab-border-disabled': 'content-disabled красит фон индикатора неактивной вкладки. Bg-роли с таким значением нет.',
+ '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;
+};
+
+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(num('Д'), `${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(num('Д'),
+ `${code(x.stem)} — состояния ${x.states.map((s2) => code(s2)).join(' = ')} дают одну роль ${roleList(x.role)}`,
+ `
Тема красит фокус ролью границы border-primary-shared. Fluent 2 рисует фокус '
+ + 'двухтонной обводкой — colorStrokeFocus1 #ffffff внутри '
+ + 'colorStrokeFocus2 #000000, — чтобы она выживала на любом фоне. Это не оттенок в '
+ + 'сторону, а другой механизм. Вариантов inverted и static у темы нет вовсе.
'),
+ q(num('Н'), 'Интент info не используется',
+ `
Пакет назначает ${roleList(['color-bg-info', 'color-content-info', 'color-border-info'])} и их состояния. `
+ + 'Тема не читает ни одной: компоненты с модификатором info (informer, toast, pagination) '
+ + 'красят его нейтральными ролями.
'),
+ q(num('Н'), 'Лестница «на цветной поверхности» (on-color) не используется',
+ `
Пакет описывает ими элементы, лежащие на залитой акцентом поверхности — например вариант '
+ + '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 questionsPage = page('Fluent-next: открытые вопросы по ролям', `
+
Fluent-next: открытые вопросы по ролям
+
Всё, что аудит нашёл и не стал решать сам. Ответы можно давать номерами: «Д3 — второй вариант».
+Проверено ${data.summary.declarations} цветовых объявлений в 64 папках; применено девять правок —
+семь равнозначных по значению и две решённые порогом WCAG. Всё на этой странице двигает пиксель,
+меняет публичное имя или требует расширения пакета.
Одно понятие покрашено по-разному в разных компонентах
${conceptRows.join('')}
+
+
Б. Команда пакета токенов — ${pkgQs.length} заявки
+
Роли, которая нужна, в пакете нет — обменять не на что.
${pkgQs.join('')}
+
+
В. Унификация, правка бесплатна — ${spellQs.length}
+
Компоненты кладут один и тот же цвет и пишут его ролями из разных семейств. Пока каноническая
+запись не выбрана, следующая перепривязка палитры разведёт их молча.
${spellQs.join('')}
+
+
Г. Переименование компонентного тира — ${renames.length} имён
+
Роль верна, врёт слово в имени. Публичные имена --dx-*: тир эволюционирующий,
+переименование допустимо, но с ченджлогом и отдельной волной.
+
Имя
Слот обещает
Красит
+${renames.map((x) => `
${code(x.name)}
${esc(x.slotSays)}
${x.paints.map(esc).join(', ')}
`).join('')}
+
+
Четырнадцать из ${renames.length} — filterBuilder: его чипы уходят в базовый
+button-color(), который ставит фон, а названы они -content.
+
+
Д. Возможности пакета, которыми тема не пользуется — ${data.unusedRoles.capability.length} ролей
+
Счёт от пакета внутрь, а не от наших объявлений наружу: целое семейство может отсутствовать, и при
+этом ни одно объявление не выглядит неверным. Из ${data.summary.rolesOffered} ролей, которые
+назначают четыре набора, тема читает ${data.summary.rolesRead}.
+${unusedQs.join('')}
+Полный список ${data.unusedRoles.capability.length} ролей
+
Роль
Назначают
+${data.unusedRoles.capability.map((u) => `
${code(u.role.replace(/^color-/, ''))}
${u.sets.join(', ')}
`).join('')}
+
+
+
Е. Решения не требуется — записано, чтобы не переоткрывали
+
Класс
Сколько
Почему закрыто
+${noAction.map(([k, c, why]) => `
${code(k)}
${c}
${esc(why)}
`).join('')}
+
+
+
Ж. Чего эта проверка не видела
+
39% темы сравнить не с чем. ${data.summary.byVerdict['no-counterpart']} цветовых объявлений
+из ${data.summary.declarations} — в компонентах, которых нет ни у core, ни у vnext, ни у blazor, ни у
+wpf: filterBuilder, gantt, scheduler, diagram, pivotGrid и других. По ним работали только проверки
+темы против себя самой; внешнего авторитета нет ни у пакета, ни у Fluent 2.
+
Правки инструмента шли в одну сторону. Конфликтов семейств 11 → 3, cross-family 24 → 15,
+кнопочных лестниц 8 → 0. Каждое сокращение проверено вручную и описано в коммите, но направление у
+них одно: скорее недосчитал, чем перебрал.
+`);
+
+writeFileSync(join(themeDir, 'ROLES_QUESTIONS.html'), questionsPage);
+console.log('ROLES_QUESTIONS.html');
+
+// ---------------------------------------------------------------------------------------------
+// страница 2 — типографика
+// ---------------------------------------------------------------------------------------------
+
+const px = (rem) => (typeof rem === 'string' && rem.endsWith('rem') ? `${parseFloat(rem) * 16}px` : String(rem));
+const offGrid = data.typography.filter((t) => !t.roles.length);
+const onGrid = data.typography.filter((t) => t.roles.length);
+
+const byStep = new Map();
+for (const t of offGrid) {
+ const k = `${t.family}-${t.step}`;
+ if (!byStep.has(k)) byStep.set(k, []);
+ byStep.get(k).push(t);
+}
+const order = ['font-weight', 'font-size', 'line-height'];
+const stepGroups = [...byStep.entries()].sort((a, b) => order.indexOf(a[1][0].family) - order.indexOf(b[1][0].family)
+ || b[1].length - a[1].length);
+
+// одно исключение: имя файла это данные, а не заголовок
+const WEIGHT_400 = new Set(['file-uploader-file-name-font-weight']);
+
+n = 0;
+const weightPlaces = byStep.get('font-weight-500') ?? [];
+const weightQ = q(num('Т'), `font-weight: 500 — ${weightPlaces.length} мест`,
+ '
Ближайшие роли: 400 (Regular) и 600 (Semibold). Промежуточного веса у Fluent 2 нет, '
+ + 'и у Segoe UI грани 500 тоже нет — на Windows эти места уже сегодня рендерятся как Regular 400, '
+ + 'просто непредсказуемо по платформам.
'
+ + '
Почти всё это заголовки групп, метки и подписи, то есть элементы с усилением. Колонка '
+ + '«предложение» — наша рекомендация, не решение.
${WEIGHT_400.has(t.variable) ? '400 — имя файла это данные, не заголовок' : '600'}
`).join('')
+ + '
');
+
+const headingQ = q(num('Т'), 'Рампа заголовков — 4 места, следствие на все приложения',
+ '
Самое дорогое решение: --dx-font-size-heading-1…6 — публичные переменные, их читают приложения.
'
+ + '
h1
h2
h3
h4
h5
h6
'
+ + '
сейчас, default
40 ✓
36
32 ✓
'
+ + '
26
22
22
'
+ + '
сейчас, compact
32 ✓
28 ✓
24 ✓
20 ✓
16 ✓
16 ✓
'
+ + '
Fluent 2 / сетка ролей
40
32
28
24
20
16
'
+ + '
✓ — значение уже лежит на роли.
'
+ + '
Compact-ветка целиком на сетке, а default — нет. У default совпадают только h1 и h3. '
+ + 'Если привести default к той же логике, что уже действует в compact, получится ровно ряд Fluent 2: '
+ + '40 / 32 / 28 / 24 / 20 / 16. Цена: h2 36→32, h4 26→24, h5 22→20, h6 22→16 — последнее самое заметное.
'
+ + '
Отдельно: $typography-s-font-size (18px) формально попадает в список, но это '
+ + 'утилита.dx-font-sm, а не текстовая роль — в коде так и написано, что ряд xl/l/m/s/xs '
+ + 'намеренно сидит на базовой шкале. Трогать не предлагаем.
Оговорка: шкала межстрочного у пакета не полностью повторяет Fluent 2 — у Fluent 2 есть 22 и 26, '
+ + 'у пакета вместо них 24 и 28. Наши значения 12 и 18 не встречаются ни там, ни там.
У большинства межстрочное меньше самой низкой роли. Три из них — аппойнтменты scheduler '
+ + 'на 10 и 15 минут, где высота строки прижата к высоте ячейки: там 12→14 может не поместиться, это надо '
+ + 'смотреть на макете, а не решать по таблице.
Здесь значение не меняется: роль, называющая эту ступень, резолвится в неё же. Перевод '
+ + 'равнозначен по построению. Решить нужно только, какая роль — на одной ступени их бывает '
+ + 'несколько (caption / base / title), и это выбор смысла, а не значения.
'
+ + `
${unmarked.length} из них не несут даже маркера: гейт px-audit смотрит только `
+ + 'на литералы, а чтение ступени — не литерал, поэтому они не доезжали ни до SCALES.md, ни до дизайна.
Материал к design#1555.
+${offGrid.length} мест вне сетки и ещё ${onGrid.length}, где роль есть, а тема читает ступень.
+Типографика не зависит от режима — у каждого места одно значение, светлая и тёмная темы одинаковы.
+
+
Главное: карточка сформулирована в обратную сторону
+
design#1555 спрашивает, «каким ступеням нужны семантические роли». Сверка с Fluent 2 показывает,
+что вопрос не к пакету:
+
+
веб-рампа Fluent 2 знает три начертания — Regular, Semibold, Bold. Medium (500) в ней нет;
+
её размеры — 10 / 12 / 14 / 16 / 20 / 24 / 28 / 32 / 40 / 68 px, и сетка ролей пакета
+повторяет их точно, кроме Display 68.
+
+
Ни font-weight: 500, ни размеры 11 / 18 / 22 / 26 / 30 / 36 px в Fluent 2 не существуют.
+Пакет верен источнику. Все эти места — значения, унаследованные от legacy-темы fluent, которые
+миграция сохранила по требованию «визуально это тот же fluent» (NFR-1).
+
Решать нужно не «расширять ли пакет», а что важнее в каждом месте: совпадение с legacy или
+соответствие Fluent 2.
Правки механические, значения меняются ровно в перечисленных строках, режимы не расходятся.
+После них потребуется пересъёмка эталонов скриншотов затронутых компонентов — отдельный проход,
+а не «заодно».
+`);
+
+writeFileSync(join(themeDir, 'ROLES_TYPOGRAPHY.html'), typoPage);
+console.log('ROLES_TYPOGRAPHY.html');
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index 7d04bd4ee67c..8eee1cefc9f9 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -221,6 +221,11 @@ const sameValue = (a, b) => MODES.every((mode) => {
// --- 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);
@@ -230,7 +235,7 @@ const colourFiles = (dir) => readdirSync(dir).flatMap((entry) => {
const declarations = [];
for (const file of colourFiles(themeDir)) {
const folder = relative(themeDir, file).split('/')[0];
- const source = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+ 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);
@@ -364,7 +369,7 @@ const sizeFiles = (dir) => readdirSync(dir).flatMap((entry) => {
});
for (const file of sizeFiles(themeDir)) {
const folder = relative(themeDir, file).split('/')[0];
- readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').split('\n').forEach((line, index) => {
+ 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;
From fca2da76b7c52d8dae8b6a810bfa0b718b3a5651 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Wed, 9 Sep 2026 02:38:38 +0400
Subject: [PATCH 13/23] Fluent-next: half the coverage gap was my spelling, not
a missing counterpart
Asked how the 39% with nothing to compare against could be reduced, the first answer turned out to
need no new source at all. The folder-to-component map was matching on name similarity, and that
does not survive contact with another product's vocabulary: cardView is the package's `grid` - the
way tools/review/package-disabled.mjs has mapped it all along - a speed-dial action is a button, a
lookup is a listbox, an action sheet is a popup, validation is the `field` component.
Six mappings added. no-counterpart 277 -> 226, and not one new disagreement: all 51 newly compared
declarations landed in agrees or agrees-kin. That is worth stating plainly, because a mapping that
produced no findings could equally mean it is wrong - here it means cardView really does share the
grid's roles and a FAB really is painted like a button.
Measured for the record, so the next step is a choice rather than a guess. Of the 226 left:
110 have a counterpart in Blazor's implementation SCSS, which the token package does not carry.
dxvcs/.../ds-themes/components has 57 folders against the 20 in components/blazor, and the
names differ again - `rollers` is our dateView, `edit-dropdown` our dropDownEditor. It reads
roles through the same `ds.$` bridge we do, so the comparison needs no name map:
scheduler 43 (38 roles there), filterBuilder 28 (59), pivotGrid 19, fileUploader 10,
dateView 5, splitterBar 3, dropDownEditor 2
13 slider - Fluent UI React ships one, and its styles name nine colour tokens per part. That
source needs a Fluent-2-to-dxds name map, since neither the names nor the brand values match
103 have no external authority anywhere: stepper, diagram, fileManager, gantt, tileView,
sortable, widget, and htmlEditor whose Blazor counterpart reads a single role
Measured and rejected: devextreme-vnext's implementation adds nothing - 18 components, every one
already described by the token package.
So the reachable floor is about 14% of the theme rather than 39%, and what stays uncovered is the
widgets no design system describes at all.
jest 186/186; pages regenerated.
---
.../scss/widgets/fluent-next/ROLES_QUESTIONS.html | 2 +-
packages/devextreme-scss/tools/review/roles.mjs | 11 +++++++++++
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
index 30e30a219b88..df3f1958a03b 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
@@ -105,7 +105,7 @@
Е. Решения не требуется — записано, чтобы
Ж. Чего эта проверка не видела
-
39% темы сравнить не с чем. 277 цветовых объявлений
+
39% темы сравнить не с чем. 226 цветовых объявлений
из 714 — в компонентах, которых нет ни у core, ни у vnext, ни у blazor, ни у
wpf: filterBuilder, gantt, scheduler, diagram, pivotGrid и других. По ним работали только проверки
темы против себя самой; внешнего авторитета нет ни у пакета, ни у Fluent 2.
diff --git a/packages/devextreme-scss/tools/review/roles.mjs b/packages/devextreme-scss/tools/review/roles.mjs
index 8eee1cefc9f9..83f420f949b4 100644
--- a/packages/devextreme-scss/tools/review/roles.mjs
+++ b/packages/devextreme-scss/tools/review/roles.mjs
@@ -62,9 +62,20 @@ const leavesOf = (node, trail = []) => Object.entries(node ?? {}).flatMap(([key,
* `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'],
From e69961c3eb56386eb01c8f468f52ba0c23c54a47 Mon Sep 17 00:00:00 2001
From: EugeniyKiyashko
Date: Wed, 9 Sep 2026 02:51:41 +0400
Subject: [PATCH 14/23] Fluent-next: put the coverage analysis on the page, and
stop it contradicting itself
The reduction from 39% to under 8% existed only in a conversation, and the page meanwhile carried a
hardcoded "39%" next to a computed "226 of 714" - which is 31.7%. A number written twice, once by
hand, is a number that will be wrong.
The whole section is now derived. The count, the percentage and the per-folder breakdown come from
the tool; the levers come from tests/roles.baseline.json, where they are banked with the date they
were measured, because they were measured against repositories this tool cannot read. A ladder table
subtracts them in order so the floor is arithmetic rather than assertion.
226 now 31.7%
-110 Blazor's implementation SCSS 16.2%
-13 Fluent UI React (slider) 14.4%
-47 WPF, at family level only 7.8%
=56 stepper, fileManager, tileView, sortable, widget
The WPF entry is the one worth reading. Its Gantt, Diagram and RichEdit themes exist, but they are
not tokenised: semantic keys like AccordionHeaderBackgroundRest reach only the components inside
components/wpf, while Gantt paints from the legacy Lightweight palette where Color.Foreground.Primary
is the literal #FF1A1A1A. What WPF does have is Lightweight/Common/PaletteSemantics.md, 142 keys
described in prose - a family-level signal (border / bg / content, not which role), covering exactly
the components nobody else describes.
Measured and rejected, recorded so it is not re-investigated: vnext's implementation has 18
components, every one already described by the token package.
A light gate holds the shape: a lever without a measured size or a stated cost is a suggestion, not
a plan.
jest 187/187.
---
.../widgets/fluent-next/ROLES_QUESTIONS.html | 40 ++++++++++---
.../devextreme-scss/tests/roles.baseline.json | 60 +++++++++++++++++++
packages/devextreme-scss/tests/roles.test.ts | 13 ++++
.../tools/review/roles-pages.mjs | 58 +++++++++++++++---
4 files changed, 155 insertions(+), 16 deletions(-)
diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
index df3f1958a03b..4d5fd8706536 100644
--- a/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
+++ b/packages/devextreme-scss/scss/widgets/fluent-next/ROLES_QUESTIONS.html
@@ -104,14 +104,38 @@
Е. Решения не требуется — записано, чтобы
confirmed
6
пакет назначает ровно эту роль — расходится только слово слота
rule-5
12
одно значение в двух свойствах, названо по доминирующей роли; принято ревью-раундом 2
hairline
11
волосяная линия фоном сохраняет border-роль; подтверждено core (у tabs это слот selector)
no-rung
22
состояния схлопнуты там же, где их схлопывает сама система
shade
15
то же семейство, другой оттенок — расхождение, которое компоненты вправе иметь
graphic-ok
2
глиф, порог 3:1 взят
bridge / known
2
уже инвентаризовано в BRIDGES.md и DIVERGENCES.md
stale в наборах соседей
10
blazor и wpf ссылаются на имена, которых семантический слой не объявляет — их дрейф, не наш
-
Ж. Чего эта проверка не видела
-
39% темы сравнить не с чем. 226 цветовых объявлений
-из 714 — в компонентах, которых нет ни у core, ни у vnext, ни у blazor, ни у
-wpf: filterBuilder, gantt, scheduler, diagram, pivotGrid и других. По ним работали только проверки
-темы против себя самой; внешнего авторитета нет ни у пакета, ни у Fluent 2.
-
Правки инструмента шли в одну сторону. Конфликтов семейств 11 → 3, cross-family 24 → 15,
-кнопочных лестниц 8 → 0. Каждое сокращение проверено вручную и описано в коммите, но направление у
-них одно: скорее недосчитал, чем перебрал.
+
Ж. Чего эта проверка не видела, и как это сократить
+
+
226 цветовых объявлений из 714 — 31.7% темы — сравнивать не с чем.
+Это компоненты, которых нет ни у core, ни у vnext, ни у blazor, ни у wpf в пакете токенов. По ним
+работали только проверки темы против себя самой: семейство, слот против свойства, лестницы состояний,
+контраст и согласованность понятий между компонентами.
+
Папка
Объявлений
+
scheduler
43
filterBuilder
28
stepper
25
diagram
20
fileManager
20
pivotGrid
19
gantt
14
htmlEditor
13
slider
13
fileUploader
10
tileView
7
dateView
5
sortable
3
splitterBar
3
dropDownEditor
2
widget
1
+
+
+
Чем это сокращается — измерено 2026-09-09
+
Числа сняты по репозиториям за пределами этого, поэтому инструмент их не пересчитывает: они
+забанкованы вместе с источником и протухнут заметно, если прочитать их рядом со свежим счётчиком выше.
+
Р1
+
Карта «наша папка → компонент пакета» — сделано
Карта сопоставляла по похожести имён. cardView — это пакетный grid (так его и мапит package-disabled.mjs), speedDialAction — button, lookup — listbox, actionSheet — popup, validation — field. Шесть строк, 277 → 226, и ни одного нового расхождения: все 51 попали в agrees.
Р2
+
Реализация Blazor как пятый источник — покрывает 110
В пакете у Blazor 20 компонентов, в реализации (dxvcs/.../ds-themes/components) — 57, и роли он читает через тот же мост ds.$, что и мы: карта имён ролей не нужна. Имена папок опять другие: rollers — это dateView, edit-dropdown — dropDownEditor. Покрывает scheduler 43 (у них 38 ролей), filterBuilder 28 (59), pivotGrid 19, fileUploader 10, dateView 5, splitterBar 3, dropDownEditor 2.
Что нужно: парсер ds.$ по папкам + карта из семи строк
Р3
+
Fluent UI React — покрывает 13
У Fluent есть Slider, и его стили называют девять цветовых токенов по частям. Но ни имена (colorCompoundBrandBackground против color-bg-primary-shared), ни значения (бренд #0078d4 против #0f6cbd) не совпадают — нужна разовая карта.
Что нужно: карта имён Fluent 2 → dxds, ~40-60 пар
Р4
+
WPF — только на уровне семейства — покрывает 47
Компоненты у WPF есть (Gantt 12 xaml, Diagram 14, RichEdit 8), но их темы НЕ затокенизированы: семантические ключи вида AccordionHeaderBackgroundRest получают только компоненты из components/wpf, а Gantt и Diagram красятся легаси-палитрой Lightweight, где Color.Foreground.Primary — это литерал #FF1A1A1A. Зато Lightweight/Common/PaletteSemantics.md описывает смысл 142 ключей словами («Color.Border — primary border brush for controls»). Это сигнал уровня семейства (border / bg / content), а не роли, и он покрывает ровно то, чего нет больше нигде: gantt 14, diagram 20, htmlEditor 13.
Что нужно: разбор PaletteSemantics.md, 142 ключа
+
+
Куда это приводит
+
Рычаг
Покрывает
Останется
+
—
сейчас
226 (31.7%)
+
Р2
Реализация Blazor как пятый источник
−110
116 (16.2%)
Р3
Fluent UI React
−13
103 (14.4%)
Р4
WPF — только на уровне семейства
−47
56 (7.8%)
+
Ниже этого не опускается: 56 объявлений в папках
+stepper, fileManager, tileView, sortable, widget. Виджеты, которых нет ни в одной дизайн-системе. Единственный путь — дизайн-ревью по скриншотам.
+
Померено и отброшено. Реализация vnext: 18 компонентов, все до одного уже описаны пакетом. Не добавляет ничего.
+
+
Оговорка о самой проверке
+
Правки инструмента шли в одну сторону — к меньшему числу находок: конфликтов семейств 11 → 3,
+cross-family 24 → 15, кнопочных лестниц 8 → 0. Каждое сокращение проверено вручную и описано в
+коммите, но направление у них одно: скорее недосчитал, чем перебрал.
+
+
З. Согласны ли соседи между собой
+
+
Весь аудит сравнивает наши роли с чужими, поэтому он стоит ровно столько, сколько стоит
+согласованность самих соседей. Замер 2026-09-09 по четырём наборам пакета: сравнивались
+только слоты, у которых полностью совпадает путь анатомии — компонент плюс всё после
+color..
+
Там, где они говорят об одном и том же одними словами, они согласны.
+583 сравнимых слотов, роль совпадает у 541
+(93%).
+
Пара
Общих слотов
Совпадает
+
core ↔ vnext
579
100%
blazor ↔ core
115
77%
blazor ↔ vnext
116
76%
core ↔ wpf
33
70%
blazor ↔ wpf
3
0%
+
+
core и vnext — практически один набор (vnext = core плюс field), поэтому их
+100% ничего не доказывают. Значимы пары с blazor и wpf. У пары blazor ↔ wpf всего три общих слота —
+это не выборка.
+
+
Но одними словами они почти ничего не описывают. Пересечение анатомии, а не ролей, —
+вот что расходится:
+
Компонент
Наборы
Путей всего
Общих для всех
+
button
core+vnext+blazor+wpf
401
0
checkbox
core+vnext+blazor
126
0
toast
core+vnext+blazor
92
0
grid
core+vnext+wpf
66
8
switch
core+vnext+blazor+wpf
57
0
ribbon
core+vnext+wpf
46
8
radio-button
core+vnext+blazor
36
0
text-input
core+vnext+blazor+wpf
35
0
+
+
У button четыреста один путь анатомии на четыре продукта и ноль общих для всех
+четырёх. Каждый моделирует свои варианты, суб-элементы и состояния.
+
+
Расхождений всего 42, и 9 из них — не расхождения.
+Blazor запинен на 262.9.1 и всё ещё пишет bg-none / border-none /
+content-none там, где в действующем слое одна роль none. Настоящих
+остаётся 33:
+
button, вариант outline. blazor кладёт фон на bg-none / bg-hovered / bg-active, core и vnext — на альфа-лестницу bg-alpha*. На button приходится 19 расхождений из 33.
ribbon. у wpf он акцентный: content-inverted вместо content, bg-primary-hovered вместо bg-hovered. У веба нейтральный. Похоже на осознанное десктопное решение, а не на разнобой.
grid. wpf сидит на bg-low и border, веб — на bg и border-subtle.
tabs. иконка при наведении: blazor content-primary-hovered, wpf content.
+
+
Что из этого следует для самой проверки
+
Там, где соседи описывают одно и то же одними словами, они согласны на 93%. Но одними словами они описывают почти ничего: у button 401 путь анатомии на четыре продукта и ноль общих для всех четырёх. Расходится не назначение ролей, а анатомия — каждый продукт моделирует свои варианты, суб-элементы и состояния.
+
Отсюда и устройство сравнения: оно на уровне слота, а не пути. Сравнение по полному пути
+нашло бы почти ничего — наша анатомия не совпадает с чужой ровно так же, как их анатомии не
+совпадают между собой. Цена этого выбора честная: инструмент отвечает на вопрос «использует ли пакет
+эту роль для слота такого рода в этом компоненте», а не «использует ли он её именно здесь». Поэтому
+cross-family сформулирован как вопрос, а не как вердикт.