quadraui 0.1.1

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# Backend implementation guide

How to implement [`quadraui::Backend`] for a new platform.
**Two reference backends exist today**:

- **`TuiBackend`** (`src/tui_main/backend.rs`, Phase B.4) — terminals
  via crossterm. **Fully consuming the trait**: every native event
  flows through `wait_events`; click dispatch goes through
  `dispatch_mouse_*`; accelerator registry drives keybindings;
  generic `paint::<B>` paths drive the quickfix panel and other
  cross-backend primitives.
- **`GtkBackend`** (`src/gtk/backend.rs`, Phase B.5) — desktop via
  GTK4. **Plumbing in place; runtime migration tracked at vimcode
  issue #249.** The trait surface, the `Rc<RefCell<VecDeque<UiEvent>>>`
  event queue, the GDK→`UiEvent` translation helpers, the
  accelerator registry, and `is_modal_open()` are all wired up. But
  the running GTK app still routes events / clicks / keys through
  Relm4 `Msg::*` flow — only the quickfix panel actually consumes
  the trait. The B.5b stages in #249 finish the runtime port.

After reading this guide and the existing TUI implementation you
should be able to drop in a fresh `WinBackend` / `MacBackend` /
`AndroidBackend` end-to-end. **Don't model your impl on `GtkBackend`
yet** — its runtime side is mid-migration.

This doc is descriptive: the architectural rationale (why the trait
looks the way it does, what gets normalised vs. left native) lives in
`docs/decisions/BACKEND_TRAIT_PROPOSAL.md`. Read that first if you're writing a
backend from scratch.

## Event-loop shape: poll-driven trait, queue adapter for callback-driven backends

The trait is **poll-driven**: backends implement
[`Backend::wait_events`] / [`Backend::poll_events`] returning
`Vec<UiEvent>`. TUI's crossterm fits this naturally — it has a
synchronous poll API. Callback-driven backends (GTK, Win32 partial,
Cocoa, Android, web) use the **option A event queue** adapter:

```rust
struct GtkBackend {
    events: Rc<RefCell<VecDeque<UiEvent>>>,
    // ...
}
```

The intended pattern is: signal callbacks clone the queue handle
into their captures and `events.borrow_mut().push_back(translated_event)`;
`wait_events` drains the queue. **GtkBackend ships the API but the
producer wiring is B.5b stage 1 work** (issue #249). When that lands,
the GTK runtime fragments behind one queue and the TUI's
synchronous `event_loop()` stays greppable end-to-end — same trait,
same consumer code.

**Forward-compatibility:** every callback-driven backend (Cocoa
delegate methods, Android NDK ALooper, Win32 WindowProc, web JS event
listeners) uses the same queue pattern. macOS may need its
`NSApplication.run()` started on the main thread with delegate
methods pushing to a `Mutex<VecDeque<UiEvent>>`; web pushes from
JS event listeners and drives paint from `requestAnimationFrame`.

**Landed so far:** `MacBackend` (`src/macos/backend.rs`) and
`WinBackend` (`src/win/backend.rs`, quadraui#806) both ship this same
`Rc<RefCell<VecDeque<UiEvent>>>` shape — a single-threaded `Rc<RefCell<>>`
was enough for both in practice, not the `Mutex` sketched above. Both
implement a real (non-`todo!()`) `poll_events`/`wait_events` drain with
`apply_accelerators` run over the batch, matching `GtkBackend`'s shape.
Producer wiring is partial for both: `macos::run` already feeds the
queue for one real source — native menu-bar activations
(`Backend::install_menu_bar`) `push_event` there, drained once per
paint via `poll_events` (`macos::run`'s `paint` closure) — but mouse/
keyboard/window events still go straight from each responder method to
`AppLogic::handle`, bypassing the queue. `win::run`'s `WndProc` doesn't
feed the queue at all yet; every translated event there still dispatches
directly. Finishing either wiring is separate follow-up work; the queue
exists today so a poll-style caller (a headless driver, a future shared
`DriverCore`) has something real to drain instead of a panic.

## What the backend owns

A backend struct holds the per-app state the trait requires:

| Field | Why it lives on the backend |
|---|---|
| Viewport (`width × height × scale`) | Backends measure the active drawing surface in their native units (TUI cells, GTK DIPs, Win-GUI DIPs, Cocoa points). The trait reports it via [`Backend::viewport`] so generic layout code can reach the active size without the trait knowing about pixels vs. cells. |
| `quadraui::ModalStack` | Backends need to consult it on every mouse-down to decide whether the click hit a modal or fell through to the base layer. `quadraui::dispatch::dispatch_mouse_down` does the hit-test and emits the right `UiEvent` shape; the backend just hands it the stack reference. |
| `quadraui::DragState` | Holds at most one in-flight scrollbar drag (`ScrollbarY`/`ScrollbarX` variants). Mouse-down on a scrollbar arms it; `dispatch_mouse_drag` reads it on every mouse-move to emit `ScrollOffsetChanged`; mouse-up clears it. |
| Accelerator registry | A `HashMap<AcceleratorId, Accelerator>` populated via [`Backend::register_accelerator`]. The backend's event-poll path matches incoming key events against the registry and emits `UiEvent::Accelerator(id, mods)` instead of raw `KeyPressed` for matched keystrokes. |
| `dyn PlatformServices` | Clipboard, file dialogs, notifications, URL opener — the things that genuinely differ across platforms. Wraps each backend's native API behind one trait-object surface. |

## The four hooks

Every backend implements these four-ish hook points; the rest of the
trait's `draw_*` methods are mechanical. **Get these right and the rest
falls into place.**

### 1. Frame ownership

The backend owns whatever object its native API uses to mutate the
screen during a paint pass: `&mut ratatui::Frame<'_>` for TUI,
`&cairo::Context` for GTK, `&ID2D1RenderTarget` for Win-GUI,
`&mut CGContext` for Cocoa.

These are typically only valid inside a closure / scope yielded by the
native draw API, so the backend can't hold one across method calls.
The pattern is: stash a type-erased pointer in a `Cell<*mut ()>` set at
scope entry, run the caller's painting code, clear the pointer on exit.
Trait `draw_*` methods reach the frame via a safe accessor that returns
`None` when the pointer is null (i.e., outside the scope).

`TuiBackend`'s implementation:

```rust
pub fn enter_frame_scope<R>(
    &mut self,
    frame: &mut Frame<'_>,
    f: impl FnOnce(&mut Self) -> R,
) -> R {
    let ptr = frame as *mut Frame<'_> as *mut ();
    let prev = self.current_frame_ptr.replace(ptr);
    let result = f(self);
    self.current_frame_ptr.set(prev);
    result
}
```

Each `draw_*` method then:

```rust
fn draw_palette(&mut self, rect: QRect, palette: &Palette) {
    let area = q_rect_to_ratatui(rect);
    let theme = self.current_theme;
    let frame = self
        .current_frame_mut()
        .expect("draw_palette called outside enter_frame_scope");
    quadraui::tui::draw_palette(frame.buffer_mut(), area, palette, &theme, …);
}
```

The `expect` is a programmer-error tripwire — a misuse from app code,
not a runtime input.

### 2. Event poll / wait

The trait surfaces two methods. [`Backend::wait_events`] blocks up to
`timeout` for the next native event and returns one or more `UiEvent`s.
[`Backend::poll_events`] is the non-blocking variant — used in render
loops that poll on every frame (GTK, Win-GUI, Cocoa) where `wait_events`
on a vsync timer is the wrong shape.

The body of either method is:

1. Read the native event(s).
2. Translate to one or more `UiEvent`s. (`TuiBackend` uses
   [`super::events::crossterm_to_uievents`]; GTK will use
   `gtk_event_to_uievents`; etc.)
3. Run the resulting vec through the accelerator matcher so registered
   key bindings surface as `UiEvent::Accelerator` instead of
   `UiEvent::KeyPressed`.
4. Return the vec.

```rust
fn wait_events(&mut self, timeout: Duration) -> Vec<UiEvent> {
    if let Ok(true) = ratatui::crossterm::event::poll(timeout) {
        if let Ok(ev) = ratatui::crossterm::event::read() {
            let mut out = super::events::crossterm_to_uievents(ev);
            self.apply_accelerators(&mut out);
            return out;
        }
    }
    Vec::new()
}
```

`apply_accelerators` is an inherent helper on `TuiBackend`; the same
shape will work on every backend. It iterates registered accelerators
in insertion order, parses each binding into a `(modifiers, key_name)`
pair via `quadraui::parse_key_binding`, and rewrites matching
`UiEvent::KeyPressed` events to `UiEvent::Accelerator(id, mods)`.

### 3. Modal stack + drag dispatch

Mouse events go through three stages:

1. The backend's native event-translation layer turns a click into
   `UiEvent::MouseDown { widget: None, button, position, modifiers }`.
2. The backend hands the modal stack and the mouse coords to
   `quadraui::dispatch::dispatch_mouse_down(&stack, position, button, modifiers)`,
   which returns the right `UiEvent` shape — either filling in
   `widget: Some(modal_id)` if the click landed on a modal, or
   emitting a `Closed` event when the click fell on the backdrop.
3. The same applies for mouse-drag (consults `DragState` to emit
   `ScrollOffsetChanged`) and mouse-up (clears the drag, fills in
   `widget` from the stack).

`TuiBackend` exposes `drag_and_modal_mut()` so the click handler can
borrow both at once without conflicting `&mut self` calls.

### 4. PlatformServices

Wrap the platform's native API behind a small `Clipboard` /
`FileDialog` / `Notification` / `URL opener` set of impls. The trait
just hands them out via [`Backend::services`]; the `&dyn PlatformServices`
return is an erased borrow so backends can mix-and-match (e.g. a TUI
backend on macOS uses the same Cocoa clipboard impl the macOS native
backend uses).

`TuiBackend`'s `TuiPlatformServices` lives in `services.rs` and is the
minimal stub set; real backends will replace each method with a
platform-native call.

## Error reporting: `BackendError` + polled `last_error()` (issue #507/#805)

`begin_frame`, `poll_events`, `wait_events`, every `draw_*`, and the four
CSD `bool` methods (`begin_window_drag`, `toggle_window_maximize`,
`begin_window_resize`, `set_cursor`) stay exactly as designed in D-009
(`docs/decisions/DECISIONS.md`) — infallible, by deliberate choice, not
because nothing has landed yet. What *did* land, minimal and additive,
per D-009's two seams:

- **`BackendError`** (`src/backend.rs`) — a three-variant `Clone`,
  `PartialEq` enum: `Unsupported` (this backend has no way to service a
  request `BackendCaps` otherwise says it supports), `PlatformFailure {
  context: String }` (a native call failed; `context` names it, e.g.
  `"SetClipboardData"`), and `SurfaceLost` (the render surface/device
  was lost mid-frame and must be recreated before the next
  `begin_frame`).
- **`Backend::last_error(&mut self) -> Option<BackendError>`** — a
  polled, clear-on-read method, *not* a `Result`-returning
  `begin_frame`/`end_frame`/`poll_events`/`wait_events`. Default: always
  `None`. `TuiBackend`/`GtkBackend`/`MacBackend` all use the default —
  none of them has a concrete producer yet. `WinBackend` overrides it:
  `end_frame` sets an internal field to `Some(BackendError::SurfaceLost)`
  whenever `ID2D1RenderTarget::EndDraw` fails (`D2DERR_RECREATE_TARGET`
  or anything else — both are treated the same, since either way the
  target must be rebuilt) and drops the render target; `last_error()`
  hands that back to a caller and clears it. `end_frame` also routes the
  same event through `crate::diagnostics::emit` so a host with a sink
  installed sees it even if nothing ever calls `last_error()`.
  Recreation itself isn't part of this method — `WinBackend::ensure_surface`
  (called from `win::run`'s `WM_PAINT`/`WM_SIZE` handlers, and from
  `WinDriver::render` in tests) lazily rebuilds the dropped surface
  before the next frame paints, whether that surface was a real `HWND`
  or (since #805) a headless `WinDriver` target.
- **`Clipboard::write_text_result(&self, text: &str) -> ServiceResult<()>`**
  — the one Seam-2-shaped twin that shipped so far (D-009's other two,
  the three `PlatformServices` dialog `_result` methods, remain
  follow-up scope — see below). Default body calls the existing
  infallible `write_text` and returns `Ok(())`, so no implementor needed
  to change to keep compiling. `WinClipboard` overrides it with the real
  `OpenClipboard`/`GlobalAlloc`/`SetClipboardData` failure it can now
  tell apart from success; `write_text` itself is unchanged and still
  discards it, for callers that don't care.

**What's still design-only, per D-009's follow-up list**: the three
`PlatformServices::show_*_dialog_result` twins, and wiring GTK's ~25
swallowed `cr.fill().ok()`/`cr.stroke().ok()` call sites
(`gtk/activity_bar.rs`, `chart.rs`, `command_center.rs`, `panel.rs`,
`status_bar.rs`) through `last_error()`. Neither exists in `src/` yet;
don't assume either from this section without checking.

See D-009 for the full design rationale (why `begin_frame`/`end_frame`
stay infallible instead of `Result`-returning, why `draw_*`/the CSD
`bool`s were rejected for migration, the `BackendCaps`-vs-`BackendError`
split) — this section only tracks what's actually in the tree.

## `UiEvent` emission matrix (issue #501)

Not every `UiEvent` variant needs to be emitted by every backend to be
conformant, and until this issue almost nothing said which was which —
`docs/LESSONS.md`'s "all runners must fire all `UiEvent` variants the
consumer pattern needs" rule was unenforceable without a definition of
the required set. This table is that definition. `docs/decisions/DECISIONS.md`
D-010 has the full per-variant reasoning and the grep evidence behind
each disposition; this table is the quick-reference a new backend author
should build against.

**Required** — a conformant backend must emit this from real native
input, or the variant is inapplicable to that backend class (TUI has no
OS window: `WindowClose`/`DpiChanged`/`WindowFocused`/`WindowResized`'s
"window" is the terminal's viewport, not an OS window it can close
independently of the process).

| Variant | TUI | GTK | macOS | Win | Note |
|---|---|---|---|---|---|
| `KeyPressed` | ✅ | ✅ | ✅ | ✅ | Canonical text-input path — see `CharTyped` below. |
| `MouseDown` / `MouseUp` / `MouseMoved` | ✅ | ✅ | ✅ | ✅ | |
| `Scroll` | ✅ | ✅ | ✅ | ✅ | |
| `DoubleClick` | ✅ | ✅ | ✅ (`MacBackend::fold_double_click`, #486) | ✅ (`WinBackend::fold_double_click`, #729) | Win: still *not* `WM_*BUTTONDBLCLK` — #729 folds two `MouseDown`s in `win::run::dispatch_event` via the shared `DoubleClickDetector`, the same pattern macOS uses. The `❌` here was stale as of quadraui#742. |
| `WindowResized` | ✅ | ✅ | ✅ (`macos/run.rs:560`, #486) | ✅ | |
| `WindowClose` | N/A (no OS window) | ✅ (this issue — `gtk::run::activate`'s `connect_close_request`) | ✅ (`macos/run.rs`'s `QuadraView`/`NSWindowDelegate::windowShouldClose:` + `QuadraAppDelegate::applicationShouldTerminate:`, issue #951) | ✅ (`WM_CLOSE`, pre-existing) | Veto mechanism: app returns `Reaction::Exit` to allow, anything else vetoes. macOS needs both hooks: `windowShouldClose:` covers the traffic-light button / Cmd-W / native "Close" menu item, but Cmd-Q and the auto-prepended app-menu's Quit item route to `applicationShouldTerminate:` instead and bypass `windowShouldClose:` entirely — `macos::run::window_close_tests` proves the `dispatch_event` funnel itself (shared by both hooks) is correct; the veto *decision* each hook applies is proven separately by the same module's pure `window_should_close_for_reaction`/`terminate_reply_for_reaction` unit tests, since neither hook itself can run outside a live `macos-latest` host. The GTK ✅ here is proven end-to-end for `gtk_terminal` only (`examples/common/terminal_app.rs`'s `WindowClose` arm); the rest of `examples/gtk_*` have no opinion on it yet, so their catch-all's `Reaction::Continue` vetoes their own "×" button until D-010 follow-up 5 lands — `gtk::run::window_close_tests` proves the `dispatch_event` funnel itself is correct regardless. |
| `Accelerator` | ✅ | ✅ | ✅ (`MacBackend::match_keypress`, #486) | ✅ (`WinBackend::match_keypress`, #707) | Win: wired via `win::run::dispatch_event`'s global-accelerator rewrite (#707) — the "no accelerator matching wired yet" note here was stale as of quadraui#742. |
| `ClipboardPaste` | ✅ | ✅ | ✅ (`macos/run.rs:266`, #486) | ✅ (`win::run::dispatch_event`'s Ctrl-V branch, #728) | Win: Ctrl-V reads `CF_UNICODETEXT` through `WinBackend::services` and delivers `ClipboardPaste` instead of the raw key event. The `❌` here was stale as of quadraui#742. |
| `TextCopied` | ✅ | ✅ | ❌ | ❌ | Neither macOS nor Win wire a Ctrl-C-with-selection → `TextCopied` path yet. Out of this issue's scope. |
| `OpenRequested` | N/A (not wired by issue #957 — `src/tui/run.rs` wasn't in that issue's file scope) | ✅ (`ApplicationFlags::HANDLES_OPEN` + `Application::connect_open`, argv-at-launch and single-instance forwarding both covered) | ✅ launch/runtime delegate methods (`application:openURLs:`/`application:openFiles:`); ❌ the `NSAppleEventManager`/`kAEGetURL` early-URL race (needs `objc2-core-services`, not added — no macOS host to verify against in the #957 pass) | ✅ (argv classified at startup unconditionally; `RunConfig::single_instance` additionally arms `CreateMutexW` + `WM_COPYDATA` forwarding) | Issue #957. Registering as the OS handler for a URL scheme/file extension is a packaging concern this crate does not do for you — see the variant's own doc. |

Verified directly against `quadraui/src/{tui,gtk,macos,win}` on this
issue's branch, superseding `SMELL_AUDIT_2026-07.md`'s PORT-04 table
(dated 2026-07-25) where they disagree — #486 landed most of the macOS
column since that audit ran; re-verify before trusting either table
against a much later `develop`.

**Optional** (declare the gap via a doc comment / `BackendCaps` once one
exists for it — do not silently no-op and do not fake it):

| Variant | Status | Note |
|---|---|---|
| `CharTyped` | Emitted by **no backend today** | Reserved exclusively for IME-committed composed text (epic #481, IME story #502) — **not** a second way to report a plain keystroke. `KeyPressed{Key::Char}` is the always-on text-input event every backend already emits; two in-tree consumers (`compose::sidebar_system`, `compose::tree_controller`) will double-insert a character if a future backend ever emits both for the same keystroke. See D-010. |
| `MouseEntered` / `MouseLeft` | Emitted by no backend | Zero consumers anywhere today; kept for future hover-driven features (tooltip auto-show). See D-010. |
| `FilesDropped` | GTK: ✅ (`gtk::DropTarget`). macOS: ✅ (`NSDraggingDestination` on `QuadraView`). Win: ✅ (`WM_DROPFILES`). TUI: N/A (no native drag-and-drop surface). | Wired by issue #834. Still zero in-tree consumers — no primitive reacts to the event yet, only the emission side is done. |
| `DpiChanged` | Win: ✅ (`WM_DPICHANGED`, pre-existing). GTK: ✅ (`notify::scale-factor` on the `DrawingArea`). macOS: ✅ (`NSWindowDidChangeBackingPropertiesNotification`). TUI: N/A (`scale` is always `1.0`). | Wired by issue #834 (closing D-010 follow-up 2, and macOS's gap). `crate::runtime::preprocess_event` forces a redraw on this variant regardless of the app's own `Reaction`, so all three GUI backends re-measure on receipt without each runner needing its own eager-invalidate logic. |
| Native menu events (`MenuActivated`, `ContextMenuItemActivated`, `ContextMenuDismissed`) | Backend-dependent, out of this table's scope | Only meaningful on a backend with `BackendCaps::native_menu`. |
| `TrayClicked` | macOS: ✅ (`NSStatusItem` button click, no menu attached — `macos::tray`). Win: ✅ (`Shell_NotifyIconW` callback, no menu attached — `win::tray`). GTK: N/A (no GTK4 status-icon API — see `gtk::services`'s module doc). TUI: N/A permanently (`Backend::tray` doc). | Issue #953. Only meaningful on a backend with `BackendCaps::tray`; does not fire on macOS/Win while `TrayService::set_menu` has a non-empty menu attached — see `macos::tray`/`win::tray`'s module docs for that trade-off and why `ContextMenuItemActivated`/`ContextMenuDismissed` take over instead. |

`❓` = not verified as part of this pass (out of scope for issue #501;
don't infer either way from this table). `❌` on a windowed backend for
a **required** row is a tracked gap, not a design choice — every such
cell above is either wired in this PR or has a named follow-up issue in
D-010.

### Conformance tier C2

`quadraui/tests/conformance/c2.rs` (quadraui#501; Win column quadraui#742)
is the executable half of this table: per-backend "native-injection
recipes" that call each backend's real native→`UiEvent` translation
function (not the Tier-1 scenario suite's higher-level `AppLogic`
replay) with a synthetic native input and assert the resulting
`UiEvent` has the expected shape. It covers the mouse/key/scroll/resize
core on TUI+GTK+Win — `win::events`'s translators are plain
host-independent functions (no WinAPI call in sight), so `win_case`/the
`win` column run on the `ubuntu-latest --features win` leg with no live
`HWND` required, unlike Tier C0/C1's `WinFactory` registration, which
stays `target_os = "windows"`-gated.

Every **required** row of the table above appears in the printed matrix.
Cells fall into three kinds, and the distinction matters when reading it:

- `pass` — a real native-injection assertion ran.
- `n/a` — the variant is *inapplicable* to that backend class
  (`window_close` on TUI: no OS window, D-010).
- `pass*` — declared but *unmeasurable at this tier*, with a footnote
  naming the production wiring and where its real coverage lives. Two
  groups: `window_close` on GTK/Win (needs a live window/`wndproc`), and
  `c2.rs`'s `DISPATCH_ROWS` — `double_click`, `accelerator`,
  `clipboard_paste` — which no backend produces from a native→`UiEvent`
  translator at all. They're synthesised one layer up in each backend's
  `pub(crate)` `dispatch_event` (fold two `MouseDown`s; rewrite a
  `KeyPressed` against the accelerator registry; swallow Ctrl-V and read
  the system clipboard), so they need a live backend + `AppLogic`, not a
  translation-function call. TUI's `clipboard_paste` is the one
  exception and is a real `pass`: crossterm surfaces bracketed paste as a
  native `Event::Paste`, which `crossterm_to_uievents` translates
  directly.

`TextCopied` is not in the required set and has no row. Promoting any
`pass*` to a real assertion is D-010 follow-up work, and needs a
dispatch-level fixture — it is *not* blocked on production wiring, which
has landed on every backend the table marks ✅. See `docs/TESTING.md`'s
"Conformance tiers" section for how C2 relates to C0/C1/C3/C4.

## Glossary

- **Accelerator**: a stable `AcceleratorId` + `KeyBinding` registered
  with the backend. The backend matches incoming key events against
  the registry; the app dispatches on `id` instead of raw key strings.
- **DragTarget**: what's being dragged. Today: `ScrollbarY` (vertical
  scroll-thumb drag) and `ScrollbarX` (horizontal). Both carry the
  track geometry, viewport size, total content size, and a
  `grab_offset` (cursor's offset from the thumb start at click-down,
  so the thumb doesn't snap out from under the cursor on grab).
- **ModalStack**: the LIFO stack of currently-open modals (palette,
  dialog, tooltip, completion popup, …). `dispatch_mouse_down` walks
  it in reverse order on every click so events landing inside an
  open modal can't fall through to widgets behind it.
- **WidgetId**: a stable string identifier the app uses to route
  primitive-specific events (`tui:terminal_scrollback`,
  `tui:editor:3:vsb`, `picker`, `explorer:sb`). Convention: bin /
  primitive-specific id namespaces, colon-separated.

## Worked example: where `tui:editor:3:vsb` flows

When a user clicks the vertical scrollbar of editor window id 3 in
TuiBackend:

1. Crossterm emits `MouseEvent { kind: Down(Left), col, row }`.
2. `events::crossterm_mouse_to_uievent` translates to
   `UiEvent::MouseDown { widget: None, button: Left, position, modifiers }`.
3. The event loop pulls it from `backend.wait_events(timeout)` and feeds
   the synthesised crossterm event back to the legacy mouse handler
   via `events::uievent_to_crossterm`.
4. `mouse.rs::handle_mouse` finds the click is on a window's rightmost
   column (the v-scrollbar), arms the backend's drag state with
   `DragTarget::ScrollbarY { widget: WidgetId::new("tui:editor:3:vsb"), … }`,
   and immediately runs `dispatch_mouse_drag` against the click
   position so the click-time offset uses the same thumb-aware math
   subsequent drags will use (no thumb jump).
5. The dispatch's `ScrollOffsetChanged` event is matched in
   `apply_scrollbar_drag`'s `tui:editor:N:<axis>` arm; that calls
   `engine.set_scroll_top_for_window(WindowId(3), new_offset)`.
6. On every subsequent mouse-drag while the button stays down, the
   same `apply_scrollbar_drag` call fires and updates the scroll.
7. On mouse-up, the legacy handler calls `drag_state.end()` and the
   drag is cleared.

The same flow works for any scrollbar in any backend; the only piece a
new backend implements is steps 1–2 (its native event → `UiEvent`
translation). Steps 3–7 are app code that's already shared.