Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 47 additions & 6 deletions apps/playground/src/TestHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Scenario =
| 'custom-steps'
| 'all-offers'
| 'standalone-offer'
| 'rich-text'
| 'color-scheme'
| 'i18n'

Expand Down Expand Up @@ -69,6 +70,11 @@ const SCENARIOS: { id: Scenario; label: string; description: string }[] = [
label: 'Standalone Offer (No Survey)',
description: 'Flow starts on an OFFER step — proactive save offer before any survey.',
},
{
id: 'rich-text',
label: 'Rich Text Parity',
description: 'Builder-shaped bold, italic, list, and link HTML rendered through the SDK reset.',
},
{
id: 'color-scheme',
label: 'Color Scheme',
Expand Down Expand Up @@ -302,6 +308,37 @@ const standaloneOfferSteps: Step[] = [
successStep,
]

// Mirrors the TipTap HTML shape emitted by the dashboard flow builder. Keep
// this as a manual visual fixture alongside the computed-style regression test
// so changes to the SDK reset can be checked in a real modal.
const richTextSteps: Step[] = [
{
type: 'offer',
title: 'Want to go month-to-month instead?',
description: `
<ul>
<li><p><em>2 new stock recommendations every month</em></p></li>
<li><p>Monthly Top 10 Rankings from our analysts</p></li>
<li><p><strong>Ongoing commentary and updates</strong></p></li>
</ul>
<p><a href="https://example.com">See plan details</a></p>
`,
offer: {
type: 'plan_change',
plans: [
{
id: 'stock-advisor',
name: 'Stock Advisor',
amount: { value: 3900, currency: 'USD' },
duration: { interval: 'month' },
},
],
},
},
{ type: 'confirm' },
successStep,
]

function usePersistedField(key: string, defaultValue = '') {
const [value, setValue] = useState(() => localStorage.getItem(`ck_test_${key}`) ?? defaultValue)
const set = useCallback(
Expand Down Expand Up @@ -695,8 +732,10 @@ export function TestHarness() {
}
}, [log])

const customer: DirectCustomer | undefined =
scenario !== 'open-source' ? { id: customerId || 'cus_test', email: customerEmail || undefined } : undefined
const isLocalOnly = scenario === 'open-source' || scenario === 'rich-text'
const customer: DirectCustomer | undefined = !isLocalOnly
? { id: customerId || 'cus_test', email: customerEmail || undefined }
: undefined

// Half-typed JSON shouldn't take down the harness — invalid input just
// means no attributes are passed.
Expand Down Expand Up @@ -762,6 +801,8 @@ export function TestHarness() {
return allOffersSteps
case 'standalone-offer':
return standaloneOfferSteps
case 'rich-text':
return richTextSteps
case 'i18n':
return i18nSteps
case 'token':
Expand All @@ -787,9 +828,9 @@ export function TestHarness() {
log(`step: ${prevStep} → ${step}`)
}

const needsAppId = scenario !== 'open-source'
const needsAppId = !isLocalOnly
const needsApiKey = needsToken
const needsCustomer = scenario !== 'open-source' && scenario !== 'color-scheme'
const needsCustomer = !isLocalOnly && scenario !== 'color-scheme'

const missing: string[] = []
if (needsAppId && !appId) missing.push('App ID')
Expand Down Expand Up @@ -901,8 +942,8 @@ export function TestHarness() {
</div>
</fieldset>

{/* Mode (does not apply to open-source — no session is recorded) */}
{scenario !== 'open-source' && (
{/* Local-only scenarios do not record a session. */}
{!isLocalOnly && (
<fieldset style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: 16, marginBottom: 16 }}>
<legend style={{ fontSize: 13, fontWeight: 600, color: '#374151', padding: '0 4px' }}>Session mode</legend>
<div style={{ display: 'flex', gap: 6 }}>
Expand Down
6 changes: 6 additions & 0 deletions packages/react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
7 changes: 6 additions & 1 deletion packages/react/src/components/rich-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,7 +20,10 @@ export function RichText({ html, as = 'p', className }: RichTextProps) {
const Tag = as
return (
<Tag
className={className}
// The SDK's scoped reset intentionally neutralizes bare HTML elements
// so host-app styles cannot leak into the flow. This class opts authored
// content back into the semantic prose rules in cancel-flow.css.
className={cn('ck-rich-text', className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: see file-level comment
dangerouslySetInnerHTML={{ __html: html }}
/>
Expand Down
70 changes: 64 additions & 6 deletions packages/react/src/styles/cancel-flow.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 --- */
Expand Down
58 changes: 58 additions & 0 deletions packages/react/tests/components/rich-text.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RichText as="div" html="<p>Copy</p>" className="merchant-copy" />)

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(
<div className="ck-cancel-flow">
<RichText
as="div"
html={`
<ul>
<li><p><em>Italic copy</em></p></li>
<li><p><strong>Bold copy</strong></p></li>
</ul>
<p><a href="https://example.com">Linked copy</a></p>
`}
/>
</div>,
)

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')
})
})
Loading