From 6fa27613918b779d86b013cfe24e69090ffc96df Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 12:19:34 -0500 Subject: [PATCH 1/2] Settle device-scoped viewport entrances outside their media query --- .../HtmlToBlocks/Generators/ViewportEntranceRuntime.php | 4 +++- .../tests/contract/viewport-entrance-runtime-browser.mjs | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php b/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php index a7225e070..9ddfaaf3c 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php +++ b/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php @@ -55,7 +55,9 @@ function replayViewportEntrances() { element.setAttribute(name, value); } }; - if (reduced.matches || !window.IntersectionObserver) { + // A device-scoped entrance (the source animates it only on some devices) + // shows the settled pose wherever its media query does not match. + if (reduced.matches || !window.IntersectionObserver || (typeof state.media === 'string' && !matchMedia(state.media).matches)) { apply('after'); continue; } diff --git a/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs b/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs index 7d67584cd..c6d78aa82 100644 --- a/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs +++ b/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs @@ -4,13 +4,14 @@ import { chromium } from '../../tools/visual-parity/node_modules/playwright/inde const root = new URL('../..', import.meta.url).pathname; const runtime = execFileSync('php', ['-r', `require '${root}/vendor/autoload.php'; echo Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\Generators\\ViewportEntranceRuntime::script();`], { encoding: 'utf8' }); -const state = repeat => JSON.stringify({ attributes: { class: { before: 'reveal', after: 'reveal in' }, style: { before: 'translate: 0 8px', after: null }, 'data-pose': { before: null, after: 'done' } }, threshold: [0.12], rootMargin: '0px 0px 0px 0px', repeat }); -const html = `
${[false, true].map((repeat, index) => `
Card
`).join('')}
`; +const state = (repeat, media) => JSON.stringify({ ...(media ? { media } : {}), attributes: { class: { before: 'reveal', after: 'reveal in' }, style: { before: 'translate: 0 8px', after: null }, 'data-pose': { before: null, after: 'done' } }, threshold: [0.12], rootMargin: '0px 0px 0px 0px', repeat }); +const html = (media) => `
${[false, true].map((repeat, index) => `
Card
`).join('')}
`; const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); try { - for (const mode of ['normal', 'reduced', 'no-observer', 'no-js']) { + for (const mode of ['normal', 'reduced', 'no-observer', 'no-js', 'outside-media']) { const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: mode === 'reduced' ? 'reduce' : 'no-preference', javaScriptEnabled: mode !== 'no-js' }); - await page.setContent(html); + // The page is 1440px wide; a narrow-only entrance does not animate here. + await page.setContent(html(mode === 'outside-media' ? '(max-width: 750px)' : mode === 'normal' ? '(min-width: 751px)' : undefined)); if (mode === 'no-observer') await page.evaluate(() => { delete window.IntersectionObserver; }); if (mode !== 'no-js') await page.addScriptTag({ content: runtime }); const opacity = () => page.locator('#card0').evaluate(node => Number(getComputedStyle(node).opacity)); From 16f6c1033b85442c1d3236811917f0c3811f7e88 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 12:23:04 -0500 Subject: [PATCH 2/2] Scope viewport entrances to device keys resolved through core viewport queries --- .../Generators/ViewportEntranceRuntime.php | 19 ++++++++++++++----- .../WordPressSitePlan/WordPressSitePlan.php | 6 ++++-- .../viewport-entrance-runtime-browser.mjs | 12 ++++++------ 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php b/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php index 9ddfaaf3c..ecf1bcbbf 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php +++ b/php-transformer/src/HtmlToBlocks/Generators/ViewportEntranceRuntime.php @@ -13,12 +13,21 @@ final class ViewportEntranceRuntime * revision 7f4b5cc73eef0db4d61292eaa07ecd03c2d0e00b). * Keep attribute poses, CSS-animation bindings and startup contexts together: * the evidence is a replay contract, not an animation-provider heuristic. + * + * Evidence may carry `devices`: the device documents whose source plays the + * entrance. `$viewportQueries` is a JSON object of media queries keyed by + * device (the site's core viewport queries, the same ones block visibility + * uses); a device without a query matches the selected device document. */ - public static function script(): string + public static function script(string $viewportQueries = '{}'): string { - return str_replace(array('__ENTRANCE__', '__DEVICE_DOCUMENT__', '__ENTRANCE_LOSS__'), array(CaptureEvidence::VIEWPORT_ENTRANCE, CaptureEvidence::DEVICE_DOCUMENT, CaptureEvidence::VIEWPORT_ENTRANCE . '-loss'), <<<'JS' + return str_replace(array('__ENTRANCE__', '__DEVICE_DOCUMENT__', '__ENTRANCE_LOSS__', '__SELECTED_DOCUMENT__', '__VIEWPORT_QUERIES__'), array(CaptureEvidence::VIEWPORT_ENTRANCE, CaptureEvidence::DEVICE_DOCUMENT, CaptureEvidence::VIEWPORT_ENTRANCE . '-loss', CaptureEvidence::SELECTED_DOCUMENT, $viewportQueries), <<<'JS' function replayViewportEntrances() { const reduced = matchMedia('(prefers-reduced-motion: reduce)'); + const viewportQueries = __VIEWPORT_QUERIES__; + const selected = document.documentElement.getAttribute('__SELECTED_DOCUMENT__'); + const onDevice = (devices) => devices.some(device => typeof viewportQueries[device] === 'string' + ? matchMedia(viewportQueries[device]).matches : selected === null || selected === device); // Engines enumerate a keyframe's properties in different orders (Chromium // changed it between releases), so the binding compares values, not order. const signature = (value) => JSON.stringify(value, (_key, field) => field && typeof field === 'object' && !Array.isArray(field) @@ -55,9 +64,9 @@ function replayViewportEntrances() { element.setAttribute(name, value); } }; - // A device-scoped entrance (the source animates it only on some devices) - // shows the settled pose wherever its media query does not match. - if (reduced.matches || !window.IntersectionObserver || (typeof state.media === 'string' && !matchMedia(state.media).matches)) { + // An entrance the source plays only on some devices shows its settled + // pose on the others. + if (reduced.matches || !window.IntersectionObserver || (Array.isArray(state.devices) && !onDevice(state.devices))) { apply('after'); continue; } diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 48fd2810f..918fbc734 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -7144,8 +7144,10 @@ private static function bootstrap(array $assets, array $scripts = array(), array } foreach (array_merge($templates, $parts, $pages) as $document) { if (!str_contains((string) ($document['canonical_block_markup'] ?? $document['block_markup'] ?? ''), CaptureEvidence::VIEWPORT_ENTRANCE)) continue; - $entranceScript = \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\ViewportEntranceRuntime::script(); - $lines[] = "add_action( 'wp_enqueue_scripts', static function (): void { wp_register_script( 'blocks-engine-viewport-entrances', false, array(), null, true ); wp_enqueue_script( 'blocks-engine-viewport-entrances' ); wp_add_inline_script( 'blocks-engine-viewport-entrances', " . var_export($entranceScript, true) . " ); } );"; + // Device-scoped entrances resolve through the site's live core + // viewport queries, so they follow block visibility breakpoints. + $entranceScript = \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\ViewportEntranceRuntime::script('__BLOCKS_ENGINE_VIEWPORT_QUERIES__'); + $lines[] = "add_action( 'wp_enqueue_scripts', static function (): void { \$queries = array(); if ( method_exists( 'WP_Theme_JSON', 'get_viewport_media_queries' ) ) { foreach ( WP_Theme_JSON::get_viewport_media_queries( wp_get_global_settings( array( 'viewport' ) ), array( 'include_desktop' => true ) ) as \$state => \$query ) { \$queries[ ltrim( \$state, '@' ) ] = preg_replace( '/^@media\\s+/', '', \$query ); } } wp_register_script( 'blocks-engine-viewport-entrances', false, array(), null, true ); wp_enqueue_script( 'blocks-engine-viewport-entrances' ); wp_add_inline_script( 'blocks-engine-viewport-entrances', str_replace( '__BLOCKS_ENGINE_VIEWPORT_QUERIES__', (string) wp_json_encode( (object) \$queries ), " . var_export($entranceScript, true) . " ) ); } );"; break; } foreach (array_merge($templates, $parts, $pages) as $document) { diff --git a/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs b/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs index c6d78aa82..48c02b32c 100644 --- a/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs +++ b/php-transformer/tests/contract/viewport-entrance-runtime-browser.mjs @@ -3,15 +3,15 @@ import { execFileSync } from 'node:child_process'; import { chromium } from '../../tools/visual-parity/node_modules/playwright/index.mjs'; const root = new URL('../..', import.meta.url).pathname; -const runtime = execFileSync('php', ['-r', `require '${root}/vendor/autoload.php'; echo Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\Generators\\ViewportEntranceRuntime::script();`], { encoding: 'utf8' }); -const state = (repeat, media) => JSON.stringify({ ...(media ? { media } : {}), attributes: { class: { before: 'reveal', after: 'reveal in' }, style: { before: 'translate: 0 8px', after: null }, 'data-pose': { before: null, after: 'done' } }, threshold: [0.12], rootMargin: '0px 0px 0px 0px', repeat }); -const html = (media) => `
${[false, true].map((repeat, index) => `
Card
`).join('')}
`; +const runtime = execFileSync('php', ['-r', `require '${root}/vendor/autoload.php'; echo Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\Generators\\ViewportEntranceRuntime::script('{"mobile":"(width <= 750px)","desktop":"(width > 750px)"}');`], { encoding: 'utf8' }); +const state = (repeat, devices) => JSON.stringify({ ...(devices ? { devices } : {}), attributes: { class: { before: 'reveal', after: 'reveal in' }, style: { before: 'translate: 0 8px', after: null }, 'data-pose': { before: null, after: 'done' } }, threshold: [0.12], rootMargin: '0px 0px 0px 0px', repeat }); +const html = (devices) => `
${[false, true].map((repeat, index) => `
Card
`).join('')}
`; const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); try { - for (const mode of ['normal', 'reduced', 'no-observer', 'no-js', 'outside-media']) { + for (const mode of ['normal', 'reduced', 'no-observer', 'no-js', 'other-device']) { const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: mode === 'reduced' ? 'reduce' : 'no-preference', javaScriptEnabled: mode !== 'no-js' }); - // The page is 1440px wide; a narrow-only entrance does not animate here. - await page.setContent(html(mode === 'outside-media' ? '(max-width: 750px)' : mode === 'normal' ? '(min-width: 751px)' : undefined)); + // At 1440px the desktop viewport query matches; a mobile-only entrance settles. + await page.setContent(html(mode === 'other-device' ? ['mobile'] : mode === 'normal' ? ['desktop'] : undefined)); if (mode === 'no-observer') await page.evaluate(() => { delete window.IntersectionObserver; }); if (mode !== 'no-js') await page.addScriptTag({ content: runtime }); const opacity = () => page.locator('#card0').evaluate(node => Number(getComputedStyle(node).opacity));