Pular para o conteúdo

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

Todos os triggers

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_intent is stamped at input injection (the same injection path stack-RFC-005 defines), NOT at handler entry.
  • t_painted is 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" per specs/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'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 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's quality category, 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 via reportReference for 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:

Surfacet_painted source
Flutter (mobile/desktop/web)SchedulerBinding.addTimingsCallbackFrameTiming.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
CLIN/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.kmd with CPU governor pinned to performance (as /k-bench already does).
  • The body must be deterministic (seeded, no network) so deltas are signal, per commands/k-test-gen-perf.md generation 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 — the kroma_test_state harness + frame timing (frames-to-paint via the winit/wgpu present cadence), not IntegrationTestWidgetsFlutterBinding. 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+pump wall-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_test is frames-to-paint (the number of real frames between the input and the result being present), or the engine's own addTimingsCallback rasterFinish delta (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 #026 no-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):

TierMeaningDefault seed (before owner verdict)
idealPromote-to-prod.ratio ≤ 1.2 AND p99 ≤ 100 ms (RAIL "instant")
acceptableGood 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)
unacceptableMust 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 (the ideal number) so the deferral is tracked, not forgotten (composes with regression-tests.kmd §7 backfill discipline).

R7 — Gating (perf golden) — OFF until stack-RFC-012 Phase 5

Once enabled per koder.toml [testing.perf]:

  • A row degrading from ideal to acceptable is a regression ⚠ (composes with the golden category of regression-tests.kmd).
  • A row crossing into unacceptable is a hard fail ✗ — blocks the module's perf gate.
  • Until Phase 5 every row is advisory (report-only): surfaced in the /k-bench quality 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. Per reuse-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-first finding and the row is keyed to the product with a promote-to-sdk note.

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