Appearance
Rich Text Formatting -- Engine and Composer Changes
Branch: rich-text-formatting
This document covers the bug fixes required in Creative-Engine and Creative-Composer to support rich text (inline HTML with <em>, <strong>, <span style="...">) coming from the TipTap editor in Application-Frontend.
Bug 1: Italic not rendering in preview
Symptom
Text with <em> tags (italic) rendered without italic styling in the creative preview. The HTML was correct -- <em> was present in the DOM -- but the visual style was overridden.
Root cause (two parts)
Part A -- Google Fonts missing italic variants
FontHelper.ts loaded Google Fonts with weight-only axis notation:
fonts.googleapis.com/css2?family=Lato:wght@300;400;500;600;700This only downloads upright (roman) variants. The browser has no italic font data to render, so font-style: italic on <em> elements has no visible effect -- the browser falls back to the upright variant.
Part B -- CSS resets killing semantic element styles
The engine applies CSS resets in this order:
globalReset.ts-- setsem, i { font-style: italic }andstrong, b { font-weight: 700 }bannerReset.ts/expandableReset.ts-- appliesfont: inheritto a massive selector list
The reset selector lists included em, i, strong, b among many other elements. Because font: inherit is a shorthand that resets font-style, font-weight, font-size, line-height, and font-family all at once, it overwrote the font-style: italic set by globalReset.
The cascade order meant bannerReset always won: globalReset runs first (line 18 in styleEngine.ts), then bannerReset (line 24), so the later font: inherit overrides the earlier font-style: italic.
Fix
FontHelper.ts -- Include italic axis in Google Fonts URL:
typescript
const weights = [300, 400, 500, 600, 700]
const italWght = weights.map(w => `0,${w};1,${w}`).join(';')
// Result: ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700The ital axis uses 0 for upright and 1 for italic. This downloads both variants for every weight. The Google Fonts CSS2 API requires this format when requesting italic variants.
This does not increase creative payload size -- Google Fonts are loaded at runtime from Google's CDN. The browser only downloads the variants actually used (via unicode-range subsetting in Google's CSS response).
bannerReset.ts / expandableReset.ts -- Removed em, i, strong, b from the reset selector. These elements need their semantic styling preserved. The globalReset already handles them correctly.
Changed files
Creative-Engine/src/style-engine/helpers/FontHelper.ts(line 101-106)Creative-Engine/src/style-engine/bannerReset.ts(line 2-6, selector list)Creative-Engine/src/style-engine/expandableReset.ts(line 5-9, selector list)
Bug 2: ChangeText operator colors not applied
Symptom
When a ChangeText flow operator contained rich text with inline colors (e.g. <span style="color: rgb(255, 0, 0);">text</span>), the colors were stripped. The changed text inherited the target element's color instead of showing the inline colors.
Other inline styles (font-size, font-weight) worked correctly -- only color was affected.
Root cause
The Composer's remapData.ts calls separateStyleStringFromContent() on text content. This legacy function:
- Uses a regex
/(^.*?)style="(.*?)"(.*?$)/to find the FIRSTstyle="..."attribute in the text - Extracts those styles and applies them as
blockStyles(with!important) on the message bubble - Strips the
styleattribute from the text, returning modified HTML
The regex is non-global -- it only matches the first occurrence. With rich text like:
html
<p><span style="color: rgb(255, 0, 0);">CHANGE</span> <span style="font-size: 26px; font-weight: 700;">text</span></p>The first style="color: rgb(255, 0, 0);" was extracted and stripped, while the second style="font-size: 26px; font-weight: 700;" survived. This explains why color was lost but other styles worked.
The extracted color was then applied as a blockStyles override on the bubble, which is irrelevant for ChangeText since it's a functional operator (no bubble rendering) -- the extracted style was effectively discarded.
Why the fix is safe
ChangeText is a functional operator that changes the text content of a target visual block. It does not render its own message bubble. The blockStyles produced by separateStyleStringFromContent would apply to a bubble CSS class that doesn't exist for ChangeText components.
The legacy style extraction was designed for a pre-rich-text era where a single style="..." on wrapper elements encoded block-level styling. Rich text uses multiple inline style attributes for character-level formatting -- the extraction pattern is incompatible with this.
The fix does not affect tree shaking. separateStyleStringFromContent returns { text, styles } -- when skipped, comp.payload.text keeps its original value (the full rich HTML), and comp.blockStyles remains unset (undefined). Both are valid states that the engine handles correctly.
Link operators already had this same exclusion (comp.type !== 'Link'), because links store their text in body.link.name and handle style extraction separately in their own branch.
Fix
In remapData.ts, added comp.type !== 'ChangeText' to the condition that gates separateStyleStringFromContent:
typescript
// Text may have styles embedded in it - for non link
// Skip for ChangeText: its text is rich HTML with inline styles that must be preserved
if (comp.payload.text && comp.type !== 'Link' && comp.type !== 'ChangeText') {Changed files
Creative-Composer/src/remapper/remapData.ts(line 80)
Test impact
Existing tests for separateStyleStringFromContent in Creative-Composer/src/remapper/tests/utils/separateStyleStringFromContent.spec.ts are unaffected -- they test the utility function directly, not the calling code in remapData.ts.
Bug 3: Inline rich text colors not rendering in preview
Symptom
Rich text inline colors (applied via the Tiptap editor's color picker) were stripped or overridden in the creative preview and delivery. Text blocks showed the block's default color instead of inline formatting. Flow messages showed all text in a single color (the first span's color).
Root cause (three parts)
Part A -- Editor DOM mutation corrupting ProseMirror state (AF)
applyColorUnderlines() in RichTextEditor.vue set span.style.color = 'inherit' on ProseMirror's DOM to show colors as dotted underlines. ProseMirror's MutationObserver re-parsed the change into its model with color: inherit. getHTML() then output <span style="color: inherit"> which the engine sanitizer rejected (only hex/rgb/rgba allowed).
Part B -- Block default color overriding inline rich text (CE)
getRichTextOverrides() in CreativeTextBlock.vue received otherCustomStyles (merged block.style + customCSS). Since block.style always includes color, this generated { '& span[style]': { color: '<default> !important' } } for every text block, overriding all inline colors.
Part C -- Composer stripping first span's color (CC)
separateStyleStringFromContent() in remapData.ts extracted the first style="..." from operator text as blockStyles. With rich text, this stripped the first span's inline color and set it as the bubble color.
Fix
Part A: Replace JS DOM mutation with CSS-only approach: -webkit-text-fill-color: inherit hides text color, text-decoration: underline dotted with currentColor shows the applied color as a dotted underline. ProseMirror's inline styles are never touched.
Part B: Parse customCSS directly instead of using the merged otherCustomStyles. No overrides generated when customCSS is empty.
Part C: Skip separateStyleStringFromContent when text contains <p> tags (rich text from Tiptap). Pre-rich-text content never has <p> tags.
Changed files
Application-Frontend/src/components/common/RichTextEditor/RichTextEditor.vueCreative-Engine/src/components/creative/VisualElements/CreativeTextBlock.vueCreative-Engine/src/components/conversationflow/MessageHolder.vueCreative-Engine/src/components/blocks/basic/Choice.vueCreative-Composer/src/remapper/remapData.ts
PRs
- AF#1918, CE#754, CC#109
Data flow reference
For context, the full pipeline for rich text in a ChangeText operator:
Frontend (TipTap editor, mode="html")
-> inputText.value = '<p><span style="color: ...">text</span></p>'
-> Saved to flow JSON as operator body
Composer (remapData.ts)
-> comp.payload.text = inputText.value (raw HTML preserved)
-> separateStyleStringFromContent SKIPPED for ChangeText and rich text (<p> tags)
-> Output: payload.text contains full rich HTML
Engine (funcBlocks.ts -> Script.ts -> DataStore)
-> ChangeText sets DataStore.overrides[abbreviation].changeText = text
-> MessageHolder.vue applies override to target component's payload.text
-> Text.vue renders via RichText.getRichText() -> v-html
-> RichText sanitizer preserves allowed inline styles (color, font-size, font-weight)