@qxuken/kui 0.0.0-stage → 0.1.0-alpha.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13313 -0
- package/LICENSE +21 -0
- package/README.md +336 -3
- package/docs/adr/0001-accessibility-as-data.md +414 -0
- package/docs/adr/0002-keyboard-focus-as-data.md +452 -0
- package/docs/adr/0003-modal-surfaces.md +248 -0
- package/docs/adr/0004-multi-window.md +626 -0
- package/docs/adr/0005-the-paint-vocabulary.md +601 -0
- package/docs/adr/0006-c-abi-versioning.md +257 -0
- package/docs/adr/0007-composite-keyboard-patterns.md +350 -0
- package/docs/adr/0008-live-regions-and-announcements.md +379 -0
- package/docs/adr/0009-press-drag-release-into-a-popup.md +318 -0
- package/docs/adr/0010-a-segment-primitive.md +410 -0
- package/docs/adr/0011-keys-bubble-to-the-enclosing-sink.md +258 -0
- package/docs/adr/0012-the-exit-budget.md +411 -0
- package/docs/adr/0013-effects-as-data.md +197 -0
- package/docs/adr/0014-slots-an-extension-fills-in-place.md +411 -0
- package/docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md +559 -0
- package/docs/adr/0016-caching-against-the-last-frame.md +351 -0
- package/docs/adr/0017-selection-as-a-scope.md +696 -0
- package/docs/adr/0018-a-menu-bar-the-app-declares.md +264 -0
- package/docs/adr/0019-a-theme-derived-from-appearance-and-accent.md +382 -0
- package/docs/adr/0020-the-surface-the-schema-does-not-cover.md +351 -0
- package/docs/adr/0021-one-subject-per-example.md +729 -0
- package/docs/adr/0022-focus-regions.md +205 -0
- package/docs/adr/0023-layers-stack-in-the-order-they-open.md +376 -0
- package/docs/adr/0024-the-devtools-are-the-cores.md +425 -0
- package/docs/adr/0025-the-image-is-the-canvas.md +507 -0
- package/docs/adr/0026-hit-testing-by-shape.md +197 -0
- package/docs/adr/0027-tokens-beside-the-theme.md +521 -0
- package/docs/adr/0028-derived-tokens.md +572 -0
- package/docs/adr/0029-a-selection-follows-the-pointer-past-the-edge.md +543 -0
- package/docs/adr/0030-the-standard-menus-the-runner-keeps.md +292 -0
- package/docs/adr/0031-a-drop-zone-is-a-row-and-the-files-are-an-event.md +409 -0
- package/docs/adr/0032-a-devtools-tab-mounts-a-slot.md +480 -0
- package/docs/adr/0033-a-table-is-a-column-whose-cells-align.md +377 -0
- package/docs/adr/0034-stock-controls-over-the-roles.md +192 -0
- package/docs/adr/0035-a-rounded-background-is-joined-by-meeting.md +134 -0
- package/docs/adr/0036-an-event-handler-gets-its-window.md +142 -0
- package/docs/adr/0037-a-family-is-named.md +134 -0
- package/docs/adr/0038-a-scroll-gesture-latches-its-target.md +188 -0
- package/docs/adr/0039-a-tutorial-is-a-sequence.md +139 -0
- package/encoder.js +1282 -0
- package/howto.md +1774 -0
- package/index.d.ts +5162 -0
- package/index.js +1414 -0
- package/jsx-dev-runtime.js +1 -0
- package/jsx-runtime.d.ts +905 -0
- package/jsx-runtime.js +45 -0
- package/native.cjs +118 -0
- package/package.json +51 -6
- package/prebuilds/darwin-arm64/kui_node.node +0 -0
- package/prebuilds/darwin-x64/kui_node.node +0 -0
- package/prebuilds/linux-arm64/kui_node.node +0 -0
- package/prebuilds/linux-x64/kui_node.node +0 -0
- package/prebuilds/win32-x64/kui_node.node +0 -0
- package/props.md +636 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-09-03
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Accessibility as data: semantics in the IR, AccessKit at the edge
|
|
7
|
+
|
|
8
|
+
kui draws its own pixels, so assistive technology (screen readers, switch
|
|
9
|
+
access, voice control, UI automation) sees a window with nothing in it. We
|
|
10
|
+
decided that the per-frame tree carries semantics as two more plain-data
|
|
11
|
+
props, `role` and `label`; that the core derives an **access tree** from
|
|
12
|
+
them every frame, as data; and that only the windowed drivers translate that
|
|
13
|
+
tree into platform accessibility APIs, through AccessKit. Nothing about
|
|
14
|
+
accessibility is a callback, an object, or a runner-only side channel: it
|
|
15
|
+
is declared, derived and consumed the way layout, events, audio and
|
|
16
|
+
warnings already are.
|
|
17
|
+
|
|
18
|
+
## Context
|
|
19
|
+
|
|
20
|
+
- The IR is the contract. Rust builders, Lua tables, JSX and the C API all
|
|
21
|
+
lower into `NodeSpec` + `Value`; whatever is not in the IR does not exist
|
|
22
|
+
for three of the four frontends. An accessibility story that lives only
|
|
23
|
+
in the Rust runner would cover one binding.
|
|
24
|
+
- The tree is immediate-mode and flat, rebuilt every frame. There are no
|
|
25
|
+
widget objects to attach accessibility to, but there is stable identity:
|
|
26
|
+
`Key` is a 64-bit hash of the path from the root, retained state
|
|
27
|
+
(editors, scroll offsets, transitions) already hangs off it, and
|
|
28
|
+
AccessKit node ids are also 64-bit integers. The identity problem that
|
|
29
|
+
makes accessibility hard in immediate-mode toolkits is already solved
|
|
30
|
+
here.
|
|
31
|
+
- The core is headless. Layout events, warnings, window commands and
|
|
32
|
+
audio commands all leave as data a driver drains, and `createApp` tests
|
|
33
|
+
assert on them without a window. Accessibility built the same way is
|
|
34
|
+
testable in CI; accessibility built against platform APIs is testable
|
|
35
|
+
only by hand with a screen reader running.
|
|
36
|
+
- Today the tree knows what things *do* (`on_click`, `on_drag`, `on_key`,
|
|
37
|
+
`NodeContent::Edit`) but not what they *are*. A button is a box with a
|
|
38
|
+
click payload and a text child. That is enough to derive most roles,
|
|
39
|
+
but not names for icon buttons or images, and never a checkbox, slider
|
|
40
|
+
or tab.
|
|
41
|
+
- Keyboard reach is narrower than pointer reach: Tab cycles edit widgets
|
|
42
|
+
only (`Core::focus_adjacent_edit`), key sinks take focus by click or by
|
|
43
|
+
the app declaring `keyFocus`, and a click-only node cannot be reached or
|
|
44
|
+
activated from the keyboard at all.
|
|
45
|
+
- The per-frame text list keeps a cache key and a colour per text node,
|
|
46
|
+
not the string (`TextSystem::add` interns content by hash). Deriving a
|
|
47
|
+
control's name from its text children needs the text back.
|
|
48
|
+
- AccessKit is the ecosystem answer: one Rust data model (`accesskit`,
|
|
49
|
+
pure data, no platform deps) with adapters for Windows UIA, macOS
|
|
50
|
+
NSAccessibility, Unix AT-SPI and Android, and a winit adapter.
|
|
51
|
+
`accesskit_winit` 0.34 pins winit 0.30.5, the same line the workspace
|
|
52
|
+
pins. egui, Bevy and Masonry ship on it.
|
|
53
|
+
|
|
54
|
+
## Decision
|
|
55
|
+
|
|
56
|
+
1. **Two schema rows, on every node, in every binding.** `role` (id 53,
|
|
57
|
+
an enum string) and `label` (id 54, a string) join `PROPS`, land on
|
|
58
|
+
`NodeSpec`, and thread through JSX, Lua, `KuiSpec` and `docs/props.md`
|
|
59
|
+
like `tooltip` does. The role vocabulary is kui's own small enum, not
|
|
60
|
+
AccessKit's 150 roles: the roles kui can honour and a binding can spell
|
|
61
|
+
as a string. Initial set: `none`, `button`, `checkbox`, `radio`,
|
|
62
|
+
`switch`, `slider`, `tab`, `tabList`, `link`, `heading`, `list`,
|
|
63
|
+
`listItem`, `image`, `dialog`, `group`. Roles that need a value
|
|
64
|
+
(`checkbox`, `slider`) read it from companion rows landed with them:
|
|
65
|
+
`checked` (id 55), `valueNow` / `valueMin` / `valueMax` (ids 56–58;
|
|
66
|
+
ARIA's spelling, because Lua's text tables already use `value` for
|
|
67
|
+
their content).
|
|
68
|
+
2. **The core derives the access tree; it is data.** After layout, when
|
|
69
|
+
asked, `Core` produces an `AccessTree`: the semantic nodes of the frame
|
|
70
|
+
in tree order, each with its key, role, name, description, rect (final
|
|
71
|
+
viewport coordinates, like `onLayout`), state (focused, disabled,
|
|
72
|
+
scrollable, edit value and selection) and its supported actions.
|
|
73
|
+
Derivation rules, in precedence order:
|
|
74
|
+
- an explicit `role` wins; `role="none"` removes the node and its
|
|
75
|
+
subtree from the tree (decorative);
|
|
76
|
+
- `NodeContent::Edit` is a text input (multiline or not), with value,
|
|
77
|
+
caret and selection from the `EditStore`;
|
|
78
|
+
- `NodeContent::Text` is static text with its content;
|
|
79
|
+
- `NodeContent::Image` is an image, named by `label`;
|
|
80
|
+
- a node with `on_click` and no role is a button; a `window` role maps
|
|
81
|
+
to the window-button roles and the drag strip to the title bar;
|
|
82
|
+
- a scrolling container is a scroll view; the root is the window,
|
|
83
|
+
named by `title`;
|
|
84
|
+
- everything else is structure and is **elided**: plain boxes do not
|
|
85
|
+
appear, their semantic descendants attach to the nearest semantic
|
|
86
|
+
ancestor. A 10k-rect frame produces a tree of a handful of nodes.
|
|
87
|
+
- a name comes from `label`, else from the node's own text content
|
|
88
|
+
(static text, edit placeholder), else from the concatenated text of
|
|
89
|
+
its descendants (name-from-content, as ARIA does for buttons and
|
|
90
|
+
links); `tooltip` becomes the description.
|
|
91
|
+
3. **Assistive requests come back in as input.** Platform actions
|
|
92
|
+
(activate, focus, set value, set text selection, scroll into view)
|
|
93
|
+
arrive as one new `InputEvent::Access(AccessRequest { key, action })`
|
|
94
|
+
and are resolved inside the core: activating a button emits the same
|
|
95
|
+
`UiEvent` a pointer click would, focusing an edit sets edit focus,
|
|
96
|
+
setting a value writes the editor. A headless test can therefore drive
|
|
97
|
+
an app the way a screen reader would and assert on what it produced.
|
|
98
|
+
4. **The bridge lives in the drivers, behind AccessKit.** The Rust runner
|
|
99
|
+
creates an `accesskit_winit::Adapter` next to the window, forwards
|
|
100
|
+
winit events to it, and on frames where the access tree changed (a
|
|
101
|
+
hash, so idle frames send nothing) converts it to a `TreeUpdate` inside
|
|
102
|
+
`update_if_active`, so an app with no assistive technology attached
|
|
103
|
+
pays nothing per frame. Action requests come through the event loop
|
|
104
|
+
proxy and become `InputEvent::Access`. The Node windowed loop rides on
|
|
105
|
+
the Rust runner; `createApp` exposes the tree as `app.accessTree()`; C
|
|
106
|
+
drains it through the FFI as `repr(C)` rows and integrates whatever
|
|
107
|
+
platform layer it owns.
|
|
108
|
+
5. **Missing semantics are diagnostics.** An image without a label and a
|
|
109
|
+
button with no computable name each raise a `Warning` code
|
|
110
|
+
(`image-without-label`, `control-without-name`) through the existing
|
|
111
|
+
diagnostics path, once per node, so a test suite can fail on an
|
|
112
|
+
unnamed control.
|
|
113
|
+
6. **Two-phase delivery.** Phase 1 lands the schema rows, the `NodeSpec`
|
|
114
|
+
fields, the bindings and the docs, with no behaviour, so the contract
|
|
115
|
+
is settled and apps can start labelling. Phase 2 lands the derivation,
|
|
116
|
+
the input variant, the warnings and the runner bridge. Phase 1 is small
|
|
117
|
+
and is not blocked on Phase 2 design questions.
|
|
118
|
+
|
|
119
|
+
## Considered options
|
|
120
|
+
|
|
121
|
+
- **Leave it to the app.** Rejected: an app cannot reach the platform
|
|
122
|
+
accessibility API of a window kui owns without kui cooperating, and an
|
|
123
|
+
immediate-mode tree gives it nothing to attach semantics to.
|
|
124
|
+
- **A runner-side API** (`launcher.access(|tree| ...)`, the app builds an
|
|
125
|
+
AccessKit tree itself). Rejected: breaks IR-first; Lua, JSX and C apps
|
|
126
|
+
could not participate, and the app would redo the tree walk kui already
|
|
127
|
+
does.
|
|
128
|
+
- **Depend on `accesskit` in the core** and expose `TreeUpdate` directly.
|
|
129
|
+
Tempting (pure data, MSRV 1.85, no duplication of the role enum).
|
|
130
|
+
Rejected for now: the contract would then be another crate's types,
|
|
131
|
+
every binding needs its own string spelling of the role vocabulary
|
|
132
|
+
anyway, and a small kui enum keeps the promise honest about which roles
|
|
133
|
+
kui actually honours. The runner's mapping to `accesskit::Role` is a
|
|
134
|
+
`match`. Revisit if the mapping grows past that.
|
|
135
|
+
- **Nested `a11y = { role, label }` prop** instead of flat rows. Rejected:
|
|
136
|
+
every binding threads flat rows already (`tooltip`, `title`); nested
|
|
137
|
+
objects (`enter`, `keyframes`) exist but cost each binding a decoder.
|
|
138
|
+
Flat matches the schema's grain.
|
|
139
|
+
- **Export every node, let AccessKit filter.** Rejected: the 10k-node
|
|
140
|
+
bench would send 10k generic nodes per frame while an AT is attached,
|
|
141
|
+
and platform APIs expose exactly what they are given. Eliding structure
|
|
142
|
+
is what browsers do with `<div>`.
|
|
143
|
+
- **Platform APIs directly** (NSAccessibility, UIA, AT-SPI). Rejected:
|
|
144
|
+
three implementations of one thing that AccessKit already maintains and
|
|
145
|
+
the winit ecosystem already pairs with.
|
|
146
|
+
- **Bundle keyboard focus traversal for buttons into this ADR.** Deferred:
|
|
147
|
+
it is a real gap (Tab cannot reach a button), but it is a focus-model
|
|
148
|
+
decision (a `focusable` row, a focus ring, arrow keys within lists and
|
|
149
|
+
tab lists), independent of semantics, and AccessKit activation does not
|
|
150
|
+
need it: an AT activates a node by id. It gets its own ADR; the `role`
|
|
151
|
+
vocabulary here is chosen so that ADR can build on it.
|
|
152
|
+
|
|
153
|
+
## Consequences
|
|
154
|
+
|
|
155
|
+
- `NodeSpec` grows a `role`, a `label` and a `description` (what the
|
|
156
|
+
`tooltip` prop sets in the bindings). Labels are `Arc<str>`: cloning a
|
|
157
|
+
spec is a refcount bump, a Rust view can keep one across frames and pay
|
|
158
|
+
nothing, and the bindings allocate once where they already turn a
|
|
159
|
+
string into a `Value`. The C API borrows them (`KuiStr`) while the node
|
|
160
|
+
opens.
|
|
161
|
+
- Text content must be recoverable per frame for name-from-content.
|
|
162
|
+
`TextSystem` (or the tree) keeps the string, or a reference to the
|
|
163
|
+
interned entry, alongside the cache key. A small memory cost on text
|
|
164
|
+
nodes that the existing cache already mostly pays.
|
|
165
|
+
- The 5-pass layout gains no work. Derivation is a post-layout walk that
|
|
166
|
+
runs only when the driver asks for the tree; when no AT is attached the
|
|
167
|
+
runner never asks. The bench gains a "10k rects, access tree requested"
|
|
168
|
+
row so the elision claim is measured, not assumed. Measured on
|
|
169
|
+
2026-09-03 (M-series, debug-free bench build): the 10k-rect frame with
|
|
170
|
+
text and 2,500 buttons costs ~1.38 ms; deriving its access tree (2,500
|
|
171
|
+
buttons named from content, ~3,750 semantic nodes, the other 6,000+
|
|
172
|
+
rects elided) adds ~0.32 ms, paid only on frames a screen reader is
|
|
173
|
+
attached and the tree changed.
|
|
174
|
+
- The `kui-native` runner takes `accesskit_winit` as a default dependency (an
|
|
175
|
+
accessibility feature that is off by default does not get tested). On
|
|
176
|
+
Linux this brings an AT-SPI bus connection at startup; on Windows and
|
|
177
|
+
macOS the adapters are lazy until an AT asks. An `accesskit` cargo
|
|
178
|
+
feature can turn it off for kiosk builds. The adapters must hook the
|
|
179
|
+
window before it is shown, so the runner creates it hidden and shows
|
|
180
|
+
it once the bridge exists; the event loop carries a user event type
|
|
181
|
+
for the adapter's callbacks.
|
|
182
|
+
- `InputEvent` gains a variant, so C and Node drivers that match on it
|
|
183
|
+
exhaustively need a case. The FFI gains `kui_access_tree` (rows) and
|
|
184
|
+
`kui_access_request`.
|
|
185
|
+
- Warnings become an accessibility lint. Existing example apps will
|
|
186
|
+
start warning on unlabelled images and icon buttons; that is the
|
|
187
|
+
intended pressure, and the examples get labels in Phase 2.
|
|
188
|
+
- Semantic changes are not animated and not eased: the access tree
|
|
189
|
+
reports final rects and current values, like `onLayout`.
|
|
190
|
+
- What this does not solve: keyboard reach for non-edit controls (next
|
|
191
|
+
ADR), reduced-motion and high-contrast facts (`Env` rows, small and
|
|
192
|
+
separate), colour contrast and target size (the app's design, though
|
|
193
|
+
a lint on contrast between `bg` and text `color` fits the warnings
|
|
194
|
+
channel later).
|
|
195
|
+
|
|
196
|
+
### Follow-ups
|
|
197
|
+
|
|
198
|
+
- **Live regions and announcements.** A node whose changed text a reader
|
|
199
|
+
should read without being asked (`aria-live`), and a one-off
|
|
200
|
+
announcement with no node behind it at all ("Saved", "3 results"), are
|
|
201
|
+
both still missing. The second is the reason this is not just another
|
|
202
|
+
row: an announcement is an event on a timeline, not a property of a
|
|
203
|
+
tree, and a frame-by-frame IR that re-declares the whole tree has no
|
|
204
|
+
natural place to say "this, once". A polite/assertive row on the node
|
|
205
|
+
plus a per-frame announcement channel alongside the tree is the shape
|
|
206
|
+
to design, and it wants its own ADR rather than a prop.
|
|
207
|
+
- **`required` and `invalid` on form fields.** AccessKit has both (and
|
|
208
|
+
`invalid` distinguishes spelling and grammar); nothing in the IR
|
|
209
|
+
carries them, so an editor cannot say it must be filled in or that
|
|
210
|
+
what it holds is wrong. Two schema rows when the first app needs them.
|
|
211
|
+
- **`level` on a heading.** `Role::Heading` has no depth, so every
|
|
212
|
+
heading is the same size to a reader walking by heading.
|
|
213
|
+
|
|
214
|
+
## Outcome: text (2026-09-03)
|
|
215
|
+
|
|
216
|
+
The tree goes below the node for editors only. An editor's access node
|
|
217
|
+
carries its laid-out lines as runs, built once from cosmic-text's layout
|
|
218
|
+
runs (per-character byte length, x and width from the glyph cluster
|
|
219
|
+
covering the character, word starts from Unicode word boundaries, the
|
|
220
|
+
trailing newline as a zero-width character, runs split past 200
|
|
221
|
+
characters so indices fit the platform's byte), and its caret and
|
|
222
|
+
selection as run positions. `setTextSelection` and `replaceSelectedText`
|
|
223
|
+
resolve in the edit store. Static text keeps its name only: labels are
|
|
224
|
+
read whole, and giving every label runs would double a text-heavy tree
|
|
225
|
+
for no reader benefit.
|
|
226
|
+
|
|
227
|
+
### Verified against the platform (macOS, 2026-09-03)
|
|
228
|
+
|
|
229
|
+
The headless tests pin the data; they cannot tell whether the OS accepts
|
|
230
|
+
it. `scripts/ax-audit.swift` drives a running window through the macOS
|
|
231
|
+
accessibility API — the same one VoiceOver calls — against the
|
|
232
|
+
`examples/rust/accessibility.rs` fixture: 70 checks over roles, names, values,
|
|
233
|
+
the whole text protocol (`AXNumberOfCharacters`, `AXStringForRange`,
|
|
234
|
+
`AXLineForIndex`, `AXRangeForLine`, `AXBoundsForRange`,
|
|
235
|
+
`AXSelectedTextRange` read *and* written, `AXSelectedText`,
|
|
236
|
+
`AXInsertionPointLineNumber`) and the actions (`AXPress`, increment,
|
|
237
|
+
decrement, setting a selection), each confirmed by the change it makes.
|
|
238
|
+
It needs Accessibility permission for the calling terminal, so it stays
|
|
239
|
+
a manual tool rather than CI.
|
|
240
|
+
|
|
241
|
+
Two things it caught that no headless test would have. A drawn titlebar
|
|
242
|
+
was named the window title *and* kept its text child, so the title was
|
|
243
|
+
announced twice; the strip is now unnamed, since the window already
|
|
244
|
+
carries it and the strip has to keep its children for the window
|
|
245
|
+
buttons. And the latency HUD was exposed as static text between the
|
|
246
|
+
controls, so `widgets::latency_graph` is now `role="none"` — a
|
|
247
|
+
development overlay is not content.
|
|
248
|
+
|
|
249
|
+
Still open by ear: AccessKit maps `Role::Heading` to the literal role
|
|
250
|
+
string `"Heading"` rather than a standard `AX` constant, so how
|
|
251
|
+
VoiceOver announces headings is worth listening to; and nothing here
|
|
252
|
+
tests announcement phrasing or focus-following, which only a real screen
|
|
253
|
+
reader session shows.
|
|
254
|
+
|
|
255
|
+
An app that owns its text is the same tree with the markup inverted: the
|
|
256
|
+
`on_key` sink declares an editor role, every drawn line a `role="line"`
|
|
257
|
+
row (its text nodes, in whatever pieces the app draws them, are that
|
|
258
|
+
line), and the lines holding the caret and the selection's other end
|
|
259
|
+
declare `caret` / `selectionAnchor` byte offsets. The runs come from the
|
|
260
|
+
same text buffers the frame drew. Because the core cannot apply a
|
|
261
|
+
selection or an edit to a buffer it does not own, those requests reach
|
|
262
|
+
the app as `access` events in its own terms (line ordinal among the drawn
|
|
263
|
+
lines, byte offset), exactly as a slider nudge does. A virtualised editor
|
|
264
|
+
therefore exposes the lines it draws; `label` is the place to say "line
|
|
265
|
+
120 of 4000". A key sink with no role became a focusable group, since a
|
|
266
|
+
sink that was elided could not be reached at all.
|
|
267
|
+
|
|
268
|
+
## Outcome: selection state (2026-09-04)
|
|
269
|
+
|
|
270
|
+
`checked` covered checkbox / radio / switch and nothing else, so a row of
|
|
271
|
+
tabs read out with no way to tell which was open. Three more facts now
|
|
272
|
+
reach the tree, and the split between them is the point — AccessKit and
|
|
273
|
+
ARIA both keep `toggled` and `selected` apart, and a screen reader says
|
|
274
|
+
different things for each:
|
|
275
|
+
|
|
276
|
+
- **`selected`** (a flag) is the current one of a set. A `tab` reports it
|
|
277
|
+
either way, so its siblings read as "not selected"; a `listItem` or a
|
|
278
|
+
`link` reports it only where the view sets it. That asymmetry is
|
|
279
|
+
deliberate: a tab is one of a set by definition, while most lists are
|
|
280
|
+
not selections and every navigation bar is not one, and AccessKit's own
|
|
281
|
+
documentation calls a stray "not selected" on each of them the common
|
|
282
|
+
annoyance to avoid. The bridge maps it to `set_selected`, not
|
|
283
|
+
`set_toggled`.
|
|
284
|
+
- **`expanded`** names its state (`"collapsed"` / `"expanded"`) rather
|
|
285
|
+
than being a flag. A flag cannot say "collapsed": absent has to keep
|
|
286
|
+
meaning "this node does not expand", and a disclosure that is shut has
|
|
287
|
+
to announce that it is shut or the user never learns it opens. It lands
|
|
288
|
+
wherever it is declared — a twisty, an accordion header and a menu
|
|
289
|
+
button share no role, and gating it on one would have excluded the
|
|
290
|
+
others.
|
|
291
|
+
- **"3 of 7" is derived, not declared.** A `list` already holds its rows
|
|
292
|
+
and a `tabList` its tabs as semantic children, so the core numbers them
|
|
293
|
+
itself: the zero-based ordinal on each item, the count on the container
|
|
294
|
+
(AccessKit's model; ARIA repeats the count on every item instead). A
|
|
295
|
+
view that had to declare both would restate what the tree knows and get
|
|
296
|
+
it wrong the first time a row was filtered out — the same reason names
|
|
297
|
+
come from content rather than from a required `label`.
|
|
298
|
+
|
|
299
|
+
`selected` and `expanded` are one `PROPS` row each, so all four bindings
|
|
300
|
+
carry them; the ordinals are core-side and no binding declares anything.
|
|
301
|
+
The AccessKit bridge gained four lines.
|
|
302
|
+
|
|
303
|
+
### What macOS actually does with them (2026-09-04)
|
|
304
|
+
|
|
305
|
+
`scripts/ax-audit.swift` grew a tab list and a disclosure in the fixture
|
|
306
|
+
and 14 checks over them, and the three facts land in three quite
|
|
307
|
+
different places — which is the reason to ask the OS rather than trust
|
|
308
|
+
the tree:
|
|
309
|
+
|
|
310
|
+
- A `tabList` becomes an `AXTabGroup` exposing `AXTabs`; a `tab` becomes
|
|
311
|
+
an `AXRadioButton` with the `AXTabButton` subrole, and **`selected`
|
|
312
|
+
arrives as its `AXValue`**, not as `AXSelected`. `accesskit_macos`
|
|
313
|
+
treats a tab as checkable and keeps `AXSelected` for item-like nodes
|
|
314
|
+
(a list row), so the audit pins both halves: one tab reads 1 and the
|
|
315
|
+
others 0, and no tab answers `AXSelected`. Pressing a tab moves the
|
|
316
|
+
state to it and off the one that had it — a tab that switched on
|
|
317
|
+
without its sibling switching off is what a reader announces as two
|
|
318
|
+
open tabs, and it is the failure a `checked`-shaped API would have
|
|
319
|
+
given us.
|
|
320
|
+
- **`expanded` reaches macOS nowhere.** `accesskit_macos` 0.27 maps no
|
|
321
|
+
disclosure state at all — there is no `isAccessibilityExpanded` in it —
|
|
322
|
+
so the tree carries the fact, UIA and AT-SPI receive it, and VoiceOver
|
|
323
|
+
hears nothing. The audit asserts the absence so it fails the day
|
|
324
|
+
AccessKit adds it. What a reader can observe today is the panel
|
|
325
|
+
arriving in the tree, which the audit checks instead. Putting the state
|
|
326
|
+
back into the label would paper over this and is exactly what the
|
|
327
|
+
release notes say to delete, so the fixture does not.
|
|
328
|
+
- A `listItem` takes the other spelling entirely: it becomes an `AXGroup`
|
|
329
|
+
(macOS has no row role outside tables) and, because a row is item-like
|
|
330
|
+
there, its `selected` arrives as **`AXSelected`** — the attribute a tab
|
|
331
|
+
deliberately does not answer. So the two roles that share one `selected`
|
|
332
|
+
row in the schema are read through two different platform attributes,
|
|
333
|
+
and the audit pins both directions: a tab carries no `AXSelected`, a row
|
|
334
|
+
carries no `AXValue`.
|
|
335
|
+
- **`set_size` has no macOS attribute behind it.** Neither
|
|
336
|
+
`position_in_set` nor `size_of_set` is mapped there; VoiceOver derives
|
|
337
|
+
"1 of 3" for a tab group from `AXTabs` itself. So the ordinals are for
|
|
338
|
+
UIA and AT-SPI, and on macOS they are carried by the platform's own
|
|
339
|
+
notion of a tab group — which the audit checks holds three tabs and
|
|
340
|
+
only tabs.
|
|
341
|
+
|
|
342
|
+
Two platform gaps, then, neither of them kui's to fix: they belong to
|
|
343
|
+
`accesskit_macos`, and the data is already there when it grows them.
|
|
344
|
+
|
|
345
|
+
### A gap that *is* ours: an unpicked row cannot be selected
|
|
346
|
+
|
|
347
|
+
Adding the list turned up something the headless tests could not have.
|
|
348
|
+
`AXSelected` is settable, and a reader uses it to move a selection — but
|
|
349
|
+
`accesskit_macos` honours it only on a node it already considers
|
|
350
|
+
*selectable*, and `accesskit_consumer` defines that as
|
|
351
|
+
`is_selected().is_some()`: carrying the state at all, true or false.
|
|
352
|
+
|
|
353
|
+
The gate this ADR chose for rows — `Role::ListItem if spec.selected` —
|
|
354
|
+
gives an unpicked row `None`. `Some(false)` is unreachable for a row: a
|
|
355
|
+
view has no way to say "part of this selection, not the current one". So
|
|
356
|
+
setting `AXSelected` on any row but the one that already has it is
|
|
357
|
+
dropped, and a reader can only move the selection with `AXPress`. The
|
|
358
|
+
audit pins exactly that, in both halves, so the day either side changes it
|
|
359
|
+
says so.
|
|
360
|
+
|
|
361
|
+
The trade was made to keep an ordinary bullet list quiet — a reader saying
|
|
362
|
+
"not selected" on each of its rows is the annoyance AccessKit's own
|
|
363
|
+
documentation warns about — and it was made before the platform had a
|
|
364
|
+
vote. The way to keep both is to derive the selection set the way the
|
|
365
|
+
ordinals already are: `set_positions` is walking a `list`'s `listItem`
|
|
366
|
+
children anyway, so if *any* of them declares `selected` the list is a
|
|
367
|
+
selection and all of them get `Some(spec.selected)`, and if none does they
|
|
368
|
+
all stay `None`. A quiet list stays quiet, and a real selection becomes
|
|
369
|
+
settable. That is a change to accepted semantics, so it is written down
|
|
370
|
+
here rather than made in passing.
|
|
371
|
+
|
|
372
|
+
## Outcome: a slider's reading (2026-09-06)
|
|
373
|
+
|
|
374
|
+
The companion rows this ADR landed for `slider` were the three numbers,
|
|
375
|
+
and that turned out to be half a slider. A field report (backlog F8) ran
|
|
376
|
+
an alpha.6 pomodoro under real VoiceOver and heard all three of its
|
|
377
|
+
sliders announce percentages: 25 minutes in [5..60] is "36 percent". The
|
|
378
|
+
numbers are right and the reading is useless, because only the app knows
|
|
379
|
+
that the unit is minutes. ARIA has `aria-valuetext` for exactly this, and
|
|
380
|
+
kui had no row for it.
|
|
381
|
+
|
|
382
|
+
`valueText` (id 82) is that row, and where it *goes* was decided by asking
|
|
383
|
+
the platform rather than by reading the ARIA mapping. The harness is the
|
|
384
|
+
one `docs/adr/0008` used: `examples/rust/accessibility` driven through the
|
|
385
|
+
macOS AX API, with a scratch patch that gave the slider a string value
|
|
386
|
+
beside its `numeric_value`.
|
|
387
|
+
|
|
388
|
+
**`accesskit_macos` 0.27 has one value slot, and the string takes it.**
|
|
389
|
+
`NodeWrapper::value` (`node.rs:344`) tries a toggle state, then a tab's
|
|
390
|
+
selection, then the roles whose label is exposed as their value, then the
|
|
391
|
+
node's string value — and only reaches `numeric_value` when all of those
|
|
392
|
+
are absent. With both set, `AXValue` came back as the string and the
|
|
393
|
+
number was not readable there at all. `AXMinValue` and `AXMaxValue` were
|
|
394
|
+
untouched, since the adapter reads them straight off the node, and
|
|
395
|
+
`AXIncrement` / `AXDecrement` stayed advertised and still worked.
|
|
396
|
+
|
|
397
|
+
Two things follow. First, the semantics: a reading **replaces** the
|
|
398
|
+
number, it does not join it — which is what `aria-valuetext` means, so
|
|
399
|
+
kui's row means the same thing on every platform its backends map to.
|
|
400
|
+
Second, the shape of the IR: `AccessNode` gets no new field. The reading
|
|
401
|
+
lands in `value`, the string slot an editor's text already used, because
|
|
402
|
+
an IR with two string fields feeding one `set_value` could express a frame
|
|
403
|
+
no platform can draw. The bridge was already correct, `KuiAccessNode` did
|
|
404
|
+
not grow — it is an [out-array] struct, so growing it would have bumped
|
|
405
|
+
`KUI_ABI_VERSION` for every C host — and the corpus report's value column
|
|
406
|
+
pinned the row across all four bindings for free.
|
|
407
|
+
|
|
408
|
+
Where it is *not*: a progress-shaped `group`. A group carries none of
|
|
409
|
+
`valueNow` / `valueMin` / `valueMax` — they are set in the `Role::Slider`
|
|
410
|
+
arm and nowhere else — and a role whose numbers are ignored should not
|
|
411
|
+
have a reading that is not. The adapter would put the string in `AXValue`
|
|
412
|
+
on any role, but whether VoiceOver speaks an `AXGroup`'s value is its
|
|
413
|
+
policy, and a harness that reads attributes cannot settle what a reader
|
|
414
|
+
says. A progress role of its own is the honest way to want this.
|