Appearance
Flow Variables POC — Implementation Log
Branch: flow-variables-poc in all 3 repos (CE, Composer, Frontend)
Status: POC complete + live variables + auto quiz scoring implemented.
What's done
- [x] POC 1:
{varName}template substitution in CE - [x] POC 2: Set Variable operator (Frontend + Composer + CE)
- [x] POC 3: System variable
{answer}and{answer.position} - [x] Bug fix: changed type from
set_variabletosetVariable(camelCase) - [x] POC 4: "When" conditions on all functional operators
- [x] Builder polish:
{variable autocomplete dropdown (VariableSuggestions.vue) - [x] Builder polish: Condition fields — custom dropdowns, pill styling, keyboard nav
- [x] Builder polish: Previous choices backward traversal (BFS through linkList)
- [x] Builder polish: SetVariable operator pill/center styling
- [x] Builder polish: Variable background highlights in textareas
- [x] CE fix:
{answer}frozen at render time in Text.vue (prevent retroactive updates) - [x] CSS classes from flow variables (flowClasses.ts + styleTreeParser fix)
- [x] Phase 2: Correct/wrong — toggle on answers,
cavai-answer-correct/cavai-answer-wrongclasses - [x] Phase 2: isCorrectAnswer passthrough in Composer (remapData.ts)
- [x] Bug fix: Refresh clears
flowVariablesin-place + stripscavai-*DOM classes - [x]
answer.resultvariable:"correct"/"wrong"string for templates + conditions - [x] Live variables: snapshot vs live distinction (answer vars frozen, score etc. reactive)
- [x] Variables in static building blocks (CreativeTextBlock.vue)
- [x] Auto quiz scoring:
{score}+{score.total}system variables, auto-increment on correct answer
What's next (future phases)
- [ ] OnEvent trigger operator (formSubmit, countdown, slideChange) — see onEvent-plan.md
- [ ] Date/time system variables — see dateTime-variables-plan.md
POC 1: Template substitution in CE
Goal: {varName} in message text renders the value from DataStore.flowVariables.
Changes
CE/src/services/dataStore.ts
Added flowVariables: reactive({}) to the global state (after runtimeHidden).
CE/src/utils/content/richText.ts
Added variable substitution before encodeHTMLTags:
ts
innerHTML = innerHTML.replace(/\{(\w+(?:\.\w+)*)\}/g, (_match, varName) => {
const value = DataStore.flowVariables[varName]
return value !== undefined ? String(value) : _match
})- Supports dotted names:
{answer.position} - Keeps original
{varName}text if variable not set (won't break existing content) - Substitution happens before HTML encoding so values are safe
POC 2: Set Variable operator (full stack)
Goal: A new "Set Variable" operator that writes to DataStore.flowVariables at runtime.
Frontend changes
src/utils/_temp_buildercomponents.ts
- Added
{ title: 'Set Variable', type: 'setVariable' }toCOMPONENTLIST - Added
'setVariable'toswitchComponentsandFIRSTCOMPONENTLIST
src/pages/Chatbots/components/CavaiFlow/OperatorHelper.ts
Added default props for setVariable:
ts
props.body.variable = { name: '', operation: '=', value: '' }src/interfaces/chatbot.ts
Added variable? to OperatorProperties.body.
src/pages/Chatbots/components/CavaiFlow/operators/SetVariableOp.vue (NEW)
Simple form: variable name, operation dropdown (=, +=, -=), value. With validation.
src/pages/Chatbots/components/CavaiFlow/flowactors/OperatorBase.vue
Imported + registered SetVariableOp. Added 'sv' abbreviation.
Composer changes
src/remapper/remapData.ts
Added: if (opKey.includes('setVariable')) comp.payload.variable = body.variable
src/remapper/newTypes.ts + src/remapper/jsonTypes.ts
Added variable? to payload/body types.
CE changes
src/components/blocks/functional/SetVariable.ts (NEW)
Reads payload.variable, applies = / += / -= to DataStore.flowVariables[name].
src/utils/funcBlocks.ts
Registered: SetVariable: SetVariable.init
src/interfaces/.../FlowComponentInterface.ts
Added variable? to payload type.
POC 3: System variable {answer}
Goal: When user clicks a choice, {answer} and {answer.position} auto-populate.
CE/src/components/conversationflow/MessageHolder.vue
In handleInputEntry:
- Choice click: sets
answer(text) andanswer.position(1-based index) - Text input: sets
answer(entered text)
Bug fix: set_variable → setVariable
The original type set_variable broke getOperatorIndecesByType which splits on _ — it saw curOpType = "set" instead of "set_variable". All multi-word operator types use camelCase (changeText, showHide, resetChanges), so we aligned to that convention.
Test results
| Test | Result |
|---|---|
| Test 2: Set Variable + substitution (score = 0, += 1, show {score}) | Pass |
Test 3: {answer} system variable (choice → "You picked {answer}") | Pass |
| Test 4: Combined quiz flow (score init → question → conditional increment → show) | Pass |
POC 4: "When" conditions on functional operators
Goal: Any functional operator can have an optional condition. If the condition is not met, the operator is skipped and the flow continues to the next step.
CE changes
src/utils/evaluateCondition.ts (NEW)
Evaluates a condition { variable, operator, value } against DataStore.flowVariables:
- Operators:
=,!=,>,<,>=,<= - Auto-detects numeric values for comparison operators
- Falls back to string comparison for
=and!= - Returns
trueif no condition provided (operator runs unconditionally) - Returns
falseif variable doesn't exist yet (condition not met — prevents operators from firing before variables are set)
src/logic-system/processors/conversationFlow.ts
Before funcBlocks[type](nextCompSet), checks evaluateCondition(nextCompSet[0].payload.condition). If false, skips the block via setTimeout → addComponentsToFlow (same pattern as normal progression).
src/interfaces/.../FlowComponentInterface.ts
Added condition? to payload type.
Composer changes
src/remapper/remapData.ts
Generic passthrough at the end (applies to ANY operator, not type-specific):
ts
if (body.condition) {
comp.payload.condition = body.condition
}src/remapper/newTypes.ts + src/remapper/jsonTypes.ts
Added condition? to both FlowComponentPayload and OperatorProperties.body.
Frontend changes
src/pages/Chatbots/components/CavaiFlow/operators/ConditionFields.vue (NEW)
Shared component rendered on all functional operators. Two states:
- Collapsed (no condition): Shows "+ Add condition" link
- Expanded (condition set): Two-row form — "When [variable] ×" / "[operator] [value]"
Uses Vue.set() / Vue.delete() for reactivity. Unscoped styles so invert.scss overrides work on dark change-operators.
src/pages/Chatbots/components/CavaiFlow/flowactors/OperatorBase.vue
- Imported
ConditionFields,switchComponents,changeComponents isFunctionalOperatorcomputed: checksopTypeagainst all functional types- Renders
<ConditionFields>after<component :is>for all functional operators - Undo/redo deep watcher for
op.properties.body.condition
src/pages/Chatbots/components/CavaiFlow/flowactors/invert.scss
Added invert color overrides for .condition-fields, .condition-add, .condition-label, .condition-input, .condition-select, .condition-remove — so text is readable on dark change-operators.
src/interfaces/chatbot.ts
Added condition? to OperatorProperties.body.
Minor improvements in same session
src/pages/Chatbots/components/CavaiFlow/operators/ShowHideOp.vue
- Removed redundant
padding-bottom: 30pxfrom.targets-grid(ConditionFields now handles bottom spacing) - Improved grid layout: 6-column grid where orphan items on the last row stretch to fill width (1 alone = full width, 2 = half each, 3 = perfect row)
Test results
| Test | Result |
|---|---|
| Condition not met: score=5, +=1 when score=0 → shows 5 | Pass |
| Condition met: score=0, +=1 when score=0 → shows 1 | Pass |
| Answer condition: Choice→ChangeText when answer=Oslo | Pass |
| Numeric comparison: count=10, +=5 when count>5 → shows 15 | Pass |
| Not-equal: status=active, set blocked when status!=active → stays active | Pass |
| UI: add/remove condition, not shown on non-functional ops | Pass |
| Save/load: conditions persist | Pass |
| Invert theme: readable on dark change-operators | Pass |
| Narrow operators (CSS 90px): two-row layout fits | Pass |
POC 5: Builder polish — variable autocomplete, pills, highlights
Variable autocomplete (VariableSuggestions.vue — NEW)
Type { in any textarea → dropdown appears with available variables:
- System variables:
answer("What the user answered"),answer.position("Which answer was picked") - User variables: scanned from all SetVariable operators in the flow
- Smart descriptions: "starts at 10, then += 1"
- Filters
{answer}from suggestions in answer operators (since that's where answer comes from) - Keyboard nav: Arrow up/down, Enter to select, Escape to close
- Positioned near cursor line (not bottom of textarea)
- Click on variable → inserts
{varName}at cursor position
OperatorBase.vue — event delegation
@input.captureon root div detects{typed in any child textarea@keydown.capturefor Backspace/Delete to remove entire{varName}tokens@focusoutwithrelatedTargetcheck to close dropdown on outside click
Condition fields improvements
Custom dropdowns (replacing native <datalist>)
Native <datalist> had two issues:
- Ugly arrow + centered dropdown on browser-native UI
- ID collision bug: Multiple ConditionFields instances shared the same
id, browser showed the first one's data for all
Fix: Replaced with custom <ul class="condition-dropdown"> with @mousedown.prevent for selection.
Pill styling for set values
When a variable or value is set and valid, shows as a compact <span class="condition-pill"> instead of the input. Click to switch back to input for editing.
displayVariablecomputed: converts dots to spaces (answer.position→answer position)onVariableInput: converts spaces back to dots for engine formatisValidVariablecomputed: checks if variable matches known variables
Keyboard navigation
Arrow up/down to move through dropdown items, Enter to select, Escape to close. Works in both variable and value dropdowns.
Previous choices (backward flow traversal)
previousChoices computed on OperatorBase: BFS backward through $parent.$parent.linkList to find answer choices from preceding steps. Used for value suggestions in ConditionFields.
SetVariable operator polish
- Pill/input swap for variable name and value (same pattern as ConditionFields)
- Operation select (=, +=, -=) centered
- All elements center-aligned in the operator
Variable background highlights in textareas
When a textarea contains {varName}, a background highlight is shown behind the variable token.
Approach: A .var-highlight-layer div is inserted BEFORE the textarea in .op-textarea-wrapper. The div mirrors the textarea text with {varName} wrapped in <span class="var-token">. The div has color: transparent (text invisible) and .var-token gets background: rgba($secondary-default, 0.12). The textarea gets background: transparent so the highlight shows through. All text-layout properties are copied from the textarea's computed style to guarantee identical rendering.
CE fix: {answer} retroactive update bug
In Text.vue, {answer} was a reactive computed — when a new answer was picked, ALL previous messages updated retroactively. Initial fix: froze substituted HTML in created() hook → frozenHTML. Later replaced by snapshot/live split in POC 8: answer vars are snapshot (frozen), all other vars are live (reactive).
Challenges and iterations
Highlight alignment (multiple iterations)
| Attempt | Approach | Problem |
|---|---|---|
| 1 | Highlight div ON TOP of textarea, textarea text color: transparent | Cursor hidden behind overlay, can't type |
| 2 | Highlight div BEHIND textarea (insertBefore), textarea background: transparent, color: transparent | Text in div and textarea wrap differently → highlight offset on second line |
| 3 | Background-only: textarea keeps visible text, highlight only adds colored backgrounds on {varName} | Offset persists — font mismatch between div and textarea |
| 4 | Copy font properties (fontFamily, fontSize, etc.) from textarea computed style | Still offset — not enough properties copied |
| 5 | Copy ALL text-layout properties (same as textarea-caret-position libraries), use left:0; right:0; top:0 positioning | Current approach — testing |
Root cause: Browsers apply their own default font to <textarea> elements via the UA stylesheet. A <div> inherits font from its parent, but the textarea may use a different font/size. Even a 1px font-size difference accumulates across characters, causing large offsets. The fix copies ALL computed style properties that affect text layout from the textarea to the highlight div.
POC 6: CSS classes from flow variables
Goal: Automatically inject CSS classes onto the widget root based on flow variable state. Users can write conditional CSS in a CSS operator — no JavaScript needed.
Classes generated
| Variable state | CSS class |
|---|---|
answer = "Oslo" | .cavai-answer-oslo |
answer.position = 2 | .cavai-answer-position-2 |
score = 3 | .cavai-var-score-3 |
_answerCorrect = true | .cavai-answer-correct |
_answerCorrect = false | .cavai-answer-wrong |
Files changed
CE/src/utils/flowClasses.ts (NEW)
slugify(str)— lowercase, special chars → hyphensupdateFlowClasses()— removes oldcavai-*classes, adds current state- Called from
MessageHolder.vue(after answer) andSetVariable.ts(after variable write)
CE/src/style-engine/styleTreeParser/index.ts (MODIFIED)
Fixed CSS namespacing for .cavai-* selectors. Changed from descendant selector (.creative-outer-container .cavai-answer-oslo) to combined selector (.creative-outer-container.cavai-answer-oslo) since the classes are ON the container element, not on a descendant.
Challenges discovered during implementation
Vue
:classbinding overwrites manual classes:#creative-containerhas a reactive:classbinding in Creative.vue that overwritesclassList.add()on re-render. Fix: target.creative-outer-containerinstead (set once inmounted(), not Vue-managed).CSS namespacing mismatch:
prependOuterContainerSelectorConditionallyadds.creative-outer-containeras a descendant prefix to all selectors. But.cavai-*classes are ON the container itself, so descendant selectors don't match. Fix: detect.cavai-prefix and combine without space.Choices become responses after click:
.choiceelements transform into.responseelements (withis-hidden-response) after the user clicks. CSS targeting.choiceor specific answer elements doesn't work post-answer. This is why Phase 2 pivoted to container-level.cavai-answer-correct/.cavai-answer-wrongclasses instead of per-element styling.creativeIdundefined in preview:import.meta.env.VITE_CREATIVE_IDis undefined in preview mode, makingmainDivID = 'creative-undefined'. Fix: fallback to.creative-outer-containerselector.
POC 7: Phase 2 — Correct/Wrong answer marking
Goal: Mark answer operators as "correct" in the builder. On click: .cavai-answer-correct or .cavai-answer-wrong class on the widget. Users can style quiz results with a CSS operator.
Frontend changes
operators/AnswerOp.vue
Added ✓ toggle button (top-right of answer card, next to gear icon):
toggleCorrect()method:Vue.set(this.op.properties.body, 'isCorrectAnswer', !current)- Styled: green circle when active, subtle grey when inactive
- Position:
absolute; top: 4px; right: 24px(not overlapping context menu atright: 2px)
flowactors/OperatorBase.vue
Added undo/redo watcher for op.properties.body.isCorrectAnswer.
Composer changes
remapper/remapData.ts
Added passthrough at end of function:
ts
if (body.isCorrectAnswer) {
comp.payload.isCorrectAnswer = body.isCorrectAnswer
}CE changes
components/conversationflow/MessageHolder.vue
In handleInputEntry, after recording answer:
ts
const hasCorrectMarker = this.componentsWithOverrides.some(c => c.payload?.isCorrectAnswer)
if (hasCorrectMarker) {
const pickedCorrect = this.componentsWithOverrides[meta]?.payload?.isCorrectAnswer === true
DataStore.flowVariables['_answerCorrect'] = pickedCorrect
}utils/flowClasses.ts
Added correct/wrong class logic:
ts
if (vars._answerCorrect !== undefined) {
root.classList.add(vars._answerCorrect ? 'cavai-answer-correct' : 'cavai-answer-wrong')
}Example quiz CSS
css
.cavai-answer-correct { background: #e8f5e9; }
.cavai-answer-wrong { background: #ffebee; }POC 8: Live variables + snapshot/live split
Goal: Variables like {score} update live everywhere. Variables like {answer} stay frozen per-message.
Architecture: two kinds of variable
| Category | Variables | Behavior | Use case |
|---|---|---|---|
| Snapshot | answer, answer.position, answer.result | Frozen at render time per message | "You picked {answer}" stays correct even after next question |
| Live | score, score.total, all user-defined | Reactive, updates everywhere | Score display, counters, dynamic state |
CE changes
utils/content/richText.ts
- New
getRichTextLive(plaintext, snapshots)— uses snapshot values for answer vars, liveDataStore.flowVariablesfor the rest - New
captureSnapshots()— captures current answer-related values at creation time SNAPSHOT_VARS = ['answer', 'answer.position', 'answer.result']
components/blocks/basic/Text.vue (flow messages)
Changed from fully frozen to snapshot+live:
data():rawText(template string) +answerSnapshots(frozen answer values)created(): saves raw text + captures snapshotsinnerHTMLis now a reactive computed callinggetRichTextLive()— re-evaluates whenDataStore.flowVariableschanges
components/creative/VisualElements/CreativeTextBlock.vue (static building blocks)
Added flow variable substitution to parsedText computed:
ts
const vars = DataStore.flowVariables
text = text.replace(/\{(\w+(?:\.\w+)*)\}/g, (_match, varName) => {
const value = vars[varName]
return value !== undefined ? String(value) : _match
})Static blocks are always "live" — no snapshots needed since they persist on screen. This is the key change that lets {score}/{score.total} work in a Text block on the visuals canvas.
POC 9: Auto quiz scoring — {score} and {score.total}
Goal: Zero-config quiz scoring. Mark answers as correct (✓), put {score}/{score.total} in a Text block → done.
How it works
- Flow init (
conversationFlow.ts): scans all components forisCorrectAnswer, setsscore = 0andscore.total = Nwhere N = number of question steps with a correct marker - On correct answer (
MessageHolder.vue): auto-incrementsscore - Text block (
CreativeTextBlock.vue):{score}and{score.total}reactively update
No SetVariable operators needed for basic quiz scoring.
Files changed
CE/src/logic-system/processors/conversationFlow.ts
In init(), after setting up event listeners:
ts
// Count quiz questions (steps with at least one isCorrectAnswer)
let quizCount = 0
for (const step of components) {
if (step?.some(c => c.payload?.isCorrectAnswer)) quizCount++
}
if (quizCount > 0) {
DataStore.flowVariables['score'] = 0
DataStore.flowVariables['score.total'] = quizCount
}CE/src/components/conversationflow/MessageHolder.vue
After correct/wrong detection:
ts
if (pickedCorrect && DataStore.flowVariables['score'] !== undefined) {
DataStore.flowVariables['score'] = Number(DataStore.flowVariables['score']) + 1
}Uses Number() coercion because manual SetVariable stores "0" (string from input field), not 0 (number). typeof === 'number' would fail on string values.
CE/src/entry.ts
In buildCreative(): strips cavai-* DOM classes from .creative-outer-container on refresh.
CE/src/services/dataStore.ts
In resetDataStore(): clears flowVariables reactive object in-place before replacing.
Builder changes
Frontend/VariableSuggestions.vue
Added score, score.total, answer.result to { autocomplete suggestions.
Frontend/ConditionFields.vue
Added score, score total, answer result to available condition variables. Added ['correct', 'wrong'] as suggested values for answer.result.
Example quiz setup
Text block (Visuals): {score}/{score.total} Flow: Question → Answers (one marked ✓) → ... → Question → Answers (one marked ✓)
Result: Text block shows "0/3" initially, updates to "1/3", "2/3", "3/3" as user answers correctly.
Bug fixes during implementation
evaluateCondition: undefined variable → false (was true)
The original evaluateCondition returned true when a variable was undefined — intended as "safe default, operators run by default". But for when answer.result = correct, this meant the condition matched BEFORE the user had answered. Fix: no condition → true (run unconditionally); condition set but variable undefined → false (condition not met).
Score auto-increment: string coercion
The manual SetVariable operator (score = 0) stores the string "0", not the number 0. Auto-increment checked typeof === 'number' which failed. Fix: Number(value) + 1 handles both types.
CSS animation re-trigger
CSS animations on .cavai-answer-correct/.cavai-answer-wrong only played once because updateFlowClasses() removed and re-added the same class in the same synchronous block — browser optimized it as a no-op. Fix: void root.offsetWidth between remove and add forces a reflow, restarting the animation.
Preview refresh: stale flowVariables + DOM classes
resetDataStore()replaced the reactive proxy reference with a new empty one, but code holding the old proxy still saw stale data. Fix: clear the reactive object in-place withdeletebeforeObject.assign..creative-outer-container(parent of Vue app, survives unmount) keptcavai-*classes across refreshes. Fix: stripcavai-*classes inbuildCreative()after reset.
Known limitations (POC scope)
- Variables are not persisted across page reloads (runtime only)
{answer}always reflects the LAST choice made (not per-question)- No
toggleor other operations beyond=,+=,-= - No compound conditions (AND/OR) — single condition per operator only