Skip to content

Animation System V2 — Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the preset-based animation system with a composable effect-based system supporting multi-step keyframe effects, custom bezier easing, state-aware triggers, copy/paste, and flow-triggered animations.

Architecture: Each block's AnimationConfig changes from a single preset string to an array of AnimationEffect objects, each with type and keyframes (array of {percent, value} stops). Simple cases are just two stops (from→to), complex cases like bounce have intermediate stops. In Creative-Engine, CSS @keyframes are generated dynamically by merging all effects at each percent stop. The AnimationMixin is extended with state-awareness (pause during hover/active/focus states) and trigger filtering for stateful blocks. A new AnimationConfigCard component replaces the current per-config-file animation sections. Flow-triggered animations reuse the existing OperatorStyles pattern — a panel on flow operators that lets users select target blocks and animation configs. No backward compatibility needed — the current animation system is not yet live.

Tech Stack: Vue 2 (Options API), Vuex, TypeScript, CSS Keyframes, cubic-bezier, StyleTreeParser

Repos:

  • Application-Frontend — branch add-animation-system
  • Creative-Engine — branch add-animation-system

Issue: Cavai/Application-Frontend#1804


File Structure

Application-Frontend — New Files

FileResponsibility
Configuration/components/AnimationEffectRow.vueSingle effect row: type dropdown + from/to inputs + remove button
Configuration/components/AnimationEffectList.vueList of effect rows + "Add effect" button
Configuration/components/BezierEasingEditor.vueVisual cubic-bezier curve editor with drag handles
Configuration/components/AnimationConfigCard.vueComplete animation card (effects, timing, trigger) — shared by all configs
CavaiFlow/flowactors/OperatorAnimations.vueFlow-triggered animation panel (like OperatorStyles)

Application-Frontend — Modified Files

FileChanges
Blocks/data/types.tsReplace AnimationConfig with effect-based types
Blocks/data/defaults.tsUpdate animationDefaults() factory
Blocks/utils.tsUpdate sectionSettings for new animation sections
Configuration/mixins/configurationLogic.tsAdd animCopyPayload computed, copy/paste methods
Configuration/configs/*Configuration.vue (×8)Replace inline animation sections with AnimationConfigCard
assets/i18n/en.jsAdd effect type labels, bezier preset names
store/modules/blocks.tsAdd animationClipboard state + mutations
CavaiFlow/flowactors/OperatorBase.vueAdd animation icon next to paint-roll
CavaiFlow/CavaiFlow.vueAdd animation panel positioning + show/hide logic

Creative-Engine — Modified Files

FileChanges
interfaces/jsonTypes/payload-v2/index.tsMirror new effect-based types
styles/animations/presets.tsPreset effect arrays for quick-fill
styles/animations/helpers.tsDynamic keyframe generation from effects
mixins/AnimationMixin.tsState-awareness, flow-trigger listener, pause/resume
components/creative/VisualElements/CreativeButtonBlock.vuePause animation during state changes
interfaces/operator/OperatorPropertiesInterface.tsAdd animationTrigger to operator properties

Chunk 1: New Data Model & Backward Compatibility

Task 1: Define new effect-based types in Application-Frontend

Files:

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts

  • [ ] Step 1: Replace AnimationConfig types

Remove ALL current animation types (AnimationPreset, AnimationTrigger, AnimationDirection, AnimationConfig — lines ~70-98) and replace with:

typescript
export type AnimationEffectType = 'fade' | 'slideX' | 'slideY' | 'scale' | 'rotate' | 'blur' | 'skew'

export type AnimationKeyframe = {
  percent: number  // 0-100
  value: number
}

export type AnimationEffect = {
  type: AnimationEffectType
  keyframes: AnimationKeyframe[]  // Min 2 entries. Simple: [{percent:0, value:0}, {percent:100, value:1}]
}

export type AnimationTrigger = 'load' | 'hover' | 'click' | 'scroll'

export type AnimationDirection = 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'

export type AnimationConfig = {
  effects: AnimationEffect[]
  trigger: AnimationTrigger
  duration: number
  delay: number
  loop: boolean
  iterationCount: number
  direction: AnimationDirection
  easing: string // 'ease' | 'ease-in' | ... | 'cubic-bezier(x1,y1,x2,y2)'
}

No backward compatibility needed — current animation system is not live.

  • [ ] Step 2: Verify no type errors

Run: npx vue-tsc --noEmit 2>&1 | grep -i animation Expected: Only pre-existing errors (Reports, CreativeGroups)

  • [ ] Step 3: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts
git commit -m "Replace preset-based AnimationConfig with effect-based types"

Task 2: Update animation defaults

Files:

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts

  • [ ] Step 1: Update animationDefaults factory

typescript
export const animationDefaults = (overrides?: Partial<AnimationConfig>): AnimationConfig => ({
  effects: [],
  trigger: 'load',
  duration: 500,
  delay: 0,
  loop: false,
  iterationCount: 1,
  direction: 'normal',
  easing: 'ease',
  ...overrides,
})

Empty effects: [] = no animation.

  • [ ] Step 2: Add effect unit map and preset library

Helper for displaying units in the UI:

typescript
export const EFFECT_UNITS: Record<AnimationEffectType, string> = {
  fade: '',       // 0-1 opacity, unitless
  slideX: 'px',
  slideY: 'px',
  scale: '',      // unitless multiplier
  rotate: 'deg',
  blur: 'px',
  skew: 'deg',
}

Helper to create simple from→to effects:

typescript
const ft = (type: AnimationEffectType, from: number, to: number): AnimationEffect => ({
  type,
  keyframes: [{ percent: 0, value: from }, { percent: 100, value: to }],
})

Preset library — presets just populate the effects list, user can tweak after:

typescript
export const ANIMATION_PRESETS: Record<string, AnimationEffect[]> = {
  fadeIn: [ft('fade', 0, 1)],
  fadeOut: [ft('fade', 1, 0)],
  slideUp: [ft('slideY', 30, 0)],
  slideDown: [ft('slideY', -30, 0)],
  slideLeft: [ft('slideX', 30, 0)],
  slideRight: [ft('slideX', -30, 0)],
  bounce: [{
    type: 'slideY',
    keyframes: [
      { percent: 0, value: 40 },
      { percent: 60, value: -10 },
      { percent: 80, value: 5 },
      { percent: 100, value: 0 },
    ],
  }],
  pop: [{
    type: 'scale',
    keyframes: [
      { percent: 0, value: 0.5 },
      { percent: 70, value: 1.1 },
      { percent: 100, value: 1 },
    ],
  }],
  pulse: [{
    type: 'scale',
    keyframes: [
      { percent: 0, value: 1 },
      { percent: 50, value: 1.05 },
      { percent: 100, value: 1 },
    ],
  }],
  shake: [{
    type: 'slideX',
    keyframes: [
      { percent: 0, value: 0 },
      { percent: 20, value: -5 },
      { percent: 40, value: 5 },
      { percent: 60, value: -5 },
      { percent: 80, value: 5 },
      { percent: 100, value: 0 },
    ],
  }],
  rotate: [ft('rotate', 0, 360)],
  flip: [ft('rotate', 90, 0)],
}

Multi-step keyframes (bounce, pop, pulse, shake) are now fully preserved in presets — no simplification needed.

  • [ ] Step 3: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts
git commit -m "Update animationDefaults for effect-based system with migration helper"

Task 3: Mirror types in Creative-Engine

Files:

  • Modify: Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.ts

  • [ ] Step 1: Update AnimationConfig types to match AF

Same types as Task 1, replacing lines ~312-340. Remove old AnimationPreset type — no backward compatibility needed.

  • [ ] Step 2: Build CE

Run: cd Creative-Engine && npm run build Expected: Build succeeds

  • [ ] Step 3: Commit
bash
cd Creative-Engine
git add src/interfaces/jsonTypes/payload-v2/index.ts
git commit -m "Mirror effect-based AnimationConfig types from AF"

Task 4: Update CE keyframe generation

Files:

  • Modify: Creative-Engine/src/styles/animations/helpers.ts

  • Modify: Creative-Engine/src/styles/animations/presets.ts

  • [ ] Step 1: Rewrite helpers.ts for dynamic keyframe generation

typescript
import type { AnimationConfig, AnimationEffect, AnimationKeyframe } from '@/interfaces/jsonTypes/payload-v2'

export const getAnimationKeyframeName = (blockName: string): string => {
  return `cavai-anim-${blockName}`
}

/** Get the interpolated value of an effect at a given percent (0-100) */
const interpolateEffect = (keyframes: AnimationKeyframe[], percent: number): number => {
  if (keyframes.length === 0) return 0

  // Find surrounding keyframes
  let before = keyframes[0]
  let after = keyframes[keyframes.length - 1]

  for (let i = 0; i < keyframes.length - 1; i++) {
    if (keyframes[i].percent <= percent && keyframes[i + 1].percent >= percent) {
      before = keyframes[i]
      after = keyframes[i + 1]
      break
    }
  }

  if (before.percent === after.percent) return before.value

  const t = (percent - before.percent) / (after.percent - before.percent)
  return before.value + (after.value - before.value) * t
}

/** Convert an effect value to CSS at a given percent */
const getEffectCSS = (effect: AnimationEffect, percent: number): Record<string, string | number> => {
  const value = interpolateEffect(effect.keyframes, percent)

  switch (effect.type) {
    case 'fade':
      return { opacity: value }
    case 'slideX':
      return { transform: `translateX(${value}px)` }
    case 'slideY':
      return { transform: `translateY(${value}px)` }
    case 'scale':
      return { transform: `scale(${value})` }
    case 'rotate':
      return { transform: `rotate(${value}deg)` }
    case 'blur':
      return { filter: `blur(${value}px)` }
    case 'skew':
      return { transform: `skew(${value}deg)` }
    default:
      return {}
  }
}

/** Merge all effects into one CSS object at a given percent */
const mergeEffectsAtPercent = (effects: AnimationEffect[], percent: number): Record<string, string | number> => {
  const result: Record<string, string | number> = {}
  const transforms: string[] = []

  for (const effect of effects) {
    const css = getEffectCSS(effect, percent)

    if (css.transform) {
      transforms.push(css.transform as string)
      continue
    }

    Object.assign(result, css)
  }

  if (transforms.length > 0) {
    result.transform = transforms.join(' ')
  }

  return result
}

/** Collect all unique percent stops across all effects */
const getAllPercentStops = (effects: AnimationEffect[]): number[] => {
  const stops = new Set<number>()
  for (const effect of effects) {
    for (const kf of effect.keyframes) {
      stops.add(kf.percent)
    }
  }
  return Array.from(stops).sort((a, b) => a - b)
}

/** Build CSS @keyframes object from effects */
export const buildKeyframes = (effects: AnimationEffect[]): Record<string, Record<string, string | number>> => {
  const stops = getAllPercentStops(effects)
  const keyframes: Record<string, Record<string, string | number>> = {}

  for (const percent of stops) {
    const key = percent === 0 ? 'from' : percent === 100 ? 'to' : `${percent}%`
    keyframes[key] = mergeEffectsAtPercent(effects, percent)
  }

  return keyframes
}

export const buildAnimationString = (config: AnimationConfig, keyframeName: string): string => {
  const iterationCount = config.loop ? 'infinite' : config.iterationCount
  return `${keyframeName} ${config.duration}ms ${config.easing} ${config.delay}ms ${iterationCount} ${config.direction} both`
}

export const getAnimationStyles = (
  config: AnimationConfig | undefined,
  blockName: string,
): { animationStyle: Record<string, string>, keyframeRule: Record<string, any> } | null => {
  if (!config || config.effects.length === 0) return null

  const keyframeName = getAnimationKeyframeName(blockName)
  const keyframes = buildKeyframes(config.effects)

  const animationStyle: Record<string, string> = {}

  if (config.trigger === 'load') {
    animationStyle.animation = buildAnimationString(config, keyframeName)
  }

  const keyframeRule = {
    [`@keyframes ${keyframeName}`]: keyframes,
  }

  return { animationStyle, keyframeRule }
}

export const getHoverAnimationStyle = (
  config: AnimationConfig | undefined,
  blockName: string,
): Record<string, string> | null => {
  if (!config || config.effects.length === 0 || config.trigger !== 'hover') return null

  const keyframeName = getAnimationKeyframeName(blockName)
  return {
    animation: buildAnimationString(config, keyframeName),
  }
}

Key improvement: getAllPercentStops() collects every unique percent from all effects and generates a CSS keyframe stop for each. Effects with different percent stops are interpolated at shared stops. This means a fade [0%→100%] combined with a bounce [0%, 60%, 80%, 100%] produces keyframe stops at 0%, 60%, 80%, 100% with the fade value interpolated at 60% and 80%.

  • [ ] Step 2: Delete presets.ts

The old preset keyframe definitions are no longer needed — presets now live in defaults.ts (AF) as ANIMATION_PRESETS using the keyframe format. Delete Creative-Engine/src/styles/animations/presets.ts entirely and remove any imports referencing it.

  • [ ] Step 3: Update AnimationMixin for effects-only format (no backward compat)

In AnimationMixin.ts, simplify animationConfig computed — no migration needed since the old system is not live:

typescript
animationConfig(): AnimationConfig | undefined {
  const raw = this.block?.animation
  if (!raw || !raw.effects || raw.effects.length === 0) return undefined
  return raw
},

Update setupAnimationTrigger — use effects.length check:

typescript
setupAnimationTrigger() {
  const config = this.animationConfig
  if (!config || config.effects.length === 0) return

  if (config.trigger === 'scroll') {
    // ... existing IntersectionObserver code
  }
},

Remove the PRESET_TO_EFFECTS import (file was deleted in Step 2).

  • [ ] Step 4: Build CE and verify

Run: cd Creative-Engine && npm run build Expected: Build succeeds

  • [ ] Step 5: Commit
bash
cd Creative-Engine
git rm src/styles/animations/presets.ts
git add src/styles/animations/helpers.ts src/mixins/AnimationMixin.ts
git commit -m "Rewrite animation rendering for composable effect-based system"

Chunk 2: UI — Effect Builder & Shared AnimationConfigCard

Task 5: Create AnimationEffectRow component

Files:

  • Create: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationEffectRow.vue

  • [ ] Step 1: Create the component

A single effect row with a type dropdown, expandable keyframe list, unit display, and remove button. Each effect shows its keyframes as [percent%: value] rows. Simple effects (2 keyframes at 0% and 100%) show inline, complex ones expand.

vue
<template>
  <div class="animation-effect-row">
    <div class="effect-header">
      <InputSelect
        class="effect-type"
        :items="effectTypeItems"
        :value="effect.type"
        :disabled="disabled"
        @change="updateType($event)"
      />
      <span class="effect-unit">{{ unit }}</span>
      <button
        v-if="!disabled"
        class="effect-add-keyframe"
        :title="$t('visuals.animation.addKeyframe')"
        @click="addKeyframe"
      >
        +
      </button>
      <button
        v-if="!disabled"
        class="effect-remove"
        @click="$emit('remove')"
      >
        <CIcon name="trash" :size="14" />
      </button>
    </div>

    <div class="keyframe-list">
      <div
        v-for="(kf, i) in effect.keyframes"
        :key="i"
        class="keyframe-row"
      >
        <InputField
          class="kf-percent"
          :value="kf.percent"
          :disabled="disabled || (i === 0 && kf.percent === 0) || (i === effect.keyframes.length - 1 && kf.percent === 100)"
          number-input
          :min="0"
          :max="100"
          @input="updateKeyframe(i, 'percent', Number($event))"
        />
        <span class="kf-separator">%</span>
        <InputField
          class="kf-value"
          :value="kf.value"
          :disabled="disabled"
          number-input
          @input="updateKeyframe(i, 'value', Number($event))"
        />
        <button
          v-if="!disabled && effect.keyframes.length > 2"
          class="kf-remove"
          @click="removeKeyframe(i)"
        >
          ×
        </button>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import CIcon from '@/components/common/Icon/CIcon.vue'
import InputField from '@/components/common/InputField.vue'
import InputSelect from '@/components/common/InputSelect.vue'
import { EFFECT_UNITS } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults'
import type { AnimationEffect, AnimationEffectType, AnimationKeyframe } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/types'

export default {
  name: 'AnimationEffectRow',
  components: { CIcon, InputField, InputSelect },
  props: {
    effect: { type: Object as () => AnimationEffect, required: true },
    disabled: Boolean,
  },
  data() {
    return {
      effectTypeItems: Object.freeze(
        (['fade', 'slideX', 'slideY', 'scale', 'rotate', 'blur', 'skew'] as AnimationEffectType[]).map((value) => ({
          value,
          text: this.$t(`visuals.animation.effectTypes.${value}`),
        })),
      ),
    }
  },
  computed: {
    unit(): string {
      return EFFECT_UNITS[this.effect.type] || ''
    },
  },
  methods: {
    updateType(type: AnimationEffectType) {
      this.$emit('update', { ...this.effect, type })
    },
    updateKeyframe(index: number, field: keyof AnimationKeyframe, value: number) {
      const keyframes = [...this.effect.keyframes]
      keyframes[index] = { ...keyframes[index], [field]: value }
      // Keep sorted by percent
      keyframes.sort((a, b) => a.percent - b.percent)
      this.$emit('update', { ...this.effect, keyframes })
    },
    addKeyframe() {
      const keyframes = [...this.effect.keyframes]
      // Insert at 50% by default, with value interpolated from neighbors
      const lastKf = keyframes[keyframes.length - 1]
      const firstKf = keyframes[0]
      const midValue = (firstKf.value + lastKf.value) / 2
      keyframes.push({ percent: 50, value: Math.round(midValue * 100) / 100 })
      keyframes.sort((a, b) => a.percent - b.percent)
      this.$emit('update', { ...this.effect, keyframes })
    },
    removeKeyframe(index: number) {
      const keyframes = this.effect.keyframes.filter((_, i) => i !== index)
      this.$emit('update', { ...this.effect, keyframes })
    },
  },
}
</script>

<style scoped lang="scss">
.animation-effect-row {
  padding: 4px 12px;
  border-bottom: 1px solid #f0f0f0;

  &:last-child {
    border-bottom: none;
  }

  .effect-header {
    display: flex;
    align-items: center;
    gap: 4px;
    margin-bottom: 4px;
  }

  .effect-type {
    width: 90px;
    flex-shrink: 0;
  }

  .effect-unit {
    width: 24px;
    color: #999;
    font-size: 12px;
  }

  .effect-add-keyframe {
    background: none;
    border: 1px dashed #ccc;
    border-radius: 3px;
    width: 20px;
    height: 20px;
    cursor: pointer;
    font-size: 12px;
    color: #666;
    margin-left: auto;

    &:hover {
      border-color: #999;
    }
  }

  .effect-remove {
    background: none;
    border: none;
    cursor: pointer;
    opacity: 0.5;
    padding: 2px;

    &:hover {
      opacity: 1;
    }
  }

  .keyframe-list {
    padding-left: 8px;
  }

  .keyframe-row {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 2px 0;
  }

  .kf-percent {
    width: 45px;
    flex-shrink: 0;
  }

  .kf-separator {
    color: #999;
    font-size: 11px;
  }

  .kf-value {
    width: 55px;
    flex-shrink: 0;
  }

  .kf-remove {
    background: none;
    border: none;
    cursor: pointer;
    opacity: 0.4;
    font-size: 14px;
    padding: 0 2px;

    &:hover {
      opacity: 1;
    }
  }
}
</style>
  • [ ] Step 2: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationEffectRow.vue
git commit -m "Add AnimationEffectRow component for effect-based animation builder"

Task 6: Create AnimationEffectList component

Files:

  • Create: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationEffectList.vue

  • [ ] Step 1: Create the component

List of effect rows with add button and quick-preset dropdown.

vue
<template>
  <div class="animation-effect-list">
    <div
      v-if="effects.length === 0"
      class="no-effects"
    >
      {{ $t('visuals.animation.noEffects') }}
    </div>

    <AnimationEffectRow
      v-for="(effect, index) in effects"
      :key="index"
      :effect="effect"
      :disabled="disabled"
      @update="updateEffect(index, $event)"
      @remove="removeEffect(index)"
    />

    <div class="effect-actions">
      <button
        v-if="!disabled"
        class="add-effect-btn"
        @click="addEffect"
      >
        + {{ $t('visuals.animation.addEffect') }}
      </button>

      <InputSelect
        v-if="!disabled"
        class="preset-select"
        :items="presetItems"
        :value="''"
        :placeholder="$t('visuals.animation.loadPreset')"
        @change="loadPreset($event)"
      />
    </div>
  </div>
</template>

<script lang="ts">
import InputSelect from '@/components/common/InputSelect.vue'
import { ANIMATION_PRESETS } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults'
import type { AnimationEffect } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/types'
import AnimationEffectRow from './AnimationEffectRow.vue'

const DEFAULT_EFFECT: AnimationEffect = {
  type: 'fade',
  keyframes: [{ percent: 0, value: 0 }, { percent: 100, value: 1 }],
}

export default {
  name: 'AnimationEffectList',
  components: { AnimationEffectRow, InputSelect },
  props: {
    effects: { type: Array as () => AnimationEffect[], default: () => [] },
    disabled: Boolean,
  },
  data() {
    return {
      presetItems: Object.freeze(
        Object.keys(ANIMATION_PRESETS)
          .map((value) => ({
            value,
            text: this.$t(`visuals.animation.presets.${value}`),
          })),
      ),
    }
  },
  methods: {
    addEffect() {
      const newEffect = JSON.parse(JSON.stringify(DEFAULT_EFFECT))
      this.$emit('update:effects', [...this.effects, newEffect])
    },
    removeEffect(index: number) {
      const updated = this.effects.filter((_, i) => i !== index)
      this.$emit('update:effects', updated)
    },
    updateEffect(index: number, effect: AnimationEffect) {
      const updated = [...this.effects]
      updated[index] = effect
      this.$emit('update:effects', updated)
    },
    loadPreset(presetName: string) {
      const effects = ANIMATION_PRESETS[presetName]
      if (effects) {
        // Deep clone so preset values are independent
        this.$emit('update:effects', JSON.parse(JSON.stringify(effects)))
      }
    },
  },
}
</script>

<style scoped lang="scss">
.animation-effect-list {
  padding: 8px 0;

  .no-effects {
    padding: 8px 12px;
    color: #999;
    font-size: 12px;
  }

  .effect-actions {
    display: flex;
    gap: 8px;
    padding: 4px 12px;
    align-items: center;
  }

  .add-effect-btn {
    background: none;
    border: 1px dashed #ccc;
    border-radius: 4px;
    padding: 4px 8px;
    cursor: pointer;
    font-size: 12px;
    color: #666;

    &:hover {
      border-color: #999;
      color: #333;
    }
  }

  .preset-select {
    width: 120px;
  }
}
</style>
  • [ ] Step 2: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationEffectList.vue
git commit -m "Add AnimationEffectList component with preset loading"

Task 7: Create AnimationConfigCard component

Files:

  • Create: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationConfigCard.vue

  • [ ] Step 1: Create shared animation card

This replaces the inline animation sections in all 8 config files. One component, used everywhere.

vue
<template>
  <Card
    :title="$t('visuals.animation.title')"
    no-padding
  >
    <template #title-actions>
      <button
        v-if="hasEffects"
        class="copy-animation-btn"
        :title="$t('visuals.animation.copy')"
        @click="$emit('copy')"
      >
        <CIcon name="copy" :size="14" />
      </button>
      <button
        v-if="canPaste"
        class="paste-animation-btn"
        :title="$t('visuals.animation.paste')"
        @click="$emit('paste')"
      >
        <CIcon name="clipboard" :size="14" />
      </button>
    </template>

    <AnimationEffectList
      :effects="config.effects"
      :disabled="inputLocked"
      @update:effects="$emit('update:effects', $event)"
    />

    <AnimationTriggerSection
      :value="config.trigger"
      :exclude-triggers="excludeTriggers"
      @update:value="$emit('update:trigger', $event)"
    />

    <DurationSection
      :duration="config.duration"
      :min="50"
      :max="5000"
      :step="50"
      @update:value="$emit('update:duration', $event)"
    />

    <DelaySection
      :delay="config.delay"
      :min="0"
      :max="5000"
      :step="50"
      @update:value="$emit('update:delay', $event)"
    />

    <BezierEasingEditor
      :easing="config.easing"
      @update:value="$emit('update:easing', $event)"
    />

    <OptionRow
      :title="$t('visuals.animation.loop')"
      :checked="config.loop"
      @update:checked="$emit('update:loop', Boolean($event))"
    />

    <IterationsSection
      v-if="!config.loop"
      :iterations="config.iterationCount"
      :custom-title="$t('visuals.animation.iterations')"
      @update:value="$emit('update:iterationCount', $event)"
    />

    <AnimationDirectionSection
      :value="config.direction"
      @update:value="$emit('update:direction', $event)"
    />
  </Card>
</template>

<script lang="ts">
import Card from '@/components/common/Card/Card.vue'
import CIcon from '@/components/common/Icon/CIcon.vue'
import AnimationDirectionSection from './AnimationDirectionSection.vue'
import AnimationEffectList from './AnimationEffectList.vue'
import AnimationTriggerSection from './AnimationTriggerSection.vue'
import BezierEasingEditor from './BezierEasingEditor.vue'
import DurationSection from './DurationSection.vue'
import DelaySection from './DelaySection.vue'
import IterationsSection from './IterationsSection.vue'
import OptionRow from '@/pages/Chatbots/components/OptionRow/OptionRow.vue'
import type { AnimationConfig } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/types'

export default {
  name: 'AnimationConfigCard',
  components: {
    Card, CIcon, AnimationEffectList, AnimationTriggerSection,
    AnimationDirectionSection, BezierEasingEditor,
    DurationSection, DelaySection, IterationsSection, OptionRow,
  },
  props: {
    config: { type: Object as () => AnimationConfig, required: true },
    inputLocked: Boolean,
    canPaste: Boolean,
    excludeTriggers: { type: Array as () => string[], default: () => [] },
  },
  computed: {
    hasEffects(): boolean {
      return this.config.effects.length > 0
    },
  },
}
</script>

<style scoped lang="scss">
.copy-animation-btn,
.paste-animation-btn {
  background: none;
  border: none;
  cursor: pointer;
  opacity: 0.5;
  padding: 2px;

  &:hover {
    opacity: 1;
  }
}
</style>
  • [ ] Step 2: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationConfigCard.vue
git commit -m "Add shared AnimationConfigCard component"

Task 8: Update AnimationTriggerSection to support exclusions

Files:

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationTriggerSection.vue

  • [ ] Step 1: Add excludeTriggers prop

Add filtering so stateful blocks can exclude hover and click:

vue
<script lang="ts">
// ... existing imports

export default {
  name: 'AnimationTriggerSection',
  components: { OptionList, OptionButton, OptionRow },
  mixins: [sectionLogic],
  props: {
    value: {
      type: String,
      default: 'load',
    },
    excludeTriggers: {
      type: Array as () => string[],
      default: () => [],
    },
  },
  computed: {
    filteredOptions(): string[] {
      return this.allOptions.filter((opt) => !this.excludeTriggers.includes(opt))
    },
  },
  data() {
    return {
      allOptions: Object.freeze(['load', 'hover', 'click', 'scroll']),
    }
  },
}
</script>

Update template to use filteredOptions instead of triggerOptions.

  • [ ] Step 2: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/components/AnimationTriggerSection.vue
git commit -m "Add excludeTriggers prop to AnimationTriggerSection"

Task 9: Replace animation sections in all 8 config files

Files:

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/*Configuration.vue (×8)

  • [ ] Step 1: Update TextConfiguration.vue as template

Replace the entire animation Card section with:

vue
<AnimationConfigCard
  :config="animConfig"
  :input-locked="!isMasterCreative"
  :can-paste="hasAnimationClipboard"
  @update:effects="updateValue($event, 'animation.effects')"
  @update:trigger="updateValue($event, 'animation.trigger')"
  @update:duration="updateValue($event, 'animation.duration')"
  @update:delay="updateValue($event, 'animation.delay')"
  @update:easing="updateValue($event, 'animation.easing')"
  @update:loop="updateValue($event, 'animation.loop')"
  @update:iterationCount="updateValue($event, 'animation.iterationCount')"
  @update:direction="updateValue($event, 'animation.direction')"
  @copy="copyAnimation"
  @paste="pasteAnimation"
/>

Remove all individual animation section imports (AnimationPresetSection, AnimationDirectionSection, DurationSection, DelaySection, TransitionEasingSection, IterationsSection, OptionRow for loop) and replace with single AnimationConfigCard import.

For ButtonConfiguration.vue: add :exclude-triggers="['hover', 'click']" since button has stateConfig with hover/active states.

For FormConfiguration.vue: add :exclude-triggers="['hover', 'click']" if the form block has stateConfig.

  • [ ] Step 2: Apply same pattern to all 8 config files

Each config file gets the same AnimationConfigCard usage. Only excludeTriggers varies.

  • [ ] Step 3: Verify no errors

Run: npx vue-tsc --noEmit 2>&1 | grep -i animation

  • [ ] Step 4: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/
git commit -m "Replace inline animation sections with shared AnimationConfigCard in all configs"

Chunk 3: Custom Bezier Easing Editor

Task 10: Create BezierEasingEditor component

Files:

  • Create: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/BezierEasingEditor.vue

  • [ ] Step 1: Create the component

A visual cubic-bezier editor with:

  • SVG canvas showing the curve
  • Two draggable control points (P1, P2)
  • Preset buttons for common easings
  • Text input for manual entry
vue
<template>
  <OptionRow
    v-bind="overrideProps"
    :title="$t('visuals.animation.easing')"
    no-toggle
    v-on="overrideHandlers"
  >
    <div class="bezier-editor">
      <div class="bezier-presets">
        <OptionButton
          v-for="preset in easingPresets"
          :key="preset.value"
          :selected="easing === preset.value"
          :disabled="inputLocked"
          @click="$emit('update:value', preset.value)"
        >
          {{ preset.label }}
        </OptionButton>
      </div>

      <div
        v-if="isCubicBezier"
        class="bezier-canvas-wrap"
      >
        <svg
          ref="canvas"
          class="bezier-canvas"
          viewBox="0 0 100 100"
          @mousedown="startDrag"
          @mousemove="onDrag"
          @mouseup="stopDrag"
          @mouseleave="stopDrag"
        >
          <!-- Grid lines -->
          <line x1="0" y1="100" x2="100" y2="0" stroke="#eee" stroke-width="0.5" />

          <!-- Control lines -->
          <line :x1="0" :y1="100" :x2="p1.x" :y2="100 - p1.y" stroke="#aaa" stroke-width="0.5" stroke-dasharray="2" />
          <line :x1="100" :y1="0" :x2="p2.x" :y2="100 - p2.y" stroke="#aaa" stroke-width="0.5" stroke-dasharray="2" />

          <!-- Curve -->
          <path
            :d="curvePath"
            fill="none"
            stroke="#2563EB"
            stroke-width="2"
          />

          <!-- Control points -->
          <circle
            :cx="p1.x" :cy="100 - p1.y" r="4"
            fill="#2563EB" stroke="white" stroke-width="1"
            class="control-point"
            @mousedown.stop="activeDrag = 'p1'"
          />
          <circle
            :cx="p2.x" :cy="100 - p2.y" r="4"
            fill="#2563EB" stroke="white" stroke-width="1"
            class="control-point"
            @mousedown.stop="activeDrag = 'p2'"
          />
        </svg>
      </div>

      <input
        class="bezier-text-input"
        :value="easing"
        :disabled="inputLocked"
        @change="$emit('update:value', $event.target.value)"
      />
    </div>
  </OptionRow>
</template>

<script lang="ts">
import OptionButton from '@/components/common/Button/OptionButton.vue'
import { sectionLogic } from '@/pages/Chatbots/components/BuilderVisuals/Configuration/mixins/sectionLogic'
import OptionRow from '@/pages/Chatbots/components/OptionRow/OptionRow.vue'

const EASING_PRESETS = [
  { value: 'ease', label: 'Ease' },
  { value: 'ease-in', label: 'In' },
  { value: 'ease-out', label: 'Out' },
  { value: 'ease-in-out', label: 'In-Out' },
  { value: 'linear', label: 'Linear' },
  { value: 'cubic-bezier(0.68,-0.55,0.27,1.55)', label: 'Bounce' },
]

const parseBezier = (value: string): [number, number, number, number] | null => {
  const match = value.match(/cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/)
  if (!match) return null
  return [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])]
}

export default {
  name: 'BezierEasingEditor',
  components: { OptionButton, OptionRow },
  mixins: [sectionLogic],
  props: {
    easing: { type: String, default: 'ease' },
  },
  data() {
    return {
      easingPresets: Object.freeze(EASING_PRESETS),
      activeDrag: null as 'p1' | 'p2' | null,
    }
  },
  computed: {
    isCubicBezier(): boolean {
      return this.easing.startsWith('cubic-bezier')
    },
    bezierValues(): [number, number, number, number] {
      return parseBezier(this.easing) || [0.25, 0.1, 0.25, 1]
    },
    p1() {
      return { x: this.bezierValues[0] * 100, y: this.bezierValues[1] * 100 }
    },
    p2() {
      return { x: this.bezierValues[2] * 100, y: this.bezierValues[3] * 100 }
    },
    curvePath(): string {
      const { p1, p2 } = this
      return `M 0,100 C ${p1.x},${100 - p1.y} ${p2.x},${100 - p2.y} 100,0`
    },
  },
  methods: {
    startDrag(e: MouseEvent) {
      // activeDrag is set by mousedown on control points
    },
    onDrag(e: MouseEvent) {
      if (!this.activeDrag || !this.$refs.canvas) return

      const svg = this.$refs.canvas as SVGSVGElement
      const rect = svg.getBoundingClientRect()
      const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
      const y = Math.max(-0.5, Math.min(1.5, 1 - (e.clientY - rect.top) / rect.height))

      const values = [...this.bezierValues] as [number, number, number, number]
      if (this.activeDrag === 'p1') {
        values[0] = Math.round(x * 100) / 100
        values[1] = Math.round(y * 100) / 100
      } else {
        values[2] = Math.round(x * 100) / 100
        values[3] = Math.round(y * 100) / 100
      }

      this.$emit('update:value', `cubic-bezier(${values.join(',')})`)
    },
    stopDrag() {
      this.activeDrag = null
    },
  },
}
</script>

<style scoped lang="scss">
.bezier-editor {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 4px 0;
}

.bezier-presets {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.bezier-canvas-wrap {
  padding: 0 12px;
}

.bezier-canvas {
  width: 100%;
  max-width: 200px;
  height: auto;
  border: 1px solid #eee;
  border-radius: 4px;
  cursor: crosshair;

  .control-point {
    cursor: grab;

    &:active {
      cursor: grabbing;
    }
  }
}

.bezier-text-input {
  font-family: monospace;
  font-size: 11px;
  padding: 4px 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin: 0 12px;
}
</style>
  • [ ] Step 2: Add sectionSettings entry

In utils.ts, add:

typescript
BezierEasing: ['animation.easing'],
  • [ ] Step 3: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/components/BezierEasingEditor.vue
git add src/pages/Chatbots/components/BuilderVisuals/Blocks/utils.ts
git commit -m "Add visual bezier easing editor component"

Chunk 4: Copy/Paste Animation & State-Aware Triggers

Task 11: Add animation clipboard to Vuex

Files:

  • Modify: Application-Frontend/src/store/modules/blocks.ts

  • Modify: Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/mixins/configurationLogic.ts

  • [ ] Step 1: Add clipboard state and mutations to blocks store

In blocks.ts, add to state:

typescript
animationClipboard: null as AnimationConfig | null,

Add mutations:

typescript
setAnimationClipboard(state, config: AnimationConfig) {
  state.animationClipboard = JSON.parse(JSON.stringify(config))
},

Add getter:

typescript
hasAnimationClipboard: (state) => state.animationClipboard !== null,
animationClipboard: (state) => state.animationClipboard,
  • [ ] Step 2: Add copy/paste methods to configurationLogic
typescript
// In computed:
...mapGetters(['hasAnimationClipboard', 'animationClipboard']),

// In methods:
...mapMutations(['setAnimationClipboard']),

copyAnimation() {
  if (this.blockData?.animation) {
    this.setAnimationClipboard(this.blockData.animation)
  }
},

pasteAnimation() {
  const clipboard = this.animationClipboard
  if (!clipboard) return

  // Write each field individually through updateValue to trigger proper Vuex tracking
  this.updateValue(clipboard.effects, 'animation.effects')
  this.updateValue(clipboard.trigger, 'animation.trigger')
  this.updateValue(clipboard.duration, 'animation.duration')
  this.updateValue(clipboard.delay, 'animation.delay')
  this.updateValue(clipboard.easing, 'animation.easing')
  this.updateValue(clipboard.loop, 'animation.loop')
  this.updateValue(clipboard.iterationCount, 'animation.iterationCount')
  this.updateValue(clipboard.direction, 'animation.direction')
},
  • [ ] Step 3: Commit
bash
git add src/store/modules/blocks.ts
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/mixins/configurationLogic.ts
git commit -m "Add animation clipboard for copy/paste between blocks"

Task 12: Add state-awareness to AnimationMixin in CE

Files:

  • Modify: Creative-Engine/src/mixins/AnimationMixin.ts

  • [ ] Step 1: Add animation pause during state changes

For blocks with stateConfig (button, form input, form submit), animation should pause when hovering/active/focused:

typescript
// In computed:
animationStyleProps(): Record<string, string> {
  if (!this.shouldPlayAnimation) return {}
  const styles = this.animationResult?.animationStyle || {}

  // If block has stateConfig, add pause-on-hover/active
  if (this.block?.stateConfig) {
    return {
      ...styles,
      // Will be combined with &:hover, &:active etc. in block styles
    }
  }

  return styles
},

animationPauseStyles(): Record<string, string> {
  if (!this.animationConfig || this.animationConfig.effects.length === 0) return {}
  if (!this.block?.stateConfig) return {}

  return {
    animationPlayState: 'paused',
  }
},

Block components (Button, FormInput, FormSubmitButton) add ...this.animationPauseStyles to their &:hover and &:active selectors.

  • [ ] Step 2: Update CreativeButtonBlock.vue

Add animation pause to hover/active states:

typescript
'&:hover': {
  ...this.animationPauseStyles,
  // existing hover styles from stateConfig
},
'&:active': {
  ...this.animationPauseStyles,
  // existing active styles from stateConfig
},
  • [ ] Step 3: Build and verify

Run: cd Creative-Engine && npm run build

  • [ ] Step 4: Commit
bash
cd Creative-Engine
git add src/mixins/AnimationMixin.ts src/components/creative/VisualElements/CreativeButtonBlock.vue
git commit -m "Pause animation during state changes on stateful blocks"

Chunk 5: Flow-Triggered Animations

Task 13: Add animation trigger to operator properties

Files:

  • Modify: Creative-Engine/src/interfaces/operator/OperatorPropertiesInterface.ts

  • [ ] Step 1: Add animationTrigger to operator body

typescript
animationTrigger?: {
  value: string  // JSON: { targetBlock: string, effects: AnimationEffect[], duration: number, easing: string }
}

This follows the exact same pattern as stylesText.

  • [ ] Step 2: Commit
bash
cd Creative-Engine
git add src/interfaces/operator/OperatorPropertiesInterface.ts
git commit -m "Add animationTrigger to operator properties interface"

Task 14: Create OperatorAnimations panel in AF

Files:

  • Create: Application-Frontend/src/pages/Chatbots/components/CavaiFlow/flowactors/OperatorAnimations.vue

  • Modify: Application-Frontend/src/pages/Chatbots/components/CavaiFlow/flowactors/OperatorBase.vue

  • Modify: Application-Frontend/src/pages/Chatbots/components/CavaiFlow/CavaiFlow.vue

  • [ ] Step 1: Create OperatorAnimations.vue

Follows the same pattern as OperatorStyles.vue — a positioned panel that opens from an icon on the operator. Contains:

  • Block selector dropdown (list all visual element blocks)
  • Effect list (same AnimationEffectList)
  • Duration, easing inputs
vue
<template>
  <div class="operator-animations-panel">
    <div class="caret" />
    <div class="panel-content">
      <div class="panel-header">
        <span>{{ $t('visuals.animation.flowTrigger') }}</span>
        <button
          class="remove-btn"
          @click="clearAnimation"
        >
          <CIcon name="trash" :size="14" />
        </button>
      </div>

      <div class="target-block">
        <label>{{ $t('visuals.animation.targetBlock') }}</label>
        <InputSelect
          :items="blockOptions"
          :value="config.targetBlock"
          @change="updateField('targetBlock', $event)"
        />
      </div>

      <AnimationEffectList
        :effects="config.effects"
        @update:effects="updateField('effects', $event)"
      />

      <DurationSection
        :duration="config.duration || 500"
        :min="50"
        :max="5000"
        :step="50"
        @update:value="updateField('duration', $event)"
      />
    </div>
  </div>
</template>
  • [ ] Step 2: Add animation icon to OperatorBase.vue

Next to the paint-roll icon (line ~290), add an animation icon:

vue
<div
  v-if="opCanHaveAnimation"
  class="operator-animation-wrapper"
  @click.stop="handleAnimationIconClick"
>
  <CIcon name="play" :size="14" />
</div>
  • [ ] Step 3: Add panel positioning to CavaiFlow.vue

Follow the same pattern as openStylesEditor() (lines 3731-3752):

typescript
openAnimationEditor(data) {
  // Same positioning logic as styles editor
  this.animationEditorX = data.paintrollButton.x + 6
  this.animationEditorY = data.paintrollButton.y - 79 + data.paintrollButton.height
  this.animationEditorComponent = data.component
  this.showAnimationEditor = true
},
  • [ ] Step 4: Commit
bash
git add src/pages/Chatbots/components/CavaiFlow/flowactors/OperatorAnimations.vue
git add src/pages/Chatbots/components/CavaiFlow/flowactors/OperatorBase.vue
git add src/pages/Chatbots/components/CavaiFlow/CavaiFlow.vue
git commit -m "Add flow-triggered animation panel to flow operators"

Task 15: Handle flow animation triggers in CE

Files:

  • Modify: Creative-Engine/src/mixins/AnimationMixin.ts

  • [ ] Step 1: Add flow-trigger listener

The AnimationMixin listens for a custom event that fires when an operator with animationTrigger executes:

typescript
// In mounted():
this.$root.$on('trigger-animation', this.handleFlowAnimation)

// In beforeDestroy():
this.$root.$off('trigger-animation', this.handleFlowAnimation)

// New method:
handleFlowAnimation({ targetBlock, effects, duration, easing }: any) {
  if (this.block?.blockName !== targetBlock) return

  // Apply one-shot animation
  this.flowAnimationEffects = effects
  this.flowAnimationDuration = duration
  this.flowAnimationEasing = easing
  this.flowAnimationTriggered = false
  this.$nextTick(() => {
    this.flowAnimationTriggered = true
  })
},

The operator execution code in CE emits this.$root.$emit('trigger-animation', parsedConfig) when an operator with animationTrigger runs.

  • [ ] Step 2: Commit
bash
cd Creative-Engine
git add src/mixins/AnimationMixin.ts
git commit -m "Add flow-triggered animation handler to AnimationMixin"

Chunk 6: i18n & Cleanup

Task 16: Add all new i18n translations

Files:

  • Modify: Application-Frontend/src/assets/i18n/en.js

  • [ ] Step 1: Add new translation keys

In the visuals.animation section, add:

javascript
effectTypes: {
  fade: 'Opacity',
  slideX: 'Slide X',
  slideY: 'Slide Y',
  scale: 'Scale',
  rotate: 'Rotate',
  blur: 'Blur',
  skew: 'Skew',
},
addEffect: 'Add effect',
addKeyframe: 'Add keyframe',
loadPreset: 'Preset...',
noEffects: 'No effects added',
copy: 'Copy animation',
paste: 'Paste animation',
easing: 'Easing',
flowTrigger: 'Trigger Animation',
targetBlock: 'Target block',
  • [ ] Step 2: Update sectionSettings in utils.ts

Add entries for any new section components:

typescript
AnimationEffectList: ['animation.effects'],
BezierEasing: ['animation.easing'],
  • [ ] Step 3: Commit
bash
git add src/assets/i18n/en.js src/pages/Chatbots/components/BuilderVisuals/Blocks/utils.ts
git commit -m "Add i18n translations and section settings for animation v2"

Task 17: Manual testing checklist

  • [ ] Test 1: New creative — Create new creative, add text block, verify animation card shows empty effects list
  • [ ] Test 2: Add effects — Add fade (0→1), verify InputSelect dropdown works, values update
  • [ ] Test 3: Multiple effects — Add fade + slideY, verify both render
  • [ ] Test 4: Load preset — Use preset dropdown to load "Bounce", verify effects populate
  • [ ] Test 5: Bezier editor — Click "Bounce" easing preset, verify curve appears, drag control points
  • [ ] Test 6: Copy/paste — Copy animation from text block, paste to button block, verify values match
  • [ ] Test 7: Button states — On button, verify hover/click triggers are hidden
  • [ ] Test 8: Multi-step keyframes — Add a bounce preset, verify keyframe rows show all percent stops (0%, 60%, 80%, 100%), modify a keyframe value, verify update
  • [ ] Test 9: CE buildcd Creative-Engine && npm run build succeeds
  • [ ] Test 10: AF lintnpm run lint -- --no-fix passes (no new errors)

Task 18: Update PR descriptions and push

  • [ ] Step 1: Push both repos
  • [ ] Step 2: Update AF PR #1806 description with v2 changes
  • [ ] Step 3: Update CE PR #706 description with v2 changes
  • [ ] Step 4: Update architecture blueprint

Internal documentation