feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import type { AnimationDefinition } from "../../src/animation/animation.types";
import {
AnimationValidationError,
buildAnimationCss,
validateAnimationDefinitions,
} from "../../src/animation/validation";
const animation: AnimationDefinition = {
id: "pulse",
name: "Pulse",
targetNodeKey: "id:shape",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0.25", easing: "cubic-bezier(0.2, 0, 0.8, 1)" },
{ offset: 1, value: "calc(1 - 0.1)" },
],
timing: {
durationMs: 500,
delayMs: -20,
iterations: 2.5,
direction: "alternate",
fillMode: "both",
easing: "steps(4, jump-end)",
},
};
function invalid(mutator: (value: AnimationDefinition) => void): () => void {
const value = structuredClone(animation);
mutator(value);
return () => validateAnimationDefinitions([value]);
}
describe("application-owned animation validation", () => {
it("builds source-safe CSS through the same validated serializer", () => {
const css = buildAnimationCss([animation], {
keyframeNamePrefix: "svg-tools",
selectorFor: () => "#shape",
});
expect(css).toContain("@keyframes svg-tools-0");
expect(css).toContain("#shape { animation: svg-tools-0 500ms");
expect(css).toContain(
"animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1)",
);
});
it.each([
[
"NaN offset",
invalid((value) => (value.keyframes[0]!.offset = Number.NaN)),
],
["large offset", invalid((value) => (value.keyframes[0]!.offset = 1.1))],
[
"infinite duration",
invalid((value) => (value.timing.durationMs = Number.POSITIVE_INFINITY)),
],
[
"infinite delay",
invalid((value) => (value.timing.delayMs = Number.NEGATIVE_INFINITY)),
],
[
"infinite iterations",
invalid((value) => (value.timing.iterations = Number.POSITIVE_INFINITY)),
],
["negative iterations", invalid((value) => (value.timing.iterations = -1))],
])("rejects non-finite or out-of-range timing: %s", (_label, action) => {
expect(action).toThrow(AnimationValidationError);
});
it.each([
["property", invalid((value) => (value.property = "opacity; stroke: red"))],
[
"declaration",
invalid((value) => (value.keyframes[0]!.value = "0; stroke: red")),
],
[
"closing style element",
invalid(
(value) =>
(value.keyframes[0]!.value = "0</style><script>alert(1)</script>"),
),
],
[
"external URL",
invalid(
(value) =>
(value.keyframes[0]!.value = "url(https://attacker.invalid/a.svg)"),
),
],
[
"escaped external URL",
invalid(
(value) =>
(value.keyframes[0]!.value = String.raw`u\72 l("https://attacker.invalid/a.svg")`),
),
],
[
"timing easing",
invalid((value) => (value.timing.easing = "linear; stroke: red")),
],
[
"keyframe easing",
invalid(
(value) => (value.keyframes[0]!.easing = "linear } body { color:red"),
),
],
])("rejects CSS injection through %s", (_label, action) => {
expect(action).toThrow(AnimationValidationError);
});
it("validates disabled definitions instead of retaining dormant payloads", () => {
const value = structuredClone(animation);
value.enabled = false;
value.keyframes[0]!.value = "url(https://attacker.invalid/payload)";
expect(() =>
buildAnimationCss([value], {
keyframeNamePrefix: "svg-tools",
selectorFor: () => "#shape",
}),
).toThrow(AnimationValidationError);
});
});