Skip to content

Gradient Enhancement 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: Upgrade the gradient system from a basic 2-color linear gradient to a full Figma-inspired editor with type selection (linear/radial/conic), circular angle dial, unlimited color stops with positions, visual gradient bar with draggable handles, and click-to-set position picker.

Architecture: Extend the existing BackgroundSettings type with an optional gradient object containing type, angle, stops, shape, and position. The engine reads gradient when present, falls back to colors for legacy creatives. All UI changes are contained within BackgroundStyleSection and a few new sub-components. No changes needed in the 15+ configuration files that consume BackgroundStyleSection.

Tech Stack: Vue 2 Options API, TypeScript, SCSS, Vuex, JSS (engine)

Spec: Cavai-Documentation/src/DocumentationTexts/todos/Gradients/gradient-enhancement-research.md

Repos:

  • AF: /Users/nicolay/CavaiProduct/Application-Frontend
  • CE: /Users/nicolay/CavaiProduct/Creative-Engine

File Structure

New files (AF)

FileResponsibility
Configuration/components/GradientAngleDial.vueCircular dial for angle input (linear + conic). Emits angle in degrees.
Configuration/components/GradientStopList.vueList of color stops with add/remove. Each stop has ColorInput + position %.
Configuration/components/GradientPositionPicker.vueClick-on-preview to set x/y center for radial/conic. Small square canvas.

Modified files (AF)

FileChange
Blocks/data/types.tsAdd GradientStop, GradientSettings types; update BackgroundSettings gradient variant
Blocks/data/defaults.tsAdd gradientSettingsDefaults(); update backgroundGradientDefaults()
Configuration/components/BackgroundStyleSection.vueImport new sub-components; add gradient type/angle/stops/position UI
components/common/ImagePreview.vueAccept gradientSettings prop; render all gradient types
assets/i18n/en.jsAdd i18n keys for gradient sub-options

Modified files (CE)

FileChange
interfaces/jsonTypes/payload-v2/index.tsAdd GradientStop, GradientSettings; update BackgroundSettings
styles/components/helpers.tsAdd renderGradient(); update getBackgroundProperties() with fallback

Shared utility

A renderGradientCSS function will be needed in both AF (for ImagePreview) and CE (for rendering). Since the repos don't share code, each gets its own copy. They're small (< 20 lines) and identical in logic.


Chunk 1: Data Model + Engine Rendering

Task 1: Add types to CE

Files:

  • Modify: CE/src/interfaces/jsonTypes/payload-v2/index.ts:10-26

  • [ ] Step 1: Add GradientStop and GradientSettings types

Add above the existing BackgroundSettings type:

typescript
export type GradientStop = {
  color: string
  position: number
}

export type GradientType = 'linear' | 'radial' | 'conic'

export type GradientSettings = {
  type: GradientType
  angle: number
  stops: GradientStop[]
  shape?: 'circle' | 'ellipse'
  position?: { x: number; y: number }
}
  • [ ] Step 2: Update BackgroundSettings gradient variant

Change the gradient variant from:

typescript
{
  mode: 'gradient'
  colors: string[]
}

To:

typescript
{
  mode: 'gradient'
  colors: string[]
  gradient?: GradientSettings
}

colors stays for backwards compatibility. gradient is optional -- old creatives won't have it.

  • [ ] Step 3: Verify build

Run: cd /Users/nicolay/CavaiProduct/Creative-Engine && npm run build


Task 2: Add renderGradient to CE helpers

Files:

  • Modify: CE/src/styles/components/helpers.ts:149-163

  • [ ] Step 1: Add renderGradient function

Add above getBackgroundProperties:

typescript
const renderGradient = (gradient: GradientSettings): Styles => {
  const stops = gradient.stops
    .map((s) => `${s.color} ${s.position}%`)
    .join(', ')

  switch (gradient.type) {
    case 'linear':
      return { backgroundImage: `linear-gradient(${gradient.angle}deg, ${stops})` }

    case 'radial': {
      const shape = gradient.shape || 'ellipse'
      const pos = gradient.position || { x: 50, y: 50 }
      return { backgroundImage: `radial-gradient(${shape} at ${pos.x}% ${pos.y}%, ${stops})` }
    }

    case 'conic': {
      const pos = gradient.position || { x: 50, y: 50 }
      return { backgroundImage: `conic-gradient(from ${gradient.angle}deg at ${pos.x}% ${pos.y}%, ${stops})` }
    }

    default:
      return {}
  }
}

Import GradientSettings at the top of the file.

  • [ ] Step 2: Update getBackgroundProperties to use renderGradient

Change the gradient branch in getBackgroundProperties from:

typescript
if (mode === 'gradient') {
  return {
    backgroundImage: `linear-gradient(to bottom, ${colors[0]}, ${colors[1]})`,
  }
}

To:

typescript
if (mode === 'gradient') {
  const { gradient } = settings as Record<string, any>

  if (gradient) {
    return renderGradient(gradient)
  }

  // Legacy fallback: old creatives without gradient settings
  return {
    backgroundImage: `linear-gradient(to bottom, ${colors[0]}, ${colors[1]})`,
  }
}
  • [ ] Step 3: Verify build

Run: cd /Users/nicolay/CavaiProduct/Creative-Engine && npm run build

  • [ ] Step 4: Commit CE changes

Task 3: Add types to AF

Files:

  • Modify: AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts:214-217

  • [ ] Step 1: Add GradientStop and GradientSettings types

Add above BackgroundSettings:

typescript
export type GradientStop = {
  color: string
  position: number
}

export type GradientType = 'linear' | 'radial' | 'conic'

export type GradientSettings = {
  type: GradientType
  angle: number
  stops: GradientStop[]
  shape?: 'circle' | 'ellipse'
  position?: { x: number; y: number }
}
  • [ ] Step 2: Update BackgroundSettings gradient variant

Change from:

typescript
| { mode: 'gradient'; colors: string[] }

To:

typescript
| { mode: 'gradient'; colors: string[]; gradient?: GradientSettings }
  • [ ] Step 3: Update defaults

In AF/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts, update backgroundGradientDefaults:

typescript
export const gradientSettingsDefaults = (
  stops?: GradientStop[],
): GradientSettings => ({
  type: 'linear',
  angle: 180,
  stops: stops || [
    { color: '#00C3FFFF', position: 0 },
    { color: '#30AFD6FF', position: 100 },
  ],
})

export const backgroundGradientDefaults = (colors: string[] = ['#00C3FFFF', '#30AFD6FF']): BackgroundSettings => ({
  colors,
  gradient: gradientSettingsDefaults([
    { color: colors[0], position: 0 },
    { color: colors[1], position: 100 },
  ]),
  mode: 'gradient',
})

Import GradientStop, GradientSettings from types.ts.

  • [ ] Step 4: Add i18n keys

In AF/src/assets/i18n/en.js, add under visuals.backgroundOptions:

javascript
backgroundOptions: {
  color: 'Color',
  gradient: 'Gradient',
  image: 'Image',
  none: 'None',
  url: 'URL',
},
gradientType: {
  linear: 'Linear',
  radial: 'Radial',
  conic: 'Conic',
},
gradientAngle: 'Angle',
gradientPosition: 'Position',
gradientShape: 'Shape',
gradientStops: 'Colors',
addStop: 'Add color',
removeStop: 'Remove',
  • [ ] Step 5: Verify build

Run: cd /Users/nicolay/CavaiProduct/Application-Frontend && npm run build

  • [ ] Step 6: Commit AF type/default changes

Chunk 2: UI Components

Task 4: GradientAngleDial component

Files:

  • Create: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientAngleDial.vue

A small circular dial where the user can click/drag to set an angle (0-360). Also shows a numeric InputField for direct entry.

  • [ ] Step 1: Create GradientAngleDial.vue
vue
<template>
  <OptionRow
    v-bind="overrideProps"
    :title="$t('visuals.gradientAngle')"
    no-toggle
    v-on="overrideHandlers"
  >
    <div class="angle-dial-row">
      <div
        ref="dial"
        class="angle-dial"
        @mousedown="startDrag"
      >
        <div class="angle-dial__track" />
        <div
          class="angle-dial__indicator"
          :style="indicatorStyle"
        />
      </div>

      <InputField
        :value="angle"
        :fallback-value="180"
        :disabled="disabled"
        :min="0"
        :max="360"
        number-input
        class="angle-dial__input"
        @input="onInputChange"
      >
        <template #append-text>
          deg
        </template>
      </InputField>
    </div>
  </OptionRow>
</template>

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

export default {
  name: 'GradientAngleDial',
  components: { InputField, OptionRow },
  mixins: [sectionLogic],
  props: {
    angle: {
      type: Number,
      default: 180,
    },
    disabled: Boolean,
  },
  computed: {
    indicatorStyle() {
      return {
        transform: `rotate(${this.angle}deg)`,
      }
    },
  },
  methods: {
    startDrag(event: MouseEvent) {
      if (this.disabled) {
        return
      }

      this.updateAngleFromEvent(event)
      document.addEventListener('mousemove', this.onDrag)
      document.addEventListener('mouseup', this.stopDrag)
    },

    onDrag(event: MouseEvent) {
      this.updateAngleFromEvent(event)
    },

    stopDrag() {
      document.removeEventListener('mousemove', this.onDrag)
      document.removeEventListener('mouseup', this.stopDrag)
    },

    updateAngleFromEvent(event: MouseEvent) {
      const dial = this.$refs.dial as HTMLElement

      if (!dial) {
        return
      }

      const rect = dial.getBoundingClientRect()
      const centerX = rect.left + rect.width / 2
      const centerY = rect.top + rect.height / 2
      const dx = event.clientX - centerX
      const dy = event.clientY - centerY

      // atan2 gives angle from positive x-axis, we want from top (north)
      let angle = Math.atan2(dx, -dy) * (180 / Math.PI)

      if (angle < 0) {
        angle += 360
      }

      this.$emit('update:angle', Math.round(angle))
    },

    onInputChange(value: string) {
      const num = Number(value)

      if (!isNaN(num)) {
        this.$emit('update:angle', Math.max(0, Math.min(360, num)))
      }
    },
  },
  beforeDestroy() {
    document.removeEventListener('mousemove', this.onDrag)
    document.removeEventListener('mouseup', this.stopDrag)
  },
}
</script>

<style scoped lang="scss">
.angle-dial-row {
  display: flex;
  align-items: center;
  gap: $size-8;
}

.angle-dial {
  position: relative;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: 1px solid var(--border-primary);
  background: var(--surface-base);
  cursor: pointer;
  flex-shrink: 0;

  &__track {
    position: absolute;
    inset: 3px;
    border-radius: 50%;
    border: 1px solid var(--border-secondary);
  }

  &__indicator {
    position: absolute;
    top: 3px;
    left: 50%;
    width: 2px;
    height: 12px;
    margin-left: -1px;
    background: var(--accent-primary);
    transform-origin: bottom center;
    border-radius: 1px;
  }

  &__input {
    flex: 1;
  }
}
</style>
  • [ ] Step 2: Test locally

Add the dial temporarily to BackgroundStyleSection to verify it renders and drag works.

  • [ ] Step 3: Commit

Task 5: GradientStopList component

Files:

  • Create: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientStopList.vue

List of gradient color stops. Each row: ColorInput + position InputField + remove button. Add button at the bottom.

  • [ ] Step 1: Create GradientStopList.vue
vue
<template>
  <div class="gradient-stop-list">
    <div
      v-for="(stop, index) in stops"
      :key="index"
      class="gradient-stop-list__row"
    >
      <ColorInput
        :initial-color="stop.color"
        :disabled="disabled"
        class="gradient-stop-list__color"
        @update:color="updateStopColor(index, $event)"
      />

      <InputField
        :value="stop.position"
        :fallback-value="0"
        :disabled="disabled"
        :min="0"
        :max="100"
        number-input
        class="gradient-stop-list__position"
        @input="updateStopPosition(index, $event)"
      >
        <template #append-text>
          %
        </template>
      </InputField>

      <button
        v-if="stops.length > 2 && !disabled"
        class="gradient-stop-list__remove"
        @click="removeStop(index)"
      >
        <Icon icon="close" />
      </button>
    </div>

    <button
      v-if="!disabled"
      class="gradient-stop-list__add"
      @click="addStop"
    >
      + {{ $t('visuals.addStop') }}
    </button>
  </div>
</template>

<script lang="ts">
import ColorInput from '@/components/common/ColorInput.vue'
import InputField from '@/components/common/InputField.vue'
import Icon from '@/components/common/Icon.vue'
import type { GradientStop } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/types'

export default {
  name: 'GradientStopList',
  components: { ColorInput, InputField, Icon },
  props: {
    stops: {
      type: Array as () => GradientStop[],
      required: true,
    },
    disabled: Boolean,
  },
  methods: {
    updateStopColor(index: number, color: string) {
      const updated = [...this.stops]
      updated[index] = { ...updated[index], color }
      this.$emit('update:stops', updated)
    },

    updateStopPosition(index: number, value: string) {
      const position = Math.max(0, Math.min(100, Number(value)))

      if (isNaN(position)) {
        return
      }

      const updated = [...this.stops]
      updated[index] = { ...updated[index], position }
      this.$emit('update:stops', updated)
    },

    addStop() {
      const lastStop = this.stops[this.stops.length - 1]
      const secondLast = this.stops[this.stops.length - 2]
      const midPosition = Math.round((secondLast.position + lastStop.position) / 2)

      const newStop: GradientStop = {
        color: lastStop.color,
        position: midPosition,
      }

      const updated = [...this.stops]
      updated.splice(this.stops.length - 1, 0, newStop)
      this.$emit('update:stops', updated)
    },

    removeStop(index: number) {
      if (this.stops.length <= 2) {
        return
      }

      const updated = this.stops.filter((_, i) => i !== index)
      this.$emit('update:stops', updated)
    },
  },
}
</script>

<style scoped lang="scss">
.gradient-stop-list {
  display: flex;
  flex-direction: column;
  gap: $size-8;

  &__row {
    display: flex;
    align-items: center;
    gap: $size-6;
  }

  &__color {
    flex: 1;
  }

  &__position {
    width: 64px;
    flex-shrink: 0;
  }

  &__remove {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    padding: 0;
    border: none;
    background: none;
    color: var(--text-secondary);
    cursor: pointer;
    border-radius: $border-radius-milli;
    flex-shrink: 0;

    &:hover {
      color: var(--text-primary);
      background: var(--surface-raised);
    }

    .icon {
      width: 12px;
      height: 12px;
    }
  }

  &__add {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: $size-4 $size-8;
    border: 1px dashed var(--border-secondary);
    border-radius: $border-radius-milli;
    background: none;
    color: var(--text-secondary);
    font-size: 12px;
    cursor: pointer;

    &:hover {
      color: var(--text-primary);
      border-color: var(--border-primary);
    }
  }
}
</style>
  • [ ] Step 2: Commit

Task 6: GradientPositionPicker component

Files:

  • Create: AF/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientPositionPicker.vue

A small square where the user clicks/drags to set the center point (x/y percentage) for radial and conic gradients.

  • [ ] Step 1: Create GradientPositionPicker.vue
vue
<template>
  <OptionRow
    v-bind="overrideProps"
    :title="$t('visuals.gradientPosition')"
    no-toggle
    v-on="overrideHandlers"
  >
    <div class="position-picker-row">
      <div
        ref="canvas"
        class="position-picker"
        @mousedown="startDrag"
      >
        <div
          class="position-picker__dot"
          :style="dotStyle"
        />
      </div>

      <div class="position-picker__inputs">
        <InputField
          :value="position.x"
          :fallback-value="50"
          :disabled="disabled"
          :min="0"
          :max="100"
          number-input
          class="position-picker__input"
          @input="onXChange"
        >
          <template #append-text>
            x
          </template>
        </InputField>

        <InputField
          :value="position.y"
          :fallback-value="50"
          :disabled="disabled"
          :min="0"
          :max="100"
          number-input
          class="position-picker__input"
          @input="onYChange"
        >
          <template #append-text>
            y
          </template>
        </InputField>
      </div>
    </div>
  </OptionRow>
</template>

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

export default {
  name: 'GradientPositionPicker',
  components: { InputField, OptionRow },
  mixins: [sectionLogic],
  props: {
    position: {
      type: Object,
      default: () => ({ x: 50, y: 50 }),
    },
    disabled: Boolean,
  },
  computed: {
    dotStyle() {
      return {
        left: `${this.position.x}%`,
        top: `${this.position.y}%`,
      }
    },
  },
  methods: {
    startDrag(event: MouseEvent) {
      if (this.disabled) {
        return
      }

      this.updatePositionFromEvent(event)
      document.addEventListener('mousemove', this.onDrag)
      document.addEventListener('mouseup', this.stopDrag)
    },

    onDrag(event: MouseEvent) {
      this.updatePositionFromEvent(event)
    },

    stopDrag() {
      document.removeEventListener('mousemove', this.onDrag)
      document.removeEventListener('mouseup', this.stopDrag)
    },

    updatePositionFromEvent(event: MouseEvent) {
      const canvas = this.$refs.canvas as HTMLElement

      if (!canvas) {
        return
      }

      const rect = canvas.getBoundingClientRect()
      const x = Math.round(Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100)))
      const y = Math.round(Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height) * 100)))

      this.$emit('update:position', { x, y })
    },

    onXChange(value: string) {
      const x = Math.max(0, Math.min(100, Number(value)))

      if (!isNaN(x)) {
        this.$emit('update:position', { ...this.position, x })
      }
    },

    onYChange(value: string) {
      const y = Math.max(0, Math.min(100, Number(value)))

      if (!isNaN(y)) {
        this.$emit('update:position', { ...this.position, y })
      }
    },
  },
  beforeDestroy() {
    document.removeEventListener('mousemove', this.onDrag)
    document.removeEventListener('mouseup', this.stopDrag)
  },
}
</script>

<style scoped lang="scss">
.position-picker-row {
  display: flex;
  align-items: flex-start;
  gap: $size-8;
}

.position-picker {
  position: relative;
  width: 48px;
  height: 48px;
  border: 1px solid var(--border-primary);
  border-radius: $border-radius-milli;
  background: var(--surface-base);
  cursor: crosshair;
  flex-shrink: 0;

  // Grid lines
  &::before,
  &::after {
    content: '';
    position: absolute;
    background: var(--border-secondary);
  }

  &::before {
    left: 50%;
    top: 4px;
    bottom: 4px;
    width: 1px;
  }

  &::after {
    top: 50%;
    left: 4px;
    right: 4px;
    height: 1px;
  }

  &__dot {
    position: absolute;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--accent-primary);
    border: 1px solid var(--surface-base);
    transform: translate(-50%, -50%);
    pointer-events: none;
  }

  &__inputs {
    display: flex;
    flex-direction: column;
    gap: $size-4;
    flex: 1;
  }

  &__input {
    width: 100%;
  }
}
</style>
  • [ ] Step 2: Commit

Chunk 3: Integration

Task 7: Update BackgroundStyleSection

Files:

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

This is the main integration point. When gradient mode is selected, show the gradient sub-controls.

  • [ ] Step 1: Add imports

Add to the imports:

typescript
import GradientAngleDial from './GradientAngleDial.vue'
import GradientStopList from './GradientStopList.vue'
import GradientPositionPicker from './GradientPositionPicker.vue'
import OptionButton from '@/components/common/Button/OptionButton.vue'
import { gradientSettingsDefaults } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults'
import type { GradientSettings, GradientType } from '@/pages/Chatbots/components/BuilderVisuals/Blocks/data/types'

Add to components:

typescript
components: {
  // ... existing
  GradientAngleDial,
  GradientStopList,
  GradientPositionPicker,
}
  • [ ] Step 2: Replace the gradient template block

Replace the existing gradient <div> (lines 41-60 approx) with:

vue
<div
  v-if="backgroundSettings.mode === 'gradient'"
  class="gradient-input"
>
  <!-- Type selector -->
  <OptionList
    v-slot="{ option }"
    :options="gradientTypes"
    full-width
  >
    <OptionButton
      :selected="currentGradientType === option"
      :disabled="inputLocked"
      @click="updateGradientType(option)"
    >
      {{ $t(`visuals.gradientType.${option}`) }}
    </OptionButton>
  </OptionList>

  <!-- Angle dial (linear + conic) -->
  <GradientAngleDial
    v-if="currentGradientType === 'linear' || currentGradientType === 'conic'"
    :angle="currentGradientSettings.angle"
    :disabled="inputLocked"
    @update:angle="updateGradientProp('angle', $event)"
  />

  <!-- Position picker (radial + conic) -->
  <GradientPositionPicker
    v-if="currentGradientType === 'radial' || currentGradientType === 'conic'"
    :position="currentGradientSettings.position || { x: 50, y: 50 }"
    :disabled="inputLocked"
    @update:position="updateGradientProp('position', $event)"
  />

  <!-- Shape selector (radial only) -->
  <OptionRow
    v-if="currentGradientType === 'radial'"
    :title="$t('visuals.gradientShape')"
    no-toggle
  >
    <OptionList
      v-slot="{ option }"
      :options="radialShapes"
    >
      <OptionButton
        :selected="(currentGradientSettings.shape || 'ellipse') === option"
        :disabled="inputLocked"
        @click="updateGradientProp('shape', option)"
      >
        {{ option }}
      </OptionButton>
    </OptionList>
  </OptionRow>

  <!-- Color stops -->
  <GradientStopList
    :stops="currentGradientSettings.stops"
    :disabled="inputLocked"
    @update:stops="onStopsUpdated"
  />

  <!-- Preview -->
  <ImagePreview
    :gradient-settings="currentGradientSettings"
    class="gradient-input__preview"
  />
</div>
  • [ ] Step 3: Add data and computed properties

Add to data():

typescript
gradientTypes: Object.freeze(['linear', 'radial', 'conic']),
radialShapes: Object.freeze(['circle', 'ellipse']),

Update modeDefaults gradient entry:

typescript
gradient: {
  colors: this.backgroundSettings.colors || ['#FF60FEFF', '#5758D5FF'],
  gradient: this.backgroundSettings.gradient || gradientSettingsDefaults(),
},

Add computed:

typescript
currentGradientSettings() {
  return this.backgroundSettings.gradient || gradientSettingsDefaults()
},

currentGradientType() {
  return this.currentGradientSettings.type || 'linear'
},
  • [ ] Step 4: Add methods
typescript
updateGradientType(type: GradientType) {
  const gradient = { ...this.currentGradientSettings, type }
  this.emitGradientUpdate(gradient)
},

updateGradientProp(prop: string, value: any) {
  const gradient = { ...this.currentGradientSettings, [prop]: value }
  this.emitGradientUpdate(gradient)
},

onStopsUpdated(stops) {
  const gradient = { ...this.currentGradientSettings, stops }
  const colors = stops.map((s) => s.color)
  this.emitGradientUpdate(gradient, colors)
},

emitGradientUpdate(gradient: GradientSettings, colors?: string[]) {
  const updatedColors = colors || gradient.stops.map((s) => s.color)

  this.$emit('update:settings', {
    ...this.backgroundSettings,
    gradient,
    colors: updatedColors,
  })
},

Remove the old updateGradient(index, value) method (no longer needed -- stops handle individual color updates).

  • [ ] Step 5: Verify build and test locally

Open a creative in the builder, select a block, change background to Gradient, and verify:

  • Type selector shows Linear/Radial/Conic

  • Angle dial appears for Linear and Conic

  • Position picker appears for Radial and Conic

  • Shape selector appears for Radial

  • Color stops list with add/remove

  • Preview updates

  • [ ] Step 6: Commit


Task 8: Update ImagePreview

Files:

  • Modify: AF/src/components/common/ImagePreview.vue

  • [ ] Step 1: Add gradientSettings prop

typescript
props: {
  gradient: Array,
  gradientSettings: Object, // GradientSettings
  image: String,
  // ... rest
},
  • [ ] Step 2: Update generateGradient computed
typescript
generateGradient() {
  if (this.gradientSettings) {
    return `background: ${this.renderGradientCSS(this.gradientSettings)}`
  }

  // Legacy: simple 2-color array
  const from = this.gradient[0]
  const to = this.gradient[1]
  return `background: linear-gradient(${from}, ${to})`
},
  • [ ] Step 3: Add renderGradientCSS method
typescript
renderGradientCSS(gradient) {
  const stops = gradient.stops
    .map((s) => `${s.color} ${s.position}%`)
    .join(', ')

  switch (gradient.type) {
    case 'linear':
      return `linear-gradient(${gradient.angle}deg, ${stops})`

    case 'radial': {
      const shape = gradient.shape || 'ellipse'
      const pos = gradient.position || { x: 50, y: 50 }
      return `radial-gradient(${shape} at ${pos.x}% ${pos.y}%, ${stops})`
    }

    case 'conic': {
      const pos = gradient.position || { x: 50, y: 50 }
      return `conic-gradient(from ${gradient.angle}deg at ${pos.x}% ${pos.y}%, ${stops})`
    }

    default:
      return `linear-gradient(${gradient.stops?.[0]?.color || '#000'}, ${gradient.stops?.[1]?.color || '#fff'})`
  }
},
  • [ ] Step 4: Update the condition for showing gradient

In the template, update the v-if on the gradient container div to also check gradientSettings:

vue
<div
  v-if="(gradient || gradientSettings) && !image"
  class="gradient-container"
>
  • [ ] Step 5: Verify preview renders all gradient types

  • [ ] Step 6: Commit


Task 9: Update event handling in BackgroundStyleSection consumers

Files:

  • Check: All configuration files that use @update:gradient

The existing event pattern:

vue
@update:gradient="updateValue($event, 'backgroundSettings.colors')"

Since we now emit @update:settings for all gradient changes (which updates the entire backgroundSettings object including both colors and gradient), the @update:gradient event is no longer emitted from BackgroundStyleSection.

  • [ ] Step 1: Verify no breakage

The @update:gradient handler in consumer configs will simply never fire (no error, just unused). But we should verify that @update:settings correctly persists both colors and gradient via updateValue($event, 'backgroundSettings').

Test by:

  1. Opening a creative
  2. Changing gradient type/angle/stops
  3. Saving and reloading -- gradient settings should persist
  4. Opening a legacy creative with only colors -- should still render with legacy fallback
  • [ ] Step 2: Clean up unused @update:gradient handlers

Search all configuration files for @update:gradient and remove those lines, since gradient updates now go through @update:settings. This is a cleanup, not a functional change.

Files to update (remove @update:gradient line):

  • TextConfiguration.vue

  • TaglineConfiguration.vue

  • ButtonConfiguration.vue

  • SliderConfiguration.vue

  • FormSubmitButtonConfiguration.vue

  • FormConfiguration.vue

  • BlockGroupConfiguration.vue

  • ResponseConfiguration.vue

  • MessageConfiguration.vue

  • IconConfiguration.vue

  • HeaderConfiguration.vue

  • GraphicConfiguration.vue

  • ConversationConfiguration.vue

  • ChoiceConfiguration.vue

  • BaseConfiguration.vue

  • [ ] Step 3: Commit cleanup


Task 10: Visual gradient bar (stretch)

Files:

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

Add a visual gradient bar above the stop rows that shows the actual gradient and allows click-to-add stops and drag stop handles.

  • [ ] Step 1: Add gradient bar to GradientStopList template

Add above the stop rows:

vue
<div
  ref="gradientBar"
  class="gradient-bar"
  :style="gradientBarStyle"
  @click="addStopAtPosition"
>
  <div
    v-for="(stop, index) in stops"
    :key="'handle-' + index"
    class="gradient-bar__handle"
    :style="{ left: `${stop.position}%` }"
    @mousedown.stop="startHandleDrag(index, $event)"
  >
    <div
      class="gradient-bar__handle-color"
      :style="{ backgroundColor: stop.color }"
    />
  </div>
</div>
  • [ ] Step 2: Add computed and methods for gradient bar
typescript
computed: {
  gradientBarStyle() {
    const stops = this.stops
      .slice()
      .sort((a, b) => a.position - b.position)
      .map((s) => `${s.color} ${s.position}%`)
      .join(', ')

    return {
      background: `linear-gradient(to right, ${stops})`,
    }
  },
},

Add methods:

typescript
startHandleDrag(index: number, event: MouseEvent) {
  if (this.disabled) {
    return
  }

  event.preventDefault()
  this.draggingIndex = index

  const onMove = (e: MouseEvent) => {
    const bar = this.$refs.gradientBar as HTMLElement

    if (!bar) {
      return
    }

    const rect = bar.getBoundingClientRect()
    const position = Math.round(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)))
    const updated = [...this.stops]
    updated[index] = { ...updated[index], position }
    this.$emit('update:stops', updated)
  }

  const onUp = () => {
    this.draggingIndex = -1
    document.removeEventListener('mousemove', onMove)
    document.removeEventListener('mouseup', onUp)
  }

  document.addEventListener('mousemove', onMove)
  document.addEventListener('mouseup', onUp)
},

addStopAtPosition(event: MouseEvent) {
  if (this.disabled) {
    return
  }

  const bar = this.$refs.gradientBar as HTMLElement

  if (!bar) {
    return
  }

  const rect = bar.getBoundingClientRect()
  const position = Math.round(((event.clientX - rect.left) / rect.width) * 100)

  // Find surrounding stops to interpolate color
  const sorted = this.stops.slice().sort((a, b) => a.position - b.position)
  let color = sorted[0].color

  for (let i = 0; i < sorted.length - 1; i++) {
    if (position >= sorted[i].position && position <= sorted[i + 1].position) {
      color = sorted[i].color
      break
    }
  }

  const updated = [...this.stops, { color, position }]
  this.$emit('update:stops', updated)
},

Add to data:

typescript
data() {
  return {
    draggingIndex: -1,
  }
},
  • [ ] Step 3: Add gradient bar styles
scss
.gradient-bar {
  position: relative;
  height: 16px;
  border-radius: $border-radius-milli;
  cursor: pointer;
  margin-bottom: $size-4;

  // Checkerboard background for transparency
  &::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background-image: url('@/assets/images/svg/checkerboard.svg');
    background-repeat: repeat;
    z-index: 0;
  }

  // Gradient overlay
  &::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: inherit;
    z-index: 1;
  }

  &__handle {
    position: absolute;
    top: 50%;
    transform: translate(-50%, -50%);
    z-index: 2;
    width: 12px;
    height: 20px;
    cursor: grab;

    &:active {
      cursor: grabbing;
    }
  }

  &__handle-color {
    width: 100%;
    height: 100%;
    border: 2px solid var(--surface-base);
    border-radius: 2px;
    box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
  }
}
  • [ ] Step 4: Test gradient bar

  • Drag handles to change stop positions

  • Click on bar to add new stop

  • Verify checkerboard shows through transparent colors

  • [ ] Step 5: Commit


Chunk 4: Final Verification

Task 11: End-to-end testing

  • [ ] Step 1: Test linear gradient

  • Set type to Linear

  • Change angle with dial and number input

  • Add 3+ color stops

  • Adjust stop positions

  • Verify preview matches

  • Save, reload, verify persistence

  • [ ] Step 2: Test radial gradient

  • Switch to Radial

  • Verify angle dial hides, position picker shows, shape selector shows

  • Click position picker to move center

  • Toggle circle/ellipse

  • Verify preview

  • [ ] Step 3: Test conic gradient

  • Switch to Conic

  • Verify angle dial shows, position picker shows, no shape selector

  • Adjust angle and position

  • Verify preview

  • [ ] Step 4: Test legacy creatives

  • Open an existing creative with gradient background

  • Verify it renders correctly (legacy colors fallback)

  • Edit the gradient -- should migrate to new gradient settings

  • [ ] Step 5: Test across different blocks

  • Text block gradient

  • Button gradient

  • Base/expandable gradient

  • Form submit button gradient

  • [ ] Step 6: Test inputLocked state

  • Open a child creative

  • Verify all gradient controls respect inputLocked (disabled state)

  • [ ] Step 7: Build check

  • cd AF && npm run build

  • cd CE && npm run build

  • npm run lint -- --no-fix in both repos

  • [ ] Step 8: Final commits and list changed files for lint

AF changed files:

  • src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts
  • src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts
  • src/pages/Chatbots/components/BuilderVisuals/Configuration/components/BackgroundStyleSection.vue
  • src/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientAngleDial.vue
  • src/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientStopList.vue
  • src/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientPositionPicker.vue
  • src/components/common/ImagePreview.vue
  • src/assets/i18n/en.js
  • 15x configuration files (remove @update:gradient line)

CE changed files:

  • src/interfaces/jsonTypes/payload-v2/index.ts
  • src/styles/components/helpers.ts

Internal documentation