Skip to content

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_variable to setVariable (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-wrong classes
  • [x] Phase 2: isCorrectAnswer passthrough in Composer (remapData.ts)
  • [x] Bug fix: Refresh clears flowVariables in-place + strips cavai-* DOM classes
  • [x] answer.result variable: "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' } to COMPONENTLIST
  • Added 'setVariable' to switchComponents and FIRSTCOMPONENTLIST

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) and answer.position (1-based index)
  • Text input: sets answer (entered text)

Bug fix: set_variablesetVariable

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

TestResult
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 true if no condition provided (operator runs unconditionally)
  • Returns false if 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
  • isFunctionalOperator computed: checks opType against 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: 30px from .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

TestResult
Condition not met: score=5, +=1 when score=0 → shows 5Pass
Condition met: score=0, +=1 when score=0 → shows 1Pass
Answer condition: Choice→ChangeText when answer=OsloPass
Numeric comparison: count=10, +=5 when count>5 → shows 15Pass
Not-equal: status=active, set blocked when status!=active → stays activePass
UI: add/remove condition, not shown on non-functional opsPass
Save/load: conditions persistPass
Invert theme: readable on dark change-operatorsPass
Narrow operators (CSS 90px): two-row layout fitsPass

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.capture on root div detects { typed in any child textarea
  • @keydown.capture for Backspace/Delete to remove entire {varName} tokens
  • @focusout with relatedTarget check to close dropdown on outside click

Condition fields improvements

Custom dropdowns (replacing native <datalist>)

Native <datalist> had two issues:

  1. Ugly arrow + centered dropdown on browser-native UI
  2. 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.

  • displayVariable computed: converts dots to spaces (answer.positionanswer position)
  • onVariableInput: converts spaces back to dots for engine format
  • isValidVariable computed: 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)

AttemptApproachProblem
1Highlight div ON TOP of textarea, textarea text color: transparentCursor hidden behind overlay, can't type
2Highlight div BEHIND textarea (insertBefore), textarea background: transparent, color: transparentText in div and textarea wrap differently → highlight offset on second line
3Background-only: textarea keeps visible text, highlight only adds colored backgrounds on {varName}Offset persists — font mismatch between div and textarea
4Copy font properties (fontFamily, fontSize, etc.) from textarea computed styleStill offset — not enough properties copied
5Copy ALL text-layout properties (same as textarea-caret-position libraries), use left:0; right:0; top:0 positioningCurrent 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 stateCSS 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 → hyphens
  • updateFlowClasses() — removes old cavai-* classes, adds current state
  • Called from MessageHolder.vue (after answer) and SetVariable.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

  1. Vue :class binding overwrites manual classes: #creative-container has a reactive :class binding in Creative.vue that overwrites classList.add() on re-render. Fix: target .creative-outer-container instead (set once in mounted(), not Vue-managed).

  2. CSS namespacing mismatch: prependOuterContainerSelectorConditionally adds .creative-outer-container as 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.

  3. Choices become responses after click: .choice elements transform into .response elements (with is-hidden-response) after the user clicks. CSS targeting .choice or specific answer elements doesn't work post-answer. This is why Phase 2 pivoted to container-level .cavai-answer-correct/.cavai-answer-wrong classes instead of per-element styling.

  4. creativeId undefined in preview: import.meta.env.VITE_CREATIVE_ID is undefined in preview mode, making mainDivID = 'creative-undefined'. Fix: fallback to .creative-outer-container selector.


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 at right: 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

CategoryVariablesBehaviorUse case
Snapshotanswer, answer.position, answer.resultFrozen at render time per message"You picked {answer}" stays correct even after next question
Livescore, score.total, all user-definedReactive, updates everywhereScore display, counters, dynamic state

CE changes

utils/content/richText.ts

  • New getRichTextLive(plaintext, snapshots) — uses snapshot values for answer vars, live DataStore.flowVariables for 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 snapshots
  • innerHTML is now a reactive computed calling getRichTextLive() — re-evaluates when DataStore.flowVariables changes

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

  1. Flow init (conversationFlow.ts): scans all components for isCorrectAnswer, sets score = 0 and score.total = N where N = number of question steps with a correct marker
  2. On correct answer (MessageHolder.vue): auto-increments score
  3. 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

  1. 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 with delete before Object.assign.
  2. .creative-outer-container (parent of Vue app, survives unmount) kept cavai-* classes across refreshes. Fix: strip cavai-* classes in buildCreative() 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 toggle or other operations beyond =, +=, -=
  • No compound conditions (AND/OR) — single condition per operator only

Internal documentation