quadraui 0.1.0

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
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
# Primitive Authoring Rules

Read this when adding or changing a primitive.

1. **Declarative description first.** Add the struct + `Layout` +
   `hit_test` to `quadraui/src/primitives/<name>.rs`. Tests for layout
   correctness go at the bottom of that file as inline tests; they must
   not pull in any backend feature.
2. **One source of truth for layout.** Both rasterisers AND tests must
   call the primitive's `layout(...)` — never re-derive bounds inline.
   Where a backend needs additional metrics (like TUI's `cell_quantum`),
   they go on `LayoutMetrics` so the primitive can apply them.
3. **Both backends, same shape.** Add the rasteriser to
   `quadraui/src/tui/<name>.rs` AND `quadraui/src/gtk/<name>.rs`. Even
   if the GTK consumer hasn't been written yet, add a stub rasteriser
   so the primitive's contract is honoured on every backend it claims
   to support.
4. **Paint/click round-trip harness.** Every primitive that has clicks
   must have a paint/click round-trip test in its TUI rasteriser
   (`quadraui/src/tui/<name>.rs::tests`). The pattern: paint into a
   `ratatui::Buffer`, find painted glyphs, hit_test those exact
   coordinates, assert paint and click identify the same region.
   Examples to copy: `tui::multi_section_view::tests`,
   `tui::tree::tests`. The harness must catch the bug class it's
   designed for — verify by temporarily mutating the rasteriser to
   break the contract; the harness should fail. Restore + commit only
   when both sides round-trip cleanly.
5. **Public layout helper.** Each backend exposes a public layout
   helper (e.g. `tui_msv_layout`, `tui_tree_layout`) so consumers can
   drive hit-testing without re-deriving metrics. The rasteriser uses
   this same helper internally — paint and consumer-driven hit_test
   consume one source of truth.
6. **Test state-derived paint geometry.** Whenever a painter computes
   a position from primitive state — scrollbar thumb position from
   `(scroll_offset, content_rows, viewport_rows)`, focus ring from
   `has_focus`, badge offset from text length, animation phase from
   a tick counter — write a test that paints at a known state and
   asserts the painted indicator lands where the formula predicts.
   The paint/click harness covers coordinate-system drift; this rule
   covers paint-formula bugs (e.g. "thumb hardcoded at top of gutter
   instead of computed from scroll_offset"). Verify empirically by
   mutating the formula and observing the test fail. See *Coverage
   taxonomy* under *Testing* for the full bug-class breakdown.
7. **Add the primitive to the `Backend` trait.** Every primitive MUST
   have a `Backend::draw_<name>` (and where applicable, `Backend::<name>_layout`)
   method. Per `docs/decisions/BACKEND_TRAIT_PROPOSAL.md` section 4, adding a primitive is
   an intentional breaking change to the trait — every backend
   implementer sees the new method as a compile error and fills in
   their rasteriser. **No primitive ships with TUI/GTK free-function
   rasterisers but no trait coverage.** That's how the
   *Cross-backend portability commitment* stays load-bearing —
   if a primitive isn't on the trait, downstream consumer code has to
   pick a backend explicitly. **If the new primitive is composed into
   top-level screens (most are), add its `Surface`/`FrameZone` variant
   in the same PR** — see "One primitive, one canonical paint path"
   below (issue #456).

   **Which `<name>_layout` shape to pick (issue #506):**

   | Primitive class | Convention | Examples |
   |---|---|---|
   | **Content-in-rect** — backend paints inline inside a caller-supplied `rect` and can compute the same geometry with no live paint context | **Paired**: `draw_<name>(rect, ..)` + `<name>_layout(&self, rect, ..)`, same signature shape, same resolver underneath. | `data_table_layout`, `tree_layout`, `form_layout`, `list_layout`, `board_layout`, `terminal_layout`, `editor_layout` |
   | **Overlay-with-caller-anchor** — host computes anchor/viewport/measure itself and calls the *primitive's own* `.layout(...)` directly; the backend only paints at the resolved bounds | **Draw-takes-layout**: `draw_<name>(&self, thing, layout)`. No `Backend::<name>_layout` — there's nothing backend-specific left to compute. | `Tooltip`, `ContextMenu`, `Completions`, `RichTextPopup`, `Dialog`, `Float` |
   | **Interactive chrome** — freestanding widget at its own screen rect | **Paired**, same as content-in-rect. (Draw-returns-layout-only, with no no-paint twin, is the same convention minus the accessor a host needs to hit-test without a frame in progress — add the twin rather than leaving a primitive as the odd one out.) | `chart_layout`, `toolbar_layout`, `sidebar_panel_layout`, `board_layout`, `diff_view_layout` |

   A `<name>_layout` gets a **default trait body** only when the
   geometry is a *provably uniform* pure function of
   `Backend::char_width()` / `Backend::line_height()` (and primitive
   state) — i.e. every backend's own `draw_<name>` already resolves the
   same two values into the same formula (`terminal_layout`,
   `editor_layout`, `diff_view_layout`). "The same two values" can
   include a small backend-shaped accessor of the same shape as
   `char_width()`/`line_height()` when the *formula* is still uniform
   but a scalar it depends on genuinely isn't — `terminal_layout` also
   calls `Backend::terminal_scrollbar_default_width()` to reserve the
   scrollbar gutter's default width before dividing by `char_width()`,
   because every backend's real `draw_terminal` reserves that gutter
   with a backend-specific fallback (1 cell on TUI, 8px on GTK/macOS/Win)
   before iterating cells — skip that reservation and the default body's
   `grid_cols` silently over-reports by the gutter's width whenever a
   caller omits an explicit scrollbar width (issue #506 review fix; see
   `docs/decisions/DECISIONS.md` D-007's "`terminal_layout`'s scrollbar gap" note). If a backend supplies its own
   sizing constants that aren't derivable from those two accessors
   (`BoardMeasure`'s per-backend column/card pixel sizes, `ListView`'s
   scrollbar reservation), there is no default — every backend
   implements it explicitly, same as `draw_<name>` itself. A default
   body still needs a from-scratch, per-backend-verified justification
   in `tests/conformance/caps.rs`'s `ACCEPTED_DEFAULTS` (quadraui#492) —
   it is exempt from that check's "silently defaulted" failure only
   because the reason is written down, not because a default exists.
   See `quadraui/docs/decisions/DECISIONS.md` D-007 for the full audit, the
   off-trait-fn resolutions, and why `Palette` did **not** get a
   `palette_layout` method despite fitting the content-in-rect row
   above (a latent paint/layout drift, not a design gap — D-007's
   "Palette: deferred, not missed").
8. **Public API changes are versioned by deprecation, not by
   announcement.** See rule 8 in full below — it is the one rule whose
   failure mode lands in *other repos*.
9. **A new `PlatformServices` capability states its TUI story and ships
   a driver test proving it.** See rule 9 in full below — it is the rule
   the Electron-parity push (`GOAL.md`) will lean on hardest, since that
   is where most of the new capabilities land.

## Rule 8 — public-API lifecycle

`quadraui` is publishable (`publish = false` was dropped in `quadraui#797`,
the first tagged release is `v0.1.0`, not yet cut as of this writing — see
that issue). Nothing pins a published *version* today regardless — both
consumers pin a git *revision* instead, so neither floats on `develop`'s
tip. `coord-tui` — `JDonaghy/coord-tui`, a
standalone repo since `claude-coordinator#2899` (2026-08-29) — pinned
first, via `claude-coordinator#1973`. `vimcode` followed via
`vimcode#691`, replacing its old path dependency with a git+rev pin the
same shape as coord-tui's. A breaking change on `develop` reaches
either consumer only when someone bumps its pin, in a reviewable diff
that's the natural place to carry the migration — the urgency framing
of "this breaks two repos the moment it merges" no longer applies. The
deprecation-shim discipline below still matters, though: it's what
keeps that eventual pin bump cheap. And pinning trades floating for a
different hazard, **pin lag** — a pin can sit stale indefinitely with
nothing forcing it to move; vimcode's was 44 commits behind quadraui's
`develop` tip as of 2026-09-04 (`quadraui#782`). quadraui's CI does
carry a `downstream` compile-truth job (#528) that overrides both pins
to check every PR's quadraui against both consumers before merge, but
its coord-tui leg is currently skipped for lack of a read token on that
repo, so coord-tui breakage will *not* show up here. See the
*Downstream consumers* section of `CLAUDE.md` for the declarations, the
token, and the #476 post-mortem.

**What is breaking here vs. what isn't.** Consumers implement `ShellApp`
and `AppLogic` — never `Backend`. So:

| Change | Breaks consumers? |
|---|---|
| New `Backend` method, no default (rule 7's intentional compile error) | **No** — in-tree backends only. Keep doing this. |
| New required method on `ShellApp` / `AppLogic` | **Yes** — give it a default impl. |
| New field on a public struct a consumer constructs | **Yes** unless the struct is `#[non_exhaustive]` + built via `Default`/builder. |
| New variant on a public enum a consumer matches | **Yes** unless the enum is `#[non_exhaustive]`. |
| Rename / removal of any `pub` item with a consumer hit | **Yes.** |

**`Backend` is `pub` but sealed — this is enforced, not just written down
(quadraui#800).** Before this, `Backend` was a `pub` trait with 100+
required methods and this table's "in-tree backends only" claim was
prose an outsider had no way to verify short of trying it and getting
burned by the next release. `Backend: sealed::Sealed` in `src/backend.rs`
now makes that literally true: `sealed` is a `pub(crate)` module, so its
`Sealed` trait can't be named from outside this crate, and `Backend`
cannot be implemented downstream — see the "Sealed" section of
`Backend`'s own rustdoc (including a `compile_fail` doctest proving an
out-of-crate `impl Backend` is rejected) and `BACKEND.md`'s framing of
"contribute a fifth in-tree backend" as the supported path for a target
none of the four cover. Every one of the four in-tree backends (plus the
in-tree `RecordingBackend`/`MockBackend` test doubles) implements
`sealed::Sealed` alongside `Backend` — do the same for any new backend.

**Measure before you cut.** Both consumers sit beside this checkout:

```bash
grep -rn '<symbol>' ~/src/coord-tui/src ~/src/vimcode/src
```

Zero hits in both plus no in-tree use ⇒ remove it outright; that is what
a dead-API pass is for. **Paste the grep output into the PR body** — the
claim is cheap to assert and cheap to verify, so verify it. Note that
`vimcode` is easy to forget: #476's commit message named coord-tui as
"the only known" consumer and broke vimcode too.

**Deprecation shims, in ascending cost.** All of these keep a consumer
compiling, with a warning that names the fix:

```rust
// Rename a type — the whole cost of a non-breaking rename.
pub struct CardBadge { /* … */ }
#[deprecated(since = "0.0.2", note = "renamed to `CardBadge`")]
pub type Stage = CardBadge;

// Rename an enum variant — keep the old spelling as an associated const.
// (`allow` is required: the quality gate runs clippy with `-D warnings`,
// and a PascalCase const trips `non_upper_case_globals`.)
impl BadgeStatus {
    #[allow(non_upper_case_globals)]
    #[deprecated(since = "0.0.2", note = "renamed to `BadgeStatus::Warning`")]
    pub const RequestChanges: BadgeStatus = BadgeStatus::Warning;
}

// Change a shape — forward from the old one.
#[deprecated(since = "0.0.2", note = "use `CardBadge { label, status }`")]
impl From<(Stage, BadgeStatus)> for CardBadge { /* … */ }

// Drop a field consumers still set — accept and ignore, don't delete.
#[deprecated(since = "0.0.2", note = "no backend ever painted this; drop the call")]
pub fn with_machine(self, _machine: impl Into<String>) -> Self { self }
```

**The two-PR protocol.** A breaking change is never one PR:

- **PR 1** — add the new shape, keep the old one compiling behind
  `#[deprecated]`, update in-tree examples and tests to the new shape.
  Open the migration issue in each affected consumer repo and link it
  here. Consumers are now on a warning, not an error, and can migrate on
  their own schedule.
- **PR 2** — delete the shims, once those migrations have merged.
  Reference them by number.

**Both PRs get a `CHANGELOG.md` entry** (repo root — see that file's own
header for the format): PR 1 adds a line under `### Deprecated` naming the
old item, its replacement, and the PR-2 tracking issue; PR 2 moves that same
line to `### Removed`. This is the two-PR protocol made visible outside a
diff — a reader of `CHANGELOG.md` alone should be able to follow a
deprecation from introduction to removal without pulling up either PR.

**One breaking change per PR.** #476 removed a type, renamed a variant,
deleted two struct fields and gutted a keymap in a single commit, so the
consumer's migration was all-or-nothing with no partially-compiling
state to bisect from. Split them.

**Every PR touching a `pub` item carries a `## Downstream impact`
section** naming each consumer file that must move, or stating "no
consumer hits" with the grep. A public-API PR without one should be sent
back at review.

## Rule 9 — `PlatformServices` honesty (issue #949)

`Backend`'s defaulted (rule-7-adjacent) methods are policed mechanically
by `tests/conformance/caps.rs`'s `CAP_CONTRACTS` / `backends_declare_only_what_they_override`:
declare a `BackendCaps` field and the matching override must exist, in
both directions. `PlatformServices` had no equivalent — every one of its
methods was on the honour system — and that gap produced a real,
shipped bug: `TuiPlatformServices::open_url` was
`fn open_url(&self, _url: &str) {}`, returning `()` with **no**
`BackendCaps` entry, so an app calling it on TUI had no way to learn it
did nothing. `GtkPlatformServices::send_notification` is also `{}`, but
`BackendCaps::notifications` is honestly `false` there — same empty
body, but a caller can check first. The difference between those two is
the entire point of this rule.

**The mechanism.** `tests/conformance/caps.rs` now also carries
`PLATFORM_SERVICE_CONTRACTS` — one entry per `PlatformServices` method,
parsed from `src/backend.rs` the same source-parsing way
`defaulted_trait_methods()` reads `Backend`. Every method needs a
`ServiceHonesty`:

| Honesty | When to use it | Example |
|---|---|---|
| `CapBacked(cap)` | The method's return type is already ambiguous (`Option<T>` — `None` on cancel vs. `None` on no-facility-at-all) and a `BackendCaps` field disambiguates it. | `show_message_dialog` / `native_dialogs` |
| `ResultTwin(name)` | The method returns a bare `()` — there is nothing on the method itself that could report absence. Add a `<name>_result` twin returning `ServiceResult<...>` (D-009 seam 2, the same shape as `Clipboard::write_text`/`write_text_result`) and keep the old `()` method for source compatibility. | `open_url` / `open_url_result` |
| `Delegates(trait)` | The method vends another trait object whose own methods carry their own honesty story. | `clipboard()` → `Clipboard` |
| `AlwaysReal` | The method can never silently do nothing — a plain identifier, or a method whose return type is already fully typed and unambiguous with no cap needed. | `platform_name`, a `_result` twin itself |

Two tests make this a gate, not a suggestion:

- `platform_service_contracts_cover_every_method` — a new
  `PlatformServices` method with no `PLATFORM_SERVICE_CONTRACTS` entry
  fails the build. No silent exemption by omission.
- `every_bare_unit_service_method_has_a_typed_honesty_story` — a method
  that returns bare `()` **must** be `CapBacked` or `ResultTwin`.
  Declaring `AlwaysReal` or `Delegates` for a `()`-returning method is
  exactly the `open_url` bug this rule exists to catch, reintroduced.

**What this means when you add a capability.** Every new
`PlatformServices` method (or new capability *family* — see
`CLAUDE.md`'s Electron-parity scope) needs, in the same PR:

1. **A `PLATFORM_SERVICE_CONTRACTS` entry** stating its honesty story —
   pick `ResultTwin` for a fire-and-forget action, `CapBacked` for
   something whose success/failure is otherwise ambiguous, or (per the
   issue's guidance for a whole new capability *family* — tray, window
   control, etc.) prefer vending `Option<&mut dyn SubService>` so
   absence is structural and there is no cap flag to keep in sync at
   all.
2. **An explicit TUI story.** TUI has no browser, no OS notification
   centre, no system tray — most new platform capabilities degrade to
   "unsupported" there, and that degrade must be a documented, typed
   answer (`Err(BackendError::Unsupported)` from a `_result` method, or
   `None` gated by a `false` `BackendCaps` field), never a silent no-op.
3. **A `tui_*` driver test asserting the degrade** — build the example's
   `AppLogic`, drive it through `TuiDriver`, and assert the typed
   "unsupported" answer actually surfaces to the app. `examples/tui_file_dialog.rs`
   + its driver tests in `tests/tui_example_driver.rs`
   (`file_dialog_demo_open_reports_unsupported_on_tui` and siblings) are
   the template: the demo exercises the exact contract ("always reports
   unsupported"), and the driver test proves it end to end rather than
   trusting the doc comment.

## Coordinate frames for `*_layout` methods (issue #505)

Every `Backend::<name>_layout` method — and its `draw_<name>` twin, where
one returns hit-region data — returns its `hit_regions` / `bounds` in
one of exactly two frames, and its doc comment states which one:

- **LOCAL** — relative to the `rect` passed in; `(0, 0)` is `rect`'s
  top-left corner. The caller subtracts `rect.x` / `rect.y` from an
  absolute click coordinate before calling `hit_test`.
- **ABSOLUTE** — shifted by `rect.x` / `rect.y` (target-surface
  coordinates). The caller compares raw click coordinates against the
  returned bounds with no further adjustment.

**Which one a given primitive uses is a design choice made once, not a
per-backend one.** In practice: primitives a parent composer paints
*inline* and already tracks the origin for (`tree_layout`,
`form_layout`, `data_table_layout`, `text_display_layout`,
`status_bar_layout`, `activity_bar_layout`) are LOCAL. Primitives
painted as a freestanding widget at their own screen rect, where the
caller has no other reason to track that rect, are ABSOLUTE
(`tab_bar_layout`, `menu_bar_layout`, `split_layout`,
`split_tree_layout`, `panel_layout`, `toast_stack_layout`,
`pipeline_view_layout`, `progress_layout`, `spinner_layout`,
`command_center_layout`, `toolbar_layout`, `sidebar_panel_layout`,
`chart_layout`, `minimap_layout`, `msv_layout`, `text_input_layout`,
`command_line_layout`).
See `quadraui/docs/decisions/DECISIONS.md` D-005 for the full audit and why the
split isn't collapsed to one frame everywhere.

**The rule this enforces is not "always LOCAL" — `docs/LESSONS.md`'s
"Layout helpers must return coords in the same frame across backends"
predates this audit and reads that way; treat this section as its
successor.** The rule is: (1) the method's doc comment states its frame
explicitly — **LOCAL** or **ABSOLUTE**, in those words, so grep finds
every one — and (2) every backend implementation actually agrees with
that statement and with its TUI/GTK/macOS siblings. A `*_layout` doc
comment with neither word is an #505 regression.

**Every `*_layout` method needs a non-zero-origin regression test** on
every backend it ships on (TUI + GTK always; macOS per #483
availability) — `rect.x != 0` or `rect.y != 0`. `area = (0, 0)` is
exactly the case where a LOCAL/ABSOLUTE mixup is invisible (adding or
skipping `rect.x` is a no-op when `rect.x == 0`), which is why the
historical `mac_tree_layout` bug (`docs/LESSONS.md`) shipped past
tests that all used the origin.

### Converging on one convention, ABSOLUTE-only (issue #816)

The LOCAL/ABSOLUTE split above is the audited *current state*, not the
target. quadraui#816 is converging every primitive onto: one `layout()`
argument shape (`fn layout(&self, bounds: Rect, m: &Self::Measure) ->
Self::Layout`, replacing the four in use today), one `hit_test()` shape
(`fn hit_test(&self, p: Point) -> Self::Hit`, with a `Miss`/empty
variant on every `Hit` — seven primitives have none today), and
**ABSOLUTE coordinates everywhere** — once every primitive agrees, this
table has nothing left to record and gets deleted.

`quadraui/src/layout.rs` (new) is the shared infrastructure that
per-primitive convergence PRs adopt one at a time:

- `Anchor` + `Side`/`ResolvedSide` — the "preferred side, flip on
  overflow, pin to viewport edge as a last resort" resolution that
  `Tooltip`, `Completions`, `ContextMenu`, and `RichTextPopup` each
  reimplement today behind their own placement enum.
- `visible_range_walk` + `VisibleItem` — the "visible items from a
  scroll offset, clipping the last one to the remaining viewport"
  walk that all 19 `Visible*{idx, bounds}` structs (`VisibleListItem`,
  `VisibleTreeRow`, `VisibleTab`, ...) hand-roll today.

Landing `layout.rs` does not change any primitive's public API by
itself — nothing in `primitives/` consumes it yet. Converting an
existing primitive onto it (new `Visible*` alias, new placement type)
is a rule-8 breaking change and lands as **its own PR per primitive**,
with a `#[deprecated]` shim per the two-PR protocol above, so no
consumer breaks in one commit — see #816 for the full split and its
coordination with quadraui#785's Phase 2. Do not delete the LOCAL/
ABSOLUTE table until every primitive listed in it has converged; a
partially-converged table with rows removed early is worse than the
current complete one.

(Issue #1098 asked quadraui to finish this entire table's conversion in
the same pass as an unrelated `Rect`-unit-contract fix. `docs/decisions/DECISIONS.md`
D-016 declines that batching for the same one-primitive-per-PR reason
stated above; the table above is unchanged by that decision and remains
the tracker for each remaining conversion.)

## One primitive, one canonical paint path (issue #456)

`quadraui` has two ways to paint a primitive: `backend.draw_<name>(rect,
&data)` directly, or `layout.push(Surface::<Name> { rect, .. })` +
`layout.draw(backend)` via `quadraui::frame::ScreenLayout`. Nothing
stops two backends of the same consumer app from picking different
ones for the identical call site — and when they do, the two paint
paths silently drift, because the compiler can't see across the split.
That's exactly what happened in vimcode: the TUI palette painted via
`b.draw_palette(...)`, the GTK palette via `Surface::Palette`, with
every other line at both call sites identical (#456; contributed to
vimcode#587).

**`ScreenLayout` + `Surface` is the canonical path for a consumer
assembling a top-level screen from multiple primitives** — it forces
both backends through the same call site and its `zone_for` helper
keeps the hit-map in lock-step with what was painted by construction
(see `quadraui/src/frame.rs`'s module doc). `Backend::draw_<name>`
stays public, non-deprecated, low-level API — `ScreenLayout::draw`
calls it internally. As of #1099 every primitive with a `draw_*`
method has a matching `Surface` variant, so this is no longer the
*only* path for any primitive; `draw_*` stays directly reachable by
design, for rasteriser tests and compose helpers that paint a single
primitive outside a full-screen `ScreenLayout`. See
`docs/decisions/DECISIONS.md` D-006 for the full audit.

**Going forward:** when rule 7 above has you adding a `Backend::draw_<name>`
method for a primitive that's composed into top-level screens (i.e. a
consumer will paint it as one layer of a multi-primitive frame, not
just as the sole content of its own pane), add its `Surface::<Name>` /
`FrameZone::<Name>` variant and `ScreenLayout::zone_for` arm in the
**same PR**. Skipping this is how the trait and `Surface` started drifting apart in
the first place — 11 primitives had a trait method but no `Surface`
variant as of the #456 audit (Board, DiffView, PipelineView, Toolbar,
SidebarPanel, TextInput, Spinner, Progress, CommandCenter, DropOverlay,
MessageList), grown to 14 by the #1099 audit (the same 11, plus Image,
Minimap, SplitTree). **Issue #1099 closed that backlog**: every one of
those 14 now has a `Surface`/`FrameZone` variant — see `frame.rs`'s
module doc, "Closing the `Surface` coverage gap." This rule's job now
really is just to stop the gap from reopening, not to describe a
standing backlog.

**When `Backend::draw_*` direct calls are still the right choice**, even
for a primitive that does have a `Surface` variant: a rasteriser's own
paint/click round-trip test (rule 4), a compose helper painting a
primitive it fully owns, or any call site that has no need for the
frame-level hit-map `ScreenLayout` produces. The rule is "assembling a
multi-primitive screen goes through `Surface`," not "never call
`Backend::draw_*` directly."

## Shared pixel-layout math (#499)

`gtk::tree::gtk_tree_layout` and `macos::tree::mac_tree_layout` used to
be byte-identical apart from the function name and one comment — same
magic constants (`line_height * 1.2` header pitch, `* 1.4` item pitch,
`* 0.9` indent, `* 0.65` chevron glyph estimate), same body. The
`MultiSectionView` body-measure match arms were the same story, and had
already **drifted**: GTK measured real `MessageList` content height,
macOS's copy returned `0.0` — a live macOS layout bug born of
copy-paste, not a deliberate backend difference.

**Rule: pixel-backend layout math that doesn't touch a native drawing
handle belongs in `quadraui/src/primitives/layout_metrics.rs`, not in
`gtk::<name>` / `macos::<name>` / `win::<name>`.** A function belongs
there if it can be written as `(primitive, rect, line_height, ..) ->
*Layout` / `*Measure` with no `cairo::Context`, `CGContextRef`,
`pango::Layout`, `CTFont`, or `ID2D1RenderTarget` in its signature.
Each backend's `<name>.rs` then keeps only:

- a thin wrapper (`gtk_tree_layout`, `mac_tree_layout`, …) that calls
  the shared fn — kept per-backend only so each rasteriser's public
  layout-helper name matches rule 5 above, not because the body
  differs;
- the native paint code, which consumes the same `*Layout` /
  `*Measure` the shared fn produced.

**When a shared fn needs a real glyph width** (Form's per-item hit
regions can't use a fixed-ratio estimate the way tree/MSV do), it takes
`&dyn primitives::layout_metrics::TextMeasure` instead of a native font
handle. Each backend supplies a private one-method adapter over its own
live font/context — see `macos::form::CtFontMeasure` wrapping `CTFont` +
`measure_text`. The shared fn never learns a native type exists.

**Migrating a primitive's layout math is not the same PR as fixing a
drift it exposes**, unless the drift *is* the primitive's whole reason
for migrating (as MSV's `MessageList` fix was for #499) — see rule 8's
"one breaking change per PR" discipline; the same "don't batch" logic
applies to behavior changes uncovered mid-refactor. If sharing a
function would change one backend's *current* output (see
`layout_metrics::form_field_measure`'s doc on `FieldKind::TextArea` for
a case where sharing was deliberately **not** done for this reason),
leave that backend's existing behavior in place and note the gap in the
shared fn's doc comment rather than silently changing it.

**A file-overlap conflict on a fenced backend file (e.g. concurrent
work on `gtk/backend.rs`) blocks that backend's migration, not the
others.** Migrate every backend whose files are free; leave the
blocked one's duplication in place with a comment pointing at the
tracking issue, and land what's clean rather than waiting on the whole
set. **Before using this escape hatch, read "A fence is a debt, and it
needs an owner" below — it has already produced one shipped bug
(#710).**

## Primitive-first: a new primitive lands its layout shared, before any backend paints it (#481)

The "Shared pixel-layout math" rule above tells you how to *migrate*
duplication that already exists. This rule exists because migrating is
not enough: **the lift rate has been lower than the copy rate.**

### The measurement that produced this rule

A function-level re-audit on 2026-09-03 (`develop` @ `7f00f73`) compared
`gtk/` and `macos/` against the same measurement taken in July
(`73172ff`) — contiguous byte-identical production runs of eight
normalised lines or more:

| | July `73172ff` | 2026-09-03 `7f00f73` |
|---|---|---|
| contiguous identical lines, gtk↔macos | 626 | **757** (+21%) |
| whole functions identical | 113 | **219** |

#498 and #499 **worked where they were applied** — `services.rs` 15→0,
`backend.rs` 37→0, `tree.rs` 30→19, `multi_section_view.rs` 49→24, and
every exhibit the July audit named is gone. That is roughly −90 lines.

In the same window roughly **+220 lines of new duplication arrived**:
`testing.rs` 0→87, `board.rs` 0→44, `diff_view.rs` 0→34,
`activity_bar.rs` 0→24. macOS had no `board.rs`, `diff_view.rs` or
`testing.rs` in July — each arrived as a copy of GTK's. The `Toolbar`
button-measure formula now exists in **five** places and they agree by
luck, not by construction.

So the per-primitive lifts were correct and effective, and the number
still went up, because nothing stopped the next primitive arriving as N
copies.

**Rule: a new primitive's layout, measurement and state-resolution land
in `quadraui/src/primitives/` in the same PR that introduces it — before
any backend paints it. A backend file may only add an executor.**

An "executor" is code that takes a `*Layout` / `*Measure` the shared
code produced and issues native drawing calls. If a backend file
computes *what* to paint rather than *how* — which theme colour a
hover/pressed/focused state resolves to, which glyph a status or badge
maps to, where a row starts, whether a scrollbar is needed — that
belongs in `primitives/`.

Concretely, these all belong shared and none of them may be written a
second time in a second backend:

- `status → glyph` and `status → colour` tables (`pipeline_view`'s
  status icons, `board`'s badges, `toast`'s severity background)
- geometry constants (`*_BOARD_*_PX`, `TOAST_*`, and the ~40 others
  currently duplicated with identical values across backends)
- scroll-offset resolution, including keeping a selection visible
- `needs_hscrollbar`-style reservations that hit-test and paint must
  agree on

### Porting a primitive to a new backend

When you add backend N+1's copy of an existing primitive, you are not
"porting" it — you are **splitting it**. Extract the shared half into
`primitives/` first, reduce the existing backends to executors in that
same PR, and then write the new backend's executor. Copying
`gtk/<name>.rs` to `<newbackend>/<name>.rs` and editing the draw calls
is how `board.rs`, `diff_view.rs` and `activity_bar.rs` became twins,
and it is what a Windows or web backend will do to every remaining
primitive unless this is enforced at review.

### A fence is a debt, and it needs an owner

The "Shared pixel-layout math" rule permits leaving a blocked backend's
duplication in place behind a comment pointing at a tracking issue.
**That escape hatch has already failed once.** `GtkBackend::form_layout`
carried a comment reading *"fenced by concurrent #683 work … migrate
this one once #683 lands. Tracking: #499."* #683 landed; nobody came
back; GTK kept measuring `TextArea` as `row_h * visible_rows` while its
own painter drew one row, so every row below a `TextArea` hit-tested to
the `TextArea` (#710).

If you fence a file:

- name the issue that **unblocks** it, not just the issue that tracks
  the migration — a reader needs to know what to watch for;
- open a follow-up issue *at fence time* to remove the fence, and link
  it in the comment. A comment is not a work item;
- state what the duplication currently does differently, so the next
  reader can tell a deliberate difference from drift.

A fence whose blocker has closed is a bug waiting to be found, not
technical debt being managed.

## Native-vs-painted menu convention

`ContextMenu` has two display paths and apps pick by trigger, not by
backend. The split is deliberate — it's what makes "feels native on
Mac while staying portable" the default outcome.

| Trigger | Path | Why |
|---|---|---|
| `MouseDown { button: Right }` on a canvas / row / cell | `backend.show_context_menu(&menu, position)` | User-triggered system menu — gets the platform's fonts, accent colour, Dark Mode, VoiceOver, ⌘-shortcuts. macOS uses `NSMenu.popUpMenuPositioningItem` natively; TUI/GTK fall through to the painted overlay today (stash-and-paint default lands when a consumer asks). Activation arrives as `UiEvent::ContextMenuItemActivated(WidgetId)`; dismissal as `UiEvent::ContextMenuDismissed`. |
| Left-click on a UI affordance that opens a menu-style dropdown (palette sub-menu, in-window action list) | `Backend::draw_context_menu` in the next render pass | App-controlled rendering, pixel-consistent across backends, positioned relative to a widget rather than the cursor. Use the `MenuSystem` compose helper. |

## Primitive maturity levels

A primitive file in `primitives/` is not "shipped" until it has all
three legs: (1) descriptor + layout + hit_test + layout tests, (2) TUI
and GTK rasterisers with paint/click harnesses, (3) `Backend` trait
methods. Primitives with only leg (1) are **descriptors** — their shape
is real and tested, but consumers cannot adopt them yet because there's
nothing to paint. As of 2026-05-03, all previously descriptor-only
primitives have shipped with full rasteriser + Backend trait coverage.
Don't delete descriptors — the layout + hit_test work is real and
reusable. Do prioritise adding rasterisers for any descriptor that
blocks a consumer's bespoke-paint elimination.