Appearance
Rich Text Formatting: Design Spec
Overview
Inline formatting for text blocks, buttons, taglines, and flow operators (messages, choices, links, change-text). Bubble menu appears on text selection, similar to Linear/Notion.
Supported formatting: italic, underline, strikethrough, font size, font weight, color, uppercase toggle.
Bold is intentionally excluded as a toggle -- font weight covers the same range with more control. Cmd+B toggles fontWeight between 700 and the block's default weight.
Hybrid Storage Model
Two different storage strategies based on where the text lives:
Visual element blocks (TextProperties, ButtonProperties, TaglineProperties)
Format: ProseMirror JSON in a new richText field.
json
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello " },
{ "type": "text", "marks": [{ "type": "bold" }], "text": "world" },
{ "type": "text", "marks": [{ "type": "textStyle", "attrs": { "fontSize": "24px", "fontWeight": "700", "color": "#ff0000" } }], "text": "!" }
]
}
]
}Why ProseMirror JSON:
- Structured data enables future extensions (per-word animations, DCO tokens)
- Individual text nodes map directly to DOM nodes needed for write-on/word-pop animations
- Can always extract HTML from JSON; going the other way is lossy
Fallback: Existing text: string is kept permanently. Creatives without richText render via escaped text as before. When a user edits an old creative, richText is populated on first save.
Flow operators (StatementOp, AnswerOp, LinkOp, ChangeTextOp)
Format: HTML string in existing inputText.value.
Hello <strong>world</strong><span style="font-size: 24px; color: #00ff00">!</span>Why HTML string:
- Engine already renders flow text via
v-htmlthroughRichText.getRichText() - No new fields needed -- plain text is valid HTML, so backward compatible
- Simpler data path through Creative Composer (no conversion step)
- Can migrate to ProseMirror JSON later if needed
Editor
Library: Tiptap (@tiptap/vue-2, @tiptap/pm, @tiptap/starter-kit)
Only installed in Application-Frontend. Engine has zero Tiptap dependency.
Tiptap Extensions
| Extension | Output | Package |
|---|---|---|
| Bold | <strong> | @tiptap/starter-kit (included) |
| Italic | <em> | @tiptap/starter-kit (included) |
| Underline | <u> | @tiptap/extension-underline |
| Strikethrough | <s> | @tiptap/starter-kit (included) |
| TextStyle | Mark container | @tiptap/extension-text-style |
| Placeholder | Placeholder text | @tiptap/extensions |
| InlineStyleExtension | fontSize, fontWeight, color attrs on textStyle | Custom (fontSizeExtension.ts) |
InlineStyleExtension
Custom extension that adds three attributes to the textStyle mark:
| Attribute | parseHTML | renderHTML | Validation in CE sanitizer |
|---|---|---|---|
| fontSize | element.style.fontSize | style="font-size: Xpx" | Decimal number + px/rem/em/% |
| fontWeight | element.style.fontWeight | style="font-weight: NNN" | Exactly 3-digit number |
| color | element.style.color | style="color: #rrggbb" | Exactly 6-digit hex |
Also provides a ProseMirror decoration plugin for visual font-size scaling in the editor (see "Font Size Scaling" below).
Bubble Menu
Appears on text selection. Custom-positioned relative to selection range.
Layout: [I] [U] [S] [TT] | [font-size] [weight-picker] | [color-swatch]
| Control | Action |
|---|---|
| I | Toggle italic mark |
| U | Toggle underline mark |
| S | Toggle strikethrough mark |
| TT | Toggle uppercase (transforms actual text content, preserves marks) |
| Font size input | Number input, sets textStyle fontSize in px |
| Weight picker | Dropdown filtered by font family's available weights |
| Color swatch | Opens ColorPicker (hex only, no alpha) |
Keyboard Shortcuts
| Shortcut | Action | Notes |
|---|---|---|
Cmd+B | Toggle bold weight | Sets fontWeight to 700 or reverts to block default. Not Tiptap's built-in bold. |
Cmd+S | Toggle strikethrough | Only when text is selected (prevents conflict with browser save) |
Cmd+Shift+U | Toggle uppercase | Transforms actual characters, not CSS text-transform |
Uppercase Toggle
Uses ProseMirror transactions to modify text content directly rather than CSS text-transform. This ensures uppercase text is stored as actual uppercase characters, making it visible in all contexts (preview, delivery, export).
The toggle walks selected text nodes, transforms each character, and replaces the text while preserving all marks. If all selected text is already uppercase, it converts to lowercase.
Font Size Scaling
Inline font sizes can't render at actual size in the compact editor (a 100px font would break the layout). Instead, a ProseMirror decoration plugin assigns CSS classes based on size ranges:
| Actual size | Class | Editor scale |
|---|---|---|
| 1-10px | .fs-xs | 0.75em |
| 11-14px | .fs-sm | 0.88em |
| 15-20px | .fs-md | 1.00em |
| 21-32px | .fs-lg | 1.15em |
| 33px+ | .fs-xl | 1.35em |
These decorations only exist in the editor DOM -- they're never stored in JSON or HTML output. The actual font sizes render correctly in the preview and delivery.
Transparent Mode
Flow operators use transparent prop on RichTextEditor, which:
- Removes the grey background
- Inherits font-family, font-size, line-height from the parent operator element
- Left-aligns text
- Uses text cursor instead of grab cursor
This makes the editor blend seamlessly into the operator UI.
Integration Points
Text blocks, buttons, taglines (ContentSection.vue):
- RichTextEditor with
mode="json"outputs ProseMirror JSON - Connects to
configurationLogic.updateValue()for Vuex persistence inputLockeddisables the editor viaeditor.setEditable(false)defaultFontSize,defaultFontWeight,defaultColorfrom block stylesfontFamilyused to filter available font weights in the weight picker
Flow operators (StatementOp, AnswerOp, LinkOp, ChangeTextOp):
- RichTextEditor with
mode="html"andtransparentprop editor.getHTML()output is cleaned viastripEmptySpans()before emittingstripEmptySpans()removes bare<span>wrappers (Tiptap artifacts)- Stored in
inputText.valueas before
OperatorBase focus handling:
focusComponentHandlerextended to recognizecontenteditableelements- ProseMirror clicks now correctly set
focusComponentInput = true, hiding BrandingToolbar
Randomly Mode Compatibility
The | split in Text.vue happens on the raw string. With HTML, a pipe inside a tag could cause issues:
Hello <strong>wor|ld</strong> -- would split mid-tagSince randomly mode is rarely used: document the limitation. Users who use randomly mode should avoid rich text formatting across the pipe boundary. This can be revisited later if needed.
Engine Rendering
jsonToHtml() converter
Lightweight function (~60 lines) that walks ProseMirror JSON and produces HTML. No Tiptap dependency. Located at CE/src/utils/content/jsonToHtml.ts.
Supports all marks: bold, italic, underline, strike, textStyle (fontSize, fontWeight, color). Text content is HTML-escaped. Empty textStyle marks (no attributes) produce no wrapper.
RichText.getRichText() sanitizer
Whitelist-based HTML sanitizer at CE/src/utils/content/richText.ts. Uses placeholder-and-escape strategy:
- Strip bare
<span>tags (no style attribute) - Replace allowed tags with null-byte placeholders
- Escape all remaining
<and> - Restore placeholders
Allowed tags: <strong>, <em>, <u>, <s>, <p>, and <span style="..."> with validated font-size/font-weight/color properties.
See architecture/rich-text-content-pipeline.md for the complete sanitizer reference with regex patterns.
Engine CSS
Blocks explicitly set fontWeight: 700 on & strong, & b and fontStyle: italic on & em, & i in their styles() computed. This ensures formatting works even when the parent block has an explicit fontWeight.
<u> and <s> use browser defaults (text-decoration) which aren't overridden by block styles.
Affected Files
Application-Frontend
| File | Change |
|---|---|
package.json | Add Tiptap dependencies |
Blocks/data/types.ts | Add richText? field to TextProperties, ButtonProperties, TaglineProperties |
Configuration/components/ContentSection.vue | Wire RichTextEditor with styling defaults |
Configuration/configs/TextConfiguration.vue | Pass defaultColor from blockData |
Configuration/configs/ButtonConfiguration.vue | Pass defaultColor from blockData |
Configuration/configs/TaglineConfiguration.vue | Pass defaultColor, singleLine from blockData |
CavaiFlow/operators/StatementOp.vue | Use RichTextEditor with transparent mode |
CavaiFlow/operators/AnswerOp.vue | Use RichTextEditor with transparent mode |
CavaiFlow/operators/LinkOp.vue | Use RichTextEditor with transparent, singleLine |
CavaiFlow/operators/ChangeTextOp.vue | Use RichTextEditor with transparent mode |
CavaiFlow/flowactors/OperatorBase.vue | Add contenteditable to focus detection |
components/common/ColorPicker.vue | Add hideAlpha, hidePreview props |
New files:
| File | Purpose |
|---|---|
components/common/RichTextEditor/RichTextEditor.vue | Shared Tiptap wrapper with bubble menu |
components/common/RichTextEditor/fontSizeExtension.ts | Custom extension: fontSize, fontWeight, color + decoration plugin |
Creative-Engine
| File | Change |
|---|---|
utils/content/richText.ts | Whitelist-based sanitizer (was escape-all) |
utils/content/jsonToHtml.ts | Added fontWeight, color to textStyle handling |
interfaces/jsonTypes/payload-v2/index.ts | Add richText? to Text, Button, Tagline types |
components/creative/VisualElements/CreativeTextBlock.vue | v-html with jsonToHtml + fallback |
components/creative/VisualElements/CreativeButtonBlock.vue | v-html with jsonToHtml + fallback |
components/conversationflow/atoms/Tagline.vue | v-html with jsonToHtml + fallback |
No Backend Changes
Operator data and block properties are stored as schemaless JSON. No migration needed. The richText field and HTML in inputText.value are handled transparently.
Future Extensions
- Feed/DCO variables as immutable inline tokens (Tiptap node views)
- Text animations: write-on, word-pop (ProseMirror JSON gives individual DOM nodes per mark)
- Migrate flow operators to ProseMirror JSON if more structure is needed
- Links as inline marks (clickable text within a paragraph)
- Google Fonts picker in TypefaceSection