Animation-parity TDDs (temporal capture / motion-curve & timing diff / cross-impl reference-target / native shell+compositor)

mandatory

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

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,

    CGTK4 layer-shell) and Koder Kompose (`infralinux/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-golden mode (compare against this surface's own committed golden — catches regressions). A surface under a parity program additionally runs reference-target mode (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 ABC/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.kmd spring tokens for spring animations. In reference-target mode 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].currentTime driven to each t.
    • Flutter → AnimationController.value stepped under fakeAsync.
    • Native (Kolide/Kompose) → either (a) the *airo-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.

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=0 and t=1 end states must match the static golden from visual-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 radiusoriginalpha + 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-roadmap and policies/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 method org.koder.Kolide.Debug.Trigger(interaction:s) / a --debug-trigger <interaction> flag) that fires the SAME code path the real input would (calls hot_corner ripple + 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.toml listing, 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 same t. 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 exactnesst=0 and t=1 frames 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 *ative shell/compositor

    surface: surface = "wayland-layer-shell" (Kolide) or surface = "wlroots-compositor" (Kompose). ← the new path*the static generator lacks.

  • Any surface (webFlutterAndroid) whose code declares animations

    (@keyframes, AnimationController, animateContentSize, a transition: on a tracked element) → emit M1/M2 on those animations.

  • A tests/regression/motion/parity.toml present → 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 M1M2M4 comparison.

Template engines:

  • Web → Playwright + getAnimations() time-stepping.
  • Flutter → flutter_test + fakeAsync + golden_toolkit per-frame.
  • Android → Compose mainClock.advanceTimeBy frame stepping.
  • *ative → 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).

  1. Build matched versions together. The harness builds Kompose +

    kolide-shell + kolide-services from the same origin/master tree (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.

  2. 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 each progress to 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.

  3. Debug-trigger hook (R2.1) opens each menupopoveroverview and

    fires each pulse, since input injection is blocked.

  4. 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 (M2M4) needs s.khost1 → it gates releasesnightly, 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=false on GTK; prefers-reduced-motion on web; disableAnimations on Flutter/Compose) the effect draws nothing (M3) and transitions resolve as an instant state change (M1/M2 end state at t=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 / Compose mainClock / for native the progress parameter or the debug hook's normalized marker) — never sleep on wall-clock. A test that sleeps 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=1 frames).

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

  • Realizes policies/headless-first.kmd (test the logic without the

    seat — the cairo geometry prong) and policies/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-test R4 detection + the native template emitters (R5/R6).
  • Build the motion-parity-coverage.md registry (R10).