Fix/paragraph margins and full document html - #36
Merged
Conversation
Generated <p> tags now carry margin: 0px plus the logical block-margin longhands inline, matching the markup the Unlayer editor exports. Browsers default <p> to 1em block margins and email clients strip <head> resets, so without the inline reset multi-paragraph text gained extra vertical spacing compared to the editor. Also brings paragraph serialization to parity for custom styles, indent, string/numeric alignment formats, and inline-tool spans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The web grid's mobile rules (display: block, width/min-width overrides) are inert on flex items in a nowrap row — flex-basis governs their size — so columns never stacked on small screens and overflowed the viewport instead. Stack by letting the row wrap and growing each column's flex-basis to 100% below the breakpoint, matching the behavior of editor-exported web pages. Also aligns two related behaviors: the web breakpoint is the 480px mobile device breakpoint (email keeps contentWidth + 20), and document/print output never stacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
renderToHtml now returns a ready-to-use document from <!DOCTYPE ...>
to </html>, matching what the Unlayer editor exports: an XHTML
transitional shell with VML namespaces and MSO conditionals for email,
an HTML5 shell for web, and a print-friendly XHTML shell for document
mode. New options: title and fonts (stylesheet link URLs). Apps that
own their document shell keep using renderToHtmlParts, which still
returns embeddable { head, body } chunks.
Also fixes display-mode resolution for the Email/Page/Document
wrappers: they lock their mode internally, so head extraction
previously ran in web mode for all of them — renderToHtmlParts(<Email>)
returned web CSS instead of email CSS.
Golden snapshots now byte-lock the full documents for all three modes.
BREAKING CHANGE: renderToHtml previously returned only the body
markup. Callers that wrapped it in their own document shell should
switch to renderToHtmlParts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renders full documents with the built dist for all three modes and asserts in headless Chromium: zero computed <p> margins, a single <body>, responsive column stacking at mobile width, button hover colors, RTL direction, no horizontal overflow, hidden preheader, accessibility basics (img alt, link names, presentation tables), image width pinning, CSS parseability, print media visibility, and a computed-style baseline (deterministic across platforms, unlike pixel screenshots; regenerate with UPDATE_E2E_BASELINE=1). A negative control must fail, proving the gate detects what it guards. Runs in CI after the existing Playwright Chromium install. The ESM bundle budget rises 68KB -> 75KB for the per-mode document shells (currently ~70KB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the rendering pipeline to (1) ensure paragraphs render with email-client-safe zero margins and (2) make renderToHtml() return a complete HTML document (doctype → </html>) with per-mode shells aligned to the editor’s exported HTML, plus adds a Chromium-based E2E gate to validate the rendered output.
Changes:
- Add Lexical paragraph HTML serialization that enforces inline
<p>margin resets and supports alignment/indent/dir. - Change
renderToHtml()to emit full document shells (email/web/document), addRenderToHtmlOptions(title,fonts), and adjust related tests/snapshots/docs. - Update Row responsive CSS behavior/breakpoint and add a Playwright-driven browser E2E test + baseline + CI step.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates docs to reflect full-document renderToHtml() output and mention renderToHtmlParts(). |
| packages/shared/src/utils/lexical-helpers.ts | Adds paragraph HTML serialization with margin resets, alignment/indent handling. |
| packages/shared/src/utils/lexical-helpers.test.ts | Adds unit coverage for updated Lexical → HTML behavior. |
| packages/react/src/utils/render-to-html.tsx | Implements full-document renderToHtml() + options, refactors body rendering, updates renderToPlainText/parts behavior. |
| packages/react/src/utils/render-to-html.test.tsx | Adds tests for full document shells and mode resolution behavior. |
| packages/react/src/utils/extract-head.test.tsx | Updates assertions to reflect full-document wrapping behavior. |
| packages/react/src/utils/document-layouts.ts | Introduces per-mode document shell builders (email/web/document). |
| packages/react/src/index.ts | Exports RenderToHtmlOptions type. |
| packages/react/src/golden-template.test.tsx | Adds Document-mode golden template coverage. |
| packages/react/src/dx-behaviors.test.tsx | Updates/extends DX expectations around paragraph output. |
| packages/react/src/components/Row.tsx | Updates responsive flex stacking CSS and default breakpoint. |
| packages/react/src/components/snapshots/snapshots.test.tsx.snap | Snapshot updates for paragraph margin resets and responsive CSS. |
| packages/react/src/snapshots/golden-template.test.tsx.snap | Snapshot updates to lock full-document HTML output. |
| packages/react/scripts/browser-e2e.mjs | Adds Playwright Chromium E2E gate validating rendered DOM/computed styles and regressions. |
| packages/react/scripts/browser-e2e-baseline.json | Adds deterministic computed-style baseline for the E2E gate. |
| packages/react/README.md | Updates package docs for full-document renderToHtml() and new options. |
| packages/react/package.json | Adds test:e2e script. |
| CLAUDE.md | Updates internal docs for new layouts, E2E gate, and updated size budget. |
| .github/workflows/test.yml | Adds Browser E2E gate step and adjusts bundle size budget. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Review feedback: node.direction was interpolated into the dir attribute unescaped, so untrusted Lexical JSON could inject attribute markup. Only ltr/rtl are emitted now — which is also exact parity, since the editor ignores any other direction value. The same guard applies to heading nodes, plus an h1-h6 whitelist for the heading tag name, which was likewise interpolated unvalidated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: the responsive rule targeted bare .container with !important, which collides with host-page classes (Bootstrap et al.) when Elements output is embedded. Every Elements .container sits inside the .u_row wrapper, so scoping keeps our rendering identical while making the rule inert outside our markup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: the header still said 620px; the gate tests the real breakpoints (web: 480px device breakpoint, email: contentWidth + 20px). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: stripOuterDiv ran on every root, so a bare item root (e.g. renderToHtml(<Button className=...>)) lost its own wrapper div — including author-supplied className/style. The strip now applies only when the root is Body/Email/Page/Document, whose host div is renderer plumbing rather than the element's own markup. Also documents what HtmlParts.body actually contains per mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fingerprints the computed styles of every story (126 renders) at desktop and mobile widths in headless Chromium and diffs against a committed, dictionary-encoded baseline. A PR that changes any story's rendered styling fails naming the exact stories and property-level diffs — no more paging through stories by hand to spot drift. Computed styles rather than pixel screenshots keep the baseline deterministic across macOS and Linux. Regenerate intentional changes with UPDATE_VISUAL_BASELINE=1 pnpm test:visual. Runs in CI after the Storybook smoke test, reusing its static build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First CI run caught the one environment-derived style: elements with no explicit font inherit the browser default serif, reported as Times on macOS and Times New Roman on Linux. Normalized to a stable token so the baseline transfers across platforms; explicit font stacks compute identically and stay covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
macOS Chromium canonicalizes BlinkMacSystemFont to system-ui in computed font-family values; Linux keeps it literal. Normalize to one spelling so story stacks using system-font fallbacks fingerprint identically on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
What does this PR do? Keep it brief.
Changes
Test Plan
pnpm buildpassespnpm testpassesNotes
Any additional context for reviewers.
📖 Storybook Preview: https://unlayer.github.io/elements/pr/36/