Commit 84de7237622 for woocommerce

commit 84de7237622e723fa4a03828c6556344d1960a8f
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date:   Tue Oct 13 09:29:23 2026 -0400

    [Email Editor] Fix blocks losing color when a palette slug is missing (#69617)

    * fix: don't render unresolvable color slugs as CSS colors

    `translate_slug_to_color()` returned its input when the email's palette had
    no match, so a block naming a slug the palette lacks rendered
    `background-color: theme-4`. Mail clients drop the invalid declaration and
    the block loses its color -- a button can keep its text color and lose its
    background, making it disappear against a same-colored page.

    Return an empty string for an unmatched slug instead, so callers skip the
    declaration and the block falls back to the surrounding theme color,
    matching what the editor previews.

    The old passthrough did double duty: it also let a caller hand the method a
    value that is already a literal color and get it back untranslated, which
    `Quote_Test::testItContainsQuoteStyles` covers. `Styles_Helper::is_color_literal()`
    keeps that path working. Only unambiguous literal forms count (`#`, `rgb()`,
    `hsl()`, `var()`); a bare identifier such as `red` cannot be told apart from a
    slug, so it is treated as one.

    Two callers needed guarding alongside it. `Table::get_custom_border_color()`
    passed the result to `sanitize_color()`, which turns an empty string into
    `#000000`; it now returns null. `Typography_Preprocessor` copied the value
    down to child blocks and would have passed on an empty `color`.

    `sanitize_color()` is also why the slug reached the markup from Table and
    Cover in the first place: `theme-4` matches its permissive named-color
    branch, so sanitizing never rejected it.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: resolve color slugs against every palette origin

    Addresses the a8c-review findings on the previous commit.

    `translate_slug_to_color()` only ever searched the `theme` and `default`
    palette origins, but `User_Theme` builds its theme with origin `custom`
    (`class-user-theme.php:43`) and `Theme_Controller::get_theme()` merges it in.
    A color the user defined in the email's own global styles therefore landed at
    `settings.color.palette.custom` and never resolved -- it leaked as an invalid
    CSS value before the previous commit and is dropped after it, so the block
    loses a color that is right there in the settings array. Verified against a
    live `WP_Theme_JSON`: a `custom`-origin palette survives into
    `get_settings()` under its own key. `blocks` had the same gap.

    All four origins are now searched in the order `WP_Theme_JSON` layers them,
    so a user's own color beats the theme's, which beats core's.

    The lookup was duplicated byte-for-byte between `Theme_Controller` and
    `Rendering_Context`, and the origin merge a third time for preset class
    generation, so this change had to land in three places. Extracted to
    `Styles_Helper::resolve_color_from_palette()` and
    `Styles_Helper::palette_definitions()`; both public methods keep their
    signatures.

    `is_color_literal()` was too narrow: it rejected `oklch()`, `lab()`,
    `color-mix()` and the other CSS Color 4/5 forms, plus `transparent` and
    `currentColor`, all of which passed through untranslated before. A slug
    cannot contain a bracket, so any functional notation is now treated as a
    literal, alongside an explicit keyword list.

    Also: cover the `Typography_Preprocessor` and `Table` guards with tests (the
    Table one verified to fail with the guard removed), and reword the changelog,
    which described the opposite of what the fix does -- the block does not keep
    its color, it falls back to the theme's.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: keep CSS named colors that no palette defines

    From wpcom testing of this branch against real sent newsletters. A few
    emails name color slugs that happen to be CSS color names -- `gray`, `blue`,
    `green`, `orange`, on paragraph text -- with no palette entry behind them.
    Those rendered as `color: blue`, which is valid CSS, so the text kept its
    intended color by accident. Dropping the declaration changed how they look.

    The previous commit justified treating a bare identifier as a slug by saying
    a slug and a named color cannot be told apart. That reasoning was wrong: only
    callers that have already searched every palette origin reach the literal
    check, and a palette entry wins there. So a theme that defines a swatch named
    `blue` still gets its own color, and a `blue` nothing defines can safely
    render as the CSS color. `is_color_literal()` now accepts the 148 CSS named
    colors alongside the keywords it already had. `theme-4`, `primary`,
    `foreground` and `dark-gray` are not colors, so they still yield nothing.

    Also trim the button cell's class attribute. It joins the style engine's
    class names to the block's own, and with no background color to resolve the
    style engine contributes none, leaving `class=" wp-block-button"`. Only
    reproducible on a button with no text color set, since a text color makes the
    style engine emit `has-text-color` and the gap cannot occur -- the first
    version of this test passed with the fix reverted for exactly that reason.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: cover compound CSS system colors, and pin behavior to observed slugs

    Closes the remaining open end in the literal check, and replaces reasoning
    about CSS syntax coverage with a table of values real emails actually use.

    The 15 compound CSS system colors (`buttontext`, `canvastext`, `linktext`,
    and so on) now count as colors. The four single-word ones -- `canvas`,
    `field`, `mark`, `highlight` -- are deliberately left out: a system color can
    only reach a block attribute through hand-edited markup since the editor
    cannot produce one, while all four are plausible palette slug names. Treating
    them as colors would render an unresolved slug in an OS color instead of the
    theme's, which trades the bug this branch fixes for a new one.

    `observedColorSlugProvider()` records the slug values seen on real emails and
    what each must resolve to: palette slugs left behind by a theme switch
    (`theme-4`, `primary`, `foreground`, `dark-gray`, `light-gray`) resolve to
    nothing, while slugs that are CSS color names (`gray`, `blue`, `green`,
    `orange`) and literal values pass through. The CSS-named-color regression was
    caught by diffing real sends rather than by reviewing the matcher, so the
    table is there to keep asking whether behavior still matches real data
    instead of whether the matcher covers every syntax. New values found in the
    wild should be appended to it.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: return one palette definition per slug

    From CodeRabbit on #69617. A slug several origins define was read two ways:
    `resolve_color_from_palette()` takes the first entry, while
    `get_stylesheet_for_rendering()` emits a rule per entry and the cascade takes
    the last. With `palette_definitions()` ordered highest priority first, the
    lookup returned the `custom` color while the preset class gave the `theme`
    one, so a block's color depended on which route it arrived through.

    The inversion predates this branch -- trunk merges `theme` then `default`, so
    `default` already won the cascade while the lookup returned `theme`. Adding
    `custom` to the merge extended it rather than introducing it.

    Fixed by returning a single definition per slug instead of reordering one
    caller: both agree by construction, a third caller cannot reintroduce the
    split, the pre-existing theme/default case is fixed too, and the stylesheet
    carries no redundant rules for the inliner to inline. wpcom's own
    `merge_palettes()` deduplicates for exactly this reason.

    Entries without a string slug are dropped while deduplicating. The palette
    arrives through the `woocommerce_email_editor_theme_json` filter and both
    callers read `slug` and `color` directly, so a malformed entry was a PHP
    warning on every render.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: require a usable color before a palette entry claims its slug

    From CodeRabbit on #69617, and a bug in the deduplication added in the
    previous commit. Claiming a slug on the first entry that merely names it let
    an incomplete higher-priority entry discard a lower-priority origin that
    defines the same slug properly: `custom` holding `array( 'slug' => 'shared' )`
    shut out a `default` entry with a color, so a color the palette does define
    resolved to nothing.

    The second half was worse than the report says. The previous commit guarded
    `slug` precisely so a malformed entry could not reach the callers that read it
    directly, but left `color` unguarded, so such an entry still reached
    `get_stylesheet_for_rendering()` and emitted `color: ` with a PHP warning on
    every render -- the exact failure the guard was added to prevent.

    An entry now needs a non-empty string slug and a non-empty string color before
    it is kept, which also makes that pair an invariant of the return value rather
    than something each caller re-checks.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: harden the palette lookup against malformed values

    Self-review of the area the last two review rounds landed in. Three issues,
    each confirmed by probing the code rather than reading it.

    A non-array palette origin was a fatal, not a warning. `array_merge()` throws
    a TypeError on one, so a filter returning `palette.theme` as a string took the
    whole render down. Origins are now read one at a time behind an `is_array()`
    check. wpcom hit this exact TypeError downstream, where it put every email
    from the affected site on the legacy renderer.

    `is_color_literal()` accepted incomplete values and passed them straight
    through: `#`, `#zz`, `#abcd1`, `rgb(` and `foo(` all resolved to themselves
    and would be emitted as colors. `background-color: #zz` is the same invalid
    declaration as `background-color: theme-4`, so the check was defeating its own
    purpose on malformed input. Hex now has to be 3, 4, 6 or 8 digits, and
    functional notation has to close its bracket -- which still needs no list of
    CSS color function names.

    A literal is now returned trimmed. It is deliberately not lowercased the way
    a palette color is, because the custom property name inside `var()` is
    case-sensitive.

    Two further observations, neither changed here. A flat, non-origin-keyed
    palette resolves nothing; `WP_Theme_JSON::get_settings()` never produces that
    shape and both in-tree callers pass its output, so supporting it would invent
    a priority order for a shape core does not emit. And
    `Theme_Controller::get_settings()` rebuilds the entire theme JSON on every
    call, re-applying the `woocommerce_email_editor_theme_json` filter each time;
    that predates this branch, and the deduplication loop is immaterial beside it.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: treat a bare word as a palette slug, never as a CSS color

    A measured sample of 8,534 sends settles the open review question. 13 emails
    (0.15%) name a slug the email palette lacks that happens to spell a CSS color.
    None come from a theme switch: every one is the site's *current* classic
    theme's PHP palette, which the email palette does not carry. The CSS color
    only approximates the theme's -- apostrophe-2 blue `#159ae7` against
    `#0000ff`, dyad-2 yellow `#e7ae01` against `#ffff00`, which is close to
    unreadable on white -- so passing it through paints a color nobody chose.
    Dropped, the text falls back to `#1e1e1e` and matches the editor preview; no
    affected email loses legibility.

    So a bare word is a slug, and only a value a slug could never be passes
    through: a complete hex value, or functional notation of any name. That
    removes the keyword list entirely, along with the question of which CSS
    colors and system colors belong in it, and makes the rule one sentence.

    The real gap is on the wpcom side, where classic themes' `editor-color-palette`
    never reaches the email palette. Feeding it in would give these slugs the
    theme's exact color, and would equally serve the `primary`/`accent` kind that
    this PR drops. That is separate from this package.

    Also tightens the comments this branch added, per review: a comment says what
    the code does, not what it did before or why it changed. The duplicated
    contract on the two `translate_slug_to_color()` delegates now points at
    `Styles_Helper::resolve_color_from_palette()` instead of restating it a third
    and fourth time.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/packages/php/email-editor/changelog/fix-unresolved-color-slugs b/packages/php/email-editor/changelog/fix-unresolved-color-slugs
new file mode 100644
index 00000000000..274d8253014
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-unresolved-color-slugs
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fall back to the email's theme color when a block's color can no longer be resolved, instead of rendering no color.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-typography-preprocessor.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-typography-preprocessor.php
index ad5001f6225..b1bb0cae558 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-typography-preprocessor.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-typography-preprocessor.php
@@ -94,7 +94,10 @@ class Typography_Preprocessor implements Preprocessor {
 			$email_attrs['color'] = $block['attrs']['style']['color']['text'];
 		}
 		if ( isset( $block['attrs']['textColor'] ) && is_string( $block['attrs']['textColor'] ) && ! isset( $email_attrs['color'] ) ) {
-			$email_attrs['color'] = $this->settings_controller->translate_slug_to_color( $block['attrs']['textColor'] );
+			$text_color = $this->settings_controller->translate_slug_to_color( $block['attrs']['textColor'] );
+			if ( '' !== $text_color ) {
+				$email_attrs['color'] = $text_color;
+			}
 		}
 		// In case the fontSize is set via a slug (small, medium, large, etc.) we translate it to a number
 		// The font size slug is set in $block['attrs']['fontSize'] and value in $block['attrs']['style']['typography']['fontSize'].
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php
index c041fb154a1..fed2a28b643 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-rendering-context.php
@@ -111,24 +111,16 @@ class Rendering_Context {
 	}

 	/**
-	 * Translate color slug to color.
+	 * Translate a color slug to the color the palette gives it.
 	 *
-	 * @param string $color_slug Color slug.
-	 * @return string
+	 * A slug can be missing from the palette because the post was written under a different theme,
+	 * or came from a palette the email does not carry. {@see Styles_Helper::resolve_color_from_palette()}.
+	 *
+	 * @param string $color_slug Color slug, or a literal color.
+	 * @return string The color value, or an empty string for a slug the palette does not define.
 	 */
 	public function translate_slug_to_color( string $color_slug ): string {
-		$settings = $this->get_theme_settings();
-
-		$color_definitions = array_merge(
-			$settings['color']['palette']['theme'] ?? array(),
-			$settings['color']['palette']['default'] ?? array()
-		);
-		foreach ( $color_definitions as $color_definition ) {
-			if ( $color_definition['slug'] === $color_slug ) {
-				return strtolower( $color_definition['color'] );
-			}
-		}
-		return $color_slug;
+		return Styles_Helper::resolve_color_from_palette( $this->get_theme_settings(), $color_slug );
 	}

 	/**
diff --git a/packages/php/email-editor/src/Engine/class-settings-controller.php b/packages/php/email-editor/src/Engine/class-settings-controller.php
index d3ca314a104..daf3118880d 100644
--- a/packages/php/email-editor/src/Engine/class-settings-controller.php
+++ b/packages/php/email-editor/src/Engine/class-settings-controller.php
@@ -185,10 +185,12 @@ class Settings_Controller {
 	}

 	/**
-	 * Translate slug to color.
+	 * Translate a color slug to the color the palette gives it.
 	 *
-	 * @param string $color_slug Color slug.
-	 * @return string
+	 * {@see Theme_Controller::translate_slug_to_color()}.
+	 *
+	 * @param string $color_slug Color slug, or a literal color.
+	 * @return string The color value, or an empty string for a slug the palette does not define.
 	 */
 	public function translate_slug_to_color( string $color_slug ): string {
 		return $this->theme_controller->translate_slug_to_color( $color_slug );
diff --git a/packages/php/email-editor/src/Engine/class-theme-controller.php b/packages/php/email-editor/src/Engine/class-theme-controller.php
index 8e2fa632633..fad87b89fc4 100644
--- a/packages/php/email-editor/src/Engine/class-theme-controller.php
+++ b/packages/php/email-editor/src/Engine/class-theme-controller.php
@@ -9,6 +9,7 @@ declare(strict_types = 1);
 namespace Automattic\WooCommerce\EmailEditor\Engine;

 use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Preset_Variable_Resolver;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
 use WP_Block_Template;
 use WP_Post;
 use WP_Theme_JSON;
@@ -212,7 +213,7 @@ class Theme_Controller {
 			$css_presets .= ".has-{$font_size['slug']}-font-size { font-size: {$font_size['size']}; } \n";
 		}
 		// Color palette classes.
-		$color_definitions = array_merge( $email_theme_settings['color']['palette']['theme'] ?? array(), $email_theme_settings['color']['palette']['default'] ?? array() );
+		$color_definitions = Styles_Helper::palette_definitions( $email_theme_settings );
 		foreach ( $color_definitions as $color ) {
 			$css_presets .= ".has-{$color['slug']}-color { color: {$color['color']}; } \n";
 			$css_presets .= ".has-{$color['slug']}-background-color { background-color: {$color['color']}; } \n";
@@ -297,20 +298,16 @@ class Theme_Controller {
 	}

 	/**
-	 * Translate color slug to color.
+	 * Translate a color slug to the color the palette gives it.
 	 *
-	 * @param string $color_slug Color slug.
-	 * @return string
+	 * A slug can be missing from the palette because the post was written under a different theme,
+	 * or came from a palette the email does not carry. {@see Styles_Helper::resolve_color_from_palette()}.
+	 *
+	 * @param string $color_slug Color slug, or a literal color.
+	 * @return string The color value, or an empty string for a slug the palette does not define.
 	 */
 	public function translate_slug_to_color( string $color_slug ): string {
-		$settings          = $this->get_settings();
-		$color_definitions = array_merge( $settings['color']['palette']['theme'] ?? array(), $settings['color']['palette']['default'] ?? array() );
-		foreach ( $color_definitions as $color_definition ) {
-			if ( $color_definition['slug'] === $color_slug ) {
-				return strtolower( $color_definition['color'] );
-			}
-		}
-		return $color_slug;
+		return Styles_Helper::resolve_color_from_palette( $this->get_settings(), $color_slug );
 	}

 	/**
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php
index fa30c4845d3..bc650c54833 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php
@@ -111,7 +111,7 @@ class Button extends Abstract_Block_Renderer {
 		);

 		$cell_attrs = array(
-			'class'  => $wrapper_styles['classnames'] . ' ' . $block_classname,
+			'class'  => trim( $wrapper_styles['classnames'] . ' ' . $block_classname ),
 			'style'  => $wrapper_styles['css'],
 			'align'  => $block_attributes['textAlign'],
 			'valign' => 'middle',
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php
index bf02373cf00..6f596fba0bf 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-table.php
@@ -258,6 +258,13 @@ class Table extends Abstract_Block_Renderer {

 		if ( ! empty( $block_attributes['borderColor'] ) ) {
 			$border_color = $rendering_context->translate_slug_to_color( $block_attributes['borderColor'] );
+
+			// sanitize_color() turns an empty string into black, so report no border color and let
+			// the table keep its own.
+			if ( '' === $border_color ) {
+				return null;
+			}
+
 			return Html_Processing_Helper::sanitize_color( $border_color );
 		}

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 40e7c5c6001..6b98d9ad858 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
@@ -66,6 +66,101 @@ class Styles_Helper {
 		return $parsed_styles;
 	}

+	/**
+	 * Whether a value is a literal color rather than a palette slug.
+	 *
+	 * Only a value a slug could never be counts: a complete hex value, or functional notation of any
+	 * name, since a slug contains neither `#` nor a bracket. A bare word is a slug, including one
+	 * that names a CSS color -- classic themes register those, and the email cannot see their color.
+	 *
+	 * @param string $value Value of a color block attribute.
+	 * @return bool
+	 */
+	public static function is_color_literal( string $value ): bool {
+		$value = strtolower( trim( $value ) );
+
+		// #rgb, #rgba, #rrggbb, #rrggbbaa. A bare `#` or a stray `#zz` is not a color.
+		if ( 1 === preg_match( '/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/', $value ) ) {
+			return true;
+		}
+
+		// Any function name, so CSS color functions need no list here. The bracket has to close:
+		// `rgb(` is not a color either.
+		return 1 === preg_match( '/^[a-z][a-z0-9-]*\(.*\)$/', $value );
+	}
+
+	/**
+	 * Every color the palette defines, one entry per slug, highest priority origin first.
+	 *
+	 * `WP_Theme_JSON` layers origins so a later one wins, so they are read in reverse of that order.
+	 * One entry per slug matters because a lookup reads the first and the stylesheet's cascade reads
+	 * the last, which would give a block a different color by route. An entry needs a usable slug
+	 * and color to be kept, and an incomplete one does not claim its slug.
+	 *
+	 * @param array $settings Theme settings array, as `WP_Theme_JSON::get_settings()` returns it.
+	 * @return array Flat list of color definitions, one per slug, each with a usable slug and color.
+	 */
+	public static function palette_definitions( array $settings ): array {
+		$palette = $settings['color']['palette'] ?? array();
+
+		// The palette arrives through a filter, and a non-array origin is a TypeError in
+		// array_merge() rather than a warning.
+		$definitions = array();
+		foreach ( array( 'custom', 'theme', 'blocks', 'default' ) as $origin ) {
+			if ( isset( $palette[ $origin ] ) && is_array( $palette[ $origin ] ) ) {
+				$definitions = array_merge( $definitions, $palette[ $origin ] );
+			}
+		}
+
+		$by_slug = array();
+		foreach ( $definitions as $definition ) {
+			if ( ! is_array( $definition ) ) {
+				continue;
+			}
+
+			$slug  = $definition['slug'] ?? null;
+			$color = $definition['color'] ?? null;
+
+			// An entry has to be usable to claim the slug, or it shuts out an origin that defines
+			// the same slug properly.
+			if ( ! is_string( $slug ) || '' === $slug || ! is_string( $color ) || '' === trim( $color ) ) {
+				continue;
+			}
+
+			// First origin wins, so a slug already seen is a lower-priority duplicate.
+			if ( ! array_key_exists( $slug, $by_slug ) ) {
+				$by_slug[ $slug ] = $definition;
+			}
+		}
+
+		return array_values( $by_slug );
+	}
+
+	/**
+	 * Resolve a color slug against the palette.
+	 *
+	 * Searches every origin, highest priority first: a slug the user defined in the email's own
+	 * global styles (`custom`) beats the theme's, which beats core's.
+	 *
+	 * A slug no origin defines returns an empty string, so callers omit the declaration and the
+	 * block keeps the surrounding color. A literal color passes through.
+	 *
+	 * @param array  $settings   Theme settings array, as `WP_Theme_JSON::get_settings()` returns it.
+	 * @param string $color_slug Color slug, or a literal color.
+	 * @return string The color value, or an empty string for a slug the palette does not define.
+	 */
+	public static function resolve_color_from_palette( array $settings, string $color_slug ): string {
+		foreach ( self::palette_definitions( $settings ) as $color_definition ) {
+			if ( $color_definition['slug'] === $color_slug ) {
+				return strtolower( (string) $color_definition['color'] );
+			}
+		}
+
+		// Not lowercased the way a palette color is: a custom property name inside `var()` is
+		// case-sensitive.
+		return self::is_color_literal( $color_slug ) ? trim( $color_slug ) : '';
+	}
+
 	/**
 	 * Get normalized block styles by translating color slugs to actual color values.
 	 *
diff --git a/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php b/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php
index 69b81f1c9e2..8c38c543796 100644
--- a/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Theme_Controller_Test.php
@@ -149,6 +149,75 @@ class Theme_Controller_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertEquals( '#9b51e0', $this->theme_controller->translate_slug_to_color( 'vivid-purple' ) );
 	}

+	/**
+	 * Test that a slug the palette does not define translates to an empty string.
+	 *
+	 * The slug as a value (`background-color: theme-4`) is invalid CSS that mail clients drop, which
+	 * costs the block its color.
+	 */
+	public function testItReturnsNoColorForUnknownSlug(): void {
+		add_filter( 'woocommerce_email_editor_site_style_sync_enabled', '__return_true' );
+		$this->assertSame( '', $this->theme_controller->translate_slug_to_color( 'theme-4' ) );
+	}
+
+	/**
+	 * Test the generated preset classes agree with the color lookup on a duplicated slug.
+	 *
+	 * The stylesheet emits one rule per definition and the cascade reads the last, while the lookup
+	 * reads the first, so one definition per slug is what keeps the two routes agreeing.
+	 */
+	public function testItGeneratesPresetClassesThatAgreeWithTheColorLookup(): void {
+		add_filter( 'woocommerce_email_editor_site_style_sync_enabled', '__return_false' );
+
+		// `black` is in core's default palette, so defining it again here duplicates the slug.
+		$duplicate = static function ( $theme ) {
+			$theme->merge(
+				new \WP_Theme_JSON(
+					array(
+						'version'  => 3,
+						'settings' => array(
+							'color' => array(
+								'palette' => array(
+									array(
+										'slug'  => 'black',
+										'color' => '#abcdef',
+									),
+								),
+							),
+						),
+					),
+					'custom'
+				)
+			);
+			return $theme;
+		};
+		add_filter( 'woocommerce_email_editor_theme_json', $duplicate, 20 );
+
+		$controller = $this->di_container->get( Theme_Controller::class );
+		$stylesheet = $controller->get_stylesheet_for_rendering();
+
+		$this->assertSame( '#abcdef', $controller->translate_slug_to_color( 'black' ) );
+		$this->assertStringContainsString( '.has-black-color { color: #abcdef; }', $stylesheet );
+		$this->assertStringNotContainsString( '.has-black-color { color: #000000; }', $stylesheet );
+
+		// One rule per slug, not one per origin that defines it.
+		$this->assertSame( 1, substr_count( $stylesheet, '.has-black-color {' ) );
+
+		remove_filter( 'woocommerce_email_editor_theme_json', $duplicate, 20 );
+	}
+
+	/**
+	 * Test that a literal color passes through untranslated.
+	 *
+	 * Color block attributes usually hold a slug, but a literal color can arrive in one, and callers
+	 * rely on getting it back unchanged.
+	 */
+	public function testItPassesLiteralColorsThrough(): void {
+		add_filter( 'woocommerce_email_editor_site_style_sync_enabled', '__return_true' );
+		$this->assertSame( '#012345', $this->theme_controller->translate_slug_to_color( '#012345' ) );
+		$this->assertSame( 'rgba(1, 2, 3, 0.5)', $this->theme_controller->translate_slug_to_color( 'rgba(1, 2, 3, 0.5)' ) );
+	}
+
 	/**
 	 * Test if the theme controller loads custom user theme
 	 */
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php
index d8ad614ca35..a92fe173a0d 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php
@@ -100,6 +100,38 @@ class Button_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'padding-bottom:40px;', $output );
 	}

+	/**
+	 * Test an unresolvable background color slug falls back to the theme's button color.
+	 */
+	public function testItFallsBackToThemeColorForUnresolvableBackgroundSlug(): void {
+		$this->parsed_button['attrs']['backgroundColor'] = 'theme-4';
+		unset( $this->parsed_button['attrs']['style']['color']['background'] );
+
+		$output = $this->button_renderer->render( $this->parsed_button['innerHTML'], $this->parsed_button, $this->rendering_context );
+
+		// The slug never reaches the markup as a color, and nothing claims a background is set.
+		$this->assertStringNotContainsString( 'theme-4', $output );
+		$this->assertStringNotContainsString( 'background-color:;', $output );
+	}
+
+	/**
+	 * Test the cell's class attribute has no stray leading space.
+	 *
+	 * The cell's class list is the style engine's class names joined to the block's own, and with no
+	 * background color to resolve the style engine contributes none.
+	 */
+	public function testItRendersCellClassWithoutLeadingSpace(): void {
+		$this->parsed_button['attrs']['backgroundColor'] = 'theme-4';
+
+		// No color styles at all, so the style engine contributes no class names. A text color makes
+		// it emit `has-text-color`, and then the leading space cannot occur.
+		unset( $this->parsed_button['attrs']['style']['color'] );
+
+		$output = $this->button_renderer->render( $this->parsed_button['innerHTML'], $this->parsed_button, $this->rendering_context );
+
+		$this->assertStringNotContainsString( 'class=" ', $output );
+	}
+
 	/**
 	 * Test it renders colors
 	 */
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php
index 84b8fedd5a8..986c5a14df6 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Table_Test.php
@@ -127,6 +127,40 @@ class Table_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'Cell 4', $rendered );
 	}

+	/**
+	 * Test a border color slug the palette defines is rendered.
+	 */
+	public function testItRendersResolvableBorderColorSlug(): void {
+		$parsed_table                             = $this->parsed_table;
+		$parsed_table['innerHTML']                = $this->simple_table_content;
+		$parsed_table['attrs']['borderColor']     = 'vivid-purple';
+		$parsed_table['attrs']['style']['border'] = array( 'width' => '2px' );
+
+		$rendered = $this->table_renderer->render( $this->simple_table_content, $parsed_table, $this->rendering_context );
+		$this->assertStringContainsString( 'solid #9b51e0', $rendered );
+	}
+
+	/**
+	 * Test a border color slug the palette cannot resolve falls back to the table's own color.
+	 *
+	 * Two things are pinned. The slug must not reach the markup: sanitize_color() accepts a bare
+	 * identifier through its permissive named-color branch. And the border must not be black:
+	 * sanitize_color() turns the empty string the slug resolves to into `#000000`, which is why
+	 * get_custom_border_color() reports no color rather than sanitizing.
+	 */
+	public function testItFallsBackToTableColorForUnresolvableBorderColorSlug(): void {
+		$parsed_table                             = $this->parsed_table;
+		$parsed_table['innerHTML']                = $this->simple_table_content;
+		$parsed_table['email_attrs']['color']     = '#abcdef';
+		$parsed_table['attrs']['borderColor']     = 'theme-4';
+		$parsed_table['attrs']['style']['border'] = array( 'width' => '2px' );
+
+		$rendered = $this->table_renderer->render( $this->simple_table_content, $parsed_table, $this->rendering_context );
+		$this->assertStringContainsString( 'solid #abcdef', $rendered );
+		$this->assertStringNotContainsString( 'theme-4', $rendered );
+		$this->assertStringNotContainsString( 'solid #000000', $rendered );
+	}
+
 	/**
 	 * Test it uses RTL default wrapper and cell alignment when alignment is absent.
 	 */
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Typography_Preprocessor_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Typography_Preprocessor_Test.php
index f94cb59e12d..37b36da33b3 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Typography_Preprocessor_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Typography_Preprocessor_Test.php
@@ -84,6 +84,8 @@ class Typography_Preprocessor_Test extends \Email_Editor_Unit_Test {
 		$settings_mock->method( 'translate_slug_to_color' )->willReturnMap(
 			array(
 				array( 'slug-red', '#ff0000' ),
+				// A slug the email's palette does not define resolves to nothing.
+				array( 'theme-4', '' ),
 			)
 		);
 		$this->preprocessor = new Typography_Preprocessor( $settings_mock );
@@ -236,6 +238,47 @@ class Typography_Preprocessor_Test extends \Email_Editor_Unit_Test {
 		$this->assertEquals( $expected_email_attrs, $result['innerBlocks'][1]['innerBlocks'][0]['email_attrs'] );
 	}

+	/**
+	 * Test an unresolvable text color slug is not copied down to children.
+	 *
+	 * Passing on the empty string the slug resolves to would put `color: ;` in the children's
+	 * styles. Leaving it unset lets set_defaults_from_theme() supply the theme's own text color.
+	 */
+	public function testItDoesNotCopyUnresolvableTextColorSlugs(): void {
+		$blocks = array(
+			array(
+				'blockName'   => 'core/columns',
+				'attrs'       => array(
+					'textColor' => 'theme-4',
+					'style'     => array(),
+				),
+				'innerBlocks' => array(
+					array(
+						'blockName'   => 'core/column',
+						'innerBlocks' => array(
+							array(
+								'blockName'   => 'core/paragraph',
+								'attrs'       => array(),
+								'innerBlocks' => array(),
+							),
+						),
+					),
+				),
+			),
+		);
+		// The theme's own text color, not the slug and not an empty string.
+		$expected_email_attrs = array(
+			'color'     => '#000000',
+			'font-size' => '13px',
+		);
+		$result               = $this->preprocessor->preprocess( $blocks, $this->layout, $this->styles );
+		$result               = $result[0];
+		$this->assertSame( '#000000', $result['email_attrs']['color'] );
+		$this->assertEquals( $expected_email_attrs, $result['email_attrs'] );
+		$this->assertEquals( $expected_email_attrs, $result['innerBlocks'][0]['email_attrs'] );
+		$this->assertEquals( $expected_email_attrs, $result['innerBlocks'][0]['innerBlocks'][0]['email_attrs'] );
+	}
+
 	/**
 	 * Test it does not copy columns width
 	 */
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php
index e94b8dcf6e4..d40d1faade8 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Rendering_Context_Test.php
@@ -114,7 +114,12 @@ class Rendering_Context_Test extends \Email_Editor_Unit_Test {

 		$this->assertSame( '#ff0000', $context->translate_slug_to_color( 'primary' ) );
 		$this->assertSame( '#00ff00', $context->translate_slug_to_color( 'secondary' ) );
-		$this->assertSame( 'unknown', $context->translate_slug_to_color( 'unknown' ) );
+		$this->assertSame( '', $context->translate_slug_to_color( 'unknown' ) );
+
+		// A value that is already a literal color is not a slug and passes through untranslated.
+		$this->assertSame( '#012345', $context->translate_slug_to_color( '#012345' ) );
+		$this->assertSame( 'rgb(1, 2, 3)', $context->translate_slug_to_color( 'rgb(1, 2, 3)' ) );
+		$this->assertSame( 'var(--wp--preset--color--base)', $context->translate_slug_to_color( 'var(--wp--preset--color--base)' ) );
 	}

 	/**
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 eb0695c8015..93dd853b353 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
@@ -122,6 +122,362 @@ class Styles_Helper_Test extends \Email_Editor_Unit_Test {
 		$this->assertSame( $expected, Styles_Helper::parse_styles_to_array( $input ) );
 	}

+	/**
+	 * Test it tells a literal color from a palette slug.
+	 */
+	public function testItIdentifiesColorLiterals(): void {
+		$this->assertTrue( Styles_Helper::is_color_literal( '#abcdef' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( '#abc' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( '#abcd' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( '#aabbccdd' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( '  #abcdef  ' ) );
+
+		// Any functional notation, including CSS Color 4/5 forms and nesting.
+		$this->assertTrue( Styles_Helper::is_color_literal( 'rgb(1, 2, 3)' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( 'rgba(1, 2, 3, 0.5)' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( 'hsl(1, 2%, 3%)' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( 'oklch(0.7 0.1 200)' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( 'var(--wp--preset--color--base)' ) );
+		$this->assertTrue( Styles_Helper::is_color_literal( 'color-mix(in srgb, red, blue)' ) );
+
+		// Incomplete values are not colors, and emitting one is the invalid declaration this check
+		// exists to prevent.
+		$this->assertFalse( Styles_Helper::is_color_literal( '#' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( '#zz' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( '#abcd1' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'rgb(' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'oklch(' ) );
+
+		// A bare word is a slug, including one that names a CSS color or keyword. Classic themes
+		// register such slugs in PHP, and the email palette does not carry their colors.
+		$this->assertFalse( Styles_Helper::is_color_literal( 'blue' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'gray' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'rebeccapurple' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'transparent' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'currentColor' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'buttontext' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'theme-4' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'primary' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( 'dark-gray' ) );
+		$this->assertFalse( Styles_Helper::is_color_literal( '' ) );
+	}
+
+	/**
+	 * Test it resolves a slug against every palette origin, highest priority first.
+	 */
+	public function testItResolvesColorsFromEveryPaletteOrigin(): void {
+		$settings = array(
+			'color' => array(
+				'palette' => array(
+					'default' => array(
+						array(
+							'slug'  => 'core-slug',
+							'color' => '#111111',
+						),
+						array(
+							'slug'  => 'shared',
+							'color' => '#aaaaaa',
+						),
+					),
+					'theme'   => array(
+						array(
+							'slug'  => 'theme-slug',
+							'color' => '#222222',
+						),
+						array(
+							'slug'  => 'shared',
+							'color' => '#bbbbbb',
+						),
+					),
+					'blocks'  => array(
+						array(
+							'slug'  => 'block-slug',
+							'color' => '#333333',
+						),
+					),
+					'custom'  => array(
+						array(
+							'slug'  => 'user-slug',
+							'color' => '#DDEEFF',
+						),
+						array(
+							'slug'  => 'shared',
+							'color' => '#cccccc',
+						),
+					),
+				),
+			),
+		);
+
+		// A color the user defined in the email's own global styles resolves, not just the theme's.
+		$this->assertSame( '#ddeeff', Styles_Helper::resolve_color_from_palette( $settings, 'user-slug' ) );
+		$this->assertSame( '#222222', Styles_Helper::resolve_color_from_palette( $settings, 'theme-slug' ) );
+		$this->assertSame( '#333333', Styles_Helper::resolve_color_from_palette( $settings, 'block-slug' ) );
+		$this->assertSame( '#111111', Styles_Helper::resolve_color_from_palette( $settings, 'core-slug' ) );
+
+		// On a slug several origins define, the highest priority origin wins.
+		$this->assertSame( '#cccccc', Styles_Helper::resolve_color_from_palette( $settings, 'shared' ) );
+
+		// A slug no origin defines yields nothing, so the caller skips the declaration.
+		$this->assertSame( '', Styles_Helper::resolve_color_from_palette( $settings, 'theme-4' ) );
+
+		// A literal still passes through, and an absent palette is not a fatal.
+		$this->assertSame( '#012345', Styles_Helper::resolve_color_from_palette( $settings, '#012345' ) );
+		$this->assertSame( '', Styles_Helper::resolve_color_from_palette( array(), 'theme-4' ) );
+		$this->assertSame( '#012345', Styles_Helper::resolve_color_from_palette( array(), '#012345' ) );
+
+		// A bare word is a slug even when it names a CSS color, so it resolves to nothing.
+		$this->assertSame( '', Styles_Helper::resolve_color_from_palette( $settings, 'blue' ) );
+	}
+
+	/**
+	 * Color slug values observed on real emails, and what each resolves to.
+	 *
+	 * Drawn from a read-only sample of sent newsletters whose blocks name a color slug. Append new
+	 * values found in the wild rather than reasoning about whether the matcher covers their shape.
+	 *
+	 * `null` means the value resolves to nothing, so the caller omits the declaration and the block
+	 * keeps the surrounding theme color.
+	 *
+	 * @return array<string, array{string, string|null}>
+	 */
+	public function observedColorSlugProvider(): array {
+		return array(
+			// Palette slugs the email cannot resolve: left behind by a theme switch, or registered
+			// by a classic theme in PHP, which the email palette does not carry.
+			'theme palette slug'          => array( 'theme-4', null ),
+			'semantic slug'               => array( 'primary', null ),
+			'semantic slug, foreground'   => array( 'foreground', null ),
+			'hyphenated gray slug'        => array( 'dark-gray', null ),
+			'hyphenated gray slug, light' => array( 'light-gray', null ),
+
+			// Classic themes' palette slugs that happen to name a CSS color. They are slugs like any
+			// other, and the CSS color is not the color the theme defines.
+			'named color, gray'           => array( 'gray', null ),
+			'named color, blue'           => array( 'blue', null ),
+			'named color, green'          => array( 'green', null ),
+			'named color, orange'         => array( 'orange', null ),
+
+			// Literal colors, which reach these attributes too and pass through untouched.
+			'hex'                         => array( '#abcdef', '#abcdef' ),
+			'rgb'                         => array( 'rgb(1, 2, 3)', 'rgb(1, 2, 3)' ),
+			'preset variable'             => array( 'var(--wp--preset--color--base)', 'var(--wp--preset--color--base)' ),
+		);
+	}
+
+	/**
+	 * Test every observed slug value resolves the way it should against a palette that defines none of them.
+	 *
+	 * @dataProvider observedColorSlugProvider
+	 *
+	 * @param string      $value    Value of a color block attribute.
+	 * @param string|null $expected Expected color, or null when nothing should be emitted.
+	 */
+	public function testItResolvesObservedColorSlugs( string $value, ?string $expected ): void {
+		// A palette that defines none of the values above.
+		$settings = array(
+			'color' => array(
+				'palette' => array(
+					'theme' => array(
+						array(
+							'slug'  => 'base',
+							'color' => '#ffffff',
+						),
+						array(
+							'slug'  => 'contrast',
+							'color' => '#000000',
+						),
+					),
+				),
+			),
+		);
+
+		$this->assertSame(
+			null === $expected ? '' : $expected,
+			Styles_Helper::resolve_color_from_palette( $settings, $value )
+		);
+	}
+
+	/**
+	 * Test a palette origin that is not an array does not take the render down.
+	 *
+	 * The palette arrives through the `woocommerce_email_editor_theme_json` filter, and a non-array
+	 * origin reaching array_merge() is a TypeError rather than a warning.
+	 */
+	public function testItSurvivesAPaletteOriginThatIsNotAnArray(): void {
+		$settings = array(
+			'color' => array(
+				'palette' => array(
+					'custom' => 'not-an-array',
+					'theme'  => array(
+						array(
+							'slug'  => 'usable',
+							'color' => '#cccccc',
+						),
+					),
+				),
+			),
+		);
+
+		$this->assertSame( '#cccccc', Styles_Helper::resolve_color_from_palette( $settings, 'usable' ) );
+		$this->assertSame( '', Styles_Helper::resolve_color_from_palette( $settings, 'theme-4' ) );
+	}
+
+	/**
+	 * Test a literal is returned trimmed but not case-folded.
+	 *
+	 * A palette color is lowercased; a literal cannot be, because the custom property name inside
+	 * `var()` is case-sensitive.
+	 */
+	public function testItTrimsButDoesNotCaseFoldALiteral(): void {
+		$this->assertSame( '#ABCDEF', Styles_Helper::resolve_color_from_palette( array(), '  #ABCDEF  ' ) );
+		$this->assertSame(
+			'var(--wp--preset--color--myBrand)',
+			Styles_Helper::resolve_color_from_palette( array(), ' var(--wp--preset--color--myBrand) ' )
+		);
+	}
+
+	/**
+	 * Test a slug several origins define yields exactly one definition, the highest priority one.
+	 *
+	 * A lookup reads the first entry and the stylesheet's cascade reads the last, so one entry per
+	 * slug is what stops a block's color depending on which route it arrives through.
+	 */
+	public function testItReturnsOneDefinitionPerSlug(): void {
+		$settings = array(
+			'color' => array(
+				'palette' => array(
+					'default' => array(
+						array(
+							'slug'  => 'shared',
+							'color' => '#aaaaaa',
+						),
+						array(
+							'slug'  => 'core-only',
+							'color' => '#111111',
+						),
+					),
+					'theme'   => array(
+						array(
+							'slug'  => 'shared',
+							'color' => '#bbbbbb',
+						),
+					),
+					'custom'  => array(
+						array(
+							'slug'  => 'shared',
+							'color' => '#cccccc',
+						),
+					),
+				),
+			),
+		);
+
+		$definitions = Styles_Helper::palette_definitions( $settings );
+		$slugs       = array_column( $definitions, 'slug' );
+
+		$this->assertSame( array( 'shared', 'core-only' ), $slugs );
+		$this->assertCount( 1, array_keys( $slugs, 'shared', true ) );
+
+		// The one definition kept is the one the lookup resolves to, so both agree.
+		$this->assertSame( '#cccccc', $definitions[0]['color'] );
+		$this->assertSame( '#cccccc', Styles_Helper::resolve_color_from_palette( $settings, 'shared' ) );
+	}
+
+	/**
+	 * Test a palette entry without a usable slug and color is dropped rather than reaching a caller.
+	 *
+	 * The palette arrives through a filter, and callers read `slug` and `color` directly.
+	 */
+	public function testItDropsUnusablePaletteEntries(): void {
+		$settings = array(
+			'color' => array(
+				'palette' => array(
+					'theme' => array(
+						array( 'color' => '#aaaaaa' ),
+						array(
+							'slug'  => 123,
+							'color' => '#bbbbbb',
+						),
+						array( 'slug' => 'no-color' ),
+						array(
+							'slug'  => 'empty-color',
+							'color' => '   ',
+						),
+						array(
+							'slug'  => 'array-color',
+							'color' => array( '#dddddd' ),
+						),
+						'not-an-array',
+						array(
+							'slug'  => 'usable',
+							'color' => '#cccccc',
+						),
+					),
+				),
+			),
+		);
+
+		$definitions = Styles_Helper::palette_definitions( $settings );
+
+		$this->assertSame( array( 'usable' ), array_column( $definitions, 'slug' ) );
+		$this->assertSame( '#cccccc', Styles_Helper::resolve_color_from_palette( $settings, 'usable' ) );
+		$this->assertSame( '', Styles_Helper::resolve_color_from_palette( $settings, 'no-color' ) );
+	}
+
+	/**
+	 * Test an incomplete higher-priority entry does not shut out an origin that defines the slug.
+	 *
+	 * An entry that only names the slug must not discard the `default` color, which the palette
+	 * does define.
+	 */
+	public function testItPrefersALowerPriorityOriginOverAColorlessDuplicate(): void {
+		$settings = array(
+			'color' => array(
+				'palette' => array(
+					'default' => array(
+						array(
+							'slug'  => 'shared',
+							'color' => '#aaaaaa',
+						),
+					),
+					'custom'  => array(
+						array( 'slug' => 'shared' ),
+					),
+				),
+			),
+		);
+
+		$definitions = Styles_Helper::palette_definitions( $settings );
+
+		$this->assertCount( 1, $definitions );
+		$this->assertSame( '#aaaaaa', $definitions[0]['color'] );
+		$this->assertSame( '#aaaaaa', Styles_Helper::resolve_color_from_palette( $settings, 'shared' ) );
+	}
+
+	/**
+	 * Test a slug that names a CSS color still resolves from the palette.
+	 *
+	 * Classic themes register slugs like `blue`, so the lookup has to reach the palette rather than
+	 * read the word as a color.
+	 */
+	public function testItResolvesASlugThatNamesACssColorFromThePalette(): void {
+		$settings = array(
+			'color' => array(
+				'palette' => array(
+					'theme' => array(
+						array(
+							'slug'  => 'blue',
+							'color' => '#0000AA',
+						),
+					),
+				),
+			),
+		);
+
+		$this->assertSame( '#0000aa', Styles_Helper::resolve_color_from_palette( $settings, 'blue' ) );
+	}
+
 	/**
 	 * Test it gets normalized block styles with color translations.
 	 */
@@ -170,6 +526,54 @@ class Styles_Helper_Test extends \Email_Editor_Unit_Test {
 		$this->assertSame( $expected, $result );
 	}

+	/**
+	 * Test that a color slug the palette cannot resolve produces no declaration.
+	 *
+	 * The slug as a value (`background-color: theme-4`) is invalid CSS that mail clients drop, which
+	 * costs the block its color. Omitting the declaration lets the surrounding theme color show
+	 * through, which is what the editor previews.
+	 */
+	public function testItOmitsUnresolvedColorSlugsFromNormalizedBlockStyles(): void {
+		$block_attributes = array(
+			'backgroundColor' => 'theme-4',
+			'textColor'       => 'primary',
+			'borderColor'     => 'theme-9',
+			'style'           => array(
+				'spacing' => array(
+					'padding' => '10px',
+				),
+			),
+		);
+
+		/**
+		 * Rendering_Context mock for using in test.
+		 *
+		 * @var Rendering_Context&\PHPUnit\Framework\MockObject\MockObject $rendering_context
+		 */
+		$rendering_context = $this->createMock( Rendering_Context::class );
+		$rendering_context->method( 'translate_slug_to_color' )
+			->willReturnMap(
+				array(
+					array( 'primary', '#00ff00' ),
+					array( 'theme-4', '' ),
+					array( 'theme-9', '' ),
+				)
+			);
+
+		$result = Styles_Helper::get_normalized_block_styles( $block_attributes, $rendering_context );
+
+		$expected = array(
+			'color'   => array(
+				'text' => '#00ff00',
+			),
+			'spacing' => array(
+				'padding' => '10px',
+			),
+		);
+
+		$this->assertSame( $expected, $result );
+	}
+
 	/**
 	 * Test it gets normalized block styles without color attributes.
 	 */