From c1e99809742b553bc5a448d95cbf5e2c6990e24e Mon Sep 17 00:00:00 2001 From: Alexia-Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:55:24 +0300 Subject: [PATCH 01/10] fix: Design Library inserts locking as patterns instead of editable blocks Two failure modes when inserted pattern content carries wp:pattern references. Registered references: WordPress 7.0 inlines them server-side but stamps the blocks with metadata.patternName, which its editor locks into content-only mode ("A block pattern" / "Edit pattern", no style controls). Missing references: the raw core/pattern block reaches the canvas and renders as an invisible, uneditable placeholder. Expand references at insert time with the existing resolvePatternBlocks helper (also covers pre-7.0 WordPress, which serves references raw), and strip metadata.patternName from inserted blocks so unsynced Design Library inserts stay fully editable. Other metadata (bindings, custom labels) is preserved. Closes #2854 Co-Authored-By: Claude Fable 5 --- .../mu-plugins/otter-e2e-pattern-fixtures.php | 33 ++++++++++++++++ .../plugins/patterns-library/library.js | 33 ++++++++++++++-- .../test/e2e/blocks/design-library.spec.js | 38 +++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php b/packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php new file mode 100644 index 000000000..de41c5a34 --- /dev/null +++ b/packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php @@ -0,0 +1,33 @@ + 'E2E Referenced Inner Pattern', + 'content' => '

E2E inner pattern content

', + ) + ); + + // One registered reference, one unresolvable reference — a single insert + // exercises both paths of the Design Library's pattern expansion. + register_block_pattern( + 'otter-e2e/pattern-reference-fixture', + array( + 'title' => 'E2E Pattern Reference Fixture', + 'categories' => array( 'otter-blocks' ), + 'content' => '

E2E reference fixture heading

', + ) + ); + }, + 100 +); diff --git a/src/blocks/plugins/patterns-library/library.js b/src/blocks/plugins/patterns-library/library.js index 8fa8859d1..021a1fbd5 100644 --- a/src/blocks/plugins/patterns-library/library.js +++ b/src/blocks/plugins/patterns-library/library.js @@ -38,6 +38,28 @@ import { import { accentContent } from './accent'; +import { resolvePatternBlocks } from '../../../onboarding/utils.js'; + +// WordPress 7.0 inlines nested wp:pattern references server-side and stamps +// each inlined block with metadata.patternName, which its editor then locks +// into content-only mode ("A block pattern" / "Edit pattern"). Library inserts +// are unsynced copies, so shed the attribution; every other metadata key +// (bindings, custom labels) is kept. +const stripPatternAttribution = (blocks) => + blocks.map((block) => { + const metadata = { ...(block.attributes?.metadata || {}) }; + delete metadata.patternName; + + return { + ...block, + attributes: { + ...block.attributes, + metadata: Object.keys(metadata).length ? metadata : undefined, + }, + innerBlocks: stripPatternAttribution(block.innerBlocks || []), + }; + }); + const CLOUD_EMPTY_CATEGORY = 'cloud-empty'; // Section categories bucketed into meaningful sidebar groups. Mirrors the @@ -574,8 +596,13 @@ const Library = ({ onClose }) => { } // With Pro active the upsell banner removes itself right after - // insertion anyway — skip it up front. - const blocks = parse(accentContent(pattern, accent)).filter( + // insertion anyway — skip it up front. Inline wp:pattern references + // while parsing: inserted literally they render as locked "Edit + // pattern" wrappers (or nothing, for unregistered slugs) instead of + // editable blocks. + const blocks = stripPatternAttribution( + resolvePatternBlocks(parse(accentContent(pattern, accent)), allPatterns), + ).filter( (block) => UPSELL_BLOCK !== block.name || !Boolean(window.themeisleGutenberg?.hasPro), @@ -660,7 +687,7 @@ const Library = ({ onClose }) => { onClose(); }, - [ clientID, accent ], + [ clientID, accent, allPatterns ], ); const resetFilters = () => { diff --git a/src/blocks/test/e2e/blocks/design-library.spec.js b/src/blocks/test/e2e/blocks/design-library.spec.js index 785c1ef30..c4599bfe0 100644 --- a/src/blocks/test/e2e/blocks/design-library.spec.js +++ b/src/blocks/test/e2e/blocks/design-library.spec.js @@ -244,6 +244,44 @@ test.describe( 'Design Library', () => { page.locator( '.components-snackbar', { hasText: title }) ).toBeVisible(); }); + + test( 'expands wp:pattern references into editable blocks on insert', async({ page, editor }) => { + + // The fixture pattern (registered by otter-e2e-pattern-fixtures.php) + // contains a heading, a reference to a registered pattern and a + // reference to a missing slug. Inserted content must arrive as plain + // editable blocks: registered references inlined, missing ones dropped — + // never a core/pattern block, which the editor renders as a locked + // "Edit pattern" wrapper (or nothing at all for missing slugs). + // See https://github.com/Codeinwp/otter-blocks/issues/2854 + const modal = await openLibrary( page ); + await waitForGrid( modal ); + + await modal.locator( '.o-library__search' ).fill( 'E2E Pattern Reference Fixture' ); + + const card = firstCard( modal ); + await card.hover(); + await card.getByRole( 'button', { name: 'Insert', exact: true }).click(); + + await expect( modal ).toHaveCount( 0 ); + await waitForEditorReady( page ); + + const blocks = await editor.getBlocks(); + const names = blocks.map( ( block ) => block.name ); + + expect( names ).toContain( 'core/heading' ); + expect( names ).toContain( 'core/paragraph' ); + expect( names ).not.toContain( 'core/pattern' ); + + // WordPress 7.0 inlines registered references server-side and stamps + // the inlined blocks with metadata.patternName, which its editor then + // locks into content-only mode ("A block pattern" / "Edit pattern"). + // Unsynced inserts must shed the attribution to stay editable. + const stamped = blocks + .filter( ( block ) => block.attributes?.metadata?.patternName ) + .map( ( block ) => block.name ); + expect( stamped ).toEqual( [] ); + }); }); test.describe( 'Pro upsells', () => { From f6cf6e196ee18f68a0a9c828a77eea0b93a15d88 Mon Sep 17 00:00:00 2001 From: Alexia-Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:46:43 +0300 Subject: [PATCH 02/10] fix: popup inside close button unclickable under positioned content The Inside close button header had no z-index, so positioned popup content (e.g. Section columns, position:relative on desktop) painted over it and swallowed clicks. The z-index on the button itself was inert since the button is position:static. Fixes #2863 Co-Authored-By: Claude Fable 5 --- src/blocks/blocks/popup/style.scss | 4 +- src/blocks/test/e2e/blocks/popup.spec.js | 56 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/blocks/blocks/popup/style.scss b/src/blocks/blocks/popup/style.scss index 7db46eaaf..5e718a98a 100644 --- a/src/blocks/blocks/popup/style.scss +++ b/src/blocks/blocks/popup/style.scss @@ -122,6 +122,9 @@ $base-index: 99999 !default; position: absolute; right: var( --padding ); + // Keep the close button above positioned popup content, e.g. Section columns. + z-index: 2; + @media (max-width: 600px) { padding-bottom: 0px; } @@ -135,7 +138,6 @@ $base-index: 99999 !default; padding: 0 !important; border: none; cursor: pointer; - z-index: 2; &:hover { opacity: .75; diff --git a/src/blocks/test/e2e/blocks/popup.spec.js b/src/blocks/test/e2e/blocks/popup.spec.js index f080d160e..5c0e339fb 100644 --- a/src/blocks/test/e2e/blocks/popup.spec.js +++ b/src/blocks/test/e2e/blocks/popup.spec.js @@ -115,4 +115,60 @@ test.describe( 'Popup', () => { await page.keyboard.press( 'Enter' ); await expect( page.getByText( 'Popup Content Test' ) ).toBeHidden(); }); + + test( 'inside close button receives clicks when content has positioned columns', async({ editor, page }) => { + + // Section columns are position:relative on desktop; without a z-index on + // the header they paint over the close button and swallow its clicks. + // See https://github.com/Codeinwp/otter-blocks/issues/2863 + await page.setViewportSize({ width: 1280, height: 800 }); + + await editor.insertBlock({ + name: 'themeisle-blocks/popup', + attributes: {}, + innerBlocks: [ + { + name: 'themeisle-blocks/advanced-columns', + attributes: { + columns: 2, + layout: 'equal' + }, + innerBlocks: [ + { + name: 'themeisle-blocks/advanced-column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: 'Popup Content Test' + } + } + ] + }, + { + name: 'themeisle-blocks/advanced-column', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { + content: 'Second Column' + } + } + ] + } + ] + } + ] + }); + + await publishAndViewPost({ editor, page }); + + await expect( page.getByText( 'Popup Content Test' ) ).toBeVisible(); + + // Playwright refuses to click covered elements, so this fails if the + // columns overlay the button. + await page.getByRole( 'button', { name: 'Close' }).click(); + + await expect( page.getByText( 'Popup Content Test' ) ).toBeHidden(); + }); }); From 7d8cc94a8930cfce976078f15f569c653e8b8e6a Mon Sep 17 00:00:00 2001 From: Alexia-Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:04:27 +0300 Subject: [PATCH 03/10] chore: tighten fix comments Co-Authored-By: Claude Fable 5 --- .../mu-plugins/otter-e2e-pattern-fixtures.php | 8 +++----- src/blocks/plugins/patterns-library/library.js | 15 ++++++--------- .../test/e2e/blocks/design-library.spec.js | 17 ++++++----------- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php b/packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php index de41c5a34..a1f646b43 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-pattern-fixtures.php @@ -4,9 +4,8 @@ * Description: Registers Design Library test patterns containing wp:pattern references (issue #2854). */ -// Late priority: mu-plugins load before Otter, and the library's "Featured" -// sort is registration order — registering late keeps the fixtures out of the -// grid's first cards, which other tests insert blindly. +// Register after Otter's patterns: "Featured" sort is registration order and +// other tests insert the grid's first card blindly. add_action( 'init', function () { @@ -18,8 +17,7 @@ function () { ) ); - // One registered reference, one unresolvable reference — a single insert - // exercises both paths of the Design Library's pattern expansion. + // One registered and one unresolvable reference — one insert covers both paths. register_block_pattern( 'otter-e2e/pattern-reference-fixture', array( diff --git a/src/blocks/plugins/patterns-library/library.js b/src/blocks/plugins/patterns-library/library.js index 021a1fbd5..342dda7c4 100644 --- a/src/blocks/plugins/patterns-library/library.js +++ b/src/blocks/plugins/patterns-library/library.js @@ -40,11 +40,9 @@ import { accentContent } from './accent'; import { resolvePatternBlocks } from '../../../onboarding/utils.js'; -// WordPress 7.0 inlines nested wp:pattern references server-side and stamps -// each inlined block with metadata.patternName, which its editor then locks -// into content-only mode ("A block pattern" / "Edit pattern"). Library inserts -// are unsynced copies, so shed the attribution; every other metadata key -// (bindings, custom labels) is kept. +// WP 7.0 stamps blocks inlined from wp:pattern refs with metadata.patternName +// and locks them into content-only mode. Inserts are unsynced copies — drop +// the stamp, keep the rest of metadata (bindings, labels). const stripPatternAttribution = (blocks) => blocks.map((block) => { const metadata = { ...(block.attributes?.metadata || {}) }; @@ -596,10 +594,9 @@ const Library = ({ onClose }) => { } // With Pro active the upsell banner removes itself right after - // insertion anyway — skip it up front. Inline wp:pattern references - // while parsing: inserted literally they render as locked "Edit - // pattern" wrappers (or nothing, for unregistered slugs) instead of - // editable blocks. + // insertion anyway — skip it up front. Inline wp:pattern refs while + // parsing: inserted raw they render locked, or not at all for + // unregistered slugs. const blocks = stripPatternAttribution( resolvePatternBlocks(parse(accentContent(pattern, accent)), allPatterns), ).filter( diff --git a/src/blocks/test/e2e/blocks/design-library.spec.js b/src/blocks/test/e2e/blocks/design-library.spec.js index c4599bfe0..d1430bec9 100644 --- a/src/blocks/test/e2e/blocks/design-library.spec.js +++ b/src/blocks/test/e2e/blocks/design-library.spec.js @@ -247,13 +247,10 @@ test.describe( 'Design Library', () => { test( 'expands wp:pattern references into editable blocks on insert', async({ page, editor }) => { - // The fixture pattern (registered by otter-e2e-pattern-fixtures.php) - // contains a heading, a reference to a registered pattern and a - // reference to a missing slug. Inserted content must arrive as plain - // editable blocks: registered references inlined, missing ones dropped — - // never a core/pattern block, which the editor renders as a locked - // "Edit pattern" wrapper (or nothing at all for missing slugs). - // See https://github.com/Codeinwp/otter-blocks/issues/2854 + // Fixture (otter-e2e-pattern-fixtures.php): a heading plus one + // registered and one missing wp:pattern reference. Inserts must + // arrive as plain editable blocks — refs inlined, missing ones + // dropped, never a locked core/pattern wrapper. See #2854. const modal = await openLibrary( page ); await waitForGrid( modal ); @@ -273,10 +270,8 @@ test.describe( 'Design Library', () => { expect( names ).toContain( 'core/paragraph' ); expect( names ).not.toContain( 'core/pattern' ); - // WordPress 7.0 inlines registered references server-side and stamps - // the inlined blocks with metadata.patternName, which its editor then - // locks into content-only mode ("A block pattern" / "Edit pattern"). - // Unsynced inserts must shed the attribution to stay editable. + // WP 7.0 stamps inlined blocks with metadata.patternName and locks + // them into content-only mode — inserts must shed it. const stamped = blocks .filter( ( block ) => block.attributes?.metadata?.patternName ) .map( ( block ) => block.name ); From f3ad496a22c7e9b7b613abdc8cf1958988c4c748 Mon Sep 17 00:00:00 2001 From: Alexia-Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:43:30 +0300 Subject: [PATCH 04/10] fix: nested animated blocks staying hidden inside transform-animated parents On load, elements were measured for viewport visibility one at a time, interleaved with processing that unblocks each element's animation. Unblocking a parent's animation applies its first keyframe transform immediately (animation-fill-mode: both), so nested children measured afterwards appeared displaced by the parent's height and were wrongly parked for scroll-triggered replay, staying invisible until the first scroll. Measure all elements before processing any of them. Fixes #2883 Co-Authored-By: Claude Fable 5 --- src/animation/frontend.js | 12 +++++-- src/blocks/test/e2e/blocks/animations.spec.js | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/animation/frontend.js b/src/animation/frontend.js index 832cd46f1..55bc47b8e 100644 --- a/src/animation/frontend.js +++ b/src/animation/frontend.js @@ -160,7 +160,7 @@ const speed = [ 'none', 'slow', 'slower', 'fast', 'faster' ]; const elementsScroll = []; let scrollListenerAttached = false; -const processElement = ( element ) => { +const processElement = ( element, visible = isElementInViewport( element ) ) => { // Skip if already processed if ( element.classList.contains( 'o-anim-ready' ) ) { return; @@ -169,7 +169,7 @@ const processElement = ( element ) => { const classes = element.classList; element.animationClasses = []; - if ( ! isElementInViewport( element ) ) { + if ( ! visible ) { const animationClass = animations.find( ( i ) => { return Array.from( classes ).find( ( o ) => o === i ); }); @@ -283,8 +283,14 @@ const animateElements = () => { createCustomAnimationNode( elements ); + // Measure every element before processing marks any of them o-anim-ready: + const inViewport = new Map(); for ( const element of elements ) { - processElement( element ); + inViewport.set( element, isElementInViewport( element ) ); + } + + for ( const element of elements ) { + processElement( element, inViewport.get( element ) ); } attachScrollListener(); diff --git a/src/blocks/test/e2e/blocks/animations.spec.js b/src/blocks/test/e2e/blocks/animations.spec.js index 10ed9e4a4..7d8fcc627 100644 --- a/src/blocks/test/e2e/blocks/animations.spec.js +++ b/src/blocks/test/e2e/blocks/animations.spec.js @@ -3,11 +3,46 @@ */ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +/** + * Internal dependencies + */ +import { publishAndViewPost } from '../helpers/editor'; + test.describe( 'Animations', () => { test.beforeEach( async({ admin }) => { await admin.createNewPost(); }); + test( 'nested animated block plays on load inside a transform-animated parent', async({ editor, page }) => { + + await page.setViewportSize({ width: 1280, height: 900 }); + + await editor.insertBlock({ + name: 'core/cover', + attributes: { + overlayColor: 'black', + dimRatio: 100, + minHeight: 800, + contentPosition: 'top center', + className: 'animated slideInDown' + }, + innerBlocks: [ + { + name: 'core/heading', + attributes: { + content: 'Nested Animated Heading', + className: 'animated fadeInLeft delay-1s' + } + } + ] + }); + + await publishAndViewPost({ editor, page }); + + // Without any scrolling, the nested heading must animate in after its delay. + await expect( page.getByText( 'Nested Animated Heading' ) ).toBeVisible({ timeout: 10000 }); + }); + test( 'can add a typing animation"', async({ editor, page }) => { await editor.insertBlock({ name: 'core/paragraph', From 6ea09d60ab478cd9ccea15695b1229a7965aa4a1 Mon Sep 17 00:00:00 2001 From: abaicus Date: Wed, 15 Jul 2026 13:13:37 +0300 Subject: [PATCH 05/10] feat: load atomic-wind blocks styles wherever posts containing them are queried --- inc/plugins/class-atomic-wind-blocks.php | 252 +++++++++++++++++++---- tests/test-atomic-wind-blocks.php | 196 ++++++++++++++++++ 2 files changed, 405 insertions(+), 43 deletions(-) diff --git a/inc/plugins/class-atomic-wind-blocks.php b/inc/plugins/class-atomic-wind-blocks.php index 9f99f4746..ef47aa5e5 100644 --- a/inc/plugins/class-atomic-wind-blocks.php +++ b/inc/plugins/class-atomic-wind-blocks.php @@ -22,6 +22,27 @@ class Atomic_Wind_Blocks { */ private static $in_query = false; + /** + * Expected Atomic Wind blocks by post ID. + * + * @var array + */ + private $expected = array(); + + /** + * Rendered Atomic Wind blocks by post ID. ID 0 is unknown. + * + * @var array + */ + private $rendered = array(); + + /** + * Hashes of inlined CSS. + * + * @var array + */ + private $inlined = array(); + /** * Initialize the module. * @@ -55,6 +76,8 @@ public function run() { add_filter( 'render_block', array( $this, 'render_post_fields' ), 20, 2 ); add_filter( 'render_block', array( $this, 'render_animation_attrs' ), 10, 2 ); add_filter( 'render_block', array( $this, 'render_state_attrs' ), 10, 2 ); + add_filter( 'render_block', array( $this, 'track_rendered_blocks' ), 10, 2 ); + add_action( 'wp_footer', array( $this, 'output_late_css' ), 19 ); add_action( 'template_redirect', array( $this, 'maybe_render_css_warm_page' ), 0 ); add_action( 'save_post', array( $this, 'clear_cached_css' ) ); add_filter( 'block_categories_all', array( $this, 'register_category' ) ); @@ -161,18 +184,25 @@ public function enqueue_tailwind_generator() { * @return void */ public function enqueue_base_css() { + $css = '[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}[class*="wp-block-atomic-wind-"] p{margin:0;}'; + + // Always reset frontend blocks, including those outside the main query. + if ( ! is_admin() ) { + wp_register_style( 'atomic-wind-base', false, [], OTTER_BLOCKS_VERSION ); + wp_enqueue_style( 'atomic-wind-base' ); + wp_add_inline_style( 'atomic-wind-base', $css ); + return; + } + global $post; - + if ( ! $post || ! $this->post_has_atomic_wind_blocks( $post ) ) { return; } - + wp_register_style( 'atomic-wind-base', false, [], OTTER_BLOCKS_VERSION ); wp_enqueue_style( 'atomic-wind-base' ); - $css = '[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}[class*="wp-block-atomic-wind-"] p{margin:0;}'; - if ( is_admin() ) { - $css .= '.editor-styles-wrapper .wp-block[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}.editor-styles-wrapper [class*="wp-block-atomic-wind-"] p{margin:0;}'; - } + $css .= '.editor-styles-wrapper .wp-block[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}.editor-styles-wrapper [class*="wp-block-atomic-wind-"] p{margin:0;}'; wp_add_inline_style( 'atomic-wind-base', $css ); } @@ -252,69 +282,205 @@ public function enqueue_editor_assets() { } /** - * Output cached Tailwind CSS or enqueue the generator + style-builder. + * Enqueue the frontend Tailwind fallback. * * @return void */ - public function output_cached_css() { - global $post; + private function enqueue_generator() { + $generator_asset = $this->build_path() . '/tailwind-generator-frontend.asset.php'; - if ( ! $post ) { + if ( ! file_exists( $generator_asset ) ) { return; } - if ( ! $this->post_has_atomic_wind_blocks( $post ) ) { - return; + $gen = include $generator_asset; + wp_enqueue_script( + 'atomic-wind-tailwind-generator', + $this->plugin_url( 'build/atomic-wind/tailwind-generator-frontend.js' ), + $gen['dependencies'], + $gen['version'], + true + ); + } + + /** + * Load cached CSS for main-query posts. + * + * Missing CSS uses the generator. Footer rendering is handled separately. + * + * @return void + */ + public function output_cached_css() { + $candidates = array(); + + if ( isset( $GLOBALS['wp_query'] ) && ! empty( $GLOBALS['wp_query']->posts ) ) { + foreach ( $GLOBALS['wp_query']->posts as $candidate ) { + if ( $candidate instanceof \WP_Post ) { + $candidates[ $candidate->ID ] = $candidate; + } + } } - $cached_css = get_post_meta( $post->ID, '_atomic_wind_css', true ); + $blobs = array(); + $needs_generator = false; - if ( $cached_css ) { + foreach ( $candidates as $candidate ) { + if ( ! $this->post_has_atomic_wind_blocks( $candidate ) ) { + continue; + } + + $this->expected[ $candidate->ID ] = substr_count( $candidate->post_content, '