# PureAnim scripting reference **Status:** Live in the editor (Edit → Scripting). V1 + Phase A + Phase B + canvas/text/shapes, 118 commands. **API version:** 1 **Editor:** https://flekta.app/app **This file:** https://flekta.app/scripting.md --- ## For AI assistants If you were pointed at this file to write a PureAnim script, this document is self-contained — everything you need is below. Follow these rules: > **Controlling the editor yourself, through a browser?** Read [agents.md](https://flekta.app/agents.md) as well — it covers loading a document, opening the console, which elements to click, and how to read results back. 1. **Never invent a command.** Only use commands listed in §5. If the operation you need isn't there, say so and suggest the closest one that exists. 2. **Read §3 (units) before writing anything.** Wrong units are the most common failure, and they fail loudly. 3. **Start every script by inspecting.** Layer names in an imported SVG are almost never what you'd guess. Call `api.listLayers()` first, and `console.log` it — that output comes back to you in the console (§2). 4. **Sections 6–8 are not optional.** A script can be API-correct and still produce animation that looks wrong. Timing and easing are the difference. --- ## 1. What this is PureAnim is a visual SVG animation editor that exports CSS `@keyframes`, animated SVG, and Lottie. The **script console** lets you author animation with code instead of clicking — useful for staggered lists, applying one change across many layers, data-driven motion, and anything repetitive. Open it in the editor at **Edit → Scripting**, paste a script, press **Run**. --- ## 2. Execution model Your script body receives one argument, `api`: ```js api.setProperty({ layer: "icon", prop: "opacity", value: "0", at: 0 }); api.setProperty({ layer: "icon", prop: "opacity", value: "1", at: 100 }); ``` Three things to know, all of which affect how you write scripts: - **Writes are applied together at the end.** If the script throws, nothing is applied — your document is untouched. There is no half-applied state. - **The whole script is one undo step.** `⌘Z` / `Ctrl+Z` reverses all of it. - **Reads see your earlier writes**, so read-then-write loops behave as expected. - **A script that changes nothing is not committed** and adds no undo entry, so inspecting is free. ### Reading results back `console.log` (and `info` / `debug` / `warn` / `error`) prints into the console, below the editor — not to devtools. Objects are formatted as JSON, so inspection commands are readable: ```js console.log(api.listLayers()); console.log(api.getPropertyAt({ layer: "bell", prop: "rotate", at: 50 })); ``` **Use it before you author.** Layer names in an imported SVG are rarely what you'd guess, and a log is the only way to see what a read command returned. The log is also printed when the script throws, so logging before a failing line tells you what the script actually saw. Limits: 200 lines, 1,000 characters per line. Beyond that the log is truncated. Scripts run in the page. Direct access to `fetch`, `document`, `window`, `localStorage`, `indexedDB`, and similar host globals is blocked, and those identifiers (plus `constructor` / `eval` / `import`) are rejected before the script runs. **This is a speed bump, not a sandbox.** A determined script can still reach the host. Only run scripts you wrote or fully trust. Sharing, import-from-URL, and project-embedded scripts need a real iframe sandbox and are not supported. A script may call at most **50,000 commands** or run for **5 seconds** — the budget only increments inside `api.*` calls. A `while (true)` with no API calls hangs the tab. Every command is also reachable as `api.run("set_property", { … })`. Use that only for commands without a bound method. Bound methods return the command's **result payload** (the read value, or a small write receipt). Failures throw. --- ## 3. Units and conventions Get these wrong and the command is rejected. | Concept | Rule | |---|---| | **Time** | `at` is a **percentage, 0–100, as a number**. Not seconds, not frames, not `"50%"`. | | **Values** | **Strings with units**: `"10px"`, `"45deg"`, `"0.5"`, `"#ff0044"`. A bare `10` is rejected. | | **Layers** | Addressed by their `data-layer` name. Get real names from `api.listLayers()`. | | **Keyframes** | Addressed by `keyframeId` (`"kf-…"`), with `at` as a fallback. | | **Rounding** | Percentages round to whole numbers — `at: 33.7` becomes `34`. | Because time is a percentage, **scripts are duration-independent**: the same script produces the same motion on a 1-second and a 6-second animation. Need real time? `api.convertTime({ at: 50 })` returns `{ at, seconds, frame }`. You can also convert the other way: `api.convertTime({ seconds: 2 })` or `api.convertTime({ frame: 12, fps: 24 })`. `fps` defaults to 30. --- ## 4. Property channels ``` transformX transformY rotate scale scaleX scaleY skewX skewY transformOrigin bboxMode opacity blur strokeDasharray strokeDashoffset strokeWidth fill stroke fillOpacity strokeOpacity vectorEffect paintOrder pathMorph ``` | Channel | Value form | |---|---| | `transformX`, `transformY`, `blur`, `strokeWidth`, `strokeDashoffset` | `"10px"` | | `rotate`, `skewX`, `skewY` | `"45deg"` | | `scale`, `scaleX`, `scaleY` | `"1.2"` (unitless string; negative flips) | | `opacity`, `fillOpacity`, `strokeOpacity` | `"0"` to `"1"` | | `strokeDasharray` | dash list string, e.g. `"8 4"` | | `fill`, `stroke` | `"#rrggbb"`, a named CSS color, `"none"`, or `"transparent"` | | `transformOrigin` | `"center center"`, `"top left"`, `"top center"`, `"top right"`, `"center left"`, `"center right"`, `"bottom left"`, `"bottom center"`, `"bottom right"` | | `bboxMode` | `"static"` (Fixed — rest-pose bbox) or `"dynamic"` (Follow content). Groups only; layer-held, not a track. | | `vectorEffect` | `"none"` (scale with the layer) or `"non-scaling-stroke"` | | `paintOrder` | `"fill stroke"` or `"stroke fill"` | | `pathMorph` | pose `{ v, i, o, c }`. `v` / `i` / `o` are `[x, y]` lists of the same length (at least one point). `i` and `o` are tangents relative to the vertex. `c` is closed (`true`) or open (`false`). Every key on the layer must share that count and that open/closed flag. Lottie export only — CSS does not animate `d`. Not linkable, and repeater Offset mode does not apply. Writes fail until `enablePathMorph` has converted the leaf. | **Composition layers accept only transforms, opacity, and blur.** Group layers (``) also accept `bboxMode`. When unsure, call `api.listPropertyChannels({ layer })` rather than writing and failing. --- ## 5. Command reference ### Reading ```js api.getProjectSummary(); // { duration, layerCount, keyframeCount, markerCount, fps: 30 } // fps is always 30 — the project has no stored frame rate. api.listLayers(); // [{ id, label, type, visible, locked, color, keyCount }] api.getLayer({ layer }); api.getLayerTree(); // nested parent/child + z-order api.listPropertyChannels({ layer }); // valid prop keys for that layer type api.validateProject(); // { errors, warnings } api.listKeyframes({ layer }); // authored bags: { id, percentage, properties } api.listKeyframes({ layer, prop }); // that channel's diamonds only api.getKeyframe({ layer, keyframeId }); // or { layer, at } // { id, percentage, properties, generatedBy } api.getPropertyAt({ layer, prop, at }); // { prop, at, value } — interpolated. Compiles with the editor's // curve-sampling pref (localStorage). For a deterministic read use // getCompiledTimeline({ layer, stepPx }) instead. api.convertTime({ at }); // { at, seconds, frame } api.convertTime({ seconds }); // same shape; fps defaults to 30 api.convertTime({ frame, fps }); api.getCompiledTimeline(); // { layers: [{ name, keyCount, authoredCount, generatedCount, hasCurve, sourceHash }], revision } api.getCompiledTimeline({ layer, stepPx }); // + { compiled } for that layer (incl. generated tiles) ``` `getCompiledTimeline` is one full compile per call. `stepPx` defaults to 10 and never reads localStorage. Unscoped: per-layer summary only. With `layer`: that layer's full compiled bags. Pivots are transform-based, not measured visual pivots. ### Properties and keyframes ```js api.setProperty({ layer, prop, value, at }); api.setProperties({ layer, at, properties: { transformY: "0px", opacity: "1" } }); api.setPropertiesBatch({ layers: ["a", "b"], at, properties }); api.holdProperty({ layer, prop, value, from, to }); api.nudgeProperty({ layer, prop, delta, at }); // or layers[]; optional stepPx api.copyPropertyKeys({ fromLayer, toLayer, prop }); api.clearProperty({ layer, prop }); // removes that channel from every bag api.enablePathMorph({ layer }); // Converts a path, polygon, polyline, circle, ellipse, rect, or line into // one , reveals the Path track, and seeds a 0% pose. Groups, text, // and images fail. Safe to treat as the required first step before any // pathMorph write. api.setMorphFirstVertex({ layer, at, index }); // that key only; index becomes vertex 0 api.reverseMorphPath({ layer, at }); // that key only; swaps in/out tangents api.deleteMorphVertex({ layer, index }); // same index on every morph key api.setProperty({ layer, prop: "pathMorph", at: 100, value: { v: [[0, 0], [40, 0], [40, 20], [0, 20]], i: [[0, 0], [0, 0], [0, 0], [0, 0]], o: [[0, 0], [0, 0], [0, 0], [0, 0]], c: true, }, }); api.createKeyframe({ layer, at, properties }); api.createPropertyKey({ layer, prop, at, value }); // same write path as setProperty api.updateKeyframe({ layer, keyframeId, properties }); // or { layer, at, properties } api.deletePropertyKey({ layer, prop, at }); // or keyframeId api.deleteKeyframe({ layer, keyframeId }); // or { layer, at } api.moveKeyframe({ layer, keyframeId, to }); // to is a percentage; optional prop api.moveKeyframes({ layer, ids, delta }); // or toMap: { [id]: pct } api.clearLayerKeyframes({ layer, confirm: true }); api.copyKeyframes({ layer }); // whole layer if ids omitted api.copyKeyframes({ layer, ids }); // returns a payload — not the system clipboard api.pasteKeyframes({ layer, clipboard, at }); // at shifts the payload so the first key lands there api.findKeyframe({ layer, from, direction: "next" }); // or "prev"; optional prop // { keyframeId, at } or { keyframeId: null, at: null } ``` Batch writes take an explicit `layers[]`. Selection is UI-only and never required. `keyframeId` (`"kf-…"`) is preferred; `at` is the fallback when the id is unknown. ### Relative transforms Deltas against the interpolated pose at `at`. Unauthored channels start from identity (`0px` / `0deg` / `1`). `factor` on scale is multiplicative; the others are additive. Values round to 2 decimal places — multiplicative scale is not exactly invertible. `from_center` / `origin` are not accepted: `transformOrigin` is layer-held, not per-time. ```js api.applyTranslate({ layer, dx, dy, at }); // or layers[]; optional stepPx api.applyScale({ layer, factor: 1.2, at }); // or factor: { x, y } api.applyRotate({ layer, degrees: 15, at }); api.applySkew({ layer, x: 5, y: 0, at }); // or { skew: { x, y } } / skewX / skewY api.applyBurst({ layers, direction, distance, at }); // optional markerId links transformX/Y ``` A parent plus its child in `layers[]` applies once, to the root. Zero deltas / `factor: 1` are no-ops. `factor` must be a real number — `null`, `""` and `[]` are rejected rather than coerced to a layer-flattening `0`. `stepPx` (default 10) is the motion-path densify step for the pose compile; it never reads localStorage. `applyBurst` needs at least two layers. Centers come from SVG geometry attrs (rect / circle / ellipse / group average) plus the pose translate at `at` — no live layout. `direction` is `"outward"` (default) or `"inward"`. Each layer moves `distance` px along its ray from the selection AABB center. Optional `markerId` links `transformX` and `transformY` individually on the written bags and snaps them to that marker. ### Easing ```js api.setLayerEase({ layer, ease }); api.setPropertyEase({ layer, prop, ease }); // ease: "none" clears the property override api.setSegmentEase({ layer, prop, fromKeyframeId, ease }); api.setEaseBatch({ layers: ["a", "b"], ease }); // optional at (default 100), optional prop api.getEase({ layer }); // layer default api.getEase({ layer, prop }); // { ease, level: "segment"|"property"|"layer"|"default" } api.getEase({ layer, prop, fromKeyframeId }); api.clearEase({ layer }); // layer default → linear api.clearEase({ layer, prop }); // property override api.clearEase({ layer, prop, fromKeyframeId }); // one segment api.setCustomBezier({ layer, p1x, p1y, p2x, p2y }); // optional prop / fromKeyframeId ``` `ease` accepts `"linear"`, `"ease"`, `"ease-in"`, `"ease-out"`, `"ease-in-out"`, `"step-start"` (hold), or `"cubic-bezier(x1,y1,x2,y2)"`. Easing resolves **segment → property → layer → linear**. Set a layer default and override per segment only where it matters. ### Layer tree ```js api.setLayerVisibility({ layer, visible }); // boolean required api.setLayerLock({ layer, locked }); // boolean required api.setLayerColor({ layer, color }); // null, or "color1".."color5" api.setLayerLabel({ layer, label }); // writes both the labels map and the layer bag api.deleteLayer({ layer, confirm: true }); // confirm is required api.duplicateLayer({ layer }); // optional label; copies authored keys; returns { layer } api.duplicateLayer({ layer, label }); api.moveLayer({ layer, direction: "up" }); // or "down" api.moveLayer({ layer, toIndex }); // absolute index in project.layers api.reparentLayer({ layer, target, index }); // target: layer id, or "root" / null; index defaults to 0 api.setLayerViewClip({ layer, clip: true }); // or clip: "clip" / false / "none"; composition layers only api.copyLayers({ layers: ["layer-0", "layer-2"] }); // returns a payload — not the system clipboard api.pasteLayers({ clipboard }); // a copyLayers payload; rich paste, keyframes ride api.pasteLayers({ svg: "…" }); // raw markup; fresh import, geometry only api.duplicateLayers({ layers }); // copy + paste in one step ``` `copyLayers` / `pasteLayers` / `duplicateLayers` move whole layers with their animation. A selected group carries its subtree; a nested child copied alone keeps its canvas position. Paste always lands on top of the layer stack and returns `{ layers, allLayers }` (top-level vs incl. remapped descendants). A fresh `` document pastes as one group (`` → ``); children stay nested. Pasted layers are independent: fresh keyframe ids, marker links dropped, `linkedLayer` / `propertyLinks` / `maskLayer` cut, locks cleared. `pasteLayers` rejects non-SVG text and oversized or script-bearing markup. ### Time, markers, work area ```js api.setDuration({ seconds }); // clamped 0.1–90 api.setDuration({ seconds, lockKeys: true }); // remaps authored keys to keep wall-clock times api.applyInsertTime({ at, deltaMs }); // at defaults to 0; deltaMs > 0 api.applyRemoveTime({ at, deltaMs }); api.createMarker({ at, label }); // returns { markerId } api.moveMarker({ markerId, to }); // to (or at) is a percentage api.updateMarker({ markerId, label }); // optional at / to to move as well api.deleteMarker({ markerId }); api.listMarkers(); // includes linkCount per marker api.snapKeyframeToMarker({ layer, keyframeId, markerId, prop }); // one-shot move, never links api.linkKeyframeToMarker({ layer, keyframeId, markerId, prop }); // persists; prop must exist on the key api.unlinkKeyframeFromMarker({ layer, keyframeId, prop }); api.unlinkMarker({ markerId }); // drops every diamond linked to it api.setWorkArea({ in: 0, out: 100 }); // percentages api.clearWorkArea(); // resets to 0–100 ``` Without `lockKeys`, changing duration keeps percentages (motion stretches). With `lockKeys: true`, keys keep their time in seconds. ### Effects ```js api.setShake({ layer, frequency, amount }); // UI uses ~0–30 / 0–500; the command does not clamp api.createShakeKey({ layer, field: "amount", at, value }); // field: "amount" | "frequency" api.deleteShakeKey({ layer, field: "amount", keyId }); api.clearShake({ layer }); api.setEcho({ layer, mode, count, delayMs, pathOffsetPct, opacityDecay, scaleDecay }); api.getEcho({ layer }); api.clearEcho({ layer }); api.setDraw({ layer, mode, start, end, offset }); // "off"|"draw-on"|"erase"|"draw-off"|"window"|"flow" api.createDrawKey({ layer, field: "start", at, value }); // field: "start" | "end" | "offset" api.deleteDrawKey({ layer, field: "start", at }); api.clearDraw({ layer }); api.setRepeaterMode({ layer, mode, prop }); // "none"|"cycle"|"pingpong"|"pingpong-travel"|"offset" api.setRepeaterSegment({ layer, start, end, tileUntil, prop }); api.setRepeaterTileUntil({ layer, tileUntil, prop }); api.setDrawRepeater({ layer, mode }); api.clearRepeater({ layer, prop }); api.getRepeater({ layer, prop }); ``` `erase` is accepted as an alias for `draw-off`. Echo `delayMs` and `pathOffsetPct` are **negative** in the document (the UI shows the positive form). Pass the negative numbers. ### Links and masks There is no `stagger` command. Temporal offset on a link is `linkWindow` — a percentage remap of the source. ```js api.setLayerLink({ layer, source }); // omit source to unlink; optional drawLength api.setLayerLinkMirror({ layer, mirror: true }); api.setPropertyLink({ layer, prop, source }); // omit source to unlink; optional drawLength api.setPropertyLinkMirror({ layer, prop, mirror: true }); api.setLinkRange({ layer, start, end, prop }); // sets linkWindow (percentage remap) api.getLinks({ layer }); // omit layer to list every link api.setLayerMask({ layer, source }); // omit source to clear ``` Link and mask cycles are rejected. A linked source with Stroke Draw cannot be measured from a script (`getTotalLength` is browser-only) — pass `drawLength` or the command warns `DRAW_LENGTH_UNMEASURED` and continues. Writing a channel that a link already drives is rejected. Unlink first. ### Motion path ```js api.getMotionPath({ layer }); // { layer, hasCurve, autoOrient, anchors, segments } api.setMotionPath({ layer, segmentIndex, curved }); // alias of setSegmentCurved; no raw-d path api.setSegmentCurved({ layer, segmentIndex, curved }); // curved defaults to true api.setPathAnchor({ layer, keyframeId, x, y }); // or { index }; x/y are local px; transform pivots api.setPathHandle({ layer, keyframeId, side, along, perp }); // side: "in" | "out". Also accepts { offset } or { x, y } instead of along/perp. api.setAutoOrient({ layer, face: "e" }); // "n"|"e"|"s"|"w", or "off" / false to clear api.setAutoOrient({ layer, face: "e", reverse: "travel" }); api.clearMotionPath({ layer }); // also clears Auto-Orient ``` There is no `setMotionPath({ d })` — raw path strings are not ingested. Handles are chord-relative and need no measurement. Two caveats: **anchor moves on nested groups use transform pivots, not measured visual pivots**, so they can diverge from the editor; and layers with a repeater are re-expanded after the edit so tiles do not drift. ### Compositions `createCompositionLayer` stays IndexedDB-free: it does not load the source project. Pass `sourceDurationSec` to size the bar from the source length; omit it and the bar is 0–100%. ```js api.createCompositionLayer({ sourceProjectId, sourceDurationSec, label }); // optional id, startPct/endPct, clip: false (default view is clip) api.getComposition(); // every composition layer api.getComposition({ layer }); // one layer's bag api.setCompositionRange({ layer, startPct, endPct }); api.setCompositionDuration({ layer, seconds }); // keeps start, changes end api.resetCompositionDuration({ layer, sourceDurationSec }); // sourceDurationSec > 0 api.deleteCompositionLayer({ layer }); // no confirm — composition only ``` ### Canvas The canvas is the root `` `viewBox` / `width` / `height`, in px. Resizing moves the **rest pose** of the artwork (the authored `transform` on every root-level element) by the anchored delta. Keyframes are untouched, so animation stays relative to the new rest position; nothing is baked into the timeline. Projects that nest this one as a composition are shifted the opposite way in the same step, so the nested artwork stays put in its hosts — one undo reverses both. ```js api.getCanvasSize(); // { width, height, viewBox: { x, y, width, height } | null } api.resizeCanvas({ width: 600, height: 500 }); // anchor defaults to "center center" api.resizeCanvas({ width: 800, anchor: "top left" }); // one dimension keeps the other // anchor: the nine transformOrigin keywords — "top left", "top center", "top right", // "center left", "center center", "center right", "bottom left", "bottom center", "bottom right" // → { width, height, dx, dy, anchor, previous: { width, height } } // Whole px, 1–8192. Figma frame clips and background rects sized to the old canvas // are resized to the new one rather than shifted. ``` ### Text A text layer is a whole `` element; its lines are ``s. Content and font are **static SVG attributes**, not keyframe channels — animate a text layer with the usual transform, opacity, blur, fill, and stroke rows. Fonts are resolved by name on the viewer's machine; nothing is embedded in a download. Two open families ship with the editor and render identically in the SVG canvas and the Lottie preview everywhere: **Noto Sans** and **Noto Serif** (Regular + Bold; weights ≤ 500 use Regular, ≥ 600 Bold). In Lottie, `letterSpacing` becomes tracking (`tr`), `fontWeight` picks the style name, and `text-anchor` sets the justification. ```js api.getLayer({ layer }); // text layers add: text: { content, lines, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, tag } api.createTextLayer({ text: "Hello\nWorld" }); // defaults: artboard centre, text-anchor middle, sans-serif 24px 400, fill #000000 api.createTextLayer({ text, x, y, fontFamily, fontSize, fontWeight, fill, label }); // → { layer, text } api.setTextContent({ layer, text: "One\nTwo" }); // "\n" = new line (tspan) api.setTextStyle({ layer, fontFamily: "Inter" }); // fontFamily, fontSize, fontWeight, lineHeight, letterSpacing api.setTextStyle({ layer, fontSize: 32, fontWeight: 700, lineHeight: 40, letterSpacing: 1 }); ``` **Explode, Flatten, Ungroup** — the same edits the canvas right-click menu offers on a selected text layer (Flatten, Explode) or group (Ungroup). Each is one undo step. ```js // Explode: the text layer's id becomes a holding one single-letter layer // per visible glyph, each at the glyph's measured start position, so nothing moves. // `glyphs` are measured from the live canvas (getStartPositionOfChar, ligatures off, // whitespace skipped); the console cannot measure, so pass them in — the editor menu does. api.explodeTextLayer({ layer, glyphs: [{ char: "H", x: 0, y: 24 }, { char: "i", x: 15.2, y: 24 }] }); // → { layer, letters: ["layer-1-g1", "layer-1-g2"], count: 2 } // The group keeps transform / opacity / blur keyframes; fill, stroke and draw keys move to every letter. // Flatten: the becomes a with the same id, keyframes and label. // `d` is the outline from the real font (the editor runs the Lottie export outliner; // in the console pass path data you already have). No font is ever substituted. api.flattenTextLayer({ layer, d: "M0 0 L10 0 L10 10 Z" }); // → { layer, type: "path" } // Ungroup: hoists a plain 's children in place and removes the group row. The // group's static transform / transform-origin (px pair) and opacity are baked into // each child. Optional `matrix` [a, b, c, d, e, f] + `opacity` bake a *measured* pose // instead (the canvas menu passes parent.getScreenCTM()⁻¹ × g.getScreenCTM()), which // also covers a constant gizmo keyframe and keyword origins. Refused (UNGROUP_BLOCKED) // when the group is animated (keys that change), has a blur key, repeater, shake, echo, // draw, mask, links, or clip-path / mask / filter attributes. api.ungroupLayer({ layer: "layer-4" }); // → { layer: "layer-4", children: ["layer-5", "layer-6"] } api.ungroupLayer({ layer: "layer-4", matrix: [1, 0, 0, 1, 24, -10], opacity: 0.8 }); ``` ### Shapes `createLayer` inserts new artwork — paths, shapes and groups — from an **SVG fragment string**. This is the way to draw something that is not in the document yet ("add a hamster"). It works in the console and over WebMCP; it needs no clipboard. Use it instead of `pasteLayers`, which cannot parse markup inside the script sandbox. For text, use `createTextLayer`. Every element becomes a layer, exactly as if it had been imported: it gets a `layer-N` key, a `data-layer` stamp and a row in the Layers panel, and is animatable straight away. Nested elements stay nested. An `id` on a fragment element becomes its layer **label** and is then dropped; layer keys are always chosen for you. Coordinates are unitless canvas units — call `getCanvasSize()` first and design inside that box. ```js const { layers, allLayers } = api.createLayer({ svg: ` `, }); // layers → ["layer-8"] top-level layers created // allLayers → ["layer-8", "layer-9", …] including nested children api.createLayer({ svg: '', target: "layer-2" }); // into a group api.createLayer({ svg: '', label: "Slash" }); // label arg names the first root api.setLayerAttrs({ layer: "layer-9", fill: "tomato", strokeWidth: 3 }); api.setLayerAttrs({ layer: "layer-10", d: "M0 0 L9 9 Z" }); api.setLayerAttrs({ layer: "layer-9", stroke: null }); // null removes a paint attribute ``` `createLayer({ svg, target?, label? })` returns `{ layers, allLayers, warnings }`. `target` (same meaning as in `reparentLayer`) is a plain group layer (not a composition, not locked) or omitted / `"root"` for the top of the stack; new layers go in as its last children. A fragment may be bare elements or one wrapping ``; the wrapper's `viewBox` is ignored (with a warning). **Strict by design.** Anything outside the list below is **rejected with a message that names it** — never silently stripped — and a rejected call changes nothing. | Allowed | Notes | |---|---| | Elements | `g`, `path`, `rect`, `circle`, `ellipse`, `line`, `polygon`, `polyline` | | Geometry | `d`, `points`, `x`, `y`, `width`, `height`, `rx`, `ry`, `cx`, `cy`, `r`, `x1`…`y2`; plain numbers only (no `px`, no `%`), within ±1,000,000 | | Paint | `fill`, `stroke` as `none`, `currentColor`, hex, `rgb()` / `rgba()` / `hsl()` / `hsla()` or a named colour. **No `url()`, no `var()`** | | Stroke and opacity | `stroke-width`, `stroke-linecap`, `stroke-linejoin`, `stroke-miterlimit`, `stroke-dasharray`, `stroke-dashoffset`, `fill-rule`, `fill-opacity`, `stroke-opacity`, `opacity` | | `transform` | `matrix`, `translate`, `scale`, `rotate`, `skewX`, `skewY` | Not supported yet, and rejected: `text` / `tspan` (use `createTextLayer`), `defs`, gradients, `clipPath`, `mask`, `filter`, `use`, `image`, `style`, `script`, `foreignObject`, SMIL (`animate*`), nested ``, `class`, `style`, `href`, `on*` and `data-*` attributes, `DOCTYPE` / entities. Use solid fills and animate with `setProperty`. Limits: 512 KB of markup, 200 elements, 8 levels deep, 100,000 characters per path, 64 characters per label; the whole document may not pass 2,000 layers (a warning appears past 200). A shape with no fill and no stroke is created but flagged in `warnings` as invisible. When no ancestor supplies a fill, a shape gets an explicit `#000000` (`none` for `line` / `polyline`), so it shows even on Figma artboards that set `fill="none"` at the root. `setLayerAttrs({ layer, …attrs })` edits one shape or group in place: geometry for that element's tag, the paint / stroke attributes above (camelCase: `strokeWidth`, `fillRule`, …) and `transform`. A group accepts only `transform` and `opacity`. Text, compositions, locked layers and generated layers are refused. Geometry cannot be removed; paint can (`null`). It returns `{ layer, changed, applied }`; setting a value a layer already has is a no-op. To animate a created layer, use the normal channels — `setProperty` on `opacity`, `transform` and the rest, or `setDraw` for a stroke draw-on. `setLayerAttrs` changes the **rest pose** of the shape; it never writes keyframes. --- ## 6. Writing motion that looks good An API-correct script can still produce bad animation. These are the defaults worth reaching for. **Few keys, good easing.** Two keys with the right ease beat six keys approximating a curve. Extra keys make motion mechanical and bloat the export. If a move needs shaping, change the ease before adding a key. **Ease out for entrances, ease in for exits.** Things arriving should decelerate (`ease-out`); things leaving should accelerate away (`ease-in`). `linear` reads as robotic for anything physical — save it for continuous rotation and looping cycles. **Compose two channels, not one.** Opacity alone is flat. The reliable entrance is opacity plus a small offset: ```js api.setProperties({ layer, at: 0, properties: { opacity: "0", transformY: "8px" } }); api.setProperties({ layer, at: 40, properties: { opacity: "1", transformY: "0px" } }); api.setLayerEase({ layer, ease: "ease-out" }); ``` Keep the offset small — 6 to 12 pixels. Large offsets read as sliding rather than arriving. **Timing that reads.** A micro-interaction (hover, toggle) wants 10–20% of the composition. An element entrance wants 25–40%. A full scene spreads across 100% using staggers. Fast in, slow to settle. Anything that takes the whole timeline feels broken unless it's an ambient loop. **Rotate and scale sparingly.** Rotation draws a lot of attention — use it for intent, like a spinner or a chevron flip, not decoration. Scale entrances work best from `0.9`–`0.95`; starting from `0` reads as a pop. **Stagger, don't synchronise.** Identical simultaneous motion on many elements looks like a glitch. Offset each by a small constant, and keep the last element's end under 100% so it isn't clipped. **Use the repeater for cycles.** For anything looping, set a repeater over one segment instead of duplicating keyframes. It keeps the timeline readable, the export small, and the loop seamless. **Effects have jobs.** Shake is for impact and error states — small amount, high frequency reads as vibration, low as sway. Echo is for speed and trails, and needs actual motion to echo. Stroke Draw is for line art appearing, with `draw-on` as the workhorse. --- ## 7. Pitfalls | Trap | What happens | |---|---| | Writing to a locked layer | Rejected. Check `locked` in `listLayers()` first. | | Writing `rotate` while Auto-Orient is on | Rejected — Auto-Orient owns rotation. | | Writing `strokeDasharray` / `strokeDashoffset` while Stroke Draw is on | Rejected — Draw owns those channels. | | Editing a generated keyframe | Rejected. Repeater and echo tiles are derived; edit the source segment instead. | | Deleting and recreating a keyframe to move it | **Loses its easing.** Easing is keyed by keyframe id. Use `moveKeyframe` or `moveKeyframes`. | | Writing a linked channel | Rejected. Unlink first (`setLayerLink` / `setPropertyLink` with no source). | | Nested-group path anchors | `setPathAnchor` uses transform pivots, not measured visual pivots. | | `getCompiledTimeline` pivots | Same as `setPathAnchor` — transform pivots, not measured visual pivots. | | `getPropertyAt` vs `getCompiledTimeline` | `getPropertyAt` compiles with the editor's localStorage curve-sampling pref. Use `getCompiledTimeline({ layer, stepPx })` for a deterministic pose. | | `transformOrigin` on `applyRotate` / `applyScale` | Not accepted. Origin is layer-held at emit; use `setProperty({ prop: "transformOrigin" })`. | | Multiplicative scale | `applyScale` rounds to 2 dp, so scale-then-invert is not a perfect restore. | | `setDuration` without `lockKeys` | Percentages stay put — motion stretches with the new length. Pass `lockKeys: true` to keep wall-clock times. | | Echo sign convention | `delayMs` and `pathOffsetPct` are **negative** in the document while the UI shows positive numbers. Pass the negative form. | | Bare numbers | `value: 10` is rejected. Write `"10px"`. `strokeDashoffset` also needs `"px"`. | | Keyframes past 100% | Clamped. Check your stagger arithmetic. | | Guessing layer names | Always `listLayers()` first. | | Expecting a read to show itself | Command results are returned to your code, not displayed. `console.log` them or you see nothing. | | `while (true)` with no API calls | Hangs the tab. The 50,000-call / 5s budget only increments inside `api.*`. | --- ## 8. Worked example A staggered list entrance with a drawn underline: ```js // 1. Look before touching anything. const rows = api.listLayers().filter((l) => l.id.startsWith("row-") && !l.locked); // 2. Stagger the rows: fade plus a small rise, decelerating in. rows.forEach((row, i) => { const start = i * 5; api.setProperties({ layer: row.id, at: start, properties: { opacity: "0", transformY: "10px" }, }); api.setProperties({ layer: row.id, at: Math.min(start + 30, 100), properties: { opacity: "1", transformY: "0px" }, }); api.setLayerEase({ layer: row.id, ease: "ease-out" }); }); // 3. Underline draws on once the rows have settled. api.setDraw({ layer: "underline", mode: "draw-on", start: 0, end: 100 }); api.setPropertyEase({ layer: "underline", prop: "strokeDashoffset", ease: "ease-in-out" }); // 4. Confirm the document is still coherent. api.validateProject(); ``` Note the shape: inspect, act in a loop, ease deliberately, validate. Use that structure by default.