Skip to content

Stagger Animation Redesign -- 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: Redesign stagger animation so it writes animation configs to each child block (making stagger a "shortcut"), with child inclusion/exclusion, reverse order, and per-child override capability.

Architecture: When the user applies a stagger preset from the group config, the system writes an AnimationConfig to each included child's animation field in Vuex. The group stores staggerSettings metadata (preset, timing, loop, reverse, excluded children) for recalculation. The engine's CreativeGroupBlock continues to coordinate WAAPI round-robin across children (for hover-pause synchronization and tab-visibility restart), reading effect data from children's animation configs. Each child's AnimationMixin suppresses its own CSS animation when stagger is active to avoid double-animation.

Tech Stack: Vue 2 (Options API), Vuex, TypeScript, Web Animations API, SCSS with CSS custom properties.

Status (2026-06-02): Infrastructure (Tasks 1-8) implemented on theming-system-redesign. UI was built but removed by user for reconsideration. Key post-implementation fixes: child groups excluded from stagger targets, paste strips staggerSource/repeatDelay, dead repeatDelay removed from buildStaggerChildConfigs, updateAndClearStagger helper extracted in configurationLogic.


Current State Reference

These files already have stagger implementation (uncommitted on theming-system-redesign):

FileRole
AF/src/pages/.../Blocks/data/types.tsStaggerAnimationConfig, StaggerPresetConfig types
AF/src/pages/.../Blocks/data/defaults.tsSTAGGER_PRESETS (10 presets), FLOW_EASING_OPTIONS
AF/src/pages/.../Configuration/configs/BlockGroupConfiguration.vueGroup config with stagger tab: preset, duration, easing, delay, loop, preview dots
CE/src/components/creative/CreativeGroupBlock/CreativeGroupBlock.vueWAAPI round-robin, hover pause, visibility restart
CE/src/mixins/AnimationMixin.tsCSS @keyframes animation for all blocks (no stagger awareness)
CE/src/styles/animations/helpers.tsbuildKeyframes(), buildAnimationString(), getAnimationStyles()
CE/src/interfaces/jsonTypes/payload-v2.tsAnimationConfig, BasicConfig.animation?
AF/src/pages/.../CavaiFlow/flowactors/StaggerAnimationPanel.vueFlow stagger panel (writes to operators, reference pattern)

File Structure

Types (data model changes)

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts
  • Modify: CE/src/interfaces/jsonTypes/payload-v2.ts

AF -- Group Config UI

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/BlockGroupConfiguration.vue
  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/utils.ts

AF -- configurationLogic mixin

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/mixins/configurationLogic.ts

CE -- Engine Animation

  • Modify: CE/src/mixins/AnimationMixin.ts
  • Modify: CE/src/components/creative/CreativeGroupBlock/CreativeGroupBlock.vue

i18n

  • Modify: AF/src/assets/i18n/en.js

Chunk 1: Data Model & Write-to-Children

Task 1: Update type definitions

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts
  • Modify: CE/src/interfaces/jsonTypes/payload-v2.ts

Replace StaggerAnimationConfig with StaggerSettings and add stagger fields to AnimationConfig.

  • [ ] Step 1: Update AF types.ts

Replace the StaggerAnimationConfig type and add staggerSource + repeatDelay to AnimationConfig:

ts
// Replace StaggerAnimationConfig (lines 89-97) with:
export type StaggerSettings = {
  preset: string
  duration: number
  easing: string
  staggerDelay: number
  loop: boolean
  iterations: number
  reverse: boolean
  excludedChildren: string[]  // blockNames to skip
}

// Add to AnimationConfig (after line 114, inside the type):
  repeatDelay?: number    // ms between iterations for round-robin stagger
  staggerSource?: string  // blockName of the parent group that assigned this animation

Update BlockGroupProperties (line 994): change staggerAnimation?: StaggerAnimationConfig to staggerSettings?: StaggerSettings.

  • [ ] Step 2: Update CE payload-v2.ts

Add repeatDelay?: number and staggerSource?: string to the CE AnimationConfig type. These fields are needed for the engine to:

  • repeatDelay: Switch to WAAPI round-robin mode in AnimationMixin

  • staggerSource: Know the animation was stagger-assigned (for suppression logic)

  • [ ] Step 3: Commit

feat: add stagger settings types and animation stagger fields

Task 2: Write stagger configs to children

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/BlockGroupConfiguration.vue
  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/utils.ts

When the user applies a stagger preset, write animation to each child block in Vuex (same pattern as flow's StaggerAnimationPanel.emitApply).

  • [ ] Step 1: Add helper to utils.ts

Add a function to build stagger animation configs for children:

ts
import type { AnimationConfig, AnimationEffect, StaggerSettings } from './types'

/**
 * Build AnimationConfig for each child in a stagger group.
 * Returns an array of { blockName, animation } pairs, one per included child.
 */
export const buildStaggerChildConfigs = (
  children: { blockName: string }[],
  settings: StaggerSettings,
  effects: AnimationEffect[],
): { blockName: string, animation: AnimationConfig }[] => {
  const ordered = settings.reverse ? [...children].reverse() : children
  const step = settings.duration + settings.staggerDelay
  const count = ordered.length
  const totalCycle = count * step
  const repeatDelay = settings.loop ? totalCycle - settings.duration : undefined

  return ordered.map((child, i) => ({
    blockName: child.blockName,
    animation: {
      effects: JSON.parse(JSON.stringify(effects)),
      trigger: 'appear' as const,
      duration: settings.duration,
      delay: i * step,
      loop: settings.loop,
      iterationCount: settings.loop ? 0 : settings.iterations,
      direction: 'normal' as const,
      easing: settings.easing,
      ...(repeatDelay ? { repeatDelay } : {}),
      staggerSource: '__pending__', // replaced with actual group blockName by caller
    },
  }))
}
  • [ ] Step 2: Update BlockGroupConfiguration to write to children

Replace the current onStaggerPresetChange and updateStaggerField methods. When stagger settings change, the component must:

  1. Write staggerSettings to the group block
  2. Write animation to each included child block

Key changes to BlockGroupConfiguration.vue:

ts
// New computed: get child blocks
includedChildren() {
  if (!this.blockData?.blockName) return []
  const all = getGroupChildren(this.blockData.blockName, this.$store.state.blocks.creativeBlocks)
  const excluded = this.blockData?.staggerSettings?.excludedChildren || []
  return all.filter((b) => !excluded.includes(b.blockName))
},

// Rewrite onStaggerPresetChange
onStaggerPresetChange(value) {
  if (value === 'none') {
    this.clearStagger()
    return
  }

  const presetConfig = STAGGER_PRESETS[value]
  if (!presetConfig) return

  const settings = {
    preset: value,
    duration: presetConfig.duration,
    easing: presetConfig.easing,
    staggerDelay: presetConfig.staggerDelay,
    loop: this.staggerLoop,
    iterations: this.staggerIterations,
    reverse: this.staggerReverse,
    excludedChildren: this.blockData?.staggerSettings?.excludedChildren || [],
  }

  this.applyStaggerToChildren(settings, presetConfig.effects)
},

// New method: apply stagger to all included children
applyStaggerToChildren(settings, effects) {
  // 1. Write stagger settings to group
  this.updateValue(settings, 'staggerSettings')

  // 2. Build per-child animation configs
  const groupName = this.blockData.blockName
  const childConfigs = buildStaggerChildConfigs(this.includedChildren, settings, effects)

  // 3. Write animation to each child
  for (const { blockName, animation } of childConfigs) {
    animation.staggerSource = groupName
    this.$store.commit('updateBlockValue', {
      path: `${blockName}.animation`,
      value: animation,
    })
  }
},

// Rewrite updateStaggerField to recalculate children
updateStaggerField(field, value) {
  const current = this.blockData?.staggerSettings
  if (!current) return

  const settings = { ...current, [field]: value }
  const presetConfig = STAGGER_PRESETS[settings.preset]
  if (!presetConfig) return

  this.applyStaggerToChildren(settings, presetConfig.effects)
},

// Rewrite clearStagger to also clear children's animations
clearStagger() {
  // Clear stagger-assigned animations from all children
  const groupName = this.blockData?.blockName
  const all = getGroupChildren(groupName, this.$store.state.blocks.creativeBlocks)

  for (const child of all) {
    const childAnim = child.animation
    if (childAnim?.staggerSource === groupName) {
      this.$store.commit('updateBlockValue', {
        path: `${child.blockName}.animation`,
        value: undefined,
      })
    }
  }

  // Clear group settings
  this.updateValue(undefined, 'staggerSettings')
},
  • [ ] Step 3: Update computed properties for new field names

Change all staggerAnimation references to staggerSettings:

  • hasStaggerAnimation -> checks staggerSettings?.preset

  • staggerPreset -> reads from staggerSettings

  • staggerDuration/Easing/Delay/Loop/Iterations -> reads from staggerSettings

  • Add staggerReverse computed

  • [ ] Step 4: Update watch and preview to use staggerSettings

The watchers and restartStaggerPreview already work with the computed values, so they should work without changes. Verify the preview dots still animate correctly.

  • [ ] Step 5: Commit
feat: stagger writes animation configs to child blocks

Task 3: Child inclusion/exclusion and reverse

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/BlockGroupConfiguration.vue
  • Modify: AF/src/assets/i18n/en.js

Add UI for toggling which children participate in stagger, and a reverse toggle.

  • [ ] Step 1: Add child list UI to template

After the iterations OptionRow and before the preview strip, add a child toggle section:

html
<OptionRow :title="$t('visuals.animation.staggerChildren')" no-toggle>
  <div class="stagger-children-list">
    <label
      v-for="child in allGroupChildren"
      :key="child.blockName"
      class="stagger-child-toggle"
    >
      <input
        type="checkbox"
        :checked="!isChildExcluded(child.blockName)"
        :disabled="inputLocked"
        @change="toggleChildInclusion(child.blockName)"
      >
      <span :class="{ 'stagger-child-overridden': isChildOverridden(child) }">
        {{ child.displayName || child.blockName }}
      </span>
    </label>
  </div>
</OptionRow>

<OptionRow :title="$t('visuals.animation.staggerReverse')" no-toggle>
  <button
    :class="['stagger-reverse-toggle', { active: staggerReverse }]"
    :disabled="inputLocked"
    @click="updateStaggerField('reverse', !staggerReverse)"
  >
    {{ staggerReverse ? $t('visuals.animation.reversed') : $t('visuals.animation.inOrder') }}
  </button>
</OptionRow>
  • [ ] Step 2: Add computed properties and methods
ts
// Computed
allGroupChildren() {
  if (!this.blockData?.blockName) return []
  return getGroupChildren(this.blockData.blockName, this.$store.state.blocks.creativeBlocks)
},

staggerReverse() {
  return this.blockData?.staggerSettings?.reverse ?? false
},

// Methods
isChildExcluded(blockName) {
  return (this.blockData?.staggerSettings?.excludedChildren || []).includes(blockName)
},

isChildOverridden(child) {
  const groupName = this.blockData?.blockName
  return child.animation && child.animation.staggerSource !== groupName
},

toggleChildInclusion(blockName) {
  const current = this.blockData?.staggerSettings
  if (!current) return

  const excluded = [...(current.excludedChildren || [])]
  const idx = excluded.indexOf(blockName)

  if (idx >= 0) {
    excluded.splice(idx, 1)
  } else {
    excluded.push(blockName)
    // Clear stagger animation from excluded child
    const groupName = this.blockData.blockName
    const child = this.allGroupChildren.find((c) => c.blockName === blockName)
    if (child?.animation?.staggerSource === groupName) {
      this.$store.commit('updateBlockValue', {
        path: `${blockName}.animation`,
        value: undefined,
      })
    }
  }

  const settings = { ...current, excludedChildren: excluded }
  const presetConfig = STAGGER_PRESETS[settings.preset]
  if (presetConfig) {
    this.applyStaggerToChildren(settings, presetConfig.effects)
  }
},
  • [ ] Step 3: Add i18n keys

Add to en.js under visuals.animation:

js
staggerChildren: 'Include',
staggerReverse: 'Direction',
reversed: 'Reversed',
inOrder: 'In order',
  • [ ] Step 4: Add styles
scss
.stagger-children-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 120px;
  overflow-y: auto;
}

.stagger-child-toggle {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--text-secondary);
  cursor: pointer;

  input[type="checkbox"] {
    accent-color: var(--accent-primary);
  }
}

.stagger-child-overridden {
  font-style: italic;
  color: var(--text-muted);
}

.stagger-reverse-toggle {
  background: var(--surface-hover);
  border: 1px solid var(--border-default);
  border-radius: 4px;
  padding: 4px 10px;
  font-size: 12px;
  color: var(--text-secondary);
  cursor: pointer;
  transition: all 150ms ease;

  &:hover {
    border-color: var(--text-muted);
    color: var(--text-primary);
  }

  &.active {
    background: var(--accent-primary);
    border-color: var(--accent-primary);
    color: var(--surface-base);
  }

  &:disabled {
    opacity: 0.5;
    pointer-events: none;
  }
}
  • [ ] Step 5: Commit
feat: add child inclusion/exclusion and reverse toggle for stagger

Chunk 2: Engine Changes & Override Detection

Task 4: AnimationMixin stagger awareness

Files:

  • Modify: CE/src/mixins/AnimationMixin.ts

When a block's animation.staggerSource is set, the AnimationMixin should suppress its own CSS animation to avoid double-animation (the parent group handles WAAPI).

  • [ ] Step 1: Add stagger suppression to animationStyleProps

In the animationStyleProps computed, check for staggerSource:

ts
animationStyleProps(): Record<string, string> {
  // Flow animation takes full priority over block animation
  if (this.flowAnimationActive && this.flowAnimationConfig) {
    const { duration, easing } = this.flowAnimationConfig
    const name: string = this.flowAnimationName
    return { animation: `${name} ${duration}ms ${easing} both` }
  }

  // Stagger source present = parent group handles WAAPI, suppress CSS animation
  if (this.animationConfig?.staggerSource) {
    return {}
  }

  const styles: Record<string, string> = {}
  if (this.shouldPlayAnimation) {
    Object.assign(styles, this.animationResult?.animationStyle || {})
  }
  return styles
},

Also suppress keyframes and hover styles when stagger is active:

ts
animationKeyframeRule(): Record<string, any> {
  if (this.animationConfig?.staggerSource) {
    // Still emit flow animation keyframes if active
    if (this.flowAnimationActive && this.flowAnimationConfig) {
      const keyframes = buildKeyframes(this.flowAnimationConfig.effects)
      const name: string = this.flowAnimationName
      return { [`@keyframes ${name}`]: keyframes }
    }
    return {}
  }

  const rules = this.animationResult?.keyframeRule || {}
  // ... rest unchanged
},

animationHoverStyle(): Record<string, string> {
  if (this.animationConfig?.staggerSource) return {}
  return getHoverAnimationStyle(this.animationConfig, this.animationBlockName) || {}
},
  • [ ] Step 2: Commit
feat(engine): suppress CSS animation when block has stagger source

Task 5: Refactor CreativeGroupBlock stagger to read from children

Files:

  • Modify: CE/src/components/creative/CreativeGroupBlock/CreativeGroupBlock.vue

Change playStaggerAnimation to read staggerSettings (instead of staggerAnimation) and get effects from children's animation configs.

  • [ ] Step 1: Update playStaggerAnimation
ts
playStaggerAnimation() {
  const settings = this.typedBlock.staggerSettings
  if (!settings?.preset) return

  const children = this.getStaggerChildren()
  if (!children.length) return

  this.setupStaggerHover(children)

  // Read each child's animation config for its effects
  // Fall back to group's preset if child has no animation
  const childBlocks = Object.values(this.typedBlock)
    .filter((v: any) => v?.blockName)
    .sort((a: any, b: any) => (Number(b.order) || 0) - (Number(a.order) || 0))

  const excludedSet = new Set(settings.excludedChildren || [])
  const orderedBlocks = settings.reverse ? [...childBlocks].reverse() : childBlocks
  const includedBlocks = orderedBlocks.filter((b: any) => !excludedSet.has(b.blockName))

  const duration = settings.duration || 500
  const easing = settings.easing || 'ease'
  const staggerDelay = settings.staggerDelay || 200
  const loop = settings.loop ?? true
  const iterations = loop ? Infinity : (settings.iterations || 1)

  const step = duration + staggerDelay
  const count = includedBlocks.length
  const totalCycle = count * step
  const repeatDelay = loop ? totalCycle - duration : 0

  // Map DOM children to their block data
  // Children render in the same sort order as subBlocks computed
  const childEls = this.getStaggerChildren()

  for (let i = 0; i < count; i++) {
    const block = includedBlocks[i]
    // Find the DOM element for this block
    const elIndex = childBlocks.indexOf(block)
    const el = childEls[elIndex]
    if (!el) continue

    // Get animation effects: prefer child's own, fall back to preset
    const childAnim = block.animation
    const effects = childAnim?.effects?.length
      ? childAnim.effects
      : STAGGER_PRESETS[settings.preset]?.effects
    if (!effects?.length) continue

    const keyframeObj = buildKeyframes(effects)
    const webKeyframes: Keyframe[] = Object.entries(keyframeObj).map(([stop, props]) => ({
      offset: stop === 'from' ? 0 : stop === 'to' ? 1 : parseFloat(stop) / 100,
      ...props as Record<string, string>,
    })).sort((a, b) => (a.offset as number) - (b.offset as number))

    // ... rest of WAAPI round-robin logic stays the same
    // (trackTimeout, playOnce pattern, etc.)
  }
},
  • [ ] Step 2: Update mounted/beforeUnmount to use staggerSettings

Change condition check from staggerAnimation to staggerSettings:

ts
// In visibilityHandler:
if (document.visibilityState === 'visible' && this.typedBlock.staggerSettings?.preset) {
  this.cancelStaggerAnimation()
  this.playStaggerAnimation()
}
  • [ ] Step 3: Remove staggerAnimation references

Search for any remaining staggerAnimation references in CreativeGroupBlock and replace with staggerSettings.

  • [ ] Step 4: Commit
feat(engine): read stagger from children's animation + group staggerSettings

Task 6: Override detection in group config

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/BlockGroupConfiguration.vue

When a child's animation was stagger-assigned but the user manually edited it (changed effects, duration, etc.), show a visual indicator in the child list.

  • [ ] Step 1: Add override detection computed
ts
childStaggerStatus() {
  const groupName = this.blockData?.blockName
  if (!groupName) return {}

  const status = {}
  for (const child of this.allGroupChildren) {
    const anim = child.animation
    if (!anim) {
      status[child.blockName] = 'none'
    } else if (anim.staggerSource === groupName) {
      status[child.blockName] = 'stagger'
    } else {
      status[child.blockName] = 'overridden'
    }
  }
  return status
},
  • [ ] Step 2: Update isChildOverridden to use status
ts
isChildOverridden(child) {
  return this.childStaggerStatus[child.blockName] === 'overridden'
},
  • [ ] Step 3: Add visual indicator in template

Next to the child name in the toggle list, show a small "(modified)" label:

html
<span v-if="isChildOverridden(child)" class="stagger-modified-badge">
  {{ $t('visuals.animation.modified') }}
</span>

Add i18n key: modified: 'modified'

  • [ ] Step 4: Style the badge
scss
.stagger-modified-badge {
  font-size: 10px;
  color: var(--text-muted);
  font-style: italic;
  margin-left: 4px;
}
  • [ ] Step 5: Commit
feat: show override indicator for stagger-assigned children

Task 7: Clean up old staggerAnimation field

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts
  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts
  • Modify: CE/src/components/creative/CreativeGroupBlock/CreativeGroupBlock.vue

Remove the old StaggerAnimationConfig type (now StaggerSettings) and ensure no references to the old staggerAnimation field remain.

  • [ ] Step 1: Search and replace

Grep both AF and CE repos for staggerAnimation and replace any remaining references with staggerSettings.

  • [ ] Step 2: Remove old type if still present

If StaggerAnimationConfig wasn't already replaced in Task 1, remove it now.

  • [ ] Step 3: Verify build

Run npm run build in both AF and CE to confirm no type errors.

  • [ ] Step 4: Commit
refactor: clean up old staggerAnimation references

Chunk 3: Polish & Edge Cases

Task 8: Handle child animation editing (clear staggerSource)

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/mixins/configurationLogic.ts

When a child block's animation is edited via AnimationConfigCard, clear staggerSource to indicate it's been overridden.

  • [ ] Step 1: Update animationCardListeners

In configurationLogic.ts, modify the animation update listeners to clear staggerSource when the user manually changes animation properties:

ts
// In animationCardListeners computed, wrap each update handler:
'update:effects': ($event) => {
  this.updateValue($event, 'animation.effects')
  if (this.blockData?.animation?.staggerSource) {
    this.updateValue(undefined, 'animation.staggerSource')
  }
},
// Same for update:duration, update:easing, update:trigger, update:delay, etc.

This way, any manual edit to a stagger-assigned animation clears the source marker, signaling override to the group.

  • [ ] Step 2: Commit
feat: clear stagger source when child animation is manually edited

Task 9: Stagger preset change preserves excluded children

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/BlockGroupConfiguration.vue

Verify that when changing stagger preset, excluded children stay excluded and their animations are not overwritten.

  • [ ] Step 1: Verify applyStaggerToChildren skips excluded

The includedChildren computed already filters out excluded. Verify that applyStaggerToChildren only writes to included children and doesn't touch excluded ones.

  • [ ] Step 2: Verify clearStagger clears only stagger-assigned

When clearing stagger, only clear animations that have staggerSource matching this group. Don't clear manually-set animations on excluded children.

  • [ ] Step 3: Commit (if changes needed)
fix: preserve excluded children state when changing stagger preset

Task 10: Manual test checklist

Before merging:

  • [ ] Apply stagger preset from group config -- verify child blocks show animation in their own AnimationConfigCard
  • [ ] Change stagger duration/easing/delay -- verify all children update
  • [ ] Toggle a child off -- verify its animation is cleared and stagger recalculates delays
  • [ ] Toggle a child back on -- verify it gets animation with correct delay
  • [ ] Enable reverse -- verify stagger order reverses
  • [ ] Manually edit a child's animation -- verify "(modified)" badge appears in group config
  • [ ] Clear stagger -- verify all stagger-assigned animations are removed, manually-edited ones preserved
  • [ ] Preview dots animate correctly in group config panel
  • [ ] Engine: stagger plays correctly in creative preview (round-robin, hover pause, tab restart)
  • [ ] Engine: child with manually-edited animation still participates in group stagger (or doesn't, depending on exclusion)
  • [ ] Flow stagger still works unchanged (StaggerAnimationPanel)
  • [ ] No console errors in builder or preview

Internal documentation