Appearance
Conversation 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: Pragmatic rewrite of the conversation rendering layer -- cleaner DOM, configurable defaults, flow-group support, choice feedback/stagger, scroll/entrance behavior, responsive prep, DCO-ready architecture.
Architecture: V2 replaces only the rendering layer (MessageHolder, Choice, CreativeConversationBlock template). The flow processor (conversationFlow.ts), data model (FlowComponentInterface), and flow modifier are reused unchanged. A conversationVersion: 1 | 2 flag on ConversationProperties gates the new rendering path, so existing creatives keep working. All hardcoded values become configurable properties with backward-compatible defaults matching current behavior.
Tech Stack: Vue 3 (Creative-Engine), TypeScript, JSS (computed styles()), CSS custom properties, existing AnimationMixin
Prerequisite: Understanding
Before starting any task, read the feasibility study and visual companion:
Cavai-Documentation/src/DocumentationTexts/todos/ConversationRedesign/feasibility.mdCavai-Documentation/src/DocumentationTexts/todos/ConversationRedesign/flow-group-visual-companion.html
Key Engine conventions:
- No
<style>blocks in Engine components. All CSS goes through thestyles()computed (JSS). DataStoreis the global reactive state container.AnimationMixinhandles all block-level animations (fade, slide, scale, rotate, blur, skew).- Components emit events via
DataStore.emitter.
File Structure
Creative-Engine (new files)
| File | Responsibility |
|---|---|
src/components/conversationflow/MessageHolderV2.vue | V2 message wrapper -- clean DOM, data attributes, configurable entrance |
src/components/conversationflow/FlowGroup.vue | display: contents wrapper for flow-group visual grouping |
src/components/blocks/basic/ChoiceV2.vue | V2 choice rendering -- flex/grid layout, variant/outcome, feedback delay, configurable stagger |
src/utils/autoScrollV2.ts | Configurable scroll -- duration, easing, direction, abort-on-user-scroll |
Creative-Engine (modified files)
| File | What changes |
|---|---|
src/components/creative/CreativeConversationBlock/CreativeConversationBlock.vue | Template branches on conversationVersion, groupedFlow computed for flow-group wrapping |
src/interfaces/jsonTypes/payload-v2/index.ts | New fields on ConversationProperties, new ChoiceV2Properties type |
src/interfaces/components/FlowComponent/FlowComponentInterface.ts | Add variant?: string, outcome?: string, flowGroup?: string |
src/utils/constants.ts | Export conversation default constants (currently only speed/render maps) |
Application-Frontend (modified files)
| File | What changes |
|---|---|
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts | V2 conversation properties types |
src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts | V2 defaults |
src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/ConversationConfiguration.vue | V2 config sections (conditional on version) |
| New section components as needed for V2-only settings | Entrance, scroll, choice layout, feedback |
Chunk 1: Foundation -- Clean DOM + Configurable Defaults
This chunk creates the V2 rendering path with a clean MessageHolderV2 that replaces all hardcoded values with configurable properties.
Task 1: Add conversationVersion to type definitions
Files:
Modify:
Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.ts:166-185Modify:
Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts:261-336Modify:
Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts:469-574[ ] Step 1: Add
conversationVersionto Engine ConversationProperties
In Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.ts, add to the ConversationProperties type:
ts
export type ConversationProperties = BasicConfig
& MandatorySizeConfig
& BorderConfig
& BoxShadowConfig
& BackgroundConfig
& FlexAlignConfig
& BackgroundBlurConfig
& CustomStylesConfig & {
conversationVersion?: 1 | 2 // NEW -- undefined or 1 = legacy, 2 = V2
speed: 'conversational' | 'swift' | 'instant' | number
typingAnimation: 'none' | 'typeIn' | 'fadeIn' | boolean
scrollOut: 'cutOff' | 'fadeOut'
senderIcon?: {
url: string
imageName: string
}
messageProperties: MessageProperties
choiceProperties: ChoiceProperties
responseProperties: ResponseProperties
rotate: number
// -- V2 entrance --
entranceEffect?: 'slide' | 'fade' | 'none'
entranceDistance?: number // px, default 10
entranceDuration?: number // ms, default 200
// -- V2 scroll --
scrollDuration?: number // ms, default 400
scrollEasing?: string // CSS easing, default 'cubic-bezier(0.22, 1, 0.36, 1)'
// -- V2 choice feedback --
choiceFeedbackDelay?: number // ms, 0 = instant (today's behavior)
}- [ ] Step 2: Add matching types in Frontend
In Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/types.ts, add the same new fields to the frontend ConversationProperties type. Follow the existing pattern in that file.
- [ ] Step 3: Add defaults
In Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Blocks/data/defaults.ts, add to conversationDefaults:
ts
// V2 defaults -- only used when conversationVersion === 2
// Not included in default object (undefined = V1 behavior)No default value for conversationVersion -- undefined means V1 (backward-compatible). The V2 entrance/scroll/feedback defaults are only meaningful when V2 is active, so they don't need to exist on V1 creatives.
- [ ] Step 4: Commit
bash
git add -p
git commit -m "feat(types): add conversationVersion and V2 properties to ConversationProperties"Task 2: Add variant and outcome to FlowComponentInterface
Files:
Modify:
Creative-Engine/src/interfaces/components/FlowComponent/FlowComponentInterface.ts:9-102[ ] Step 1: Add fields to the interface
ts
export interface FlowComponentInterface {
name: string
type: string
// ... existing fields ...
variant?: string // Visual classification: 'success', 'error', 'primary', or any custom string
outcome?: string // Logical classification: 'correct', 'category-a', or any custom string
flowGroup?: string // Group ID for flow-group wrapping (e.g., 'group-1')
// ... rest of existing fields ...
}These are optional strings. variant renders as CSS class .choice-variant-{value} for visual styling. outcome renders as .choice-outcome-{value} for system behavior (e.g., quiz feedback). Both are set per flow component in the flow editor (future frontend work).
- [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(types): add variant, outcome, flowGroup to FlowComponentInterface"Task 3: Extract hardcoded constants
Files:
Modify:
Creative-Engine/src/utils/constants.ts[ ] Step 1: Add conversation rendering constants
Add to the bottom of constants.ts:
ts
// -- Conversation V2 defaults --
// These match current hardcoded behavior so V2 renders identically by default.
export const CONVERSATION_DEFAULTS = {
// MessageHolder entrance
entranceEffect: 'slide' as const,
entranceDistance: 10, // px -- currently hardcoded translate3d(0, 10px, 0)
entranceDuration: 200, // ms -- currently hardcoded 200ms ease-out
// Message spacing
messageMarginX: 15, // px -- currently hardcoded marginLeft/marginRight: 15px
messagePaddingTop: 15, // px -- currently hardcoded &:first-child paddingTop: 15px
messageGapY: 3, // px -- currently hardcoded 3px for not-first-in-block
inputNeededGapY: 6, // px -- currently hardcoded 6px for input-needed state
// Bubble defaults
bubblePaddingX: 12, // px -- currently hardcoded padding: 8px 12px
bubblePaddingY: 8, // px
bubbleBorderRadius: 15, // px -- currently hardcoded in 5 places
// Avatar
avatarSize: 30, // px -- width/height of sender icon
avatarMargin: 6, // px -- margin-right of sender icon
avatarOffset: 36, // px -- paddingLeft when icon present (30 + 6)
// Choice stagger
choiceStaggerInterval: 120, // ms -- currently hardcoded index * 120
choiceStaggerDuration: 300, // ms -- currently hardcoded 300ms
choiceStaggerOffset: 20, // px -- currently hardcoded top: 20px in keyframe
// Scroll
scrollDuration: 400, // ms -- currently SCROLL_DURATION in autoScroll.ts
scrollEasing: 'cubic-bezier(0.22, 1, 0.36, 1)',
// Scroll timing offset
scrollTimingOffset: 75, // ms -- currently hardcoded typingDelay - 75
} as const- [ ] Step 2: Commit
bash
git add src/utils/constants.ts
git commit -m "feat(constants): extract hardcoded conversation values to named constants"Task 4: Create MessageHolderV2.vue
Files:
- Create:
Creative-Engine/src/components/conversationflow/MessageHolderV2.vue - Reference:
Creative-Engine/src/components/conversationflow/MessageHolder.vue(691 lines)
This is the core of V2. MessageHolderV2 does the same job as MessageHolder but with:
Clean semantic DOM with
data-*attributes instead of class soupAll spacing/sizing from conversation properties (no hardcoded px values)
CSS custom properties for easy override
Entrance animation using AnimationMixin patterns instead of hardcoded translate3d
[ ] Step 1: Create the component
Create MessageHolderV2.vue. The component should:
Template:
html
<template>
<div
v-show="!isRuntimeHidden"
:class="[blockClassNames.messageWrap]"
:data-step="stepIndex"
:data-type="componentType"
:data-variant="variant"
:data-outcome="outcome"
:data-input-needed="inputNeeded"
:data-first-in-block="firstInBlock"
:data-last-in-block="lastInBlock"
:data-input-entered="inputEntered"
>
<!-- Avatar (sender icon) -->
<div
v-if="showAvatar"
:class="[blockClassNames.avatar]"
>
<img :src="senderIconUrl" alt="" />
</div>
<!-- Message bubble -->
<div
v-if="showBubble"
v-show="!inputFromInputComponent || inputEntered"
:class="[blockClassNames.bubble]"
>
<component
:is="activeComponent"
v-bind="componentProps"
@input-entered="onInputEntered"
@user-action="onUserAction"
@scroll-to-bottom="$emit('scroll-to-bottom')"
/>
</div>
<!-- Teleport for fill-container mode -->
<Teleport
v-if="shouldTeleport"
:to="teleportTarget"
>
<component
:is="activeComponent"
v-bind="componentProps"
/>
</Teleport>
</div>
</template>Key differences from V1 MessageHolder:
data-*attributes for state instead of complex class combinations- No hardcoded px values in
styles()-- all fromconversationPropsorCONVERSATION_DEFAULTS - Entrance animation as a CSS custom property
--entrance-transform/--entrance-duration - Variant/outcome classes from flow component data
styles() computed pattern:
ts
computed: {
conversationProps() {
return DataStore.creativeSettings.creativeBlocks?.conversationProperties
},
defaults() {
return CONVERSATION_DEFAULTS
},
styles() {
const { defaults: d, conversationProps: cp } = this
const entranceDistance = cp?.entranceDistance ?? d.entranceDistance
const entranceDuration = cp?.entranceDuration ?? d.entranceDuration
const marginX = d.messageMarginX // Could become configurable later
const avatarOffset = d.avatarOffset
return {
[this.toClassSelector(this.blockClassNames.messageWrap)]: {
'--entrance-distance': `${entranceDistance}px`,
'--entrance-duration': `${entranceDuration}ms`,
'marginBottom': '0px',
'opacity': 0,
'transform': `translate3d(0, var(--entrance-distance), 0)`,
'marginLeft': `${marginX}px`,
'marginRight': `${marginX}px`,
'transition': `opacity var(--entrance-duration) ease-out, transform var(--entrance-duration) ease-out`,
'&[data-first-in-block="true"]': {
paddingTop: `${d.messagePaddingTop}px`,
},
'&:not([data-first-in-block="true"])': {
marginTop: `${d.messageGapY}px`,
},
'&[data-input-needed="true"]:not([data-first-in-block="true"])': {
marginTop: `${d.inputNeededGapY}px`,
},
// Visible state
'&.visible': {
opacity: 1,
transform: 'translate3d(0, 0, 0)',
},
// Avatar offset
...(this.hasSenderIcon ? {
paddingLeft: `${avatarOffset}px`,
} : {}),
},
// Bubble styles -- use properties from messageProperties/choiceProperties/responseProperties
[this.toClassSelector(this.blockClassNames.bubble)]: {
padding: `${d.bubblePaddingY}px ${d.bubblePaddingX}px`,
borderRadius: `${d.bubbleBorderRadius}px`,
// Background, font, border from the relevant *Properties block
...this.bubbleStylesFromProperties,
},
// Avatar styles
[this.toClassSelector(this.blockClassNames.avatar)]: {
width: `${d.avatarSize}px`,
height: `${d.avatarSize}px`,
marginRight: `${d.avatarMargin}px`,
},
}
},
}The full component should mirror MessageHolder.vue's logic (mounted visibility timing, typing delay, scroll triggers, component switching on inputEntered) but use the constants and conversation properties instead of hardcoded values.
- [ ] Step 2: Verify it renders identically to V1
Mount both V1 and V2 side by side with the same liveFlow data. Every visual detail should match. The DOM structure will differ (data attributes vs classes) but the rendered output should be pixel-identical.
- [ ] Step 3: Commit
bash
git add src/components/conversationflow/MessageHolderV2.vue
git commit -m "feat(engine): add MessageHolderV2 with configurable defaults and clean DOM"Task 5: Wire V2 rendering path in CreativeConversationBlock
Files:
Modify:
Creative-Engine/src/components/creative/CreativeConversationBlock/CreativeConversationBlock.vue:1-40[ ] Step 1: Add version-conditional template
Update the template to branch on conversationVersion:
html
<template>
<div
v-show="!isRuntimeHidden"
:class="[blockClassNames.wrap]"
>
<div
ref="creativeconversationblock"
:class="[blockClassNames.typeBased, blockClassNames.idBased]"
>
<div
ref="scrollviewport"
:class="[
blockClassNames.scrollViewport,
{
noscroll: singleBlockFillsContainer,
hide: singleBlockFillsContainer === 'container',
},
]"
>
<div
ref="scrollcontent"
:class="[blockClassNames.scrollContent]"
@click.self="$emit('bgClick')"
>
<!-- V1: existing flat iteration -->
<template v-if="conversationVersion !== 2">
<MessageHolder
v-for="(componentArray, index) in liveFlow"
:key="componentArray?.[0].name"
:ref="`messageholder${index}`"
:components="componentArray"
:first-in-block="firstInBlock[index]"
:last-in-block="lastInBlock[index]"
:scroll-content-el="$refs.scrollcontent"
@scroll-to-bottom="autoScrollToBottom"
@bg-click="$emit('bgClick')"
/>
</template>
<!-- V2: flow-group aware iteration -->
<template v-else>
<template v-for="(entry, index) in groupedFlow">
<!-- Ungrouped step -->
<MessageHolderV2
v-if="!entry.isGroup"
:key="entry.components[0]?.[0].name"
:components="entry.components[0]"
:step-index="entry.stepIndex"
:first-in-block="firstInBlock[entry.stepIndex]"
:last-in-block="lastInBlock[entry.stepIndex]"
:scroll-content-el="$refs.scrollcontent"
@scroll-to-bottom="autoScrollToBottom"
@bg-click="$emit('bgClick')"
/>
<!-- Flow group -->
<FlowGroup
v-else
:key="entry.groupId"
:group-id="entry.groupId"
>
<MessageHolderV2
v-for="(componentArray, subIndex) in entry.components"
:key="componentArray?.[0].name"
:components="componentArray"
:step-index="entry.stepIndices[subIndex]"
:first-in-block="subIndex === 0"
:last-in-block="subIndex === entry.components.length - 1"
:scroll-content-el="$refs.scrollcontent"
@scroll-to-bottom="autoScrollToBottom"
@bg-click="$emit('bgClick')"
/>
</FlowGroup>
</template>
</template>
</div>
</div>
</div>
</div>
</template>- [ ] Step 2: Add computed properties
ts
computed: {
conversationVersion() {
return this.typedBlock?.conversationVersion ?? 1
},
/**
* Groups liveFlow entries by their flowGroup ID.
* Consecutive entries with the same flowGroup are wrapped in a FlowGroup.
* Entries without flowGroup remain ungrouped.
*
* Example input: [A(no group), B(group-1), C(group-1), D(no group)]
* Example output: [
* { isGroup: false, components: [A], stepIndex: 0 },
* { isGroup: true, groupId: 'group-1', components: [B, C], stepIndices: [1, 2] },
* { isGroup: false, components: [D], stepIndex: 3 },
* ]
*/
groupedFlow() {
const result = []
let currentGroup = null
for (let i = 0; i < this.liveFlow.length; i++) {
const componentArray = this.liveFlow[i]
const groupId = componentArray?.[0]?.flowGroup
if (!groupId) {
// Ungrouped
if (currentGroup) {
result.push(currentGroup)
currentGroup = null
}
result.push({
isGroup: false,
components: [componentArray],
stepIndex: i,
})
}
else if (currentGroup?.groupId === groupId) {
// Continue existing group
currentGroup.components.push(componentArray)
currentGroup.stepIndices.push(i)
}
else {
// Start new group
if (currentGroup) {
result.push(currentGroup)
}
currentGroup = {
isGroup: true,
groupId,
components: [componentArray],
stepIndices: [i],
}
}
}
if (currentGroup) {
result.push(currentGroup)
}
return result
},
}- [ ] Step 3: Import new components
Add imports for MessageHolderV2 and FlowGroup to the component's components option.
- [ ] Step 4: Commit
bash
git add -p
git commit -m "feat(engine): wire V2 rendering path in CreativeConversationBlock"Chunk 2: Flow-Group + Choice Layout
Task 6: Create FlowGroup.vue
Files:
Create:
Creative-Engine/src/components/conversationflow/FlowGroup.vue[ ] Step 1: Create the component
FlowGroup uses the display: contents trick: invisible to layout by default, but "materializes" when a designer overrides it via block styles to display: flex or display: grid.
html
<template>
<div
:class="[blockClassNames.flowGroup]"
:data-flow-group="groupId"
>
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { StyleAndClassNameGenerationMixin } from '@/mixins/StyleAndClassNameGenerationMixin'
export default defineComponent({
name: 'FlowGroup',
mixins: [StyleAndClassNameGenerationMixin],
props: {
groupId: { type: String, required: true },
},
computed: {
styles() {
return {
[this.toClassSelector(this.blockClassNames.flowGroup)]: {
display: 'contents',
},
}
},
},
})
</script>Why display: contents: The wrapper exists in the DOM for targeting via custom CSS or block styles, but by default it doesn't affect layout at all. The children behave as if they're direct children of the scroll-content. A designer can override to display: flex; flex-direction: column; gap: 8px; background: rgba(0,0,0,0.1); border-radius: 12px; padding: 12px; to create a "question card" look.
- [ ] Step 2: Commit
bash
git add src/components/conversationflow/FlowGroup.vue
git commit -m "feat(engine): add FlowGroup wrapper with display:contents default"Task 7: Create ChoiceV2.vue
Files:
Create:
Creative-Engine/src/components/blocks/basic/ChoiceV2.vueReference:
Creative-Engine/src/components/blocks/basic/Choice.vue(316 lines)[ ] Step 1: Create ChoiceV2
ChoiceV2 replaces Choice.vue's rendering with:
- Flex/grid layout instead of float
- Configurable stagger from constants (not hardcoded
120ms * index) variantandoutcomeCSS classes on each button- Feedback delay support (hold choices visible after click)
html
<template>
<div
:class="[blockClassNames.choices]"
:data-layout="layout"
>
<button
v-for="(comp, index) in blockArray"
:key="comp.name"
:class="[
blockClassNames.choiceButton,
{
selected: selectedIndex === index,
'feedback-active': feedbackActive,
},
]"
:data-variant="comp.variant"
:data-outcome="comp.outcome"
:disabled="feedbackActive || inputEntered"
@click="doSelection(comp, index)"
>
<!-- Choice content: text, image, or both -->
<span v-if="comp.payload?.text" v-html="comp.payload.text" />
<img
v-if="comp.payload?.image"
:src="comp.payload.image"
alt=""
/>
</button>
</div>
</template>Key styles() differences from Choice.vue:
ts
computed: {
layout() {
return this.choiceProperties?.layout || 'inline'
},
styles() {
const { defaults: d } = this
const staggerInterval = d.choiceStaggerInterval
const staggerDuration = d.choiceStaggerDuration
const staggerOffset = d.choiceStaggerOffset
return {
// Keyframe -- same animation, values from constants
'@keyframes choiceAppear': {
'0%': { opacity: 0, transform: `translateY(${staggerOffset}px)` },
'100%': { opacity: 1, transform: 'translateY(0)' },
},
// Container -- flex by default, grid when layout === 'grid'
[this.toClassSelector(this.blockClassNames.choices)]: {
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
justifyContent: this.choiceAlignment,
'&[data-layout="grid"]': {
display: 'grid',
gridTemplateColumns: `repeat(${this.choiceProperties?.columns || 2}, 1fr)`,
},
'&[data-layout="stack"]': {
flexDirection: 'column',
},
},
// Buttons -- stagger from constants
[this.toClassSelector(this.blockClassNames.choiceButton)]: {
// Per-button stagger via CSS custom property set inline
'animationDelay': 'var(--stagger-delay)',
'animationDuration': `${staggerDuration}ms`,
'animationTimingFunction': 'cubic-bezier(0, 1, 1, 1)',
'animationName': 'choiceAppear',
'animationFillMode': 'forwards',
'opacity': 0,
// ... background, font, border from choiceProperties ...
// Feedback state
'&.feedback-active': {
pointerEvents: 'none',
},
'&.feedback-active.selected': {
// Selected button stays full opacity
opacity: 1,
},
'&.feedback-active:not(.selected)': {
opacity: 0.5,
transition: 'opacity 200ms ease',
},
},
}
},
},
methods: {
doSelection(comp, index) {
if (this.feedbackActive || this.inputEntered) {
return
}
// Animation triggers (same as Choice.vue)
this.emitAnimationTriggers(comp)
// Analytics
DataStore.emitter.emit('cause:components', {
name: 'user-action',
meta: { textPayload: comp.payload?.text },
})
const feedbackDelay = this.conversationProps?.choiceFeedbackDelay ?? 0
if (feedbackDelay > 0) {
// Feedback phase: show selection, wait, then progress
this.selectedIndex = index
this.feedbackActive = true
setTimeout(() => {
this.feedbackActive = false
this.emitInputEntered(comp, index)
}, feedbackDelay)
}
else {
// Instant (current behavior)
this.emitInputEntered(comp, index)
}
},
emitInputEntered(comp, index) {
this.$emit('input-entered', { choiceIndex: index })
setTimeout(() => {
this.$emit('scroll-to-bottom')
}, 20)
},
},The stagger delay is set as an inline style attribute per button: --stagger-delay: ${index * staggerInterval + 1}ms. This keeps the JSS clean while allowing per-button timing.
- [ ] Step 2: Commit
bash
git add src/components/blocks/basic/ChoiceV2.vue
git commit -m "feat(engine): add ChoiceV2 with flex/grid layout, variant/outcome, feedback delay"Task 8: Wire ChoiceV2 in MessageHolderV2
Files:
Modify:
Creative-Engine/src/components/conversationflow/MessageHolderV2.vue[ ] Step 1: Import and use ChoiceV2
In MessageHolderV2, when rendering choice components, use ChoiceV2 instead of Choice:
ts
import ChoiceV2 from '@/components/blocks/basic/ChoiceV2.vue'
// In the dynamic component resolution:
const componentMap = {
Choice: ChoiceV2,
// ... other components remain the same
}- [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): wire ChoiceV2 into MessageHolderV2"Chunk 3: Scroll + Entrance Behavior
Task 9: Create autoScrollV2.ts
Files:
Create:
Creative-Engine/src/utils/autoScrollV2.tsReference:
Creative-Engine/src/utils/autoScroll.ts(105 lines)[ ] Step 1: Create configurable scroll utility
ts
import { CONVERSATION_DEFAULTS } from '@/utils/constants'
type ScrollConfig = {
duration?: number
easing?: string // Not used in JS (we compute our own), but exposed for CSS transitions
abortOnUserScroll?: boolean
}
let currentAnimation: number | null = null
/**
* Scrolls an element to the bottom with configurable duration and easing.
*
* Uses the same weighted quintic easing as autoScroll.ts by default,
* but duration is configurable via conversation properties.
*/
export const AutoScrollV2 = {
scrollTo(el: HTMLElement, config: ScrollConfig = {}) {
const duration = config.duration ?? CONVERSATION_DEFAULTS.scrollDuration
if (duration === 0) {
el.scrollTop = el.scrollHeight - el.clientHeight
return
}
// Cancel any in-progress scroll
if (currentAnimation !== null) {
cancelAnimationFrame(currentAnimation)
currentAnimation = null
}
const startScrollTop = el.scrollTop
const targetScrollTop = el.scrollHeight - el.clientHeight
const distance = targetScrollTop - startScrollTop
if (distance <= 0) {
return
}
const startTime = performance.now()
const step = (now: number) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
// Weighted quintic ease-out (same as autoScroll.ts)
const complexEase = 1 - (1 - progress) ** 5
const eased = 0.9 * complexEase + 0.1 * progress
el.scrollTop = startScrollTop + distance * eased
if (progress < 1) {
currentAnimation = requestAnimationFrame(step)
}
else {
currentAnimation = null
}
}
currentAnimation = requestAnimationFrame(step)
},
cancel() {
if (currentAnimation !== null) {
cancelAnimationFrame(currentAnimation)
currentAnimation = null
}
},
}- [ ] Step 2: Use in MessageHolderV2
Replace AutoScroll.scrollTo(el) calls in the V2 path with:
ts
AutoScrollV2.scrollTo(el, {
duration: this.conversationProps?.scrollDuration,
})- [ ] Step 3: Commit
bash
git add src/utils/autoScrollV2.ts
git commit -m "feat(engine): add AutoScrollV2 with configurable duration"Task 10: Configurable entrance in MessageHolderV2
Files:
Modify:
Creative-Engine/src/components/conversationflow/MessageHolderV2.vue[ ] Step 1: Support entrance modes
The entranceEffect property controls how new messages appear:
'slide'(default): Current behavior -- translateY + opacity'fade': Opacity only, no translation'none': Instant, no animation
In styles():
ts
const entranceEffect = cp?.entranceEffect ?? d.entranceEffect
const entranceDistance = cp?.entranceDistance ?? d.entranceDistance
const entranceDuration = cp?.entranceDuration ?? d.entranceDuration
const getEntranceTransform = () => {
switch (entranceEffect) {
case 'slide':
return `translate3d(0, ${entranceDistance}px, 0)`
case 'fade':
case 'none':
return 'none'
}
}
const getEntranceTransition = () => {
if (entranceEffect === 'none') {
return 'none'
}
return `opacity ${entranceDuration}ms ease-out, transform ${entranceDuration}ms ease-out`
}- [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): configurable entrance effect in MessageHolderV2"Chunk 4: Frontend Configuration UI
Task 11: Add V2 toggle to ConversationConfiguration
Files:
Modify:
Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/configs/ConversationConfiguration.vue[ ] Step 1: Add version toggle
At the top of the configuration panel, add a toggle that switches between V1 and V2:
html
<OptionList
v-if="showVersionToggle"
:title="$t('builder.conversation.version')"
:value="blockData.conversationVersion || 1"
:options="versionOptions"
@input="updateValue('conversationVersion', $event)"
/>The toggle should only appear for new creatives or when explicitly enabled (e.g., via a feature flag or workspace setting). Existing creatives stay on V1 by default.
- [ ] Step 2: Add V2-only sections
Conditionally show new configuration sections when V2 is active:
html
<template v-if="isV2">
<!-- Entrance -->
<EntranceEffectSection
:path="path"
:override-props="overrideProps"
/>
<!-- Scroll -->
<ScrollBehaviorSection
:path="path"
:override-props="overrideProps"
/>
<!-- Choice Feedback -->
<ChoiceFeedbackSection
:path="path"
:override-props="overrideProps"
/>
</template>- [ ] Step 3: Add i18n keys
Add to src/assets/i18n/en.js under the builder.conversation section:
js
conversation: {
// ... existing keys ...
version: 'Rendering Version',
versionV1: 'Classic',
versionV2: 'V2',
entranceEffect: 'Entrance Effect',
entranceDistance: 'Entrance Distance',
entranceDuration: 'Entrance Duration',
scrollDuration: 'Scroll Duration',
choiceFeedbackDelay: 'Choice Feedback Delay',
choiceLayout: 'Choice Layout',
choiceColumns: 'Columns',
}- [ ] Step 4: Commit
bash
git add -p
git commit -m "feat(frontend): add V2 configuration sections to ConversationConfiguration"Task 12: Create V2 section components
Files:
Create section components in
Application-Frontend/src/pages/Chatbots/components/BuilderVisuals/Configuration/components/as needed[ ] Step 1: Create EntranceEffectSection
Uses sectionLogic mixin. Contains:
OptionListfor entrance type (slide, fade, none)InputFieldfor distance (px) -- only shown when type is 'slide'InputFieldfor duration (ms)[ ] Step 2: Create ChoiceFeedbackSection
Uses sectionLogic mixin. Contains:
InputFieldfor feedback delay (ms, 0 = instant)Help text explaining that 0 means current behavior
[ ] Step 3: Create ChoiceLayoutSection
Uses sectionLogic mixin. Contains:
OptionListfor layout mode (inline, stack, grid)InputFieldfor columns -- only shown when layout is 'grid'[ ] Step 4: Add
sectionSettingsentries
In the relevant utils.ts, add entries for each new section component:
ts
EntranceEffectSection: {
title: 'builder.conversation.entranceEffect',
properties: ['entranceEffect', 'entranceDistance', 'entranceDuration'],
},
ChoiceFeedbackSection: {
title: 'builder.conversation.choiceFeedbackDelay',
properties: ['choiceFeedbackDelay'],
},
ChoiceLayoutSection: {
title: 'builder.conversation.choiceLayout',
properties: ['choiceLayout', 'choiceColumns'],
},- [ ] Step 5: Commit
bash
git add -p
git commit -m "feat(frontend): add V2 section components for entrance, feedback, layout"Chunk 5: Polish + CSS Reset + Responsive Prep
Task 13: CSS custom properties layer
Files:
Modify:
Creative-Engine/src/components/conversationflow/MessageHolderV2.vueModify:
Creative-Engine/src/components/blocks/basic/ChoiceV2.vue[ ] Step 1: Expose key values as CSS custom properties
In the V2 scroll-content container, set CSS custom properties that designers can override:
ts
// In CreativeConversationBlock styles(), V2 path:
[scrollContentSelector]: {
'--conv-message-margin-x': `${d.messageMarginX}px`,
'--conv-message-gap-y': `${d.messageGapY}px`,
'--conv-bubble-padding': `${d.bubblePaddingY}px ${d.bubblePaddingX}px`,
'--conv-bubble-radius': `${d.bubbleBorderRadius}px`,
'--conv-entrance-distance': `${entranceDistance}px`,
'--conv-entrance-duration': `${entranceDuration}ms`,
'--conv-choice-gap': '6px',
'--conv-choice-stagger': `${d.choiceStaggerInterval}ms`,
}MessageHolderV2 and ChoiceV2 reference these variables:
ts
marginLeft: 'var(--conv-message-margin-x)',
marginRight: 'var(--conv-message-margin-x)',This means a designer can override with a single custom CSS rule:
css
.scroll-content { --conv-bubble-radius: 8px; }- [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): expose conversation values as CSS custom properties"Task 14: Clean slate toggle
Files:
Modify:
Creative-Engine/src/interfaces/jsonTypes/payload-v2/index.tsModify:
Creative-Engine/src/components/conversationflow/MessageHolderV2.vue[ ] Step 1: Add
cleanSlateproperty
Add to ConversationProperties:
ts
cleanSlate?: boolean // When true, minimal default styles -- designer builds from scratch- [ ] Step 2: Implement in MessageHolderV2
When cleanSlate is true, the message-wrap and bubble get minimal styles:
ts
if (this.conversationProps?.cleanSlate) {
return {
[messageWrapSelector]: {
// Only layout essentials, no visual opinions
marginBottom: '0px',
opacity: 0,
'&.visible': { opacity: 1 },
},
[bubbleSelector]: {
// No padding, no border-radius, no background
// Designer adds everything via custom CSS or block styles
},
}
}- [ ] Step 3: Add frontend toggle
Add a "Clean Slate" toggle to ConversationConfiguration (V2 only):
html
<ToggleSection
v-if="isV2"
:path="path"
property="cleanSlate"
:title="$t('builder.conversation.cleanSlate')"
/>- [ ] Step 4: Commit
bash
git add -p
git commit -m "feat: add cleanSlate toggle for minimal conversation styling"Task 15: Responsive considerations
Files:
Modify:
Creative-Engine/src/components/conversationflow/MessageHolderV2.vue[ ] Step 1: Use relative units where possible
In MessageHolderV2's styles, use em for spacing that should scale with font size, and keep px for structural layout. The CSS custom properties layer (Task 13) already enables easy override per breakpoint via custom CSS.
Key responsive improvements:
- Message margins: use CSS custom properties so they can be overridden per creative size
- Choice button padding: use
emso it scales with font size - Avatar size: keep px but expose as custom property
This is a soft improvement -- no new properties needed. The custom properties + clean slate toggle give designers full control.
- [ ] Step 2: Commit
bash
git add -p
git commit -m "feat(engine): responsive-friendly units in MessageHolderV2"Migration Strategy
How existing creatives work
conversationVersionisundefinedon all existing creativesundefinedfalls through to1in theconversationVersion ?? 1check- V1 rendering path is used -- zero behavioral change
- All new V2 properties are optional with
undefineddefaults - The backend doesn't need changes -- it stores whatever JSON the frontend sends
How new V2 creatives work
- User toggles "V2" in conversation configuration (or new creatives default to V2 via workspace setting)
conversationVersion: 2is set on the creative- V2 rendering path activates: MessageHolderV2, ChoiceV2, FlowGroup, AutoScrollV2
- All V2 properties use
CONVERSATION_DEFAULTSwhen not explicitly set - Result looks identical to V1 by default -- the difference is configurability
Migrating existing creatives
Not automatic. A creative stays on V1 until someone explicitly switches it. This is intentional:
- V2 rendering is slightly different in DOM structure (data attributes vs classes)
- Custom CSS targeting V1 class names would break
- The creative owner should verify after switching
Future Work (Not in This Plan)
These features build on V2's architecture but are separate efforts:
- DCO/variable substitution in messages:
{variable}syntax in message text, resolved at render time. Depends on CAV-31 (variable system). - Rich text formatting: Markdown or limited HTML in message bubbles. Depends on #1812.
- Flow-group configuration UI: Frontend flow editor support for grouping steps. Currently groups can only be set in JSON.
- Per-step variant/outcome UI: Frontend flow editor fields for setting variant and outcome on individual flow components.
- Scroll-driven animations: Map animation progress to scroll position (
scroll-progresstrigger type). Depends on animation system maturity. - Choice layout templates: Preset layouts (quiz 2x2, horizontal pills, vertical stack) as one-click options.
- Transition between V1 and V2: A migration assistant that previews V2 rendering alongside V1 for comparison.
Related Documents
todos/ConversationRedesign/feasibility.md-- Problem analysis and approach comparison (this plan supersedes the Recommendation section)todos/ConversationRedesign/flow-group-visual-companion.html-- Interactive visual showing display:contents trick and flow-group conceptarchitecture/architecture-blueprint.md-- Overall system architecturetodos/Animations/-- Animation system docs (AnimationMixin, effects, triggers)