Appearance
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— branchadd-animation-systemCreative-Engine— branchadd-animation-system
Issue: Cavai/Application-Frontend#1804
File Structure
Application-Frontend — New Files
| File | Responsibility |
|---|---|
Configuration/components/AnimationEffectRow.vue | Single effect row: type dropdown + from/to inputs + remove button |
Configuration/components/AnimationEffectList.vue | List of effect rows + "Add effect" button |
Configuration/components/BezierEasingEditor.vue | Visual cubic-bezier curve editor with drag handles |
Configuration/components/AnimationConfigCard.vue | Complete animation card (effects, timing, trigger) — shared by all configs |
CavaiFlow/flowactors/OperatorAnimations.vue | Flow-triggered animation panel (like OperatorStyles) |
Application-Frontend — Modified Files
| File | Changes |
|---|---|
Blocks/data/types.ts | Replace AnimationConfig with effect-based types |
Blocks/data/defaults.ts | Update animationDefaults() factory |
Blocks/utils.ts | Update sectionSettings for new animation sections |
Configuration/mixins/configurationLogic.ts | Add animCopyPayload computed, copy/paste methods |
Configuration/configs/*Configuration.vue (×8) | Replace inline animation sections with AnimationConfigCard |
assets/i18n/en.js | Add effect type labels, bezier preset names |
store/modules/blocks.ts | Add animationClipboard state + mutations |
CavaiFlow/flowactors/OperatorBase.vue | Add animation icon next to paint-roll |
CavaiFlow/CavaiFlow.vue | Add animation panel positioning + show/hide logic |
Creative-Engine — Modified Files
| File | Changes |
|---|---|
interfaces/jsonTypes/payload-v2/index.ts | Mirror new effect-based types |
styles/animations/presets.ts | Preset effect arrays for quick-fill |
styles/animations/helpers.ts | Dynamic keyframe generation from effects |
mixins/AnimationMixin.ts | State-awareness, flow-trigger listener, pause/resume |
components/creative/VisualElements/CreativeButtonBlock.vue | Pause animation during state changes |
interfaces/operator/OperatorPropertiesInterface.ts | Add 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.tsModify:
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.tsModify:
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.vueModify:
Application-Frontend/src/pages/Chatbots/components/CavaiFlow/flowactors/OperatorBase.vueModify:
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 build —
cd Creative-Engine && npm run buildsucceeds - [ ] Test 10: AF lint —
npm run lint -- --no-fixpasses (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