From pulp
Debugs Pulp animations, transitions, and scroll behavior by attaching runtime motion traces over the inspector wire. Captures fixture files, provides assertion helpers for overshoot, settling time, monotonicity, and scroll geometry. Triggered by timing or visual issues.
How this skill is triggered — by the user, by Claude, or both
Slash command
/pulp:motionThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Pulp's agent-first motion observability — sample view geometry / scalar
Pulp's agent-first motion observability — sample view geometry / scalar values / scroll state over time, emit epsilon-bounded events with monotonic timestamps and burst framing, and route them to log lines + inspector events + JSONL fixtures. You are reading this skill because an agent needs to debug, validate, or reproduce a motion behavior.
Trigger this skill the moment a user (or your own reasoning) describes any
of these symptoms — don't reach for grep or git log, attach a trace:
If the user says any of those, this skill applies. Don't suggest reading source; attach a trace and read the numbers.
Eight paths — pick by what you have, all eight terminate at the same
motion::Coordinator so fixtures, scrubber, cost, reduced-motion, and
provenance work identically across surfaces.
| You have | Path | Tool |
|---|---|---|
| A running app + a node id + a scalar / geometry of interest | A — Runtime trace | Motion.startTrace over the inspector wire |
A ScrollView whose offset / visible rect / content size you need to observe | A — Runtime trace (scroll) | Trace.scroll_geometry(name, scroll_view, props) — emits contentOffsetX/Y, visibleRect*, contentSize*, scrollableMax*, inset* |
| A captured frame sequence (no app instrumentation available) | B — Visual analysis | tools/motion/visual/analyze_sequence.py |
A previously recorded .motion.jsonl fixture | C — Replay + assert | motion::replay_fixture + motion::assert_matches |
| An interaction that drives the suspect motion | D — Input record + replay | motion::make_input_recorder + motion::replay_inputs |
| A fixture + an inspector client (design review / CI triage) | E — Timeline scrubber | Motion.loadFixture + Motion.scrubTo over the inspector wire |
| "Which animation is expensive and why?" | F — Cost attribution | Motion.enableCost + CostAttributor + make_render_cost_probe |
| SwiftUI / UIKit / AppKit / iOS / macOS / AUv3 host code path | G — Swift native | View.pulpMotionTrace { Trace.* } / PulpMotionGeometryProbe |
Jetpack Compose or Android View code path | H — Android native | Modifier.pulpMotionGeometry { +Trace.* } / View.pulpMotionTrace |
| Imported design + intent doc (e.g. "fade in 350 ms ease-out") | Both A + C | Record a fixture from the import, assert timing / monotonicity |
The runtime path attaches a trace at runtime over the inspector wire, no source instrumentation, no cleanup phase to forget.
Translate "looks off" into a metric and a target. Example mappings:
opacity scalar, settling time > X msframe geometry, final minY mismatchframe traces, deltas correlatePULP_MOTION_SERVER=1 ./build/examples/ui-preview/pulp-ui-preview
# Motion inspector listening on port 9147
Three discovery surfaces — pick whichever is at hand. All three terminate
at the same Coordinator.
3a. pulp motion * CLI (terminal, one verb per Motion.* method,
prints the trace_id so you can copy it into motion stop):
pulp motion record --view Card --fps 30 --out card-fade.jsonl
# → trace started — trace_id=1
# → stop with: pulp motion stop --trace-id 1
Every verb honours --port N and $PULP_INSPECTOR_PORT, exits 1
with a clear "start the host with PULP_MOTION_SERVER=1 …" hint
when nothing is listening, and supports --json for raw inspector
output. Full list: record / stop / snapshot / list-traces / load-fixture / scrub / play / pause / cost {enable|disable}.
3b. Raw inspector wire (TCP port 9147, length-prefix-framed JSON):
{ "id": 1, "method": "Motion.startTrace",
"params": {
"view_name": "Card",
"fps": 30,
"metrics": [{
"kind": "geometry",
"name": "frame",
"node_id": "card",
"properties": ["minX","minY","width","height"],
"space": "window",
"source": "presentation"
}]
}
}
Response: { "trace_id": 1 }.
3c. pulp_motion_* MCP wrapper tools — same payload shape as
the wire path, called as a tools/call JSON-RPC request when an
agent is driving an MCP server (see
docs/guides/motion-observability.md Tooling section).
Stream of events: Motion.start, Motion.sample (one per change), Motion.end
(with deltas).
Drive the app — click, hover, type, fire whatever causes the suspected animation. The motion server emits events as the values change.
pulp motion stop --trace-id 1
# → trace stopped (removed=true)
Or the raw wire-protocol equivalent:
{ "id": 2, "method": "Motion.stopTrace", "params": { "trace_id": 1 } }
Did the trace match? Use the dedicated assertion helpers — do not conflate continuity, monotonicity, settling time, and overshoot into a single number.
auto samples = motion::extract_scalar(events, "Card", "frame", "minY");
REQUIRE(motion::is_monotonic(samples));
REQUIRE(motion::settling_time_seconds(samples) < 0.7);
REQUIRE(motion::overshoot(samples) < 0.05);
REQUIRE(motion::final_value(samples) == Catch::Approx(120.0).margin(1.0));
// Local-step outlier check is a *separate* axis from monotonicity /
// timing — flags one-frame jumps (5x = "this one step is 5x larger
// than the median of its neighbors"). Use it alongside, not in place
// of, the helpers above.
REQUIRE(motion::local_step_outlier_ratio(samples) < 3.0);
Use when no scalar is observable (transitions, GPU filters, mask compositing, opacity-only effects you suspect aren't rendering).
Any source: a host window-capture loop, an ffmpeg extraction from a screen
recording, scripted PNG dumps. Land sequential frames in a directory:
captures/card-open/
frame_0000.png
frame_0001.png
…
pip install -r tools/motion/visual/requirements.txt
python3 -m tools.motion.visual.analyze_sequence \
--frames-dir ./captures/card-open/ \
--output ./reports/card-open/ \
--keyframes 2
# Recommended flags for design / motion review:
python3 -m tools.motion.visual.analyze_sequence \
--frames-dir ./captures/card-open/ \
--output ./reports/card-open/ \
--grid # alphanumeric cell overlay (A1..H12) on
# frames/diffs/sprite so claims can cite a cell
--trim # drop idle prefix + suffix from the analysis
# window (frames stay on disk)
--affine # estimate translation/rotation/scale first→last
# (uses opencv if installed, else PIL-FFT
# translation only — see requirements-optional.txt)
Tunables: --grid-rows N (default 8, A..Z), --grid-cols N (default 12),
--grid-theme auto|light|dark (default auto — samples corner luminance),
--trim-threshold (default 0.01 mean-diff luminance fraction).
When you don't already have a frame directory, capture one with the gated helper. It only starts saving frames once real motion appears, so a short pre-roll doesn't pollute the analysis window.
# macOS window region (requires --bounds X,Y,W,H)
python3 tools/motion/visual/capture_sim_frames.py \
--source macos --bounds 0,0,800,600 \
--output-dir ./captures/card-open/ \
--fps 30 --frame-count 60 \
--gate-threshold 4.0 --gate-consecutive 1 \
--idle-timeout 8
# Booted iOS Simulator
python3 tools/motion/visual/capture_sim_frames.py \
--source simulator \
--output-dir ./captures/card-open/ \
--fps 30 --frame-count 60
The capture tool exits 3 (ctest SKIP) when neither screencapture
nor a booted simulator is available, so this composes cleanly with
CI lanes that lack the platform tooling.
The pipeline writes analysis.json (machine-readable), summary.md
(agent-readable), diff/diff_NNNN_NNNN.png (pairwise heatmaps), and
keyframes.png (sprite). With --grid: grid/frame_NNNN.png,
diff_grid/diff_NNNN_NNNN.png, and keyframes_grid.png siblings.
With --affine: an analysis.json#affine_first_to_last block and a
## Net motion section in summary.md. With --trim:
summary.trimmed_leading_frames / summary.trimmed_trailing_frames.
The JSON carries schema_version — refuse unknown versions.
Claim-evidence contract. Every claim you make from this report
must cite: (1) pair index (NN→NN+1), (2) artifact type (frames/,
diff/, grid/, diff_grid/, keyframes.png, or
affine_first_to_last), and (3) a confidence score 0.0..1.0 from
pairs[].confidence / summary.mean_confidence. Confidence < 0.7
means the analyzer is unsure — escalate by re-running with
--max-diff-frames 0 (all pairs), a longer capture window, or fall
back to a runtime trace (Path A) if instrumentation is possible. The
summary.md preamble carries the same contract verbatim.
A fixture is the on-disk form of a motion stream — a versioned JSONL file. Use fixtures to:
auto sink = motion::make_fixture_sink("test/motion/goldens/card-open.motion.jsonl");
int sid = Coordinator::instance().add_sink(std::move(sink));
// ... run the animation ...
Coordinator::instance().remove_sink(sid); // closes the file
std::vector<motion::SampleEvent> replayed;
motion::replay_fixture("captures/card-open.motion.jsonl",
motion::make_buffer_sink(&replayed));
auto golden = motion::load_fixture("test/motion/goldens/card-open.motion.jsonl");
auto captured = motion::load_fixture("/tmp/run.motion.jsonl");
auto diff = motion::assert_matches(golden, captured);
REQUIRE(diff.matches()); // or inspect diff.differences on failure
FixtureMatchOptions { component_epsilon, timing_epsilon_seconds, require_same_event_count } controls tolerances.
When the bug is "what the user did caused the wrong motion", record the interaction alongside the motion stream so a fresh tree can replay the same sequence deterministically.
// Recording — paired with whatever motion sinks you already have.
{
auto recorder = motion::make_input_recorder("/tmp/card-open.motion.jsonl");
root.simulate_hover({150, 150});
clock.tick(1.0f / 60.0f);
root.simulate_click({150, 150});
// ... drive your animation ...
} // RAII: destructor closes the sink + flips recording off.
// Replay against a fresh tree on a fresh FrameClock.
motion::replay_inputs("/tmp/card-open.motion.jsonl", fresh_root, fresh_clock);
make_input_recorder(path) installs a make_fixture_sink(path) AND flips
the process-wide input_recording_enabled() flag. View::simulate_* checks
that flag (a single relaxed atomic load, off by default) and emits a
SampleEvent::Kind::Input carrying the input_kind ("click" / "drag" /
"hover"), the recorded target's View::id(), and the root-space coords on
the existing components map (sorted by name: x/y for click+hover;
start_x/start_y/end_x/end_y/steps for drag).
replay_inputs(path, root, clock):
Input event in fixture order.clock to match the recorded t_seconds (first input anchors,
subsequent inputs tick by the delta).root (not the recorded view_id — root
coords with hit_test land on the same descendant).The motion stream that emerges — when paired with the same animation
primitives — matches the originally-recorded one within
FixtureMatchOptions::timing_epsilon_seconds. Use the ID-keyed
assert_matches for the comparison so reordered identical bursts don't
false-fail.
pulp::inspect::MotionScrubber loads a .motion.jsonl fixture and
re-emits the prefix of events with frame <= playhead to caller
sinks and (when attached to an InspectorServer) to inspector clients
over the wire. The scrubber is passive — no clock is pumped, no
animation runs live; play() is a jump-to-end that emits every event.
Real-time pacing and live overlay drawing are intentionally Phase 11+.
Protocol surface (routed by DomainHandler::handle_motion):
| Method | Params | Response |
|---|---|---|
Motion.loadFixture | { path } | { ok, event_count, max_frame, header: {version,policy,duration_scale} } |
Motion.scrubTo | { frame } | { playhead_frame, emitted_count } + broadcast events |
Motion.play | {} | { playing:true, emitted_count, playhead_frame } |
Motion.pause | {} | { playing:false, playhead_frame } |
Broadcast events reuse MotionInspector's Motion.start / .sample / .end shape, with an additional "replay":true marker so clients can
distinguish replayed bursts from live coordinator events on the same
wire.
CLI shortcut (no inspector REPL needed):
pulp motion load-fixture captures/card-open.motion.jsonl
pulp motion scrub 120
pulp motion play
pulp motion pause
Direct C++ usage:
pulp::inspect::MotionScrubber scrub(/*server=*/nullptr);
std::vector<motion::SampleEvent> buf;
scrub.add_sink(motion::make_buffer_sink(&buf));
scrub.load_fixture("captures/card-open.motion.jsonl");
scrub.scrub_to(120); // emits prefix with frame <= 120
scrub.scrub_to(0); // emits only the frame-0 prefix (backwards scrub)
scrub.play(); // jump to max frame, emit everything
Gotchas:
load_fixture is passive. Sinks see no events until scrub_to /
play is called. Don't pre-clear UI overlays on loadFixture and
expect a refill — wait for the first scrub_to.t_seconds) are the recording's
timestamps, not wall clock. Don't drive a live clock from them.When the question is "which animation is expensive and why?", switch
the cost channel on. It's off by default and runs on a separate stream
from the fixture format — cost samples don't appear in *.motion.jsonl.
#include <pulp/view/motion_cost.hpp>
#include <pulp/view/motion_cost_render.hpp>
auto& cost = pulp::view::motion::CostAttributor::instance();
cost.set_enabled(true);
// Optional but recommended: surface real render stats. Pointers may be
// null — defensive degradation returns 0 for the missing field.
cost.set_probe(pulp::view::motion::make_render_cost_probe(
&render_pass_manager, &dirty_tracker));
// Sink: JSONL on disk for later analysis…
cost.add_sink(pulp::view::motion::make_cost_sink("/tmp/run.motion-cost.jsonl"));
// …or a buffer for in-test assertions:
std::vector<pulp::view::motion::CostSample> samples;
cost.add_sink(pulp::view::motion::make_cost_buffer_sink(&samples));
Each frame, the Coordinator's tick now emits one CostSample per active
sink with:
frame, t_secondsrender_pass_duration_ms — from RenderPassManager::total_time_ms()dirty_rect_area_px, dirty_rect_count — from DirtyTracker::dirty_rects()active_trace_ids — every trace_id that emitted on this frameactive_provenance — Phase 9 envelopes for those traces, in the same
order, so a reader can answer "this 12ms pass came from
figma:LevelMeter/Panel (source_kind=design-import)" without
cross-referencing the event fixture.Motion.enableCost / Motion.disableCost toggle the channel; while
enabled, Motion.cost events broadcast per frame. Motion.snapshot
also reports cost_enabled and cost_samples_emitted.
CLI shortcut:
pulp motion cost enable
# ... drive the suspect animation ...
pulp motion cost disable
pulp motion snapshot --json | jq '.cost_samples_emitted'
*.motion-cost.jsonl) with
its own version header ({"motion_cost_version":1}). Do not confuse
with the fixture schema — they're independent.When the suspect motion lives in a pure SwiftUI / UIKit / AppKit code
path — an iOS AUv3 editor, a Swift host app, anything wired through
apple/Sources/PulpSwift/ — use the Swift facade. Samples flow into
the same motion::Coordinator as the JS-bridge and design-import
paths, so fixtures, scrubber, cost attribution, reduced-motion gating,
and provenance envelopes all work identically.
import PulpSwift
import SwiftUI
struct CardView: View {
@State var opacity: Double = 1
var body: some View {
Rectangle()
.opacity(opacity)
.pulpMotionTrace("Card") {
Trace.value("opacity", opacity)
Trace.geometry("frame",
properties: [.minX, .minY, .width, .height])
Trace.scrollGeometry("scroll")
}
}
}
The pulpMotionTrace(_:fps:_:) modifier:
source_kind="swiftui" /
source_id=<view name> provenance on onAppear.GeometryReader that pushes every new
global-space frame into pulp_motion_update_geometry.onDisappear (RAII).PulpMotion.isTracingEnabled is false. No
registration, no probe, zero cost beyond a SwiftUI background view.PulpMotion.publishValue(view: "Card", metric: "opacity", value: 0.5)
PulpMotion.publishComponents(view: "Card", metric: "frame",
components: [("x", x), ("y", y)])
Both are guarded by the backend's isTracingEnabled() so they cost a
single branch when motion is off.
final class CardUIView: UIView {
private let probe = PulpMotionGeometryProbe(view: "Card")
override func layoutSubviews() {
super.layoutSubviews()
probe.update(minX: frame.minX, minY: frame.minY,
width: frame.width, height: frame.height)
}
// deinit auto-detaches.
}
PulpSwift is a pure-Swift package; the C bridge lives in
apple/Sources/PulpSwift/PulpBridge.cpp (excluded from SwiftPM and
linked by the AUv3 / standalone host). At launch the host installs a
PulpMotionBackend that forwards into the C ABI:
var backend = PulpMotionBackend()
backend.isTracingEnabled = { pulp_motion_tracing_enabled() }
backend.publishValue = { v, m, val, eps, p in
v.withCString { vc in m.withCString { mc in
pulp_motion_publish_value(vc, mc, val, eps, Int32(p))
}}
}
// …same shape for publishComponents, ambient provenance,
// registerGeometryTrace, updateGeometry, detachTrace.
PulpMotionRuntime.installBackend(backend)
In unit tests, install a recording backend instead — swift test --package-path apple runs the facade with no C++ host linked.
For the C bridge itself, pulp-test-motion-swift-bridge covers the
pulp_motion_* ABI plus invalid state-buffer guards; run it whenever
apple/Sources/PulpSwift/PulpBridge.cpp changes.
Every Swift entry point is a no-op when the process-wide Coordinator
has tracing disabled. The C bridge double-checks
(pulp_motion_tracing_enabled()) so even a misconfigured Swift caller
cannot spam events in production.
apple/Sources/PulpSwift/PulpBridge.h — C ABI (publish + provenance
apple/Sources/PulpSwift/PulpBridge.cpp — bridge to
pulp::view::motion. Internal mutex-protected registry keeps the
TraceHandle separate from the lambda-captured atomic rect so
Coordinator::reset() cannot self-deadlock.apple/Sources/PulpSwift/PulpMotion.swift — PulpMotion facade,
Trace.* factories, MotionGeometryProperty,
@MotionTraceBuilder, PulpMotionBackend / PulpMotionRuntime.apple/Sources/PulpSwift/PulpMotionProbe.swift — SwiftUI
pulpMotionTrace(_:) modifier + UIKit / AppKit
PulpMotionGeometryProbe.apple/Tests/PulpSwiftTests/PulpMotionTests.swift — Swift facade
XCTest coverage.test/test_motion_swift_bridge.cpp — Catch2 round-trip test for the
C ABI shims and Coordinator integration.When the suspect motion lives in a Kotlin / Compose / Android View
code path — a Pulp Android app screen, a TalkBack overlay, a
SurfaceView driver — use the Kotlin facade. Samples flow into the
same motion::Coordinator as the Swift, JS-bridge, and design-import
paths, so fixtures, scrubber, cost attribution, reduced-motion
gating, and provenance envelopes all work identically. The Android
bridge is the platform sibling of Path G and follows the same shape
(C ABI + closure-bag backend seam + probe wrapper).
import androidx.compose.ui.Modifier
import com.pulp.motion.PulpMotion
import com.pulp.motion.Trace
import com.pulp.motion.pulpMotionGeometry
Box(
Modifier
.size(120.dp)
.pulpMotionGeometry("Card") {
+Trace.value("opacity", opacity.toDouble())
+Trace.geometry("frame",
properties = listOf(
MotionGeometryProperty.minX,
MotionGeometryProperty.minY,
MotionGeometryProperty.width,
MotionGeometryProperty.height,
))
}
) { ... }
The Modifier.pulpMotionGeometry(name, fps) modifier:
source_kind="android"
provenance (set on the C bridge).boundsInWindow() deltas from onGloballyPositioned into
pulp_motion_update_geometry.DisposableEffect.onDispose (composition exit).PulpMotion.isTracingEnabled is false. Zero
Compose-side cost beyond a single branch in composed { }.For non-Compose UIs (XML layouts, SurfaceView, custom Views):
class CardView(context: Context) : View(context) {
private val probe = pulpMotionTrace("Card") {
+Trace.geometry("frame")
}
}
View.pulpMotionTrace(name, fps) { ... } returns a
PulpMotionGeometryProbe? — null when tracing is disabled, an
AutoCloseable handle otherwise. The probe installs a
ViewTreeObserver.OnPreDrawListener (NOT OnGlobalLayoutListener —
PreDraw catches intra-frame scroll/translation GlobalLayout misses)
and pushes window-space rects via getLocationInWindow(). Auto-
detaches when the host View is removed from the window.
PulpMotion.publishValue(view = "Card", metric = "opacity", value = 0.5)
PulpMotion.publishComponents(
view = "Card",
metric = "frame",
components = rect.toMotionComponents(),
)
Both short-circuit on PulpMotion.isTracingEnabled so they cost a
single branch when motion is off.
PulpMotion.withProvenance(kind = "android", id = "CardView") {
PulpMotion.publishValue(view = "Card", metric = "opacity", value = 1.0)
}
withProvenance is single-threaded by design — the process-wide
ambient slot is not coroutine-safe. Do not call from suspending code
that may switch dispatchers inside the block. The Swift bridge ships
the same constraint; this is a deliberate limit, not a TODO.
PulpApplication.onCreate, after System.loadLibrary("pulp"):
if (nativeLoaded) {
com.pulp.motion.PulpMotion.installNativeBackend()
}
installNativeBackend() wires every PulpMotionBackend closure to
the matching external fun on the internal PulpMotionNative
object. The C bridge double-checks
motion::Coordinator::tracing_enabled() so a misconfigured backend
cannot spam events in production.
The closure-bag backend lets gradle test exercise the facade
without ever loading libpulp.so:
val recorder = RecorderBackend()
PulpMotionRuntime.installBackend(recorder.asBackend())
PulpMotion.publishValue("Card", "opacity", 0.5)
assertEquals(1, recorder.publishedValues.size)
See android/app/src/test/kotlin/com/pulp/motion/PulpMotionTest.kt
for the full pattern.
Every Kotlin entry point is a no-op when the process-wide Coordinator
has tracing disabled. The C bridge re-checks
pulp_motion_tracing_enabled() so even a misconfigured caller
cannot spam events in production. The View probe extension returns
null immediately when tracing is off — no listener installed, no
allocation.
core/platform/include/pulp/platform/android/motion_bridge.h — C
ABI declarations the JNI shims forward into (also pulled by the
Catch2 host test).core/platform/src/android/jni_motion.cpp — bridge implementation:
C ABI + Java_com_pulp_motion_PulpMotionNative_* JNI shims.
Internal mutex-protected registry keeps the TraceHandle
separate from the lambda-captured atomic rect so
Coordinator::reset() cannot self-deadlock — same fix shape as
the Swift bridge.android/app/src/main/kotlin/com/pulp/motion/PulpMotionNative.kt —
internal object with external fun JNI declarations.android/app/src/main/kotlin/com/pulp/motion/PulpMotionBackend.kt —
closure-bag backend + PulpMotionRuntime.installBackend(...).android/app/src/main/kotlin/com/pulp/motion/PulpMotion.kt —
public facade (publishValue, publishComponents,
setAmbientProvenance, withProvenance, register / update /
detach, installNativeBackend()).android/app/src/main/kotlin/com/pulp/motion/Trace.kt — Trace.*
factories, MotionGeometryProperty, @MotionTraceBuilder
motionTrace { } block, RectF/Rect.toMotionComponents().android/app/src/main/kotlin/com/pulp/motion/MotionProbe.kt —
View.pulpMotionTrace(...) extension + PulpMotionGeometryProbe
(AutoCloseable).android/app/src/main/kotlin/com/pulp/motion/MotionCompose.kt —
Modifier.pulpMotionGeometry(...) modifier + pulpMotionPublish(...)
composable.android/app/src/test/kotlin/com/pulp/motion/PulpMotionTest.kt —
JVM facade unit tests (JUnit4 only, no Mockito, no native lib).test/test_motion_android_bridge.cpp — Catch2 round-trip test for
the C ABI + the Coordinator::reset() deadlock regression.Apply these on every motion debugging run:
pulp-ui-preview)| Variable | Effect |
|---|---|
PULP_MOTION_LOG=1 | Install the default log sink + enable tracing |
PULP_MOTION_SERVER=1 | Start the Motion inspector server on port 9147 |
PULP_MOTION_FIREHOSE=1 | Broadcast every publish_* call to all sinks |
core/view/include/pulp/view/motion.hpp — public C++ APIcore/view/src/motion.cpp — Coordinator, geometry walker, fixture I/O, assertionscore/view/include/pulp/view/motion_cost.hpp — CostSample / CostAttributor / cost JSONLcore/view/include/pulp/view/motion_cost_render.hpp — make_render_cost_probe bridgecore/view/src/motion_cost.cpp — attributor singleton + sinks + JSONL loadcore/view/include/pulp/view/motion_preferences.hpp — reduced-motion policy + duration_scalecore/view/src/motion_preferences.cpp — singleton + override + OS readerscore/view/platform/mac/motion_preferences_mac.mm — NSWorkspace reduced-motion querycore/view/platform/win/motion_preferences_win.cpp — SPI_GETCLIENTAREAANIMATION queryinspect/include/pulp/inspect/motion_inspector.hpp — Motion inspector bridgeinspect/src/motion_inspector.cpp — protocol handler + event broadcasterinspect/include/pulp/inspect/motion_scrubber.hpp — timeline scrubber (Phase 7)inspect/src/motion_scrubber.cpp — passive fixture replay + scrubber dispatchtools/motion/visual/analyze_sequence.py — visual analysis CLI (grid overlay, --trim, --affine, claim-evidence contract)tools/motion/visual/capture_sim_frames.py — motion-gated capture from macOS region or booted simulatortools/motion/visual/test_self_check.py — pipeline self-check (baseline + claim-evidence assertions)tools/motion/visual/test_grid_overlay.py — visual-plus self-check (grid, trim, affine)tools/motion/visual/test_capture_smoke.py — gated-capture smoke (skip 3 without source)tools/motion/visual/requirements-optional.txt — opt-in deps (opencv-python for full affine)examples/ui-preview/main.cpp — env-knob wiring for the standalone hostapple/Sources/PulpSwift/PulpBridge.h — Swift C ABI surface (Path G)apple/Sources/PulpSwift/PulpBridge.cpp — Swift bridge shims (Path G)apple/Sources/PulpSwift/PulpMotion.swift — Swift facade + Trace DSL (Path G)apple/Sources/PulpSwift/PulpMotionProbe.swift — SwiftUI / UIKit probe (Path G)core/platform/include/pulp/platform/android/motion_bridge.h — Android C ABI (Path H)core/platform/src/android/jni_motion.cpp — Android JNI bridge + C ABI (Path H)android/app/src/main/kotlin/com/pulp/motion/PulpMotion.kt — Kotlin facade (Path H)android/app/src/main/kotlin/com/pulp/motion/PulpMotionBackend.kt — closure-bag backend seam (Path H)android/app/src/main/kotlin/com/pulp/motion/PulpMotionNative.kt — internal JNI declarations (Path H)android/app/src/main/kotlin/com/pulp/motion/Trace.kt — Trace DSL + Rect helpers (Path H)android/app/src/main/kotlin/com/pulp/motion/MotionProbe.kt — View probe + AutoCloseable handle (Path H)android/app/src/main/kotlin/com/pulp/motion/MotionCompose.kt — Compose modifier (Path H)android/app/src/test/kotlin/com/pulp/motion/PulpMotionTest.kt — JVM facade unit tests (Path H)test/test_motion_android_bridge.cpp — Catch2 round-trip for the C ABI (Path H)docs/guides/motion-observability.md — full guideEvery trace can carry a Provenance envelope that flows through the fixture
to agents reading a golden weeks later:
auto handle = motion::Coordinator::instance()
.trace("Card", { 60 })
.with_provenance({ "tween", "Card.opacity", __FILE__, __LINE__ })
.value("opacity", [&]{ return opacity; })
.attach();
The envelope shows up on the trace's TraceStarted event and in the JSONL
fixture. When you read a fixture and the burst looks wrong, the provenance
tells you which file / Figma node / animator the trace was attached to —
without grepping.
Direct with_provenance(...) is the bedrock; Phase 9 added per-surface
shortcuts so common cases don't require hand-building an envelope:
t.set_motion_provenance("tween", "knob-hover"), then call
t.publish(view, metric) each tick. The macro
PULP_MOTION_TWEEN("knob-hover", from, to, duration) auto-fills
source_file / source_line from std::source_location::current()..name("knob-glow") on the builder; the resulting
Runner::publish(view, metric, value) stamps source_kind="animator-set",
source_id="knob-glow".parse_transition_shorthand_with_provenance(css, "/styles/card.css", line) carries source_file / source_line through;
CssAnimation::publish(view, metric) stamps
source_kind="css-transition", source_id=<property name>.WidgetBridge::load_script(code, "my-script.js") (or
set_active_script_id(...)) records the script id; __flushFrames__
sets the ambient envelope per callback so a motion.publishValue from
inside an rAF body emits source_kind="rAF",
source_id="my-script.js:<callback_id>".motion.publishValue(view, metric, value) and
motion.setProvenance(kind, id, file?, line?) are exposed on the
globalThis.motion object the bridge installs. motion.clearProvenance()
empties the slot. Explicit PublishOptions::provenance always wins over
the ambient slot.generate_pulp_js emits a motion.setProvenance(...)
line at the top of every bundle, tagged with vendor + root-node id
(figma:Card/Hover, stitch:Panel, claude:HeaderLayout, …). Drop the
generated JS into a bridge and any animation it drives inherits the
envelope automatically.pulp::view::MotionPreferences (sibling of AppearanceTracker) reads the
OS reduced-motion accessibility setting on first use and exposes it as a
MotionPolicy (Full / Reduced / Off) plus a clamped duration_scale
(0.0–2.0, default 1.0). Animation primitives honor the policy on start:
| Policy | Tween / ValueAnimation | CssAnimation | AnimatorSet |
|---|---|---|---|
| Full | Animate as configured | Animate as configured | Animate as configured |
| Reduced | Scale duration × duration_scale | Same | Same (via each Tween) |
| Off | Jump to target on tick 0 | Complete on first tick() | Each Tween starts finished |
Tests get a per-process override that wins over the OS value:
auto& prefs = pulp::view::MotionPreferences::instance();
prefs.set_override(pulp::view::MotionPolicy::Reduced);
prefs.set_duration_scale(0.5);
// …drive the animation…
prefs.reset_for_tests(); // clears override, re-reads OS
Fixtures recorded under a non-Full policy capture it on the v2 header:
{"motion_fixture_version":2,"policy":"reduced","duration_scale":0.5}
load_fixture_header(path) returns the policy + scale; the header-aware
assert_matches(g_hdr, g_events, c_hdr, c_events, opts) overload flags a
"policy-mismatch" diff item if the goldens were recorded under one policy
and the capture under another — no more silent comparisons of a Reduced
golden against a Full capture. policy / duration_scale are additive on
the header; v2 fixtures without them still load (defaulting to
policy="full", duration_scale=1.0).
Note: MotionPreferences is a sibling of AppearanceTracker, not a
subclass. Use the OS reader on the platform that matters (macOS: NSWorkspace
accessibilityDisplayShouldReduceMotion; Windows: SPI_GETCLIENTAREAANIMATION)
or set an override for deterministic tests.
TraceStarted is emitted once per trace registration on the first tick
after attach. Tests that count events should either filter it out or pass
through data_event_count(buffer).ViewGlobal for portable code.paint_all override does not apply base View transforms when
painting children. The presentation walker matches this quirk: a child of a
ScrollView with scale set on the ScrollView itself reports as if the scale
were not there. This is correct relative to what is painted.motion_fixture_version values. Bumping the
schema is a deliberate break — write a migration tool, do not silently
accept old goldens.epsilon is sticky on the first publish; subsequent
publishes inherit it. Pass PublishOptions only when you want to configure
the threshold for that key.MotionPolicy is captured at animation start (constructor / reset() /
animate_to() / first tick() for CssAnimation). Changing
MotionPreferences::set_override() partway through a running animation
does NOT retroactively re-scale it — the next animation that starts will
pick up the new policy.MotionPolicy::Off, a Tween-driven "publish until finished" loop
emits one final-value Sample and exits. That's the contract — Off is not
"no observability", it's "single snap" — assertions like is_monotonic
and final_value still work.MotionPreferences::instance().reset_for_tests() to
clear overrides between cases. Forgetting it leaks state into the next
test (and surprises CI when run with --shuffle).npx claudepluginhub danielraffel/pulp --plugin pulpDiagnose, fix, or validate SwiftUI animation and scroll bugs by instrumenting views with MotionEyes, capturing console traces via XcodeBuildMCP or CLI, and comparing motion data to expectations.
Audits and fixes animation performance issues: layout thrashing, compositor properties, scroll-linked motion, blur effects. Use when animations stutter or transitions jank.
Applies Disney's 12 animation principles to UI motion: easing curves, duration guidelines, choreography, and reduced-motion accessibility. Use when designing state changes, transitions, or motion systems.