From a599dad9e6635f4268a783751bf12d6575c2a1df Mon Sep 17 00:00:00 2001 From: Rob Moore Date: Wed, 15 Jul 2026 16:20:31 -0400 Subject: [PATCH] Fix React SDK rich text styles --- apps/playground/src/TestHarness.tsx | 53 ++++++++++++-- packages/react/CHANGELOG.md | 6 ++ packages/react/package.json | 2 +- packages/react/src/components/rich-text.tsx | 7 +- packages/react/src/styles/cancel-flow.css | 70 +++++++++++++++++-- .../react/tests/components/rich-text.test.tsx | 58 +++++++++++++++ 6 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 packages/react/tests/components/rich-text.test.tsx diff --git a/apps/playground/src/TestHarness.tsx b/apps/playground/src/TestHarness.tsx index 4e58875..da27f82 100644 --- a/apps/playground/src/TestHarness.tsx +++ b/apps/playground/src/TestHarness.tsx @@ -23,6 +23,7 @@ type Scenario = | 'custom-steps' | 'all-offers' | 'standalone-offer' + | 'rich-text' | 'color-scheme' | 'i18n' @@ -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', @@ -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: ` + +

See plan details

+ `, + 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( @@ -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. @@ -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': @@ -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') @@ -901,8 +942,8 @@ export function TestHarness() { - {/* Mode (does not apply to open-source — no session is recorded) */} - {scenario !== 'open-source' && ( + {/* Local-only scenarios do not record a session. */} + {!isLocalOnly && (
Session mode
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') + }) +})