diff --git a/docs/technical-debt.md b/docs/technical-debt.md index fb972b7..7a00b0b 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -297,6 +297,152 @@ millisecond for the `point` and `all` chains, and the codegen tests still pass b --- +## TD-6 — The quietest ink does not reach WCAG AA, and the rule does not reach 3:1 + +**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "non-canvas UI meets WCAG AA contrast". + +### What it does + +`style.yaml` sets three inks and four surfaces. Measured as WCAG 2 contrast ratios (sRGB relative +luminance, the standard formula), against the surfaces each ink is actually drawn on: + +| ink | on `ground` | on `surface` | on `surface-raised` | on `hover` | on `selected` | +|---|---|---|---|---|---| +| `ink` #EDEEF0 | 16.02 | 14.69 | 13.03 | 11.39 | 9.68 | +| `ink-dim` #9EA1A6 | 7.18 | 6.58 | 5.84 | 5.10 | **4.34** | +| `ink-faint` #71747A | **3.97** | **3.64** | **3.23** | **2.82** | **2.40** | +| `warn-ink` #C9A05A | 7.67 | 7.03 | 6.24 | 5.45 | 4.64 | +| `rule` #323438 | **1.49** | **1.37** | **1.21** | **1.06** | **1.11** | + +Every text size in the application is 11px, 13px, 17px or 24px, and WCAG's "large text" relief +begins at 18.66px bold or 24px regular — so all four of those thresholds are the normal-text one, +**4.5:1**, except the masthead. Bold entries fail it. + +The inverted cases pass and are worth stating so nobody re-measures them: `ground` on `active` +(#FFFFFF) is 18.60, on `active-dim` 11.20, on `active-pressed` 5.95, on `selected-ring` 13.02. The +near-white fills that `Button.primary`, `FilterChip.active` and the held tool-rail entry use are +the *best*-contrasting text in the interface, not the worst. + +So the failures are exactly two, and neither is where one would guess: + +- **`ink-faint` reaches 4.5:1 nowhere at all.** It is the ink for `Caption`, `PanelHeading`, + `Disclosure`, `Value`'s placeholder state and `FilterChip`'s count — every hint, every section + name, every "3 photographs" under a title. +- **`rule` reaches 3:1 nowhere.** WCAG 1.4.11 asks 3:1 of the boundary of a control the user must + perceive, and `rule` is the border of every `Button`, `Field`, `Panel`, `ChoiceChip` and + `IconButton`. An unfilled secondary button is a 1.4:1 outline on a 1.2:1 background. + +`ink-dim` on `selected` at 4.34 is a third case, marginal enough that a two-point lift fixes it. + +### Why + +Not an oversight — the direct consequence of the palette's own argument, which `style.yaml`'s +preamble makes at length and correctly. The chrome is deliberately quiet because a bright surround +biases how a photograph is judged, and hue is banned outright because an accent beside the image +shifts the perception of nearby colours. What is left to signal with is luminance, and the palette +spends its luminance range on the *photograph*, keeping the chrome inside a narrow band above the +ground. + +A narrow band is precisely what a contrast ratio measures. `ink-faint` exists to be skipped by the +reader who did not stop to look; that is a real design intent, and "text you are meant to skip" +and "text everyone can read" are in genuine tension rather than one being a mistake. + +### What it costs + +The photographer who cannot read a caption cannot read *any* caption, on any screen — this is one +token, so it fails everywhere at once. The hints under the settings switches say what a setting +costs, the section names say what a panel is, and the counts say how big a filter is. None of it is +decorative. + +### Paying it off + +Two token changes, and the second is the awkward one. + +`ink-faint` needs roughly #8A8D93 to clear 4.5:1 against `surface-raised`, the darkest surface it +is drawn on that matters — which puts it about where `ink-dim` sits today and collapses the +three-ink scale to two. So the real fix is to re-derive all three inks against the surfaces rather +than to nudge one: the scale wants to start higher and keep its steps, not compress. + +`rule` needs about #4A4D52 for 3:1 against `surface`. That is a visibly stronger line, and the +preamble's "instrument rather than absence" reasoning applies to it as much as to the greys — this +is a look change, not a number change, and it should be looked at rather than computed. + +Both are decisions about how the application appears next to a photograph, which is the one thing +this palette was designed around. They want a screenshot and an opinion, not a patch. + +**Done when:** every `Theme` ink reaches 4.5:1 against every surface it is drawn on, `rule` reaches +3:1 against `surface` and `surface-raised`, and a test recomputes those ratios from `style.yaml` so +the next palette edit cannot quietly undo it. The table above is the baseline to compare against. + +### Not in scope + +The histogram's `plot-*` inks (2.36 for `plot-luma` on `ground`) are drawn *on* the canvas, and +NFR-A11Y-2 scopes contrast to non-canvas UI. NFR-A11Y-3 covers what those need instead, and is +already met — the readouts name the channel in words. + +--- + +## TD-7 — Platform font scaling is not honoured + +**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "platform font scaling is honoured +without clipping". + +### What it does + +Nothing at all, which is the entry. Every type size is a constant in `style.yaml` — 11, 13, 17, 24 +— read as `Theme.text-sm` and friends at 65 call sites, and there is no multiplier anywhere between +the platform's font-size preference and those numbers. `scale_factor()` is read in `display_ui.rs` +and in `lib.rs`, but only to size the canvas in physical pixels for the render; it is display DPI, +which Slint already applies to logical lengths, and it is not the user's text-size setting. A +photographer who sets 130% text on GNOME or Android gets an application that ignores it. + +### Why + +Because honouring it is not a multiplier, and pretending it is would be worse than not doing it. + +The layout is built on constants that are not derived from the type size: `control-height` 28, +`touch-target` 44, `row-height` 26, `rail-entry-height` 54, `panel-width` 360, and a dozen fixed +heights written at their call sites — `ParamSlider`'s 46px, the folder picker's 220px box, the +readout column's 30px. Scaling the type alone clips against every one of them, silently, because +Slint elides rather than errors. `SwatchSlider` is the sharpest case: 12 hue bands × 3 channels in +a 360px column, sized so that a track and a swatch and a three-character readout fit on one line. + +And the failure is invisible to the person shipping it. The requirement's own phrase is "without +clipping", and clipping is exactly what a screenshot at 100% cannot show — the memory note on +verifying Slint changes exists because these files have a history of compiling, rendering and being +wrong. + +### What it costs + +The user for whom this matters most is not the screen-reader user the rest of this branch serves — +it is the one with usable but poor sight, who reads the interface and needs it larger. The +application is unusable to them at any setting, and there is no partial credit: text scaling is a +system-wide preference, so an app that ignores it is the one thing on the desktop that did. + +### Paying it off + +In the order the pieces depend on each other: + +1. **A scale token.** `Theme.text-sm` and the rest become `base × Theme.type-scale`, with the + scale an `in-out` property Rust writes at startup from the platform. `build.rs` already emits + `in-out` tokens under `live-style`, so the codegen half of this exists and is proven — that + feature is the mechanism, one line from being general. +2. **A source for the number.** GNOME publishes `text-scaling-factor` over the settings portal; + Android has `Configuration.fontScale` through JNI, beside the calls `lib.rs` already makes for + `ACTION_VIEW`. Both want a default of 1.0 and a sane clamp — 0.8 to 2.0 — because a user who has + set 300% for a phone launcher has not asked for a 72px slider readout. +3. **The constants that are not type.** Every fixed height a *label* sits inside has to follow the + scale; every touch target must not shrink and need not grow. That is the work, and it is where + the 46px and 220px literals get read one at a time. +4. **Evidence.** Screenshots at 1.0, 1.3 and 2.0 of the develop column, the settings page and the + colour mixer — the three densest layouts — because "without clipping" is a claim about the + worst case and nothing else will show it. + +**Done when:** the develop column, the settings page and the colour mixer render at a 2.0 scale +with no elided label and no touch target under 44 logical pixels. + +--- + ## Related, and deliberately not here The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed* diff --git a/ui/dr-ui/build.rs b/ui/dr-ui/build.rs index 8d6d2d7..642a9f3 100644 --- a/ui/dr-ui/build.rs +++ b/ui/dr-ui/build.rs @@ -12,6 +12,48 @@ //! `OUT_DIR` as an include path makes the generated file answer every //! existing `import { Theme } from "theme.slint"` unchanged. This only works //! while no `ui/theme.slint` exists to shadow it — see the guard below. +//! +//! # Translations (NFR-A11Y-1) +//! +//! A string written `@tr("Sign in")` in the markup is **already correct in a +//! build with no translation at all**: with neither the `gettext` nor the +//! `bundle-translations` path active, Slint's `translate()` formats the +//! original and returns it. That is the property that makes converting the +//! interface a string at a time possible rather than a flag day — the +//! alternative, wiring the machinery first and converting after, means every +//! intermediate commit ships an interface half of which cannot be translated +//! and none of which can be extracted. +//! +//! **Extracting.** `slint-tr-extractor` walks the `.slint` files and writes a +//! `.pot`: +//! +//! ```text +//! cargo install slint-tr-extractor +//! find ui/dr-ui/ui -name '*.slint' | xargs slint-tr-extractor -o dr-ui.pot +//! ``` +//! +//! Do **not** pass `--no-default-translation-context`. Slint's default +//! context is the enclosing component's name, which is what keeps the two +//! senses of a word like "or" apart when the same word is a conjunction on one +//! screen and a search operator on another — and the extractor and the +//! compiler have to agree about it or every lookup misses silently. +//! +//! **Delivering.** Two mechanisms exist and this one picks bundling: a `.po` +//! per language under `lang//LC_MESSAGES/dr-ui.po`, compiled into the +//! binary by the block in `main`. The alternative is Slint's `gettext` +//! feature, which reads `.mo` files off disk at runtime through the C gettext +//! library. Bundling wins here for one reason that outranks the rest: +//! **Android**, where there is no filesystem path a `.mo` could sit at that +//! the app can reach under ARCH §6.9's storage model, and no C library to +//! link. A build with no `lang/` directory takes neither path and keeps every +//! original string, which is exactly the state of this crate today. +//! +//! **What this does not reach.** `@tr()` is markup. The operation and +//! parameter labels NFR-A11Y-1 names explicitly are resolved in Rust, by +//! `labels.rs`, from the `LocalizedKey`s the core publishes — the core cannot +//! depend on a localisation library (ARCH §6.5a), which is the constraint that +//! put the catalogue in the UI crate in the first place. Translating those +//! needs a second mechanism on the Rust side, and it is not built. use std::collections::BTreeSet; use std::fmt::Write as _; @@ -21,6 +63,8 @@ use serde_norway::Value; const STYLE_YAML: &str = "style.yaml"; const GENERATED: &str = "theme.slint"; +/// Where a `.po` goes, relative to this crate: `lang//LC_MESSAGES/`. +const LANG_DIR: &str = "lang"; fn main() { println!("cargo:rerun-if-changed={STYLE_YAML}"); @@ -72,12 +116,48 @@ fn main() { // The failure is silent either way: a missing image loads as empty. // Embedding costs the size of ui/app-icon.png, the only asset this // reaches, since every UI glyph is a Path rather than a file. - let config = slint_build::CompilerConfiguration::new() + let mut config = slint_build::CompilerConfiguration::new() .with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")]) .embed_resources(slint_build::EmbedResourcesKind::EmbedFiles); + + // Bundling is asked for only once a translation exists to bundle. + // + // Enabling it unconditionally would make an empty `lang/` — or a + // missing one, which is every checkout today — into a build failure for + // everyone, in service of a feature nobody is yet using. Asking the + // directory instead means the mechanism is wired and inert: the first + // `lang/fr/LC_MESSAGES/dr-ui.po` someone commits turns it on with no + // build-system change, which is the point at which a translator can + // actually verify their work. + if let Some(lang) = translations(&manifest_dir) { + println!("cargo:rerun-if-changed={}", lang.display()); + config = config.with_bundled_translations(lang); + } + slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint"); } +/// The translation root, if any language has a catalogue in it. +/// +/// The domain is the crate name — slint-build takes it from `CARGO_PKG_NAME` +/// and this reads the same variable, so a rename does not leave the two +/// halves looking for different files. Checking for a `.po` rather than +/// merely for the directory is deliberate: an empty `lang/` left behind by a +/// half-finished translation would otherwise switch bundling on and hand every +/// string to a lookup with nothing behind it. +fn translations(manifest_dir: &Path) -> Option { + let root = manifest_dir.join(LANG_DIR); + let catalogue = format!("{}.po", env!("CARGO_PKG_NAME")); + let entries = std::fs::read_dir(&root).ok()?; + + for entry in entries.flatten() { + if entry.path().join("LC_MESSAGES").join(&catalogue).is_file() { + return Some(root); + } + } + None +} + /// The file handed to the Slint compiler. /// /// Normally `ui/app.slint` itself. Under `live-style` it is a generated diff --git a/ui/dr-ui/tests/ui_controls_are_accessible.rs b/ui/dr-ui/tests/ui_controls_are_accessible.rs new file mode 100644 index 0000000..44141bf --- /dev/null +++ b/ui/dr-ui/tests/ui_controls_are_accessible.rs @@ -0,0 +1,325 @@ +// TRACES: NFR-A11Y-2 +//! Every shared control says what it is, and every slider says what it edits. +//! +//! NFR-A11Y-2 asks that controls expose names, roles and values to AT-SPI and +//! TalkBack. Almost none of that is Rust: it is `accessible-*` properties in +//! Slint markup, which no unit test can observe by running the interface — +//! there is no accessibility tree without a window, and CI has no display. +//! +//! So this reads the markup, the way `darkroom-android`'s manifest test reads +//! the XML that aapt2 owns and rustc never sees. The property being defended +//! is not "the screen reader announces the right words", which needs a device +//! and a person; it is the thing that silently regresses instead — **a control +//! losing its role or its name in an ordinary refactor**, which compiles, +//! renders identically, passes every other test, and is invisible to anyone +//! not using a screen reader. That failure has already happened once here: the +//! whole application had five `accessible-*` lines in 14,482 lines of markup, +//! all of them on one row of the colour mixer, and nothing said so. +//! +//! ## What is checked, and what deliberately is not +//! +//! Two properties, both structural: +//! +//! 1. **Named components declare the roles they promise.** A `Button` that +//! stops declaring `accessible-role` stops existing for a screen reader +//! entirely, because Slint rejects every other `accessible-*` property that +//! is not accompanied by a role — so the role is the load-bearing one and +//! the rest fail with it. +//! +//! 2. **Every `SliderTrack` instantiation supplies a `label`.** This is the +//! one that catches a *new* control rather than a broken old one. The track +//! cannot name itself — it has no idea what number it is dragging — so an +//! unnamed one announces "slider, 0.35" and is the single most-used control +//! in the application. A track added without a label is the exact mistake +//! this file exists to make loud. +//! +//! Not checked: whether the words are good, whether they are translated, +//! whether contrast passes, or whether Android exposes any of it — spike S13 +//! is still unrun and no test on this side of it can answer that. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// A component that must carry particular `accessible-*` properties, and the +/// properties it must carry. +/// +/// Only the ones whose absence is a behavioural loss are listed. `Button` must +/// declare `accessible-action-default` because without it a screen reader can +/// read the button and not press it, which is a control that exists and cannot +/// be used; it need not declare `accessible-description`, which is a nicety. +struct Promise { + file: &'static str, + component: &'static str, + properties: &'static [&'static str], +} + +const PROMISES: &[Promise] = &[ + Promise { + file: "widgets.slint", + component: "Button", + properties: &["accessible-role", "accessible-label", "accessible-action-default"], + }, + Promise { + file: "widgets.slint", + component: "IconButton", + properties: &["accessible-role", "accessible-label", "accessible-action-default"], + }, + Promise { + file: "widgets.slint", + component: "FilterChip", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, + Promise { + file: "widgets.slint", + component: "Section", + properties: &["accessible-role", "accessible-label", "accessible-expanded"], + }, + Promise { + file: "widgets.slint", + component: "ProgressBar", + properties: &["accessible-role", "accessible-label", "accessible-value"], + }, + // The label goes on the `TextInput` inside, not on the box around it — + // Slint gives that element its role, so the name belongs beside it. + Promise { + file: "widgets.slint", + component: "Field", + properties: &["accessible-label", "accessible-placeholder-text"], + }, + Promise { + file: "controls.slint", + component: "SliderTrack", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-value", + "accessible-value-minimum", + "accessible-value-maximum", + "accessible-action-increment", + "accessible-action-decrement", + "accessible-action-set-value", + ], + }, + Promise { + file: "controls.slint", + component: "Check", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, + Promise { + file: "controls.slint", + component: "ChoiceChip", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, +]; + +fn ui_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("ui") +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// The body of one top-level component declaration, or `None` if the file +/// declares no such component. +/// +/// Every component in these files is declared at column zero, so the block +/// runs from its header to the next header or to the end of the file. A +/// looser rule than brace matching and a stricter one than "somewhere in the +/// file": the point is that a property found for `Button` really is inside +/// `Button` and not inside the component below it. +fn component_body<'a>(source: &'a str, name: &str) -> Option<&'a str> { + let header_starts = |line: &str| { + line.starts_with("component ") || line.starts_with("export component ") + }; + + let mut start = None; + for (offset, line) in line_offsets(source) { + if !header_starts(line) { + continue; + } + // `component Foo inherits Bar {` — the word after `component`. + let declared = line + .trim_start_matches("export ") + .trim_start_matches("component ") + .split_whitespace() + .next(); + + match start { + None if declared == Some(name) => start = Some(offset), + Some(from) => return Some(&source[from..offset]), + None => {} + } + } + start.map(|from| &source[from..]) +} + +/// Each line of `source` with its byte offset. +fn line_offsets(source: &str) -> impl Iterator { + let mut offset = 0; + source.lines().map(move |line| { + let at = offset; + offset += line.len() + 1; + (at, line) + }) +} + +/// Whether a block sets a property — the name at the start of a line, followed +/// by what Slint puts after a name it is binding to. +/// +/// Three forms, and the third is easy to forget: `name:` for a property, +/// `name =>` for a callback, and `name(arg) =>` for a callback that takes one. +/// `accessible-action-set-value` is the only member of the third group here and +/// leaving it out made this test report the slider as missing the very action +/// it declares. +/// +/// Anchored at the start of the trimmed line so that a *mention* in the prose +/// above a component does not count. These files carry more comment than code, +/// and several of those comments name the very properties asserted here; a +/// substring search would pass on the strength of an explanation of the thing +/// that had been deleted, which is the failure `ui_names_no_operation.rs` +/// documents at length and the android manifest test strips comments to avoid. +fn sets(block: &str, property: &str) -> bool { + block.lines().any(|line| { + let line = line.trim_start(); + line.strip_prefix(property).is_some_and(|rest| { + let rest = rest.trim_start(); + rest.starts_with(':') || rest.starts_with("=>") || rest.starts_with('(') + }) + }) +} + +#[test] +fn shared_controls_declare_their_roles() { + let ui = ui_dir(); + let mut failures = Vec::new(); + + for promise in PROMISES { + let path = ui.join(promise.file); + let source = read(&path); + + let Some(body) = component_body(&source, promise.component) else { + failures.push(format!( + " {} declares no component `{}` — it was renamed or removed, and \ + this test then checks nothing", + promise.file, promise.component + )); + continue; + }; + + for property in promise.properties { + if !sets(body, property) { + failures.push(format!( + " {}: `{}` does not set `{property}`", + promise.file, promise.component + )); + } + } + } + + assert!( + failures.is_empty(), + "\n\nNFR-A11Y-2: controls expose names, roles and values to the platform \ + accessibility layer.\n\n{}\n\n\ + Slint rejects every `accessible-*` property that is not accompanied by an \ + `accessible-role`, so the role is what makes the control exist for AT-SPI \ + and TalkBack at all, and the actions are what make it usable rather than \ + merely readable.\n\n\ + These live on the shared components rather than at the call sites \ + deliberately: a control annotated where it is used is a control unnamed \ + everywhere it is used next. See the preamble to widgets.slint.\n", + failures.join("\n") + ); +} + +#[test] +fn every_slider_track_is_named() { + let ui = ui_dir(); + let mut files: Vec = fs::read_dir(&ui) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", ui.display())) + .map(|entry| entry.expect("read dir entry").path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("slint")) + .collect(); + files.sort(); + + let mut instantiations = 0usize; + let mut unnamed = Vec::new(); + + for path in &files { + let source = read(path); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + + // The declaration itself, in controls.slint, is not an instantiation. + for (offset, line) in line_offsets(&source) { + if line.trim_start() != "SliderTrack {" { + continue; + } + instantiations += 1; + + let block = &source[offset..offset + block_len(&source[offset..])]; + if !sets(block, "label") { + let number = source[..offset].lines().count() + 1; + unnamed.push(format!(" {name}:{number}")); + } + } + } + + // A scan that found nothing passes for the wrong reason. Four tracks are + // instantiated today — the develop panel's three shapes and the settings + // page's row — and the floor sits below that and well above zero, so + // removing one is a code change and not a silent scan failure. + assert!( + instantiations >= 3, + "found only {instantiations} `SliderTrack` instantiations — the scan is \ + matching nothing, and a scan over nothing passes" + ); + + assert!( + unnamed.is_empty(), + "\n\nNFR-A11Y-2: a slider that does not say what it adjusts.\n\n{}\n\n\ + `SliderTrack` cannot name itself — it is handed four numbers and knows \ + nothing about what they mean — so a track without a `label` announces as \ + \"slider, 0.35\". The develop column holds thirty-six of them in the \ + colour mixer alone, which is thirty-six controls a screen reader cannot \ + tell apart.\n\n\ + Pass the same string the row is already drawing above the track.\n", + unnamed.join("\n") + ); +} + +/// The length of the brace-delimited block beginning at the start of `rest`, +/// including both braces. +/// +/// Braces inside string literals are not handled, and do not occur inside a +/// `SliderTrack` instantiation — the only strings there are labels and units. +fn block_len(rest: &str) -> usize { + let mut depth = 0usize; + for (i, c) in rest.char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + _ => {} + } + } + rest.len() +} diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 17dbaf1..ae2a438 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -140,6 +140,18 @@ component GroupHeading inherits Rectangle { width: 34px; visible: root.has-reset; + // A `Caption` is a `Text`, which Slint announces as text — so + // without this the reset reads as the word "reset" sitting beside + // the heading rather than as something that can be pressed. + accessible-role: button; + accessible-label: "Reset"; + accessible-enabled: root.has-reset; + accessible-action-default => { + if (root.has-reset) { + root.reset(); + } + } + reset-touch := TouchArea { width: 100%; height: max(parent.height, Theme.touch-target); @@ -202,6 +214,18 @@ component ParamSlider inherits Rectangle { modified: root.data.value != root.data.default-value; SliderTrack { + // The same two strings `ControlRow` is drawing above the track. + // Drawn text and announced text are separate channels — Slint + // associates neither with the control on its own — so the label a + // sighted user reads and the label a screen reader hears come from + // one expression each, rather than the second being left empty. + label: root.data.param-label; + readout: Readout.of(root.data); + // The descriptor's declared precision, as a step: a parameter in + // whole units nudges by one and one in stops by a hundredth, + // which is the same quantum the readout is rounded to. + step: root.data.precision == 0 ? 1.0 : 0.01; + value: root.data.value; default-value: root.data.default-value; minimum: root.data.minimum; @@ -245,12 +269,18 @@ component FacetHeading inherits Rectangle { // most of a screen of scrolling with the band name absent from every row of it // anyway. // -// **The name is not thrown away, it moves.** It is the row's accessible label, -// so a screen reader says "Orange" where the eye reads the colour, and the -// catalogue in `labels.rs` is where the mapping is written down for anyone who -// cannot separate two squares by eye. A row identified by colour *alone* +// **The name is not thrown away, it moves.** It becomes the track's accessible +// label, so a screen reader says "Orange" where the eye reads the colour, and +// the catalogue in `labels.rs` is where the mapping is written down for anyone +// who cannot separate two squares by eye. A row identified by colour *alone* // would be a control some photographers could not use, which is why the // spoken name is part of the design and not an afterthought. +// +// It used to be announced on this row, with the track below it silent. Now +// that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a +// slider inside a slider, the outer one carrying the value and the inner one +// carrying the actions that can change it — so the row stands down and hands +// the same three strings to the control that owns the gesture. component SwatchSlider inherits Rectangle { in property data; callback changed(float); @@ -264,12 +294,6 @@ component SwatchSlider inherits Rectangle { // and the gestures behind it (FR-UI-3). height: Theme.touch-target / 2 + 4px; - accessible-role: slider; - accessible-label: root.data.param-label; - accessible-value: Readout.of(root.data); - accessible-value-minimum: root.data.minimum; - accessible-value-maximum: root.data.maximum; - HorizontalLayout { padding-left: Theme.gap-sm; spacing: Theme.gap-sm; @@ -284,6 +308,8 @@ component SwatchSlider inherits Rectangle { SliderTrack { horizontal-stretch: 1; + label: root.data.param-label; + readout: Readout.of(root.data); value: root.data.value; default-value: root.data.default-value; minimum: root.data.minimum; @@ -346,6 +372,10 @@ component PlainSlider inherits Rectangle { modified: root.value != root.default-value; SliderTrack { + label: root.label; + readout: (Math.round(root.value * 10) / 10) + root.unit; + step: 0.1; + value: root.value; default-value: root.default-value; minimum: root.minimum; @@ -575,16 +605,26 @@ export component GeometryPanel inherits Rectangle { // Rotation and flips. Icons rather than labels: four controls // named in words would wrap the 280px column, and each of // these shows its own result. + // + // The words are still written, once each, as `label` — the width + // argument is about the column, and a screen reader has no column. + // The two flips also declare themselves checkable, which + // `IconButton` cannot do on its own: `active` says a toggle is on + // and says nothing at all about whether an inactive control is a + // toggle that is off, so only these call sites know that the two + // rotations are not toggles and these two are. HorizontalLayout { spacing: Theme.gap-sm; IconButton { icon: "rotate-ccw"; + label: "Rotate left"; enabled: root.enabled; clicked => { root.rotate(-1); } } IconButton { icon: "rotate-cw"; + label: "Rotate right"; enabled: root.enabled; clicked => { root.rotate(1); } } @@ -593,13 +633,19 @@ export component GeometryPanel inherits Rectangle { IconButton { icon: "flip-h"; + label: "Flip horizontally"; active: root.flip-h; + accessible-checkable: true; + accessible-checked: root.flip-h; enabled: root.enabled; clicked => { root.flip-h-toggled(); } } IconButton { icon: "flip-v"; + label: "Flip vertically"; active: root.flip-v; + accessible-checkable: true; + accessible-checked: root.flip-v; enabled: root.enabled; clicked => { root.flip-v-toggled(); } } diff --git a/ui/dr-ui/ui/controls.slint b/ui/dr-ui/ui/controls.slint index 0282927..c3b1296 100644 --- a/ui/dr-ui/ui/controls.slint +++ b/ui/dr-ui/ui/controls.slint @@ -22,6 +22,16 @@ // primitives take plain numbers and strings, and the ParamRow-shaped wrappers // stay in the panel that owns the model. This is the constraint that makes the // file reusable, so it is worth stating rather than merely observing. +// +// **Accessibility follows the same rule as behaviour** (NFR-A11Y-2, and see +// widgets.slint's preamble for the two Slint constraints that shape it): a +// control's role, and the actions assistive technology can invoke on it, are +// written once here. Its *name* is the one thing that cannot be — a track has +// no idea what number it is dragging — so every primitive below takes a +// `label`, and the wrappers that do know pass it down. An unnamed control is +// the failure mode that matters: a screen reader announcing "slider, 0.35" for +// each of the thirty-six controls in the colour mixer has told the user +// nothing at all. import { Theme } from "theme.slint"; import { Icon, Label, Value, Caption, Field } from "widgets.slint"; @@ -49,6 +59,30 @@ export component SliderTrack inherits Rectangle { in property minimum; in property maximum; + /// What this track adjusts. The track's accessible name. + /// + /// Every wrapper already draws this word somewhere — `ControlRow` puts it + /// above, `FieldRow` puts it above and to the left — and none of those + /// placements associates it with the control as far as the platform is + /// concerned. `SwatchSlider` is the case that makes the point: it draws no + /// word at all, only a coloured square, and its name has *always* had to + /// travel this way. + in property label; + /// The value as the user should hear it, already formatted. + /// + /// Empty falls back to the raw number, which is right for a track whose + /// caller has nothing better; a caller with a declared precision and a + /// unit hands over what it is drawing, so "+1.25 EV" is announced rather + /// than "1.2500000298". + /// + /// A string rather than a float for the reason `ControlRow.readout` gives: + /// precision belongs to whoever owns the value, and a control that rounded + /// on its own would announce a parameter one way and draw it another. + in property readout; + /// How far one assistive-technology nudge moves the value. Zero takes a + /// hundredth of the range, which is the resolution a drag has anyway. + in property step: 0; + /// Live, once per movement. For anything that should follow the drag: a /// readout, a preview, the image itself. callback changed(float); @@ -76,6 +110,47 @@ export component SliderTrack inherits Rectangle { // by nothing and put every position at infinity. property span: max(0.000001, root.maximum - root.minimum); + property nudge: root.step > 0 ? root.step : root.span / 100; + + // **One nudge is a whole gesture, so it commits.** + // + // The two callbacks exist because a drag is many movements and one + // decision (see `committed` above). An arrow key pressed once is both at + // the same time: there is no stream to debounce and no release to wait + // for, so a nudge that only fired `changed` would move the photograph and + // never be saved by any caller that listens for the end of a drag — which + // is every settings-shaped caller in the application. + function move-to(v: float) { + root.changed(clamp(v, root.minimum, root.maximum)); + root.committed(clamp(v, root.minimum, root.maximum)); + } + + // **The only route to this control that is not a pointer.** ui-navigation + // D-N2 rules out hover as the sole affordance; a control reachable only by + // dragging it is the same objection with the pointer itself as the + // modifier. These three actions are what AT-SPI and TalkBack drive a + // slider with, and they are what makes the track adjustable rather than + // merely readable. + accessible-role: slider; + accessible-label: root.label; + // Both arms of the ternary must be strings — the empty concatenation is + // what makes the fallback one. + accessible-value: root.readout != "" ? root.readout : (root.value + ""); + accessible-value-minimum: root.minimum; + accessible-value-maximum: root.maximum; + accessible-value-step: root.nudge; + accessible-action-increment => { root.move-to(root.value + root.nudge); } + accessible-action-decrement => { root.move-to(root.value - root.nudge); } + accessible-action-set-value(v) => { + // `is-float()` for the reason `NumberField` gives at length: + // `to-float()` answers 0 for a string it could not parse, and a + // screen reader handing over "abc" would silently set the exposure to + // zero rather than reject the entry. + if (v.is-float()) { + root.move-to(v.to-float()); + } + } + // **Why hover, and not the drag itself.** // // A Flickable does not merely compete for a gesture, it *withholds* the @@ -258,6 +333,9 @@ export component NumberField inherits Rectangle { in property value; in property minimum; in property maximum; + /// What the number means. Handed straight to the entry, which is where the + /// accessibility tree wants it — see `Field.label`. + in property label; /// Decimal places shown, and the precision an entry is held to. Zero for a /// count, two for a value in stops — the same figure a parameter /// descriptor declares. @@ -303,6 +381,7 @@ export component NumberField inherits Rectangle { field := Field { width: 100%; height: 100%; + label: root.label; text <=> root.text; // Committed on Enter *and* on losing focus, matching `TextRow`: Enter // alone loses the edit the moment the user clicks the next control, @@ -344,6 +423,21 @@ export component Check inherits Rectangle { callback toggled(bool); + // The hint becomes the description rather than part of the name. It exists + // to say what a setting *costs* — "location is stripped", "upscaling is + // off" — which is the second thing a reader wants and never the first, and + // a name that carried it would read the whole sentence back on every pass + // through the page. + accessible-role: checkbox; + accessible-label: root.label; + accessible-description: root.hint; + accessible-checkable: true; + accessible-checked: root.checked; + accessible-action-default => { + root.checked = !root.checked; + root.toggled(root.checked); + } + height: max(row.preferred-height, Theme.control-height); touch := TouchArea { @@ -408,6 +502,31 @@ export component ChoiceChip inherits Rectangle { callback clicked(); + // `radio-button` and not `button`, because single-selection over a fixed + // list is what a radio button *is* — and the difference is audible: a + // reader announcing "radio button, selected" has told the user that + // picking another one will unpick this, which "button, pressed" has not. + // It is the same distinction the prose above draws against `FilterChip`, + // said in the vocabulary the platform already has a word for. + // + // **No `radio-group` around them.** The role exists, and the natural home + // for it — `Segmented` — is a `VerticalLayout` rather than the plain root + // Slint's own `RadioGroupBase` carries it on, and `Segmented` delegates to + // `ChipGrid` for a wrapped set, so the group would either sit on a layout + // element or be declared twice and nest. The group's name still reaches a + // reader: `FieldRow` draws it as a `Text`, which Slint exposes on its own, + // immediately before the chips in traversal order. + accessible-role: radio-button; + accessible-label: root.label; + accessible-enabled: root.enabled; + accessible-checkable: true; + accessible-checked: root.selected; + accessible-action-default => { + if (root.enabled) { + root.clicked(); + } + } + height: Theme.control-height; // Wide enough that a one-word label is still a comfortable target, which // is what `control-min-width` exists for — but chips sit several to a row, @@ -612,6 +731,7 @@ export component TextRow inherits VerticalLayout { field := Field { width: 100%; + label: root.label; text <=> root.text; placeholder: root.placeholder; // Committed on Enter *and* on losing focus. Enter alone loses @@ -755,6 +875,14 @@ export component SliderRow inherits VerticalLayout { // tall where the track is half of one. y: (parent.height - self.height) / 2; + label: root.label; + // A declared precision is a declared step — the argument above, + // reused. The nudge an assistive technology makes is therefore the + // same quantum a drag snaps to, so arrowing to a value and + // dragging to it produce the same number rather than two that + // differ in the last place. + step: 1.0 / root.step-factor; + value: root.live; default-value: root.default-value; minimum: root.minimum; @@ -768,6 +896,7 @@ export component SliderRow inherits VerticalLayout { NumberField { // Follows the drag, so the number and the handle never disagree. value: root.live; + label: root.label; minimum: root.minimum; maximum: root.maximum; precision: root.precision; diff --git a/ui/dr-ui/ui/identity.slint b/ui/dr-ui/ui/identity.slint index 7899f75..ace70f6 100644 --- a/ui/dr-ui/ui/identity.slint +++ b/ui/dr-ui/ui/identity.slint @@ -155,12 +155,19 @@ component FaceCell inherits Rectangle { // judgement, and the two are never conflated. A // rejection is remembered, so the face is not suggested // for that person again. + // The gesture note above is the whole label: a tick and a cross + // are only "confirm" and "reject" to someone who can see the + // suggestion they sit beside, and `IconButton`'s fallback would + // announce them as "check" and "cross" — two icon names that say + // nothing about which person is being ruled on. if !face.confirmed: IconButton { icon: "check"; + label: "Confirm this face"; clicked => { root.confirm(); } } if !face.confirmed: IconButton { icon: "cross"; + label: "Reject this face"; clicked => { root.reject(); } } if face.confirmed: Text { @@ -750,6 +757,7 @@ export component IdentityScreen inherits Rectangle { Rectangle { } IconButton { icon: "rotate-cw"; + label: "Recheck coverage"; clicked => { root.check-coverage(); } } } diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index 1a05090..7c18cdd 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -7,6 +7,30 @@ import { Check } from "controls.slint"; // Deliberately separate from AppWindow. It is the first thing a user sees // with no library configured, and the place they return to in order to sign // out or switch account (FR-NC-1, FR-NC-4). +// +// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first +// because it is the one a user cannot get past: an interface they cannot read +// is unusable here in a way it is not in a preferences page they could ignore. +// The mechanism, the extraction command and the reason a build with no +// translation behaves identically are in `build.rs`. +// +// Four kinds of string are deliberately *not* wrapped, and the distinction is +// worth stating because "wrap every literal" is the obvious rule and the wrong +// one: +// +// - **"DarkRoom".** A product name, the same in every language. Translating +// it invites a translator to answer the question, and there is no answer. +// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`, +// the app-password mask. These are shapes rather than sentences; a +// translated hostname would teach the wrong format. +// - **`".."`**, the row that leads out of a folder. A filesystem convention, +// not a word. +// - **`"/"`**, the path separator. +// +// The headings are wrapped and carry their own capitals — `PanelHeading` draws +// what it is given — so a translator supplies "SERVEUR" rather than "Serveur". +// That is a real cost of styling in the string, and it is recorded here rather +// than discovered by the first person to translate the screen. // This screen's buttons are form actions in a single stacked column, not // chrome beside a photograph — they are given the full `touch-target` height @@ -17,11 +41,21 @@ component FormButton inherits Button { } // One folder in the picker. The whole row is the target, not just the text. +// +// A `Rectangle` with a `TouchArea` over it is a button as far as the user is +// concerned and a decorated box as far as the platform is, so the name reaches +// a screen reader — the `Value` below is a `Text` — while the fact that it can +// be entered does not. Saying so is what makes the picker navigable rather +// than merely readable (NFR-A11Y-2). component FolderRow inherits Rectangle { in property label; in property is-parent: false; callback clicked(); + accessible-role: button; + accessible-label: root.label; + accessible-action-default => { root.clicked(); } + height: Theme.touch-target; background: touch.has-hover ? Theme.surface-raised : transparent; border-radius: 3px; @@ -141,8 +175,8 @@ export component LaunchScreen inherits Rectangle { } Label { text: root.signed-in - ? "Connected" - : "Connect a Nextcloud account, or open a folder"; + ? @tr("Connected") + : @tr("Connect a Nextcloud account, or open a folder"); body: true; } } @@ -153,29 +187,36 @@ export component LaunchScreen inherits Rectangle { if !root.signed-in && root.login-url == "": VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "SERVER"; } + PanelHeading { text: @tr("SERVER"); } server-input := Field { + // The `PanelHeading` above each of these entries names + // it on screen and names nothing at all as far as the + // accessibility tree is concerned — Slint associates a + // heading with the control below it only if something + // says so. `Field.label` is that something, so the word + // is written twice on purpose. + label: @tr("Server address"); text: root.server-url; placeholder: "https://cloud.example.com"; accepted(url) => { root.sign-in(url); } } if !root.can-remember: Caption { - text: "No system keyring found — you will need to sign in each time."; + text: @tr("No system keyring found — you will need to sign in each time."); warn: true; wrap: word-wrap; } FormButton { - text: root.busy ? "Connecting…" : "Sign in"; + text: root.busy ? @tr("Connecting…") : @tr("Sign in"); primary: true; enabled: !root.busy && server-input.text != ""; clicked => { root.sign-in(server-input.text); } } Caption { - text: "Sign-in happens in your browser. DarkRoom never sees your password."; + text: @tr("Sign-in happens in your browser. DarkRoom never sees your password."); wrap: word-wrap; } @@ -193,7 +234,7 @@ export component LaunchScreen inherits Rectangle { background: Theme.rule; horizontal-stretch: 1; } - Caption { text: "or"; } + Caption { text: @tr("or"); } Rectangle { height: 1px; background: Theme.rule; @@ -201,14 +242,16 @@ export component LaunchScreen inherits Rectangle { } } - PanelHeading { text: "USERNAME"; } + PanelHeading { text: @tr("USERNAME"); } user-input := Field { + label: @tr("Username"); text: ""; - placeholder: "your Nextcloud username"; + placeholder: @tr("your Nextcloud username"); } - PanelHeading { text: "APP PASSWORD"; } + PanelHeading { text: @tr("APP PASSWORD"); } pass-input := Field { + label: @tr("App password"); text: ""; placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"; secret: true; @@ -218,7 +261,7 @@ export component LaunchScreen inherits Rectangle { } FormButton { - text: root.busy ? "Connecting…" : "Connect directly"; + text: root.busy ? @tr("Connecting…") : @tr("Connect directly"); enabled: !root.busy && server-input.text != "" && user-input.text != "" @@ -233,7 +276,7 @@ export component LaunchScreen inherits Rectangle { } Caption { - text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own."; + text: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own."); wrap: word-wrap; } @@ -251,7 +294,7 @@ export component LaunchScreen inherits Rectangle { background: Theme.rule; horizontal-stretch: 1; } - Caption { text: "or"; } + Caption { text: @tr("or"); } Rectangle { height: 1px; background: Theme.rule; @@ -259,21 +302,22 @@ export component LaunchScreen inherits Rectangle { } } - PanelHeading { text: "FOLDER"; } + PanelHeading { text: @tr("FOLDER"); } folder-input := Field { + label: @tr("Library folder"); text: root.folder-path; placeholder: "/home/you/Pictures"; accepted(path) => { root.use-folder(path); } } FormButton { - text: "Open folder"; + text: @tr("Open folder"); enabled: !root.busy && folder-input.text != ""; clicked => { root.use-folder(folder-input.text); } } Caption { - text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed."; + text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed."); wrap: word-wrap; } } @@ -282,7 +326,7 @@ export component LaunchScreen inherits Rectangle { if root.login-url != "": VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "APPROVE IN YOUR BROWSER"; } + PanelHeading { text: @tr("APPROVE IN YOUR BROWSER"); } Panel { Label { @@ -294,18 +338,18 @@ export component LaunchScreen inherits Rectangle { } FormButton { - text: "Copy link"; + text: @tr("Copy link"); clicked => { root.copy-login-url(); } } - Caption { text: "Waiting for approval…"; } + Caption { text: @tr("Waiting for approval…"); } } // --- folder picker --- if root.signed-in && root.browsing: VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "CHOOSE LIBRARY FOLDER"; } + PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); } // Current location, so it is always clear what // "Use this folder" would select. @@ -326,7 +370,7 @@ export component LaunchScreen inherits Rectangle { border-color: Theme.rule; if root.browse-loading: Caption { - text: "Loading…"; + text: @tr("Loading…"); horizontal-alignment: center; width: 100%; height: 100%; @@ -357,7 +401,7 @@ export component LaunchScreen inherits Rectangle { if !root.browse-loading && root.browse-entries.length == 0 && root.browse-path != "": Caption { - text: "No subfolders here"; + text: @tr("No subfolders here"); horizontal-alignment: center; width: 100%; height: 100%; @@ -367,12 +411,12 @@ export component LaunchScreen inherits Rectangle { HorizontalLayout { spacing: Theme.gap; FormButton { - text: "Cancel"; + text: @tr("Cancel"); horizontal-stretch: 1; clicked => { root.browse-cancel(); } } FormButton { - text: "Use this folder"; + text: @tr("Use this folder"); primary: true; horizontal-stretch: 1; clicked => { root.browse-confirm(); } @@ -384,22 +428,22 @@ export component LaunchScreen inherits Rectangle { if root.signed-in && !root.browsing: VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "ACCOUNT"; } + PanelHeading { text: @tr("ACCOUNT"); } Value { text: root.account; } Rectangle { height: Theme.gap-sm; } - PanelHeading { text: "LIBRARY FOLDER"; } + PanelHeading { text: @tr("LIBRARY FOLDER"); } HorizontalLayout { spacing: Theme.gap; Value { - text: root.library-root == "" ? "(not chosen)" : root.library-root; + text: root.library-root == "" ? @tr("(not chosen)") : root.library-root; placeholder: root.library-root == ""; horizontal-stretch: 1; overflow: elide; } FormButton { - text: "Choose…"; + text: @tr("Choose…"); width: 110px; clicked => { root.choose-folder(); } } @@ -407,7 +451,7 @@ export component LaunchScreen inherits Rectangle { Rectangle { height: Theme.gap-sm; } - PanelHeading { text: "SCAN FOR"; } + PanelHeading { text: @tr("SCAN FOR"); } for label[i] in root.format-labels: Check { label: label; @@ -418,13 +462,13 @@ export component LaunchScreen inherits Rectangle { Rectangle { height: Theme.gap; } FormButton { - text: root.busy ? "Scanning…" : "Open library"; + text: root.busy ? @tr("Scanning…") : @tr("Open library"); primary: true; enabled: !root.busy && root.library-root != ""; clicked => { root.open-library(); } } FormButton { - text: "Sign out"; + text: @tr("Sign out"); clicked => { root.sign-out(); } } } diff --git a/ui/dr-ui/ui/toolrail.slint b/ui/dr-ui/ui/toolrail.slint index da913f8..1753208 100644 --- a/ui/dr-ui/ui/toolrail.slint +++ b/ui/dr-ui/ui/toolrail.slint @@ -121,6 +121,26 @@ export component ToolRail inherits Rectangle { root.picked(entry.on ? ViewMode.photo : tool.mode); } + // **The word below is drawn; this is what makes it a control.** + // Without these the rail reaches a screen reader as four pieces of + // static text — the labels get through, because a `Text` announces + // itself, and nothing says any of them can be pressed. That is the + // develop view's primary navigation reduced to a caption. + // + // `checkable` unconditionally, unlike `Button`'s: a rail entry is + // always a held-or-not state, so an unheld one should say "not + // pressed" rather than pass for an ordinary button. The action + // repeats the click handler rather than calling it, because a + // `TouchArea`'s `clicked` is raised by the pointer and cannot be + // raised from here. + accessible-role: button; + accessible-label: tool.label; + accessible-checkable: true; + accessible-checked: entry.on; + accessible-action-default => { + root.picked(entry.on ? ViewMode.photo : tool.mode); + } + // The lit tile, and the only marker there is. Inset from the // rail's edges so the run of four reads as four things rather than // as one striped column. diff --git a/ui/dr-ui/ui/widgets.slint b/ui/dr-ui/ui/widgets.slint index 18c32a6..e7ef6c2 100644 --- a/ui/dr-ui/ui/widgets.slint +++ b/ui/dr-ui/ui/widgets.slint @@ -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: ;` — + /// which Slint permits because the role below is inherited. in property 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 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 label; in property 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 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 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 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 label; in property 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 fraction: 0; in property 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 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;