Merge: say what each control is, so a screen reader can use one

NFR-A11Y-2 went from five accessible-* declarations in the whole
interface -- all five on the colour mixer's swatch row -- to
seventy-three, on twelve shared components and four screens. The one that
mattered is SliderTrack: the most-used control in the application, until
now unnamed, and now carrying a label, a formatted readout and
increment/decrement/set-value, so it is adjustable rather than merely
readable. Fixing the shared components covered library.slint's
twenty-seven buttons and eleven chips without editing that file at all.

NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr()
calls, about a tenth of the interface's strings. Slint's translate()
returns the original when no bundle is active, so a converted string and
a literal behave identically today and each remaining screen is an
independent commit.

Two accessibility defects are recorded rather than fixed, as TD-6 and
TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at
best) and rule reaches 3:1 on none. Fixing either re-derives the palette
beside a photograph, which wants a screenshot and an opinion.

Verified: fmt, clippy -D warnings, 563 tests including a new integration
test that walks the markup and fails on an unnamed control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:50:38 +02:00
co-authored by Claude Opus 5
9 changed files with 987 additions and 42 deletions
+146
View File
@@ -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*