UI Interaction Performance Corpus — comparative request→painted latency tiers for reusable interaction primitives
*The latency of a reusable interaction — opening a context menu, creating a new tab, focusing an input, switching tabs — is a first-class property of the shared widget that implements it, not of the module that hosts it. The Stack measures that latency as the wall time from user-intent to the frame where the result is fully painted, compares it on the same run against the best-in-class external equivalent (Chrome, GNOME, Tilix), and stores a per-primitive
ideal / acceptable / unacceptabletier that gates promotion.*
Abstract
Koder products are felt as slow long before any backend benchmark says so. The owner's lived example: in Koder Kruze, "New Tab" and right-click context menus take ~seconds where Chrome takes ~milliseconds. These interaction primitives — tabs, menus, buttons, popovers — are common to practically every module across every surface (webdesktopmobiletv cli-tui) and most of them are (or should be) implemented once in the SDKframeworklib layer (engines/sdk/koder_kit, koder_web_kit, …).
The Stack already has the adjacent pieces, scattered:
registries/perf-baseline.md— a perf corpus, but an *append-onlylog keyed by component* and explicitly hardware-tagged; the wrong shape for human-seeded tiers used as a gate (see §4).
/k-benchqualitycategory — already measures "TTI, render budget,frame drop count, cold-start ms".
self-hosted-first.kmd §G2— performance parity is already aratio-vs-external gate (
Koder ≤ external × 1.05hot-path).stack-RFC-005— headless input + deterministic observation, thesubstrate any request→painted measurement needs.
stack-RFC-011— shadow/parallel comparative runs (Koder vs reference).
This RFC unifies them into a single, SDK-primitive-keyed corpus with a defined metric, a same-run comparative protocol, a three-tier lifecycle, and a closed vocabulary of interaction primitives. The normative detail lives in specs/testing/ui-interaction-perf.kmd; the data lives in registries/ui-interaction-baselines.md.
Status
draft — drafted 2026-06-07 after a /k-arch deliberation that selected the phased path (ratify shape first, prove the request→painted instrumentation on a thin slice, then build out). Phase 1 (this RFC + spec + registry skeleton) is authored in the same session. Phases 2–6 are backlog tickets. Owner ratification pending.
Motivation
- Viability as real products. Micro-latency on common interactions is
the difference between "a demo" and "a tool a client will use all day". Sub-perceptible response (<100 ms) is the bar competitors already meet.
- The budget belongs to the widget, not the caller (
reuse-first.kmd).If the popup menu is a
koder_kitwidget reused by ≥3 modules, its latency budget is a property ofkoder_kit— measured once, owned once, fixed once, and inherited by every consumer. Keying perf by consuming module (asperf-baseline.mddoes today) fragments the same primitive across N rows and orphans the fix. - Meta First (
stack-principles §1). Tabsmenusbuttons recur in everymodule; this is ≥3 instances of one pattern → formalise the measurement once instead of re-deriving "is this fast enough?" per module.
Design
1. The metric — request→painted
The measured quantity is the wall time from user-intent event (the synthetic click/keystroke that requests the action) to the first frame on which the result is fully painted and interactive. Not "the handler returned" — that lies (a menu whose widget is constructed but not yet composited is not open to the user).
This requires frame-accurate end markers per surface (Flutter SchedulerBinding.addTimingsCallback / frame timeline; CEF OnPaint / OnAcceleratedPaint; web PerformanceObserver + element-timing + requestAnimationFrame). The instrumentation is per-surface and must be validated — naive headless timing on Kruze is known to lie because kruze --headless suspends the Dart isolate, so CEF-side timing must run on the CEF UI thread (C++), not from a suspended isolate. Full contract in the spec §2–§3.
2. Same-run comparative ratio is the portable truth
Absolute milliseconds are not portable (perf-baseline.md says so). The corpus runs the Koder primitive and its external reference in the same run on the same machine and stores the ratio as the primary signal; raw ms is secondary and hardware-tagged. This dovetails with self-hosted-first §G2, which is already ratio-based. Reference drivers: Chrome via CDP (trivial), GNOME/Tilix via AT-SPI / XTEST synthetic input (the kover #026 no-CGo XTEST substrate is reusable). Each reference row stores the reference software's version + capture date; references are moving targets and get re-baselined periodically.
3. Three-tier lifecycle: ideal / acceptable / unacceptable
Per (sdk-component, primitive, surface, target) the corpus stores a tier:
- ideal — at or within a small margin of the best-in-class reference
(default: ≤ reference × 1.2, never worse than the HCI instant bar of ~100 ms). Promote-to-prod state.
- acceptable — owner-judged "good enough to move on"; *ust
auto-open a perf-debt ticket in the owning SDK component's backlog* Not a prod-ideal state — a tracked deferral.
- unacceptable — must fix now; blocks (once gating, phase 5).
The base is not empty at bootstrap: HCI priors seed defaults — RAIL/ Doherty (<100 ms = instant/ideal, ~16 ms frame budget for 60 fps, ~1 s = attention-keeping/borderline, >1 s = unacceptable) — refined by the measured reference. Owner verdict overrides the seeded tier.
Once a tier is set it becomes a perf golden (composes with regression-tests.kmd golden category): degradation ideal→acceptable = regression ⚠; crossing into unacceptable = ✗ hard fail.
4. Why a new registry, not extending perf-baseline.md
perf-baseline.md is by contract an append-only log ("one row per bench run", "don't let CI noise rewrite existing rows"), keyed by component and hardware-tagged. A tiered-threshold table (owner-seeded, gate-enforced, keyed by SDK primitive×surface, ratio-primary) is a different abstraction (D1 — abstração correta). Overloading the log would bury tiers in a notes column and keep the wrong key. So: registries/ui-interaction-baselines.md is new; perf-baseline.md stays the backend hot-path log. The runner (/k-bench quality) and generator (/k-test-gen-perf) are reused, not duplicated.
5. Closed vocabulary of interaction primitives
To keep "open menu" on Kruze and on Tilix the same key, the primitive name comes from a closed vocabulary (like the test taxonomy): new-tab, switch-tab, close-tab, open-context-menu, open-menu, open-popover, focus-input, open-dialog, expand-tree-node, … The vocabulary is defined and extended in the spec; adding one is a spec edit, not an ad-hoc string.
Rollout
Phased (see frontmatter phases). Phase 3 is advisory (report-only) — it proves the request→painted instrumentation is feasible and stable on the surface where the pain is real (Kruze) before any gating. Phase 5 flips to gating only after the slice stabilises — same discipline as the Stack's soak windows. This de-risks the one genuinely hard part (per-surface paint instrumentation) without re-shaping the corpus later.
Alternatives considered
- Extend
perf-baseline.md— rejected (§4): wrong abstraction + wrongkey; cheap now, structural re-shape later.
- Full build-out across 5 surfaces up front — rejected: builds infra
before the request→painted metric is proven feasible (Kruze isolate- suspend risk); over-build against an unproven measurement.
Open questions
- Where exactly the primitive→
koder_kit-widget map is declared(
benchmarks.quality[]inkoder.manifest.jsonvs the spec). - Per-surface tolerance for ratio noise (p50 vs p99 — UI jank is a tail
phenomenon; the spec leans p99 for responsiveness).
- Reference re-baseline cadence and how reference version drift is flagged.
- *ismissal metric for
close-*primitives — RESOLVED (owner-ratified via/k-arch→/k-go, option a; now normative as spec R2.2, implemented KKIT-092).close-*measure first-response = structural-removal latency with the exit transition OFF (gated, next-frame budget), while the exit-animation duration is a non-gatedsettlereference*(R2.1's dual). Empirically validated:close-dialogfirst-response frames p991 (ideal) vs settle frames p999 (the ~200ms designed fade). Gating on settle would have falsely flagged an instant close — animation smoothness is a frame-drop concern (/k-benchquality), not this corpus. Harness:framesUntilGone+reportReference.