Skip to content

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;700

This 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:

  1. globalReset.ts -- sets em, i { font-style: italic } and strong, b { font-weight: 700 }
  2. bannerReset.ts / expandableReset.ts -- applies font: inherit to 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,700

The 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:

  1. Uses a regex /(^.*?)style="(.*?)"(.*?$)/ to find the FIRST style="..." attribute in the text
  2. Extracts those styles and applies them as blockStyles (with !important) on the message bubble
  3. Strips the style attribute 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.vue
  • Creative-Engine/src/components/creative/VisualElements/CreativeTextBlock.vue
  • Creative-Engine/src/components/conversationflow/MessageHolder.vue
  • Creative-Engine/src/components/blocks/basic/Choice.vue
  • Creative-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)

Internal documentation