Commit f5969b77fac for woocommerce

commit f5969b77face566b8a9757058ab7b8f39c29c4a9
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Tue Nov 10 08:08:28 2026 +0100

    Add the Separator block to the email editor (#70573)

    * Add the Separator block to the email editor

    Email clients style <hr> inconsistently, so the renderer draws the line
    as a table cell border and the dots as text. Default is a short centered
    line, Wide Line spans the content, and the color falls back to the text
    color. The editor CSS now shows the same width and color as the email.

    * Keep the Separator block styles in the email editor

    The editor removes block styles by default. Default, Wide Line and Dots
    are all rendered in the email, so the separator keeps them.

    * Import block types from @wordpress/blocks directly

    ESLint's import plugin can't resolve the @wordpress/blocks/index path,
    and the module was imported twice in general-block-support.ts.

    * Size separator dots from the inherited font size

    Inside a Group with its own font size, the editor draws the dots at 1.5×
    that size. The renderer used the email's base size, so the sent dots
    were smaller than in the editor.

    * Keep only the separator styles the email renderer draws

    Themes and plugins can register extra Separator styles. The email would
    show those as a plain default line, so the editor now keeps only
    Default, Wide Line and Dots for the separator.

    * Test that Social Links keeps all its styles in the email editor

    The block style filter now takes a per-block list; cover the blocks that
    keep every style, and register test blocks with API version 3.

    * Fix the separator invalid-color test and Outlook dots line height

    The test now asserts the black fallback the renderer really uses for a
    rejected color. The dots cell gets mso-line-height-rule like the line,
    so Outlook keeps the row at one line.

    * Show separator lines as dashes in the plain-text email

    The line is drawn as a table, which Html2Text turned into an empty line.
    Html2Text now treats an element with the email-block-separator class
    like an <hr>, so the text version keeps the divider.

    * Match separator style classes as whole names

    A custom class such as is-style-wide-dashed was read as the Wide Line
    style. Compare whole class names, as Html2Text already does.

    * Blend see-through separator colors onto the email background

    The color picker saves a lowered opacity as #rrggbbaa, which Outlook
    desktop can't read, so the line could vanish there. The renderer now
    blends it onto the email background, which looks the same as the editor.

    * Drop an unreachable dots size fallback and its test

    convert_to_px() always returns a value, so the extra fallback only kept
    PHPStan quiet; a string cast does that. The theme-size test covered a
    path real sends never take, since the preprocessor always passes the
    font size; the inherited-size test covers the real one.

    * Center the default separator in the editor at full width

    The full-width editor wrapper sets inline negative margins, which put
    the short Default line on the left, while the email centers it. Keep
    the 100px line centered in the editor too.

    * Rely on the preprocessor for the separator color and font size

    Typography_Preprocessor always sets email_attrs color and font-size from
    the theme, so the renderer's own theme fallbacks never ran in a real
    email. Keep one plain default and drop the test that covered only the
    unused path. Note that the alpha blend uses the email background.

    * Mark separator lines with a data attribute for plain text

    A block's custom CSS class could match the email-block-separator class,
    and Html2Text then replaced that block and its text with dashes. A data
    attribute can't be set from the editor, so only separator lines match.

    * Test the plain-text separator attribute directly

    Html2Text had no unit test for data-email-separator; the existing one
    only checked that a matching class name keeps the block's text.

    * Show the Dots separator as dashes in the plain-text email

    Lines already became dashes but Dots stayed as a stray "···". Mark the
    dots table too, so every separator style reads the same in plain text.

    * Blend short-hex and rgba() separator colors as well

    Only #rrggbbaa was blended for Outlook: a #rgba palette color turned
    black, an rgba() one kept its alpha, and a #rgb email background was
    replaced by white. Parse all of these before blending.

    * Output every readable separator color as 6-digit hex

    An opaque #rgba color fell through to sanitize_color() and turned black,
    and an opaque #rrggbbaa one reached Outlook unchanged. Any color the
    renderer can read now comes out as plain 6-digit hex.

    * Draw see-through separators once in the editor

    The editor's <hr> painted the picked color as both its border and its
    background, so 50% red looked like about 75%. Clip the background so
    only the border shows, matching the color the email sends.

    * Use typographic apostrophes in the Html2Text separator docblock

    * Keep the Wide separator from shrinking in the Gmail app

    The Gmail app can shrink a table with no content to almost nothing on
    mobile. Add min-width:100%, as the Text and Audio blocks do.

    * Blend separator colors onto a palette email background

    A palette email background is stored as a preset reference, which the
    blend couldn't read, so it used white. Look the slug up in the palette
    first, so a see-through line on a black email stays grey.

    * Keep the Dots separator inside narrow columns and phones

    The dots used fixed 2em spacing, and a table cell grows to fit its
    content, so a dots row widened narrow columns and made emails scroll
    sideways on phones. Cap the row at 240px, or the column width, in
    both the renderer and the editor CSS so the two still match.

    * Use a parent Group's font size for text blocks in emails

    The typography preprocessor passes a Group's font size down to its
    children, but the Text renderer only used the inherited color, so a
    paragraph in a 32px Group was sent at the base size. Use the inherited
    font size when the block sets none, flattening clamp() like elsewhere.

    * Flatten fluid inherited font sizes for the separator dots

    The dots passed an inherited clamp() size to convert_to_px(), which fell
    back to 16px, so dots in a fluid-size Group were smaller than the text
    beside them. Flatten clamp() first, as the Text renderer now does.

    * Accept only a plain px length as an inherited text font size

    convert_to_px() keeps any value ending in px, so an inherited font size
    carrying extra declarations reached the paragraph cell's style. Use the
    inherited size only when it is a single number followed by px.

    * Keep static font sizes while rendering emails

    Core's typography support rewrites the first matching font size in a
    block's HTML into a fluid clamp(). With a preset-size Group that match
    became the first paragraph's cell, so one paragraph shrank in Apple
    Mail. Remove the filter during email rendering and restore it after.

    * Share one static font size helper between Text and Separator

    Both renderers turned an inherited font size into px, with different
    checks. Styles_Helper::get_static_font_size() flattens clamp(), converts
    to px and rejects anything that isn't a single px length.

    * Test the Dots width cap with a real column width

    The dots tests passed no width, so they fell back to the cap itself and
    stayed green with the cap removed. Give them the 600px width a top-level
    block gets in a real email, so the cap is what decides the spacing.

    * Also remove Gutenberg's typography support while rendering emails

    The Gutenberg plugin swaps core's wp_render_typography_support for its
    own gutenberg_render_typography_support, so with the plugin active the
    fluid clamp() rewrite still ran on emails. Remove and restore both
    callbacks, each at its own priority.

diff --git a/packages/js/email-editor/changelog/add-wooplug-3863-separator-block b/packages/js/email-editor/changelog/add-wooplug-3863-separator-block
new file mode 100644
index 00000000000..26a7e08501f
--- /dev/null
+++ b/packages/js/email-editor/changelog/add-wooplug-3863-separator-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Keep the Separator block styles (Default, Wide Line, Dots) in the email editor.
diff --git a/packages/js/email-editor/src/blocks/core/general-block-support.ts b/packages/js/email-editor/src/blocks/core/general-block-support.ts
index ef479666b4b..89388f93ddc 100644
--- a/packages/js/email-editor/src/blocks/core/general-block-support.ts
+++ b/packages/js/email-editor/src/blocks/core/general-block-support.ts
@@ -4,9 +4,9 @@
 import {
 	Block as WPBlock,
 	BlockSupports as WPBlockSupports,
-} from '@wordpress/blocks/index';
+	getBlockTypes,
+} from '@wordpress/blocks';
 import domReady from '@wordpress/dom-ready';
-import { getBlockTypes } from '@wordpress/blocks';
 import { select } from '@wordpress/data';

 /**
@@ -21,8 +21,11 @@ import { unregisterBlockStyleForEmail } from '../../config-tools/block-style';
 type BlockSupports = WPBlockSupports & { shadow: boolean };
 type Block = WPBlock & { supports?: BlockSupports };

-// List of blocks that we want to support.
-const BLOCK_STYLES_TO_PRESERVE = [ 'core/social-links' ];
+// Block styles the email renderer supports: a list of style names, or `true` for all of them.
+const BLOCK_STYLES_TO_PRESERVE: Record< string, string[] | true > = {
+	'core/separator': [ 'default', 'wide', 'dots' ],
+	'core/social-links': true,
+};

 /**
  * Disables Shadow Support for all blocks
@@ -43,7 +46,7 @@ function alterSupportConfiguration(): void {
 }

 /**
- * Remove block styles for all blocks that are not in the BLOCK_STYLES_TO_PRESERVE array
+ * Remove block styles the email renderer doesn’t support (see BLOCK_STYLES_TO_PRESERVE)
  * We are removing these block styles because they might contain CSS not supported in the email editor renderer (or email clients).
  *
  * Block styles removed:
@@ -55,9 +58,9 @@ function alterSupportConfiguration(): void {
 function removeBlockStyles() {
 	getBlockTypes().forEach( ( blockType ) => {
 		const blockName = blockType.name;
+		const preserved = BLOCK_STYLES_TO_PRESERVE[ blockName ];

-		if ( BLOCK_STYLES_TO_PRESERVE.includes( blockName ) ) {
-			// Skip block styles that are in the BLOCK_STYLES_TO_PRESERVE array
+		if ( preserved === true ) {
 			return;
 		}
 		const blockStyles = select( 'core/blocks' ).getBlockStyles( blockName );
@@ -65,9 +68,13 @@ function removeBlockStyles() {
 			return;
 		}

-		blockStyles.forEach( ( blockStyle ) => {
-			unregisterBlockStyleForEmail( blockName, blockStyle.name );
-		} );
+		blockStyles
+			.filter(
+				( blockStyle ) => ! preserved?.includes( blockStyle.name )
+			)
+			.forEach( ( blockStyle ) => {
+				unregisterBlockStyleForEmail( blockName, blockStyle.name );
+			} );
 	} );
 }

diff --git a/packages/js/email-editor/src/blocks/core/test/general-block-support.spec.ts b/packages/js/email-editor/src/blocks/core/test/general-block-support.spec.ts
new file mode 100644
index 00000000000..f45bfb4098a
--- /dev/null
+++ b/packages/js/email-editor/src/blocks/core/test/general-block-support.spec.ts
@@ -0,0 +1,84 @@
+/**
+ * External dependencies
+ */
+import {
+	registerBlockType,
+	unregisterBlockType,
+	registerBlockStyle,
+} from '@wordpress/blocks';
+import { select } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { removeBlockStylesFromAllBlocks } from '../general-block-support';
+
+jest.mock(
+	'@wordpress/dom-ready',
+	() => ( callback: () => void ) => callback()
+);
+
+const BLOCKS = [ 'core/separator', 'core/social-links', 'test/other' ];
+
+function styleNames( blockName: string ): string[] {
+	return select( 'core/blocks' )
+		.getBlockStyles( blockName )
+		.map( ( style: { name: string } ) => style.name );
+}
+
+describe( 'removeBlockStylesFromAllBlocks', () => {
+	beforeEach( () => {
+		BLOCKS.forEach( ( name ) => {
+			registerBlockType( name, {
+				apiVersion: 3,
+				title: name,
+				category: 'design',
+				edit: () => null,
+				save: () => null,
+			} );
+		} );
+		[ 'default', 'wide', 'dots', 'theme-fancy' ].forEach( ( style ) =>
+			registerBlockStyle( 'core/separator', {
+				name: style,
+				label: style,
+			} )
+		);
+		[ 'default', 'pill-shape', 'theme-fancy' ].forEach( ( style ) =>
+			registerBlockStyle( 'core/social-links', {
+				name: style,
+				label: style,
+			} )
+		);
+		registerBlockStyle( 'test/other', { name: 'outline', label: 'x' } );
+	} );
+
+	afterEach( () => {
+		BLOCKS.forEach( ( name ) => unregisterBlockType( name ) );
+	} );
+
+	it( 'keeps only the separator styles the email renderer supports', () => {
+		removeBlockStylesFromAllBlocks();
+
+		expect( styleNames( 'core/separator' ) ).toEqual( [
+			'default',
+			'wide',
+			'dots',
+		] );
+	} );
+
+	it( 'keeps every style of a block that supports them all', () => {
+		removeBlockStylesFromAllBlocks();
+
+		expect( styleNames( 'core/social-links' ) ).toEqual( [
+			'default',
+			'pill-shape',
+			'theme-fancy',
+		] );
+	} );
+
+	it( 'removes the styles of blocks without supported styles', () => {
+		removeBlockStylesFromAllBlocks();
+
+		expect( styleNames( 'test/other' ) ).toEqual( [] );
+	} );
+} );
diff --git a/packages/php/email-editor/changelog/add-wooplug-3863-separator-block b/packages/php/email-editor/changelog/add-wooplug-3863-separator-block
new file mode 100644
index 00000000000..578432689a4
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-wooplug-3863-separator-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the Separator block to the email editor, with its Default, Wide Line and Dots styles and color.
diff --git a/packages/php/email-editor/changelog/fix-email-fluid-font-sizes b/packages/php/email-editor/changelog/fix-email-fluid-font-sizes
new file mode 100644
index 00000000000..067fa724deb
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-email-fluid-font-sizes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep fixed font sizes in emails when the site theme uses fluid typography, which many email clients can’t read.
diff --git a/packages/php/email-editor/changelog/fix-email-text-inherited-font-size b/packages/php/email-editor/changelog/fix-email-text-inherited-font-size
new file mode 100644
index 00000000000..710ae0a1ccb
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-email-text-inherited-font-size
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use a parent Group’s font size for paragraphs in the sent email, as the editor does.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
index 0c240a8747e..1283b54fe8e 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
@@ -154,6 +154,19 @@ class Content_Renderer {
 		$this->fallback_renderer   = new Fallback();
 	}

+	/**
+	 * Typography support callbacks that turn font sizes into fluid clamp() values: core's, and the
+	 * Gutenberg plugin's, which replaces core's when the plugin is active.
+	 */
+	private const TYPOGRAPHY_SUPPORT_CALLBACKS = array( 'wp_render_typography_support', 'gutenberg_render_typography_support' );
+
+	/**
+	 * Priorities of the typography support callbacks removed for email rendering, by callback name.
+	 *
+	 * @var array<string, int>
+	 */
+	private array $removed_typography_support = array();
+
 	/**
 	 * Initialize the content renderer
 	 *
@@ -162,6 +175,16 @@ class Content_Renderer {
 	private function initialize() {
 		add_filter( 'render_block', array( $this, 'render_block' ), 10, 2 );
 		add_filter( 'block_parser_class', array( $this, 'block_parser' ) );
+
+		// Typography support turns font sizes into fluid clamp() values, which many email clients
+		// can’t read. Keep the static sizes while the email renders.
+		foreach ( self::TYPOGRAPHY_SUPPORT_CALLBACKS as $callback ) {
+			$priority = has_filter( 'render_block', $callback );
+			if ( false !== $priority ) {
+				remove_filter( 'render_block', $callback, $priority );
+				$this->removed_typography_support[ $callback ] = $priority;
+			}
+		}
 		add_filter( 'woocommerce_email_blocks_renderer_parsed_blocks', array( $this, 'preprocess_parsed_blocks' ) );

 		// Swap core/post-content render callback for email rendering.
@@ -554,6 +577,12 @@ class Content_Renderer {
 	private function reset(): void {
 		remove_filter( 'render_block', array( $this, 'render_block' ) );
 		remove_filter( 'block_parser_class', array( $this, 'block_parser' ) );
+		foreach ( $this->removed_typography_support as $callback => $priority ) {
+			if ( is_callable( $callback ) ) {
+				add_filter( 'render_block', $callback, $priority, 2 );
+			}
+		}
+		$this->removed_typography_support = array();
 		remove_filter( 'woocommerce_email_blocks_renderer_parsed_blocks', array( $this, 'preprocess_parsed_blocks' ) );

 		$this->post_content_width = null;
diff --git a/packages/php/email-editor/src/Engine/Renderer/class-html2text.php b/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
index d844fb36d07..d4af046e0ac 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
@@ -22,6 +22,11 @@ namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer;
  * - Handles various HTML elements appropriately for text conversion
  */
 class Html2Text {
+	/**
+	 * Attribute for elements drawn as a horizontal rule without an <hr> (email clients style <hr> poorly).
+	 * The plain text shows them like an <hr>. An attribute, not a class, so a block’s custom class can’t match it.
+	 */
+	public const HR_ATTRIBUTE = 'data-email-separator';

 	/**
 	 * Default options for HTML to text conversion
@@ -382,7 +387,10 @@ class Html2Text {
 		}

 		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
-		$name      = strtolower( $node->nodeName );
+		$name = strtolower( $node->nodeName );
+		if ( $node instanceof \DOMElement && $node->hasAttribute( self::HR_ATTRIBUTE ) ) {
+			$name = 'hr';
+		}
 		$next_name = self::next_child_name( $node );

 		// Start whitespace.
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-separator.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-separator.php
new file mode 100644
index 00000000000..b99633e21c3
--- /dev/null
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-separator.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Html2Text;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
+
+/**
+ * Renders a separator block as a table, since email clients style <hr> inconsistently.
+ */
+class Separator extends Abstract_Block_Renderer {
+	/**
+	 * Widest the dots row may get, in px, so it fits a 320px phone screen after the email’s padding.
+	 * The editor CSS in theme.json uses the same limit.
+	 */
+	private const MAX_DOTS_WIDTH = 240;
+
+	/**
+	 * Renders the block content.
+	 *
+	 * @param string            $block_content Block content.
+	 * @param array             $parsed_block Parsed block.
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 * @return string
+	 */
+	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
+		$attributes = $parsed_block['attrs'] ?? array();
+		$classes    = explode( ' ', (string) ( $attributes['className'] ?? '' ) );
+		$color      = $this->get_color( $attributes, $parsed_block['email_attrs'] ?? array(), $rendering_context );
+
+		if ( in_array( 'is-style-dots', $classes, true ) ) {
+			// Match the editor: dots are 1.5× the inherited text size, spaced 2em apart.
+			$font_size = Styles_Helper::parse_value( Styles_Helper::get_static_font_size( (string) ( $parsed_block['email_attrs']['font-size'] ?? '' ) ) ?? '16px' );
+			$dots_size = (int) round( 1.5 * $font_size );
+			// The cell grows to fit its content, so cap the spacing to fit the column and a phone screen.
+			// The row is 3 dots (counted as 1em each, a safe overestimate) plus 4 spacings.
+			$max_width = min( self::MAX_DOTS_WIDTH, (int) Styles_Helper::parse_value( $parsed_block['email_attrs']['width'] ?? self::MAX_DOTS_WIDTH ) );
+			$spacing   = max( 0, min( 2 * $dots_size, (int) floor( ( $max_width - 3 * $dots_size ) / 4 ) ) );
+			return Table_Wrapper_Helper::render_table_wrapper(
+				'&#183;&#183;&#183;',
+				array(
+					Html2Text::HR_ATTRIBUTE => '1',
+					'align'                 => 'center',
+					'width'                 => '100%',
+				),
+				array(
+					'align' => 'center',
+					'style' => "color:{$color};font-family:serif;font-size:{$dots_size}px;line-height:1;mso-line-height-rule:exactly;letter-spacing:{$spacing}px;padding-left:{$spacing}px;",
+				)
+			);
+		}
+
+		$is_wide = in_array( 'is-style-wide', $classes, true );
+		return Table_Wrapper_Helper::render_table_wrapper(
+			'&nbsp;',
+			array(
+				Html2Text::HR_ATTRIBUTE => '1',
+				'align'                 => 'center',
+				'width'                 => $is_wide ? '100%' : '100',
+				// min-width stops the Gmail app from shrinking the table on mobile, as in the Text block.
+				'style'                 => 'width:' . ( $is_wide ? '100%;min-width:100%' : '100px' ) . ';border-collapse:collapse;',
+			),
+			array(
+				'style' => "border-top:2px solid {$color};font-size:0;line-height:0;height:0;mso-line-height-rule:exactly;",
+			)
+		);
+	}
+
+	/**
+	 * Returns the separator color: the color picked in the editor, or the inherited text color.
+	 *
+	 * The editor stores the separator color as the background color.
+	 *
+	 * @param array             $attributes Block attributes.
+	 * @param array             $email_attrs Email attributes.
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 */
+	private function get_color( array $attributes, array $email_attrs, Rendering_Context $rendering_context ): string {
+		$styles = Styles_Helper::get_block_styles( $attributes, $rendering_context, array( 'background-color' ) );
+		$color  = (string) ( $styles['declarations']['background-color'] ?? $email_attrs['color'] ?? '#000000' );
+
+		// Outlook can’t read alpha, so output plain 6-digit hex, blending a see-through color onto the
+		// email background. That’s the email background, not a parent Group’s: preprocessors don’t
+		// pass that down.
+		$rgba = $this->parse_color( $color );
+		if ( null === $rgba ) {
+			return Html_Processing_Helper::sanitize_color( $color );
+		}
+		$theme_styles = $rendering_context->get_theme_styles();
+		$background   = (string) ( $theme_styles['color']['background'] ?? '' );
+		// A palette background is stored as a preset reference; look its slug up in the palette.
+		if ( preg_match( '/^(?:var:preset\|color\||var\(--wp--preset--color--)([a-z0-9-]+)\)?$/i', $background, $matches ) ) {
+			$background = $rendering_context->translate_slug_to_color( $matches[1] );
+		}
+		$background = $this->parse_color( $background ) ?? array( 255, 255, 255, 1.0 );
+		$hex        = '#';
+		for ( $i = 0; $i < 3; $i++ ) {
+			$hex .= sprintf( '%02x', (int) round( $rgba[ $i ] * $rgba[3] + $background[ $i ] * ( 1 - $rgba[3] ) ) );
+		}
+		return $hex;
+	}
+
+	/**
+	 * Reads a hex or rgb()/rgba() color as [ red, green, blue, alpha ], or null for any other format.
+	 *
+	 * @param string $color Color.
+	 * @return array{0: int, 1: int, 2: int, 3: float}|null
+	 */
+	private function parse_color( string $color ): ?array {
+		$color = trim( $color );
+		if ( preg_match( '/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $color, $matches ) ) {
+			$hex = $matches[1];
+			if ( strlen( $hex ) <= 4 ) {
+				$hex = preg_replace( '/(.)/', '$1$1', $hex ) ?? $hex;
+			}
+			return array(
+				(int) hexdec( substr( $hex, 0, 2 ) ),
+				(int) hexdec( substr( $hex, 2, 2 ) ),
+				(int) hexdec( substr( $hex, 4, 2 ) ),
+				8 === strlen( $hex ) ? hexdec( substr( $hex, 6, 2 ) ) / 255 : 1.0,
+			);
+		}
+		if ( preg_match( '/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d*\.?\d+)\s*)?\)$/i', $color, $matches ) ) {
+			return array(
+				min( 255, (int) $matches[1] ),
+				min( 255, (int) $matches[2] ),
+				min( 255, (int) $matches[3] ),
+				isset( $matches[4] ) ? min( 1.0, (float) $matches[4] ) : 1.0,
+			);
+		}
+		return null;
+	}
+}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php
index eef2528fc59..51183679e1a 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php
@@ -73,6 +73,14 @@ class Text extends Abstract_Block_Renderer {
 			$additional_styles['color'] = $parsed_block['email_attrs']['color'] ?? $email_styles['color']['text'] ?? '#000000'; // Fallback for the text color.
 		}

+		// Use the font size inherited from a parent block (e.g. a Group) when the block sets none.
+		if ( empty( $block_styles['declarations']['font-size'] ) && is_string( $parsed_block['email_attrs']['font-size'] ?? null ) ) {
+			$inherited_font_size = Styles_Helper::get_static_font_size( $parsed_block['email_attrs']['font-size'] );
+			if ( null !== $inherited_font_size ) {
+				$additional_styles['font-size'] = $inherited_font_size;
+			}
+		}
+
 		$additional_styles['text-align'] = $rendering_context->get_default_text_align();
 		if ( ! empty( $parsed_block['attrs']['textAlign'] ) ) { // in this case, textAlign needs to be one of 'left', 'center', 'right'.
 			$additional_styles['text-align'] = $rendering_context->resolve_text_align( $parsed_block['attrs']['textAlign'] );
diff --git a/packages/php/email-editor/src/Integrations/Core/class-initializer.php b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
index 46c79af04e8..c3e5467255b 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -28,6 +28,7 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Media_T
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Post_Content;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Post_Template;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Quote;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Separator;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Video;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Social_Link;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Social_Links;
@@ -54,6 +55,7 @@ class Initializer {
 		'core/list-item',
 		'core/paragraph',
 		'core/quote',
+		'core/separator',
 		'core/spacer',
 		'core/social-link',
 		'core/social-links',
@@ -238,6 +240,9 @@ class Initializer {
 			case 'core/quote':
 				$renderer = new Quote();
 				break;
+			case 'core/separator':
+				$renderer = new Separator();
+				break;
 			case 'core/social-link':
 				$renderer = new Social_Link();
 				break;
diff --git a/packages/php/email-editor/src/Integrations/Core/theme.json b/packages/php/email-editor/src/Integrations/Core/theme.json
index d9f659c1159..cebf1c29839 100644
--- a/packages/php/email-editor/src/Integrations/Core/theme.json
+++ b/packages/php/email-editor/src/Integrations/Core/theme.json
@@ -39,6 +39,9 @@
 				"css": "&.has-text-align-right { border-width: 0 1px 0 0; } &.has-text-align-center { border-width: 0; border-inline: 0; padding-inline: 0; }",
 				"variations": {}
 			},
+			"core/separator": {
+				"css": "color: inherit; background-clip: padding-box; &:not(.is-style-wide):not(.is-style-dots) { width: 100px; margin-inline: auto !important; } &.is-style-dots::before { letter-spacing: min(2em, calc((240px - 3em) / 4)); padding-left: min(2em, calc((240px - 3em) / 4)); }"
+			},
 			"core/table": {
 				"spacing": {
 					"margin": {
diff --git a/packages/php/email-editor/src/Integrations/Utils/class-styles-helper.php b/packages/php/email-editor/src/Integrations/Utils/class-styles-helper.php
index 6b98d9ad858..50e66d8cb9c 100644
--- a/packages/php/email-editor/src/Integrations/Utils/class-styles-helper.php
+++ b/packages/php/email-editor/src/Integrations/Utils/class-styles-helper.php
@@ -407,6 +407,17 @@ class Styles_Helper {
 		return $fallback;
 	}

+	/**
+	 * Converts a font size to a plain px length that email clients read, flattening clamp().
+	 *
+	 * @param string $font_size Font size, such as 32px, 2rem or clamp(…).
+	 * @return string|null The px length, or null when the value isn't a single readable size.
+	 */
+	public static function get_static_font_size( string $font_size ): ?string {
+		$px = self::convert_to_px( (string) self::clamp_to_static_px( $font_size ), false );
+		return null !== $px && preg_match( '/^\d+(\.\d+)?px$/', $px ) ? $px : null;
+	}
+
 	/**
 	 * Remove the CSS unit from a string.
 	 *
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
index 160bb16c4ff..7c56fd5f47e 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
@@ -463,6 +463,66 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertSame( 1, $filter_calls );
 	}

+	/**
+	 * The Gutenberg plugin replaces core's typography callback with its own; that one is removed during rendering too.
+	 */
+	public function testItRemovesGutenbergTypographySupportWhileRendering(): void {
+		require_once __DIR__ . '/gutenberg-typography-support-stub.php';
+		add_filter( 'render_block', 'gutenberg_render_typography_support', 10, 2 );
+		$template          = new \WP_Block_Template();
+		$template->id      = 'template-id';
+		$template->content = '<!-- wp:post-content /-->';
+
+		try {
+			$content = $this->renderer->render( $this->email_post, $template );
+			$this->assertStringContainsString( 'Hello!', $content );
+			$this->assertStringNotContainsString( 'gutenberg-typography-support', $content );
+			$this->assertSame( 10, has_filter( 'render_block', 'gutenberg_render_typography_support' ) );
+		} finally {
+			remove_filter( 'render_block', 'gutenberg_render_typography_support', 10 );
+		}
+	}
+
+	/**
+	 * Fluid typography would turn font sizes into clamp(), which many email clients can’t read.
+	 * The email keeps the static px size.
+	 */
+	public function testItKeepsStaticFontSizesWhenFluidTypographyIsOn(): void {
+		$enable_fluid = static function ( $theme_json ) {
+			return $theme_json->update_with(
+				array(
+					'version'  => 3,
+					'settings' => array( 'typography' => array( 'fluid' => true ) ),
+				)
+			);
+		};
+		add_filter( 'wp_theme_json_data_theme', $enable_fluid );
+		wp_clean_theme_json_cache();
+
+		$email_post_id = $this->factory->post->create(
+			array(
+				'post_content' => '<!-- wp:group {"style":{"typography":{"fontSize":"48px"}},"layout":{"type":"constrained"}} --><div class="wp-block-group" style="font-size:48px"><!-- wp:paragraph --><p>Big text</p><!-- /wp:paragraph --></div><!-- /wp:group -->',
+			)
+		);
+		$this->assertIsInt( $email_post_id );
+		$email_post = get_post( $email_post_id );
+		$this->assertInstanceOf( \WP_Post::class, $email_post );
+		$template          = new \WP_Block_Template();
+		$template->id      = 'template-id';
+		$template->content = '<!-- wp:post-content /-->';
+
+		try {
+			$content = $this->renderer->render( $email_post, $template );
+		} finally {
+			remove_filter( 'wp_theme_json_data_theme', $enable_fluid );
+			wp_clean_theme_json_cache();
+		}
+
+		$this->assertStringContainsString( 'Big text', $content );
+		$this->assertStringNotContainsString( 'clamp(', $content );
+		$this->assertNotFalse( has_filter( 'render_block', 'wp_render_typography_support' ) );
+	}
+
 	/**
 	 * Test it collects content styles without inlining them.
 	 */
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/gutenberg-typography-support-stub.php b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/gutenberg-typography-support-stub.php
new file mode 100644
index 00000000000..827c2ab1e7e
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/gutenberg-typography-support-stub.php
@@ -0,0 +1,21 @@
+<?php
+/**
+ * Stand-in for the Gutenberg plugin's typography support callback, which isn't installed in tests.
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+
+if ( ! function_exists( 'gutenberg_render_typography_support' ) ) {
+	/**
+	 * Marks every block it runs on with the block name, so a test can tell whether it ran.
+	 *
+	 * @param string $block_content Rendered block content.
+	 * @param array  $block Block object.
+	 * @return string
+	 */
+	function gutenberg_render_typography_support( $block_content, $block ) {
+		return $block_content . '<!-- gutenberg-typography-support ' . ( $block['blockName'] ?? '' ) . ' -->';
+	}
+}
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Paragraph_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Paragraph_Test.php
index 10d40a07d69..737ccd210fc 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Paragraph_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Paragraph_Test.php
@@ -254,6 +254,57 @@ class Paragraph_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'color:#ff0000;', $rendered );
 	}

+	/**
+	 * Inside a Group with its own font size, a paragraph without one uses the Group's size.
+	 *
+	 * @dataProvider inherited_font_size_provider
+	 * @param string $inherited Font size passed down in email_attrs.
+	 * @param string $expected Font size the paragraph renders with.
+	 */
+	public function testItUsesInheritedFontSizeFromEmailAttrs( string $inherited, string $expected ): void {
+		$parsed_paragraph = $this->parsed_paragraph;
+		unset( $parsed_paragraph['attrs']['style']['typography'] );
+		$parsed_paragraph['email_attrs'] = array( 'font-size' => $inherited );
+
+		$rendered = $this->paragraph_renderer->render( '<p>Lorem Ipsum</p>', $parsed_paragraph, $this->rendering_context );
+		$this->assertStringContainsString( 'font-size:' . $expected . ';', $rendered );
+	}
+
+	/**
+	 * Inherited font sizes and what they render as.
+	 */
+	public function inherited_font_size_provider(): array {
+		return array(
+			'px'    => array( '32px', '32px' ),
+			'clamp' => array( 'clamp(20px, 2vw, 40px)', '20px' ),
+		);
+	}
+
+	/**
+	 * An inherited font size that isn’t a plain px length adds nothing to the styles.
+	 */
+	public function testItIgnoresInheritedFontSizeWithExtraDeclarations(): void {
+		$parsed_paragraph = $this->parsed_paragraph;
+		unset( $parsed_paragraph['attrs']['style']['typography'] );
+		$parsed_paragraph['email_attrs'] = array( 'font-size' => '16px;display:none;--x:1px' );
+
+		$rendered = $this->paragraph_renderer->render( '<p>Lorem Ipsum</p>', $parsed_paragraph, $this->rendering_context );
+		$this->assertStringNotContainsString( 'display:none', $rendered );
+		$this->assertStringNotContainsString( 'font-size', $rendered );
+	}
+
+	/**
+	 * A paragraph's own font size wins over the inherited one.
+	 */
+	public function testItPrefersOwnFontSizeOverInheritedOne(): void {
+		$parsed_paragraph                = $this->parsed_paragraph;
+		$parsed_paragraph['email_attrs'] = array( 'font-size' => '32px' );
+
+		$rendered = $this->paragraph_renderer->render( '<p>Lorem Ipsum</p>', $parsed_paragraph, $this->rendering_context );
+		$this->assertStringContainsString( 'font-size:16px;', $rendered );
+		$this->assertStringNotContainsString( 'font-size:32px', $rendered );
+	}
+
 	/**
 	 * Test it extracts alignment from has-text-align-center class when no textAlign attribute is set
 	 */
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Separator_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Separator_Test.php
new file mode 100644
index 00000000000..643701af08e
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Separator_Test.php
@@ -0,0 +1,310 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Html2Text;
+use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
+
+/**
+ * Integration test for Separator class
+ */
+class Separator_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * Separator renderer instance
+	 *
+	 * @var Separator
+	 */
+	private $separator_renderer;
+
+	/**
+	 * Instance of Rendering_Context class
+	 *
+	 * @var Rendering_Context
+	 */
+	private $rendering_context;
+
+	/**
+	 * Set up before each test
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->di_container->get( Email_Editor::class )->initialize();
+		$this->separator_renderer = new Separator();
+		$theme_controller         = $this->di_container->get( Theme_Controller::class );
+		$this->rendering_context  = new Rendering_Context( $theme_controller->get_theme() );
+	}
+
+	/**
+	 * Renders a separator block with the given attributes and saved markup.
+	 *
+	 * @param array  $attrs Block attributes.
+	 * @param string $html Saved block markup.
+	 * @param array  $email_attrs Email attributes added by the preprocessors.
+	 */
+	private function render( array $attrs, string $html = '<hr class="wp-block-separator has-alpha-channel-opacity"/>', array $email_attrs = array() ): string {
+		$parsed_block = array(
+			'blockName'    => 'core/separator',
+			'attrs'        => $attrs,
+			'email_attrs'  => $email_attrs,
+			'innerBlocks'  => array(),
+			'innerHTML'    => $html,
+			'innerContent' => array( $html ),
+		);
+		$rendered     = $this->separator_renderer->render( $html, $parsed_block, $this->rendering_context );
+		$this->checkValidHTML( $rendered );
+		return $rendered;
+	}
+
+	/**
+	 * The default style is a short centered line in the inherited text color.
+	 */
+	public function testItRendersDefaultStyleAsShortCenteredLine(): void {
+		$rendered = $this->render( array(), '<hr class="wp-block-separator has-alpha-channel-opacity"/>', array( 'color' => '#123456' ) );
+
+		$this->assertStringContainsString( 'align="center"', $rendered );
+		$this->assertStringContainsString( 'width="100"', $rendered );
+		$this->assertStringContainsString( 'border-top:2px solid #123456', $rendered );
+		$this->assertStringNotContainsString( '<hr', $rendered );
+	}
+
+	/**
+	 * The plain-text email shows the line as a row of dashes, as it would for an <hr>.
+	 */
+	public function testItRendersLineAsDashesInPlainText(): void {
+		$text = Html2Text::convert( '<p>Above</p>' . $this->render( array() ) . '<p>Below</p>' );
+
+		$this->assertMatchesRegularExpression( '/Above\s+-{20,}\s+Below/', $text );
+	}
+
+	/**
+	 * The wide style spans the full content width.
+	 */
+	public function testItRendersWideStyleAtFullWidth(): void {
+		$rendered = $this->render(
+			array( 'className' => 'is-style-wide' ),
+			'<hr class="wp-block-separator has-alpha-channel-opacity is-style-wide"/>'
+		);
+
+		$this->assertStringContainsString( 'width="100%"', $rendered );
+		$this->assertStringNotContainsString( 'width="100"', $rendered );
+		$this->assertStringContainsString( 'border-top:2px solid', $rendered );
+	}
+
+	/**
+	 * A palette color picked in the editor colors the line.
+	 */
+	public function testItUsesPaletteColor(): void {
+		$rendered = $this->render(
+			array( 'backgroundColor' => 'vivid-red' ),
+			'<hr class="wp-block-separator has-text-color has-vivid-red-color has-alpha-channel-opacity has-vivid-red-background-color has-background"/>'
+		);
+
+		$this->assertStringContainsString( 'border-top:2px solid #cf2e2e', $rendered );
+	}
+
+	/**
+	 * A custom color picked in the editor colors the line.
+	 */
+	public function testItUsesCustomColor(): void {
+		$rendered = $this->render(
+			array( 'style' => array( 'color' => array( 'background' => '#00ff00' ) ) ),
+			'<hr class="wp-block-separator has-text-color has-alpha-channel-opacity has-background" style="background-color:#00ff00;color:#00ff00"/>'
+		);
+
+		$this->assertStringContainsString( 'border-top:2px solid #00ff00', $rendered );
+	}
+
+	/**
+	 * A see-through color is blended onto the email background, since Outlook can’t read hex with alpha.
+	 */
+	public function testItBlendsTransparentColorOntoEmailBackground(): void {
+		$rendered = $this->render(
+			array( 'style' => array( 'color' => array( 'background' => '#cf2e2e80' ) ) )
+		);
+		$this->assertStringContainsString( 'border-top:2px solid #e79696', $rendered );
+	}
+
+	/**
+	 * Short hex and rgba() colors with alpha are blended too, and every hex form ends up as 6 digits.
+	 *
+	 * @dataProvider alpha_color_provider
+	 * @param string $color Picked color.
+	 * @param string $expected Blended color.
+	 */
+	public function testItBlendsOtherAlphaColorFormats( string $color, string $expected ): void {
+		$rendered = $this->render( array( 'style' => array( 'color' => array( 'background' => $color ) ) ) );
+
+		$this->assertStringContainsString( 'border-top:2px solid ' . $expected . ';', $rendered );
+	}
+
+	/**
+	 * Alpha colors in other formats and their blend onto white.
+	 */
+	public function alpha_color_provider(): array {
+		return array(
+			'four-digit hex' => array( '#f008', '#ff7777' ),
+			'rgba()'         => array( 'rgba(255, 0, 0, 0.5)', '#ff8080' ),
+			'opaque #rgba'   => array( '#f00f', '#ff0000' ),
+			'opaque 8-digit' => array( '#ff0000ff', '#ff0000' ),
+		);
+	}
+
+	/**
+	 * A three-digit email background is used for the blend, not replaced by white.
+	 */
+	public function testItBlendsOntoShortHexBackground(): void {
+		$theme = clone $this->di_container->get( Theme_Controller::class )->get_theme();
+		$theme->merge(
+			new \WP_Theme_JSON(
+				array(
+					'version' => 3,
+					'styles'  => array( 'color' => array( 'background' => '#000' ) ),
+				),
+				'custom'
+			)
+		);
+		$this->rendering_context = new Rendering_Context( $theme );
+
+		$rendered = $this->render( array( 'style' => array( 'color' => array( 'background' => '#ff000080' ) ) ) );
+
+		$this->assertStringContainsString( 'border-top:2px solid #800000', $rendered );
+	}
+
+	/**
+	 * A palette color as the email background is resolved before blending, not replaced by white.
+	 */
+	public function testItBlendsOntoPaletteBackground(): void {
+		$theme = clone $this->di_container->get( Theme_Controller::class )->get_theme();
+		$theme->merge(
+			new \WP_Theme_JSON(
+				array(
+					'version' => 3,
+					'styles'  => array( 'color' => array( 'background' => 'var:preset|color|black' ) ),
+				),
+				'custom'
+			)
+		);
+		$this->rendering_context = new Rendering_Context( $theme );
+
+		$rendered = $this->render( array( 'style' => array( 'color' => array( 'background' => '#ffffff80' ) ) ) );
+
+		$this->assertStringContainsString( 'border-top:2px solid #808080;', $rendered );
+	}
+
+	/**
+	 * The Wide line keeps its width in the Gmail app, which shrinks tables without a minimum width.
+	 */
+	public function testItKeepsWideLineFromShrinkingInGmail(): void {
+		$rendered = $this->render( array( 'className' => 'is-style-wide' ) );
+
+		$this->assertStringContainsString( 'min-width:100%', $rendered );
+	}
+
+	/**
+	 * The plain-text email shows the dots as a row of dashes too.
+	 */
+	public function testItRendersDotsAsDashesInPlainText(): void {
+		$text = Html2Text::convert( '<p>Above</p>' . $this->render( array( 'className' => 'is-style-dots' ) ) . '<p>Below</p>' );
+
+		$this->assertMatchesRegularExpression( '/Above\s+-{20,}\s+Below/', $text );
+	}
+
+	/**
+	 * A style is picked by its whole class name, not a class that starts with it.
+	 */
+	public function testItIgnoresClassesThatOnlyStartWithStyleName(): void {
+		$rendered = $this->render( array( 'className' => 'is-style-wide-dashed' ) );
+
+		$this->assertStringContainsString( 'width="100"', $rendered );
+	}
+
+	/**
+	 * The dots style renders three centered dots in the picked color, without a line.
+	 */
+	public function testItRendersDotsStyle(): void {
+		$rendered = $this->render(
+			array(
+				'className'       => 'is-style-dots',
+				'backgroundColor' => 'vivid-red',
+			),
+			'<hr class="wp-block-separator has-text-color has-vivid-red-color has-alpha-channel-opacity has-vivid-red-background-color has-background is-style-dots"/>',
+			array( 'width' => '600px' )
+		);
+
+		$this->assertStringContainsString( '&#183;&#183;&#183;', $rendered );
+		$this->assertStringContainsString( 'color:#cf2e2e', $rendered );
+		$this->assertStringContainsString( 'font-size:24px', $rendered );
+		// 2em would be 48px; the row is capped at 240px so it fits a phone screen.
+		$this->assertStringContainsString( 'letter-spacing:42px', $rendered );
+		$this->assertStringContainsString( 'align="center"', $rendered );
+		$this->assertStringNotContainsString( 'border-top', $rendered );
+	}
+
+	/**
+	 * Inside a block with its own font size (a Group), the dots follow that size.
+	 */
+	public function testItSizesDotsFromInheritedFontSize(): void {
+		$rendered = $this->render(
+			array( 'className' => 'is-style-dots' ),
+			'<hr class="wp-block-separator has-alpha-channel-opacity is-style-dots"/>',
+			array(
+				'font-size' => '32px',
+				'width'     => '600px',
+			)
+		);
+
+		$this->assertStringContainsString( 'font-size:48px', $rendered );
+		// 2em would be 96px; the row is capped at 240px so it fits a phone screen.
+		$this->assertStringContainsString( 'letter-spacing:24px', $rendered );
+	}
+
+	/**
+	 * A fluid (clamp) inherited font size is turned into its smallest px value, as for paragraphs.
+	 */
+	public function testItSizesDotsFromFluidInheritedFontSize(): void {
+		$rendered = $this->render(
+			array( 'className' => 'is-style-dots' ),
+			'<hr class="wp-block-separator has-alpha-channel-opacity is-style-dots"/>',
+			array( 'font-size' => 'clamp(20px, 2vw, 40px)' )
+		);
+
+		$this->assertStringContainsString( 'font-size:30px', $rendered );
+	}
+
+	/**
+	 * In a narrow column the dot spacing shrinks to fit, so the column doesn’t grow.
+	 */
+	public function testItFitsDotsIntoNarrowColumn(): void {
+		$rendered = $this->render(
+			array( 'className' => 'is-style-dots' ),
+			'<hr class="wp-block-separator has-alpha-channel-opacity is-style-dots"/>',
+			array(
+				'font-size' => '32px',
+				'width'     => '200px',
+			)
+		);
+
+		$this->assertStringContainsString( 'letter-spacing:14px', $rendered );
+		$this->assertStringContainsString( 'padding-left:14px', $rendered );
+	}
+
+	/**
+	 * A color that isn’t a valid CSS color never reaches the style attribute.
+	 */
+	public function testItDropsInvalidColor(): void {
+		$rendered = $this->render(
+			array( 'style' => array( 'color' => array( 'background' => 'red;background:url(x)' ) ) )
+		);
+
+		$this->assertStringNotContainsString( 'url(', $rendered );
+		$this->assertStringContainsString( 'border-top:2px solid #000000', $rendered );
+	}
+}
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
index 3aa97cd556c..d4193fc1d53 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
@@ -254,6 +254,25 @@ class Html2Text_Test extends \Email_Editor_Unit_Test {
 		$this->assertIsString( $result );
 	}

+	/**
+	 * An element with the separator attribute becomes a row of dashes.
+	 */
+	public function test_separator_attribute_renders_dashes(): void {
+		$result = Html2Text::convert( '<p>Above</p><table data-email-separator="1"><tr><td>&nbsp;</td></tr></table><p>Below</p>' );
+
+		$this->assertMatchesRegularExpression( '/Above\s+-{20,}\s+Below/', $result );
+	}
+
+	/**
+	 * A separator-like class name is not the marker, so the block keeps its text.
+	 */
+	public function test_separator_class_name_keeps_content(): void {
+		$result = Html2Text::convert( '<div class="email-block-separator"><p>Hello</p></div>' );
+
+		$this->assertStringContainsString( 'Hello', $result );
+		$this->assertStringNotContainsString( '-----', $result );
+	}
+
 	/**
 	 * Test default options
 	 */
diff --git a/packages/php/email-editor/tests/unit/Integrations/Utils/Styles_Helper_Test.php b/packages/php/email-editor/tests/unit/Integrations/Utils/Styles_Helper_Test.php
index 93dd853b353..74fb817033b 100644
--- a/packages/php/email-editor/tests/unit/Integrations/Utils/Styles_Helper_Test.php
+++ b/packages/php/email-editor/tests/unit/Integrations/Utils/Styles_Helper_Test.php
@@ -913,4 +913,28 @@ class Styles_Helper_Test extends \Email_Editor_Unit_Test {
 		$this->assertSame( '100', Styles_Helper::remove_css_unit( '100%' ) );
 		$this->assertSame( '100', Styles_Helper::remove_css_unit( '100vh' ) );
 	}
+
+	/**
+	 * A font size becomes a plain px length an email can use, or null when it can't.
+	 *
+	 * @dataProvider static_font_size_provider
+	 * @param string      $input Font size.
+	 * @param string|null $expected Static px length, or null.
+	 */
+	public function testItConvertsFontSizeToStaticPx( string $input, ?string $expected ): void {
+		$this->assertSame( $expected, Styles_Helper::get_static_font_size( $input ) );
+	}
+
+	/**
+	 * Font sizes and their static px value.
+	 */
+	public function static_font_size_provider(): array {
+		return array(
+			'px'                 => array( '32px', '32px' ),
+			'rem'                => array( '2rem', '32px' ),
+			'clamp'              => array( 'clamp(20px, 2vw, 40px)', '20px' ),
+			'extra declarations' => array( '16px;display:none;--x:1px', null ),
+			'unknown'            => array( 'large', null ),
+		);
+	}
 }