Skip to content

Expose the disabled state on the widget root and stop a11y checks passing vacuously - #35010

Open
EugeniyKiyashko wants to merge 53 commits into
DevExpress:feature/26_2_new_fluent_theme_with_design_tokensfrom
EugeniyKiyashko:fluent-next/disabled
Open

Expose the disabled state on the widget root and stop a11y checks passing vacuously#35010
EugeniyKiyashko wants to merge 53 commits into
DevExpress:feature/26_2_new_fluent_theme_with_design_tokensfrom
EugeniyKiyashko:fluent-next/disabled

Conversation

@EugeniyKiyashko

Copy link
Copy Markdown
Contributor

No description provided.

…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.
…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.
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant