Expose the disabled state on the widget root and stop a11y checks passing vacuously - #35010
Open
EugeniyKiyashko wants to merge 53 commits into
Conversation
EugeniyKiyashko
force-pushed
the
fluent-next/disabled
branch
from
September 1, 2026 19:26
a188314 to
d22cc4b
Compare
…sing vacuously
aria-disabled was set on _getAriaTarget(), which in a composite widget is a
descendant: the input of a text editor, the select-file button of FileUploader,
the field of Lookup. Everything outside that element - tags, labels, file lists -
was dimmed but carried no disabled semantics, so assistive technology and axe
judged it as ordinary text. 12 of 24 dimmed widget roots had no marker axe
accepts. The root is now marked as well; the previous target keeps its attribute,
so the change is additive.
Disabled command links in the grid and disabled navigation buttons in Pagination
had a disabled look and no marker at all, and are marked too.
With that in place the color-contrast suppressions for disabled TagBox,
FileUploader and DateRangeBox are unnecessary: all 44 disabled configurations of
their option matrices report zero violations in both light and dark, and 15
violations without the markup fix. The suppression list and two of the demo
entries are removed; the cardView ones are kept, with the comment corrected -
the sortable source is active content painted with the disabled roles, and every
theme is below 4.5:1 there, so the fix belongs to the shared base layer.
Two harness defects found on the way:
- a11yCheck() returned without a single assertion when the caller excluded
color-contrast, so the test reported as passed. Applicability is now a
predicate, testAccessibility declares such tests with test.skip, and a direct
call that would check nothing throws.
- runOnly: '' in the DataGrid accessibility tests is normalised by axe into
{ type: 'tag', values: [''] }, which matches none of the 104 rules. Seven of
the eight call sites ran no rule at all and the eighth ran one, in every theme.
The theme carried the disabled state of most components with one blanket rule,
.dx-state-disabled .dx-widget { opacity: … }, and only about twenty components
opted out of it and painted themselves. Measured over the whole disable-able
surface, 18 components had no disabled appearance of their own, and Toolbar had
none at all: base/toolbar opts it out of the dim and puts nothing in its place,
while the "do not dim nested widgets" rule clears the dim from its item widgets
too, so a disabled toolbar rendered exactly like an enabled one in every theme.
Each migrated component now opts out of the dim and paints its own parts from
the disabled roles: toolbar, menu, tabs, treeView, stepper, gallery,
filterBuilder, pagination, cardView, tileView, fileUploader, scheduler,
pivotGrid, and the grid family through the grid-base mixin. Where the component
already had tokens for its individually disabled items, those are reused; the
rest get one new tier name each. cardView deliberately does not reuse its
header-panel-item-*-disabled names: base applies those to the sortable drag
source, not to a disabled state.
Five components keep the dim, and that is the right mechanism for them: three
layout containers with no surface of their own (splitter, drawer, box), the
colour palette of ColorView, and Chat with its own hardcoded opacity. So the
blanket rule is narrowed by attrition rather than removed.
The Scheduler demo read --dxds-color-content-subtle-disabled, a role dropped in
262.15.0; it moves to the surviving one. The gate caught it only after a real
install, because the working tree still had 262.10.1 linked.
Verified with playground/disabled-readonly-compare.html, which puts enabled,
disabled, disabled-without-the-dim and legacy fluent side by side and compares
the sorted colour multiset of each component's own elements and pseudo-elements:
37 components paint their own state, 7 are covered through their children, and
none renders a disabled state indistinguishable from enabled.
Rationale and the full inventory are in fluent-next/DISABLED_STATES.md.
The previous commit painted the grid content but left DataGrid dimmed on top of it: opacity on a child cannot undo a parent's group opacity, and the rule set opacity on .dx-datagrid, which is one level below the element that carries the dim. TreeList was unaffected because it puts .dx-treelist on the widget root, while DataGrid keeps its class on the container inside. The root is now reached with :has(> .dx-<widget>), a form the theme already uses. The direct-child combinator keeps the match on the grid's own root: a disabled container holding a grid deeper down is not touched, verified by the dim staying in place on the nine components that still rely on it, FileManager among them.
…modes are measured
…es painting Lifting the blanket dim with `opacity: 1` and painting nothing leaves a component rendering exactly like an enabled one. That is not a hypothetical failure mode: Toolbar shipped that way in all four themes, and an attempt to migrate ColorView reproduced it inside this branch. The rule was a convention until now, so nothing stopped the next migration from repeating it. The gate reads the compiled bundles and requires every component that lifts the dim in a disabled context to also set a colour there. It runs on the bundle rather than the sources because that is where a with()-injected mixin argument and a hand-written rule become indistinguishable. Matching is by prefix, not by class: the reset and the paint routinely sit on different parts of one component (.dx-progressbar resets, .dx-progressbar-container paints). Five entries lift the dim on purpose and paint nothing - the blanket rule and its nested-widget companion, TimeView inside a disabled DateBox, the tab nav buttons, the spin buttons, the resize handle - plus three pure wrappers whose visible content is entirely nested widgets. Each is listed with its reason. A negative self-check feeds the scan a fixture with one painted and one unpainted component and requires exactly one finding, so a green gate means "nothing to find" rather than "the scan matched nothing". Verified against the real defect as well: removing the Toolbar paint makes it fail.
…ling them Making a11yCheck throw when nothing is left to check caught its first callers - the five cardView drag-source checks, which switch color-contrast off because the sortable source is painted with the disabled roles in the shared base layer. They call a11yCheck directly rather than going through testAccessibility, so on fluent-next they hit the throw and turned four green jobs red. The applicability predicate is now used where the tests are declared, the way testAccessibility already does it, so they are reported as skipped on a color-contrast-only theme. In columnSortable the config moves out of the test body to make that possible.
With runOnly: '' gone, "Filter row - filter menu" started reporting the best-practice `region` rule against .dx-scrollable-content: the filter menu is rendered into body, outside the role="main" landmark, so none of its content sits in a landmark. It reproduces in every theme and on every quarantine retry, so it is neither flaky nor theme-specific. Portalled overlays are excluded from that rule elsewhere in this suite for the same reason - cardView/sortable.ts and the overlay checks in pivotGrid.ts - so this follows the existing decision rather than inventing one.
…rk palette Three findings, three different owners. Focused filled buttons (fluent-next, both modes). The confirm-delete dialog autofocuses "Yes", and the focused background reused the hovered step. In the dark palette that step lightens rather than darkens, so white content landed on 4.21:1 for primary and 4.18:1 for success. The package defines no focus state for a filled button - rest, hovered, active and disabled only - so the focused background is the theme's own choice. It now uses the active step, which is the same colour in both modes, keeps the state visible and measures 11.17 / 11.83 / 10.59. This is what made "Row editing mode - confirm delete message" fail. Hovered filled buttons stay as they are: that pair is the package's own (button.color.contained.primary.bg.hovered plus content.rest), Blazor maps it identically, and no content role in the package reaches 4.5:1 on #2b7ecf - white gives 4.21, black 4.3. The theme cannot fix it; the palette has to. The adjacent-month cell in legacy fluent dark kept its dimmed #999999 while the hovered background lightened to #3d3d3d - 3.81:1. The dimming rule outranks the shared hover rule, which already sets white text. Fixed in the fluent layer with the theme's own hovered-cell colour: 10.86:1, rest unchanged. fluent-next was never affected (6.90 and 11.20 at the same places). DataGrid-SignalRService paints its falling quotes with the theme's --dx-color-danger. In dark that role is #e4554f: 4.92:1 on an alternating row but 4.22:1 on a normal one, so about half the rows fail and the data decides which. The package assigns that role to error text and Blazor uses the same one, so it is a palette matter as well; the demo carries the measurement until it is fixed. Both palette findings are written up with their evidence in fluent-next/DISABLED_STATES.md and need a card against the token package.
…aint Wave D3 of the disabled work: a name ending in -disabled must mark a disabled state, otherwise the next reader has to open the stylesheet to find out whether a value is a state or just a step someone borrowed. --dx-tag-box-border-disabled had exactly one reader, the border of an ordinary tag, and --dx-pivot-grid-border-disabled painted the field boxes in their normal state. Both are renamed after what they paint; they keep reading the disabled border role, because that is the step the design uses here, and the comment now says so. The pivotGrid colour joins the field-area-box family rather than taking -area-box-border, which the size variable already owns as a shorthand. $tree-view-checkbox-bg-focused read the disabled background role for a focused checkbox. color-bg-low resolves to the same value in both modes, so the role becomes honest without moving a pixel. Value-neutral by measurement: the legacy bundles are byte-identical, and the fluent-next bundles differ in nine declarations - three renamed readers and three renamed publications - with the declaration count unchanged and every resolved colour the same.
Two leftovers of the disabled work, both settled against Blazor. The drag source. base/cardView repainted .dx-sortable-source with the disabled colours, so an item being dragged - ordinary, active content - measured between 1.61 and 2.61 against its own background in every theme. That is a real contrast failure, not the false positive the test comment claimed. Blazor marks a drag source with a background and elevation and leaves the content alone (ds-themes/components/tabs/layout.scss, list-box/variables.scss), so base no longer sets a colour there and the four colour parameters are gone. Removing them exposed the next problem: in fluent-next the remaining background pointed at the ordinary item background, which would have left the source with no mark at all - the ColorView mistake again. It now takes the subtle fill, so the item reads as "being moved" while its text keeps the ordinary contrast: 2.11 becomes 16.6 in light and 18.1 in dark. The parameters are renamed after what they mark. base keeps --drag-source-, mirroring the class it styles; the theme uses the `dragging` modifier, which the grammar already knows and which sits next to the existing `dragged` for the clone. Read-only. The text editor gave a read-only field the disabled border through a shared selector, so the name claimed a state the element is not in. It now has --dx-text-editor-border-read-only and keeps the same value: Blazor leaves a read-only editor the ordinary border and distinguishes it by the lost fill alone, but the softer border carries the affordance here and that is worth keeping. The choice is at least visible now. The cardView color-contrast suppressions are removed - the defect they hid is fixed - and the naming baseline drops four wiring entries with the parameters.
…urce The previous commit fixed the drag-source contrast by deleting the parameters that carried the dimmed colours. Six of them per theme are ThemeBuilder controls: generic, material and fluent annotate them with $name / $type metadata (600. Background color, 602. Text color, 604. Icon color and so on), and build/internal-scss-paths.json excludes only widgets/fluent-next/ from the collector. Deleting them would have dropped the controls from the ThemeBuilder UI and silently discarded the value in any theme saved against those names. The variables are back, with their metadata, and the defect is fixed where it actually was - in the values. Each theme fed the drag source a dimmed content colour: 60% lighter in generic and fluent light, 56-59% darker in their dark palettes, alpha 0.38 in material. They now pass the ordinary colour through, so the element being dragged stays readable: 12.63 in generic, 13.62 and 19.80 in fluent light and dark, 17.62 in material, 16.60 and 18.10 in fluent-next. The background and border still mark it, which is how Blazor distinguishes a drag source. Names: base carries no metadata, so its parameters are renamed to --drag-source- after the class they style, and fluent-next - excluded from the collector - moves to the `dragging` modifier the grammar already knows. The three public themes keep their spelling: it is a shipped contract, and renaming it needs a deprecation path, not a rename in passing.
base/toolbar opted the toolbar out of the blanket disabled dim and put nothing in its place. The toolbar root carries dx-state-disabled and dx-widget on the same element, so the companion rule that stops nested widgets being dimmed twice also cleared the dim from the item widgets - a disabled toolbar rendered exactly like an enabled one in generic, material and fluent, and did so in fluent-next until this branch painted it. The opt-out is gone, so the three themes that have no toolbar colours of their own get the dim back: measured on the built bundles, a disabled toolbar in fluent dark now renders at 0.38 where it was 1. fluent-next keeps its own treatment and opts out in its own layer, next to the colours that replace the dim - it measures opacity 1 with the label at #ababab. Same defect and same shape as the cardView drag source: an opt-out without a replacement is worse than the dim it removes. The gate added earlier in this branch is what will catch the next one.
…base dxChat was the one component whose disabled treatment no theme could reach: the 0.5 sat as a literal in base/chat/layout/chat, so the widget ignored every theme's disabled opacity, including the global one it would otherwise have inherited. It is now an injected parameter with 0.5 as the base default, so generic, material and fluent are byte-identical, and fluent-next feeds it from the opacity scale as --dx-chat-opacity-disabled - the same value, but overridable like every other disabled value in the theme. Two things the gates corrected along the way: the child module needed a different parameter name from its parent, matching how chat-fileview is already wired, and the declaration belongs in _sizes.scss rather than the rules file, with the name putting the state last (chat-opacity-disabled) so the grammar accepts it.
…abled opacity It included base/widget's disabled-widget() directly, which defaults to 0.3, so this one button ignored --dx-global-disabled-opacity while every other disabled widget in the theme honoured it. It now uses the theme's own mixin. The legacy themes are untouched - they have no wrapper of their own and keep base's 0.3.
…he pattern The remaining borrowers of a -disabled name were read-only states: the check box icon border and four radio button reads. Same treatment as the text editor earlier in this branch - their own read-only names, the same values, so nothing moves on screen but either state can now be changed without dragging the other with it. Blazor keeps the two apart the same way. With those split, no -disabled name in the theme is read outside a disabled element any more, so the rule becomes a gate rather than a convention. It reads the compiled bundles and knows the three spellings the library actually uses for a disabled element: the shared state class, the pager's dx-button-disable, and the BEM modifier on the grid's AI chat. A name defined in terms of another disabled name is a chain, not a read, and is not reported. Checked both ways: a fixture with one borrowed and one legitimate read yields exactly one finding, and putting the old tagBox name back makes the real bundles fail. That was the pattern that let the cardView drag source be painted with the disabled colours in the first place.
The Toolbar and cardView defects both had the same shape: a component with nothing of its own, and nobody noticing. The paint gate added earlier catches the component that opts out of the dim without replacing it; this catches the one that never had a rule at all. The list may shrink, never grow. It is a static signal and says so in the file: it asks only whether some rule mentions a component's root class in a disabled context and paints, so it over-reports - a text box is painted through the .dx-texteditor chassis, and Calendar, Form and TabPanel are covered by the widgets inside them. The runtime comparison in playground/disabled-readonly-compare.html stays the judge of what actually renders. Matching is by whole class token. The first version joined the painting selectors and searched for a substring, which reported .dx-toolbar as covered because a scheduler rule mentions .dx-toolbar-item-content - the ratchet stayed silent in exactly the case it exists for. Verified by removing the Toolbar rule and again by disabling the Menu one: both make it fail.
Removing the color-contrast suppression from the cardView drag tests was premature: they still failed, in fluent-next and material alike. Recolouring the drag source fixed the colour but not the contrast, because the number that actually decided it was an opacity, and not the one I had been looking at. dxSortable dims a drag source to 0.5 (base/_sortable.scss), and it puts dx-sortable-source on the header item's *container*. The component's own opt-out matched only the item, so it never applied - and once the class is on the container, opacity on the child cannot undo the parent's group opacity anyway. The header text came out at #888888 on #f8f8f8: 3.33, which is #161616 at half alpha, not a colour any theme declares. The dim is now cancelled on the container, where it is set. Measured on a real drag - mousedown plus mousemove, the way the tests trigger it - the source renders at full opacity with no violation, where before the fix the same repro reported one at 3.33. Third time this branch that opacity on a child was mistaken for a way to undo a parent's: DataGrid needed :has() on its root, the toolbar needed the opt-out removed from base, and this needed it moved one level up.
The theme no longer dims a disabled widget wholesale. Every component with a surface of its own paints the state from the disabled roles, which is what Fluent specifies and how Blazor's themes are built - they carry no global rule either and keep opacity for a composite dimmed as a whole, such as a scheduler appointment. What stays in the shared rule is the part that is not a colour: no text selection, no pointer cursor on something that cannot be used. This is a fluent-next-only change. Each theme applies the dim from its own widget/_index.scss and base merely defines the mixin, so generic, material and fluent are untouched - fluent still measures 0.38 on a disabled widget. ColorView was the one component the dim was really carrying. It cannot be greyed out, its content is the colour, so it follows Blazor's colour palette: the tile rings, the handles and the captions take the disabled roles and the swatches are left alone. ContextMenu and ActionSheet got the same treatment as the other item lists. Measured on the comparison page, now 59 cases covering the whole base the dim used to reach - 33 components were dimmed by it in legacy. Four of the 59 render no disabled state, and three are Splitter, Drawer and Box: layout containers with no surface, which is also what Blazor does, since their content is made of components that mark themselves. Not verified: ContextMenu and ActionSheet render into portals, so the gallery measures their host rather than their content and cannot confirm them. Their rules are in the bundle; they need a look on real demos. The gate that asserted the dim exists is inverted: no rule may apply an opacity through a selector that reaches any widget at all. It matches on the shape of the selector, so a component-scoped rule like the TimeView reset is not caught.
The document still described the cardView drag source and the Toolbar as open defects, said the blanket dim could not be removed, and knew nothing about the ThemeBuilder constraint - the single most important thing the work turned up. Rewritten against the current state: what was fixed and how it measures, the four gates that now hold it, Blazor as the reference for the drag source, read-only and the colour palette, and the ThemeBuilder metadata that decides which layers may be renamed at all. The three-times-repeated mistake - opacity on a child does not undo a parent's - is written down once, with all three places it appeared. Also records the 34 vestigial opacity: 1 resets and why they are still there: the attempt to remove them failed on the measurement, not the code, and some of them cancel a component's own opacity rather than the dim.
With the blanket dim gone, most of the `opacity: 1` in the theme cancelled something that no longer exists. 29 of the 34 are removed, along with the eleven rules that held nothing else. The five that stay cancel a real source, and each is a named per-component value rather than a blanket one: the list item opacity when the whole list is disabled, the number box spin container, the tabs navigation button, and the two grid resets - the root reached through :has() and the draggable column. Decided by static analysis of the compiled bundle, not by looking at the rendered page. An earlier attempt did the latter and thrashed: removing all 34 reported five changed components, restoring five reported two others, restoring two more reported ten. Heavy widgets keep rendering after load, so the comparison was reading the render phase. Listing every rule that sets an opacity below 1 and matching it against every reset by class is deterministic and gave a stable answer in one pass. Nine dead resets remain in base/** and are deliberately untouched: they are dead only for fluent-next, and the legacy themes still apply the dim they cancel. Two gate corrections fell out of it. The ratchet counted the vestigial reset as a component's "own disabled rule", so removing it made ProgressBar look uncovered; it now matches by prefix, like the paint gate, because a component paints its sub-elements rather than its root. ButtonGroup, DropDownButton and RadioGroup do end up with no rule of their own, correctly - they are wrappers, and their content marks itself: measured #161616 to #ababab through their children.
The resets that cancel the blanket dim belong to the themes that apply it. base already has the mechanism for that in places where the reset sits inside a mixin: the opacity is a parameter, and a theme passes what it wants. Switch and RadioButton now get null from fluent-next, so the declaration disappears there and the legacy themes keep it - measured on the bundles, fluent still emits all three of its resets, generic and material one each. Switch needed the parameter added; it is trailing and defaulted, and the mixin has only two callers. RadioButton already had one and fluent-next was passing a literal 1. Four more resets stay: the resize handle, TimeView, the text editor input and the nested RadioButton rule, all emitted from a base _index.scss rather than a mixin. Parameterising those the same way did not take effect - the value reaches the module, but the declaration is still emitted - and I did not find the reason cheaply. They are inert in fluent-next (nothing dims those elements any more), so this is tidiness, not behaviour, and it is not worth more guessing. The attempt is reverted rather than left as unused parameters in the shared layer.
The page reported twelve components as indistinguishable from enabled, and that number was doing damage: I read it as four by looking past the ones I had already explained to myself, which is exactly what a headline number should stop you doing. Most of the twelve were not findings. ContextMenu does grey its items - measured #161616 to #ababab - but its content sits in a portal, so comparing the host shows nothing. ActionSheet renders its items into a popup for the same reason. SpeedDialAction renders nothing at all in the gallery. ScrollView and LoadIndicator have no text and no surface to grey: a spinner and a scroll blocker. The frame now reports whether a case can be judged from its host at all - not-rendered, portalled, or no-text - and the shell counts those separately. What is left is six components that genuinely render no disabled state: the three layout containers, which is deliberate and what Blazor does too, and three markup blocks that are not widgets and only carry the state class because the gallery puts it there.
Removing the dim left six components rendering nothing when disabled. Blazor settles four of them and cannot settle the other two, for a reason worth writing down: DxSplitter, DxDrawer and DxGridLayout expose no Enabled parameter at all, so their lack of disabled styling is an absent feature rather than a decision, and copying it would be copying a gap. DxFormLayout does have one, and its Fluent theme still paints nothing on the container: it cascades the state to its items. Our fieldset follows that - the captions take the disabled content role and the values are editors that mark themselves. The other three go by what the component actually is. A card is a surface, so it takes the disabled surface role: #ffffff to #f5f5f5. The splitter bar and the resize handle are the splitter's only chrome, the panes being the application's content, so they take the disabled border role. Measured: the bar goes from the accent tint to #d7d7d7, the handle from #cbcbcb to #d7d7d7 - a quiet signal, but it is the palette's declared pair rather than something invented here. Three still render nothing, each on purpose. Drawer and Box are pure layout with no surface of their own; their content is made of components that mark themselves, and Blazor's equivalents cannot even be disabled. The informer is a status block whose tint is the whole of it - greying it would delete the information it carries, the same argument that keeps the colour palette's swatches untouched. WCAG asks only that an inactive component be exempt from contrast, not that it be greyed.
… item itself Opening the portalled components in every column answered the ContextMenu question: a disabled context menu does not open by gesture in any theme, legacy included, so the menu seen in the first column only was the widget behaving correctly. Popup, Popover, Tooltip and LoadPanel are openable from the page now too; none of them was ever reached by the blanket dim. Sweeping the built bundle for disabled states still expressed with opacity found twelve rules. Three had a disabled role already declared and already used by a neighbouring rule, so the dim was merely out of step with its own component: List and TreeView items and the Pagination navigation button now paint from the role. The other nine are listed by name in a new ratchet, each with the reason it still dims, and the list can only shrink. Removing a dim that lives in base/** cannot be done by passing null: `!default` re-applies the default whenever the configured value is null, so the dim returns as a literal. Both base edits use a `false` sentinel behind an `@if`, and fluent, generic and material are byte-identical. The gallery disabled whole widgets, which hid the commoner state - one disabled entry inside an enabled collection. Eight such cases are on the page now, and two of them were indistinguishable from their enabled siblings: RadioButton and Accordion both required the state class on an ancestor, while a single disabled item carries it itself.
The third column existed to show what a component would look like with the blanket dim switched off through --dx-global-disabled-opacity. There is no blanket dim any more, so it rendered exactly what the second column rendered, and the variable it set is read by nothing. Both the column and the dim query parameter are gone, and the verdict is now the plain question the page is left with: does the disabled column differ from the enabled one. Legacy fluent stays as the NFR-1 reference - it still dims at 35%, so it also shows what the state used to look like.
Every failing check on the run was a screenshot comparison, and each one traces to a change this
branch made deliberately:
- the focused filled button now takes the active step instead of the hovered one, which is the
dark-palette contrast fix (4.21:1 -> 11.17:1). It is the whole story behind the button, dialog,
messagebox, htmleditor, grid row-editing, scheduler loading-panel and appointment-collector
screenshots, and behind all twelve editor-appearance demos;
- the cardView drag source is no longer dimmed. The item already carried an opt-out, but
dxSortable puts the class on the container, so the opt-out was inert and the header text
measured 3.33:1. This one also moves legacy fluent;
- a disabled toolbar dims in legacy fluent now. The base opt-out cleared the dim from the root
and, through the nested-widget reset, from the items too, so a disabled toolbar rendered
exactly like an enabled one. It dims once, not twice - measured, not assumed.
The two legacy changes are behaviour changes to a shipped theme, and both are the fix rather than
the regression: the old etalons recorded a drag source that was unreadable and a disabled toolbar
that looked enabled.
Screenshots come from the run's own artifacts, so they are the CI renderer's, not this machine's;
the four demo frameworks produced byte-identical images, which is what the shared etalon assumes.
… fit them The switch handle is content, not a border. Enabled it is painted from content roles - subtle when off, inverted when on - but its disabled step used the border role, because two package tokens held that colour until 262.15.0 dropped both and the border role was the nearest one left. That put a #d7d7d7 handle on an #f5f5f5 track: 1.32:1, paler than legacy fluent's 1.65:1, which is what made the control look washed out. It now uses the disabled content role - 2.11:1 in light and 3.98:1 in dark, against legacy's 1.65 and 2.76. Blazor paints the same element from a content role (switcher trigger = content-neutral-default-disabled). The track outline keeps the border role. The TreeView expander sets its own colour, so the widget-level disabled rule, which paints the item, never reached it: greyed text sat next to a full-contrast arrow. The two cases are not the same and only one of them is a defect. With the whole widget disabled the root takes pointer-events: none and the arrow cannot be clicked, so it is painted with the rest. With a single item disabled the arrow still opens the node - measured, it expands and keeps a pointer cursor - so it keeps its enabled colour; greying it would claim it is inert when it is not. The comparison page now carries that second configuration as its own case.
…ardcoded opacity Chat was not the only place where base held an opacity literal a theme could not reach. Sweeping the whole layer found 84 of them, 12 fractional. Most are not states at all: invisible hit-area overlays (map 0.01, tabs 0.001), compositing tricks (scheduler 0.99 and 0.98), a keyframe, a hidden dropzone. Five express the drag source, which is the defect cardView already produced. Two of those five are fixed here. A dragged column header dropped from 18.1:1 to 3.41:1 - the same halving that made the cardView header unreadable at 3.33:1. The theme's answer was already written, as --dx-grid-draggable-column-content, but the rule that used it was scoped to sticky columns only, so the ordinary case still dimmed. The rule now covers every draggable column and the literal in base is a parameter, so fluent, generic and material keep 0.5 and are byte-identical. Measured after: 9.74:1. The column-chooser literal is left alone: every item there carries dx-*-drag-action, whose own reset cancels it, so it does nothing in practice. The pivotGrid drag source is left for its owner - there is no role for it in the theme yet, so removing the dim needs a decision about what marks the field that stays behind, not a mechanical edit. Both are written up with the sweep.
Two page defects, both reported as theme bugs and neither of them one. The editors' labels looked missing in the disabled column. They were not: fluent resolves labelMode to "outside", so the label is drawn above the field, and the page clipped it with overflow: hidden. The other columns escaped only because they lost a race - DevExtreme resolves the theme once, when a widget is created, and a widget built before the stylesheet applied fell back to labelMode "static", which draws the label inside. So two columns were rendering the wrong mode and the third was rendering the right one invisibly. The gallery is now built after the theme stylesheet has applied, which makes every column agree, and nothing is clipped. The per-case heights were a lid: a component that needed more room in one column was cut there and nowhere else, which is what hid the toolbar text. They are a floor now, each column reports what its rows need, and the shell gives every column the tallest - so the rows still line up, which is the point of the page, without anything being cut off.
The gallery had one case per component, but the theme does not have one disabled look per component. Reading the built bundle for what co-occurs with a disabled marker gives the real list: dx-editor-outlined / -filled / -underlined, dx-texteditor-with-label and -with-floating-label, dx-label-before and -after, dx-invalid, dx-tab-selected, dx-button-mode-contained and -outlined. A single outlined text box exercised a fraction of it. Text editors are now a generated matrix - three styling modes by four label modes, twelve cases, plus one invalid case per styling mode and one with the label beside the field. Buttons are a matrix too, three styling modes by four types, replacing the three hand-picked ones. Tabs gain the case where the disabled item is also the selected one, which has a rule of its own. 95 cases now, and the read-only view grew with them: the twelve editor combinations are read-only cases as well, so that comparison is no longer three text boxes wide.
The chat case was empty, which hid the thing it was there to show. With two messages in it the state is legible: the dim is all there is. Under it the bubble text lands at 3.49:1 and the author line at 3.41:1 - not colours the theme chose, just whatever was there multiplied by a half. The label on the case said the opacity was hardcoded in base; it is a parameter now, so the label said something that had stopped being true.
…f inventing one Both sources say the same thing. The token package defines no disabled colour for a message bubble - its ai-chat tokens carry disabled only for prompt-suggestion-item, as bg-disabled plus border-disabled plus content-disabled - and Blazor never dims its chat at all, it paints the individual controls. So a transcript is content, not a control, and has no disabled state; what goes out of use is the composing area. Measuring showed that area already paints itself: with the dim lifted the input text and the send icon are #ababab on their own. The one rule added is the divider above the composing area, which takes the disabled border role - without it the chat would carry no disabled rule of its own, which the ratchet caught rather than my noticing it. Before: the whole widget at opacity .5, bubble text 3.49:1, input 1.47:1. After: no dim, the transcript at 16.6:1, the controls at #ababab. Legacy keeps its dim and is byte-identical. The survey behind this is written up: 44 components in the package tier, 125 disabled tokens across 24 of them, and 20 with none at all - drawer, popup, tooltip, toolbar, form and the rest. That last list matters, because the components this branch reports as having no disabled state of their own are in it. It also confirms the switch handle: the package maps both trigger.disabled tokens to content-disabled, which is what the handle was moved to.
… it found missing
The token package ships a component tier the theme does not consume, but it is still the vendor's
own answer for how a disabled place should look. tools/review/package-disabled.mjs now puts the
two sides next to each other: 24 components carry disabled tokens, and for each it prints the
roles the package names against the roles the theme actually emits, reading the built bundle so
that colours coming out of mixins are counted too. Absence it decides on its own; a role that
differs it prints, because only a reader can say whether the two sides mean the same place.
Three places the theme painted nowhere or painted the same as when enabled:
- Calendar had no disabled rule at all - it was carried entirely by the blanket dim, and once
that went a disabled calendar rendered exactly like an enabled one. Cells and navigator now
take content-disabled, which is what cell.period.color.text.disabled names;
- a placeholder kept its resting grey when the editor was disabled. The package separates the
two steps (text-content.color.default.placeholder.rest is content-subtler, .disabled is
content-disabled) and so does the theme now;
- a selected menu item kept the selection background when disabled. item.color.selected.bg
.disabled names bg-disabled for it.
Eight configurations were added to the comparison page for the places checked here, so the next
reader sees the same evidence rather than taking this on trust.
…fferent one
Seven places where the theme painted something the package names differently. The package's
component tier is the vendor's own answer and ranks first in this theme's own mapping order, so
the divergences are closed rather than argued:
- the indicator under a selected tab is content, not a border (item.color.selector
.selected-disabled), so it follows the content scale;
- the radio ring is a border and takes border-disabled; the dot inside it stays content;
- the splitter bar takes bg-disabled, which the package does name after all - the comment
saying Blazor had no answer was true and beside the point;
- the outlined button's disabled border stops reusing the disabled background and takes
border-disabled;
- a selected list item and a selected treeview item keep a surface when disabled, the alpha
one the package names rather than the selection colour;
- the context menu separator takes border-disabled.
One was not adopted. color-palette.color-button.color.bg.disabled would repaint the ColorBox
swatch, but the swatch carries the chosen colour as an inline style, so only !important could
win - and overriding it would hide the value the control exists to show. The package's
color-button is a palette button, not this preview.
The list rule needed the widget class to reach the specificity of the rule that paints a selected
item; one class short and it lost silently, which the page caught and I had not.
Also fixed in the page: the deferred build waited on a double animation frame, and a frame in a
hidden tab never gets one - the gallery simply never appeared while the pane was in the
background. Whichever of the frame or a short timeout comes first now wins. The splitter case
renders its bar now too, without which it showed nothing of what the package names.
EugeniyKiyashko
force-pushed
the
fluent-next/disabled
branch
from
September 1, 2026 22:46
d1e7d25 to
ebc7089
Compare
…y case
Five of the six reports were real, and four of them are the same mistake: a rule that painted
one element while a sibling kept full contrast.
- Form captions stayed black while their editors greyed. The fieldset rule matches
.dx-field-label; a Form writes .dx-field-item-label-text, and nothing reached it;
- calendar week-number cells were outside the rule added for .dx-calendar-cell;
- the stepper's filled connector kept the accent colour, so a greyed step sat on a bright blue
line - which is what looked wrong about the active step;
- a chat attachment stayed at full contrast. The transcript keeping its contrast is deliberate,
but an attachment is a control inside it: it downloads when clicked, so it follows the state.
The message text still does not, which is the distinction the package and Blazor both draw;
- the Gallery case fed plain strings to a widget that reads them as image URLs, so every slide
404ed and the case showed empty frames rather than a disabled gallery. Those were the 404s I
had looked at earlier and set aside as unrelated.
Tabs turned out not to be a defect: measured in both styling modes, primary and secondary, the
text goes #161616 to #ababab. Both modes are cases on the page now, so the measurement is visible
rather than asserted, along with week numbers and a chat message carrying a file.
… page The expander sets its own colour, so the rule that greys a disabled cell never reached it: a disabled TreeList showed grey rows with full-contrast chevrons. Same shape as the TreeView expander, and inert for the same reason - the disabled root takes pointer-events: none. The package names it too, as grid.cell-renderer.text.color.icon.disabled. The case could not have shown this before: without an explicit height the TreeList never left its virtual-row placeholder, so it rendered a header and nothing else. No rows, no expander to judge. The page now reads the Build date out of the stylesheet the first column actually loaded and prints it in the status line. The cache buster only changes when the shell itself is reloaded, so "am I looking at the new build" was a fair question with no answer on the page.
…ll accent Two more places where the rule required the state class on the element itself while a disabled widget carries it on the root - the same shape as the toolbar, the TreeView expander and the TreeList chevron before them. The selected day of a disabled calendar stayed a saturated blue circle with white text while every other cell greyed around it. The package has no disabled step for that cell, but it answers the same question for a menu (item.color.selected.bg.disabled = bg-disabled), and the circle still says which day is selected. The tab indicator kept the accent for the same reason: the rule matched .dx-tab-selected.dx-state-disabled, which is a disabled tab, not a disabled tab strip. The build stamp on the page now comes from Last-Modified rather than the Build date comment. The comment carries the day only, which cannot tell two builds apart within one day - exactly when the question gets asked.
Every defect found by eye after the dim came off had one mechanism: an element that declares its own colour cannot inherit a disabled one. The component's rule greys a container, its contents inherit, and one element does not because it paints itself - so it sits at full contrast next to greyed siblings. Toolbar items, the TreeView expander, the TreeList chevron, the Form captions, the stepper connector, the calendar's selected day, the tab indicator and the chat attachment were all that, each found by looking rather than by a rule. The new ratchet asks exactly that question of the built bundle, and it is proven on a live defect: removing the expander fix again makes the run fail. It is a baseline of 141 rather than a zero, because most of the list is legitimate - toast variants, popup chrome, theme utility classes - and it may only shrink. Running the same question over all 107 cases at runtime found the root of the series: .dx-widget carries the resting content colour and nothing repaints it when disabled, so anything without a colour of its own stayed black - the checkbox label, the slider labels, the calendar's weekday header, the splitter panes. The disabled root now takes the disabled content role. That is not the dim returning: opacity dims the background through the element and states no contrast, while the role states one, and it is the role the package names for 70 of its 125 disabled tokens. The name lives in the global tier, published on :root. Two earlier attempts - a name on the widget root, and reading the role straight from _index.scss - were both refused by the enforcer, and both refusals were right: the widget tier publishes on .dx-surface, where a rule matching .dx-widget cannot read it. Elements indistinguishable from enabled: 35 before, 8 after, and all eight are portals, the page's own markup, or the chat transcript that is meant to stay readable. Components rendering no disabled state of their own: four before, none now.
…ate cannot see pivotGrid's drag source and the scheduler tooltip's date were the two places left dimming rather than painting. Both had an answer once looked for: Blazor marks its draggable pivot header with a background, a border and a colour and never with opacity, which is what cardView and the grid already do here; and the tooltip's date is secondary text, so it takes the subtle content role - the same one the chat gives its timestamps. Both literals in base are parameters now and fluent, generic and material are byte-identical. Two things went wrong on the way and both were caught rather than reasoned about. Moving the opacity below margin-top changed the declaration order and so the emitted CSS, which broke that byte-identity until the order was restored. And the name I first chose, drag-source-content, is not grammatical for this component - drag-header is. The review screenshots showed three more, all backgrounds: the Gallery arrow disc, the LoadIndicator arc and the FilterBuilder operator and field chips each kept full strength while everything around them greyed. The own-colour ratchet cannot see these by construction - it follows colour inheritance, and background does not inherit - so they are worth naming as its blind spot rather than treating as an oversight it should have caught. Each also needed a selector as long as the rule it had to outrank.
…ection round A filled button inside a disabled popup stayed at full accent. DevExtreme puts the state on the container, and the button's disabled rule matched only the button itself, so nothing reached it. Painting just its text would have been worse than either state - grey on accent - because a filled button has to move its surface and its content together. The rule now also matches a button under a disabled ancestor, which is what a disabled toolbar, dialog or form implies. The calendar's selected day had gone square. The round selection is drawn on the span inside the cell, and the disabled rule I added painted the cell as well, putting a square behind the circle; it also cleared box-shadow, which is part of the shape. Only the span is painted now. This moves more than the two cases: every button inside anything disabled now greys, which is what a disabled container means, and etalons carrying one will need re-shooting.
A disabled tab strip kept its labels black, which I had twice measured as fixed and twice measured the wrong element. A tab renders its text as two nested spans: the outer one is visibility: hidden and exists only to reserve the width the bold selected state needs, and the visible label is the inner .dx-tab-text-span-pseudo. The name reads like the helper, and it is the other way round. That span paints its own colour, and the rule that covers it matches .dx-tab.dx-state-disabled - a disabled tab, not a disabled tab strip - so nothing reached it when the state sat on the root. Same shape as the indicator underneath it, which had the same cause. Worth recording how it was found: the computed style said #ababab and the screen said black. The measurement was right about the element it measured and useless about the question.
…dget The gallery's arrow disc went nearly invisible because I picked the wrong role family. Its enabled value is content-subtle - a content role - and I gave its disabled step bg-disabled, which put a near-white disc on a white page with a white chevron on it. It takes content-disabled now. The paging dots kept the accent altogether, reading as an active control inside a disabled gallery, and take the disabled scale too. The gallery's own coverage came from the API rather than from guessing: disabled is declared on the base collection item, so item.disabled belongs to all seventeen collection widgets, not to the handful that happened to have cases. Twelve were missing - actionSheet, box, gallery, tabPanel, splitter, stepper, tileView, contextMenu, dropDownButton, the form's simple and tabbed items, and a scheduler appointment. 119 cases now. Adding them immediately paid: the blanket disabled colour was scoped to .dx-widget, and a disabled item is not a widget, so a disabled box pane, gallery slide and splitter pane stayed black. The colour sits on the state class itself now, at one class, which makes it a floor that any component rule outranks. Two more needed rules of their own: a tile paints its own colour, and the form's label rule matched a disabled form rather than a disabled item. The chat transcript needed its exemption stated a second time - the floor would have greyed it.
…dget The gallery nav disc took bg-disabled when its enabled value is a content role, so a near-white disc landed on a white page and the white chevron vanished with it; it takes content-disabled now. The paging dots kept the accent entirely - they are a control, and they page a gallery that cannot be paged. item.disabled is declared on the base collection item, so it is not a property of a few components but of all 17 collection widgets. The gallery had eight; the rest are cases now - ActionSheet, Box, Gallery, TabPanel, Splitter, Stepper, TileView, ContextMenu, DropDownButton, Form (simple and tabbed) and a disabled scheduler appointment - 119 in total. Adding them found that the disabled colour was scoped to .dx-widget, and a disabled item is not a widget: a box pane, gallery slide and splitter pane all kept full contrast. The colour now sits on the state class itself as a floor, which anything more specific still overrides. A tile and a form item needed their own rules on top, since both paint their own colour. The chat transcript greys with the rest of the widget now. That reverses the earlier reading - that a message is content and so has no disabled state, which is what the package and Blazor support - on the owner's instruction. The rationale for the other reading stays written down so the choice remains visible rather than lost. Also: the stepper ring was filled with the same near-white as its interior, which erased the circle - it takes a border role now; and the FilterBuilder add and remove icons kept saying success and danger inside a builder that cannot be edited.
…tree a disabled state The tab indicator was recoloured in primary and not in secondary, and that is the whole answer to what should happen: the package names it - item.color.selector.selected-disabled is content-disabled - so both modes should follow, and the asymmetry was a bug, not a choice. The two modes hang the bar on different elements: primary on the tab, secondary on .dx-tab-content, so the selector that worked for one landed on nothing in the other. TabPanel uses secondary, which is why its bar stayed blue too. My first attempt at this also went into the wrong position block - left rather than bottom - and only the built CSS showed it. Four more that had no disabled state at all: the speed dial action kept its accent disc and white icon, the scheduler's time panel and the file manager's directory tree both paint their own text, and the load indicator's track was bg-disabled, which on a white page left the ring invisible and only a faint arc showing - it takes the border role now so the circle still reads as a circle.
… disabled scale flattened The editors inside a ColorView and a Pagination never greyed for the same reason the buttons did not: every disabled rule in the text editor matched the editor itself, and DevExtreme puts the state on the container. All four of those blocks now also match an editor under a disabled ancestor, which covers forms, popups and grids at the same time. Two places had been flattened rather than disabled. A disabled stepper lost the difference between the selected step and the rest - both became a white disc with a grey ring - because the selected step's fill was replaced with the same background as its neighbours; it stays filled now, in the disabled scale, and its numeral inverts against that fill. The speed dial action's label stayed white on a disc that had just gone light grey, which is worse than leaving it alone: it is painted through the overlay chain, so a two-class selector lost to it. Also greyed: the pivot grid's row and column captions, which paint their own text inside cells the container rule could not reach, and the scrollbar thumb, which is shared by every scrollable component and kept a full-strength surface where there is nothing to scroll. item.disabled on a ContextMenu was already correct - measured through the trigger that opens it in every column, its disabled entry reads #ababab - and its case was already on the page.
A disabled editor kept its validation message in full danger colour on a danger surface, next to an editor that had gone grey - an error you are not allowed to correct. It takes the disabled roles now. The review itself is recorded in DISABLED_STATES.md. Six places in 119 cases still match the enabled column and none of them is a defect; it was 35. The legacy bundles differ in exactly the three places that were changed on purpose and nowhere else, across 17 touched files under base. Read-only is expressed in all three styling modes. Two measurement traps are written down because both produced a wrong answer here first. A `git stash pop` with no stash of your own pops somebody else's, from another branch - the changes were already committed, so nothing was stashed and the "with and without" comparison ran twice over the same tree and proved nothing. And comparing only descendants misses everything a widget paints on its own root, which is what made read-only look absent in two styling modes.
…states The stepper's blue outline survived because the ring is a box-shadow, not a border, so setting border-color left it untouched. Neither the token package nor Blazor has a stepper at all, so there is nothing to copy here - the disabled ring keeps the same shape and takes the disabled scale. Four cases were added for the looks a step actually has: selected, completed, invalid, and completed-and-invalid, so the disabled version of each is judged against the state it replaces rather than against a plain step. The load indicator's motion had gone wrong for a reason worth writing down: segment0 and segment1 draw the gap in the arc with border-right-color and border-left-color set to transparent, and my blanket border-color at three classes outranked them and filled the gap in - the ring stopped reading as a rotating arc. The rules now mirror the enabled ones exactly, base at two classes and the track at full depth, so the gap survives. The adaptive "more" button paints its own icon colour and kept it inside a disabled grid. One thing this theme cannot fix: dx-filemanager-i-refresh and its siblings are background SVG data-URIs with the colour baked in, no currentColor, so no rule can recolour them. Every icon around them greys. That belongs to the component - an icon font or currentColor - not here.
The own-colour ratchet reads its baseline from disk and the baseline was never committed, so the gate has been failing every CI run since it was added while passing locally - the file was there on my machine and nowhere else. The package-disabled validator was in the same state. The cause is a habit rather than an oversight: this repo has a couple of dozen untracked files that must not be swept into a commit, so I stage from `git status` with untracked lines filtered out. That filter also silently drops files I create myself. Anything new has to be named explicitly.
…ew page Two halves of the same defect. The validation message greyed but the text inside it did not - red letters on a grey pill, which reads worse than either state. And the caption of an invalid editor stayed red after the editor was disabled: an error you are not allowed to correct. The state wins over the validity now. The review page is a static one, because the live comparison cannot travel: it needs a local server, the built bundles and a 32 MB library. So it carries the measurements instead - computed styles read from the live components for all 123 configurations, in both light and dark, three columns each. It says so on the page rather than passing itself off as a rendering. Its headline numbers are measured, not asserted: no configuration renders identically to its enabled self, and two rows keep a single element at full contrast - CardView's drag hint, which is invisible until a drag, and ScrollView's placeholder, which this page puts there itself. An earlier draft flagged ten rows by picking one element per case and comparing it; that heuristic called the white numeral inside a filled step a defect, and handing designers ten false leads would have been worse than handing them none.
DISABLED_STATES.md and the four playground pages stay on disk and leave the branch - they are working material for this review, not part of what ships. The live gallery is published from DevExtreme-dxDemos instead, so nobody needs the local copy to look at it. Seven tracked files pointed at that document from their comments, and a comment that sends the reader to a file the repository does not contain is worse than no comment. Each now carries its own reasoning: the widget note names the ratchet that lists the remaining dims, the chat notes state the reading that was chosen and the one it was chosen over, and the rest simply say the thing they were deferring.
The selected page and page size are filled discs, and a disabled pager kept the accent fill while the numeral on it greyed - grey on saturated blue, which reads worse than either state on its own. Both now take bg-disabled behind content-disabled, the answer the package gives for a selected menu item. The gallery case only ever showed the light mode, where the pages collapse into an editor and there is no disc to get wrong. The full mode is a case of its own now.
45 screenshots, all traced to a deliberate change before being accepted: the drag source is marked with colour instead of being halved, a disabled menu item greys, the switch handle and the radio ring moved to the roles the token package names, the scheduler tooltip's date line is secondary text rather than 80% opacity, and the chat transcript greys with its widget - the reading the owner chose. Two failures were not accepted. dataGrid (3/4) - fluent died with "the self-hosted runner lost communication with the server", which is infrastructure and not a comparison at all. And charts_drag differs across the whole plot area, in react only out of four frameworks, which is the signature of a drag that landed differently rather than of a colour that changed - charts have nothing to do with disabled states, so its etalon is left as it was.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.