A motion handoff record is a declaration, not the animation itself. That makes it a useful place to automate a narrow comparison without claiming to inspect pixels, SVG markup or playback. In this tutorial, a small JavaScript module reports which declared fields differ between two drafts. The result helps a reviewer ask a more precise question, but it does not decide whether the second draft is visually better.
I am associated with SVG Animation, and this article was prepared with AI assistance. The motivating browser workflow is described at https://svganimation.app/: retain editable vector source, refine motion and choose an export. The code below is an independent teaching example, not an official integration or a claim about the project's internal implementation. The cover is an original explanatory diagram, not a product screenshot or benchmark.
Decide what the comparison is allowed to say
The example compares a source identifier, a format label, a declared duration, a loop flag and a destination description. Those fields make a handoff easier to discuss because they preserve some of the assumptions behind an export. A change in duration can become a specific review question instead of being hidden in a vague statement that the motion was updated.
None of these fields proves that the corresponding artifact exists. A source identifier can be mistyped, a format label can describe the wrong file and a destination can change after the record is written. The comparison therefore reports differences in declarations. A later stage still needs to locate the actual artifact and inspect it in the intended placement. Keeping that distinction explicit prevents a convenient helper from acquiring a fictional testing scope.
Validate before reporting differences
Comparing malformed records can produce misleading rows. If one record contains a numeric duration and another contains a string with the same characters, the caller needs a clear policy rather than an accidental conversion. This module rejects the string. A separate input layer can normalize user-interface values, but that normalization should be documented and tested independently.
The allowed formats and duration bound are local teaching choices. The module accepts lowercase svg, gif and mp4 labels and an integer duration from one to six hundred thousand milliseconds. These are not universal limits imposed by an SVG specification or a browser. A reader adapting the helper should adjust the policy for a real workflow instead of presenting the example values as requirements that every motion asset must satisfy.
Keep the report order deterministic
A fixed field list gives the output a stable order. That matters when a report is copied into a review note, compared in a test or discussed by another person. The order follows the contract rather than the insertion order of whichever object happened to arrive first. The example does not alphabetically sort user data or pretend that one changed field is automatically more important than another.
The values in this contract are primitives, so Object.is is sufficient for the demonstrated comparison. Adding a nested array or object would require another explicit decision about comparison semantics. This example does not supply a general deep equality function. Expanding the record without revisiting that decision would make the helper appear more capable while leaving an important part of its behavior undefined.
The module
Required fields must be own properties. This avoids silently accepting a required value inherited from a prototype in the demonstrated object checks. Additional unrelated keys are ignored, which is also a deliberate boundary. The function is not a strict schema validator for every property that might appear in an input object.
export const FIELDS = Object.freeze([
"sourceId", "format", "durationMs", "loop", "destination",
]);
export function validateDraft(record) {
if (!record || typeof record !== "object" || Array.isArray(record)) {
throw new TypeError("draft must be an object");
}
for (const field of FIELDS) {
if (!Object.hasOwn(record, field)) {
throw new TypeError("missing own field: " + field);
}
}
for (const field of ["sourceId", "destination"]) {
if (typeof record[field] !== "string" || !record[field].trim()) {
throw new TypeError(field + " must be a nonempty string");
}
}
if (!["svg", "gif", "mp4"].includes(record.format)) {
throw new TypeError("unsupported local format label");
}
if (!Number.isSafeInteger(record.durationMs) ||
record.durationMs < 1 || record.durationMs > 600_000) {
throw new TypeError("durationMs outside the local integer policy");
}
if (typeof record.loop !== "boolean") {
throw new TypeError("loop must be a boolean");
}
return record;
}
export function compareDrafts(before, after) {
validateDraft(before);
validateDraft(after);
return FIELDS
.filter(field => !Object.is(before[field], after[field]))
.map(field => ({ field, before: before[field], after: after[field] }));
}
The module expects ordinary data objects. It does not sandbox getters, hostile JavaScript objects or code supplied by somebody else. It does not parse JSON text, bound a file read, authenticate the author of a record or sanitize an SVG. Those are separate responsibilities. Its narrow interface is useful precisely because a reader can inspect what it does without assuming a large collection of invisible protections.
Tests should expose the boundary
The fixtures cover unchanged declarations, an exact timing-change row, stable field order and the fact that the inputs are not mutated. They also exercise several invalid declarations. The accepted record is created fresh for each test so that a mutation in one case cannot quietly affect the next case and make a failure difficult to interpret.
The last fixture demonstrates that an extra note field is ignored. That is worth testing because a consumer might otherwise assume that every incoming property has been examined. A green result covers only the documented fields. If rejecting unknown keys becomes a requirement, add a dedicated rule and a corresponding test rather than relying on an implication the implementation never made.
import test from "node:test";
import assert from "node:assert/strict";
import { compareDrafts, FIELDS } from "./motion-diff.mjs";
const draft = () => ({
sourceId: "mark-r03",
format: "svg",
durationMs: 2400,
loop: true,
destination: "small help card",
});
test("unchanged declarations produce no rows", () => {
assert.deepEqual(compareDrafts(draft(), draft()), []);
});
test("one timing change has an exact row", () => {
assert.deepEqual(compareDrafts(draft(), { ...draft(), durationMs: 3000 }), [
{ field: "durationMs", before: 2400, after: 3000 },
]);
});
test("rows follow documented field order", () => {
const after = {
sourceId: "mark-r04", format: "mp4", durationMs: 3600,
loop: false, destination: "video card",
};
assert.deepEqual(compareDrafts(draft(), after).map(row => row.field), FIELDS);
});
test("inputs are not mutated", () => {
const before = draft(), after = { ...draft(), loop: false };
const snapshot = JSON.stringify([before, after]);
compareDrafts(before, after);
assert.equal(JSON.stringify([before, after]), snapshot);
});
test("null is rejected", () => assert.throws(() => compareDrafts(null, draft())));
test("array is rejected", () => assert.throws(() => compareDrafts([], draft())));
test("inherited required fields do not count", () => {
assert.throws(() => compareDrafts(Object.create(draft()), draft()));
});
test("numeric string is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), durationMs: "2400" }, draft()));
});
test("boolean duration is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), durationMs: true }, draft()));
});
test("NaN duration is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), durationMs: NaN }, draft()));
});
test("infinite duration is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), durationMs: Infinity }, draft()));
});
test("duration outside local bound is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), durationMs: 600001 }, draft()));
});
test("loop string is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), loop: "true" }, draft()));
});
test("unknown format is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), format: "jpeg" }, draft()));
});
test("blank destination is rejected", () => {
assert.throws(() => compareDrafts({ ...draft(), destination: " " }, draft()));
});
test("additional unrelated keys are ignored", () => {
assert.deepEqual(compareDrafts(draft(), { ...draft(), note: "unexamined" }), []);
});
I ran the sixteen local test methods successfully using Node's built-in test runner. This is a claim about these fixtures and this module only. No real exported animation, product backend, browser compatibility matrix or accessibility implementation was tested by that command. The file names below assume that both examples are saved together in one directory.
node --test motion-diff.test.mjs
Turn a changed row into a useful question
A duration row can prompt a reviewer to inspect the loop beginning and ending. A destination row can prompt a check of the new placement, including scale and background. A changed source identifier can prompt somebody to confirm that the delivered export actually came from the accepted revision. These are useful next actions, not automatic conclusions about the quality of the resulting graphic.
Avoid translating every changed row into a failure. A difference may be intentional and accepted. Equally, an empty report does not mean that two files are identical; the module has never opened either file. Describe the report as a structured reminder about declarations and keep the artifact-level observations in a separate part of the review record.
Keep rendering and accessibility checks separate
The loop flag is a declared boolean, not evidence of actual playback. A destination string is a description, not a test that the host accepts the export. Someone still needs to inspect the downloaded result where it will be used. That review can include readable text, the resting frame, the transition between repetitions and any reduced-motion or still alternative that the placement requires.
A metadata comparison cannot certify those experiences. It can make the handoff more explicit by showing which assumption changed and where a fresh observation is needed. Preserve that limited role in the interface and in the language around the output. Doing so makes it easier to reuse the helper without accidentally treating a small successful test suite as proof of a much larger system.
A small integration step
A caller can place the report beside a source identifier and a delivery filename in an existing review note. Start with a manual handoff before connecting the helper to a build or export workflow. That makes it easier to discover whether the chosen fields are actually useful, whether a destination description is precise enough and whether the stable order matches how reviewers discuss the work.
When the contract changes, update the validation policy, comparison semantics and fixtures together. Keep the old assumptions visible in version history rather than quietly expanding what an empty result is supposed to mean. The goal is a predictable conversation aid: report one narrow class of differences, preserve the accepted source and leave the remaining uncertainty with the people who can examine the real artifact.
Top comments (0)