UI interaction performance — request→painted latency tiers
Normative contract for measuring, comparing and gating the latency of reusable UI interaction primitives (open-context-menu, new-tab, focus-input, …) across every Koder surface. Defines: the request→painted metric, the same-run comparative-ratio protocol vs external references (Chrome/GNOME/Tilix), the closed vocabulary of interaction primitives, the ideal/acceptable/unacceptable tier semantics, and the storage schema of the SSOT registry. This is a sub-discipline of the `performance` test category (NOT a 22nd category) ratified by stack-RFC-012.
Status: v0.1.0 — Draft (ratified by stack-RFC-012, Phase 1). The tier registry starts seeded from HCI priors (R6) and is populated by the Phase-3 slice. Gating (R7) is OFF until Phase 5.
R1 — Scope
This spec governs the latency of reusable interaction primitives: a discrete user-initiated UI action whose graphical element is (or should be) implemented in a shared SDKframeworklib (koder_kit, koder_web_kit, TUI/CLI kits). It does NOT govern backend hot-path latency (that is registries/perf-baseline.md) nor cold-start / TTI of a whole app (that is /k-bench quality app-level metrics, which this spec composes with but does not replace).
R2 — The metric: request→painted
The measured quantity for every primitive is:
t_painted − t_intent— wall time from the synthetic user-intent event (the injected click / keystroke / gesture that requests the action) to the first frame on which the result is fully painted and hit-testable.
t_intentis stamped at input injection (the same injection pathstack-RFC-005 defines), NOT at handler entry.
t_paintedis stamped at the frame-presentation callback for the framethat contains the complete result, NOT when the handler returns and NOT at "widget constructed". A menu whose surface is built but not yet composited/hit-testable is not painted.
- "Fully painted" = all of the result's content is rendered (no
placeholderskeletonincremental fill remaining for that primitive) AND the element reports ready (
data-ouia-safe="true"perspecs/testing/ouia-test-hooks.kmd, or the surface equivalent).
R2.1 — first-paint vs settled (governing marker)
"Fully painted" is the first frame on which the result's content is on screen, laid out and hit-testable — NOT the frame on which a reveal animation finishes. For an animated primitive (a menu that scales/fades in) the content is painted and hit-testable on the first frame while the animation is still running; that first frame is t_painted.
- Governing metric = first-paint. The tier (R6) is classified on the
first-paint number, because it is the responsiveness the user perceives.
settled(animation finished) is informational only. It is dominatedby the animation duration, which is a design choice, not a perf defect — never gate on it. Report it alongside first-paint when it differs.
- Empirical confirmation (KKIT-079,
open-menu, flutter-desktop): first-paintp99 113.7 ms vs settled p99 511.6 ms — settled was ~4.5× larger, purely the Material menu open animation (~300 ms). Gating on settled would have falsely flagged a responsive primitive.
- Mechanically: do NOT use
pumpAndSettle(Flutter) / "network idle" (web)as the painted marker — they over-wait. Stop at the first frame where the result finder/element is present + hit-testable.
R2.2 — Dismissal primitives (close-*): first-response vs settle
The dual of R2.1 for dismissal primitives (close-dialog, close-tab, and any "closecollapsedismiss" action). "Painted" has no meaning when the target is leaving; the governing quantity is symmetric to first-paint:
- Governing metric = first-response = structural-removal latency, measured
with the exit transition set to zero duration so the count reflects how fast the framework removes the target once told — NOT the exit animation. This is the dismissal analog of first-paint and is gated (R7) on the same next-frame budget as appearance primitives.
settle(exit-animation duration) is informational only, exactly likeR2.1's
settled: it is dominated by the designed exit-animation duration (a motion-design choice), never a perf defect. Record it as a non-gated reference rowkey (`primitivesettle`), never a gate.- Rationale (owner-ratified via
/k-arch, stack-RFC-012 §Open-questions opt. a):conflating the exit animation with responsiveness would falsely flag good motion design as perf debt (inverts principle #2). Animation smoothness is a separate concern — frame-drop during the transition — owned by
/k-bench'squalitycategory, not this corpus. - Empirical confirmation (KKIT-092,
close-dialog, flutter-desktop): first-response (zero transition) frames p99=1; settle (real ~200 ms fade) frames p99=9. Gating on settle would have falsely flagged an instant close.
- Mechanically: the harness measures via
framesUntilGone(frames until thefinder is empty) on a zero-transition host for the gate, and the same close on an animated host via
reportReferencefor the non-gated settle signal.
R3 — Per-surface end-marker sources (normative bindings)
t_painted MUST be taken from a real frame-presentation signal, per surface:
| Surface | t_painted source |
|---|---|
| Flutter (mobiledesktopweb) | SchedulerBinding.addTimingsCallback → FrameTiming.timestampInMicroseconds(FramePhase.rasterFinish) of the frame committing the result |
| CEF / Kruze (desktop) | OnPaint / OnAcceleratedPaint on the CEF UI thread (C++). MUST NOT be timed from the Dart isolate — kruze --headless suspends it (see koder.md / regression history). |
| Web (non-Flutter) | PerformanceObserver element-timing + requestAnimationFrame after the mutating commit |
| TUI (Bubble Tea) | the render-flush after the Update that produces the result frame |
| CLI | N/A — CLIs have no painted frame; excluded from this spec |
A surface whose binding is not yet validated is reported as instrumentation: unverified and its rows are advisory regardless of phase.
R4 — Sampling and statistics
- Each measurement is N≥20 iterations after a warm-up of ≥3 discarded
iterations (JIT/compositor warm).
- Report p50 and p99. For responsiveness the p99 is the governing
number for the tier (UI jank is a tail phenomenon); p50 is informational.
- Runs execute on the isolation host per
policies/test-host-isolation.kmdwith CPU governor pinned to
performance(as/k-benchalready does). - The body must be deterministic (seeded, no network) so deltas are signal,
per
commands/k-test-gen-perf.mdgeneration rule 2.
R4.1 — Flutter integration_test wall-clock has a ~100 ms HARNESS FLOOR
Measured finding (KKIT-086, 2026-06-08): driving an interaction with tester.tap() + a pump-loop inside IntegrationTestWidgetsFlutterBinding yields a wall-clock floor of ~95-150 ms that is test-harness overhead, NOT app render latency. A diagnostic that counted frames-to-paint found the result paints in 1 frame (frames p501, max1 — the next frame after the input) yet the Stopwatch around tap+pump read ~113 ms. So:
- DO NOT report the
tester.tap+pumpwall-clock as request→paintedlatency — it is floored by the binding's pointer-dispatch + frame-scheduling cadence and over-states the true latency by ~100 ms.
- The governing signal in
integration_testis frames-to-paint (thenumber of real frames between the input and the result being present), or the engine's own
addTimingsCallbackrasterFinishdelta (R3) — NOT the wall clock around the test driver call.1 frame≈ instant =ideal. - This is why the koder_kit corpus rows (KKIT-079084085) read ~113 ms yet are
ideal: the app paints in 1 frame; the ms is harness floor. The registry records the ms with an explicit harness-floor caveat + the frames-to-paint. - For OS-level measurement (kruze#239
click_to_paint.py) this floor doesNOT apply — there is no test binding; the XGetImage poll measures real screen change. OS-level ms IS comparable; integration_test ms is not.
R5 — Same-run comparative ratio (the portable signal)
- When an external equivalent exists, the primitive and its reference MUST
be measured in the same run on the same machine, interleaved.
- The primary stored signal is the ratio
koder_p99 / reference_p99.Raw ms is secondary and MUST carry a hardware tag.
- Reference drivers: Chrome via CDP; GNOMETilixother X11 via AT-SPI or
XTEST synthetic input (reuse the
kover #026no-CGo XTEST substrate). - Each reference row MUST store the reference software version and
capture date. References are moving targets; re-baseline at least once per quarter or when the reference major-versions.
- Primitives with no meaningful external equivalent are self-referential
(regression-only): no ratio, tier seeded from HCI priors + owner verdict.
R6 — Tier semantics (ideal / acceptable / unacceptable)
Per (sdk-component, primitive, surface, target):
| Tier | Meaning | Default seed (before owner verdict) |
|---|---|---|
| ideal | Promote-to-prod. | ratio ≤ 1.2 AND p99 ≤ 100 ms (RAIL "instant") |
| acceptable | Good enough to move on; MUST auto-open a perf-debt ticket in the owning SDK component's backlog (gated_by: NOT used — it is actionable debt). |
100 ms < p99 ≤ 1 s (Doherty attention bound) |
| unacceptable | Must fix now; blocks once gating is on. | p99 > 1 s OR ratio > 3 |
- The HCI priors seed the base so it is never empty at bootstrap;
measured reference numbers refine
ideal; the owner verdict overrides any seeded tier and is recorded as the authority for that row. - When the owner marks a row
acceptable, the auto-opened perf-debt ticketnames the long-term target (the
idealnumber) so the deferral is tracked, not forgotten (composes withregression-tests.kmd §7backfill discipline).
R7 — Gating (perf golden) — OFF until stack-RFC-012 Phase 5
Once enabled per koder.toml [testing.perf]:
- A row degrading from
idealtoacceptableis a regression ⚠(composes with the golden category of
regression-tests.kmd). - A row crossing into
unacceptableis a hard fail ✗ — blocks themodule's perf gate.
- Until Phase 5 every row is advisory (report-only): surfaced in the
/k-benchquality summary, never blocking.
R8 — Ownership and keying
- The row is keyed by the SDK component that implements the primitive
(
engines/sdk/koder_kit, …), NOT by the consuming product. Perreuse-first.kmd, the latency budget is a property of the shared widget; the fix lands once and every consumer inherits it. - A primitive measured inside a product (e.g. Kruze's new-tab) attributes
to the owning SDK widget when one exists; if the product hand-rolls the primitive (no SDK widget yet), that is itself a
reuse-firstfinding and the row is keyed to the product with apromote-to-sdknote.
R9 — Closed vocabulary of interaction primitives
Primitive names are a closed vocabulary; the same action is the same key across surfaces and references. Initial set (extend via spec edit, like the test taxonomy):
new-tab · switch-tab · close-tab · open-context-menu · open-menu · open-submenu · open-popover · open-dialog · close-dialog · focus-input · type-into-input · expand-tree-node · open-dropdown · select-dropdown-item · open-drawer · tab-switch-view
Each name MUST resolve to one row family in registries/ui-interaction-baselines.md. Adding a name is a spec edit + a registry section; ad-hoc strings are non-conformant.
R10 — Storage
Measurements land in registries/ui-interaction-baselines.md (the SSOT), written by /k-bench quality / /k-test-gen-perf. The registry schema is defined in its own header; this spec is the contract for what each column means. perf-baseline.md is NOT used for these rows (different abstraction — stack-RFC-012 §4).