Appearance
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)
| File | Responsibility |
|---|---|
Configuration/components/GradientAngleDial.vue | Circular dial for angle input (linear + conic). Emits angle in degrees. |
Configuration/components/GradientStopList.vue | List of color stops with add/remove. Each stop has ColorInput + position %. |
Configuration/components/GradientPositionPicker.vue | Click-on-preview to set x/y center for radial/conic. Small square canvas. |
Modified files (AF)
| File | Change |
|---|---|
Blocks/data/types.ts | Add GradientStop, GradientSettings types; update BackgroundSettings gradient variant |
Blocks/data/defaults.ts | Add gradientSettingsDefaults(); update backgroundGradientDefaults() |
Configuration/components/BackgroundStyleSection.vue | Import new sub-components; add gradient type/angle/stops/position UI |
components/common/ImagePreview.vue | Accept gradientSettings prop; render all gradient types |
assets/i18n/en.js | Add i18n keys for gradient sub-options |
Modified files (CE)
| File | Change |
|---|---|
interfaces/jsonTypes/payload-v2/index.ts | Add GradientStop, GradientSettings; update BackgroundSettings |
styles/components/helpers.ts | Add 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:
- Opening a creative
- Changing gradient type/angle/stops
- Saving and reloading -- gradient settings should persist
- 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
colorsfallback)Edit the gradient -- should migrate to new
gradientsettings[ ] 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 buildcd CE && npm run buildnpm run lint -- --no-fixin both repos[ ] Step 8: Final commits and list changed files for lint
AF changed files:
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.tssrc/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.tssrc/pages/Chatbots/components/BuilderVisuals/Configuration/components/BackgroundStyleSection.vuesrc/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientAngleDial.vuesrc/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientStopList.vuesrc/pages/Chatbots/components/BuilderVisuals/Configuration/components/GradientPositionPicker.vuesrc/components/common/ImagePreview.vuesrc/assets/i18n/en.js- 15x configuration files (remove
@update:gradientline)
CE changed files:
src/interfaces/jsonTypes/payload-v2/index.tssrc/styles/components/helpers.ts