UI interaction performance — request→painted latency tiers
testing specs/testing/ui-interaction-perf.kmd
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.
Quando esta spec se aplica
Triggers primários
- Measure request→painted latency of a reusable interaction primitive
Todos os triggers
- Author a perf TDD that opens a menu / creates a tab / focuses an input / opens a dialog or popover
- Add or change a reusable interaction primitive in koder_kit / koder_web_kit
- Report or judge the latency of a UI action against an external equivalent
- Decide a per-primitive ideal/acceptable/unacceptable latency budget
Corpo da especificação
Spec — UI interaction performance (request→painted)
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 SDK/framework/lib (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 path stack-RFC-005 defines), NOT at handler entry.t_paintedis stamped at the frame-presentation callback for the frame that 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
placeholder/skeleton/incremental 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 dominated by 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-paint p99 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 "close/collapse/dismiss" 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 like R2.1'ssettled: it is dominated by the designed exit-animation duration (a motion-design choice), never a perf defect. Record it as a non-gated reference row/key (<primitive>/settle), 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 the finder is empty) on a zero-transition host for the gate, and the same close on an animated host viareportReferencefor 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 (mobile/desktop/web) | 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 toperformance(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 (LEGACY — Flutter sunset)
Scope note (Flutter sunset,
stack-RFC-019): this is a Flutter-harness-specific finding, retained for apps still on Flutter. The go-forward Kroma UI measures interaction perf through its own path — thekroma_test_stateharness + frame timing (frames-to-paint via the winit/wgpu present cadence), notIntegrationTestWidgetsFlutterBinding. The principle below (never report driver wall-clock as render latency; frames-to-paint is the governing signal) transfers to Kroma; the ~100 ms number is Flutter's binding overhead, not Kroma's.
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 p50=1, max=1 — 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→painted latency — 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 (the number of real frames between the input and the result being present), or the engine's ownaddTimingsCallbackrasterFinishdelta (R3) — NOT the wall clock around the test driver call.1 frame≈ instant =ideal. - This is why the koder_kit corpus rows (KKIT-079/084/085) 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 does NOT 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; GNOME/Tilix/other 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 ticket names the long-term target (theidealnumber) 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 ofregression-tests.kmd). - A row crossing into
unacceptableis a hard fail ✗ — blocks the module'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).
Referências
rfcs/stack-RFC-012-ui-interaction-perf-corpus.kmdrfcs/stack-RFC-005-full-headless-testability.kmdregistries/ui-interaction-baselines.mdregistries/perf-baseline.mdspecs/testing/taxonomy.mdspecs/testing/ouia-test-hooks.kmdpolicies/self-hosted-first.kmdpolicies/reuse-first.kmdpolicies/regression-tests.kmdcommands/k-bench.mdcommands/k-test-gen-perf.md