@lottiefiles/creator-mcp 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +62 -21
  2. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -324,7 +324,24 @@ const rgbColorSchema = z.object({
324
324
  g: z.number().min(0).max(255),
325
325
  b: z.number().min(0).max(255)
326
326
  }).strict();
327
- const jsonStringifiedRgbColorSchema = z.string().regex(/^(?=[\s\S]*"r"\s*:)(?=[\s\S]*"g"\s*:)(?=[\s\S]*"b"\s*:)\s*\{[\s\S]*\}\s*$/, "Expected a JSON-stringified RGB object such as {\"r\":66,\"g\":133,\"b\":244}.").describe("Compatibility only: JSON-stringified RGB object. Prefer a real {r,g,b} object.");
327
+ const jsonStringifiedRgbColorSchema = z.string().regex(/^(?=[\s\S]*"r"\s*:)(?=[\s\S]*"g"\s*:)(?=[\s\S]*"b"\s*:)\s*\{[\s\S]*\}\s*$/, "Expected a JSON-stringified RGB object such as {\"r\":66,\"g\":133,\"b\":244}.").superRefine((value, ctx) => {
328
+ let parsed;
329
+ try {
330
+ parsed = JSON.parse(value);
331
+ } catch {
332
+ ctx.addIssue({
333
+ code: "custom",
334
+ message: "Expected a JSON-stringified RGB object."
335
+ });
336
+ return;
337
+ }
338
+ const result = rgbColorSchema.safeParse(parsed);
339
+ if (!result.success) for (const issue of result.error.issues) ctx.addIssue({
340
+ code: "custom",
341
+ path: issue.path,
342
+ message: issue.message
343
+ });
344
+ }).describe("Compatibility only: JSON-stringified RGB object. Prefer a real {r,g,b} object.");
328
345
  const rgbColorInputSchema = z.union([rgbColorSchema, jsonStringifiedRgbColorSchema]);
329
346
  const fillColorInputSchema = z.union([rgbColorInputSchema, z.literal("none")]);
330
347
  const vector2Schema = z.object({
@@ -422,6 +439,30 @@ const paintInputSchema = z.union([
422
439
  linearGradientPaintInputSchema,
423
440
  radialGradientPaintInputSchema
424
441
  ]);
442
+ const jsonStringifiedPaintSchema = z.string().regex(/^\s*\{[\s\S]*\}\s*$/, "Expected a JSON-stringified paint object.").superRefine((value, ctx) => {
443
+ let parsed;
444
+ try {
445
+ parsed = JSON.parse(value);
446
+ } catch {
447
+ ctx.addIssue({
448
+ code: "custom",
449
+ message: "Expected a JSON-stringified paint object."
450
+ });
451
+ return;
452
+ }
453
+ const result = paintInputSchema.safeParse(parsed);
454
+ if (!result.success) for (const issue of result.error.issues) ctx.addIssue({
455
+ code: "custom",
456
+ path: issue.path,
457
+ message: issue.message
458
+ });
459
+ }).describe("Compatibility only: JSON-stringified paint object. Prefer a real paint object.");
460
+ const paintToolInputSchema = z.union([
461
+ solidPaintInputSchema,
462
+ linearGradientPaintInputSchema,
463
+ radialGradientPaintInputSchema,
464
+ jsonStringifiedPaintSchema
465
+ ]);
425
466
  const paintInputWithOptionalWidthSchema = z.union([
426
467
  solidPaintInputSchema.extend({ width: z.number().min(.1).optional() }),
427
468
  linearGradientPaintInputSchema.extend({ width: z.number().min(.1).optional() }),
@@ -542,11 +583,11 @@ const statePlayModeInputSchema = z.enum([
542
583
  */
543
584
  const allClientTools = {
544
585
  read_scene: {
545
- description: "Read the current Creator scene. Returns scene dimensions, framerate, duration, all layers, current selection, render order, visual bounds, scene-center deltas, and clipping/off-canvas flags. Call this once at the start of most turns to understand what's on the canvas. Use each layer's `ordering` and `layout` blocks to avoid wrong-layer, wrong-stack, and off-canvas mistakes. A layer may also carry an `occlusion` block (`likely_hidden: true`, `occluded_by: [ids]`) when an opaque layer in front of it covers it — treat that layer as INVISIBLE on the canvas (e.g. a label buried behind a button fill); reorder it above the cover with `reorder_layer` before reporting done. No input required — always reads the active scene.",
586
+ description: "Read the current Creator scene. Returns scene dimensions, framerate, duration, all layers, current selection, render order, visual bounds, scene-center deltas, and clipping/off-canvas flags. Call this once at the start of most turns to understand what's on the canvas. Use each layer's `ordering` and `layout` blocks to avoid wrong-layer, wrong-stack, and off-canvas mistakes. A layer may also carry an `occlusion` block (`likely_hidden: true`, `occluded_by: [ids]`) when an opaque layer in front of it covers it — treat that layer as INVISIBLE on the canvas (e.g. a label buried behind a button fill); reorder it above the cover with `reorder_layer` before reporting done. A shape layer whose paths all sit inside groups (typical of imported art) carries `nested_paints` — the real visible fills/strokes with their `group_id`, `type` and solid `color` — so you can see what to recolor; `set_fill` / `set_stroke` on that layer update those group paints. No input required — always reads the active scene.",
546
587
  inputSchema: z.object({}).strict()
547
588
  },
548
589
  apply_keyframes: {
549
- description: "Add keyframes to ONE animatable property on ONE layer. This is the preferred tool for simple single-property animations (rotate, fade, slide, pulse, recolor). If you need to animate multiple properties on the same layer, plan separate tool calls in the order they should be applied; Creator serializes native tool execution through the dispatcher.\n\n**POLISH TURNS (snappier / smoother / tighter / less bouncy / fix timing / more premium / etc.) — call `read_keyframes` FIRST on every animated layer in scope BEFORE calling this tool.** The conversation history's view of existing keyframes is stale once the user edits in Creator directly; reading is the only safe source of truth. Then prefer the smallest possible mutation: easing-only change > retiming > amplitude tweak > full rebuild. See motion-design.md → 'Repair / polish protocol' for the full rule.\n\nValue type depends on the property:\n- `position`, `scale` → value is `{x, y}`. **`scale` is a percentage of the layer's AUTHORED size, matching AE / Creator semantics — NOT a percentage of 100.** A layer the user imported or hand-resized can sit at `50`, `200`, `1250`, etc. — and THAT value is the resting state. Animating a layer authored at 1250 → 110 collapses it to ~9% of its on-canvas size and looks like a bug. **Always read the current static value via `read_keyframes` or `read_scene` before writing scale keyframes**, then animate RELATIVE to that authored value (1250 pulses to 1350, not to 110). Same baseline rule applies to `position` and `rotation` — the layer's authored values are the resting state, not 0/0 or 0°. See motion-design.md → 'Baseline awareness'. **TEXT `position` is doubly dangerous: a text layer's transform anchor is the baseline / box origin, so its raw `position` value is NOT its `visual_center` and NOT the `position` arg you gave `create_text` (that arg is the visual center). Feeding `visual_center` in as a `position` keyframe throws the text off-canvas. For a settle-into-place entrance use `animate_entrance` (no coordinate needed); for any other text move, `read_keyframes` the raw `static_value` and animate RELATIVE to it. Never repair a keyframed text layer with `set_anchor_point` → `center_layer` — they stack two shifts and fling it off-canvas.**\n- `rotation`, `opacity` → value is a number (rotation in degrees; opacity 0–100)\n- `fill_color` → value is `{r, g, b}` with 0-255 per channel. **Requires a SOLID fill at `fills[0]`** — animation only works when the layer's first fill is SOLID. Gradient or empty layers will fail with `'Layer has no SOLID fill at fills[0]'`. To animate the color of a layer that currently has a gradient (or no fill), call `set_fill({type:'SOLID', color: <starting color>})` FIRST, then this tool. Verify the fill type via `read_scene` (look at the layer's fill metadata) or just call `set_fill` defensively before animating colors on a layer you didn't create with a solid fill.\n- `trim_start`, `trim_end` → number 0–100 (percent of path). Use for stroke draw-on reveals: animate `trim_end` from 0 → 100 to grow the path in. A trim path is auto-created on first use. If the current playhead stays at a frame where `trim_end` is 0 or opacity is 0, the layer is correctly present but invisible on canvas; after creating a reveal, call `set_current_frame` to leave Creator on a visible frame.\n- `trim_offset` → number 0–360 (degrees around the path). For rotating-around-path effects.\n- `stroke_width` → number (pixels). Requires the layer to already have a stroke — use `set_stroke` first.\n\nTo set a static color (no animation), pass a single keyframe at frame 0 and consider `clear_first: true` to remove any existing color animation.\n\nEasing is optional on any keyframe; omit it for linear-ish default. Use `CUBIC_BEZIER` with explicit control points for polished motion; `LINEAR` for deliberate no-easing.\n\nUse `clear_first: true` to replace existing keyframes on that property; otherwise new keyframes are merged with existing ones (sorted by frame).\n\nFor paint, shape geometry, masks, groups, scene settings, and timing edits, prefer the dedicated typed tools (`animate_paint`, `animate_property`, `create_shape_group`, `update_mask`, `retime_keyframes`) over rebuilding with many generic keyframe calls.\n\n**RESPONSE — verify before saying \"Done\":** returns `{ layer_id, property, added, total_after }`. `added` is how many keyframes THIS call submitted; **`total_after` is a real read-back of how many keyframes the property holds afterwards** — it is re-read from the layer, not echoed from your input.\n- **Write success:** the call did not throw and the requested value was read back. A non-throwing one-keyframe write with `total_after: 1` is a valid static write.\n- **Animation success:** motion requires at least two distinct stored keyframe frames. After a multi-keyframe submission, `total_after: 1` means the write collapsed and the layer will not animate; call `read_keyframes` if the returned count or frames are unexpected.\n- With `clear_first: true`, expect `total_after === added`. A LARGER number means keyframes you did not write survived on that property and the result is a blend of old and new — `read_keyframes` before reporting done.\n- Without `clear_first`, `total_after` is the merged count. If it is lower than (previous + `added`), some of your frames landed on frames that already had keyframes and replaced them rather than adding.\n- Throws (and writes NOTHING) on an unknown `layer_id`, on a property that is not animatable on that layer, and on an unknown easing PRESET `name`.",
590
+ description: "Add keyframes to ONE animatable property on ONE layer. This is the preferred tool for simple single-property animations (rotate, fade, slide, pulse, recolor). If you need to animate multiple properties on the same layer, plan separate tool calls in the order they should be applied; Creator serializes native tool execution through the dispatcher.\n\n**POLISH TURNS (snappier / smoother / tighter / less bouncy / fix timing / more premium / etc.) — call `read_keyframes` FIRST on every animated layer in scope BEFORE calling this tool.** The conversation history's view of existing keyframes is stale once the user edits in Creator directly; reading is the only safe source of truth. Then prefer the smallest possible mutation: easing-only change > retiming > amplitude tweak > full rebuild. See motion-design.md → 'Repair / polish protocol' for the full rule.\n\nValue type depends on the property:\n- `position`, `scale` → value is `{x, y}`. **`scale` is a percentage of the layer's AUTHORED size, matching AE / Creator semantics — NOT a percentage of 100.** A layer the user imported or hand-resized can sit at `50`, `200`, `1250`, etc. — and THAT value is the resting state. Animating a layer authored at 1250 → 110 collapses it to ~9% of its on-canvas size and looks like a bug. **Always read the current static value via `read_keyframes` or `read_scene` before writing scale keyframes**, then animate RELATIVE to that authored value (1250 pulses to 1350, not to 110). Same baseline rule applies to `position` and `rotation` — the layer's authored values are the resting state, not 0/0 or 0°. See motion-design.md → 'Baseline awareness'. **TEXT `position` is doubly dangerous: a text layer's transform anchor is the baseline / box origin, so its raw `position` value is NOT its `visual_center` and NOT the `position` arg you gave `create_text` (that arg is the visual center). Feeding `visual_center` in as a `position` keyframe throws the text off-canvas. For a settle-into-place entrance use `animate_entrance` (no coordinate needed); for any other text move, `read_keyframes` the raw `static_value` and animate RELATIVE to it. Never repair a keyframed text layer with `set_anchor_point` → `center_layer` — they stack two shifts and fling it off-canvas.**\n- `rotation`, `opacity` → value is a number (rotation in degrees; opacity 0–100)\n- `fill_color` → value is `{r, g, b}` with 0-255 per channel. **Requires a SOLID fill at `fills[0]`** — animation only works when the layer's first fill is SOLID. Gradient or empty layers will fail with `'Layer has no SOLID fill at fills[0]'`. On a layer whose paths all sit inside groups (`read_scene` → `nested_paints`), `fills[0]` is the first nested group fill. To animate the color of a layer that currently has a gradient (or no fill), call `set_fill({type:'SOLID', color: <starting color>})` FIRST, then this tool. Verify the fill type via `read_scene` (look at the layer's fill metadata) or just call `set_fill` defensively before animating colors on a layer you didn't create with a solid fill.\n- `trim_start`, `trim_end` → number 0–100 (percent of path). Use for stroke draw-on reveals: animate `trim_end` from 0 → 100 to grow the path in. A trim path is auto-created on first use. If the current playhead stays at a frame where `trim_end` is 0 or opacity is 0, the layer is correctly present but invisible on canvas; after creating a reveal, call `set_current_frame` to leave Creator on a visible frame.\n- `trim_offset` → number 0–360 (degrees around the path). For rotating-around-path effects.\n- `stroke_width` → number (pixels). Requires the layer to already have a stroke — use `set_stroke` first.\n\nTo set a static color (no animation), pass a single keyframe at frame 0 and consider `clear_first: true` to remove any existing color animation.\n\nEasing is optional on any keyframe; omit it for linear-ish default. Use `CUBIC_BEZIER` with explicit control points for polished motion; `LINEAR` for deliberate no-easing.\n\nUse `clear_first: true` to replace existing keyframes on that property; otherwise new keyframes are merged with existing ones (sorted by frame).\n\nFor paint, shape geometry, masks, groups, scene settings, and timing edits, prefer the dedicated typed tools (`animate_paint`, `animate_property`, `create_shape_group`, `update_mask`, `retime_keyframes`) over rebuilding with many generic keyframe calls.\n\n**RESPONSE — verify before saying \"Done\":** returns `{ layer_id, property, added, total_after }`. `added` is how many keyframes THIS call submitted; **`total_after` is a real read-back of how many keyframes the property holds afterwards** — it is re-read from the layer, not echoed from your input.\n- **Write success:** the call did not throw and the requested value was read back. A non-throwing one-keyframe write with `total_after: 1` is a valid static write.\n- **Animation success:** motion requires at least two distinct stored keyframe frames. After a multi-keyframe submission, `total_after: 1` means the write collapsed and the layer will not animate; call `read_keyframes` if the returned count or frames are unexpected.\n- With `clear_first: true`, expect `total_after === added`. A LARGER number means keyframes you did not write survived on that property and the result is a blend of old and new — `read_keyframes` before reporting done.\n- Without `clear_first`, `total_after` is the merged count. If it is lower than (previous + `added`), some of your frames landed on frames that already had keyframes and replaced them rather than adding.\n- Throws (and writes NOTHING) on an unknown `layer_id`, on a property that is not animatable on that layer, and on an unknown easing PRESET `name`.",
550
591
  inputSchema: z.object({
551
592
  layer_id: z.string().describe("Layer id from `read_scene` (e.g. 'qttDVXo7OQ')."),
552
593
  property: coreAnimatedPropertySchema,
@@ -706,7 +747,7 @@ const allClientTools = {
706
747
  description: "**BEFORE CALLING: check the creation order.** New text layers are inserted at the TOP of the render stack. If this turn also creates a card / background / banner, create the background FIRST and THIS text layer LAST — otherwise the text will land beneath the background. (Reorder APIs exist if needed via `reorder_layer`, but planning the order is cheaper.)\n\nCreate a new native TEXT_LAYER with the given content and optional native TextDocument style. Returns `layer_id` so you can immediately animate it with `apply_keyframes` (opacity for fade-in, position for slide, scale for pop). For letter-by-letter or word-by-word staggered animations, emit one text-layer call per unit in the order they should be created; Creator serializes native tool execution.\n\n**Centering is by VISUAL BOUNDS, not layer origin.** Use `center_in_scene: true` for scene center. Use `position: {x,y}` when you want the bounds center at a specific target point (e.g. top-center hero text). Do not manually compensate for text origin/anchor.\n\n**For long body copy that needs to wrap, pass `wrap_width` instead of constructing a text box yourself.** The gateway translates `wrap_width` into a centered wrap box internally — your `position` / `center_in_scene` still controls visual placement. `wrap_width` without an explicit `align` uses center alignment; pass `align: \"left\"` for a conventional paragraph column. Omit `wrap_width` for headlines, subtitles, footers, single-line text.\n\nNative text styling supports font size, font family/variant or font name, fill color, stroke color/width, alignment, line height, tracking, caps, and `wrap_width`. For \"slight tracking\", use a small value such as 2-8; values around 20-40 are very wide and can make title text exceed the scene.\n\n**RESPONSE — verify these BEFORE saying \"Done\":**\n- `visual_center` is where the rendered text actually centers. Compare against your intent (`position` or scene center).\n- `visual_bounds` is the rendered rectangle in world coords.\n- `scene_fit.clipped` / `scene_fit.off_canvas` / `scene_fit.overflow.{left,right,top,bottom}`: if any overflow > 0, the new text is clipped at the canvas edge. Self-correct (reduce `font_size`, add `wrap_width`, shorten the text) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
707
748
  inputSchema: z.object({
708
749
  text: z.string().min(1).describe("The text content to display."),
709
- name: z.string().describe("Layer name (defaults to the first ~24 chars of `text`).").optional(),
750
+ name: z.string().describe("Layer name. Without one, the layer is named after `text` (numbered when a sibling has that name) and follows later `set_text` calls until it is renamed.").optional(),
710
751
  position: z.object({
711
752
  x: z.number(),
712
753
  y: z.number()
@@ -892,7 +933,7 @@ const allClientTools = {
892
933
  }).strict()
893
934
  },
894
935
  set_text: {
895
- description: "Update the text content of an existing TEXT_LAYER using native TextDocument APIs. By default the tool preserves the current visual bounds center after the text changes, so the layer does not jump when the new string is wider or narrower.\n\n**RESPONSE — verify before saying \"Done\":** returns `visual_center`, `visual_bounds`, and `scene_fit`. A string swap can push the new bounds past the canvas edge — check `scene_fit.clipped` / `scene_fit.overflow.{left,right,top,bottom}` after EVERY set_text. If anything overflows, self-correct (downscale `font_size` via `set_text_style`, add `wrap_width` via `set_text_style` for paragraph copy, or shorten the text) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
936
+ description: "Update the text content of an existing TEXT_LAYER using native TextDocument APIs. A layer named after its text is renamed to the new text, unless a state machine interaction targets its name. By default the tool preserves the current visual bounds center after the text changes, so the layer does not jump when the new string is wider or narrower.\n\n**RESPONSE — verify before saying \"Done\":** returns `visual_center`, `visual_bounds`, and `scene_fit`. A string swap can push the new bounds past the canvas edge — check `scene_fit.clipped` / `scene_fit.overflow.{left,right,top,bottom}` after EVERY set_text. If anything overflows, self-correct (downscale `font_size` via `set_text_style`, add `wrap_width` via `set_text_style` for paragraph copy, or shorten the text) or surface the limitation in one short line. See motion-design.md → \"Post-mutation layout integrity\".",
896
937
  inputSchema: z.object({
897
938
  layer_id: z.string(),
898
939
  text: z.string().min(1),
@@ -1042,28 +1083,28 @@ const allClientTools = {
1042
1083
  }).strict()
1043
1084
  },
1044
1085
  set_fill: {
1045
- description: "Set (or replace) the fill on a SHAPE_LAYER. Supports SOLID colors AND linear/radial gradients.\n\n**Picking a default color (no brand defined, no mood specified):** check `list_slots` and `list_motion_system_items` first. If both empty and the user didn't name a color, stay MONOCHROME — Slate-700 `{r:51,g:65,b:85}` for accents / buttons, Slate-50 `{r:248,g:250,b:252}` for surfaces. Indigo / purple / pink / teal as defaults read as AI slop. When the user DOES name a color or mood, pick from the Flat UI defo palette (see `motion-aesthetics` skill) instead of inventing Tailwind values. ONE chromatic accent + neutrals; no rainbow palettes.\n\n**Defaults to replacing `fills[0]`.** Pass `replace_first: false` to append as a second fill instead.\n\nColor stops: each stop has `offset` (0–1, position along the gradient line), `color` ({r,g,b} 0–255), and optional `opacity` (0–1, default 1). Keep stop count modest (2–5 stops typical). For premium 'glow' effects fade the color to pure black and set blend mode with `set_blend_mode`.\n\n**Gradient geometry — `start` / `end` are PIXEL coordinates in layer-local space (NOT normalized 0–1 ratios).** The shape primitive sits at the layer's local origin (0, 0); a rectangle/ellipse with size {W, H} extends from (-W/2, -H/2) to (+W/2, +H/2). The gradient line runs from `start` to `end`; offset 0 is at `start`, offset 1 is at `end`. Common patterns for a centered shape with size {W, H}:\n\n| Direction | start | end |\n| ---------------- | -------------- | -------------- |\n| Top → bottom | {x:0, y:-H/2} | {x:0, y:H/2} |\n| Bottom → top | {x:0, y:H/2} | {x:0, y:-H/2} |\n| Left → right | {x:-W/2, y:0} | {x:W/2, y:0} |\n| Diagonal TL→BR | {x:-W/2, y:-H/2}| {x:W/2, y:H/2}|\n| Radial center-out| {x:0, y:0} | {x:W/2, y:0} |\n\n**Omit `start`/`end` entirely if you don't have a strong opinion** — the native tool computes sensible pixel defaults from the layer's bounds (linear: top→bottom; radial: center→edge). Do NOT pass {0, 0}–{0, 1} or {0.5, 0}–{0.5, 1} thinking those are 'normalized' coordinates: those would render the gradient inside a 1-pixel strip and you'll see only the first stop's color across the entire shape.\n\n- Radial-only: `highlightAngle` (degrees) and `highlightLength` (-100 to 100) shift the focal point off-center.\n\nTo animate paint, use `animate_paint` for gradient stops/handles or `apply_keyframes` with `fill_color` for SOLID color cycles. `set_fill` is for changing the static fill kind.",
1086
+ description: "Set (or replace) the fill on a SHAPE_LAYER. Supports SOLID colors AND linear/radial gradients.\n\n**Picking a default color (no brand defined, no mood specified):** check `list_slots` and `list_motion_system_items` first. If both empty and the user didn't name a color, stay MONOCHROME — Slate-700 `{r:51,g:65,b:85}` for accents / buttons, Slate-50 `{r:248,g:250,b:252}` for surfaces. Indigo / purple / pink / teal as defaults read as AI slop. When the user DOES name a color or mood, pick from the Flat UI defo palette (see `motion-aesthetics` skill) instead of inventing Tailwind values. ONE chromatic accent + neutrals; no rainbow palettes.\n\n**Defaults to replacing `fills[0]`.** Pass `replace_first: false` to append as a second fill instead.\n\nColor stops: each stop has `offset` (0–1, position along the gradient line), `color` ({r,g,b} 0–255), and optional `opacity` (0–1, default 1). Keep stop count modest (2–5 stops typical). For premium 'glow' effects fade the color to pure black and set blend mode with `set_blend_mode`.\n\n**Gradient geometry — `start` / `end` are PIXEL coordinates in layer-local space (NOT normalized 0–1 ratios).** The shape primitive sits at the layer's local origin (0, 0); a rectangle/ellipse with size {W, H} extends from (-W/2, -H/2) to (+W/2, +H/2). The gradient line runs from `start` to `end`; offset 0 is at `start`, offset 1 is at `end`. Common patterns for a centered shape with size {W, H}:\n\n| Direction | start | end |\n| ---------------- | -------------- | -------------- |\n| Top → bottom | {x:0, y:-H/2} | {x:0, y:H/2} |\n| Bottom → top | {x:0, y:H/2} | {x:0, y:-H/2} |\n| Left → right | {x:-W/2, y:0} | {x:W/2, y:0} |\n| Diagonal TL→BR | {x:-W/2, y:-H/2}| {x:W/2, y:H/2}|\n| Radial center-out| {x:0, y:0} | {x:W/2, y:0} |\n\n**Omit `start`/`end` entirely if you don't have a strong opinion** — the native tool computes sensible pixel defaults from the layer's bounds (linear: top→bottom; radial: center→edge). Do NOT pass {0, 0}–{0, 1} or {0.5, 0}–{0.5, 1} thinking those are 'normalized' coordinates: those would render the gradient inside a 1-pixel strip and you'll see only the first stop's color across the entire shape.\n\n- Radial-only: `highlightAngle` (degrees) and `highlightLength` (-100 to 100) shift the focal point off-center.\n\nTo animate paint, use `animate_paint` for gradient stops/handles or `apply_keyframes` with `fill_color` for SOLID color cycles. `set_fill` is for changing the static fill kind.\n\n**Grouped layers (imported Lottie / SVG art, shapes the user grouped):** when every path of a layer sits inside groups, the visible color is the groups' own fills — `read_scene` lists them under the layer's `nested_paints` with their `group_id`s. `set_fill` on such a layer recolors every nested fill in place as one undo step (instead of adding a layer-level fill that would render hidden underneath) and returns `applied_to: 'nested_groups'` with `paints_updated`. To recolor only one part of a multi-color layer, pass that part's `group_id` as `layer_id`.",
1046
1087
  inputSchema: z.object({
1047
- layer_id: z.string(),
1088
+ layer_id: z.string().describe("Layer id from `read_scene`, or a `group_id` from its `nested_paints` (or a selected group id) to paint just that group."),
1048
1089
  replace_first: z.boolean().describe("If true (default), remove fills[0] before adding. If false, append.").optional(),
1049
- fill: paintInputSchema.describe("The paint to apply, as an OBJECT: `{ type: 'SOLID', color: {r,g,b} }`, `{ type: 'GRADIENT_LINEAR', stops: [...] }`, or `{ type: 'GRADIENT_RADIAL', stops: [...] }`. **Not the same `fill` as `create_shape` / `create_text`, where `fill` is a palette NAME STRING and a plain colour goes in `fill_color`.** This schema is strict — a bare string or a `{r,g,b}` without a `type` is rejected.")
1090
+ fill: paintToolInputSchema.describe("The paint to apply, as an OBJECT: `{ type: 'SOLID', color: {r,g,b} }`, `{ type: 'GRADIENT_LINEAR', stops: [...] }`, or `{ type: 'GRADIENT_RADIAL', stops: [...] }`. **Not the same `fill` as `create_shape` / `create_text`, where `fill` is a palette NAME STRING and a plain colour goes in `fill_color`.** Pass a real object; a JSON string of the same object is accepted only for compatibility, and a bare color string or a `{r,g,b}` without a `type` is rejected.")
1050
1091
  }).strict()
1051
1092
  },
1052
1093
  set_stroke: {
1053
- description: "Set (or replace) the stroke on a SHAPE_LAYER. Mirror of `set_fill` but for outlines/lines, plus a `width` (pixels). Supports SOLID and gradient strokes (same stop structure as set_fill).\n\nDefaults to replacing `strokes[0]`. Pass `replace_first: false` to append.\n\nAfter the stroke exists, you can animate its width over time with `apply_keyframes` property 'stroke_width'.",
1094
+ description: "Set (or replace) the stroke on a SHAPE_LAYER. Mirror of `set_fill` but for outlines/lines, plus a `width` (pixels). Supports SOLID and gradient strokes (same stop structure as set_fill).\n\nDefaults to replacing `strokes[0]`. Pass `replace_first: false` to append.\n\nAfter the stroke exists, you can animate its width over time with `apply_keyframes` property 'stroke_width'.\n\nOn a layer whose paths all sit inside groups (see `read_scene` → `nested_paints`), the groups' own strokes are updated in place, same as `set_fill`; pass a `group_id` as `layer_id` to update one group.",
1054
1095
  inputSchema: z.object({
1055
- layer_id: z.string(),
1096
+ layer_id: z.string().describe("Layer id from `read_scene`, or a `group_id` from its `nested_paints` (or a selected group id) to paint just that group."),
1056
1097
  width: z.number().min(.1).describe("Stroke width in pixels."),
1057
1098
  replace_first: z.boolean().describe("If true (default), replace strokes[0]; else append.").optional(),
1058
- fill: paintInputSchema.describe("The stroke's paint, as an OBJECT — `{ type: 'SOLID', color: {r,g,b} }` or a gradient, same shape as `set_fill.fill`. Named `fill` because a stroke is painted with a fill; it is NOT the palette NAME STRING that `create_shape.fill` / `create_text.fill` take.")
1099
+ fill: paintToolInputSchema.describe("The stroke's paint, as an OBJECT — `{ type: 'SOLID', color: {r,g,b} }` or a gradient, same shape as `set_fill.fill`. Named `fill` because a stroke is painted with a fill; it is NOT the palette NAME STRING that `create_shape.fill` / `create_text.fill` take.")
1059
1100
  }).strict()
1060
1101
  },
1061
1102
  animate_paint: {
1062
1103
  description: "Keyframe fill or stroke paint directly. Use this for true animated gradients: create/ensure a gradient fill or stroke, then animate `gradient_stops`, `gradient_start`, `gradient_end`, or radial highlight properties. Use `solid_color` for a SOLID paint color cycle. This is the preferred tool for prompts like 'add animated gradients', 'make the gradient sweep', or 'animate the stroke colors'.",
1063
1104
  inputSchema: z.object({
1064
- layer_id: z.string(),
1105
+ layer_id: z.string().describe("Layer id from `read_scene`, or a `group_id` from its `nested_paints` (or a selected group id) to animate just that group."),
1065
1106
  target: z.enum(["fill", "stroke"]),
1066
- paint_index: z.number().min(0).optional(),
1107
+ paint_index: z.number().int().min(0).optional(),
1067
1108
  ensure: paintInputWithOptionalStrokeWidthSchema.optional(),
1068
1109
  property: z.enum([
1069
1110
  "solid_color",
@@ -1121,7 +1162,7 @@ const allClientTools = {
1121
1162
  }).strict()
1122
1163
  },
1123
1164
  add_drop_shadow: {
1124
- description: "Attach a native Drop Shadow effect to a layer. Renders in Creator's preview and in dotlottie-web (the default Lottie player). lottie-web silently drops it.\n\nUse for: card/panel shadows, button lift, soft glow (distance=0 + large blur), text shadow. **Replaces the legacy 'duplicate dark rectangle behind the card' workaround** — call this instead.\n\n**Prefer `elevation` for UI surfaces.** Pass `elevation:'resting'|'raised'|'floating'` to attach a tuned TWO-shadow stack (a tight contact shadow + a soft ambient one, slate-tinted) in one call — this reads far more premium than a single hand-tuned shadow. `resting` = a card at rest, `raised` = hover/lifted, `floating` = modal/popover. When `elevation` is set the manual color/opacity/angle/distance/blur are ignored.\n\nManual single-shadow param tips (when NOT using `elevation`):\n- `color` defaults to black {r:0,g:0,b:0}. Tint toward dark slate {r:15,g:23,b:42} for premium UI shadows.\n- `opacity` is 0-255 (Lottie convention). 191 ≈ 75% (default). 64 ≈ 25% for subtle, 230 ≈ 90% for dramatic.\n- `angle` in degrees, 135 = bottom-right (default). 90 = straight down, 270 = straight up.\n- `distance` in px. 0 = no offset (creates a soft glow). 4-8 = resting card. 12-24 = floating/modal lift.\n- `blur` in px. 0-2 = hard shadow. 8-15 = soft. 20-40 = very soft. Default 20.",
1165
+ description: "Attach a native Drop Shadow effect to a layer. Renders in Creator's preview and in dotlottie-web (the default Lottie player). lottie-web silently drops it.\n\nUse for: card/panel shadows, button lift, soft glow (distance=0 + large blur), text shadow. **Replaces the legacy 'duplicate dark rectangle behind the card' workaround** — call this instead.\n\n**Prefer `elevation` for UI surfaces.** Pass `elevation:'resting'|'raised'|'floating'` to attach a tuned TWO-shadow stack (a tight contact shadow + a soft ambient one, slate-tinted) in one call — this reads far more premium than a single hand-tuned shadow. `resting` = a card at rest, `raised` = hover/lifted, `floating` = modal/popover. When `elevation` is set the manual color/opacity/angle/distance/blur are ignored.\n\nManual single-shadow param tips (when NOT using `elevation`):\n- `color` defaults to near-black {r:17,g:17,b:17}. Tint toward dark slate {r:15,g:23,b:42} for premium UI shadows.\n- `opacity` is 0-255 (Lottie convention). 89 ≈ 35% (default). 64 ≈ 25% for subtle, 191 ≈ 75% for strong, 230 ≈ 90% for dramatic.\n- `angle` in degrees, default 120. 135 = bottom-right, 90 = straight down, 270 = straight up.\n- `distance` in px. 0 = no offset (creates a soft glow). 4-8 = resting card. 12-24 = floating/modal lift.\n- `blur` in px. 0-2 = hard shadow. 8-15 = soft. 20-40 = very soft. Default 24.",
1125
1166
  inputSchema: z.object({
1126
1167
  layer_id: z.string(),
1127
1168
  color: z.object({
@@ -1142,14 +1183,14 @@ const allClientTools = {
1142
1183
  }).strict()
1143
1184
  },
1144
1185
  add_blur: {
1145
- description: "Attach a native Gaussian Blur effect to a layer. Renders in Creator's preview and in dotlottie-web. lottie-web silently drops it.\n\nUse for: blurred backgrounds, focus/defocus effects, soft glow with drop_shadow distance=0, glassy/frosted surfaces.\n\nParam tips:\n- `sigma` (px): blur strength. 5-10 = subtle, 15-25 = noticeable, 30-60 = dreamy/dramatic. Default 20.\n- `direction`: 0 = both axes (default), 1 = horizontal only, 2 = vertical only.\n- `wrap`: 0 = clamp edges (default, normal). 1 = repeat. Almost always leave at 0.",
1186
+ description: "Attach a native Gaussian Blur effect to a layer. Renders in Creator's preview and in dotlottie-web. lottie-web silently drops it.\n\nUse for: blurred backgrounds, focus/defocus effects, soft glow with drop_shadow distance=0, glassy/frosted surfaces.\n\nParam tips:\n- `sigma` (px): blur strength. 5-10 = subtle, 15-25 = noticeable, 30-60 = dreamy/dramatic. Default 10.\n- `direction`: 1 = both axes (default), 2 = horizontal only, 3 = vertical only.\n- `wrap`: 0 = clamp edges (default, normal). 1 = repeat. Almost always leave at 0.",
1146
1187
  inputSchema: z.object({
1147
1188
  layer_id: z.string(),
1148
1189
  sigma: z.number().min(0).optional(),
1149
1190
  direction: z.union([
1150
- z.literal(0),
1151
1191
  z.literal(1),
1152
- z.literal(2)
1192
+ z.literal(2),
1193
+ z.literal(3)
1153
1194
  ]).optional(),
1154
1195
  wrap: z.union([z.literal(0), z.literal(1)]).optional(),
1155
1196
  name: z.string().optional()
@@ -1221,7 +1262,7 @@ const allClientTools = {
1221
1262
  }).strict()
1222
1263
  },
1223
1264
  update_layer_effect: {
1224
- description: "Nudge one slot on an existing effect without removing and re-adding it. Use this when the user asks to refine an effect ('a bit more blur', 'darken the shadow', 'rotate the shadow direction') — much cheaper than `remove_layer_effect` + `add_*` and preserves the slot order.\n\n**Slot indices** (match the parser order — see add_* tool docs):\n- Drop shadow: 0=color, 1=opacity (0-255), 2=angle (degrees), 3=distance (px), 4=blur (px)\n- Gaussian blur: 0=sigma, 1=direction (0|1|2), 2=wrap (0|1)\n- Fill (color overlay): 2=color, 6=opacity (0-1)\n- Tint: 0=black color, 1=white color, 2=intensity (0-100)\n- Tritone: 0=highlight, 1=midtone, 2=shadow, 3=intensity (0-100)\n\nPass `value` as a number for scalar/angle slots and as an `{r,g,b}` triplet for color slots. Call `list_layer_effects` first if you don't already have the `effect_id`.",
1265
+ description: "Nudge one slot on an existing effect without removing and re-adding it. Use this when the user asks to refine an effect ('a bit more blur', 'darken the shadow', 'rotate the shadow direction') — much cheaper than `remove_layer_effect` + `add_*` and preserves the slot order.\n\n**Slot indices** (match the parser order — see add_* tool docs):\n- Drop shadow: 0=color, 1=opacity (0-255), 2=angle (degrees), 3=distance (px), 4=blur (px)\n- Gaussian blur: 0=sigma, 1=direction (1=both | 2=horizontal | 3=vertical), 2=wrap (0|1)\n- Fill (color overlay): 2=color, 6=opacity (0-1)\n- Tint: 0=black color, 1=white color, 2=intensity (0-100)\n- Tritone: 0=highlight, 1=midtone, 2=shadow, 3=blend with original (0-100, where 0 is a full recolor and 100 leaves the original untouched, the inverse of `add_tritone`'s `intensity`)\n\nPass `value` as a number for scalar/angle slots and as an `{r,g,b}` triplet for color slots. Call `list_layer_effects` first if you don't already have the `effect_id`.",
1225
1266
  inputSchema: z.object({
1226
1267
  layer_id: z.string(),
1227
1268
  effect_id: z.string(),
@@ -1879,7 +1920,7 @@ const allClientTools = {
1879
1920
  }).strict()
1880
1921
  },
1881
1922
  duplicate_layer: {
1882
- description: "Duplicate an existing layer N times and return the new layer IDs. Optional `stagger_frames` offsets each duplicate's timelineOffset by that many frames (i × stagger) — use for stagger/wave effects. Optional `offset` shifts each duplicate's position by (i × offset.x, i × offset.y) — use for arrays and rows.\n\nCommon flows:\n- 'make a row of 5 dots spaced 40px apart': duplicate_layer with count=4 (+ the source = 5 total) and offset={x:40,y:0}, then a single apply_keyframes call on the source animates the base motion.\n- 'stagger 5 letters': duplicate_layer with count=4 and stagger_frames=3; each clone gets timelineOffset = source + i*3.\n- **2-D grid (rows × cols)**: call duplicate_layer once per row — first call with count=cols-1 and offset={x:tile,y:0} (top row), then for each additional row, call with count=cols-1 on ONE of the top-row tiles and offset={x:0,y:tile}. Or simpler: N serialized calls, one per row, each duplicating a source tile with x-offsets. Do NOT attempt a 2-D grid with a single duplicate_layer call — `offset` is 1-D.\n\nReturns an array of `{ layer_id, name }` for the new layers, in creation order. Capped at 24 copies per call.",
1923
+ description: "Duplicate an existing layer N times and return the new layer IDs. Optional `stagger_frames` offsets each duplicate's timelineOffset by that many frames (i × stagger) — use for stagger/wave effects. Optional `offset` shifts each duplicate's position by (i × offset.x, i × offset.y) — use for arrays and rows.\n\nCommon flows:\n- 'make a row of 5 dots spaced 40px apart': duplicate_layer with count=4 (+ the source = 5 total) and offset={x:40,y:0}, then a single apply_keyframes call on the source animates the base motion.\n- 'stagger 5 letters': duplicate_layer with count=4 and stagger_frames=3; each clone gets timelineOffset = source + i*3.\n- **2-D grid (rows × cols)**: call duplicate_layer once per row — first call with count=cols-1 and offset={x:tile,y:0} (top row), then for each additional row, call with count=cols-1 on ONE of the top-row tiles and offset={x:0,y:tile}. Or simpler: N serialized calls, one per row, each duplicating a source tile with x-offsets. Do NOT attempt a 2-D grid with a single duplicate_layer call — `offset` is 1-D.\n\nReturns an array of `{ layer_id, name }` for the new layers, in creation order. Capped at 24 copies per call. Copies are named `<name> <n>`, so a text copy keeps that name when its text changes.",
1883
1924
  inputSchema: z.object({
1884
1925
  layer_id: z.string().describe("Source layer id from `read_scene`."),
1885
1926
  count: z.number().min(1).max(24).describe("How many duplicates to create. Default 1. Capped at 24 to keep the timeline sane.").optional(),
@@ -1892,7 +1933,7 @@ const allClientTools = {
1892
1933
  }).strict()
1893
1934
  },
1894
1935
  tile_layer: {
1895
- description: "Fill the active scene with a grid of copies of a layer. This is the tool to reach for whenever the user says 'tile across the scene', 'fill with a grid of', 'cover with', 'scatter N', or 'pattern the background with'. It ALWAYS uses `Math.floor` for auto cols/rows so tiles never spill outside the scene bounds.\n\nBehavior:\n- `tile_width` / `tile_height` default to the source layer's visual bounds (read via getBounds). For a polygon with outerRadius 50, that's ~100×100.\n- `cols` / `rows` default to `floor((scene_dim − 2*margin + gap) / (tile_dim + gap))` so the grid exactly fits. Pass either explicitly to override.\n- Grid is centered inside the `margin`-inset scene rect.\n- Optional `gap` between tiles (default 0).\n- Optional `margin` (default 0) to inset the grid from scene edges.\n- Optional `alternate_flip: true` rotates odd rows by 180° — ideal for triangle tessellation.\n- Reuses the source layer as cell (0,0); only cols×rows−1 new layers are created.\n- Hard cap of 144 tiles (12×12) to keep Creator responsive. Throws if exceeded.\n\nExample: 'tile this triangle to fill the scene' → one call, no params beyond layer_id. For a 512×512 scene with a 100px triangle: fills as 5×5 with the grid centered.",
1936
+ description: "Fill the active scene with a grid of copies of a layer. This is the tool to reach for whenever the user says 'tile across the scene', 'fill with a grid of', 'cover with', 'scatter N', or 'pattern the background with'. It ALWAYS uses `Math.floor` for auto cols/rows so tiles never spill outside the scene bounds.\n\nBehavior:\n- `tile_width` / `tile_height` default to the source layer's visual bounds (read via getBounds). For a polygon with outerRadius 50, that's ~100×100.\n- `cols` / `rows` default to `floor((scene_dim − 2*margin + gap) / (tile_dim + gap))` so the grid exactly fits. Pass either explicitly to override.\n- Grid is centered inside the `margin`-inset scene rect.\n- Optional `gap` between tiles (default 0).\n- Optional `margin` (default 0) to inset the grid from scene edges.\n- Optional `alternate_flip: true` rotates odd rows by 180° — ideal for triangle tessellation.\n- Reuses the source layer as cell (0,0); only cols×rows−1 new layers are created.\n- Hard cap of 144 tiles (12×12) to keep Creator responsive. Throws if exceeded.\n\nExample: 'tile this triangle to fill the scene' → one call, no params beyond layer_id. For a 512×512 scene with a 100px triangle: fills as 5×5 with the grid centered. Tiles are named `<name> r<row>c<col>`, so a text tile keeps that name when its text changes.",
1896
1937
  inputSchema: z.object({
1897
1938
  layer_id: z.string().describe("Source layer to duplicate into the grid."),
1898
1939
  cols: z.number().min(1).max(20).describe("Explicit column count. Omit to auto-fit from tile_width + margin + gap.").optional(),
@@ -2062,7 +2103,7 @@ const allClientTools = {
2062
2103
  inputSchema: z.object({ layer_id: z.string() }).strict()
2063
2104
  },
2064
2105
  rename_node: {
2065
- description: "Rename a layer, shape, group, mask, or scene node by id. Use for cleanup and making generated structures understandable.",
2106
+ description: "Rename a layer, shape, group, mask, or scene node by id. Use for cleanup and making generated structures understandable. A renamed text layer keeps its name when its text changes.",
2066
2107
  inputSchema: z.object({
2067
2108
  node_id: z.string(),
2068
2109
  name: z.string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lottiefiles/creator-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "MCP server for LottieFiles Creator",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "tsx": "4.21.0",
32
32
  "typescript": "5.8.3",
33
33
  "typescript-eslint": "8.29.0",
34
- "vitest": "4.1.9",
34
+ "vitest": "4.1.10",
35
35
  "@lottiefiles/creator-mcp-shared": "0.1.0",
36
- "@lottiefiles/motion-copilot-contract": "0.2.1"
36
+ "@lottiefiles/motion-copilot-contract": "0.2.2"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsdown",