Animation-parity TDDs (temporal capture / motion-curve & timing diff / cross-impl reference-target / native shell+compositor)
develop specs/develop/animation-parity-tdds.kmd
Companion to specs/develop/visual-regression-tdds.kmd. That spec covers STATIC golden snapshots + geometry sampled at keyframe peaks; this spec covers HOW a Koder UI moves — animations, component motion, hover/press pulses (e.g. the hot-corner ripple), transitions, and effects — over TIME. It defines: (1) temporal capture (video / frame sequence) of a running surface, (2) motion comparison (easing curve + duration + timing fit, plus per-frame visual diff with tolerance), (3) a "reference-target" mode that compares a Koder surface against a captured reference impl (e.g. GNOME 48) — the foundation of the Kolide literal-parity program (kolide#042/#044) — alongside the usual self-golden mode, and (4) a generation path for NATIVE shell/compositor surfaces (Kolide C/GTK4 layer-shell, Kompose wlroots) that the existing R4 generator (Flutter / web / Android-Compose only) does not reach. Verification runs HEADLESS on s.khost1, never on the owner's NVIDIA-only laptop (hostile seat).
When this spec applies
Primary triggers
- Author or regenerate animation-parity TDDs
- Ship a timed animation on a native shell/compositor surface (Kolide/Kompose)
All triggers
- Add or modify an animation / transition / motion effect in any Koder surface
- Claim animation/motion parity of a Koder shell against a reference (e.g. GNOME)
- Author or regenerate animation-parity TDDs
- Stand up the s.khost1 AV parity harness for Kolide/Kompose
Specification body
Spec — Animation-parity TDDs
Scope
Every Koder surface that ships a timed animation, transition, or motion
effect MUST ship animation-parity TDDs under
<module>/tests/regression/motion/. This is the temporal complement to
visual-regression-tdds.kmd (static + keyframe-peak geometry).
Targets:
- Native shell / compositor — Koder Kolide (
infra/linux/kolide, C/GTK4 layer-shell) and Koder Kompose (infra/linux/x, wlroots). This is the surface class the static spec's R4 generator does not reach, and the primary driver of this spec (the Kolide literal-parity program, kolide#042/#044). - Web / Flutter / Android-Compose — surfaces already covered for
static regression also ship motion TDDs when they declare animations
(the
Element.getAnimations()/golden_toolkit/ Compose-clock hooks already exist; this spec adds the temporal + curve assertions on top).
Out of scope: surfaces with no timed motion (static markdown, CLI without TUI animation, server-only services). A surface whose only motion is the reduced-motion fallback ships the R8 reduced-motion test only.
Two comparison modes (R3). Every motion TDD runs in
self-goldenmode (compare against this surface's own committed golden — catches regressions). A surface under a parity program additionally runsreference-targetmode (compare against a captured reference impl, e.g. GNOME 48) — this is what proves "moves like GNOME", not just "moves like it did yesterday".
R1 — Categories (the four motion TDDs)
A surface ships the categories its animations exercise. Naming mirrors the static spec's A/B/C/D; motion categories are M1–M4.
Category M1 — Motion-curve & timing parity
For every named animation (overview zoom, hot-corner pulse, drawer slide,
popover reveal, window-open/close, workspace switch), sample the animated
property's trajectory over normalized time t ∈ [0,1] and assert it
matches a target curve + duration:
∀ animation a with animated property p:
trajectory = [ sample(p, t) for t in 0, .05, .10, … 1.0 ] # ≥ 21 samples
# 1. Duration within tolerance of its motion token (easing-duration.kmd R1)
assert |a.duration − token(a).ms| ≤ max(16ms, 8% × token.ms)
# 2. Easing fit: trajectory fits the declared cubic-bezier within ε
assert curve_rmse(trajectory, token(a).bezier) ≤ 0.04 # normalized [0,1]
# 3. Monotonicity where the token requires it (no unintended overshoot
# unless the token is a spring — physics.kmd)
assert monotonic(trajectory) OR token(a).is_spring
- Source of the target curve:
specs/themes/motion/easing-duration.kmd(the 5 duration + 5 easing tokens) for deterministic animations;physics.kmdspring tokens for spring animations. Inreference-targetmode the target is the measured curve of the reference impl (R3), not the token — parity to GNOME's actual motion can differ from the KDS token, and the parity program decides which governs per animation (recorded in the surface's parity manifest, R3). - How the trajectory is sampled:
- Web →
Element.getAnimations()[i].currentTimedriven to eacht. - Flutter →
AnimationController.valuestepped underfakeAsync. - Native (Kolide/Kompose) → either (a) the cairo-render geometry pattern (R6) when the motion is pure drawing math extracted into a GTK-free function (preferred — deterministic, no Wayland), or (b) frame extraction from the wf-recorder capture (R2) with the property recovered by measuring the rendered frames.
- Web →
Category M2 — Temporal frame-sequence diff
Capture the animation as a frame sequence and diff it against the golden (self) and/or reference (R3), frame-aligned by normalized time:
golden_frames = frames(reference_or_self_golden, fps=N)
captured_frames = frames(this_run, fps=N)
for (g, c) in time_align(golden_frames, captured_frames):
d = frame_diff(g, c) # see R4 metric (SSIM + masked pixel-delta)
assert d ≤ tolerance(category, t)
- Frame-aligned by normalized animation time, not wall-clock — a run that is uniformly 5% faster but identically shaped passes M2 (M1 owns the timing verdict; M2 owns the shape/appearance).
- Tolerance is looser mid-animation (motion blur, sub-pixel) and
tight at rest (
t=0andt=1end states must match the static golden fromvisual-regression-tdds.kmd— the two specs share end-state goldens).
Category M3 — Effect presence & geometry
For pointer/state-driven effects without a fixed end-state translation — the hot-corner mouse pulse / ripple, focus-ring bloom, hover glow, press ripple — assert the effect's geometry over its lifetime:
∀ effect e (ripple/pulse/glow):
# expansion follows the declared shape & easing (M1 owns the easing)
assert e.radius(t) is non-decreasing and reaches e.max_radius by t=1
assert e.origin == trigger_point ± 2px # ripple starts at the corner/cursor
assert e.alpha(t) fades to 0 by t=1 (no residual artifact at rest)
assert e.draws_nothing when gtk-enable-animations == false # R8
The pilot for this category is the Kolide hot-corner ripple
(kolide#047) — its drawing math already lives in a GTK-free cairo file
(hot_corner_ripple_draw(cr, w, h, progress)), so the test renders each
progress frame to an ARGB32 surface and asserts radius/origin/alpha +
the no-op-under-reduced-motion branch, with zero Wayland (R6).
Category M4 — Reference-target parity (cross-impl)
Only in reference-target mode. Compares this surface's motion against a
captured reference impl for the SAME interaction:
ref = captured_reference(interaction, impl="gnome-48") # R3
this = capture(interaction, surface="kolide") # R2
assert M1.curve_rmse(this, ref) ≤ 0.06 # looser than self-golden M1
assert M2.frame_diff(this, ref, masked) ≤ ref_tolerance
report per-interaction: { duration Δ, easing Δ, peak-frame visual Δ }
→ feeds the kolide#042 gap report → tickets
M4 is advisory→blocking by program phase: while a parity program is mid-flight it runs report-only (produces the gap list that becomes tickets); once a surface declares an interaction "parity-achieved" in its manifest (R3), M4 for that interaction becomes blocking (a later change that drifts from GNOME fails CI).
R2 — Temporal capture pipeline (native, headless on s.khost1)
Where it runs. Heavy headless rendering of Kolide/Kompose runs on s.khost1, NEVER on the owner's laptop (NVIDIA-only hostile seat — VT lockup → hard reboot; see memory
koder-desktop-environment-roadmapandpolicies/heavy-work-isolation.kmd). The harness is a VM/container with a matched build of Kompose + kolide-shell + kolide-services (R6 — version skew caused the laptop lockup; never mix).
The capture stack (software-only GL where no GPU seat is available —
gpu-seat-mutter-nvidia-wall-wlr-screencopy-works):
| Step | Tool | Output |
|---|---|---|
| Run the surface | cage (single-app) or Kompose itself, headless | a live Wayland surface |
| Static frame | grim (wl-screencopy) | PNG golden for end-states (shared with the static spec) |
| Motion video | wf-recorder (or wl-screencopy frame loop) | raw video of the animation |
| Frame extract | ffmpeg -vf fps=N / kodec (self-hosted-first) | the M2/M4 frame sequence |
| Pure geometry | cairo-render (R6) | per-progress PNG, no Wayland |
- fps N ≥ 60 for capture; downsampled per-category for diff. The recorder timestamp is the wall-clock; M1/M2 re-normalize to animation time using the trigger marker (R6 debug hook emits a start/stop marker).
- Prefer kodec over ffmpeg for frame extraction where it has parity
(
policies/self-hosted-first.kmd+registries/self-hosted-pairs.md); ffmpeg is the documented fallback until kodec flips.
R2.1 — Triggering the motion: synthetic input (primary) / debug hook (fallback)
Primary path (R-superseding): real synthetic input via
specs/develop/synthetic-input-and-virtual-devices.kmd(koder-vdev— a virtual seat: uinput / wlroots virtual-pointer/keyboard). A real pointer move fires the hot corner, a real click opens a popover — the motion is triggered through the genuine input path and emits a trigger marker. Use this wherever the harness provides a virtual seat.
When no virtual seat is available (pure unit prong, or a harness without the synthetic-input layer yet), fall back to a debug hook in the shell:
- The shell exposes a guarded debug command (env
KOLIDE_DEBUG_MOTION=1+ a private D-Bus methodorg.koder.Kolide.Debug.Trigger(interaction:s)/ a--debug-trigger <interaction>flag) that fires the SAME code path the real input would (callshot_cornerripple +overview_toggle, opens a named popover, etc.) and emits a start marker before and a stop marker after, so R2 can normalize animation time exactly. - The debug surface is compiled out of release (or hard-gated on the env var) — it exists for the harness only, never user-reachable.
- This is the documented substitute for the static spec's
Element.getAnimations()driver, which native GTK4/wlroots lacks.
R3 — Reference-target mode (capturing & comparing vs GNOME)
The parity program's source of truth for "how it should move".
- Capture the reference once, commit it. A reference clip is captured
from the reference impl (GNOME 48, Adwaita dark, Cantarell, no
extensions — the owner's baseline) at a fixed resolution/scale and
stored under
<module>/tests/regression/motion/__reference__/<impl>/. Reference clips ARE committed (golden, not artifact — same rule as the static spec's__snapshots__/). Each clip carries a sidecar<interaction>.meta.json: impl + version, resolution, scale, fps, the measured duration + fitted easing (so M1 can target the measured curve without re-decoding the video every run). - Parity manifest. Each surface under a parity program ships
tests/regression/motion/parity.tomllisting, per interaction: the reference impl+version, whether the token or the measured reference curve governs M1, the M4 phase (report-only|parity-achieved), and per-category tolerance overrides. This is the single place that records "we match GNOME on this interaction". - Capturing the reference is a manual, audited step (a human/AI runs GNOME on a real or nested seat and records the clip) — it is NOT regenerated in CI (CI has no GNOME). Re-capture only on a deliberate reference-version bump, recorded in the manifest + commit.
- Resolution/scale normalization. Reference and capture are recorded at the same logical size; M2/M4 diff after normalizing to a common raster (R4). A scale mismatch is a test error, not a silent resize.
R4 — Comparison metric (curve + frame)
- Curve fit (M1) —
curve_rmse= RMSE between the sampled normalized trajectory and the target cubic-bezier evaluated at the samet. Threshold 0.04 (self-golden) / 0.06 (reference-target). Springs use the physics token's damping/stiffness as the target instead of a bezier (physics.kmd), fitted by the same RMSE on the position curve. - Frame diff (M2/M4) — composite metric:
- SSIM over the full frame (structural — catches shape/layout drift), threshold per-category.
- Masked pixel-delta — diff restricted to the region the animation touches (the popover bounds, the ripple disc), so a static wallpaper behind the motion doesn't dilute the score. The mask is the union of changed pixels across the golden's own frames.
- End-state exactness —
t=0andt=1frames diff against the static golden at the static spec's tolerance (tight), since at rest there is no motion-blur excuse.
- Tolerance is declared, not hard-coded — defaults live here; a
surface raises a per-interaction tolerance in
parity.toml(R3) with a one-line justification (mirror the static spec's allow-list rule R6 — raising a floor needs a reason in the same commit).
R5 — Generation contract (extends visual-regression R4)
/k-test (TDD generator) MUST emit the motion suite — in addition to
the static suite — when the module declares timed motion. New triggers on
top of the static spec's R4:
- A
koder.toml[ui]block declaring a native shell/compositor surface:surface = "wayland-layer-shell"(Kolide) orsurface = "wlroots-compositor"(Kompose). ← the new path the static generator lacks. - Any surface (web/Flutter/Android) whose code declares animations
(
@keyframes,AnimationController,animateContentSize, atransition:on a tracked element) → emit M1/M2 on those animations. - A
tests/regression/motion/parity.tomlpresent → also emit M4 in the declared mode.
Generated tests live under <module>/tests/regression/motion/. Naming:
<interaction>-<category>.{spec.ts,dart,kt,c,sh}. For native surfaces the
generator emits (a) a cairo-render geometry test stub (<interaction>-m3.c,
meson-registered) when the motion math is GTK-free-extractable, and (b) a
harness driver (<interaction>-capture.sh) that runs the R2 pipeline
on s.khost1 and the M1/M2/M4 comparison.
Template engines:
- Web → Playwright +
getAnimations()time-stepping. - Flutter →
flutter_test+fakeAsync+golden_toolkitper-frame. - Android → Compose
mainClock.advanceTimeByframe stepping. - Native → cairo-render (geometry) + s.khost1 grim/wf-recorder driver (appearance/timing) — the two-pronged native path (R6).
R6 — Native test-gen path & the s.khost1 AV parity harness
The concrete realization of the generator's native path — the "AV parity engine" referenced in kolide#044 (Session state).
- Build matched versions together. The harness builds Kompose +
kolide-shell + kolide-services from the same
origin/mastertree (mixing v0.4.9 shell over v0.3.2 compositor caused the laptop lockup — never again). Build off-laptop on s.khost1 / a dev container. - Two test prongs per interaction:
- Geometry (deterministic, no Wayland) — extract the motion math
into a GTK-free function (the kolide#047 ripple pattern:
hot_corner_ripple_draw(cr,w,h,progress)), render eachprogressto an ARGB32 cairo surface, assert M1 curve + M3 geometry + the reduced-motion no-op. Runs anywhere, in the meson suite, in CI. - Appearance/timing (live) — run the matched shell under cage/Kompose on s.khost1, fire the interaction via the R2.1 debug hook, capture with grim (end-states) + wf-recorder (motion), extract frames, run M2/M4. Runs on s.khost1 (heavy), nightly + pre-release.
- Geometry (deterministic, no Wayland) — extract the motion math
into a GTK-free function (the kolide#047 ripple pattern:
- Debug-trigger hook (R2.1) opens each menu/popover/overview and fires each pulse, since input injection is blocked.
- Reference clips (R3) for GNOME live under
__reference__/gnome-48/.
Prefer prong (a) wherever the math is extractable — it is deterministic, fast, and CI-portable; reserve prong (b) for motion that only exists in the composited live render (window thumbnails, real popover compositing, cross-fade between real surfaces).
R7 — Run cadence
| Trigger | Categories | Where | Action on failure |
|---|---|---|---|
Pre-commit (/k-commit) | M1 + M3 geometry (cairo prong only — fast, no seat) | laptop/CI | Block commit |
| Nightly CI | M1 + M2 (self-golden) + M3, all interactions | s.khost1 | Open ticket auto |
Pre-release (/k-ship) | M1 + M2 + M3 + M4 (reference-target) | s.khost1 | Block release on parity-achieved interactions; report-only otherwise |
| Parity sweep (kolide#042) | M4 all interactions, report-only | s.khost1 | Emit gap report → tickets |
/k-housekeep | M1 curve-token drift sanity (cairo prong) | laptop/CI | Open ticket auto |
The cairo geometry prong (M1/M3) is seat-free → it gates commits. The live prong (M2/M4) needs s.khost1 → it gates releases/nightly, never the laptop.
R8 — Reduced-motion & determinism
- Reduced-motion is mandatory (inherits
physics.kmd). Every motion TDD includes the reduced-motion case: with motion disabled (gtk-enable-animations=falseon GTK;prefers-reduced-motionon web;disableAnimationson Flutter/Compose) the effect draws nothing (M3) and transitions resolve as an instant state change (M1/M2 end state att=0). The pilot ripple already honors this — the test asserts the no-op branch. - Deterministic clock. Motion tests drive a controlled time source
(
getAnimations().currentTime/fakeAsync/ ComposemainClock/ for native theprogressparameter or the debug hook's normalized marker) — never sleep on wall-clock. A test thatsleeps to "wait for the animation" is non-conformant (flaky, R9). - Background/idle. Effects MUST stop when the surface is idle/occluded
(mirror
spatial.kmd § R4); a residual repaint at rest fails M3 (alpha→0,draws_nothing).
R9 — Forbidden / anti-patterns
- ❌ Asserting motion by
sleep-ing and snapshotting once — non- deterministic; use the controlled clock (R8). - ❌ Diffing the whole frame for a localized effect (the static wallpaper dilutes the score) — use the masked region (R4).
- ❌ Comparing wall-clock-aligned frames across impls — normalize to animation time (R2/M2).
- ❌ Running the live prong on the owner's laptop (hostile seat) — s.khost1
only (R2,
heavy-work-isolation.kmd). - ❌ Mixing shell/compositor versions in the harness (R6) — matched build.
- ❌ Shipping a motion fix without a motion TDD that would have caught it
(
policies/regression-tests.kmd— fix-without-test rule applies to motion too). - ❌ Marking an interaction
parity-achieved(R3) without a committed reference clip + a passing M4.
Tests of the test contract
| ID | Test |
|---|---|
| T1 | A native shell module with [ui] surface = "wayland-layer-shell" gets tests/regression/motion/ populated by /k-test --gen-only (both prongs where extractable). |
| T2 | Pilot — hot-corner ripple (kolide#047). The cairo-render M3 test asserts: origin at the corner ±2px, radius non-decreasing to max_radius by t=1, alpha→0 at rest, and draws_nothing when gtk-enable-animations=false. (Expands the existing kolide#047 cairo test into the M1/M3 contract.) |
| T3 | M1 curve fit fails on an animation whose easing was changed from ease-standard to linear (curve_rmse > 0.04) — reports the offending animation + measured vs target bezier. |
| T4 | M1 duration check fails on a transition retuned from motion-medium (250ms) to 600ms (> 8% drift) — reports the token + measured ms. |
| T5 | M2 temporal diff fails on a frame-sequence whose mid-animation shape drifts beyond tolerance while end-states still match — proves M2 catches shape drift M1 misses. |
| T6 | M4 reference-target produces a gap report comparing the Kolide overview-zoom against the committed __reference__/gnome-48/ clip: per-interaction duration Δ, easing Δ, peak-frame visual Δ. |
| T7 | Reduced-motion (R8): with motion disabled, the ripple M3 test asserts zero draw and the overview transition resolves instantly (no intermediate frames). |
| T8 | Determinism: a motion test that sleeps instead of driving the controlled clock is flagged non-conformant by the suite linter. |
| T9 | Version-skew guard (R6): the harness refuses to run the live prong when kolide-shell and Kompose report mismatched build hashes. |
| T10 | Coverage registry (motion-parity-coverage.md) lists every motion surface with its mode (self-golden / reference-target) and M4 phase; a surface with animations but no motion suite is flagged. |
Relationship to existing specs
- Temporal complement to
visual-regression-tdds.kmd— that spec owns static goldens + keyframe-peak geometry (does the layout break?); this spec owns motion over time (does it move right?). They share end-state goldens (t=0/t=1frames). - Targets the curves of
themes/motion/easing-duration.kmd(duration- easing tokens) and
themes/motion/physics.kmd(springs, the mandatory reduced-motion contract) — M1's self-golden target.
- easing tokens) and
- Realizes
policies/headless-first.kmd(test the logic without the seat — the cairo geometry prong) andpolicies/heavy-work-isolation.kmd(the live prong on s.khost1, never the laptop). - Bound by
policies/regression-tests.kmd— fix-without-test extends to motion. - Foundation of the Kolide literal-parity program (kolide#042/#044) — M4 reference-target is the engine that produces the GNOME-vs-Kolide gap reports that become parity tickets.
R10 — Existing live gaps (snapshot at ratification)
At ratification (2026-06-07), no Koder surface ships a motion suite:
- Kolide (
infra/linux/kolide) — the hot-corner ripple (kolide#047) has a one-off cairo geometry test (manual, not generated, not parity); the overview zoom, dash reveal, quick-settings tiles, popovers have NO temporal/parity test. Primary consumer of this spec. - Kompose (
infra/linux/x) — window-open/close, workspace switch, cross-fade: no motion suite. - Web/Flutter consumers — motion tokens are validated against the
design system (
koder_kit/test/motion_tokens_test.dart) but no surface ships M1/M2 on its own animations.
Track closure in
meta/docs/stack/registries/motion-parity-coverage.md (new — created with
this spec).
Open follow-ups
- Stand up the s.khost1 AV parity harness (R6) — matched build + cage/ Kompose + grim + wf-recorder + debug-trigger hook (kolide#044 Session state "motor de paridade AV"; KSTACK-178 DoD).
- Capture the first GNOME-48 reference clips (R3) for the Kolide parity set (overview zoom, hot-corner pulse, dash reveal).
- Extend
/k-testR4 detection + the native template emitters (R5/R6). - Build the
motion-parity-coverage.mdregistry (R10).
References
specs/develop/visual-regression-tdds.kmdspecs/themes/motion/easing-duration.kmdspecs/themes/motion/spatial.kmdspecs/themes/motion/physics.kmdspecs/themes/motion/transitions.kmdspecs/develop/audio-parity-tdds.kmdspecs/develop/synthetic-input-and-virtual-devices.kmdpolicies/regression-tests.kmdpolicies/headless-first.kmdpolicies/heavy-work-isolation.kmd