diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index 3afdf4a..b607749 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -2,6 +2,12 @@
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Expect breaking changes in minor versions while we're pre-1.0.
+## 0.7.2 — 2026-07-15
+
+### Fixed
+
+- Dashboard-authored rich text now preserves bold, italic, unordered and ordered lists, links, responsive images, and video in the default step components. Previously, the SDK's scoped CSS reset flattened text emphasis and list markers; the restored styles match the flow builder's compact content rhythm.
+
## 0.7.1 — 2026-07-14
### Added
diff --git a/packages/react/package.json b/packages/react/package.json
index d626fba..09fbd71 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@churnkey/react",
- "version": "0.7.1",
+ "version": "0.7.2",
"description": "Production-ready cancel flow for React. Drop-in component, headless hook, or full customization. Works standalone or with Churnkey for AI-powered retention.",
"license": "MIT",
"repository": {
diff --git a/packages/react/src/components/rich-text.tsx b/packages/react/src/components/rich-text.tsx
index e1b6307..29da016 100644
--- a/packages/react/src/components/rich-text.tsx
+++ b/packages/react/src/components/rich-text.tsx
@@ -5,6 +5,8 @@
// reaches this component. If we ever need stricter hardening, sanitization
// (e.g. DOMPurify) should go here, not at every call site.
+import { cn } from '../core/utils'
+
type RichTextTag = 'p' | 'div' | 'span'
interface RichTextProps {
@@ -18,7 +20,10 @@ export function RichText({ html, as = 'p', className }: RichTextProps) {
const Tag = as
return (
diff --git a/packages/react/src/styles/cancel-flow.css b/packages/react/src/styles/cancel-flow.css
index 2e5043a..5d0634e 100644
--- a/packages/react/src/styles/cancel-flow.css
+++ b/packages/react/src/styles/cancel-flow.css
@@ -275,17 +275,75 @@
margin: 0 0 20px;
}
-/* Embedded video (YouTube/Vimeo/Wistia/Brightcove iframes) authored into a
- step description. Overrides the inline height="480" the TipTap YouTube node
- emits and makes the player fill the width at a 16:9 box. */
-.ck-cancel-flow .ck-step-description :is(iframe, video),
-.ck-cancel-flow .ck-step-description [data-youtube-video] {
+/* --- Dashboard-authored rich text ---
+ The scoped reset above keeps host-app element styles out of the flow, but
+ it also neutralizes semantic HTML defaults. Restore those semantics only
+ inside RichText so SDK chrome remains fully normalized. :where() keeps the
+ selectors easy for consumers to override while .ck-rich-text provides
+ enough specificity to beat the reset. */
+.ck-cancel-flow .ck-rich-text :where(strong, b) {
+ font-weight: 700;
+}
+
+.ck-cancel-flow .ck-rich-text :where(em, i) {
+ font-style: italic;
+}
+
+.ck-cancel-flow .ck-rich-text :where(u) {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.ck-cancel-flow .ck-rich-text :where(s, del) {
+ text-decoration: line-through;
+}
+
+.ck-cancel-flow .ck-rich-text :where(a) {
+ color: var(--ck-color-primary);
+ font-weight: 600;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.ck-cancel-flow .ck-rich-text :where(a:hover) {
+ color: var(--ck-color-primary-hover);
+}
+
+.ck-cancel-flow .ck-rich-text :where(ul, ol) {
+ /* Match the TipTap builder preview. The SDK reset already supplies the
+ compact zero-margin rhythm used by both the builder and embed. */
+ padding-inline-start: 1.1em;
+}
+
+.ck-cancel-flow .ck-rich-text :where(ul) {
+ list-style: disc outside;
+}
+
+.ck-cancel-flow .ck-rich-text :where(ol) {
+ list-style: decimal outside;
+}
+
+.ck-cancel-flow .ck-rich-text :where(li > p) {
+ margin: 0;
+}
+
+.ck-cancel-flow .ck-rich-text :where(img) {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+/* Embedded video (YouTube/Vimeo/Wistia/Brightcove). Overrides the inline
+ height="480" the TipTap YouTube node emits and makes the player fill the
+ available width at a 16:9 ratio. */
+.ck-cancel-flow .ck-rich-text :where(iframe, video),
+.ck-cancel-flow .ck-rich-text :where([data-youtube-video]) {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
border: 0;
- border-radius: 10px;
+ border-radius: 8px;
}
/* --- Buttons --- */
diff --git a/packages/react/tests/components/rich-text.test.tsx b/packages/react/tests/components/rich-text.test.tsx
new file mode 100644
index 0000000..1064c40
--- /dev/null
+++ b/packages/react/tests/components/rich-text.test.tsx
@@ -0,0 +1,58 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { render, screen } from '@testing-library/react'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { RichText } from '../../src/components/rich-text'
+
+const stylesheet = readFileSync(resolve(process.cwd(), 'src/styles/cancel-flow.css'), 'utf8')
+
+describe('RichText', () => {
+ let style: HTMLStyleElement
+
+ beforeAll(() => {
+ style = document.createElement('style')
+ style.textContent = stylesheet
+ document.head.append(style)
+ })
+
+ afterAll(() => style.remove())
+
+ it('marks authored HTML as rich text while preserving consumer classes', () => {
+ render(
)
+
+ const copy = screen.getByText('Copy').parentElement
+ expect(copy).toHaveClass('ck-rich-text', 'merchant-copy')
+ })
+
+ it('restores semantic formatting inside the scoped reset', () => {
+ const { container } = render(
+
+
+ Italic copy
+ Bold copy
+
+ Linked copy
+ `}
+ />
+ ,
+ )
+
+ const italic = screen.getByText('Italic copy')
+ const bold = screen.getByText('Bold copy')
+ const link = screen.getByRole('link', { name: 'Linked copy' })
+ const list = container.querySelector('ul')!
+ const listItems = container.querySelectorAll('li')
+
+ expect(getComputedStyle(italic).fontStyle).toBe('italic')
+ expect(getComputedStyle(bold).fontWeight).toBe('700')
+ expect(getComputedStyle(list).listStyleType).toBe('disc')
+ expect(getComputedStyle(list).paddingInlineStart).toBe('1.1em')
+ expect(getComputedStyle(list).marginTop).toBe('0px')
+ expect(getComputedStyle(listItems[1]).marginTop).toBe('0px')
+ expect(getComputedStyle(link).fontWeight).toBe('600')
+ expect(getComputedStyle(link).textDecoration).toContain('underline')
+ })
+})