UI Interaction Performance Corpus — comparative request→painted latency tiers for reusable interaction primitives

draft

*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 / unacceptable tier 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-only

    log keyed by component* and explicitly hardware-tagged; the wrong shape for human-seeded tiers used as a gate (see §4).

  • /k-bench quality category — already measures "TTI, render budget,

    frame drop count, cold-start ms".

  • self-hosted-first.kmd §G2 — performance parity is already a

    ratio-vs-external gate (Koder ≤ external × 1.05 hot-path).

  • stack-RFC-005 — headless input + deterministic observation, the

    substrate 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

  1. 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.

  2. The budget belongs to the widget, not the caller (reuse-first.kmd).

    If the popup menu is a koder_kit widget reused by ≥3 modules, its latency budget is a property of koder_kit — measured once, owned once, fixed once, and inherited by every consumer. Keying perf by consuming module (as perf-baseline.md does today) fragments the same primitive across N rows and orphans the fix.

  3. Meta First (stack-principles §1). Tabsmenusbuttons recur in every

    module; 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 + wrong

    key; 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[] in koder.manifest.json vs 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-gated settle reference*(R2.1's dual). Empirically validated: close-dialog first-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-bench quality), not this corpus. Harness: framesUntilGone + reportReference.