Say what each control is, so a screen reader can use one

The whole interface carried five accessible-* declarations in 14,482 lines of
markup, all of them on a single row of the colour mixer, and nothing said so.
Every other control — every button, every tick-box, every chip, and the slider
the develop panel builds thirty-six of for the mixer alone — reached AT-SPI
and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a
user in that position; it is whether the application can be used at all.

The annotations go on the shared components rather than on the screens, which
is the same argument widgets.slint was written to make one layer down: a
control named where it is used is a control unnamed everywhere it is used
next. Twelve components now declare a role, a name and — where the control
does something — the action assistive technology invokes to do it. Three
screens were touched, and only where the component could not know the answer.

SliderTrack is the one that mattered most and the one that could not be fixed
from inside itself. It is handed four numbers and knows nothing about what
they mean, so it takes a `label` and a formatted `readout` and every wrapper
passes down what it was already drawing. A test asserts that every
instantiation does, because a track added without one announces "slider, 0.35"
and looks perfectly correct in a screenshot.

It also gains increment, decrement and set-value. A slider that can only be
dragged is a slider a pointer is a modifier for, which is the objection
ui-navigation D-N2 makes about hover-only affordances with the argument run
one step further; these three are what a screen reader drives a slider with,
and they commit as well as change — one nudge is a whole gesture, so a caller
that persists on `committed` must hear about it.

Two decisions worth recording because the obvious alternative is wrong:

`active` on Button and IconButton is deliberately not announced from the
component. It says a toggle is on and says nothing about whether a control
that is *off* is a toggle at all, so announcing it would report every button
in the application as an unpressed toggle. The four call sites that mean a
toggle say so themselves, which Slint permits because the role is inherited.

SwatchSlider's row-level role is removed rather than kept. It was the one
control that had a label, and now that the track underneath it has one too the
two would nest — a slider inside a slider, the outer holding the value and the
inner holding the actions that can change it. The row stands down and hands
the same strings to the control that owns the gesture.

The test reads the markup the way darkroom-android's manifest test reads its
XML: there is no accessibility tree without a window, so what it defends is
the failure that actually happens — a role or a name lost in a refactor, which
compiles, renders identically, and is invisible to everyone not using a screen
reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:22:32 +02:00
co-authored by Claude Opus 5
parent ef07e6ca3e
commit 8e4befa727
5 changed files with 657 additions and 10 deletions
+147
View File
@@ -13,6 +13,21 @@
// `surface` is; only this file says what a *panel heading* is — and until it
// did, four screens each re-derived one, which is exactly how a single accent
// colour reached forty call sites with no single place to change it.
//
// **The same argument decides where accessibility lives** (NFR-A11Y-2). What
// AT-SPI and TalkBack are handed — a role, a name, a value, and an action they
// can invoke — is a property of *what a control is*, not of where it happens
// to be used, so it is declared once here and at the call site only where the
// call site knows something the component cannot. A button annotated in forty
// places is a button unnamed in thirty-nine of them, which is the failure this
// file was written to stop, one layer down.
//
// Two Slint rules shape what that can look like. `accessible-role` must be a
// *constant* — a ternary over a runtime property is a compile error — so a
// component that would need two roles is two components. And every other
// `accessible-*` property is rejected unless a role is set beside it or on the
// element it inherits from; that inheritance is what lets a call site add
// `accessible-checkable` to a `Button` whose role was set here.
import { Theme } from "theme.slint";
import { Icon } from "icons.slint";
@@ -42,10 +57,31 @@ export component Button inherits Rectangle {
/// Sustained state — a toggle that is currently on, not a press. The same
/// meaning [`IconButton`] gives it, so a labelled toggle and an icon
/// toggle read alike.
///
/// **Deliberately not exposed to assistive technology from here.** Only
/// the call site knows whether a button that is currently *not* active is
/// a toggle that is off or an ordinary button that has no such state, and
/// announcing every button in the application as an unpressed toggle is
/// worse than announcing none of them. A call site that means a toggle
/// says so — `accessible-checkable: true; accessible-checked: <state>;` —
/// which Slint permits because the role below is inherited.
in property <bool> active: false;
callback clicked();
accessible-role: button;
accessible-label: root.text;
accessible-enabled: root.enabled;
// The action a screen reader invokes, and it goes around the TouchArea
// rather than through it — so the `enabled` gate the TouchArea applies to
// a pointer has to be applied again here, or a disabled button would be
// pressable by exactly the users who cannot see that it is greyed out.
accessible-action-default => {
if (root.enabled) {
root.clicked();
}
}
height: Theme.control-height;
// A minimum rather than a fixed width: callers that set `width` or hand
// this to a stretching layout still win, and a long label is not clipped.
@@ -108,12 +144,40 @@ export component Button inherits Rectangle {
export component IconButton inherits Rectangle {
/// A name from the [`Icon`] vocabulary.
in property <string> icon;
/// What the drawing means, in words.
///
/// A `Button` gets its accessible name for nothing, out of the text it was
/// already drawing. This control draws no text at all, so the name has to
/// be given — and an icon button without one is not a degraded experience
/// for a screen-reader user, it is an unusable one.
///
/// Left empty it falls back to the icon's own name, which is a bad label
/// ("chevron-right") and still a better answer than silence. That is the
/// bargain `labels::resolve` already strikes for a key nobody has
/// catalogued, and it is struck here for the same reason: a control added
/// today should be reachable before someone has written its word.
in property <string> label;
in property <bool> enabled: true;
/// Sustained state — a toggle that is currently on, not a press.
///
/// Not announced from here, for the reason [`Button`]'s copy of this
/// property gives: the component cannot tell an off toggle from a button
/// with no state, so the call site that means a toggle sets
/// `accessible-checkable: true` and `accessible-checked` beside its
/// `active`.
in property <bool> active: false;
callback clicked();
accessible-role: button;
accessible-label: root.label != "" ? root.label : root.icon;
accessible-enabled: root.enabled;
accessible-action-default => {
if (root.enabled) {
root.clicked();
}
}
width: Theme.control-height;
height: Theme.control-height;
horizontal-stretch: 0;
@@ -178,6 +242,21 @@ export component FilterChip inherits Rectangle {
callback clicked();
// Unlike [`Button`], this one *can* say it is a toggle without asking the
// call site, because being one is the whole of what distinguishes it —
// "it is *state*, not an action", two paragraphs up. So `checkable` is
// unconditional and an inactive chip announces as unpressed rather than
// as an ordinary button.
//
// The count is not folded into the name. It is drawn as a `Text`, which
// Slint already exposes as its own node, so a reader reaches it by moving
// one step further rather than by hearing a bare number glued to a word.
accessible-role: button;
accessible-label: root.label;
accessible-checkable: true;
accessible-checked: root.active;
accessible-action-default => { root.clicked(); }
height: Theme.control-height - 4px;
// A floor on a content-sized chip, expressed as one property: Slint rejects
// `width` and `min-width` together, and the floor is what keeps a chip
@@ -304,6 +383,15 @@ export component Section inherits Rectangle {
/// Whether this section's contents can be reset at all.
in property <bool> has-reset: true;
accessible-role: groupbox;
accessible-label: root.title;
accessible-expandable: true;
accessible-expanded: root.expanded;
accessible-action-expand => {
root.expanded = !root.expanded;
root.toggled(root.expanded);
}
background: transparent;
// Own height comes from the layout below, so a collapsed section shrinks
// to its header.
@@ -372,6 +460,22 @@ export component Section inherits Rectangle {
Rectangle {
width: 28px;
// The reset is drawn only under the pointer, which
// ui-navigation.md D-N2 rules out as the *only* route to
// a control. Announcing it whenever it is live gives a
// screen reader the route the ink withholds — so this is
// not a translation of the visual affordance so much as
// the honest version of it, and the gate is `has-reset &&
// modified` rather than the hover the `Text` below adds.
accessible-role: button;
accessible-label: "Reset";
accessible-enabled: root.has-reset && root.modified;
accessible-action-default => {
if (root.has-reset && root.modified) {
root.op-reset();
}
}
reset-touch := TouchArea {
// Sits after `header-touch` in the tree, so it takes
// the press first and the section does not toggle out
@@ -598,6 +702,20 @@ export component Panel inherits Rectangle {
// token is for.
export component Field inherits Rectangle {
in-out property <string> text;
/// What this entry is for, in words.
///
/// The visible caption belongs to whatever row wraps the field — `TextRow`
/// draws one above, the launch screen draws one beside — and none of those
/// is a thing Slint associates with the entry on its own. So the name is
/// carried a second time, here, where the accessibility tree can attach it
/// to the control the user is actually typing into.
///
/// That second copy is not redundant even where the caption renders. It is
/// the *only* copy where the caption does not: `TextRow`'s label draws
/// behind its own field on the settings page and has done since 0.9.0, so
/// a sighted user reading that page today has less to go on than a screen
/// reader does.
in property <string> label;
in property <string> placeholder;
/// Masks the entry, for a credential that should not be readable over the
/// user's shoulder. The placeholder still shows while the field is empty.
@@ -655,6 +773,13 @@ export component Field inherits Rectangle {
input := TextInput {
text <=> root.text;
edited => { root.edited(self.text); }
// Slint gives a TextInput its role, its value, its enabled state and
// its set-value action for free; the name and the placeholder are the
// two it cannot guess. They go on the entry rather than on the box
// around it so there is one node in the tree and not a nameless
// rectangle wrapping a nameless input.
accessible-label: root.label;
accessible-placeholder-text: root.placeholder;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
@@ -677,6 +802,12 @@ export component Field inherits Rectangle {
x: Theme.gap;
height: 100%;
visible: input.text == "";
// Drawn text, not content. The same words already reach the tree as
// the entry's `accessible-placeholder-text`, where a reader can
// announce them as a prompt rather than as a value the field holds —
// which is what a second text node beside an empty entry would look
// like. `lineedit-base.slint` in Slint's own widgets does exactly this.
accessible-role: none;
}
}
@@ -693,6 +824,22 @@ export component Field inherits Rectangle {
export component ProgressBar inherits Rectangle {
in property <float> fraction: 0;
in property <bool> indeterminate: false;
/// What is progressing. A bar with no name announces as "progress
/// indicator, 40%", which says how far along an unnamed something is.
in property <string> label;
// An indeterminate bar reports no value at all rather than 0%. It has one
// — the sweep — but it is not a position, and a reader that announced 0%
// for a directory walk that is half done would be stating a falsehood in
// the one place the interface was careful not to (see the two modes
// above). Silence is the honest answer to "how far".
accessible-role: progress-indicator;
accessible-label: root.label;
accessible-value: root.indeterminate
? ""
: Math.round(clamp(root.fraction, 0, 1) * 100) + "%";
accessible-value-minimum: 0;
accessible-value-maximum: 100;
height: 3px;
background: Theme.rule;